diff --git a/src/stripe/server/Methods.ts b/src/stripe/server/Methods.ts index dd284d6e..0080b623 100644 --- a/src/stripe/server/Methods.ts +++ b/src/stripe/server/Methods.ts @@ -1,24 +1,207 @@ +import type * as Method from '../../Method.js' +import type * as Receipt from '../../Receipt.js' +import * as tempoDefaults from '../../tempo/internal/defaults.js' +import { charge as tempoCharge } from '../../tempo/server/Charge.js' +import { charge as evmCharge } from '../../evm/server/Charge.js' +import * as EvmAssets from '../../evm/Assets.js' +import * as TempoMethods from '../../tempo/Methods.js' +import * as StripeMethods from '../Methods.js' import { charge as charge_ } from './Charge.js' +import { resolveDepositAddress } from './internal/deposit-address.js' +import { recordCryptoPayment } from './internal/record-payment.js' + +type TempoNetworkEntry = { + network: 'tempo' +} & Partial[0], 'currency' | 'recipient'>> + +type BaseNetworkEntry = { + network: 'base' +} & Omit[0], 'currency' | 'recipient'> + +type CustomNetworkEntry = { + network: string + configure: (address: string) => Method.AnyServer | readonly Method.AnyServer[] +} + +type AdditionalNetworkEntry = TempoNetworkEntry | BaseNetworkEntry | CustomNetworkEntry + +type TempoChargeServer = Method.AnyServer & { name: 'tempo'; intent: 'charge' } +type StripeSptServer = Method.AnyServer & { name: 'stripe'; intent: 'charge' } /** - * Creates a Stripe `charge` method for usage on the server. + * Creates all Stripe-supported MPP payment methods from a single configuration. + * + * Opinionated: always enables Tempo crypto and SPT (card/link) payments. + * Pass `additional` to enable more crypto networks (e.g. Base, Solana). + * + * Crypto payments are automatically recorded as Stripe PaymentIntents + * via transaction verification for unified accounting in the Stripe Dashboard. * * @example * ```ts * import { Mppx, stripe } from 'mppx/server' * + * // Minimal: tempo + cards/link * const mppx = Mppx.create({ - * methods: [stripe({ secretKey: 'sk_...' })], + * methods: [await stripe({ secretKey: 'sk_...', profileId: '...' })], + * }) + * ``` + * + * @example + * ```ts + * import { stripe } from 'mppx/server' + * import { solana } from '@solana/mpp/server' + * + * // Add Base and Solana + * const methods = await stripe({ + * secretKey: 'sk_...', + * profileId: '...', + * additional: [ + * { network: 'base', x402: { facilitator } }, + * { network: 'solana', configure: (address) => solana.charge({ recipient: address, currency: USDC, decimals: 6 }) }, + * ], * }) * ``` */ -export function stripe(parameters: parameters) { - return [stripe.charge(parameters)] as const +export async function stripe( + parameters: parameters, +): Promise { + const { secretKey, profileId, paymentMethodTypes } = parameters + const isTestMode = secretKey.includes('_test_') + + // Resolve tempo deposit address (required) + const tempoAddress = await resolveDepositAddress(secretKey, 'tempo') + if (!tempoAddress) { + throw new Error( + 'stripe(): failed to resolve Tempo deposit address. Ensure your Stripe account has crypto enabled.', + ) + } + + // Create tempo method (always on) + const tempoMethod = wrapWithPaymentRecording( + tempoCharge({ + currency: (isTestMode + ? tempoDefaults.tokens.pathUsd + : tempoDefaults.tokens.usdc) as `0x${string}`, + recipient: tempoAddress as `0x${string}`, + ...(isTestMode && { testnet: true }), + }), + secretKey, + ) + + // Create SPT method (always on) + const sptMethod = stripe.spt({ + secretKey, + networkId: profileId, + paymentMethodTypes: paymentMethodTypes ?? ['card', 'link'], + }) + + // Resolve additional networks (best-effort) + const additional = await resolveAdditionalNetworks(secretKey, isTestMode, parameters.additional) + + return [tempoMethod, sptMethod, ...additional] as readonly [ + TempoChargeServer, + StripeSptServer, + ...Method.AnyServer[], + ] +} + +export declare namespace stripe { + type Parameters = { + /** Stripe secret API key. */ + secretKey: string + /** Stripe business network profile ID. */ + profileId: string + /** Payment method types for SPT-based payments. @default ['card', 'link'] */ + paymentMethodTypes?: string[] | undefined + /** + * Additional crypto networks to enable beyond the defaults. + * Entries with the same network name as a built-in override its config. + */ + additional?: AdditionalNetworkEntry[] | undefined + } } export namespace stripe { - export type Parameters = charge_.Parameters + /** Creates a Stripe SPT charge method for card/link payments. */ + export const spt = charge_ - /** Creates a Stripe `charge` method for SPT-based payments. */ + /** @deprecated Use `stripe.spt()` instead. */ export const charge = charge_ } + +async function resolveAdditionalNetworks( + secretKey: string, + isTestMode: boolean, + additional: AdditionalNetworkEntry[] | undefined, +): Promise { + if (!additional || additional.length === 0) return [] + + const methods: Method.AnyServer[] = [] + + const resolved = await Promise.all( + additional + .filter((entry) => entry.network !== 'tempo') + .map(async (entry) => ({ + entry, + address: await resolveDepositAddress(secretKey, entry.network), + })), + ) + + for (const { entry, address } of resolved) { + if (!address) continue + + let methodOrMethods: Method.AnyServer | readonly Method.AnyServer[] + + if ('configure' in entry) { + methodOrMethods = entry.configure(address) + } else if (entry.network === 'base') { + const { network: _, ...config } = entry + methodOrMethods = evmCharge({ + currency: isTestMode ? EvmAssets.baseSepolia.USDC : EvmAssets.base.USDC, + recipient: address as `0x${string}`, + ...config, + }) + } else { + continue + } + + const wrapped = Array.isArray(methodOrMethods) + ? (methodOrMethods as readonly Method.AnyServer[]).map((m) => + wrapWithPaymentRecording(m, secretKey), + ) + : [wrapWithPaymentRecording(methodOrMethods as Method.AnyServer, secretKey)] + + methods.push(...wrapped) + } + + return methods +} + +/** + * Wraps a method's verify function to record successful crypto payments + * as Stripe PaymentIntents via transaction_verification. + */ +function wrapWithPaymentRecording( + method: Method.AnyServer, + secretKey: string, +): Method.AnyServer { + const originalVerify = method.verify + return { + ...method, + async verify(params: any): Promise { + const receipt = await originalVerify(params) + const request = params.credential?.challenge?.request ?? params.request + const amount = request?.amount + if (receipt.reference && amount) { + recordCryptoPayment({ + secretKey, + method: receipt.method, + reference: receipt.reference, + amount: String(amount), + }) + } + return receipt + }, + } +} diff --git a/src/stripe/server/index.ts b/src/stripe/server/index.ts index f7a88d3d..f1322dc8 100644 --- a/src/stripe/server/index.ts +++ b/src/stripe/server/index.ts @@ -1,2 +1,3 @@ export { charge } from './Charge.js' export { stripe } from './Methods.js' +export { charge as spt } from './Charge.js' diff --git a/src/stripe/server/internal/deposit-address.ts b/src/stripe/server/internal/deposit-address.ts new file mode 100644 index 00000000..20164d12 --- /dev/null +++ b/src/stripe/server/internal/deposit-address.ts @@ -0,0 +1,44 @@ +import { stripePreviewVersion } from '../../internal/constants.js' + +export type DepositAddressResult = { + address: string + network: string +} + +/** + * Resolves a deposit address for a given network from the Stripe API. + * Fetches an existing address or creates a new one if none exist. + */ +export async function resolveDepositAddress( + secretKey: string, + network: string, +): Promise { + const headers = { + Authorization: `Basic ${btoa(`${secretKey}:`)}`, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Stripe-Version': stripePreviewVersion, + } + + try { + const listResponse = await fetch( + `https://api.stripe.com/v1/crypto/deposit_addresses?network=${network}&limit=1`, + { headers }, + ) + if (listResponse.ok) { + const list = (await listResponse.json()) as { data?: { address: string }[] } + if (list.data && list.data.length > 0) return list.data[0]!.address + } + + const createResponse = await fetch('https://api.stripe.com/v1/crypto/deposit_addresses', { + method: 'POST', + headers, + body: new URLSearchParams({ network }), + }) + if (!createResponse.ok) return null + + const created = (await createResponse.json()) as { address: string } + return created.address + } catch { + return null + } +} diff --git a/src/stripe/server/internal/record-payment.ts b/src/stripe/server/internal/record-payment.ts new file mode 100644 index 00000000..d21cf78c --- /dev/null +++ b/src/stripe/server/internal/record-payment.ts @@ -0,0 +1,48 @@ +import { stripePreviewVersion } from '../../internal/constants.js' + +const SUPPORTED_NETWORKS: Record = { + tempo: 'tempo', + evm: 'base', +} + +/** + * Records a crypto payment as a Stripe PaymentIntent using transaction_verification mode. + * Fire-and-forget: errors are logged but never thrown. + */ +export function recordCryptoPayment(parameters: { + secretKey: string + method: string + reference: string + amount: string +}): void { + const { secretKey, method, reference, amount } = parameters + const network = SUPPORTED_NETWORKS[method] + if (!network) return + + const amountCents = Math.round(parseFloat(amount) * 100) + if (amountCents < 1) return + + const body = new URLSearchParams({ + amount: String(amountCents), + currency: 'usd', + confirm: 'true', + 'payment_method_data[type]': 'crypto', + 'payment_method_types[0]': 'crypto', + 'payment_method_options[crypto][mode]': 'transaction_verification', + 'payment_method_options[crypto][transaction_verification_options][network]': network, + 'payment_method_options[crypto][transaction_verification_options][transaction_hash]': reference, + }) + + fetch('https://api.stripe.com/v1/payment_intents', { + method: 'POST', + headers: { + Authorization: `Basic ${btoa(`${secretKey}:`)}`, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Idempotency-Key': reference, + 'Stripe-Version': stripePreviewVersion, + }, + body, + }).catch((err) => { + console.error('[stripe] failed to record crypto payment:', err) + }) +}