Skip to content

feat: add orders, payment and an outbox to the shop plugin, with no route yet - #2

Merged
JasonYv merged 8 commits into
mainfrom
feature/shop-payment-core
Oct 5, 2026
Merged

JasonYv merged 8 commits into
mainfrom
feature/shop-payment-core

Conversation

@JasonYv

@JasonYv JasonYv commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Follows #1, which is merged. This now targets main and contains only its own eight commits.

What this changes

Adds the part of the payment phase that needs nothing new from Mallok: tables and logic, with their tests. No route, page or admin screen calls any of it, so nothing changes for a visitor or an administrator.

  • Tables (migrations/0002_orders.sql): orders, order lines, payment events, the stock ledger and an outbox.
  • Orders (lib/orders.ts): placing an order with line snapshots, reading one, and confirming a payment.
  • After the order (lib/order-fulfilment.ts): ship, deliver, cancel, refund.
  • Outbox (lib/outbox.ts): every change to an order writes what it still owes — an email, a purge — in the same batch.
  • Stripe (lib/stripe-signature.ts, lib/stripe-client.ts, lib/stripe-webhook.ts): signature verification on WebCrypto, the two API calls over fetch, and the decision of what a delivery means and which status to answer.
  • Email (lib/order-email.ts): payment confirmation and shipping notice in four languages.
  • The first migration's price, stock, minimum-order and cart-quantity columns now check their storage type. It is edited in place, which is only possible because nothing has been deployed from it.

Why

The next phase of the catalogue waits for Mallok. This is the work that does not have to wait, and it finishes the port list in the design's §8.

The properties it is built to hold:

  • Stock comes off when a payment is confirmed, never when an order is placed.
  • One payment takes stock once — whether Stripe delivers the event twice, sends two events for one payment, or delivers twice at the same moment.
  • A payment is one D1 batch: event, stock, ledger, outbox row and status commit together or not at all.
  • An order whose stock has gone is marked oversold with nothing taken.
  • A payment an order cannot take is recorded for a refund; the order and the stock are left alone.
  • What follows a change is owed from a row written with the change, never from a return value.
  • A webhook answers 5xx only for what delivering again could change.

How it was verified

  • npm run lint
  • npm run typecheck
  • npm test — 10 project checks, 280 tests inside workerd, 150 of them new
  • npm run smoke:shop (when the change touches the plugin, the theme or the content) — and npm run smoke, npm run build
  • Checked in the browser (describe what you clicked) — nothing here is reachable from a browser.

The tests were written first. Then 100 guards were broken one at a time and each turned a test red, including the ones that only show when two calls run at the same moment.

The code was then reviewed twice by a reviewer given the properties and not the implementation. The first pass found five defects, each fixed with a test that fails without the fix; the second found nothing against the properties and three smaller things, two fixed and one recorded. Both are written up in the design's §14.

Not verified. No purchase has been made against Stripe, in test mode or otherwise. Nothing has run on a Cloudflare account. Nothing drains the outbox yet.

Spec impact

  • No design decision changed
  • Design decision changed — spec updated in this PR

Design §4.2 (the tables as built), §5.4 (the webhook flow as built) and a new §14: what changed while porting, what the reviews found, and fourteen questions left open for the routes.

Phase 1B waits for extension points Mallok has not released. This is the part
of the payment phase that needs none of them: tables and logic, with no route,
page or admin screen. Nothing in the deployed Worker calls it yet.

Placing an order writes the order and its line snapshots in one batch and
leaves stock alone. Taking stock when an order is placed would let scripted,
unpaid orders empty the catalogue.

Confirming a payment is one batch: the event, the stock of the stock-tracked
lines, the ledger, the outbox row and the order's status commit together or
not at all. The previous implementation decremented line by line and put
stock back by hand; a crash in between left stock taken for an unpaid order,
and two deliveries arriving together could both decrement.

Three things make it safe to call any number of times, at any moment:

- Every statement is conditional on the order still being in the status that
  was read, so a batch that arrives second finds nothing to do.
- A payment is on record by its event id and by its type with the payment
  intent. Stripe can send two events about one payment.
- What to do is read from the data, and read again after a write that failed:
  is the payment on record, what status is the order in, is the stock there.
  Never from the text of an error.

An order whose stock has gone is marked oversold with nothing taken. A
payment the order cannot take, because it was cancelled or another payment
settled it, is recorded as refused with the payment to refund; the order and
the stock are left alone.

The outbox is what keeps a follow-up from being lost. An email sent after the
batch, on the strength of a return value, is gone whenever the Worker stops in
between or the batch's answer does not come back, and every later delivery
then sees a paid order. So each change to an order writes what it owes in the
same batch. Nothing drains the outbox yet.

Shipping, delivery, cancellation and refund go through the state machine and
are written only if the order is still where it was read. A refund returns
what the ledger says the payment took.

