Vertical SaaS that gives a salon, barbershop or solo beauty specialist their own booking website in minutes.
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.
Key Results

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:
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.

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.
| Metric | Value |
|---|---|
| Catalog | 9 makes, 32 model families, 44 machine variants, 39 products, 46 variants |
| VIN matching | 216 model-name aliases map vPIC text onto catalog machines |
| Codebase | ~22k lines of TypeScript, 59 test files, 23 database migrations |
| Market | US only, USD only, shipping included in the price |
| Payments | Stripe PaymentIntent, signed webhook, event de-duplication, hourly reconciliation |
| Background jobs | Email outbox, payment reconciliation, daily fitment alerts, each under its own lock |
| Order notification | One Telegram card per paid order, sent after the payment is confirmed |
| Deploy | Single container on Coolify, migrations applied on start |
For the technically curious, here is how the core pieces are built.
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 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.
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.
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.
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.
AvailableNeed something similar?
I build custom solutions – from APIs to full products. Let’s talk about your project.
Vertical SaaS that gives a salon, barbershop or solo beauty specialist their own booking website in minutes.
Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.
40+ point production SaaS checklist: auth, Stripe billing, PostgreSQL, rate limiting, email, monitoring, and security – with honest 8-week build estimates.
Stack
Databases
Stripe webhook production architecture: idempotency keys in PostgreSQL and Redis, a BullMQ queue, and signature verification in Next.js App Router.
Databases