diff --git a/.changeset/stripe-connect-settlement.md b/.changeset/stripe-connect-settlement.md new file mode 100644 index 00000000..dc55f624 --- /dev/null +++ b/.changeset/stripe-connect-settlement.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added server-side Stripe Connect settlement options for Stripe charges. diff --git a/src/stripe/server/Charge.test.ts b/src/stripe/server/Charge.test.ts index a148c63d..ad1a7943 100644 --- a/src/stripe/server/Charge.test.ts +++ b/src/stripe/server/Charge.test.ts @@ -4,13 +4,18 @@ import { afterEach, describe, expect, test, vi } from 'vp/test' import * as Http from '~test/Http.js' import type { StripeClient } from '../internal/types.js' +import type { charge as StripeCharge } from './Charge.js' const realm = 'api.example.com' const secretKey = 'test-secret-key' let httpServer: Awaited> | undefined -afterEach(() => httpServer?.close()) +afterEach(() => { + httpServer?.close() + httpServer = undefined + vi.restoreAllMocks() +}) function createMockStripeClient( overrides?: Partial<{ status: string; id: string; throws: boolean }>, @@ -126,6 +131,215 @@ describe('stripe.charge with client', () => { expect(params.metadata.mpp_is_mpp).toBe('true') }) + test('behavior: applies Connect settlement parameters in client call', async () => { + const { client, create } = createMockStripeClient() + + const server = Mppx.create({ + methods: [ + stripe.charge({ + client, + connect({ request }) { + expect(request.amount).toBe('100') + return { + applicationFeeAmount: 12, + onBehalfOf: 'acct_merchant', + stripeAccount: 'acct_connected', + transferData: { amount: 88, destination: 'acct_destination' }, + transferGroup: 'order_123', + } + }, + networkId: 'internal', + paymentMethodTypes: ['card'], + }), + ], + realm, + secretKey, + }) + + const handle = server.charge({ amount: '1', currency: 'usd', decimals: 2 }) + const firstResult = await handle(new Request('https://example.com')) + expect(firstResult.status).toBe(402) + if (firstResult.status !== 402) throw new Error() + + const challenge = Challenge.fromResponse(firstResult.challenge) + expect(challenge.request).not.toHaveProperty('connect') + expect(challenge.request.methodDetails).not.toHaveProperty('applicationFeeAmount') + expect(challenge.request.methodDetails).not.toHaveProperty('stripeAccount') + + const credential = Credential.from({ + challenge, + payload: { spt: 'spt_test_token' }, + }) + + const result = await handle( + new Request('https://example.com', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(result.status).toBe(200) + const [params, options] = create.mock.calls[0]! + expect(params).toMatchObject({ + application_fee_amount: 12, + on_behalf_of: 'acct_merchant', + transfer_data: { amount: 88, destination: 'acct_destination' }, + transfer_group: 'order_123', + }) + expect(options).toMatchObject({ stripeAccount: 'acct_connected' }) + }) + + test('behavior: applies Connect settlement parameters in secretKey call', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'pi_fetch_123', status: 'succeeded' }), { + status: 200, + }), + ) + + const server = Mppx.create({ + methods: [ + stripe.charge({ + connect: { + applicationFeeAmount: 12, + onBehalfOf: 'acct_merchant', + stripeAccount: 'acct_connected', + transferData: { amount: 88, destination: 'acct_destination' }, + transferGroup: 'order_123', + }, + networkId: 'internal', + paymentMethodTypes: ['card'], + secretKey, + }), + ], + realm, + secretKey, + }) + + const handle = server.charge({ amount: '1', currency: 'usd', decimals: 2 }) + const firstResult = await handle(new Request('https://example.com')) + expect(firstResult.status).toBe(402) + if (firstResult.status !== 402) throw new Error() + + const credential = Credential.from({ + challenge: Challenge.fromResponse(firstResult.challenge), + payload: { spt: 'spt_test_token' }, + }) + const result = await handle( + new Request('https://example.com', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(result.status).toBe(200) + expect(fetchMock).toHaveBeenCalledOnce() + const [input, init] = fetchMock.mock.calls[0]! + expect(input).toBe('https://api.stripe.com/v1/payment_intents') + const headers = new Headers(init?.headers) + expect(headers.get('Stripe-Account')).toBe('acct_connected') + const body = init?.body as URLSearchParams + expect(body.get('application_fee_amount')).toBe('12') + expect(body.get('on_behalf_of')).toBe('acct_merchant') + expect(body.get('transfer_data[amount]')).toBe('88') + expect(body.get('transfer_data[destination]')).toBe('acct_destination') + expect(body.get('transfer_group')).toBe('order_123') + }) + + test('error: surfaces Connect PaymentIntent creation failures', async () => { + const { client } = createMockStripeClient({ throws: true }) + + const server = Mppx.create({ + methods: [ + stripe.charge({ + client, + connect: { stripeAccount: 'acct_connected' }, + networkId: 'internal', + paymentMethodTypes: ['card'], + }), + ], + realm, + secretKey, + }) + + httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx.toNodeListener( + server.charge({ amount: '1', currency: 'usd', decimals: 2 }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response = await fetch(httpServer.url) + const challenge = Challenge.fromResponse(response) + const credential = Credential.from({ + challenge, + payload: { spt: 'spt_test_token' }, + }) + + const paidResponse = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(paidResponse.status).toBe(402) + const body = (await paidResponse.json()) as { detail: string } + expect(body.detail).toContain('Stripe PaymentIntent failed') + }) + + const invalidConnectCases: readonly { + name: string + connect: StripeCharge.ConnectSettlement + }[] = [ + { name: 'empty stripeAccount', connect: { stripeAccount: '' } }, + { name: 'fee exceeds amount', connect: { applicationFeeAmount: 101 } }, + { name: 'negative fee', connect: { applicationFeeAmount: -1 } }, + { + name: 'empty transfer destination', + connect: { transferData: { destination: '' } }, + }, + { + name: 'missing transfer destination', + connect: { transferData: {} } as StripeCharge.ConnectSettlement, + }, + { + name: 'transfer amount exceeds amount', + connect: { transferData: { amount: 101, destination: 'acct_destination' } }, + }, + ] + + for (const { connect, name } of invalidConnectCases) { + test(`error: rejects invalid Connect settlement parameters (${name})`, async () => { + const { client, create } = createMockStripeClient() + + const server = Mppx.create({ + methods: [ + stripe.charge({ + client, + connect, + networkId: 'internal', + paymentMethodTypes: ['card'], + }), + ], + realm, + secretKey, + }) + + const handle = server.charge({ amount: '1', currency: 'usd', decimals: 2 }) + const firstResult = await handle(new Request('https://example.com')) + expect(firstResult.status).toBe(402) + if (firstResult.status !== 402) throw new Error() + + const credential = Credential.from({ + challenge: Challenge.fromResponse(firstResult.challenge), + payload: { spt: 'spt_test_token' }, + }) + const result = await handle( + new Request('https://example.com', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(result.status).toBe(402) + expect(create).not.toHaveBeenCalled() + }) + } + test('behavior: rejects when client throws', async () => { const { client } = createMockStripeClient({ throws: true }) diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 315e7faa..87dccbf1 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -1,7 +1,8 @@ +import type * as Challenge from '../../Challenge.js' import type * as Credential from '../../Credential.js' import { PaymentActionRequiredError, VerificationFailedError } from '../../Errors.js' import * as Expires from '../../Expires.js' -import type { LooseOmit, OneOf } from '../../internal/types.js' +import type { LooseOmit, MaybePromise, OneOf } from '../../internal/types.js' import * as Method from '../../Method.js' import type * as Html from '../../server/internal/html/config.ts' import type * as z from '../../zod.js' @@ -54,6 +55,7 @@ export function charge(parameters: p } = parameters const client = 'client' in parameters ? parameters.client : undefined + const connect = parameters.connect const secretKey = 'secretKey' in parameters ? parameters.secretKey : undefined type Defaults = charge.DeriveDefaults @@ -92,7 +94,7 @@ export function charge(parameters: p } : undefined, - async verify({ credential, request }) { + async verify({ credential, envelope, request }) { const { challenge } = credential const resolvedRequest = (() => { const parsed = Methods.charge.schema.request.safeParse(request) @@ -115,6 +117,13 @@ export function charge(parameters: p | Record | undefined const resolvedMetadata = { ...buildAnalytics({ credential }), ...userMetadata } + const settlement = validateConnectSettlement({ + amount: resolvedRequest.amount, + settlement: + typeof connect === 'function' + ? await connect({ challenge, credential, envelope, request: resolvedRequest }) + : connect, + }) const pi = client ? await createWithClient({ @@ -123,6 +132,7 @@ export function charge(parameters: p request: resolvedRequest, spt, metadata: resolvedMetadata, + settlement, }) : await createWithSecretKey({ secretKey: secretKey!, @@ -130,6 +140,7 @@ export function charge(parameters: p request: resolvedRequest, spt, metadata: resolvedMetadata, + settlement, }) if (pi.replayed) @@ -171,6 +182,8 @@ export declare namespace charge { | undefined /** Optional metadata to include in SPT creation requests. */ metadata?: Record | undefined + /** Optional server-side Stripe Connect settlement policy. Not included in MPP challenges. */ + connect?: ConnectSettlement | ResolveConnectSettlement | undefined } & Defaults & OneOf< | { @@ -187,6 +200,45 @@ export declare namespace charge { parameters, Extract > & { decimals: number } + + /** + * Server-side Stripe Connect settlement parameters. + * + * @see https://docs.stripe.com/connect/destination-charges + */ + type ConnectSettlement = { + /** Connected account used as the Stripe account context for the request. */ + stripeAccount?: string | undefined + /** Platform application fee amount in the smallest currency unit. */ + applicationFeeAmount?: number | undefined + /** Connected account used as the business of record. */ + onBehalfOf?: string | undefined + /** Destination transfer created from the PaymentIntent. */ + transferData?: { amount?: number | undefined; destination: string } | undefined + /** Reconciliation token linking related charges and transfers. */ + transferGroup?: string | undefined + } + + type ResolveConnectSettlement = (parameters: { + challenge: Challenge.Challenge< + z.output, + 'charge', + 'stripe' + > + credential: Credential.Credential< + z.output, + Challenge.Challenge, 'charge', 'stripe'> + > + envelope?: + | Method.VerifiedChallengeEnvelope< + z.output, + z.output, + 'charge', + 'stripe' + > + | undefined + request: z.output + }) => MaybePromise } /** Creates a PaymentIntent using the Stripe SDK client. */ @@ -195,21 +247,41 @@ async function createWithClient(parameters: { challenge: { id: string } metadata: Record request: { amount: unknown; currency: unknown } + settlement: charge.ConnectSettlement | undefined spt: string }): Promise<{ id: string; status: string; replayed: boolean }> { - const { client, challenge, metadata, request, spt } = parameters + const { client, challenge, metadata, request, settlement, spt } = parameters try { + const paymentIntentParams = { + amount: Number(request.amount), + automatic_payment_methods: { allow_redirects: 'never', enabled: true }, + confirm: true, + currency: request.currency as string, + metadata, + ...(settlement?.applicationFeeAmount !== undefined && { + application_fee_amount: settlement.applicationFeeAmount, + }), + ...(settlement?.onBehalfOf !== undefined && { on_behalf_of: settlement.onBehalfOf }), + ...(settlement?.transferData !== undefined && { + transfer_data: { + destination: settlement.transferData.destination, + ...(settlement.transferData.amount !== undefined && { + amount: settlement.transferData.amount, + }), + }, + }), + ...(settlement?.transferGroup !== undefined && { transfer_group: settlement.transferGroup }), + // `shared_payment_granted_token` is not yet in the Stripe SDK types (SPTs are in private preview). + shared_payment_granted_token: spt, + } + const paymentIntentOptions = { + apiVersion: stripePreviewVersion, + idempotencyKey: `mppx_${challenge.id}_${spt}`, + ...(settlement?.stripeAccount !== undefined && { stripeAccount: settlement.stripeAccount }), + } const result = await client.paymentIntents.create( - { - amount: Number(request.amount), - automatic_payment_methods: { allow_redirects: 'never', enabled: true }, - confirm: true, - currency: request.currency as string, - metadata, - // `shared_payment_granted_token` is not yet in the Stripe SDK types (SPTs are in private preview). - shared_payment_granted_token: spt, - } as any, - { idempotencyKey: `mppx_${challenge.id}_${spt}`, apiVersion: stripePreviewVersion }, + paymentIntentParams as any, + paymentIntentOptions, ) // https://docs.stripe.com/error-low-level#idempotency const replayed = result.lastResponse?.headers?.['idempotent-replayed'] === 'true' @@ -228,9 +300,10 @@ async function createWithSecretKey(parameters: { challenge: { id: string } metadata: Record request: { amount: unknown; currency: unknown } + settlement: charge.ConnectSettlement | undefined spt: string }): Promise<{ id: string; status: string; replayed: boolean }> { - const { secretKey, challenge, metadata, request, spt } = parameters + const { secretKey, challenge, metadata, request, settlement, spt } = parameters const body = new URLSearchParams({ amount: request.amount as string, @@ -243,15 +316,27 @@ async function createWithSecretKey(parameters: { for (const [key, value] of Object.entries(metadata)) { body.set(`metadata[${key}]`, value) } + if (settlement?.applicationFeeAmount !== undefined) + body.set('application_fee_amount', String(settlement.applicationFeeAmount)) + if (settlement?.onBehalfOf !== undefined) body.set('on_behalf_of', settlement.onBehalfOf) + if (settlement?.transferData !== undefined) { + body.set('transfer_data[destination]', settlement.transferData.destination) + if (settlement.transferData.amount !== undefined) + body.set('transfer_data[amount]', String(settlement.transferData.amount)) + } + if (settlement?.transferGroup !== undefined) body.set('transfer_group', settlement.transferGroup) + + const headers = { + Authorization: `Basic ${btoa(`${secretKey}:`)}`, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Idempotency-Key': `mppx_${challenge.id}_${spt}`, + 'Stripe-Version': stripePreviewVersion, + ...(settlement?.stripeAccount !== undefined && { 'Stripe-Account': settlement.stripeAccount }), + } const response = await fetch('https://api.stripe.com/v1/payment_intents', { method: 'POST', - headers: { - Authorization: `Basic ${btoa(`${secretKey}:`)}`, - 'Content-Type': 'application/x-www-form-urlencoded', - 'Idempotency-Key': `mppx_${challenge.id}_${spt}`, - 'Stripe-Version': stripePreviewVersion, - }, + headers, body, }) @@ -288,3 +373,48 @@ function buildAnalytics(parameters: { credential: Credential.Credential }): Reco ...(credential.source ? { mpp_client_id: credential.source } : {}), } } + +function validateConnectSettlement(parameters: { + amount: unknown + settlement: charge.ConnectSettlement | undefined +}): charge.ConnectSettlement | undefined { + const { amount, settlement } = parameters + if (settlement === undefined) return undefined + + const paymentAmount = Number(amount) + if (!Number.isSafeInteger(paymentAmount) || paymentAmount < 0) + throw new VerificationFailedError({ reason: 'Stripe amount must be a non-negative integer.' }) + + validateAccountId(settlement.stripeAccount, 'stripeAccount') + validateAccountId(settlement.onBehalfOf, 'onBehalfOf') + validateAmount(settlement.applicationFeeAmount, paymentAmount, 'applicationFeeAmount') + + if (settlement.transferData !== undefined) { + validateRequiredAccountId(settlement.transferData.destination, 'transferData.destination') + validateAmount(settlement.transferData.amount, paymentAmount, 'transferData.amount') + } + + return settlement +} + +function validateAccountId(value: string | undefined, name: string) { + if (value !== undefined && value.length === 0) + throw new VerificationFailedError({ reason: `Stripe Connect ${name} must be non-empty.` }) +} + +function validateRequiredAccountId(value: string | undefined, name: string) { + if (value === undefined || value.length === 0) + throw new VerificationFailedError({ reason: `Stripe Connect ${name} must be non-empty.` }) +} + +function validateAmount(value: number | undefined, paymentAmount: number, name: string) { + if (value === undefined) return + if (!Number.isSafeInteger(value) || value < 0) + throw new VerificationFailedError({ + reason: `Stripe Connect ${name} must be a non-negative integer.`, + }) + if (value > paymentAmount) + throw new VerificationFailedError({ + reason: `Stripe Connect ${name} must be less than or equal to the PaymentIntent amount.`, + }) +}