Skip to content
← All posts
9 min read

Vendure checkout audit: eight order states, six payment states, and hidden bugs

Vendure publishes its checkout as a finite state machine. That makes it a free yardstick for auditing checkouts that were written as boolean columns.

Vendure publishes its order and payment state machines. We use them as the yardstick when auditing a checkout.

Every checkout does the same four things. It holds a cart, it takes money, it sends goods, and sometimes it gives the money back. In the code we get handed to audit, those four things are usually four boolean columns on one `orders` table: `is_paid`, `is_shipped`, `is_cancelled`, `is_refunded`. Four booleans describe sixteen combinations. A working checkout needs about eight of them.

The other eight are the bugs, and they are the ones nobody wrote down. `is_paid` true with `is_cancelled` true. `is_shipped` true with `is_paid` false. `is_refunded` true on an order whose payment was authorized but never captured, which some providers will accept and then bill you a fee for. Nothing in that schema says those rows are illegal, so nothing stops them from being written at 2 AM by a retried webhook.

Vendure writes the same problem as a state machine, and publishes the whole thing.

The cart is the order

Vendure is an open source headless commerce framework, TypeScript on top of NestJS. The first design decision worth stealing is in its orders documentation, which says of the cart and the order that "the same entity is used for both".

One row, one identity, from the first add-to-cart to the delivery scan. Most of the checkouts we audit have a `carts` table and an `orders` table and a function that copies fields from one to the other. That copy is where prices go stale, where tax gets recalculated with a different rounding rule, and where a line item silently disappears because the copy ran before the last update committed.

Eight states, and the arrows between them

Vendure's `DefaultOrderProcess` names its states, among them the eight that carry a normal order end to end, and declares which transitions between them are legal:

  • `AddingItems` is the cart. The customer is still shopping.
  • `ArrangingPayment` locks the order against modification while payment runs.
  • `PaymentAuthorized` means the money is reserved but not taken.
  • `PaymentSettled` means the money moved.
  • `PartiallyShipped` and `Shipped` track fulfilments leaving the warehouse.
  • `PartiallyDelivered` and `Delivered` track them arriving.

The interesting part is not the list. It is that `ArrangingPayment` exists at all. It is a lock, expressed as a state: while the order sits there, lines cannot be added or removed, so the total the payment provider was quoted is the total that gets captured. A boolean schema has nowhere to put that idea, which is why the race between a user opening a second tab to add a t-shirt and a payment for the old total confirming is one of the most reliable bugs we find.

Vendure also lets you insert your own states. The documented example adds a `ValidatingCustomer` state between `AddingItems` and `ArrangingPayment` and hangs a tax ID check off the `onTransitionStart` hook, returning an error string to block the transition. The check lives on one edge of the graph instead of being copy-pasted into three controllers and forgotten in the fourth.

Payment is a second machine, not a column

Payments get their own states in Vendure: `Created`, `Authorized`, `Settled`, `Declined`, `Error`, `Cancelled`. Note that `Declined` and `Error` are separate. A declined card is a business outcome you show the customer. An error is your integration falling over, and it needs a retry path and an alert, not a polite message about trying another card. In the boolean version both of those are `is_paid = false`, and the difference is gone.

Providers plug in through a `PaymentMethodHandler` with three methods: `createPayment`, `settlePayment`, `cancelPayment`. Here is the shape, trimmed from Vendure's own example:

createPayment: async (ctx, order, amount, args, metadata) => {
  const result = await sdk.charges.create({
    amount,
    apiKey: args.apiKey,
    source: metadata.token,
  });
  return {
    amount: order.total,
    state: 'Authorized' as const,
    transactionId: result.id.toString(),
    metadata: { public: { referenceCode: result.publicId } },
  };
}

Read the return value again. The recorded amount is `order.total`, computed on the server from the order's own lines. It is not the `amount` argument that came in with the call, and it is certainly not a number the browser sent. That one line is the whole price-tampering defence, and it is the most common thing missing from the checkouts we audit. If your payment record takes its amount from the request body, a customer with curl decides what your products cost.

The `metadata` field is split too. Anything under `public` is readable from the Shop API, everything else stays admin-only. So the provider's raw response, which routinely carries more about the card and the cardholder than you want in a browser payload, does not leak by default.

Authorize now, capture later