Money columns check their storage type: SQLite keeps 99.5 in an INTEGER
column rather than refuse it.
The webhook is the only trustworthy word on whether an order was paid, so
this is where a forged "payment succeeded" has to be stopped. No route calls
it yet: Mallok's plugin routes parse the body before the handler runs, and a
signature can only be checked against the bytes as received.

Verification is HMAC-SHA256 over the timestamp and the raw body, on
WebCrypto, with Stripe's five-minute tolerance. Only v1 signatures are read,
several are accepted while a secret is being rolled, the timestamp is digits
and nothing else, and an unset secret refuses everything.

The decision takes the raw body, the header and the secret and returns what
happened with the status to answer. The status follows one question: could
delivering this again change the result? Stripe redelivers anything that is
not a 2xx for three days, so only a database failure answers 5xx. An event
type the shop does not act on, a payment with no order id, a payment naming
an order this database does not have, and a payment for an order that cannot
take it all answer 200. The last is recorded for a refund rather than
signalled by failing.

That differs from the previous route in two ways that matter on a shared
Stripe account. A payment without an order id answered 400, and one for an
unknown order answered 500; every shop on an account sees every payment of
that account, so each would have been redelivered for days.

The two Stripe calls, opening a hosted Checkout session and refunding a
payment, are ported over fetch with no SDK. A failure is described by
Stripe's identifiers and the status, never by its free-text message, which
nothing stops from repeating a value that was sent. Stripe is never asked to
charge nothing: a session for a total of zero creates no payment intent, and
the order would wait for ever for an event that cannot come.
The two emails an order sends its buyer, in the language the order was placed
in: English, German, French and Spanish, with a regional variant using its
language and anything else falling back to English. Each has a plain-text
part beside the HTML, since HTML alone renders blank in some clients.

They are built in code with Mallok's escapeHtml, the way Mallok's own inquiry
plugin builds its emails. Everything a person typed, product names, SKUs and
tracking numbers, is escaped before it becomes markup in a buyer's inbox.

A lead time is stated only when the order has one to state. The previous
template could print one, and nothing supplied it.

These build content only. Nothing sends them yet.
The design gains the order tables as built, the webhook flow as built, and a
section on what changed while porting and why. It also records what an
independent review found afterwards, five defects with their fixes, so the
reasoning behind the outbox, the recorded refusals and the choice of webhook
status is not left in a commit message.

Twelve questions the routes will have to answer are listed and left
undecided, among them who drains the outbox, what a free order does, and
whether Stripe's Adaptive Pricing stays on.

Two gaps on the Mallok side were found on the way and added to the design's
list: a plugin cannot add its price to a page's structured data, and a job
enqueued after a plugin's batch can be lost with the Worker.

The README, the security policy and the contributor rules say what exists
now: the logic and its tests, and no route that reaches them.
SQLite does not refuse 99.5 in an INTEGER column; it stores a real number.
The first migration's price, stock, minimum-order and cart-quantity columns
therefore accepted fractions, and only the code writing them stood between a
rounding slip and a half-cent price in the catalogue.

Each column now checks its own storage type, as the order tables do. A whole
number arriving as text, the way a form field does, is still converted and
accepted.

The migration is edited in place rather than followed by a new one. A CHECK
cannot be added to an existing table, and Mallok's migrator splits on
semicolons, which rules out a trigger. Nothing has been deployed from this
migration, so no database is left behind by the edit; a local development
database created before it keeps the old columns until it is recreated.
Rows were read back oldest first by created_at, and that time is whatever the
caller made the change with. Three changes to one order carrying the same
time came back as delivered, paid, shipped: sorted by name. A consumer
sending notices in that order would tell a buyer the parcel had arrived
before it had left.

The table now has a sequence of its own, and rows are read back by it. Within
a batch that is the order of the statements, and across batches the order
they committed in.

The migration is edited rather than followed by another: it has not left
this branch.
A write that threw was never repeated: if the next reading called for the
same write, the first error was rethrown. That assumed the same write would
fail the same way, which does not hold when the data moved in between. Stock
taken by another order just before the batch, and returned by its refund just
after, left a payable order answered with an error and waiting for Stripe to
deliver the event again.

A write that throws is now tried once more when the next reading still calls
for it. The same write throwing twice running is a real failure, and the
passes stay bounded at four.

Tests are added for what a second review found untested: four passes that all
lose, and a write that commits while its answer is lost, for an oversold
order, a refused payment, a shipment and a refund. In each the change is made
once and what it owes is in the outbox.
The reworked code went back to the reviewer. Nothing was found against the
properties it has to hold; three smaller things were, two of which are fixed
and one of which is recorded as it stands.

Two more questions join the list left open for the routes: whether a payment
naming an unknown order should leave a record, and what deleting a variant
does to the orders that name it.
@JasonYv
JasonYv changed the base branch from feature/rebuild-on-mallok to main October 5, 2026 12:45
@JasonYv
JasonYv merged commit e9a1210 into main Oct 5, 2026
1 check passed
@JasonYv
JasonYv deleted the feature/shop-payment-core branch October 6, 2026 04:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant