VII Patterns

Taking a Payment, End to End

A customer adds 50.00 to their shop balance with a card. They press “Top up”, pay on the payment provider’s page, come back to the shop, and see the new balance. The requirement in one line: the wallet is credited exactly once, soon, whatever fails on the way — the browser tab is closed, the provider’s notification is late or repeated, a process stops in the middle, the provider or one of the shop’s components is down for a while.

The naive version is one operation: create the payment at the provider, wait for the customer to pay, credit the wallet, write the ledger, send the receipt. It cannot be written — the customer pays minutes later, somewhere else — and every shortcut that looks like it (crediting when the browser comes back and says “paid”, crediting straight from the provider’s notification) loses money or creates it.

The flow is assembled from four parts: the gateway, the webhook, the wallet and its ledger, and, for payments that recur, the mirror.

§The flow

 1  customer ── POST /billing/topups/:customer/ ──> billing.topups.start
                    └── call ──> payments.gateway.checkout
              <── { url } ──
 2  the customer pays on the provider's page
 3a provider ── POST /payments/webhooks/ ──> payments.webhooks.ingest
                    └── call ──> payments.gateway.decode
                    ~ payments.webhooks.checkout ───────────────────┐
 3b customer ── POST /billing/topups/:customer/confirmations/ ──>   │
                billing.topups.confirm ── call ──> payments.gateway.retrieve
                    └── call ───────────────────────────────────────┤
 3c every 15 minutes: billing.topups.reconcile ── call ──> payments.gateway.paid
                    └── call ───────────────────────────────────────┤
                                                                    v
 4                                                        billing.topups.record   one per checkout
                                                              ~ billing.topups.paid   when created
                         ┌──────────────────────────────────────────┼─────────────────────┐
                         v                                          v                     v
 5              billing.wallets.credit                    notifications.receipt  the customer's page
                    ~ billing.wallets.moved                                           (realtime)
                         v
 6              billing.ledger.record

A ──> is a call: somebody waits for the answer. A ~ is an event: nobody does.

§Starting

A call, because the customer needs the address

billing.topups has its entity, record and the receiver of the webhook’s event from Receiving Webhooks. It gains two operations, routes for the customer, and a stream:

# components/billing.topups/manifest.toa.yaml
operations:
  start:
    type: effect
    errors: [INVALID_ORIGIN, UNAVAILABLE]
    input:
      type: object
      properties:
        customer: { type: string }
        amount: { type: integer, minimum: 100, maximum: 100000 }
        success: { type: string }
        cancel: { type: string }
      required: [customer, amount, success, cancel]
  confirm:
    type: effect
    errors: [UNKNOWN_CHECKOUT, UNAVAILABLE, UNPAID]
    input:
      type: object
      properties:
        customer: { type: string }
        checkout: { type: string }
      required: [customer, checkout]

exposition:
  /:customer:
    auth:id: customer
    GET:
      io:output: &topup [id, amount, currency, CREATED]
      endpoint: enumerate
      query:
        sort: CREATED:desc
    POST:
      io:input: [amount, success, cancel]
      io:output: [url]
      endpoint: start
    /confirmations:
      POST:
        io:input: [checkout]
        io:output: *topup
        endpoint: confirm

realtime:
  sync:
    key: customer
    expose: *topup
// components/billing.topups/operations/start.ts
import type { Context, StartInput } from '../types/index.d.ts'

export async function effect (input: StartInput, context: Context) {
  return await context.remote.payments.gateway.checkout({ input })
}

The page needs the provider’s address to send the customer to, so this is a call, all the way to the provider. If the provider is away, the customer is told now (UNAVAILABLE), which is the right time.

Nothing is stored. A payment page that was opened is not a fact about money; most are abandoned. The customer’s id goes to the provider as metadata.customer and comes back with everything the provider later says about this checkout. auth:id makes sure the id in the path is the caller’s own, so nobody starts or confirms a top-up for somebody else. io:input names what a client may send; the customer comes from the path.

§Learning the result

Three routes to one record

record, its key, its event paid, and lib/purchase.ts with paid() and request() are those of Receiving Webhooks: one object per checkout, under an id derived from the provider’s, written once, announced when created. What is new is that three callers reach it.

The webhook (3a in the picture) is the route that works when nobody is at the screen. It is the main one.

The customer’s return (3b). The provider sends the browser back to success with the id of the checkout, and the page posts it:

// components/billing.topups/operations/confirm.ts
import type { ConfirmInput, Context } from '../types/index.d.ts'
import { paid, request } from '../lib/purchase.ts'

export async function effect (input: ConfirmInput, context: Context) {
  const checkout = await context.remote.payments.gateway.retrieve({
    input: { checkout: input.checkout }
  })

  if (checkout instanceof Error) return checkout

  if (!paid(checkout)) return new Error('UNPAID')

  if (checkout.metadata.customer !== input.customer) return new Error('UNKNOWN_CHECKOUT')

  return await context.local.record(request(checkout))
}

The browser supplies an id and nothing else. Whether it was paid, how much, and by whom is read from the provider through the gateway: a client’s word that it paid is not evidence. The reply is the top-up, so the page can say “5000 added” without waiting for a notification that may be a minute behind.

The sweep (3c) is for the day both of the others fail: the webhook address was misconfigured, and the customer closed the tab on the provider’s page. Nothing in the shop knows a payment happened, so the shop asks. Where the provider can list recent payments, a pulse does it:

# components/billing.topups/manifest.toa.yaml
cadence:
  reconcile: 900 # every fifteen minutes

operations:
  reconcile:
    type: effect
    input:
      type: object
      properties:
        n: { type: integer }
        i: { type: integer }
// components/billing.topups/operations/reconcile.ts
import type { Context, ReconcileInput } from '../types/index.d.ts'
import { paid, request } from '../lib/purchase.ts'

const DAY = 24 * 3600 * 1000

export async function effect (input: ReconcileInput, context: Context) {
  const checkouts = await context.remote.payments.gateway.paid({
    input: { since: context.now() - DAY }
  })

  if (checkouts instanceof Error) return // the provider is away: the next pulse asks again

  for (const checkout of checkouts)
    if (paid(checkout)) await context.local.record(request(checkout))
}

It asks for a whole day every time, not for “the last fifteen minutes”: a pulse that was skipped then costs nothing, and recording a checkout that is already recorded writes nothing. This route is an addition to the other two, not a replacement: it is slower, and an option only where the provider can list. payments.gateway.paid is one more operation of the gateway, beside its checkout, retrieve and decode.

All three end in the same call with the same request, so it does not matter which comes first, how many of them come, or how often each repeats.

§Consequences

Events, because nobody should wait for them

billing.topups.paid is published once per checkout, in the same write that created the record. Three things hang on it, and billing.topups knows none of them:

# components/billing.wallets/manifest.toa.yaml
receivers:
  billing.topups.paid: credit      # once; ref = the checkout
# components/notifications/manifest.toa.yaml
receivers:
  billing.topups.paid: receipt

credit is the wallet’s (A Wallet and Its Ledger), and notifications with its receipt is the component of React to an Event.

The wallet’s own event then writes the ledger line (A Wallet and Its Ledger). The customer’s page learns without asking: billing.topups routes its sync to the customer’s key above, and billing.wallets does the same with key: id, so an open page receives the top-up and the new balance as they are stored (Push Changes to the Browser).

Spending is the opposite case. Approving an order depends on whether the money is there, so store.orders.approve calls billing.wallets.debit with the order’s id as ref, and returns its INSUFFICIENT_FUNDS to whoever asked (Refuse a Request). The wallet takes one debit per ref, so an approve that runs again pays once (A Wallet and Its Ledger).

§Which mechanism, and why

StepMechanismBecause
Start a checkoutcallthe page cannot continue without the address
Verify a notificationcallnothing may be stored before the answer
Read the checkout on returncallthe reply, and the record, depend on what the provider says
Record → credit the walleteventpublished with the record; the wallet may be down; nobody waits
Wallet → ledger lineeventpublished with the balance, so the two cannot disagree
Receipt emaileventa mail provider must not delay or fail a payment
Find what was missedpulseno message will ever come; somebody has to ask
Pay for an ordercallthe approval is decided by the answer

