Skip to content

Checkout kernel

Private shared kernel for the HTTP examples — mock charge, Stripe inbox, SQLite, HTTP policy.

Updated View as Markdown

@paykernel/example-checkout-kernel is the shared checkout kernel for every HTTP example. It is a private workspace package (private: true, version 0.1.0). It is not published. Hosts import its public helpers; they do not reimplement inbox HTTP mapping or Stripe signing.

Source: examples/checkout-kernel. There is no package README; this page follows src/index.ts, kernel.ts, handlers.ts, http-policy.ts, stripe-webhook.ts, and scenarios.ts.

Public exports

From src/index.ts (exports: { ".": "./src/index.ts" }):

import {
  createCheckoutKernel,
  createCheckoutHandlers,
  createCheckoutFetchApp,
  dispatchCheckoutRequest,
  checkoutJsonResponse,
  readRequestJson,
  createPaymentInputFromUnknown,
  gatewayPaymentIdFromUnknown,
  mapInboxOutcome,
  CHECKOUT_STRIPE_WEBHOOK_SECRET,
  stripePaidPaymentIntentFixture,
  stripeCreatedPaymentIntentFixture,
  signStripeWebhook,
  signedStripePaidWebhook,
  signedStripeCreatedWebhook,
  runCheckoutHttpScenarios,
} from "@paykernel/example-checkout-kernel";

mapInboxOutcome is re-exported from @paykernel/integration-http. Status codes do not live in @paykernel/webhooks.

What the kernel composes

createCheckoutKernel builds:

Piece Package Role in this example
createPaymentClient @paykernel/core mock adapter (@paykernel/testkit mockGateway) + stripeGateway. defaultGateway: "mock".
createWebhookInboxEngine @paykernel/webhooks mode: "inline" on stores.webhookInbox.
createReconciliationScheduler + createPaymentReconciler @paykernel/reconciliation Due jobs; decideReconciliationPolicy only — no second createPayment.
Stores @paykernel/store-sqlite/bun Default: createBunSqliteStoresInMemory then migrateSqliteAdapter.
HTTP @paykernel/integration-http processWebhookHttp + mapInboxOutcome.

Charge path is mock. Stripe is for webhook HMAC + PaymentEvent mapping. The Stripe secret in source is sk_test_example_not_live; the webhook secret is CHECKOUT_STRIPE_WEBHOOK_SECRET (whsec_test_example_checkout). Those are fixtures, not live credentials.

The client is created with no onWebhookVerified hooks. Fulfillment belongs only in the inbox handler after claim.

Store options

CreateCheckoutKernelOptions precedence: storeFactory > stores > executor > in-memory Bun SQLite.

Default path (sqlite hosts and tests that omit options):

const mem = createBunSqliteStoresInMemory({ clock });
await migrateSqliteAdapter(mem.executor);

:memory: is process-local and single-host. It is not multi-host coordination. Importing a store package does not apply DDL; the kernel migrates explicitly.

Do not install @paykernel/internal-sql-store. Inbox/recon tables come from @paykernel/store-sqlite (or the store you inject).

Create payment

POST /paymentscreateOrderPayment:

  1. Amount is always the server catalog: money("10.00", "USD"). Extra JSON fields (including client amount) are ignored. Only orderId is read from the body.
  2. Duplicate orderId409 { "error": "order_exists" }.
  3. client.createPayment on mock. NetworkError keeps the order, schedules recon, returns 200 with outcome: "indeterminate" and reconciliationRequired: true, and does not leak err.message.
  4. Other create failures delete the order and return 500 { "error": "create_failed" }.
  5. If result.outcome === "indeterminate" or reconciliationRequired === true, the kernel schedules recon. It does not call createPayment again.
  6. A successful mock create still leaves the order unpaid. Paid create does not fulfill. Fulfillment is webhook-claim or recon update_local_to_paid.

Webhook fulfillment

handleStripeWebhook calls processWebhookHttp with ackPolicy: { kind: "provider_redelivery" }, client as verify-only verifier, then inbox claim, then webhookHandler.

The handler returns without fulfilling unless:

  • event type is payment.succeeded or capture.completed, and
  • payment.status === "paid", and
  • a gatewayPaymentId is present (payment.references.providerObjectId, or gatewayPaymentId on the event).