`createPayment` can return `Settled` and finish the checkout in one hop, or return `Authorized` and leave the capture for later, when `settlePayment` runs. This is the hotel check-in hold: the desk reserves an amount against your card on arrival and takes the real number when you leave.

Single-step is one less moving part, and for a digital product delivered instantly it is the right call. For anything that ships, two-step is usually the cheaper mistake. Cancelling an authorization costs nothing at most providers. Refunding a capture means you have already paid the processing fee, so on a 2 percent fee with a 3 percent cancellation rate you are burning 0.06 percent of gross revenue on orders you never fulfilled. Small until it is not.

Why teams reach for booleans anyway

We should be fair to the alternative, because we have written it too. A boolean column is one migration and zero new concepts. A state machine is a config object, a transition table, hooks, and a mental model every new hire has to load before they can ship a checkout change. On a store doing thirty orders a week, the booleans work and the state machine is overhead nobody thanks you for.

The cost arrives later and arrives all at once, usually with the second payment method or the first partial refund. That is the moment the illegal combinations stop being theoretical and start being rows in production that no code path knows how to read.

The eight questions we ask a checkout

This is the checklist our audits run against payment code, whatever it is written in. Vendure is only the reference model. The questions land the same on a Django monolith, a Rails app, or 400 lines of Express that a founder's cousin wrote in a weekend.

What we askThe answer that failsWhat it costs
Can you draw the order states?No diagram, just boolean columnsRows nobody wrote a guard for
Where is the order total computed?It arrives in the request bodyA tampered price the server accepts
Authorize then capture, or capture on click?Capture on click, alwaysProcessing fees on every cancellation
Is the webhook signature verified?The endpoint parses JSON and trusts itAnyone can mark any order paid
What if the webhook arrives twice?The handler runs twiceDouble capture, double fulfilment
What if it never arrives?Nothing. There is no reconcilerPaid orders stuck in the cart state
Can a refund exceed what was captured?The provider decidesNegative revenue, found at month end
Where does card data land?Posted to your server, then loggedPCI scope you did not budget for
The checkout questions, and the answers that turn into findings

Question four is the one that ends audits early. An unsigned webhook endpoint that transitions an order to paid is a public API for free merchandise, and it is usually three lines from being fixed.

What the state machine will not save you from

Here is the limit, and it matters because it is the half most write-ups skip. Vendure's orders and payment documentation does not cover webhook idempotency at all. The state machine gives you legal transitions inside your own process. It says nothing about a provider calling your endpoint five times because your first response took longer than their timeout.

At scale you are still on the hook for three things no framework does for you. Store the provider's `transactionId` with a unique constraint and let the database reject the duplicate, rather than checking first and inserting second and losing the race. Treat the webhook as a hint, then refetch the payment from the provider before acting on it, because a retry can carry a stale body. And run a reconciler on a schedule that walks the provider's ledger and flags every payment they call settled that you still have sitting in `ArrangingPayment`.

One more Vendure detail if you extend it: the docs are explicit that changes made inside `onTransitionEnd` must mutate the passed `order` object directly, not go through a separate service call. That hook runs inside the transition, and a write that goes around it can be overwritten without a word.

We have not run Vendure in production. Everything here about Vendure is read from its public documentation and its handler interfaces, not from operating a store on it, so treat the framework details as a reference model and not a battle report. The eight questions are ours, and they come from checkouts we have actually opened up.

Try the diagram test on your own checkout

Open the payment code you own and draw the state diagram from it. Not from memory, from the code: every place an order's status changes, and every transition that code permits. If you can finish the diagram in ten minutes, the machine exists even if nobody named it. If you cannot, the states are still there. They are spread across five call sites and a webhook handler, and the illegal ones are already in your database.

Sources: Vendure's order concept docs and payment docs. If you want the eight questions run against your own checkout by someone who is not the team that built it, that is a Surface Audit.

EngineeringPaymentsE-commerceSoftware auditsVendureVendureCheckoutSecurityPaymentSystemsStateMachineHeadlessCommerceNestJSTypeScriptSoftwareAuditEcommerceGattyWorks

Ready to know?

Send what you want checked or built. Fixed scope, price, and date in writing inside 24 hours, or the website or audit fee on your first project is refunded in full.

24 clock hours. Weekends included.
Book a call