Ask by a call what you cannot go on without. Let everything that follows from a stored fact follow by events. Make the place where a repeat would cost money recognize the repeat. And for anything that depends on a message from outside, have a way to ask.

§Try it

With an empty provider key the stub answers: a checkout is the JSON given as its id, and a notification is accepted unsigned.

# features/topups.feature
Feature: Top-ups

  Scenario: A top-up reported both ways is credited once
    When `billing.topups.confirm` is called with:
      """
      input:
        customer: 8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f
        checkout: '{"id":"co_7","status":"paid","amount":5000,"currency":"USD","metadata":{"customer":"8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f"}}'
      """
    Then the reply is:
      """
      amount: 5000
      """
    When the request is sent:
      """
      POST /payments/webhooks/ HTTP/1.1
      content-type: application/json

      {"id":"evt_7","type":"checkout.completed","created":1760000000,"data":{"id":"co_7","status":"paid","amount":5000,"currency":"USD","metadata":{"customer":"8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f"}}}
      """
    Then the response is received:
      """
      201 Created
      """
    When `billing.ledger.enumerate` is called with:
      """
      query:
        criteria: owner==8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f
        limit: 10
      output: [amount, type, ref]
      """
    Then the reply is:
      """
      - amount: 5000
        type: topup
        ref: co_7
      """

The steps are those of Write a Test. The credit happens after the replies, so the last step is repeated until the line is there.

$ npm run sys
$ npm run env
$ npm run features

§What can still go wrong

The customer closes the tab after paying. The return route never runs; the webhook records the top-up. The webhook is late: the return route records it, and the webhook, a minute later, finds the record and changes nothing.

Both arrive at the same moment. Both record calls read an empty object. One write is stored; concurrency: retry runs the other again, it finds the record, and writes nothing. One paid event.

The provider repeats a notification for three days. Each delivery is stored, announced, and stops at the record. This is the repeat once could not catch: it is a different call, long after an hour.

billing.topups.paid is delivered twice. Same call, so once on credit answers the second with the first reply. One movement, one line.

A process stops right after the record is stored. The event was stored with it and is published when a process is back. There is no state “recorded but never credited”.

The wallet, the ledger or the mail component is down. Its event waits for it. The others are not delayed, and neither is the answer to the provider or to the customer.

The provider is down when the customer returns. confirm answers UNAVAILABLE; the page shows “we are checking” and tries again, or waits for the stream. The webhook follows on its own.

Somebody posts another customer’s checkout id. UNKNOWN_CHECKOUT, the answer for an id that does not exist: the checkout’s own metadata.customer decides whose money it is, never the caller.

Somebody posts a checkout that was not paid. UNPAID. A payment method that settles later is the same case: a later notification reports it paid, and the record is created then.

The receipt is sent twice. An email cannot be taken back. Keep a notification object under the top-up’s id, as in React to an Event, so the second delivery finds it.

§What this flow does not do

  • Money going back to the card. A refund requested in the shop is a request to the provider that moves money, so it carries a key the provider recognizes a repeat by, and its result is recorded once under the provider’s id of the refund — the same pattern, in the other direction.
  • A refund or a dispute started at the provider arrives as a notification about money the customer may already have spent. Taking it back from the wallet can be refused; what the shop does then is a business rule, not a technical one.

§Rules of thumb

  • The gateway alone talks to the provider; the door stores what it was told; one record per payment decides what is new; the wallet moves once per call; the ledger follows the wallet.
  • Start by a call and store nothing until money has moved.
  • Send your own id through the provider, and take the owner from what the provider returns.
  • Never credit on the client’s word: take an id from it and read the facts.
  • Give every external fact at least two routes in — the provider’s push and your own question — and make them end in one write that is safe to repeat.
  • Hang every consequence on the event of that write.
  • Test the pair “both routes, one credit” and the pair “same notification twice, one credit”.
Search
Search the documentation