Iurii RoguliaIurii Rogulia
AboutAI EngineeringServicesProjectsAI SkillsStackTestimonialsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, senior full-stack software engineer. Professionally building software since 2001.

Think of a numberThink of a birthday
Hourly ratesChecklistsPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

  1. Home
  2. /
  3. Projects
  4. /
  5. tsstboxes.com – Fitment-First Online Store for ATV and UTV Cargo Boxes

Iurii Sells: tsstboxes.com – Fitment-First Online Store for ATV and UTV Cargo Boxes

November 10, 2026

Online store for ATV and UTV cargo boxes, each made for one model. Pick your machine, see only what fits, and pay in USD with shipping included.

Live Demo

Stack

Next.jsReactTypeScriptPythonNode.jsTailwind CSSDocker

Libraries

Better AuthDrizzle ORMnext-intlpinopolytypoVitestZod

Databases

PostgreSQL

Services

CoolifyResendSentryStripeTelegramUmami

Topics

Background JobsE-commercei18nPaymentsProgrammatic SEOSchema.orgWebhooks

Key Results

  • A buyer picks make and model and sees only the boxes that fit that machine
  • Nine makes, 44 machine variants and 39 products, with fitment stated per model, year range and rack
  • Shipping is in the price: the buyer pays one USD total at checkout
  • A paid order is confirmed by the server and posted to the team’s Telegram group once, however many times Stripe repeats the event
  • Owners who add their machine to the garage get one email a day when a new product fits it
tsstboxes.com – Fitment-First Online Store for ATV and UTV Cargo Boxes

The Problem

A universal cargo box sits on U-bolts and straps and works loose on a rough trail. The boxes in this store are the opposite: each one is shaped around the rack of a single model and locks into the holes the machine already has. That makes the product better and the shopping harder. Nobody wants a box that almost fits.

A generic shop template answers ‘what do you sell?’. This store has to answer ‘does it fit my machine?’ on every page, for buyers who know their machine by a marketing name (‘Outlander’) rather than by generation, year range and rack type.

Three more constraints shaped the build:

  1. The manufacturer is the source of truth. Product names and fitment come from the manufacturer’s site, so the store can’t carry its own, slowly diverging, copy of the facts.
  2. One market, one currency, nothing implicit. The store sells in the US, in USD, from an Estonian company. The manufacturer prices in euros.
  3. Money must survive repeated and out-of-order events. Payment webhooks arrive twice, late, or not at all, and a paid order must never be lost or counted twice.

The Solution

The store is one Next.js app with PostgreSQL behind it, built end to end: catalog pipeline, storefront, cart, checkout, accounts, email, background jobs and deployment.

The catalog is data in git, not rows in a database. A Python pipeline turns the manufacturer’s product pages into a versioned JSON seed, and the app loads that seed into memory at start. Deploying a new seed is the whole catalog update. The database only holds what changes while people use the store: users, carts, orders and the garage.

Fitment is the navigation. Machines are modelled as make, family, generation (a year range) and variant, and each product carries fitment rows against them. The home page opens with a make and model picker, every machine has its own page with the boxes, jerry cans and accessories that fit, and the URL tree (/vehicles, /vehicles/polaris, /vehicles/polaris/sportsman-xp-1000) mirrors that taxonomy. Tests keep those routes unique and make sure every page renders.

A machine page for the Polaris Sportsman XP 1000: model years, a tab bar for boxes and accessories, and the single rear cargo box that fits, with price and add to cart

The garage remembers the machine. A buyer can save machines by picking them or by entering a VIN, which is decoded through the NHTSA vPIC service and mapped onto the store’s own catalog. The VIN does not record the rack and model text is often a family name, so the buyer confirms the match. A daily job finds products added since the last run that fit a saved machine and queues one email per owner.

Prices follow the manufacturer. Retail prices are the manufacturer’s euro price converted at a fixed ECB rate and rounded up to a charm price ending in 4.99 or 9.99, so the store never charges less than the euro price it owes. The rate and its date live in code and change in a commit.

Checkout is built to be repeated safely. Payment runs on Stripe PaymentIntents with the Payment Element, so every payment method enabled in Stripe appears on the store’s own page. The order is confirmed by the server from Stripe’s signed webhook, never by the browser coming back from a redirect.

The store is visible to search and to shopping channels. Every product page carries structured data, a Google Merchant feed is generated from the same catalog, and an llms.txt describes the store for AI answer engines. Sentry and structured logs cover production, and the whole thing deploys as one container on Coolify.

Results

MetricValue
Catalog9 makes, 32 model families, 44 machine variants, 39 products, 46 variants
VIN matching216 model-name aliases map vPIC text onto catalog machines
Codebase~22k lines of TypeScript, 59 test files, 23 database migrations
MarketUS only, USD only, shipping included in the price
PaymentsStripe PaymentIntent, signed webhook, event de-duplication, hourly reconciliation
Background jobsEmail outbox, payment reconciliation, daily fitment alerts, each under its own lock
Order notificationOne Telegram card per paid order, sent after the payment is confirmed
DeploySingle container on Coolify, migrations applied on start

Under the Hood

For the technically curious, here is how the core pieces are built.

Prices That Never Round Below the Manufacturer’s

The manufacturer prices in euros. The store converts at one fixed rate and rounds up to a North American price ending, so a conversion can never leave the store charging less than it owes. Amounts are integer minor units throughout; there is no floating point in the money path.

