Back to Portfolio

Mark Down Parts

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

Live — Stripe TEST mode
Visit Shop
markdownparts.com
Mark Down Parts shop homepage

Overview

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.

Next.js 15
App Router
Stripe
Checkout + Webhooks
Shippo
Rates + Labels
9
Part Statuses

Tech Stack

A modern TypeScript monorepo — one Next.js app serving both the staff portal and public shop, containerized for consistent deploys.

Next.js 15
App Router — UI + API in one app
TypeScript
End-to-end type safety
MariaDB 11.4 + Prisma
Relational DB + ORM
Auth.js v5
Credentials provider, JWT sessions
Stripe Checkout
Hosted payments, zero PCI scope
Shippo SDK
Live carrier rates + label purchase
Azure Computer Vision
OCR Read API for part number autofill
Resend
Transactional email
Docker + Compose
App + DB containers, named volumes
GitHub Actions
CI — lint + typecheck + build → GHCR
Caddy
Reverse proxy + automatic TLS
DigitalOcean VPS
2 vCPU / 4 GB, Ubuntu 24.04

Key Features

Two applications, one codebase — a complete pipeline from photographing a part to shipping it to a buyer.

OCR-Powered Intake

  • Photos-first flow — shoot the part before filling in any fields
  • Azure Computer Vision extracts part numbers from photos automatically
  • Duplicate part number warning before submission
  • localStorage draft autosave with 1-hour expiry and a Clear Draft button
  • Submit overlay with status feedback during upload

Review Queue & Enrichment

  • Status filter pills with per-status counts across 9 statuses
  • Tile layout sortable by status; soft-delete with 14-day purge
  • Full enrichment form: part details, pricing, eBay URL, notes
  • Photo strip with drag-to-reorder, per-photo delete and rotation
  • Record Sale card captures marketplace, price, buyer, and shipping address
  • Status auto-advances on key actions (eBay URL → LISTED, shipped → SHIPPED)

Public Shop

  • Explicit publish gate — "Publish to shop" button decoupled from eBay status
  • Separate shopPrice and listPrice columns so channels can differ
  • Cookie cart (mdp_cart, httpOnly, 7-day TTL) with live cart-count badge
  • SOLD landing page for delisted parts
  • JSON-LD Product schema, OpenGraph, robots.txt, and dynamic sitemap
  • Duplicate part action — clone metadata + photos, reset state for relisting

Stripe Checkout & Orders

  • Hosted Stripe Checkout — zero PCI scope
  • Webhook verifies signature, idempotent on stripeSessionId
  • Atomic transactional write: Order + OrderItem + Sale + stock decrement
  • Oversell guard — stock checked inside the transaction before confirmation
  • Double-sell refund path for edge cases where two buyers reach checkout simultaneously
  • Admin /orders list with status pills, detail view, and soft-delete

Shippo Shipping

  • Two-step checkout: ship-to form → live rate picker (cookie-stashed, 30-min TTL) → Stripe
  • Webhook purchases label on payment confirmation, persists URL + tracking number
  • labelError column persists Shippo's error reason — staff see it on the order page without grepping logs
  • Shipping adapter at src/lib/shipping.ts is single-file-swappable if vendor changes again
  • Ship-from address block pre-configured; USPS test carriers active in dashboard

Auth, Email & Settings

  • Staff-only credentials auth (Auth.js v5), JWT sessions, middleware-enforced routes
  • Forgot-password self-service: 1-hour token, PasswordResetToken table, Resend email
  • Settings: email change, password change with generator + show/hide, font picker
  • Admin panel: user table, add-user form, requireAdmin() route guard
  • Resend emails: order confirmation, Part Listed, Part Sold (buyer + all admins), label ready, password reset

Technical Highlights

The engineering decisions — and the pragmatic problem-solving — behind a production commerce platform.

Shipping Provider Pivot + Adapter Pattern

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.

Stripe Webhook: Oversell Guard + Double-Sell Refund

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.

Production Schema Drift + P3018 Migration Recovery

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.

Host-Based Routing in Next.js Middleware

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.

Pre-LLC Manual Tax Rate Workaround

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 Error Persistence for Operational Visibility

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.

What This Project Demonstrates

Full-Stack Next.js 15
App Router, server actions, middleware, API routes, and TypeScript end-to-end — not a tutorial project, a production app
Payment + Shipping Integration
Stripe Checkout, webhook handling, oversell guards, and Shippo carrier rates — the full commerce pipeline wired together
Production Infrastructure
Docker Compose, GitHub Actions CI/CD to GHCR, automatic migrations, nightly backups, and Caddy reverse proxy
Pragmatic Problem Solving
Three shipping providers, a production schema drift recovery, and a pre-LLC tax workaround — real obstacles, real fixes
Multi-Tenant App Architecture
One codebase, one container, two distinct applications — host-based routing in middleware keeps the staff portal and public shop cleanly separated
Built for Real Use
A live platform with a real business behind it — intake, review, publish, sell, fulfill, and ship, with LLC and live-money cutover the only remaining step

Need a Commerce Platform?

From checkout integration to order management pipelines — we build production-grade commerce applications that handle the hard parts.

Visit the Shop Get In Touch