diff --git a/src/stripe/internal/constants.ts b/src/stripe/internal/constants.ts new file mode 100644 index 00000000..af293921 --- /dev/null +++ b/src/stripe/internal/constants.ts @@ -0,0 +1,7 @@ +/** + * Stripe API version with `.preview` suffix. + * + * Required for `shared_payment_granted_token` (SPTs are in private preview). + * Bump this when upgrading to a newer Stripe API version. + */ +export const stripePreviewVersion = '2026-02-25.preview' diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 89bfce5d..cef42abe 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -5,6 +5,7 @@ import type { LooseOmit, 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' +import { stripePreviewVersion } from '../internal/constants.js' import type { StripeClient, CreatePaymentMethodFromElements, @@ -202,13 +203,16 @@ async function createWithClient(parameters: { // `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}` }, + { idempotencyKey: `mppx_${challenge.id}_${spt}`, apiVersion: stripePreviewVersion }, ) // https://docs.stripe.com/error-low-level#idempotency const replayed = result.lastResponse?.headers?.['idempotent-replayed'] === 'true' return { id: result.id, status: result.status, replayed } - } catch { - throw new VerificationFailedError({ reason: 'Stripe PaymentIntent failed' }) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new VerificationFailedError({ + reason: `Stripe PaymentIntent failed: ${detail}`, + }) } } @@ -240,11 +244,25 @@ async function createWithSecretKey(parameters: { Authorization: `Basic ${btoa(`${secretKey}:`)}`, 'Content-Type': 'application/x-www-form-urlencoded', 'Idempotency-Key': `mppx_${challenge.id}_${spt}`, + 'Stripe-Version': stripePreviewVersion, }, body, }) - if (!response.ok) throw new VerificationFailedError({ reason: 'Stripe PaymentIntent failed' }) + if (!response.ok) { + const body = await response.text().catch(() => '') + const detail = (() => { + try { + const parsed = JSON.parse(body) as { error?: { message?: string } } + return parsed.error?.message ?? body + } catch { + return body + } + })() + throw new VerificationFailedError({ + reason: `Stripe PaymentIntent failed: ${detail}`, + }) + } // https://docs.stripe.com/error-low-level#idempotency const replayed = response.headers.get('idempotent-replayed') === 'true' const result = (await response.json()) as { id: string; status: string } diff --git a/test/html/server.ts b/test/html/server.ts index 13d52c7e..2483ef5b 100644 --- a/test/html/server.ts +++ b/test/html/server.ts @@ -6,6 +6,8 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { tempoModerato } from 'viem/chains' import { Actions } from 'viem/tempo' +import { stripePreviewVersion } from '../../src/stripe/internal/constants.js' + export async function startServer(port: number): Promise { const stripePublishableKey = process.env.VITE_STRIPE_PUBLIC_KEY const stripeSecretKey = process.env.VITE_STRIPE_SECRET_KEY @@ -190,6 +192,7 @@ async function createSharedPaymentToken(request: Request, secretKey: string): Pr headers: { Authorization: `Basic ${btoa(`${secretKey}:`)}`, 'Content-Type': 'application/x-www-form-urlencoded', + 'Stripe-Version': stripePreviewVersion, }, body: bodyParams, })