Tutorials

Tutorial

Stripe payments with a double-entry ledger and nightly reconciliation

A test-mode payments API that starts Stripe Checkout or a PaymentIntent, posts a balanced journal entry only after a verified webhook, and reconciles those books against Stripe events at 02:00 UTC.

Arpan Bom7 min read
ShareXLinkedIn

Taking a card payment is the easy part to demo and the easy part to get wrong. The success URL in the browser is not proof that money moved. A refund can arrive twice if the API and the webhook both post it. The books can drift from Stripe if nobody compares them.

The payments API is a portfolio demo of that loop. It starts a Stripe Checkout Session or a PaymentIntent, books the payment in a Postgres double-entry ledger when a verified webhook says the money moved, and reconciles those journal lines against Stripe events. It moves no live money. The process refuses sk_live_ and rk_live_ keys and rejects live-mode webhook events.

The default stack uses an in-process fake Stripe gateway, so you can boot it and run the tests with no Stripe account. Point STRIPE_MODE=test at a restricted test key (rk_test_...) when you want Stripe's test mode. A sandbox is the right Stripe test environment for new work. Do not put secret keys in source or in git.

The Node client is the Stripe class, pinned to API version 2026-09-30.endive. Requests do not send payment_method_types, so dynamic payment methods stay on.

Boot the fake gateway

Docker Compose builds the API and Postgres.

docker compose up --build
curl -s localhost:3000/health

GET /health checks the database. The OpenAPI document is at openapi/openapi.yaml and at GET /openapi.json.

Create a Checkout session against the fake gateway. Every create requires an Idempotency-Key. The API stores a hash of the request and replays the same response if you send that key again. Reusing the key with a different body returns 409 and idempotency_key_reused. The key is also forwarded to Stripe.

curl -s -X POST localhost:3000/v1/checkout/sessions \
  -H 'content-type: application/json' \
  -H 'idempotency-key: demo-checkout-001' \
  -d '{
    "amount_cents": 2500,
    "currency": "usd",
    "description": "Design invoice",
    "success_url": "https://example.com/success",
    "cancel_url": "https://example.com/cancel"
  }'

Amounts are integer cents. The fake gateway does not contact Stripe, and checkout_url is a placeholder. The local payment stays pending. The success URL does not grant anything. Fulfillment waits for a webhook.

Book the payment only after Stripe says it is paid

POST /v1/checkout/sessions inserts a pending payment, calls checkout.sessions.create, and returns the URL. The customer pays on Stripe-hosted Checkout. Stripe then calls POST /v1/webhooks/stripe.

The handler verifies Stripe-Signature before any state change. Fulfillment runs on checkout.session.completed and checkout.session.async_payment_succeeded, and only when payment_status is paid or no_payment_required. An unpaid session is acknowledged with 200 and ignored, so Stripe will not retry it forever. A later checkout.session.async_payment_succeeded posts the same balanced entry.

fulfillCapturedPayment locks the payment row. If the Stripe amount or currency does not match the local row, it returns 409 and amount_mismatch. If the payment is already succeeded, partially_refunded, or refunded, it returns already_captured and does not post a second journal entry. Otherwise it marks the payment succeeded and posts one entry.

The capture entry uses the idempotency key capture:{payment.id}:

SideAccountType
Debitcash:stripe:{currency}asset, name "Cash at Stripe"
Creditrevenue:{currency}income, name "Revenue"

Both lines use the same integer cents. postEntry inserts the journal entry with ON CONFLICT (idempotency_key) DO NOTHING, then inserts the lines. A deferred constraint trigger, journal_lines_balanced, runs at the end of the transaction. It sums debits minus credits for that entry and raises if the imbalance is not zero. An unbalanced entry cannot commit.

In the same transaction, a new capture enqueues an outbox row. The event type is payment.captured. The payload carries the payment id, the amount, the currency, and the PaymentIntent id.

GET /v1/ledger/trial-balance is balanced when total debits equal total credits. GET /v1/payments/{id}/journal returns the entries for one payment.

PaymentIntents

POST /v1/payment-intents returns a client secret for a custom confirmation flow. The ledger does not move on that response. It waits for payment_intent.succeeded, verifies the signature, and posts the same style of balanced capture plus an outbox row.

payment_intent.payment_failed marks the local payment failed when it is still pending. It does not post a journal entry. A failed charge is not revenue and it is not cash.

Refunds must not double-book

