From 9886e0ea655d9d8da0251f376e4cf7280704ac02 Mon Sep 17 00:00:00 2001 From: Maksym Nebliienko Date: Mon, 21 Sep 2026 17:28:28 +0300 Subject: [PATCH] feat(mpp-seller): add Stripe charge offers --- .changeset/calm-rivers-charge.md | 7 + docs/mpp/README.md | 14 +- docs/mpp/architecture.md | 6 +- packages/mpp-seller/README.md | 57 +- packages/mpp-seller/package.json | 2 +- packages/mpp-seller/src/errors.ts | 31 + packages/mpp-seller/src/index.ts | 6 +- packages/mpp-seller/src/methods.server.ts | 229 ++++++- packages/mpp-seller/test/unit/index.test.ts | 3 + .../test/unit/methods-server.test.ts | 130 ++++ .../test/unit/stripe-method.test.ts | 608 ++++++++++++++++++ 11 files changed, 1073 insertions(+), 20 deletions(-) create mode 100644 .changeset/calm-rivers-charge.md create mode 100644 packages/mpp-seller/test/unit/stripe-method.test.ts diff --git a/.changeset/calm-rivers-charge.md b/.changeset/calm-rivers-charge.md new file mode 100644 index 0000000..b384ba6 --- /dev/null +++ b/.changeset/calm-rivers-charge.md @@ -0,0 +1,7 @@ +--- +'@inflowpayai/mpp-seller': minor +--- + +Add an async Stripe charge method that uses the official mppx wire schema, loads the authenticated seller profile from +InFlow, validates exact USD limits before challenge issuance, binds credential references to seller-provided references, +and delegates validation and settlement to the PSP. Document the Stripe Connect setup required for sellers. diff --git a/docs/mpp/README.md b/docs/mpp/README.md index 2bc669a..f0a7177 100644 --- a/docs/mpp/README.md +++ b/docs/mpp/README.md @@ -19,7 +19,7 @@ for x402's. | `@inflowpayai/mpp-seller` | `Method.toServer` + InFlow redeem/settle driver | Accepting MPP payments as a seller. | | `@inflowpayai/mpp-buyer` | `Method.toClient` + InFlow buyer-endpoint driver | Paying via MPP. | -All packages publish under the `@inflowpayai` scope and declare [`mppx`](https://github.com/wevm/mppx)`@^0.6.28` as a +All packages publish under the `@inflowpayai` scope and declare [`mppx`](https://github.com/wevm/mppx)`@^0.8.17` as a peer. The seller/buyer packages additionally re-export `Mppx` from the appropriate `mppx` entry (`mppx/server` / `mppx/client`) so consumers get a single import. @@ -72,11 +72,13 @@ const tx = await mpp.createTransaction({ challenge }); The seller package (`@inflowpayai/mpp-seller`) attaches non-mutating validation and authoritative broadcast behavior to `Method.toServer`: an unpaid request returns a locally issued `402` challenge, and a paid one is validated and settled -through InFlow. It exports seller methods for `inflow` and `tempo`. To accept **multiple InFlow currencies** on one -route (one challenge per currency), use the package's `inflowCharges` / `inflowChargesNodeListener` helpers over the -core `mppx/server` instance — the framework adapters expose only the single-currency `charge`. See -[architecture.md](./architecture.md) for the PSP boundary, and -[`examples/mpp-seller-express`](../../examples/mpp-seller-express) or +through InFlow. It exports seller methods for `inflow`, `tempo`, and one-time Stripe charges. `await stripe(...)` reads +the authenticated seller's Stripe profile capability from InFlow. Sellers link their Stripe account through Stripe +Connect in the InFlow dashboard; the SDK uses an InFlow API key, and InFlow handles Stripe settlement with its platform +credentials and the seller's connected-account ID. To accept **multiple InFlow currencies** on one route (one challenge +per currency), use the package's `inflowCharges` / `inflowChargesNodeListener` helpers over the core `mppx/server` +instance — the framework adapters expose only the single-currency `charge`. See [architecture.md](./architecture.md) for +the PSP boundary, and [`examples/mpp-seller-express`](../../examples/mpp-seller-express) or [`examples/mpp-seller-hono`](../../examples/mpp-seller-hono) for the complete runnable shape. ## Quickstart — buyer diff --git a/docs/mpp/architecture.md b/docs/mpp/architecture.md index 8ffc168..3931e6a 100644 --- a/docs/mpp/architecture.md +++ b/docs/mpp/architecture.md @@ -15,7 +15,11 @@ During the credential lifecycle it correlates by the server-stamped `transaction problem). `mppx` composes these into its compatibility `verify` hook. This is the direct analog of x402-seller delegating validation/settlement to the InFlow facilitator. For the `inflow` method, a single charge advertises one currency; to offer several, the seller emits one challenge per currency via `compose(...)` — surfaced by the package's - `inflowCharges` helper, the MPP analog of x402-seller's `inflowAccepts`. + `inflowCharges` helper, the MPP analog of x402-seller's `inflowAccepts`. The Stripe seller method follows the same + validate/broadcast boundary but reuses mppx's official `stripe/charge` schema. It initializes asynchronously so the + required business-profile id and payment types come from the authenticated InFlow config, never from route input. + InFlow uses its platform Stripe credentials with the seller's connected-account ID and owns PaymentIntent replay, + recovery, and settlement. The seller application supplies only an InFlow API key. - The **buyer** package's `Method.toClient.createCredential` methods do not sign locally. They forward the parsed challenge to `POST /v1/transactions/mpp`, poll `GET /v1/transactions/{id}/mpp` through the `pending → ready` lifecycle, and return the server-produced credential, re-serialised for the `Authorization: Payment` header. diff --git a/packages/mpp-seller/README.md b/packages/mpp-seller/README.md index 6719374..c3cf440 100644 --- a/packages/mpp-seller/README.md +++ b/packages/mpp-seller/README.md @@ -37,6 +37,10 @@ authoritative replay or authorization guard. - `tempo(parameters)` — the seller `tempo` method for Tempo TIP-20 charges. Pass it to `Mppx.create({ methods: [tempo({ apiKey, currency, recipient })], secretKey })`. Fee-payer sponsorship defaults to off; set `methodDetails.feePayer: true` (on the method or per charge) to mint a sponsored challenge. +- `await stripe(parameters)` — the seller `stripe/charge` method for one-time USD payments with Stripe Shared Payment + Tokens. It loads the authenticated seller's verified Stripe business-profile capability from InFlow before returning a + method. Validation and settlement still use InFlow's `/validate` and `/broadcast` endpoints; no Stripe secret enters + the application or SDK. - `inflowCharges(mppx, prices)` — present several currencies on one route. Returns the Web-fetch handler from `compose(...)`: one `WWW-Authenticate` challenge per price (the MPP analog of `@inflowpayai/x402-seller`'s `inflowAccepts`). See [Multiple currencies](#multiple-currencies) below. @@ -53,10 +57,13 @@ authoritative replay or authorization guard. - `Mppx` and `Expires` (re-exported from `mppx/server`) and `Receipt` (from `mppx`) — a single import gives the foundation server handler and the InFlow methods. - `Discovery` (from `mppx/discovery`) — generates and parses OpenAPI `x-payment-info.offers[]` metadata. -- Types: `InflowSellerParameters`, `TempoSellerParameters`, `LoadedConfig`, `InflowChargePrice`, plus the core - re-exports `Environment`, `MppCurrencyRail`, `MppProblemDetail`, `MppReceipt`. +- Types: `InflowSellerParameters`, `StripeSellerParameters`, `TempoSellerParameters`, `LoadedConfig`, + `InflowChargePrice`, plus the core re-exports `Environment`, `MppCurrencyRail`, `MppProblemDetail`, `MppReceipt`. - Errors: `MppUnsupportedCurrencyError` (charge currency has no rail in the PSP config), `MppCredentialProblemError` - (credential validation or broadcast failed; carries the PSP's RFC 9457 problem). + (credential validation or broadcast failed; carries the PSP's RFC 9457 problem), `MppStripeUnavailableError` (seller + has no safe Stripe capability), and `MppStripeAmountError` (amount is below $0.50, above $999,999.99, or cannot be + expressed as exact cents). `MppStripeRequestError` identifies unsupported metadata or an `externalId` longer than 255 + characters. Malformed request fields can raise the foundation schema's validation error before these SDK checks. ## Configuration @@ -123,6 +130,50 @@ This package ships no middleware of its own; use `mppx`'s framework adapters (`m [`examples/mpp-seller-express`](../../examples/mpp-seller-express) and [`examples/mpp-seller-hono`](../../examples/mpp-seller-hono) for the complete runnable shape. +## Stripe one-time charges + +Connect your Stripe account through the InFlow dashboard and ensure the account has an eligible Stripe business profile +before enabling Stripe payments. Your application needs an InFlow Seller API key, not a Stripe secret key. InFlow uses +its platform credentials and your connected-account ID to process payments on your Stripe account. + +Stripe challenges use the official `mppx@^0.8.17` `stripe/charge` schema. The seller SDK reads your Stripe +business-profile ID (`networkId`) and allowed payment methods from `GET /v1/mpp/config`. For that reason, `stripe(...)` +is asynchronous and fails at initialization if the required Stripe profile is unavailable. + +```ts +import { Mppx, stripe } from '@inflowpayai/mpp-seller'; + +const stripeMethod = await stripe({ + apiKey: process.env.INFLOW_API_KEY!, + environment: 'sandbox', +}); + +const mppx = Mppx.create({ + methods: [stripeMethod], + secretKey: process.env.MPP_SECRET_KEY, +}); + +export async function handler(request: Request) { + const result = await mppx.charge({ amount: '1.00', externalId: 'order-123' })(request); + if (result.status === 402) return result.challenge; + return result.withReceipt(Response.json({ access: 'granted' })); +} +``` + +The method accepts USD amounts from `0.50` through `999999.99`, with no more than two fractional digits. It rejects +values such as `0.49` or `0.501` before issuing a challenge instead of rounding them. The SDK always replaces any +caller-supplied profile id, currency, decimals, or payment-method list with the authenticated server configuration. Only +one-time Stripe charges are supported; this method does not advertise subscriptions or InFlow buyer initiation. + +If you include an `externalId` in the challenge, the buyer's credential must echo it exactly. Credentials with a missing +or different reference are rejected before payment. When the challenge has no `externalId`, a buyer reference is +optional. + +For composed offers, `canOffer` receives the same authoritative request as the issued challenge, with the amount in +integer cents. Metadata allows up to 45 string entries, with keys up to 40 characters and values up to 500 characters; +keys cannot be blank, contain square brackets, or use `externalId`, `inflowMppTransactionId`, `mppChallengeId`, +`mppIntent`, `mppMethod`, or `stripeNetworkProfile`. + ## Multiple currencies `charge(...)` advertises **one** currency per route. Per the MPP core spec, multiple currencies are multiple challenges diff --git a/packages/mpp-seller/package.json b/packages/mpp-seller/package.json index 0c25192..30bce7b 100644 --- a/packages/mpp-seller/package.json +++ b/packages/mpp-seller/package.json @@ -1,7 +1,7 @@ { "name": "@inflowpayai/mpp-seller", "version": "0.8.3", - "description": "InFlow MPP SDK: seller-side `inflow` method built as a native mppx server method — challenges are minted and HMAC-bound locally by mppx, validation is non-mutating, and broadcast delegates settlement to the InFlow PSP.", + "description": "InFlow MPP seller SDK: local mppx challenges with non-mutating validation and authoritative InFlow settlement for InFlow, Tempo, and Stripe payments.", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/packages/mpp-seller/src/errors.ts b/packages/mpp-seller/src/errors.ts index e5f857e..2a4a9f5 100644 --- a/packages/mpp-seller/src/errors.ts +++ b/packages/mpp-seller/src/errors.ts @@ -106,3 +106,34 @@ export class MppUnsupportedRailError extends Error { super(`inflow: rail "${rail}" is not supported for currency "${currency}" and intent "${intent}"`); } } + +/** Thrown when the authenticated seller cannot safely advertise Stripe through the PSP config. */ +export class MppStripeUnavailableError extends Error { + override readonly name = 'MppStripeUnavailableError'; + + constructor() { + super( + 'stripe: this seller has no verified Stripe business profile; connect one in InFlow before offering Stripe payments', + ); + } +} + +/** Thrown before challenge issuance when a Stripe USD amount cannot be represented or accepted exactly. */ +export class MppStripeAmountError extends Error { + override readonly name = 'MppStripeAmountError'; + + /** @param reason - Actionable constraint violated by the amount. */ + constructor(reason: string) { + super(`stripe: ${reason}`); + } +} + +/** Thrown before challenge issuance when Stripe request fields would be rejected by the buyer or PSP. */ +export class MppStripeRequestError extends Error { + override readonly name = 'MppStripeRequestError'; + + /** @param reason - Actionable request constraint violated by the seller. */ + constructor(reason: string) { + super(`stripe: ${reason}`); + } +} diff --git a/packages/mpp-seller/src/index.ts b/packages/mpp-seller/src/index.ts index 0ef0d10..f50ca72 100644 --- a/packages/mpp-seller/src/index.ts +++ b/packages/mpp-seller/src/index.ts @@ -2,7 +2,8 @@ // no `./server` subpath; the foundation `Mppx` server handler is re-exported here so a single import gives both the // handler and the InFlow method: `import { Mppx, inflow } from '@inflowpayai/mpp-seller'`. -export { inflow, tempo } from './methods.server.js'; +export { inflow, stripe, tempo } from './methods.server.js'; +export type { StripeSellerParameters } from './methods.server.js'; export { inflowCharges, @@ -19,6 +20,9 @@ export { MppAmbiguousRailError, MppInstrumentRequiredError, MppCredentialProblemError, + MppStripeAmountError, + MppStripeRequestError, + MppStripeUnavailableError, MppUnsupportedCurrencyError, MppUnsupportedRailError, } from './errors.js'; diff --git a/packages/mpp-seller/src/methods.server.ts b/packages/mpp-seller/src/methods.server.ts index b3503a6..a3f1be3 100644 --- a/packages/mpp-seller/src/methods.server.ts +++ b/packages/mpp-seller/src/methods.server.ts @@ -11,6 +11,7 @@ import { import { UNSAFE_OBJECT_KEYS, sanitizeJsonValue } from '@inflowpayai/mpp-internal'; import type { InflowChargeRequestInput, + Environment, MppChallenge, MppBroadcastRequest, MppCredential, @@ -19,14 +20,18 @@ import type { MppRequestOptions, TempoChargeRequestInput, } from '@inflowpayai/mpp'; -import { Method, Receipt } from 'mppx'; +import { Errors, Method, Receipt, z } from 'mppx'; import type { Credential } from 'mppx'; +import { Methods as StripeMethods } from 'mppx/stripe'; import { createConfigClient } from './config-client.js'; import { MppAmbiguousRailError, MppInstrumentRequiredError, MppCredentialProblemError, + MppStripeAmountError, + MppStripeRequestError, + MppStripeUnavailableError, MppUnsupportedCurrencyError, MppUnsupportedRailError, } from './errors.js'; @@ -57,6 +62,52 @@ interface ResolvedMethodDetails { instrumentId?: string; } +interface StripeConfig { + featureFlags: LoadedConfig['featureFlags']; + networkId: string; + paymentMethodTypes: string[]; +} + +type StripeRequest = z.input; + +type StripeDefaults = { + currency: 'usd'; + decimals: 2; + networkId: string; + paymentMethodTypes: string[]; +}; + +/** Constructor parameters for the seller-side Stripe charge method. */ +export interface StripeSellerParameters { + /** InFlow API key. The seller connects their Stripe account through the InFlow dashboard. */ + apiKey: string; + /** Selects one of the public environments. Defaults to `'production'`. */ + environment?: Environment; + /** Override the environment-derived API base URL. Takes precedence over `environment`. */ + baseUrl?: string; + /** Per-request timeout (milliseconds) for config and credential lifecycle calls. */ + timeoutMs?: number; + /** Optional `fetch` implementation. Defaults to `globalThis.fetch`. Must conform to the WHATWG fetch API. */ + fetch?: typeof fetch; + /** Decides whether the configured Stripe offer is available for a composed HTTP request. */ + canOffer?: Method.CanOfferFn; +} + +const STRIPE_MAX_MINOR_UNITS = 99_999_999n; +const STRIPE_MAX_METADATA_ENTRIES = 45; +const STRIPE_MAX_METADATA_KEY_LENGTH = 40; +const STRIPE_MAX_METADATA_VALUE_LENGTH = 500; +const STRIPE_MIN_MINOR_UNITS = 50n; +const STRIPE_RESERVED_METADATA: ReadonlySet = new Set([ + 'externalId', + 'inflowMppTransactionId', + 'mppChallengeId', + 'mppIntent', + 'mppMethod', + 'stripeNetworkProfile', +]); +const STRIPE_USD_AMOUNT = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/; + /** * The seller-side `inflow` method, built as a **native mppx server method**. `Mppx.create({ methods: [inflow(...)], * secretKey }).charge({ amount })` mints and HMAC-binds the `WWW-Authenticate: Payment` challenge **locally** with the @@ -303,6 +354,95 @@ export function tempo( }); } +/** + * Build the seller-side Stripe one-time charge method backed by InFlow. The factory is asynchronous because the + * authenticated `/v1/mpp/config` response is the authority for the seller's Stripe business-profile id and allowed + * payment methods. Stripe credentials and settlement stay on InFlow; this method only mints the HMAC-bound challenge + * locally and delegates non-mutating validation and terminal broadcast to the PSP. + * + * @param parameters - InFlow authentication, environment, and optional offer policy. + * @returns The configured Stripe server method to pass into `Mppx.create`. + * @throws {@link MppStripeUnavailableError} When this seller has no verified Stripe profile capability. + */ +export async function stripe(parameters: StripeSellerParameters) { + const client = new MppClient({ + apiKey: parameters.apiKey, + ...(parameters.environment !== undefined ? { environment: parameters.environment } : {}), + ...(parameters.baseUrl !== undefined ? { baseUrl: parameters.baseUrl } : {}), + ...(parameters.timeoutMs !== undefined ? { timeoutMs: parameters.timeoutMs } : {}), + ...(parameters.fetch !== undefined ? { fetch: parameters.fetch } : {}), + }); + const loaded = resolveStripeConfig(await client.getConfig()); + const defaults: StripeDefaults = { + currency: 'usd', + decimals: 2, + networkId: loaded.networkId, + paymentMethodTypes: [...loaded.paymentMethodTypes], + }; + + const method = Method.from({ + ...StripeMethods.charge, + schema: { + ...StripeMethods.charge.schema, + // mppx parses offer-policy requests before running the request hook. + request: z.pipe( + z.transform((request: StripeRequest): StripeRequest => ({ + ...request, + ...defaults, + paymentMethodTypes: [...defaults.paymentMethodTypes], + })), + StripeMethods.charge.schema.request, + ), + }, + }); + const canOffer = parameters.canOffer; + + return Method.toServer(method, { + canOffer: canOffer === undefined ? undefined : (context) => canOffer(context), + defaults, + + request({ request }) { + assertStripeAmount(request.amount); + assertStripeRequest(request); + return request; + }, + + stableBinding(request) { + return { + amount: request.amount, + currency: request.currency, + externalId: request.externalId, + methodDetails: request.methodDetails, + recipient: request.recipient, + }; + }, + + async broadcast({ credential }) { + return broadcast(credential, client, loaded); + }, + + async validate({ credential }) { + const externalId = credential.challenge.request.externalId; + if (externalId !== undefined && credential.payload.externalId !== externalId) { + throw new Errors.InvalidChallengeError({ + id: credential.challenge.id, + reason: 'credential externalId does not match the challenge reference', + }); + } + const details = await validateCredential(credential, client); + return { + challenge: credential.challenge, + credential, + details, + intent: StripeMethods.charge.intent, + method: StripeMethods.charge.name, + request: credential.challenge.request, + ...(credential.source !== undefined ? { source: credential.source } : {}), + }; + }, + }); +} + /** * Build the mppx request `defaults` from the seller parameters — only the keys the seller actually pinned, so unset * fields remain caller-supplied. @@ -394,6 +534,81 @@ function deriveTempoMethodDetails( }; } +function resolveStripeConfig(config: Awaited>): StripeConfig { + const method = config.supportedMethods.find((entry) => entry.id === 'stripe'); + const methodDetails: Record | undefined = method?.methodDetails; + const networkId = methodDetails?.['networkId']; + const paymentMethodTypes = methodDetails?.['paymentMethodTypes']; + if ( + method === undefined || + !method.supportedCurrencies.includes('USD') || + !method.supportedIntents.includes('charge') || + typeof networkId !== 'string' || + networkId.trim().length === 0 || + !isNonEmptyStringArray(paymentMethodTypes) + ) { + throw new MppStripeUnavailableError(); + } + return { + featureFlags: config.featureFlags, + networkId, + paymentMethodTypes: [...paymentMethodTypes], + }; +} + +function isNonEmptyStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((entry) => typeof entry === 'string' && entry.trim().length > 0) + ); +} + +function assertStripeAmount(amount: string): void { + if (!STRIPE_USD_AMOUNT.test(amount)) { + throw new MppStripeAmountError('USD amount must be a decimal with at most two fractional digits'); + } + const decimalIndex = amount.indexOf('.'); + const whole = decimalIndex === -1 ? amount : amount.slice(0, decimalIndex); + const fraction = decimalIndex === -1 ? '' : amount.slice(decimalIndex + 1); + const minorUnits = BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')); + if (minorUnits < STRIPE_MIN_MINOR_UNITS) { + throw new MppStripeAmountError('USD amount must be at least 0.50'); + } + if (minorUnits > STRIPE_MAX_MINOR_UNITS) { + throw new MppStripeAmountError('USD amount must not exceed 999999.99'); + } +} + +function assertStripeRequest(request: { + externalId?: string | undefined; + metadata?: Record | undefined; +}): void { + if (request.externalId !== undefined && request.externalId.length > 255) { + throw new MppStripeRequestError('externalId must be at most 255 characters'); + } + const metadata = request.metadata; + if (metadata === undefined) return; + const entries = Object.entries(metadata); + if (entries.length > STRIPE_MAX_METADATA_ENTRIES) { + throw new MppStripeRequestError('metadata may contain at most 45 entries'); + } + for (const [key, value] of entries) { + if ( + key.trim().length === 0 || + key.length > STRIPE_MAX_METADATA_KEY_LENGTH || + key.includes('[') || + key.includes(']') || + STRIPE_RESERVED_METADATA.has(key) + ) { + throw new MppStripeRequestError(`metadata key "${key}" is invalid or reserved`); + } + if (typeof value !== 'string' || value.length > STRIPE_MAX_METADATA_VALUE_LENGTH) { + throw new MppStripeRequestError(`metadata value for "${key}" must be a string of at most 500 characters`); + } + } +} + /** Ask the PSP whether a credential is currently acceptable without consuming payment state. */ async function validateCredential( credential: Credential.Credential>, @@ -429,7 +644,7 @@ async function validateCredential( async function broadcast( credential: Credential.Credential>, client: MppClient, - loaded: LoadedConfig, + loaded: Pick, ): Promise { const wireCredential = toWireCredential(credential); const body: MppBroadcastRequest = { credential: wireCredential }; @@ -505,12 +720,10 @@ function toReceiptExtensions(receipt: MppReceipt): Record { const budget = { entries: MAX_RECEIPT_EXTENSION_ENTRIES }; for (const [key, value] of Object.entries(receipt)) { if (RECEIPT_EXTENSION_KEYS.has(key) || UNSAFE_OBJECT_KEYS.has(key)) continue; - if (value !== undefined) { - const sanitized = sanitizeSellerReceiptExtensionValue(value, 0, budget); - if (sanitized !== undefined) { - const candidate = { ...out, [key]: sanitized }; - if (JSON.stringify(candidate).length <= MAX_RECEIPT_EXTENSIONS_LENGTH) out[key] = sanitized; - } + const sanitized = sanitizeSellerReceiptExtensionValue(value, 0, budget); + if (sanitized !== undefined) { + const candidate = { ...out, [key]: sanitized }; + if (JSON.stringify(candidate).length <= MAX_RECEIPT_EXTENSIONS_LENGTH) out[key] = sanitized; } } return out; diff --git a/packages/mpp-seller/test/unit/index.test.ts b/packages/mpp-seller/test/unit/index.test.ts index c417e27..0aa7db1 100644 --- a/packages/mpp-seller/test/unit/index.test.ts +++ b/packages/mpp-seller/test/unit/index.test.ts @@ -7,8 +7,11 @@ describe('public barrel', () => { expect(typeof pkg.inflow).toBe('function'); expect(typeof pkg.inflowCharges).toBe('function'); expect(typeof pkg.inflowChargesNodeListener).toBe('function'); + expect(typeof pkg.stripe).toBe('function'); expect(typeof pkg.createConfigClient).toBe('function'); expect(typeof pkg.MppCredentialProblemError).toBe('function'); + expect(typeof pkg.MppStripeAmountError).toBe('function'); + expect(typeof pkg.MppStripeUnavailableError).toBe('function'); expect(typeof pkg.MppUnsupportedCurrencyError).toBe('function'); // Foundation re-exports: a single import gives both the server handler and receipt helpers. expect(typeof pkg.Mppx.create).toBe('function'); diff --git a/packages/mpp-seller/test/unit/methods-server.test.ts b/packages/mpp-seller/test/unit/methods-server.test.ts index 6aa80f1..8b84777 100644 --- a/packages/mpp-seller/test/unit/methods-server.test.ts +++ b/packages/mpp-seller/test/unit/methods-server.test.ts @@ -178,6 +178,93 @@ function decodeChallengeRequest(response: Response): Record { } describe('native issuance: currency → rail in the minted 402', () => { + it.each(['inflow', 'subscription', 'tempo'] as const)( + 'uses the default API origin for %s without a base URL override', + async (kind) => { + const origin = kind === 'subscription' ? 'https://sandbox.inflowpay.ai' : 'https://api.inflowpay.ai'; + let hits = 0; + server.use( + http.get(`${origin}/v1/mpp/config`, ({ request }) => { + hits += 1; + expect(request.headers.get('X-API-Key')).toBe('sk_test'); + return HttpResponse.json(config()); + }), + ); + const customFetch = vi.fn((input, init) => globalThis.fetch(input, init)); + const method = + kind === 'subscription' + ? inflow.subscription({ apiKey: 'sk_test', environment: 'sandbox', timeoutMs: 5000, fetch: customFetch }) + : kind === 'tempo' + ? tempo({ apiKey: 'sk_test', currency: TEMPO_ASSET, recipient: TEMPO_RECIPIENT }) + : inflow({ apiKey: 'sk_test' }); + const mppx = Mppx.create({ methods: [method], secretKey: SECRET, realm: REALM }); + const challenge = + kind === 'subscription' + ? await mppx.challenge.inflow.subscription(SUB) + : kind === 'tempo' + ? await mppx.challenge.tempo.charge({ amount: '1' }) + : await mppx.challenge.inflow.charge({ amount: '1', currency: 'USDC' }); + expect(challenge.request).toMatchObject({ + amount: kind === 'subscription' ? SUB.amount : '1', + recipient: kind === 'tempo' ? TEMPO_RECIPIENT : SELLER, + }); + if (method.name === 'tempo') { + server.use( + http.post(`${origin}/v1/mpp/broadcast`, () => + HttpResponse.json({ + receipt: { + method: 'tempo', + reference: 'ref-origin', + status: 'success', + timestamp: '2026-05-31T00:00:00Z', + }, + }), + ), + ); + if (method.broadcast === undefined) throw new Error('expected broadcast hook'); + // The challenge helper returns a broader type than the concrete Tempo request. + const receipt = await method.broadcast({ + credential: { challenge, payload: { type: 'transaction', signature: '0x76deadbeef' } }, + request: challenge.request, + } as TempoValidateArg); + expect(receipt).toMatchObject({ status: 'success', reference: 'ref-origin' }); + } + expect(hits).toBe(1); + if (kind === 'subscription') expect(customFetch).toHaveBeenCalledOnce(); + }, + ); + + it('applies Tempo defaults when its request hook receives only an amount', async () => { + mockConfig(); + const method = tempo({ apiKey: 'sk_test', baseUrl: BASE, currency: TEMPO_ASSET, recipient: TEMPO_RECIPIENT }); + if (method.request === undefined) throw new Error('expected request hook'); + expect(await method.request({ request: { amount: '10' } })).toEqual({ + amount: '10', + currency: TEMPO_ASSET, + recipient: TEMPO_RECIPIENT, + methodDetails: { feePayer: false, supportedModes: ['pull'] }, + }); + }); + + it('rejects an advertised rail that the InFlow method cannot implement', async () => { + mockConfig( + config({ + supportedMethods: [ + { + id: 'inflow', + label: 'InFlow', + supportedCurrencies: ['USDC'], + supportedIntents: ['charge'], + methodDetails: { currencyRails: { USDC: { rail: 'blockchain' } } }, + }, + ], + }), + ); + const { mppx } = makeMppx(); + await expect(mppx.challenge.inflow.charge({ amount: '1', currency: 'USDC' })).rejects.toThrow( + new MppUnsupportedRailError('USDC', 'charge', 'unknown'), + ); + }); it('mints a single balance-rail challenge for a crypto currency (USDC)', async () => { mockConfig(); const { mppx } = makeMppx(); @@ -584,6 +671,36 @@ describe('stableBinding', () => { }); describe('credential lifecycle', () => { + it('limits the combined receipt extensions while retaining later small fields', async () => { + mockConfig(); + mockValidateSuccess(); + mockBroadcastSuccess('inflow', { + challengeId: undefined, + first: 'a'.repeat(8000), + second: 'b'.repeat(8000), + oversized: 'c'.repeat(1000), + last: 'retained', + }); + const { mppx } = makeMppx(); + const challenge = await mppx.challenge.inflow.charge({ amount: '10', currency: 'USDC' }); + const authorization = Credential.serialize({ + challenge, + payload: { transactionId: 'tx-limited', type: 'balance' }, + }); + const result = await mppx.charge({ amount: '10', currency: 'USDC' })( + new Request('https://app.test/r', { + headers: { Authorization: authorization }, + }), + ); + expect(result.status).toBe(200); + if (result.status !== 200) throw new Error('expected 200'); + const header = result.withReceipt(new Response('ok')).headers.get('Payment-Receipt'); + if (header === null) throw new Error('expected receipt'); + const receipt = decodeReceipt(header); + expect(receipt).toMatchObject({ first: 'a'.repeat(8000), second: 'b'.repeat(8000), last: 'retained' }); + expect(receipt).not.toHaveProperty('oversized'); + expect(receipt).not.toHaveProperty('challengeId'); + }); it('recovers Tempo broadcast after a failed configuration warmup', async () => { let configHits = 0; const fetchConfig: typeof fetch = async (input, init) => { @@ -1253,6 +1370,19 @@ describe('inflow subscription: issuance, binding, verify', () => { }); }); + it('includes the instrument in subscription binding when supplied', () => { + mockConfig(); + const { method } = makeSubMppx(); + if (method.stableBinding === undefined) throw new Error('expected stable binding hook'); + expect( + method.stableBinding({ + ...SUB, + recipient: SELLER, + methodDetails: { rail: 'instrument', instrumentId: INSTRUMENT }, + }), + ).toEqual({ ...SUB, recipient: SELLER, rail: 'instrument', instrumentId: INSTRUMENT }); + }); + it('verify reflects the receipt and preserves the server-issued subscriptionId without masking authorization replay', async () => { mockConfig(); mockValidateSuccess(); diff --git a/packages/mpp-seller/test/unit/stripe-method.test.ts b/packages/mpp-seller/test/unit/stripe-method.test.ts new file mode 100644 index 0000000..1ab2ad1 --- /dev/null +++ b/packages/mpp-seller/test/unit/stripe-method.test.ts @@ -0,0 +1,608 @@ +import { decode, decodeReceipt, encode, parseChallengeHeader } from '@inflowpayai/mpp'; +import type { MppConfigResponse } from '@inflowpayai/mpp'; +import { Credential, Receipt } from 'mppx'; +import { Mppx } from 'mppx/server'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { MppStripeAmountError, MppStripeRequestError, MppStripeUnavailableError } from '../../src/errors.js'; +import { stripe } from '../../src/methods.server.js'; +import type { StripeSellerParameters } from '../../src/methods.server.js'; + +const BASE = 'https://mpp.test'; +const NETWORK_ID = 'profile_test_seller'; +const SECRET = 'seller-binding-secret-at-least-32-bytes'; +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +function config(overrides: Partial = {}): MppConfigResponse { + return { + sellerId: '22222222-2222-2222-2222-222222222222', + featureFlags: { idempotencyKeyEnabled: true }, + replayPolicy: { managedBy: 'psp' }, + supportedMethods: [ + { + id: 'stripe', + label: 'Stripe', + methodDetails: { networkId: NETWORK_ID, paymentMethodTypes: ['card', 'link'] }, + supportedCurrencies: ['USD'], + supportedIntents: ['charge'], + }, + ], + ...overrides, + }; +} + +function mockConfig(body: MppConfigResponse = config(), onHit?: () => void): void { + server.use( + http.get(`${BASE}/v1/mpp/config`, () => { + onHit?.(); + return HttpResponse.json(body); + }), + ); +} + +async function makeMppx() { + const method = await stripe({ apiKey: 'sk_test', baseUrl: BASE }); + return { method, mppx: Mppx.create({ methods: [method], realm: 'app.test', secretKey: SECRET }) }; +} + +function requestFrom(response: Response): Record { + const header = response.headers.get('WWW-Authenticate'); + if (header === null) throw new Error('expected WWW-Authenticate header'); + return decode(parseChallengeHeader(header).request); +} + +function mockLifecycle(): { + broadcastBody(): unknown; + idempotencyKey(): string | null; + order: string[]; +} { + let body: unknown; + let idempotencyKey: string | null = null; + const order: string[] = []; + server.use( + http.post(`${BASE}/v1/mpp/validate`, async ({ request }) => { + order.push('validate'); + const submitted = (await request.json()) as { + credential: { + challenge: { intent: string; method: string; request: string }; + payload: unknown; + source: string; + }; + }; + return HttpResponse.json({ + challenge: submitted.credential.challenge, + credential: submitted.credential, + details: { provider: 'stripe' }, + intent: submitted.credential.challenge.intent, + method: submitted.credential.challenge.method, + request: decode>(submitted.credential.challenge.request), + source: submitted.credential.source, + success: true, + }); + }), + http.post(`${BASE}/v1/mpp/broadcast`, async ({ request }) => { + order.push('broadcast'); + body = await request.json(); + idempotencyKey = request.headers.get('Idempotency-Key'); + return HttpResponse.json({ + receipt: { + challengeId: 'stripe-challenge', + method: 'stripe', + reference: 'pi_test_123', + settlement: { amount: '1.00', currency: 'USD' }, + status: 'success', + timestamp: '2026-09-20T00:00:00Z', + }, + }); + }), + ); + return { broadcastBody: () => body, idempotencyKey: () => idempotencyKey, order }; +} + +describe('Stripe seller method', () => { + it.each([ + [undefined, 'https://api.inflowpay.ai'], + ['sandbox', 'https://sandbox.inflowpay.ai'], + ] as const)( + 'loads the authenticated config for environment %s without a base URL override', + async (environment, origin) => { + const requests: Request[] = []; + server.use( + http.get(`${origin}/v1/mpp/config`, ({ request }) => { + requests.push(request); + return HttpResponse.json(config()); + }), + ); + + const method = await stripe({ + apiKey: 'inflow-seller-key', + ...(environment !== undefined ? { environment } : {}), + }); + const mppx = Mppx.create({ methods: [method], realm: 'app.test', secretKey: SECRET }); + const challenge = await mppx.challenge.stripe.charge({ amount: '1' }); + + expect(requests).toHaveLength(1); + expect(requests[0]?.headers.get('X-API-KEY')).toBe('inflow-seller-key'); + expect(challenge.request).toMatchObject({ methodDetails: { networkId: NETWORK_ID } }); + }, + ); + + it('uses the supplied fetch and base URL instead of the environment URL', async () => { + mockConfig(); + const fetch = vi.fn((input, init) => globalThis.fetch(input, init)); + const method = await stripe({ + apiKey: 'inflow-seller-key', + baseUrl: BASE, + environment: 'sandbox', + fetch, + timeoutMs: 5_000, + }); + const mppx = Mppx.create({ methods: [method], realm: 'app.test', secretKey: SECRET }); + + await mppx.challenge.stripe.charge({ amount: '1' }); + + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0]?.[0]).toBe(`${BASE}/v1/mpp/config`); + expect(fetch.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal); + }); + + it('aborts config requests at the supplied timeout and reports the timeout after bounded retries', async () => { + const signals: AbortSignal[] = []; + const fetch = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal === undefined || signal === null) { + reject(new Error('expected request abort signal')); + return; + } + signals.push(signal); + signal.addEventListener('abort', () => reject(new Error('aborted', { cause: signal.reason })), { + once: true, + }); + }), + ); + + await expect(stripe({ apiKey: 'inflow-seller-key', baseUrl: BASE, fetch, timeoutMs: 1 })).rejects.toMatchObject({ + code: 'TIMEOUT', + }); + + expect(fetch).toHaveBeenCalledTimes(4); + expect(signals.every((signal) => signal.aborted)).toBe(true); + }); + + it('uses the server-authoritative profile with the official mppx Stripe charge schema', async () => { + let configHits = 0; + mockConfig(config(), () => (configHits += 1)); + const { mppx } = await makeMppx(); + const result = await mppx.charge({ + amount: '1.25', + currency: 'eur', + decimals: 3, + networkId: 'caller-profile', + paymentMethodTypes: ['caller-method'], + description: 'Widget', + externalId: 'order-123', + metadata: { campaign: 'agents' }, + })(new Request('https://app.test/widgets')); + + expect(result.status).toBe(402); + if (result.status !== 402) throw new Error('expected 402'); + expect(requestFrom(result.challenge)).toEqual({ + amount: '125', + currency: 'usd', + externalId: 'order-123', + methodDetails: { + metadata: { campaign: 'agents' }, + networkId: NETWORK_ID, + paymentMethodTypes: ['card', 'link'], + }, + }); + expect(configHits).toBe(1); + }); + + it('fails at initialization when Stripe is not safely advertised for the seller', async () => { + mockConfig(config({ supportedMethods: [] })); + await expect(stripe({ apiKey: 'sk_test', baseUrl: BASE })).rejects.toBeInstanceOf(MppStripeUnavailableError); + }); + + it.each([ + [undefined, 'missing method details'], + [{ paymentMethodTypes: ['card'] }, 'missing network id'], + [{ networkId: 123, paymentMethodTypes: ['card'] }, 'non-string network id'], + [{ networkId: '', paymentMethodTypes: ['card'] }, 'blank network id'], + [{ networkId: ' ', paymentMethodTypes: ['card'] }, 'whitespace network id'], + [{ networkId: NETWORK_ID }, 'missing payment method list'], + [{ networkId: NETWORK_ID, paymentMethodTypes: 'card' }, 'non-array payment method list'], + [{ networkId: NETWORK_ID, paymentMethodTypes: [] }, 'empty payment method list'], + [{ networkId: NETWORK_ID, paymentMethodTypes: [' '] }, 'blank payment method'], + [{ networkId: NETWORK_ID, paymentMethodTypes: ['card', 1] }, 'non-string payment method'], + ])('fails closed for malformed Stripe capability details: %s', async (methodDetails, _description) => { + mockConfig( + config({ + supportedMethods: [ + { + id: 'stripe', + label: 'Stripe', + ...(methodDetails !== undefined ? { methodDetails } : {}), + supportedCurrencies: ['USD'], + supportedIntents: ['charge'], + }, + ], + }), + ); + await expect(stripe({ apiKey: 'sk_test', baseUrl: BASE })).rejects.toBeInstanceOf(MppStripeUnavailableError); + }); + + it.each([ + [['EUR'], ['charge'], 'missing USD capability'], + [['USD'], ['subscription'], 'missing charge capability'], + ])('fails closed for %s / %s: %s', async (supportedCurrencies, supportedIntents, _description) => { + mockConfig( + config({ + supportedMethods: [ + { + id: 'stripe', + label: 'Stripe', + methodDetails: { networkId: NETWORK_ID, paymentMethodTypes: ['card'] }, + supportedCurrencies, + supportedIntents, + }, + ], + }), + ); + await expect(stripe({ apiKey: 'sk_test', baseUrl: BASE })).rejects.toBeInstanceOf(MppStripeUnavailableError); + }); + + it.each([ + ['0', 'USD amount must be at least 0.50'], + ['0.49', 'USD amount must be at least 0.50'], + ['0.501', 'USD amount must be a decimal with at most two fractional digits'], + ['1000000', 'USD amount must not exceed 999999.99'], + ['00.50', 'USD amount must be a decimal with at most two fractional digits'], + ])('rejects the non-conforming USD amount %s with an actionable error', async (amount, reason) => { + mockConfig(); + const { mppx } = await makeMppx(); + await expect(mppx.charge({ amount })(new Request('https://app.test/widgets'))).rejects.toThrow( + new MppStripeAmountError(reason), + ); + }); + + it.each([ + ['0.50', '50'], + ['0.5', '50'], + ['1', '100'], + ['999999.99', '99999999'], + ])('accepts USD amount %s as exactly %s cents', async (amount, cents) => { + mockConfig(); + const { mppx } = await makeMppx(); + const result = await mppx.charge({ amount })(new Request('https://app.test/widgets')); + expect(result.status).toBe(402); + if (result.status !== 402) throw new Error('expected 402'); + expect(requestFrom(result.challenge)['amount']).toBe(cents); + }); + + it('rejects malformed amount syntax through the foundation schema', async () => { + mockConfig(); + const { mppx } = await makeMppx(); + expect(() => mppx.charge({ amount: '-1' })).toThrow('Invalid amount'); + }); + + it.each([ + [{ externalId: 'x'.repeat(256) }, 'externalId must be at most 255 characters'], + [{ metadata: { externalId: 'order-123' } }, 'metadata key "externalId" is invalid or reserved'], + [ + { metadata: Object.fromEntries(Array.from({ length: 46 }, (_value, index) => [`key${index}`, 'value'])) }, + 'metadata may contain at most 45 entries', + ], + [{ metadata: { 'bad[key]': 'value' } }, 'metadata key "bad[key]" is invalid or reserved'], + [{ metadata: { 'bad]key': 'value' } }, 'metadata key "bad]key" is invalid or reserved'], + [{ metadata: { ' ': 'value' } }, 'metadata key " " is invalid or reserved'], + [{ metadata: { ['k'.repeat(41)]: 'value' } }, `metadata key "${'k'.repeat(41)}" is invalid or reserved`], + [ + { metadata: { campaign: 'x'.repeat(501) } }, + 'metadata value for "campaign" must be a string of at most 500 characters', + ], + ])('rejects Stripe request fields that the buyer or PSP cannot accept: %s', async (request, reason) => { + mockConfig(); + const { mppx } = await makeMppx(); + await expect(mppx.charge({ amount: '1.00', ...request })(new Request('https://app.test/widgets'))).rejects.toThrow( + new MppStripeRequestError(reason), + ); + }); + + it.each(['inflowMppTransactionId', 'mppChallengeId', 'mppIntent', 'mppMethod', 'stripeNetworkProfile'])( + 'rejects reserved metadata key %s', + async (key) => { + mockConfig(); + const { mppx } = await makeMppx(); + + await expect( + mppx.charge({ amount: '1.00', metadata: { [key]: 'value' } })(new Request('https://app.test/widgets')), + ).rejects.toThrow(new MppStripeRequestError(`metadata key "${key}" is invalid or reserved`)); + }, + ); + + it('preserves metadata and the seller reference at their exact length and count limits', async () => { + mockConfig(); + const { mppx } = await makeMppx(); + const metadata = { + ...Object.fromEntries(Array.from({ length: 44 }, (_value, index) => [`key${index}`, 'value'])), + ['k'.repeat(40)]: 'v'.repeat(500), + }; + const externalId = 'r'.repeat(255); + const result = await mppx.charge({ amount: '1', externalId, metadata })(new Request('https://app.test/widgets')); + + expect(result.status).toBe(402); + if (result.status !== 402) throw new Error('expected 402'); + expect(requestFrom(result.challenge)).toMatchObject({ externalId, methodDetails: { metadata } }); + }); + + it('binds provider destination, payment types, metadata, and seller reconciliation fields', async () => { + mockConfig(); + const { method } = await makeMppx(); + expect( + method.stableBinding?.({ + amount: '100', + currency: 'usd', + externalId: 'order-123', + methodDetails: { + metadata: { campaign: 'agents' }, + networkId: NETWORK_ID, + paymentMethodTypes: ['card', 'link'], + }, + }), + ).toEqual({ + amount: '100', + currency: 'usd', + externalId: 'order-123', + methodDetails: { + metadata: { campaign: 'agents' }, + networkId: NETWORK_ID, + paymentMethodTypes: ['card', 'link'], + }, + recipient: undefined, + }); + }); + + it('validates before broadcast and returns the authoritative Stripe receipt', async () => { + mockConfig(); + const lifecycle = mockLifecycle(); + const { mppx } = await makeMppx(); + const challenge = await mppx.challenge.stripe.charge({ amount: '1.00', externalId: 'order-123' }); + const authorization = Credential.serialize({ + challenge, + payload: { externalId: 'order-123', spt: 'spt_test_123' }, + }); + const result = await mppx.charge({ amount: '1.00', externalId: 'order-123' })( + new Request('https://app.test/widgets', { headers: { Authorization: authorization } }), + ); + + expect(result.status).toBe(200); + if (result.status !== 200) throw new Error('expected 200'); + expect(lifecycle.order).toEqual(['validate', 'broadcast']); + expect(lifecycle.idempotencyKey()).toMatch(/^[0-9a-f-]{36}$/); + expect(lifecycle.broadcastBody()).toMatchObject({ + credential: { + challenge: { intent: 'charge', method: 'stripe' }, + payload: { externalId: 'order-123', spt: 'spt_test_123' }, + source: '', + }, + }); + + const response = result.withReceipt(new Response('ok')); + const receipt = Receipt.fromResponse(response); + expect(receipt).toMatchObject({ method: 'stripe', reference: 'pi_test_123', status: 'success' }); + const header = response.headers.get('Payment-Receipt'); + if (header === null) throw new Error('expected Payment-Receipt header'); + expect(decodeReceipt(header)).toMatchObject({ + method: 'stripe', + reference: 'pi_test_123', + settlement: { amount: '1.00', currency: 'USD' }, + }); + }); + + it.each([undefined, '', 'different-order'])( + 'rejects credential reference %s when the seller specifies a reference', + async (externalId) => { + mockConfig(); + const lifecycle = mockLifecycle(); + const { mppx } = await makeMppx(); + const options = { amount: '1.00', externalId: 'order-123' }; + const challenge = await mppx.challenge.stripe.charge(options); + const authorization = Credential.serialize({ + challenge, + payload: { spt: 'spt_test_123', ...(externalId !== undefined ? { externalId } : {}) }, + }); + + const result = await mppx.charge(options)( + new Request('https://app.test/widgets', { headers: { Authorization: authorization } }), + ); + + expect(result.status).toBe(402); + if (result.status !== 402) throw new Error('expected 402'); + expect(await result.challenge.json()).toMatchObject({ + type: 'https://paymentauth.org/problems/invalid-challenge', + }); + expect(result.challenge.headers.get('Payment-Receipt')).toBeNull(); + expect(lifecycle.order).toEqual([]); + }, + ); + + it('retains the credential source in standalone validation without broadcasting a payment', async () => { + mockConfig(); + const lifecycle = mockLifecycle(); + const { mppx } = await makeMppx(); + const challenge = await mppx.challenge.stripe.charge({ amount: '1.00' }); + const credential = { challenge, payload: { spt: 'spt_test_123' }, source: 'did:example:buyer' }; + + const result = await mppx.validateCredential(credential); + + expect(result).toMatchObject({ credential, details: { provider: 'stripe' }, source: credential.source }); + expect(lifecycle.order).toEqual(['validate']); + }); + + it.each([ + { amount: '2.00', externalId: 'order-123' }, + { amount: '1.00', externalId: 'different-order' }, + { amount: '1.00', externalId: 'order-123', metadata: { purpose: 'different' } }, + ])('rejects a credential issued for different route terms: %s', async (options) => { + mockConfig(); + const lifecycle = mockLifecycle(); + const { mppx } = await makeMppx(); + const challenge = await mppx.challenge.stripe.charge({ amount: '1.00', externalId: 'order-123' }); + const authorization = Credential.serialize({ + challenge, + payload: { spt: 'spt_test_123', externalId: 'order-123' }, + }); + + const result = await mppx.charge(options)( + new Request('https://app.test/widgets', { headers: { Authorization: authorization } }), + ); + + expect(result.status).toBe(402); + if (result.status !== 402) throw new Error('expected 402'); + expect(result.challenge.headers.get('Payment-Receipt')).toBeNull(); + expect(lifecycle.order).toEqual([]); + }); + + it.each([ + ['validate', 402, 'verification-failed', 'Verification Failed'], + ['broadcast', 402, 'verification-failed', 'Verification Failed'], + ['broadcast', 503, 'settlement-unavailable', 'Settlement Pending'], + ] as const)( + 'preserves the %s payment failure with HTTP %s and no receipt', + async (operation, status, type, title) => { + mockConfig(); + const lifecycle = mockLifecycle(); + const problem = { + type: `https://paymentauth.org/problems/${type}`, + title, + status, + detail: 'Stripe payment outcome.', + }; + server.use( + http.post(`${BASE}/v1/mpp/${operation}`, () => { + lifecycle.order.push(operation); + return HttpResponse.json({ problem, ...(operation === 'validate' ? { success: false } : {}) }); + }), + ); + const { mppx } = await makeMppx(); + const challenge = await mppx.challenge.stripe.charge({ amount: '1.00' }); + const authorization = Credential.serialize({ challenge, payload: { spt: 'spt_test_123' } }); + + const result = await mppx.charge({ amount: '1.00' })( + new Request('https://app.test/widgets', { headers: { Authorization: authorization } }), + ); + + expect(result.status).toBe(402); + if (result.status !== 402) throw new Error('expected challenge response'); + expect(result.challenge.status).toBe(status); + expect(await result.challenge.json()).toMatchObject(problem); + expect(result.challenge.headers.get('Payment-Receipt')).toBeNull(); + expect(lifecycle.order).toEqual(operation === 'validate' ? ['validate'] : ['validate', 'broadcast']); + }, + ); + + it.each(['validateCredential', 'broadcastCredential'] as const)( + 'rejects mismatched references through standalone %s', + async (operation) => { + mockConfig(); + const lifecycle = mockLifecycle(); + const { mppx } = await makeMppx(); + const challenge = await mppx.challenge.stripe.charge({ amount: '1.00', externalId: 'order-123' }); + + await expect( + mppx[operation]({ challenge, payload: { spt: 'spt_test_123', externalId: 'different-order' } }), + ).rejects.toThrow('credential externalId does not match the challenge reference'); + expect(lifecycle.order).toEqual([]); + }, + ); + + it.each([ + [undefined, undefined], + [undefined, 'buyer-reference'], + ['', ''], + ])('accepts challenge reference %s and credential reference %s', async (sellerReference, buyerReference) => { + mockConfig(); + const lifecycle = mockLifecycle(); + const { mppx } = await makeMppx(); + const options = { amount: '1.00', ...(sellerReference !== undefined ? { externalId: sellerReference } : {}) }; + const challenge = await mppx.challenge.stripe.charge(options); + const authorization = Credential.serialize({ + challenge, + payload: { spt: 'spt_test_123', ...(buyerReference !== undefined ? { externalId: buyerReference } : {}) }, + }); + + const result = await mppx.charge(options)( + new Request('https://app.test/widgets', { headers: { Authorization: authorization } }), + ); + + expect(result.status).toBe(200); + expect(lifecycle.order).toEqual(['validate', 'broadcast']); + }); + + it('uses the same authoritative offer for canOffer, challenges, and payment dispatch', async () => { + mockConfig(); + const lifecycle = mockLifecycle(); + const expectedRequest = { + amount: '100', + currency: 'usd', + externalId: 'order-123', + methodDetails: { networkId: NETWORK_ID, paymentMethodTypes: ['card', 'link'] }, + }; + const canOffer = vi.fn>( + ({ request }) => + request.amount === expectedRequest.amount && + request.currency === expectedRequest.currency && + request.methodDetails.networkId === NETWORK_ID && + request.methodDetails.paymentMethodTypes.join(',') === 'card,link', + ); + const method = await stripe({ apiKey: 'sk_test', baseUrl: BASE, canOffer }); + const mppx = Mppx.create({ methods: [method], realm: 'app.test', secretKey: SECRET }); + const options = { + amount: '1.00', + currency: 'eur', + decimals: 3, + externalId: 'order-123', + networkId: 'caller-profile', + paymentMethodTypes: ['caller-method'], + }; + const handler = mppx.compose([method, options]); + const unpaid = await handler(new Request('https://app.test/widgets')); + expect(unpaid.status).toBe(402); + if (unpaid.status !== 402) throw new Error('expected 402'); + expect(requestFrom(unpaid.challenge)).toEqual(expectedRequest); + expect(canOffer).toHaveBeenCalledOnce(); + expect(canOffer.mock.calls[0]?.[0].request).toEqual(expectedRequest); + + const challenge = await mppx.challenge.stripe.charge(options); + expect(challenge.request).toEqual(expectedRequest); + const authorization = Credential.serialize({ + challenge, + payload: { externalId: 'order-123', spt: 'spt_test_123' }, + }); + const paid = await handler(new Request('https://app.test/widgets', { headers: { Authorization: authorization } })); + expect(paid.status).toBe(200); + expect(lifecycle.order).toEqual(['validate', 'broadcast']); + expect(lifecycle.broadcastBody()).toMatchObject({ + credential: { challenge: { request: encode(expectedRequest) } }, + }); + expect(canOffer).toHaveBeenCalledOnce(); + }); + + it('omits a composed Stripe offer when canOffer rejects it', async () => { + mockConfig(); + const method = await stripe({ apiKey: 'sk_test', baseUrl: BASE, canOffer: () => false }); + const mppx = Mppx.create({ methods: [method], realm: 'app.test', secretKey: SECRET }); + await expect(mppx.compose([method, { amount: '1.00' }])(new Request('https://app.test/widgets'))).rejects.toThrow( + 'No payment offers are available for this request', + ); + }); +});