Oshun Platform · Architecture

Fiat Payments — Stripe Billing, Apple Pay, and Google Pay

A focused page within the Oshun Platform Architecture documentation. The full map and every sibling page live in the Architecture hub.

5sections8 minread1table

On this page

This page specifies the V1 fiat rail: Stripe Billing for card subscriptions, and the two device wallets — Apple Pay and Google Pay — both as Stripe-tokenized payment methods and as merchant-decrypted tokens. It is the fiat counterpart of Support, Entitlements, Billing, and the Aje Entitlement Bridge (the crypto-primary rail), and it closes that page's longest-standing honest gap: "both rails publish identical-schema entitlement events" used to be a design goal; with @oshun/fiat-payments it is wired fact, proven by a cross-rail structural-equivalence test.

The implementing library is libs/oshun/fiat-payments/ (@oshun/fiat-payments, private). It depends on @oshun/payments-bridge (for the shared PaymentBusEvent topics) and @oshun/billing-support (for the subscription state machine), plus @noble/{curves,hashes,ciphers} for the wallet cryptography. It has no I/O of its own outside the injected Stripe HTTP transport — every verifier and decryptor is pure and clock-injected.

Candor up front. The deterministic plane — webhook signature verification, event normalization, subscription-lifecycle reconciliation, price-catalog validation, the fiat entitlement emitter, Apple Pay EC_v1 decryption + CMS chain verification, Google Pay ECv2 verification + decryption — is real, tested code (105 tests; the CMS verifier is tested against OpenSSL-generated signatures, the ECIES decryptors against independent node:crypto encryptors). What remains deployment-gated, and fails closed rather than pretending: a live STRIPE_SECRET_KEY (the client factory reports configured: false without one), the Apple Pay merchant identity certificate for merchant validation (the session resolver reports configured: false; the BFF returns 503), and the production key material (Apple payment-processing key, Google-registered merchant key, Apple Root CA - G3 pin, Google root-keys JSON).


Stripe Billing — src/stripe/#

Webhook verification — webhook-verifier.ts#

verifyStripeWebhookSignature implements the Stripe-Signature scheme (t=<unix>,v1=<hex hmac>, HMAC-SHA256 over ${t}.${rawBody}) with three hardenings the generic @oshun/inbound-integrations connector verifier lacks: a timestamp tolerance window (default 300 s — without it any captured webhook replays forever), multiple v1 candidates (secret rotation), and constant-time comparison across every (secret × candidate) pair. The verifier is pure (nowUnixSeconds injected) and is anchored by a known-correct HMAC vector computed independently with node:crypto.

Event normalization — event-normalizer.ts#

The fiat mirror of the crypto bridge's state mapper. Stripe's event zoo collapses onto the entitlement plane's vocabulary:

Stripe event Normalized
invoice.paid / invoice.payment_succeeded settlement confirmed over amount_paid
invoice.payment_failed / invoice.marked_uncollectible settlement failed over amount_due
charge.refunded / refund.created settlement refunded over the refunded amount
customer.subscription.{created,updated,deleted,paused,resumed} subscription-sync (status + periods + lookup keys)
checkout.session.completed settlement confirmed (payment mode, paid) — subscription-mode sessions with an invoice defer to invoice.paid so the first period never double-settles
anything else explicit ignored with reason (never silently dropped)

Zero-amount paid invoices (100 %-off promos, trial-start invoices) are ignored/zero-amount, not settled as $0. Malformed payloads normalize to a typed malformed result with per-field problems — never a throw, never a guess. FiatPaymentSettlement is shape-identical to billing-support's AjePaymentSettlement, so one settlement vocabulary drives both rails' subscription transitions.

Subscription lifecycle — subscription-lifecycle.ts#

Stripe's eight statuses map onto the billing-support machine (trialing→trial, active→active, past_due→past-due, canceled→canceled, unpaid→lapsed, paused→paused; incomplete/incomplete_expired deliberately have no local state — a first payment that never happened must not mint a subscription). Where no direct legal edge exists, planTransitionPath BFS-walks the shortest legal path, probing transitionSubscription itself as the legality oracle — the transition table is not duplicated in this library. Unreachable targets return target-unreachable for support-visible reconciliation instead of an illegal forced write.

Price catalog — price-catalog.ts#

The deterministic join between Stripe prices and EntitlementClass: canonical lookup keys oshun_<class>_<interval> first, price metadata.oshun_entitlement_class second; a typo'd key inside the oshun_ namespace fails loudly rather than falling through. validatePriceCatalog is the release gate: every paid class purchasable monthly + yearly per currency, no duplicate (class, interval, currency) cells, positive integer amounts, and an annual-discount plausibility warning (monthly < annual ≤ 12×monthly). The free class is never purchasable.

Client — client.ts and request-encoder.ts#

StripeBillingClient is a typed client over the endpoints the rail needs (customers, subscriptions, subscription-mode Checkout, billing-portal sessions, payment-method attach/default, refunds, price listing), speaking Stripe's real wire format: application/x-www-form-urlencoded with Rails-bracket nesting (items[0][price]=…), Stripe-Version pinning, Idempotency-Key headers, and typed StripeApiError (status/type/code/decline*code/request-id). Construction requires an sk*/rk\_key;resolveStripeBillingClient(env)returns{ configured: false }withoutSTRIPE_SECRET_KEY so BFF routes fail closed — same honest-seam pattern as the crypto quote route.

Settlement processor — settlement-processor.ts#

