A full-stack appliance parts reselling platform — staff portal for intake, OCR autofill, and review, paired with a public shop backed by Stripe Checkout, Shippo shipping, and a complete order management pipeline
Mark Down Parts is a two-application platform for reselling appliance parts — a staff-facing intake and review portal at app.markdownparts.com, and a public-facing shop at markdownparts.com. The mission is a fully automated pipeline: photograph a part, let OCR pull the part number, enrich the listing, publish to the shop, and let buyers browse, check out, and receive a shipping label — all without manual copy-paste.
The entire stack runs as a single Next.js 15 App Router application. Middleware routes markdownparts.com to the public shop and app.markdownparts.com to the auth-gated staff portal — both served from the same Docker container on a shared DigitalOcean VPS, proxied by Caddy. Stripe handles checkout with zero PCI scope, Shippo fetches live carrier rates and purchases labels, and Resend fires transactional emails throughout the pipeline.
A modern TypeScript monorepo — one Next.js app serving both the staff portal and public shop, containerized for consistent deploys.
Two applications, one codebase — a complete pipeline from photographing a part to shipping it to a buyer.
shopPrice and listPrice columns so channels can differmdp_cart, httpOnly, 7-day TTL) with live cart-count badgestripeSessionId/orders list with status pills, detail view, and soft-deletelabelError column persists Shippo's error reason — staff see it on the order page without grepping logssrc/lib/shipping.ts is single-file-swappable if vendor changes againPasswordResetToken table, Resend emailrequireAdmin() route guardThe engineering decisions — and the pragmatic problem-solving — behind a production commerce platform.
The shipping integration went through three providers before landing on Shippo. Pirate Ship has no outbound API — labels must be purchased manually in their UI. EasyPost was integrated next, but denied live-mode access on 2026-04-28. Shippo was the third attempt and the one that stuck. Throughout all three pivots, the shipping logic was isolated behind a single adapter file at src/lib/shipping.ts — fetch rates, purchase a label, return a URL and tracking number. Every provider change was a drop-in replacement of that one file, with no changes to the checkout flow, webhook handler, or order model. If a fourth provider becomes necessary, the swap is the same single file.
The Stripe checkout session success webhook is where money, inventory, and orders converge. Two failure modes had to be handled explicitly. The oversell guard: stock is decremented inside a Prisma transaction that first reads the current quantity — if another buyer's webhook fires concurrently and empties the stock, the transaction rolls back and the second buyer is automatically refunded via stripe.refunds.create(). The double-sell path: if a session ID has already been processed (stripeSessionId unique constraint), the webhook exits early without creating a duplicate order or double-charging. The webhook is also idempotent across retries, which Stripe may issue if it doesn't receive a 200 quickly enough.
Early schema changes on the production database were applied via prisma db push rather than migration files, which left the prod DB ahead of the migration history. When the first proper migration was deployed (20260422000000_add_shop_publish_fields), Prisma threw P3018 — the columns it wanted to create already existed. Recovery required running prisma migrate resolve --rolled-back, inspecting prod with SHOW COLUMNS to find which columns were genuinely missing, applying only those via ALTER TABLE … ADD COLUMN IF NOT EXISTS directly, then running migrate resolve --applied to bring the migration history back in sync. The migration file itself was kept unmodified in git as the canonical record of the target schema, so future fresh deploys (e.g. disaster-recovery rebuild from backup) run it cleanly on an empty database.
A single Next.js application serves three distinct URL spaces from one container. The middleware.ts file inspects the Host header on every request and applies different routing rules per hostname: www.markdownparts.com issues a 308 permanent redirect to the apex domain; markdownparts.com rewrites the root to /shop and exposes only public routes; app.markdownparts.com enforces Auth.js session presence on all routes and redirects unauthenticated requests to /login?callbackUrl=…. Caddy on the server side reverse-proxies all three domains to the same 127.0.0.1:3000 port — the application itself handles the per-domain logic entirely in middleware, with no separate containers or ports per hostname.
Stripe's automatic_tax feature requires a registered business and a sales-tax permit. While the LLC is pending, charging no tax at all would create a compliance gap, but enabling automatic_tax without the permit would create a different one. The solution: a pre-created Stripe TaxRate object set to a flat 8% Colorado rate, referenced by ID in STRIPE_TAX_RATE_ID. Checkout passes this TaxRate to the Stripe session — every buyer pays the same rate regardless of location, which is intentionally conservative (overcharging slightly rather than undercharging). When the LLC and CO sales-tax permit land, the cutover is a one-line change: empty STRIPE_TAX_RATE_ID and switch automatic_tax: enabled. No schema changes, no code refactoring.
Label purchase happens asynchronously inside the Stripe webhook — after payment is confirmed but before the buyer's confirmation email fires. If Shippo rejects the label request (bad address, carrier unavailability, API error), there's nothing the buyer can do about it, but staff need to know immediately so they can resolve it before the buyer emails asking about their order. Rather than relying on log grepping, a labelError column on the Order table persists Shippo's error message verbatim. The order detail page at /orders/[id] renders this in a visible error card when present. Staff see exactly what Shippo said, can fix the address or retry, and the buyer never has to report the problem themselves.
From checkout integration to order management pipelines — we build production-grade commerce applications that handle the hard parts.