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 reportsconfigured: falsewithout one), the Apple Pay merchant identity certificate for merchant validation (the session resolver reportsconfigured: 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 emission →
subscription 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
PKPaymentTokenprocessing 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:
- 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 ownkeyExpirationenforced; - the message signature is verified under the intermediate key over
"Google" ‖ recipientId ‖ "ECv2" ‖ signedMessage— a token minted for anothermerchant:<id>fails here; - 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; messageExpirationenforced 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
PaymentBusEventtopics (payments-bridgeentitlement-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.