One signature-verified event flows: replay guard (Stripe event id; delivery is at-least-once) → normalization → entitlement-bus emissionsubscription drive (applyPaymentSettlementToSubscription for money, syncSubscriptionWithStripeStatus for lifecycle) → persistence via the injected SubscriptionDirectory. Emission map: confirmed → payment.invoice.settled, refunded → payment.refund.broadcast, failed → no bus topic (the bus has no failure topic on either rail — failure moves the subscription to past-due instead). Events are marked processed only after the pipeline succeeds, so a mid-pipeline crash re-runs on Stripe's retry; bus consumers are idempotent on (topic, invoiceId) by contract.

The parity emitter — entitlement-emitter.ts#

FiatEntitlementEmitter mirrors the bridge's CryptoEntitlementEmitter field-for-field on the fiat-* rails, with chainAsset/chainAmount pinned null and refund broadcastTxId pinned null (a PSP refund id is carried in refundId). The cross-rail test asserts eventsStructurallyEquivalent AND key-set equality between fiat and crypto events of the same topic — the "identical schema across rails" claim is now enforced by CI, not prose.


Apple Pay — src/wallets/apple-pay/#

Two server-side flows are supported:

  • Stripe-processed (the V1 default): the browser/app tokenizes Apple Pay into a Stripe PaymentMethod client-side; the server leg is StripeBillingClient.attachPaymentMethod + subscription creation. No decryption happens on our servers and no DPAN ever touches them.
  • Merchant-decrypted: full PKPaymentToken processing for any non-Stripe acquirer, implemented from the spec:

token-processor.ts runs the pipeline signature first — a forged token must die before any ECDH: the detached CMS signature (cms.ts, RFC 5652 SignedData parsed with a from-scratch DER reader) is verified over ephemeralPublicKey ‖ data ‖ transactionId ‖ applicationData?, the message-digest attribute checked against SHA-256 of that content, the ECDSA signature checked over the re-tagged signed attributes, the certificate chain walked leaf → intermediate → pinned root with every link's ECDSA signature and validity window verified (P-256/SHA-256 and P-384/SHA-384 — Apple Root CA - G3 is P-384), the Apple Pay marker OIDs (1.2.840.113635.100.6.29 leaf, …6.2.14 intermediate) required, and the CMS signing-time bounded (default 300 s) against the injected clock. Only then does decrypt.ts run EC_v1 ECIES: merchant-key selection by publicKeyHash (constant-time), ECDH, the NIST SP 800-56A single-round concatenation KDF with Apple's parameters (0x0D‖"id-aes256-GCM", "Apple", merchant-SPKI hash), AES-256-GCM with the spec's 16-zero-byte IV. RSA_v1 (China region) is rejected explicitly, not half-handled.

Fixtures are generated by tools/generate-apple-pay-fixtures.sh: a synthetic Apple-style PKI (P-384 root, marker-OID'd P-256 intermediate/leaf) whose CMS signatures are produced by OpenSSL and whose ciphertext is produced by an independent node:crypto encryptor — the TS verifier/decryptor is tested against second implementations, not against itself. Negative fixtures cover tampered ciphertext/transactionId, rogue pinned roots, a marker-OID-less signer, stale signing time, and wrong merchant keys.

merchant-session.ts handles Apple Pay on the Web merchant validation: the client-supplied validationURL passes an allowlist gate (only apple-pay-gateway*.apple.com hosts, https, no odd ports — an SSRF gate, tested against lookalike domains) before the injected mTLS transport is called. Without the merchant identity certificate the resolver reports configured: false and the BFF fails closed.


Google Pay — src/wallets/google-pay/#

Same two flows: Stripe-tokenized (default; server leg identical to Apple Pay) and DIRECT merchant decryption, implemented to Google's "Payment data cryptography for merchants" spec (ECv2, Tink-compatible) in verify-decrypt.ts:

  1. the intermediate signing key is verified against Google root signing keys (injected JSON, per-key protocolVersion + expiry honored) over the 4-byte-little-endian length-prefixed byte string "Google" ‖ "ECv2" ‖ signedKey, and its own keyExpiration enforced;
  2. the message signature is verified under the intermediate key over "Google" ‖ recipientId ‖ "ECv2" ‖ signedMessage — a token minted for another merchant:<id> fails here;
  3. ECIES: ECDH X-coordinate → HKDF-SHA256 (ikm = ephemeral point ‖ shared secret, info "Google", 64 bytes) → AES-256-CTR (zero IV) + HMAC-SHA256, tag verified constant-time before decryption; merchant-key rotation is supported by trying each configured private key;
  4. messageExpiration enforced against the injected clock.

The round-trip test builds tokens with an independent in-test signer/encryptor over self-generated root/intermediate/merchant keys and covers: rogue roots, expired root/intermediate keys, wrong recipientId, tampered ciphertext, signature-to-bytes binding (JSON key reorder must fail), expired messages, wrong merchant keys, and non-ECv2 versions. The length-prefix encoding is anchored by an absolute byte-level assertion, not just round-trip symmetry.


Entitlement linkage#

Both wallet flows and Stripe Billing settle into the same two junction points the crypto rail uses:

  • applyPaymentSettlementToSubscription (billing-support) — money events drive the subscription state machine through legal transitions only;
  • the PaymentBusEvent topics (payments-bridge entitlement-bus) — the entitlement consumer stays rail-agnostic; rail: 'fiat-stripe' distinguishes provenance.

Tier derivation to the product's three-tier gate remains entitlementTierForSubscription — see the billing-support entitlement linkage.