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
195 changes: 189 additions & 6 deletions src/stripe/server/Methods.ts
Original file line number Diff line number Diff line change
@@ -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<Omit<Parameters<typeof tempoCharge>[0], 'currency' | 'recipient'>>

type BaseNetworkEntry = {
network: 'base'
} & Omit<Parameters<typeof evmCharge>[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<const parameters extends stripe.Parameters>(parameters: parameters) {
return [stripe.charge(parameters)] as const
export async function stripe<const parameters extends stripe.Parameters>(
parameters: parameters,
): Promise<readonly [TempoChargeServer, StripeSptServer, ...Method.AnyServer[]]> {
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<Method.AnyServer[]> {
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<Receipt.Receipt> {
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
},
}
}
1 change: 1 addition & 0 deletions src/stripe/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { charge } from './Charge.js'
export { stripe } from './Methods.js'
export { charge as spt } from './Charge.js'
44 changes: 44 additions & 0 deletions src/stripe/server/internal/deposit-address.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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
}
}
48 changes: 48 additions & 0 deletions src/stripe/server/internal/record-payment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { stripePreviewVersion } from '../../internal/constants.js'

const SUPPORTED_NETWORKS: Record<string, string> = {
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)
})
}
Loading