POST /v1/payments/{id}/refunds locks the payment, checks the remaining balance, and inserts a pending refund before it calls Stripe. A refund that already failed for that idempotency key returns 409 and refund_failed, and tells you to use a new key to try again.

When Stripe returns succeeded, the API posts the reversing entry and enqueues refund.posted. The journal idempotency key is refund:{refund.id}:

SideAccountType
Debitrefunds:{currency}expense
Creditcash:stripe:{currency}asset

refund.updated posts that same entry again under the same key. The second attempt finds the existing journal row and does not add lines. The webhook and the API cannot double-book. Partial refunds are allowed. The payment status becomes partially_refunded or refunded from the refunded cents compared with the original amount.

The outbox and the nightly diff

Domain events are inserted in the ledger transaction, not after it. A poller publishes them into published_events. The compose file turns the poller on with ENABLE_OUTBOX_POLLER=true and OUTBOX_POLL_MS=2000. Tests leave it off and drain it by hand.

A failed publish stores the error, increments attempts, and sets next_attempt_at with exponential backoff and jitter. After 8 attempts the row is left unpublished. That is a stop, not a silent drop of the original journal entry. The books already committed. The outbox is the notification, and you can see that it got stuck.

Reconciliation lists these Stripe events: checkout.session.completed, checkout.session.async_payment_succeeded, payment_intent.succeeded, refund.created, refund.updated, and refund.failed. It compares them to journal movements keyed by PaymentIntent id and Refund id. It also checks that cash at Stripe equals captured cents minus refunded cents. Each run is stored in reconciliation_runs.

The scheduler uses cron 0 2 * * * in UTC when ENABLE_SCHEDULER=true. The default in .env.example is off, with RECONCILE_CRON=0 2 * * * ready to turn on. npm run reconcile runs the same job and exits 2 when it finds a discrepancy.

You can also trigger the jobs over HTTP. Compose uses JOB_TOKEN=dev-job-token. Send it as X-Job-Token:

curl -s -X POST localhost:3000/v1/jobs/outbox/drain -H 'x-job-token: dev-job-token'
curl -s -X POST localhost:3000/v1/jobs/reconcile -H 'x-job-token: dev-job-token'

GET /v1/reconciliations lists past runs.

Point it at Stripe test mode

Copy .env.example. Set STRIPE_MODE=test and provide:

  • STRIPE_API_KEY, a restricted key (rk_test_...) that can write Checkout Sessions, PaymentIntents, and Refunds, and can read Events. Prefer that over a secret key (sk_test_...). The API logs a warning if it is started with a secret key.
  • STRIPE_WEBHOOK_SECRET, from the Stripe CLI or a test-mode webhook endpoint (whsec_...).
stripe listen --forward-to localhost:3000/v1/webhooks/stripe

Store those values in the environment or a secrets manager. The database URL in the example is postgres://payments:payments@localhost:5432/payments. npm run migrate applies db/migrations/001_init.sql, which creates the ledger accounts, payments, refunds, journal entries, journal lines, idempotency keys, Stripe events, the outbox, published events, and reconciliation runs.

Tests, and the route map

Unit tests cover backoff, journal balance, idempotency hashing, webhook signatures, and the reconciliation diff. Integration tests boot the app with FakeStripeGateway and Postgres. They create payments, post signed webhooks using Stripe's own test-header helper with no network, refund, drain the outbox, and reconcile. No Stripe secret is required.

GitHub Actions runs npm run build and npm test against a Postgres 16 service container.

npm ci
npm test

npm test needs DATABASE_URL pointing at Postgres.

The routes you will actually call:

MethodPathPurpose
GET/healthDatabase check
GET/openapi.jsonOpenAPI document
POST/v1/checkout/sessionsStart Checkout
POST/v1/payment-intentsCreate a PaymentIntent
GET/v1/paymentsList payments
GET/v1/payments/{id}Fetch a payment
GET/v1/payments/{id}/journalLedger entries
POST/v1/payments/{id}/refundsRefund
GET/v1/ledger/trial-balanceDebits, credits, balances
POST/v1/webhooks/stripeSigned Stripe webhook
POST/v1/jobs/reconcileLedger versus Stripe events
GET/v1/reconciliationsPast runs
POST/v1/jobs/outbox/drainPublish pending events

If the trial balance is off, do not patch a single account by hand. Read the latest reconciliation run, find the PaymentIntent or Refund id that has a Stripe event without a journal movement, or a journal movement without an event, and replay the webhook. The idempotency keys on capture: and refund: are what make that replay safe.