// Under 200.00 the price ends in 4.99 or 9.99, from 200.00 in 9.99.
export function charmPriceMinor(amountMinor: number): number {
  const step = amountMinor < FINE_STEP_BELOW_MINOR ? 5_00 : 10_00;
  return Math.ceil((amountMinor + 1) / step) * step - 1;
}
 
export function retailPriceMinor(eurMinor: number, market: MarketCode): number {
  const rate = EUR_RATES.perEur[markets[market].currency];
  return charmPriceMinor(Math.ceil((eurMinor * rate) / RATE_SCALE));
}

A market is an entry in a code-level list, not a row in a table. Adding a country means adding its entry, its locales, prices and policies; anything not in the list does not exist.

A Garage That Asks Instead of Guessing

A VIN says make, model text and year, but not which rack the machine has, and model text is often only a family name. The matcher returns one of four honest states, and the garage asks the buyer to confirm anything short of a full match:

export type VinMatch =
  | { state: "full"; year: number; makeSlug: string; familyId: string; vehicleKey: string | null }
  | { state: "partial"; year: number | null; makeSlug: string; model: string }
  | { state: "not_listed"; make: string }
  | { state: "none" };

The VIN goes to vPIC in a POST body rather than the URL, so request logs and traces never contain it, and the full VIN is not stored. Fitment itself is a small pure function: a fitment row covers a machine when the year is inside its range and, if the row names a rack, the rack the buyer confirmed. An unknown rack never excludes a product.

A Webhook That Can Be Delivered Twice

Stripe can deliver the same event more than once and out of order. The handler verifies the signature over the raw body, claims the event id so a duplicate is acknowledged without doing the work again, and then re-reads the payment from Stripe’s API instead of trusting the payload. A 5xx is returned only when Stripe should try again:

if (!(await deps.claimEvent(event.id, event.type))) return { status: 200, body: "duplicate" };
await deps.handle(event);
await deps.markProcessed(event.id);
return { status: 200, body: "ok" };

Before an order is marked paid, the amount and currency Stripe actually received are compared with the amount the attempt asked for:

export function isPaidAsExpected(intent, attempt): "paid" | "unpaid" | "amount_mismatch" {
  if (intent.status !== "succeeded") return "unpaid";
  return intent.amount_received === attempt.amountMinor &&
    intent.currency.toUpperCase() === attempt.currency.toUpperCase()
    ? "paid"
    : "amount_mismatch";
}

Money that can’t be attached to an order, such as a second payment for an order that is already paid or an amount mismatch, is written to a durable money_at_stake table and logged as an error, so a person refunds it by hand instead of it disappearing into a log line. Payment intents are built deterministically from the attempt, with no clock in the body, so a retry with the same idempotency key sends exactly the same request and never creates a second charge.

Jobs That Are Safe to Run Twice

Periodic work runs from one hourly, authenticated POST from an external scheduler. Each job decides from persistent state whether it has anything to do and runs under its own lock with a lease, so a skipped or repeated tick changes nothing:

export const JOBS: CronJob[] = [
  { name: "email-outbox", isDue: () => true, lockTtlSeconds: 900, run: ... },
  { name: "payment-reconcile", isDue: () => true, lockTtlSeconds: 900, run: reconcileOpenPayments },
  { name: "fitment-alerts", isDue: (now) => now.getUTCHours() === ALERT_HOUR_UTC, lockTtlSeconds: 900, run: queueFitmentAlerts },
];

Reconciliation picks up payments whose webhook never arrived. Emails are written to an outbox in the same transaction as the event that causes them and sent afterwards, so a provider outage delays an email and never loses it. The fitment alert job records when it first saw each product in each market, and its very first run backdates the whole existing catalog so launch day doesn’t email everyone about every product.

Typography, Locales and Checks as Part of Lint

The URL structure and message files are locale-aware from the first day (/en-us/…), even though only one locale exists, so adding a market later does not reshuffle URLs. Lint runs the locale, glossary and typography checks together with ESLint, so copy regressions fail the same way a type error does.

Iurii RoguliaAvailable

Need something similar?

I build custom solutions – from APIs to full products. Let’s talk about your project.

View all projects

Related projects

kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses
kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses
September 28, 2026

Vertical SaaS that gives a salon, barbershop or solo beauty specialist their own booking website in minutes.

Stack

Next.jsReactHonoTypeScriptNode.jsTailwind CSSTurborepoDocker

Databases

PostgreSQLRedis
vatnode.dev – EU VAT Validation API
vatnode.dev – EU VAT Validation API
January 19, 2026
vatnode.dev – EU VAT Validation API

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

Stack

Next.jsReactHonoTypeScriptNode.jsTailwind CSSTurborepoDocker

Databases

PostgreSQLRedis

Related posts

Next.js SaaS Checklist: Launch Production-Ready in 8 Weeks
January 19, 2026· 17 min
Next.js SaaS Checklist: Launch Production-Ready in 8 Weeks

40+ point production SaaS checklist: auth, Stripe billing, PostgreSQL, rate limiting, email, monitoring, and security – with honest 8-week build estimates.

Stack

Next.jsTypeScript

Databases

PostgreSQLRedis
Stripe Webhooks: Idempotency, Retries, and Queue Setup
January 2, 2026· 11 min
Stripe Webhooks: Idempotency, Retries, and Queue Setup

Stripe webhook production architecture: idempotency keys in PostgreSQL and Redis, a BullMQ queue, and signature verification in Next.js App Router.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis