Skip to content
All packs

Capability pack

Shop

A shop is not a product list. It is stock that two shoppers can race for, a cart that survives sign-in, prices that vary by variant, codes with limits that hold under concurrency, shipping and tax computed on the server, and an order that settles exactly once whether the browser came back or not. Payment sits behind a seam: a mock that confirms instantly, or Stripe hosted Checkout when the keys are set.

Capabilities

14

All of them are built.

Watched in production

0

14 have never been watched against a real provider.

Provider variables for a fresh project

10

Including the packs it sits on. 7 unset here, each running its documented fallback.

Needed regardless of providers

  • A Postgres database — catalog, carts, orders, and inventory.
  • Somewhere to keep product images: local files until object storage is configured.
Sits on top ofAccounts11 inherited capabilities, counted once in the number above.

What is in it

Each row links to where it lives, and carries the state it has in THIS deployment.

  • Money as integer cents, products deactivated rather than deleted, category filter in the URL.

    Prisma models + Server Components

  • A size, a color, a switch type: each with its own stock and price. With any variant visible the shopper must choose one; the order reserves that variant's stock with the same conditional update the product used and carries its name in the line. Cancel and refund return stock to the variant. Admins edit variants per product.

    ProductVariant rows with own stock and optional price; cart lines unique by (product, variant)

  • A hashed guest token, quantities clamped against live stock on the server, and a pure, unit-tested merge rule.

    Guest cookie + account rows, merged at sign-in

  • Subtotal → discount → shipping → tax → total, in integer cents, rendered identically on the cart, the checkout, the order, the receipt, and Stripe's page. Codes are checked when applied, on every view, and at checkout where a conditional update counts the use. Shipping methods and regional tax rates are lists a fork edits in one file.

    One pure pricing function; DiscountCode rows with atomic use counting; shipping methods and tax rates as data in code

  • Stock reserved with one atomic decrement, prices snapshotted onto the order, guest receipts via an httpOnly cookie.

    Conditional UPDATE inside a transaction

  • Checkout provider seam (mock or Stripe)

    Fallback

    Payment execution behind the same one-entry-point pattern as mail and AI. The mock confirms instantly so the whole shop runs with no account. With Stripe keys, the browser goes to hosted Checkout and the order settles only when a webhook signed by Stripe says so — or when the nightly sweep asks Stripe about a session the webhook never reported. A refund is an attempt row carrying the provider's own status — the order reads refunded only when the provider says succeeded, by its synchronous answer, its signed refund events, or the nightly sweep asking — and stock returns on the goods coming back, not on the money (decision 053).

    Needs STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET. Without it: A mock provider confirms payment instantly so the whole shop runs with no account.

    Mock by default; Stripe hosted Checkout over fetch when keys are set, with a signed webhook, refunds, and reconciliation

  • Money can move against you after the sale. A cardholder tells their bank the charge was wrong; the bank takes the money back and asks for evidence; weeks later it decides. That arrives here through the same signed webhook the payments use, as one row per dispute keyed by the provider's id, updated with a conditional write so retries and out-of-order deliveries leave one row saying the latest thing. The admin's order page shows what the bank wants and by when, because missing the deadline is losing by default. And the refund rule asks the dispute before the provider: a refund while the bank holds the money would return it twice, and after a lost dispute there is nothing left to refund (decision 065).

    Needs STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET. Without it: The mock provider raises no disputes; the row, the admin view, and the refund rule stay inert.

    Stripe dispute webhooks onto a Dispute row with an evidence deadline

  • An order that ships needs somewhere to go, and the somewhere must be the one the customer gave at checkout — not whatever their profile says a month later when the dispute arrives. So the address is a snapshot on the order like the prices and the line names: asked for only when the shipping method is not a pickup, validated loosely (one required line, a city, a postal code, a two-letter country; region free text because half the world has no state), and shown on the order, the admin's page, and the receipt. It is also what a tax service would quote from (decision 065).

    A validated JSON snapshot on the order, written once at checkout

  • Tax was a table in code, which is right for a shop selling into three states and wrong the day it sells into forty. The seam is the same shape as mail, AI, and payment: one interface, one plain implementation that needs nothing, and a place a fork plugs a service into. The order stores which provider quoted it. An unknown TAX_PROVIDER falls back to the table and says so in the quote, so a typo in an environment variable never silently zeroes the tax. Stripe Tax — calculation per checkout, transaction on settle, reversal on refund — is designed in the knowledge base with its costs stated (decision 065).

    One TaxProvider interface; a rate table in code behind it; Stripe Tax designed

  • Users, audit log, and AI spend behind a role check that re-reads the database rather than trusting the session claim.

    Role-gated route group + Server Actions

  • A rating and plain text from a member, marked as a verified purchase only when the server finds a paid order for the product. A flag gates writing on a purchase. The product page shows a one-decimal average and a histogram-backed summary, structured data carries aggregateRating, and an admin can hide or restore any review.

    Review rows, one per member per product; verified from paid orders; a pure rating summary; admin moderation

  • The same heart saves to the account when signed in and to the browser when not. The wishlist page shows either, resolves browser-held ids to live products on the server, and moves a guest's list to the account on one explicit click after sign-in.

    WishlistItem rows for members; localStorage for guests; one heart component for both

  • Transactions

    Ready

    A change and its audit entry commit together, or not at all.

    Prisma $transaction

  • File upload

    Fallback

    Presign, direct PUT with progress (or drag-and-drop), then verification of the real bytes by magic number. Keys are random; SVG is refused. The server probes its own credentials before promising a URL. In the APPLICATION, product images are public and a quote photo is not: publicUrl refuses the inquiry prefix outright, and the only door is an admin-checked route redirecting to a signature good for two minutes. THAT GUARANTEE DOES NOT SURVIVE THE BUCKET IT IS STORED IN, and on 2026-09-04 it did not: the R2 bucket had its Public Development URL enabled and no custom domain, so every prefix — inquiries/ included — was served to anyone at pub-*.r2.dev with no authorization at all. Privacy here is a key-naming convention enforced by application code, and an object store that serves the whole bucket does not read application code. privatePrefixExposure() asks the bucket and the admin inbox reports the answer, which is detection; the prevention is the private-object-bucket capability — with R2_PRIVATE_BUCKET set, every private prefix lives in a second bucket the public URL cannot reach. Until that variable is set, treat the private prefix as private-by-obscurity — which this project says elsewhere, correctly, is never access control — and /api/health reports storage as degraded for exactly that reason.

    Needs R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_BASE_URL. Without it: Files are written under .uploads/ and served by a local route that mirrors R2's signed PUT and its signed GET, so the whole flow — private objects included — runs with no account.

    Presigned URLs — Cloudflare R2, or a local transport

First steps in a fork

  1. 1Run npm run db:seed:catalog against the target database for something to browse.
  2. 2Add real products at /admin/products; images go through the media library.
  3. 3Set the shipping rates and tax rules for the places you actually sell to.
  4. 4Add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET last, and watch one test purchase settle before trusting it.

What to delete

  • The forum and comments, the game and leaderboard, organizations, AI, API keys, i18n, the Lab and experiments, the inventory (stack, packs, claims, the learning shell), the dashboard and projects, and the newsletter's routes — 93 paths.
  • Their models and enums in prisma/schema.prisma — 14 models and 6 enums of 70 blocks — and every relation field pointing at them; then one squashed migration with the three search triggers carried by hand.
  • Seventeen files where those domains reached into what the shop keeps: the learning shell on four task pages, the search index, the home page, the admin overview and users page, the sensitive-action list, the sitemap, the export, health, the outbox cron, the footer, the layout. docs/knowledge-base/extraction.md lists each.

Provider variables a fresh project would set

  • AUTH_GOOGLE_ID
  • AUTH_GOOGLE_SECRET
  • R2_ACCESS_KEY_ID
  • R2_ACCOUNT_ID
  • R2_BUCKET
  • R2_PUBLIC_BASE_URL
  • R2_SECRET_ACCESS_KEY
  • RESEND_API_KEY
  • STRIPE_SECRET_KEY
  • STRIPE_WEBHOOK_SECRET

Shaded green: set on this deployment.

The full inventory, with the tests that cover each capability and what has actually been watched working.

Capability inventory