Binding:

  • Match stored gatewayPaymentId first.
  • Else, if the order has no stored id, bind the webhook PaymentIntent id and fulfill.
  • If the stored id differs, do not fulfill (Stripe fixture metadata cannot pay a different mock charge).
  • No local order → handler throws → retryable handler_failed → HTTP 500.

payment_intent.created verifies and acks 200 and leaves the order unpaid.

Never fulfill in onWebhookVerified. handleWebhook verifies and normalizes; it does not claim, lease, or set HTTP status.

HTTP status (mapInboxOutcome)

Default policy in this kernel is provider_redelivery (packages/integration-http/src/http-policy.ts):

Inbox outcome Status
processed 200
duplicate_completed 200
invalid_webhook 400
payload_conflict 409
already_processing 503
handler_failed (retryable: true) 500
handler_failed (retryable: false) 200
scheduled_for_retry (not_available) 503
scheduled_for_retry (parked / handler_retry) 503

Route helpers

createCheckoutHandlers / dispatchCheckoutRequest / createCheckoutFetchApp:

const kernel = await createCheckoutKernel();
const app = createCheckoutFetchApp(kernel, { enableTestHooks: true }); // tests only
const res = await app.fetch(req);

dispatchCheckoutRequest reads POST /webhooks/stripe with req.text() and stripe-signature / Stripe-Signature. Invalid JSON on /payments or /internal/provider-paid400 { "error": "invalid_json" }. Bad orderId decode → 400 { "error": "invalid_order_id" }. Unknown path → 404 { "error": "not_found" }.

Reconciliation test hook

reconcileDue (behind the flag) runs scheduler.processDue. For each job it rebuilds a ReconciliationTarget, calls reconciler.reconcile, then decideReconciliationPolicy:

  • update_local_to_paid + safe → fulfill once
  • update_local_to_failed + safe → mark failed
  • do_not_create_replacement → retry with the decision reason (still no new createPayment)
  • otherwise manual_review / retry_later

POST /internal/provider-paid with { "gatewayPaymentId": "…" } injects a paid buildProviderPaymentSnapshot for local tests. Missing id → 400; unknown id → 404.

Stripe fixtures

stripe-webhook.ts builds sanitized PaymentIntent events and signs t=<unix>,v1=<hmac-hex> with hmacSha256Hex from core. Paid fixtures include amount_received and omit string latest_charge (an unexpanded charge id demotes domain status to processing). Checkout Session cs_test_ / cs_live_ ids fail assertFixtureSafe from @paykernel/testkit. Use signStripeWebhook / signedStripePaidWebhook — do not copy HMAC helpers.

Shared HTTP scenarios

runCheckoutHttpScenarios(name, createApp) is the bun:test suite the SQLite / Workers hosts run (bun-hono-sqlite, bun-elysia-sqlite, express-sqlite, cloudflare-workers-fetch, plus checkout-kernel itself). Each it builds its own kernel, passes { enableTestHooks: true } in the host wrapper, and close()s. bun-hono-postgres does not call it — that host uses describe.skipIf(!hasPg) over a live Postgres URL.

Covered failure / honesty paths:

Scenario Expected
Paid mock create Order stays unpaid, fulfillCount === 0
Signed paid webhook whose PI matches stored id 200, fulfill once
Paid fixture with a different PI 200, still unpaid
Indeterminate create (no stored id) + paid webhook Bind PI from webhook, then fulfill
Redelivery Second 200, still one fulfill
Bad signature 400, unpaid
JSON.parse + stringify (or pretty-print) body 400, unpaid
Concurrent same body 200 or 503, exactly one fulfill
fulfillThrows: true 500, unpaid
payment_intent.created 200, unpaid
Indeterminate create + provider-paid + reconcile Pays without a second createPayment
Client-posted amount: "10.001" Ignored; catalog charge
provider_ok_client_timeout 200 + indeterminate + reconciliationRequired; no second charge
NetworkError 200 + indeterminate; message not in JSON
Two indeterminate orders Recon does not bind the other order’s provider id

Run

bun test examples/checkout-kernel

From the repo root, bun test examples / bun run test:examples runs the kernel and every host.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close