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

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](./support-billing-and-crypto.md)
(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 }`without`STRIPE_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 `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](./support-billing-and-crypto.md#the-concrete-entitlement-linkage--oshunbilling-support).

## Related

- [Support, Entitlements, Billing, and the Aje Entitlement Bridge](./support-billing-and-crypto.md)
- [Security, Privacy, and Compliance](./security-privacy-compliance.md)
- Hub: [../ARCHITECTURE.md](../ARCHITECTURE.md)
