Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stripe-session-paymentintents.md
Original file line number Diff line number Diff line change
@@ -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. Preserved optional settlement callbacks after recording; applications no longer need to create PaymentIntents in those callbacks.
73 changes: 73 additions & 0 deletions src/stripe/server/Methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -912,3 +913,75 @@ 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,
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()
}
},
)
})
17 changes: 16 additions & 1 deletion src/stripe/server/Methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ interface StripeMachinePayments<P extends stripe.Parameters = stripe.Parameters>
metadata?: Record<string, string>
} & Partial<Omit<Parameters<typeof tempoCharge>[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,
Expand Down Expand Up @@ -256,13 +260,24 @@ export function stripe<const P extends stripe.Parameters>(parameters: P): Stripe
function makeTempoSession(
params: { recipient: `0x${string}` } & Omit<tempoSession.Parameters, 'currency' | 'recipient'>,
): 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({
receipt: { reference: context.txHash },
request: { amount: amount.toString() },
})
await onSessionSettlement?.(context)
},
} as tempoSession.Parameters) as Method.AnyServer
}

Expand Down
Loading