diff --git a/.changeset/stripe-session-paymentintents.md b/.changeset/stripe-session-paymentintents.md new file mode 100644 index 00000000..b440f36f --- /dev/null +++ b/.changeset/stripe-session-paymentintents.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added automatic Stripe PaymentIntent recording for Tempo session settlements, using each transaction's newly settled amount rounded down to whole cents and recording the session intent in analytics metadata. Preserved optional settlement callbacks after recording; applications no longer need to create PaymentIntents in those callbacks. diff --git a/src/stripe/server/Methods.test.ts b/src/stripe/server/Methods.test.ts index 0fbf9b1c..29c62a4c 100644 --- a/src/stripe/server/Methods.test.ts +++ b/src/stripe/server/Methods.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test, vi } from 'vp/test' import { sdkIdentifier } from '../../internal/version.js' import * as Method from '../../Method.js' import type { AnyServer } from '../../Method.js' +import * as TempoSession from '../../tempo/session/server/Session.js' import * as z from '../../zod.js' import type { StripeClient } from '../internal/types.js' @@ -912,3 +913,76 @@ describe('stripe.create() graceful degradation', () => { expect(findMethod(withAdditional, 'stripe', 'charge')).toBeDefined() }) }) + +describe('Stripe session settlement recording', () => { + const recipient = '0x1111111111111111111111111111111111111111' as stripe.DepositAddress<'tempo'> + const event = { + txHash: `0x${'ab'.repeat(32)}` as const, + channelId: `0x${'cd'.repeat(32)}` as const, + trigger: 'scheduled' as const, + amount: 50_000n, + delta: 10_000n, + } + + test.each([false, true])('records automatically (additional: %s)', async (additional) => { + const client = createMockStripeClient() + const mp = stripe({ + client, + networkId: 'test-profile', + livemode: true, + depositAddresses: { tempo: recipient }, + }) + const session = vi.spyOn(TempoSession, 'session') + try { + if (additional) mp.defaultMethods().additional({ tempo: { session: {} } }) + else mp.tempo.session({ recipient }) + await session.mock.calls.at(-1)![0]!.onSessionSettlement!(event) + expect(client.paymentIntents.create).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + amount: 1, + metadata: expect.objectContaining({ mpp_intent: 'session' }), + payment_method_options: { + crypto: { + mode: 'transaction_verification', + transaction_verification_options: { + network: 'tempo', + transaction_hash: event.txHash, + }, + }, + }, + }), + expect.objectContaining({ idempotencyKey: event.txHash }), + ) + } finally { + session.mockRestore() + } + }) + + test.each([ + [0n, 0], + [9_999n, 0], + [19_999n, 1], + ] as const)( + 'records %s raw units as %s cents and preserves the merchant callback', + async (delta, cents) => { + const client = createMockStripeClient() + const mp = stripe({ client, networkId: 'test-profile', livemode: true }) + const onSessionSettlement = vi.fn() + const session = vi.spyOn(TempoSession, 'session') + try { + mp.tempo.session({ recipient, onSessionSettlement }) + const context = { ...event, trigger: 'close' as const, delta } + await session.mock.calls.at(-1)![0]!.onSessionSettlement!(context) + expect(client.paymentIntents.create).toHaveBeenCalledTimes(cents ? 1 : 0) + if (cents) + expect(client.paymentIntents.create).toHaveBeenCalledWith( + expect.objectContaining({ amount: cents }), + expect.anything(), + ) + expect(onSessionSettlement).toHaveBeenCalledExactlyOnceWith(context) + } finally { + session.mockRestore() + } + }, + ) +}) diff --git a/src/stripe/server/Methods.ts b/src/stripe/server/Methods.ts index f5f7fff2..07c71202 100644 --- a/src/stripe/server/Methods.ts +++ b/src/stripe/server/Methods.ts @@ -99,6 +99,10 @@ interface StripeMachinePayments

metadata?: Record } & Partial[0], 'currency' | 'recipient'>>, ) => TempoServer + /** + * Creates a session that automatically records whole-cent settlement deltas + * as Stripe PaymentIntents. `onSessionSettlement` runs after recording. + */ session: ( params: { recipient: stripe.DepositAddress<'tempo'> } & Omit< tempoSession.Parameters, @@ -256,13 +260,25 @@ export function stripe(parameters: P): Stripe function makeTempoSession( params: { recipient: `0x${string}` } & Omit, ): Method.AnyServer { - const { recipient, ...rest } = params + const { recipient, onSessionSettlement, ...rest } = params return tempoSession({ currency: tempoCurrency, recipient, ...(!livemode && { testnet: true }), ...(hostedTempoFeePayer && { feePayer: hostedTempoFeePayer }), ...rest, + async onSessionSettlement(context) { + // Stripe verifies each transaction in whole cents. Never round a + // settlement up or carry its sub-cent remainder into another transaction. + const amount = (context.delta / 10_000n) * 10_000n + if (amount > 0n) + await tempoPaymentHandler({ + intent: 'session', + receipt: { reference: context.txHash }, + request: { amount: amount.toString() }, + }) + await onSessionSettlement?.(context) + }, } as tempoSession.Parameters) as Method.AnyServer } @@ -462,8 +478,14 @@ function createPaymentSuccessHandler( connect?: ConnectConfig, metadata?: Record, ) { - return (params: { challenge?: any; receipt: any; request: any; requestInput?: any }) => { - const { challenge, receipt, request, requestInput } = params + return (params: { + challenge?: any + intent?: string + receipt: any + request: any + requestInput?: any + }) => { + const { challenge, intent, receipt, request, requestInput } = params if (receipt?.reference && request?.amount) { const paymentIntentOptionsInput = requestInput?.paymentIntentOptions as | PaymentIntent.OptionsInput @@ -483,7 +505,7 @@ function createPaymentSuccessHandler( reference: receipt.reference, amount: String(request.amount), ...(connect && { connect }), - analyticsMetadata: buildAnalytics({ challenge }), + analyticsMetadata: buildAnalytics({ challenge, intent }), ...(Object.keys(resolvedPaymentIntentOptions).length > 0 && { paymentIntentOptions: resolvedPaymentIntentOptions, }), diff --git a/src/stripe/server/internal/analytics.ts b/src/stripe/server/internal/analytics.ts index c789e369..5fe8e887 100644 --- a/src/stripe/server/internal/analytics.ts +++ b/src/stripe/server/internal/analytics.ts @@ -5,14 +5,15 @@ import { machinePaymentMetadata } from '../../internal/constants.js' /** Builds Stripe metadata used to identify and analyze MPP payments. */ export function buildAnalytics(parameters: { challenge?: Pick | undefined + intent?: string | undefined }): Record { - const { challenge } = parameters + const { challenge, intent = challenge?.intent } = parameters const metadata = { ...machinePaymentMetadata, mpp_sdk: sdkIdentifier, + ...(intent && { mpp_intent: intent }), ...(challenge && { mpp_challenge_id: challenge.id, - mpp_intent: challenge.intent, }), } return Object.fromEntries(