From 69dd9401ce6aeee00c3c1bf74929bdf996b6729b Mon Sep 17 00:00:00 2001
From: Brian Luo <57960778+law-chain-hot@users.noreply.github.com>
Date: Sat, 11 Jul 2026 15:59:53 +0800
Subject: [PATCH] feat(billing): add Stripe sandbox verification
---
.../BillingPaymentMethodSection.test.tsx | 3 +
.../billing/BillingPaymentMethodSection.tsx | 2 +-
apps/infra-local/README.md | 26 ++
apps/infra/README.md | 9 +-
apps/infra/sst-billing-config.test.mjs | 15 +
apps/infra/sst.config.ts | 4 +
apps/package.json | 3 +
.../scripts/billing-stripe-sandbox-config.mjs | 80 +++++
.../billing-stripe-sandbox-config.test.mjs | 81 ++++++
apps/scripts/billing-stripe-sandbox-e2e.mjs | 275 ++++++++++++++++++
.../billing-stripe-sandbox-listener.mjs | 72 +++++
apps/scripts/billing-test-suite.mjs | 16 +-
12 files changed, 580 insertions(+), 6 deletions(-)
create mode 100644 apps/infra/sst-billing-config.test.mjs
create mode 100644 apps/scripts/billing-stripe-sandbox-config.mjs
create mode 100644 apps/scripts/billing-stripe-sandbox-config.test.mjs
create mode 100644 apps/scripts/billing-stripe-sandbox-e2e.mjs
create mode 100644 apps/scripts/billing-stripe-sandbox-listener.mjs
diff --git a/apps/dashboard/src/components/billing/BillingPaymentMethodSection.test.tsx b/apps/dashboard/src/components/billing/BillingPaymentMethodSection.test.tsx
index 388370b7b..d656d20da 100644
--- a/apps/dashboard/src/components/billing/BillingPaymentMethodSection.test.tsx
+++ b/apps/dashboard/src/components/billing/BillingPaymentMethodSection.test.tsx
@@ -140,6 +140,9 @@ describe('BillingPaymentMethodSection', () => {
await click(findButton('Set up payment method'))
expect(state.setupPayment).not.toHaveBeenCalled()
+ expect(document.body.textContent).toContain(
+ 'Stripe securely saves this card for future top-ups. Automatic charges occur only after you enable auto-reload.',
+ )
await click(findButton('Confirm setup'))
expect(state.setupPayment).toHaveBeenCalledWith({ organizationId: 'org-1' })
diff --git a/apps/dashboard/src/components/billing/BillingPaymentMethodSection.tsx b/apps/dashboard/src/components/billing/BillingPaymentMethodSection.tsx
index 68dca9ac4..9e56c5884 100644
--- a/apps/dashboard/src/components/billing/BillingPaymentMethodSection.tsx
+++ b/apps/dashboard/src/components/billing/BillingPaymentMethodSection.tsx
@@ -80,7 +80,7 @@ function PaymentMethodCard({
Confirm payment method setup
{payment.providerMode === 'stripe'
- ? 'Continue to Stripe to securely add or replace the organization payment method.'
+ ? 'Stripe securely saves this card for future top-ups. Automatic charges occur only after you enable auto-reload.'
: 'Continue with the test payment provider for this organization.'}
diff --git a/apps/infra-local/README.md b/apps/infra-local/README.md
index 70cd295b0..ef448e243 100644
--- a/apps/infra-local/README.md
+++ b/apps/infra-local/README.md
@@ -78,6 +78,32 @@ runner path against a separate Docker stack. The direct-SDK capability this stac
relies on — read-write host volumes + host port mapping — is pinned by
`sdks/python/tests/test_volume_port_persistence.py`.
+### Stripe Sandbox Billing
+
+Stripe tests use test-mode credentials only. Start the API with
+`BILLING_PAYMENT_PROVIDER=stripe`, `STRIPE_SECRET_KEY=sk_test_...`, and the
+webhook secret printed by `stripe listen --print-secret`. From `apps/`, use one
+shell for the API and listener:
+
+```bash
+export STRIPE_SECRET_KEY=sk_test_...
+export STRIPE_WEBHOOK_SECRET="$(stripe listen --skip-update --print-secret --color off)"
+set -a; source infra-local/api.env; set +a
+export BILLING_PAYMENT_PROVIDER=stripe
+(cd infra-local && make restart COMPONENTS="api dashboard")
+yarn billing:stripe:listen
+```
+
+While the listener is running, use a second shell:
+
+```bash
+yarn e2e:billing:stripe # replaces the local test card and adds a $5 test top-up
+```
+
+The E2E refuses non-loopback PostgreSQL, verifies `cus_` / `pm_` setup, and
+requires exactly one wallet ledger credit. Credentials stay in environment
+variables; do not add them to `api.env` or Git.
+
## Troubleshooting
| Symptom | Cause | Fix |
diff --git a/apps/infra/README.md b/apps/infra/README.md
index a156fedd7..1b2d9d035 100644
--- a/apps/infra/README.md
+++ b/apps/infra/README.md
@@ -52,7 +52,7 @@ single laptop's `.env`:
| What | Where | Set with |
|---|---|---|
-| **App secrets** — SSH host/private keys, Auth0 Management API id + secret, `SVIX_AUTH_TOKEN`, `POSTHOG_API_KEY`, `OIDC_CLIENT_ID` | SST secret store (encrypted in SST state, per stage) | `sst secret set "" --stage ` |
+| **App secrets** — SSH host/private keys, Auth0 Management API id + secret, `SVIX_AUTH_TOKEN`, `POSTHOG_API_KEY`, `OIDC_CLIENT_ID`, Stripe API + webhook secrets | SST secret store (encrypted in SST state, per stage) | `sst secret set "" --stage ` |
| **Cloudflare provider creds** — `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_DEFAULT_ACCOUNT_ID` | AWS SSM (`SecureString`, per stage) | `aws ssm put-parameter --type SecureString --name /boxlite//cloudflare-…` |
| **Non-secret config** — `STACK_DOMAIN`, `OIDC_ISSUER_BASE_URL`, `OIDC_AUDIENCE`, toggles | local `.env` (gitignored) | edit `.env` |
@@ -70,6 +70,13 @@ sst secret load .env --stage dev # bulk-load a dotenv (nam
npm run secrets -- --stage dev # list what's set
```
+Billing with Stripe also requires stage-scoped `STRIPE_SECRET_KEY` and
+`STRIPE_WEBHOOK_SECRET`. Use a Stripe test key for non-production stages and
+register only the four events consumed by the API: `checkout.session.completed`,
+`checkout.session.async_payment_failed`, `payment_intent.succeeded`, and
+`payment_intent.payment_failed`. Set `BILLING_PAYMENT_PROVIDER=stripe` in the
+deploy environment; the API fails closed when either secret is absent.
+
Secret names match the env keys the services expect. Unset optional secrets
resolve to empty (feature off); `OIDC_CLIENT_ID` defaults to `boxlite`. A changed
value takes effect on the next `npm run deploy`.
diff --git a/apps/infra/sst-billing-config.test.mjs b/apps/infra/sst-billing-config.test.mjs
new file mode 100644
index 000000000..d25717115
--- /dev/null
+++ b/apps/infra/sst-billing-config.test.mjs
@@ -0,0 +1,15 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import test from 'node:test'
+
+const source = await readFile(new URL('./sst.config.ts', import.meta.url), 'utf8')
+
+test('stores Stripe credentials in stage-scoped SST secrets', () => {
+ assert.match(source, /const stripeSecretKey = new sst\.Secret\('STRIPE_SECRET_KEY', ''\)/)
+ assert.match(source, /const stripeWebhookSecret = new sst\.Secret\('STRIPE_WEBHOOK_SECRET', ''\)/)
+})
+
+test('injects Stripe credentials into the API service from SST secret values', () => {
+ assert.match(source, /STRIPE_SECRET_KEY: stripeSecretKey\.value/)
+ assert.match(source, /STRIPE_WEBHOOK_SECRET: stripeWebhookSecret\.value/)
+})
diff --git a/apps/infra/sst.config.ts b/apps/infra/sst.config.ts
index faa71aa5f..afb62a89c 100644
--- a/apps/infra/sst.config.ts
+++ b/apps/infra/sst.config.ts
@@ -172,6 +172,8 @@ export default $config({
const svixAuthToken = new sst.Secret('SVIX_AUTH_TOKEN', '')
const sshPrivateKey = new sst.Secret('SSH_PRIVATE_KEY_B64', '')
const sshHostKey = new sst.Secret('SSH_HOST_KEY_B64', '')
+ const stripeSecretKey = new sst.Secret('STRIPE_SECRET_KEY', '')
+ const stripeWebhookSecret = new sst.Secret('STRIPE_WEBHOOK_SECRET', '')
// ─── 2. PLATFORM ─────────────────────────────────────────────────────────
// Network model + rationale (subnets / NAT / egress-only public IP, AWS citations): ./NETWORKING.md
@@ -461,6 +463,8 @@ export default $config({
BILLING_PAYMENT_PROVIDER: isProd
? requireEnv('BILLING_PAYMENT_PROVIDER', 'for production billing')
: envOr('BILLING_PAYMENT_PROVIDER', 'fake'),
+ STRIPE_SECRET_KEY: stripeSecretKey.value,
+ STRIPE_WEBHOOK_SECRET: stripeWebhookSecret.value,
// Box base images: only the three digest-pinned *_IMAGE refs below are live — the
// API gates box creation to that curated set (apps/api curated-images.constant.ts)
// and the runner pulls them straight from ghcr.io with its GHCR_TOKEN. IMAGE_TAG and
diff --git a/apps/package.json b/apps/package.json
index 8fe534c1b..abacf71fa 100644
--- a/apps/package.json
+++ b/apps/package.json
@@ -17,6 +17,8 @@
"dev:dex": "node scripts/dev-dex.mjs",
"e2e:local": "node scripts/e2e-local.mjs",
"e2e:billing:local": "node scripts/billing-local-e2e.mjs",
+ "e2e:billing:stripe": "node scripts/billing-stripe-sandbox-e2e.mjs",
+ "billing:stripe:listen": "node scripts/billing-stripe-sandbox-listener.mjs",
"e2e:billing:all": "node scripts/billing-test-suite.mjs all && node scripts/billing-local-e2e.mjs",
"e2e:dev": "node scripts/e2e-dev-smoke.mjs",
"test:billing": "node scripts/billing-test-suite.mjs quick",
@@ -26,6 +28,7 @@
"test:billing:rating": "node scripts/billing-test-suite.mjs rating",
"test:billing:wallet": "node scripts/billing-test-suite.mjs wallet",
"test:billing:payment": "node scripts/billing-test-suite.mjs payment",
+ "test:billing:stripe": "node --test scripts/billing-stripe-sandbox-config.test.mjs infra/sst-billing-config.test.mjs",
"test:billing:read": "node scripts/billing-test-suite.mjs read",
"test:billing:ui": "node scripts/billing-test-suite.mjs ui",
"test:billing:db": "node scripts/billing-test-suite.mjs db",
diff --git a/apps/scripts/billing-stripe-sandbox-config.mjs b/apps/scripts/billing-stripe-sandbox-config.mjs
new file mode 100644
index 000000000..4442741dd
--- /dev/null
+++ b/apps/scripts/billing-stripe-sandbox-config.mjs
@@ -0,0 +1,80 @@
+export const STRIPE_BILLING_EVENTS = [
+ 'checkout.session.completed',
+ 'checkout.session.async_payment_failed',
+ 'payment_intent.succeeded',
+ 'payment_intent.payment_failed',
+]
+
+export function assertStripeSandboxSecrets(secretKey, webhookSecret) {
+ if (!/^sk_test_[A-Za-z0-9]+$/.test(secretKey ?? '')) {
+ throw new Error('Stripe Sandbox requires a test-mode API key (sk_test_*)')
+ }
+ if (!/^whsec_[A-Za-z0-9]+$/.test(webhookSecret ?? '')) {
+ throw new Error('Stripe Sandbox requires a webhook signing secret (whsec_*)')
+ }
+}
+
+export function stripeListenArguments(forwardUrl) {
+ const url = new URL(forwardUrl)
+ if (!['http:', 'https:'].includes(url.protocol)) {
+ throw new Error('Stripe webhook forwarding URL must use HTTP or HTTPS')
+ }
+ return ['listen', '--skip-update', '--events', STRIPE_BILLING_EVENTS.join(','), '--forward-to', url.toString()]
+}
+
+export function redactStripeSecrets(value) {
+ return String(value).replace(/\b(?:(?:sk|rk|pk)_(?:test|live)|whsec)_[A-Za-z0-9]+\b/g, '[REDACTED]')
+}
+
+export function assertLocalStripeDatabase({ host, port, database }) {
+ if (!['127.0.0.1', 'localhost', '::1'].includes(host)) {
+ throw new Error('Stripe Sandbox E2E may reset payment fields only on loopback PostgreSQL')
+ }
+ if (!Number.isInteger(port) || port <= 0 || port > 65535 || !database) {
+ throw new Error('Stripe Sandbox E2E requires a valid local PostgreSQL target')
+ }
+}
+
+export function assertMatchingStripeAccounts(apiAccountId, cliAccountId) {
+ if (!/^acct_[A-Za-z0-9_]+$/.test(apiAccountId ?? '') || !/^acct_[A-Za-z0-9_]+$/.test(cliAccountId ?? '')) {
+ throw new Error('Stripe Sandbox requires valid Stripe account IDs')
+ }
+ if (apiAccountId !== cliAccountId) {
+ throw new Error('STRIPE_SECRET_KEY and Stripe CLI must use the same Stripe test account')
+ }
+}
+
+export function assertMatchingWebhookSecrets(apiWebhookSecret, cliWebhookSecret) {
+ if (apiWebhookSecret !== cliWebhookSecret) {
+ throw new Error('The API and Stripe CLI listener must use the same webhook signing secret')
+ }
+}
+
+export function assertStripeE2EEvidence(evidence) {
+ if (!evidence.wallet.paymentProviderCustomerId?.startsWith('cus_')) {
+ throw new Error('Stripe E2E did not persist a Stripe customer')
+ }
+ if (!evidence.wallet.paymentProviderMethodId?.startsWith('pm_') || evidence.wallet.paymentMethodLast4 !== '4242') {
+ throw new Error('Stripe E2E did not persist the expected test card')
+ }
+ if (
+ evidence.topUp.status !== 'paid' ||
+ evidence.topUp.amountCents !== '500' ||
+ !evidence.topUp.providerReference?.startsWith('cs_') ||
+ !evidence.topUp.receiptUrl?.startsWith('https://')
+ ) {
+ throw new Error('Stripe E2E did not complete the expected $5 top-up')
+ }
+ if (evidence.ledgerTopUpIds.length !== 1 || evidence.ledgerTopUpIds[0] !== evidence.topUp.id) {
+ throw new Error('Stripe E2E requires exactly one ledger credit for the top-up')
+ }
+ if (
+ !evidence.providerEventTypes.includes('setup_succeeded') ||
+ !evidence.providerEventTypes.includes('top_up_paid')
+ ) {
+ throw new Error('Stripe E2E did not consume both setup and top-up webhooks')
+ }
+ if (BigInt(evidence.paidBalanceAfterCents) - BigInt(evidence.paidBalanceBeforeCents) !== 500n) {
+ throw new Error('Stripe E2E paid balance must increase by 500 cents')
+ }
+}
diff --git a/apps/scripts/billing-stripe-sandbox-config.test.mjs b/apps/scripts/billing-stripe-sandbox-config.test.mjs
new file mode 100644
index 000000000..dd258614c
--- /dev/null
+++ b/apps/scripts/billing-stripe-sandbox-config.test.mjs
@@ -0,0 +1,81 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import {
+ assertLocalStripeDatabase,
+ assertMatchingStripeAccounts,
+ assertMatchingWebhookSecrets,
+ assertStripeE2EEvidence,
+ assertStripeSandboxSecrets,
+ redactStripeSecrets,
+ stripeListenArguments,
+} from './billing-stripe-sandbox-config.mjs'
+
+test('accepts only Stripe test-mode API keys and webhook secrets', () => {
+ assert.doesNotThrow(() => assertStripeSandboxSecrets('sk_test_example', 'whsec_example'))
+ assert.throws(() => assertStripeSandboxSecrets('sk_live_example', 'whsec_example'), /test-mode API key/)
+ assert.throws(() => assertStripeSandboxSecrets('sk_test_example', 'live_secret'), /webhook signing secret/)
+})
+
+test('listens only for payment events consumed by the API', () => {
+ assert.deepEqual(stripeListenArguments('http://localhost:3001/api/billing/webhooks/payment'), [
+ 'listen',
+ '--skip-update',
+ '--events',
+ 'checkout.session.completed,checkout.session.async_payment_failed,payment_intent.succeeded,payment_intent.payment_failed',
+ '--forward-to',
+ 'http://localhost:3001/api/billing/webhooks/payment',
+ ])
+})
+
+test('redacts Stripe credentials from subprocess output', () => {
+ assert.equal(
+ redactStripeSecrets('key=sk_test_abc webhook=whsec_def live=sk_live_ghi'),
+ 'key=[REDACTED] webhook=[REDACTED] live=[REDACTED]',
+ )
+})
+
+test('allows destructive setup only against the loopback local database', () => {
+ assert.doesNotThrow(() => assertLocalStripeDatabase({ host: '127.0.0.1', port: 25432, database: 'boxlite' }))
+ assert.doesNotThrow(() => assertLocalStripeDatabase({ host: 'localhost', port: 25432, database: 'boxlite' }))
+ assert.throws(
+ () => assertLocalStripeDatabase({ host: 'db.example.com', port: 5432, database: 'boxlite' }),
+ /loopback PostgreSQL/,
+ )
+})
+
+test('requires a real Stripe setup, one $5 credit, and one matching ledger row', () => {
+ const evidence = {
+ paidBalanceBeforeCents: '5502',
+ paidBalanceAfterCents: '6002',
+ wallet: {
+ paymentProviderCustomerId: 'cus_test',
+ paymentProviderMethodId: 'pm_test',
+ paymentMethodLast4: '4242',
+ },
+ topUp: {
+ id: 'top-up-1',
+ status: 'paid',
+ amountCents: '500',
+ providerReference: 'cs_test_1',
+ receiptUrl: 'https://pay.stripe.com/receipts/test',
+ },
+ ledgerTopUpIds: ['top-up-1'],
+ providerEventTypes: ['setup_succeeded', 'top_up_paid'],
+ }
+
+ assert.doesNotThrow(() => assertStripeE2EEvidence(evidence))
+ assert.throws(() => assertStripeE2EEvidence({ ...evidence, ledgerTopUpIds: ['top-up-1', 'top-up-1'] }), /one ledger/)
+ assert.throws(() => assertStripeE2EEvidence({ ...evidence, paidBalanceAfterCents: '6502' }), /increase by 500/)
+})
+
+test('requires the API key and Stripe CLI to use the same account', () => {
+ assert.doesNotThrow(() => assertMatchingStripeAccounts('acct_api', 'acct_api'))
+ assert.throws(() => assertMatchingStripeAccounts('acct_api', 'acct_cli'), /same Stripe test account/)
+ assert.throws(() => assertMatchingStripeAccounts('', 'acct_cli'), /valid Stripe account/)
+})
+
+test('requires the API and listener to use the same webhook secret', () => {
+ assert.doesNotThrow(() => assertMatchingWebhookSecrets('whsec_same', 'whsec_same'))
+ assert.throws(() => assertMatchingWebhookSecrets('whsec_api', 'whsec_cli'), /same webhook signing secret/)
+})
diff --git a/apps/scripts/billing-stripe-sandbox-e2e.mjs b/apps/scripts/billing-stripe-sandbox-e2e.mjs
new file mode 100644
index 000000000..8ce14adba
--- /dev/null
+++ b/apps/scripts/billing-stripe-sandbox-e2e.mjs
@@ -0,0 +1,275 @@
+#!/usr/bin/env node
+
+import fs from 'node:fs/promises'
+import path from 'node:path'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+import pg from 'pg'
+import { chromium } from 'playwright-core'
+import { assertLocalStripeDatabase, assertStripeE2EEvidence } from './billing-stripe-sandbox-config.mjs'
+
+const { Client } = pg
+const scriptsRoot = path.dirname(fileURLToPath(import.meta.url))
+const repoRoot = path.resolve(scriptsRoot, '..', '..')
+const dashboardUrl = stripTrailingSlash(process.env.BOXLITE_E2E_BASE_URL || 'http://localhost:3000')
+const loginEmail = process.env.BOXLITE_E2E_LOGIN_EMAIL || 'admin@boxlite.dev'
+const loginPassword = process.env.BOXLITE_E2E_LOGIN_PASSWORD || 'password'
+const timeoutMs = Number(process.env.BILLING_STRIPE_E2E_TIMEOUT_MS || 60_000)
+const chromeExecutablePath =
+ process.env.CHROME_EXECUTABLE_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
+const database = {
+ host: process.env.DB_HOST || '127.0.0.1',
+ port: Number(process.env.DB_PORT || 25432),
+ user: process.env.DB_USERNAME || 'boxlite',
+ password: process.env.DB_PASSWORD || 'boxlite',
+ database: process.env.DB_DATABASE || 'boxlite',
+}
+const hostStartedAt = new Date()
+const runId = hostStartedAt.toISOString().replaceAll(/[:.]/g, '-')
+const artifactsDir =
+ process.env.BOXLITE_BILLING_E2E_ARTIFACTS || path.join(repoRoot, '.apps-local', 'logs', 'billing-stripe-e2e', runId)
+
+assertLocalStripeDatabase(database)
+await fs.mkdir(artifactsDir, { recursive: true })
+
+const db = new Client(database)
+await db.connect()
+const runStartedAt = await loadDatabaseTime()
+const browser = await chromium.launch({
+ headless: process.env.HEADLESS !== 'false',
+ executablePath: chromeExecutablePath,
+})
+const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
+const page = await context.newPage()
+const localHttpErrors = []
+let organizationId = null
+
+page.setDefaultTimeout(timeoutMs)
+page.on('request', (request) => {
+ const match = new URL(request.url()).pathname.match(/^\/api\/organization\/([^/]+)\/billing\//)
+ if (match) organizationId = match[1]
+})
+page.on('response', (response) => {
+ if (response.status() >= 400 && isLocalUrl(response.url()) && !isExpectedAdminAccessProbe(response)) {
+ localHttpErrors.push(`${response.status()} ${response.request().method()} ${response.url()}`)
+ }
+})
+
+try {
+ await signIn()
+ await page.goto(`${dashboardUrl}/dashboard/billing`, { waitUntil: 'domcontentloaded' })
+ await page.getByRole('heading', { name: 'Billing', exact: true }).waitFor()
+ await waitFor(() => organizationId, 'Billing organization request')
+
+ const walletBefore = await loadWallet(organizationId)
+ await resetLocalPaymentProvider(organizationId)
+ await page.reload({ waitUntil: 'domcontentloaded' })
+ await page.getByRole('button', { name: 'Set up payment method', exact: true }).waitFor()
+
+ await page.getByRole('button', { name: 'Set up payment method', exact: true }).click()
+ await page
+ .getByText(
+ 'Stripe securely saves this card for future top-ups. Automatic charges occur only after you enable auto-reload.',
+ { exact: true },
+ )
+ .waitFor()
+ await page.getByRole('button', { name: 'Confirm setup', exact: true }).click()
+ await page.waitForURL(/^https:\/\/checkout\.stripe\.com\//)
+
+ const stripeEmail = page.getByRole('textbox', { name: 'Email', exact: true })
+ if ((await stripeEmail.count()) === 1) {
+ await stripeEmail.fill(loginEmail)
+ } else {
+ await page.getByText(loginEmail, { exact: true }).waitFor()
+ }
+ await page.getByRole('textbox', { name: 'Card number', exact: true }).fill('4242424242424242')
+ await page.getByRole('textbox', { name: 'Expiration', exact: true }).fill('1230')
+ await page.getByRole('textbox', { name: 'CVC', exact: true }).fill('123')
+ await page.getByRole('textbox', { name: 'Cardholder name', exact: true }).fill('BoxLite Test')
+ await page.getByRole('button', { name: 'Save', exact: true }).click()
+ await page.waitForURL(`${dashboardUrl}/dashboard/billing?payment=success`)
+
+ await waitFor(async () => {
+ const wallet = await loadWallet(organizationId)
+ return wallet.paymentProviderCustomerId?.startsWith('cus_') && wallet.paymentProviderMethodId?.startsWith('pm_')
+ }, 'Stripe setup webhook')
+ await page.screenshot({ path: path.join(artifactsDir, 'stripe-setup-complete.png'), fullPage: true })
+
+ await page.getByRole('button', { name: 'Add funds', exact: true }).click()
+ await page.getByRole('textbox', { name: 'Custom top-up amount', exact: true }).fill('5.00')
+ await page.getByRole('button', { name: 'Top up', exact: true }).click()
+ await page.getByRole('button', { name: 'Confirm top-up', exact: true }).click()
+ await page.waitForURL(/^https:\/\/checkout\.stripe\.com\//)
+ await page.getByRole('button', { name: 'Pay', exact: true }).click()
+ await page.waitForURL(`${dashboardUrl}/dashboard/billing?payment=success`)
+
+ const topUp = await waitFor(() => loadPaidTopUp(organizationId), 'paid Stripe top-up')
+ const walletAfter = await loadWallet(organizationId)
+ const ledgerTopUpIds = await loadLedgerTopUpIds(topUp.id)
+ const providerEventTypes = await loadProviderEventTypes()
+ const evidence = {
+ paidBalanceBeforeCents: walletBefore.paidBalanceCents,
+ paidBalanceAfterCents: walletAfter.paidBalanceCents,
+ wallet: walletAfter,
+ topUp,
+ ledgerTopUpIds,
+ providerEventTypes,
+ }
+ assertStripeE2EEvidence(evidence)
+ assertNoLocalHttpErrors()
+
+ await page.getByRole('tab', { name: 'Billing', exact: true }).click()
+ await page.getByRole('heading', { name: '▸ Receipts', exact: true }).waitFor()
+ await page.screenshot({ path: path.join(artifactsDir, 'stripe-top-up-complete.png'), fullPage: true })
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ organizationId,
+ paidBalanceBeforeCents: evidence.paidBalanceBeforeCents,
+ paidBalanceAfterCents: evidence.paidBalanceAfterCents,
+ topUpId: topUp.id,
+ providerReferenceType: topUp.providerReference.slice(0, 3),
+ ledgerCredits: ledgerTopUpIds.length,
+ providerEventTypes,
+ artifactsDir,
+ },
+ null,
+ 2,
+ ),
+ )
+} catch (error) {
+ await page.screenshot({ path: path.join(artifactsDir, 'failure.png'), fullPage: true }).catch(() => {})
+ throw error
+} finally {
+ await browser.close()
+ await db.end()
+}
+
+async function signIn() {
+ await page.goto(`${dashboardUrl}/dashboard/billing`, { waitUntil: 'domcontentloaded' })
+ await settleAuthState()
+
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ if (
+ await page
+ .locator('#login')
+ .isVisible()
+ .catch(() => false)
+ ) {
+ await page.locator('#login').fill(loginEmail)
+ await page.locator('#password').fill(loginPassword)
+ await page.locator('#submit-login').click()
+ await settleAuthState()
+ continue
+ }
+
+ const grantButton = page.getByRole('button', { name: 'Grant Access', exact: true })
+ if (await grantButton.isVisible().catch(() => false)) {
+ await grantButton.click()
+ await settleAuthState()
+ continue
+ }
+ break
+ }
+
+ if (!page.url().startsWith(new URL(dashboardUrl).origin)) {
+ throw new Error(`Stripe E2E could not complete local sign-in; current URL is ${page.url()}`)
+ }
+}
+
+async function settleAuthState() {
+ await Promise.race([
+ page.getByRole('heading', { name: 'Billing', exact: true }).waitFor(),
+ page.locator('#login').waitFor(),
+ page.getByRole('button', { name: 'Grant Access', exact: true }).waitFor(),
+ ])
+}
+
+async function loadWallet(targetOrganizationId) {
+ const result = await db.query(
+ `SELECT "paidBalanceCents", "paymentProviderCustomerId", "paymentProviderMethodId", "paymentMethodLast4"
+ FROM wallet WHERE "organizationId" = $1`,
+ [targetOrganizationId],
+ )
+ if (result.rowCount !== 1) throw new Error(`Expected one wallet for organization ${targetOrganizationId}`)
+ return result.rows[0]
+}
+
+async function loadDatabaseTime() {
+ const result = await db.query('SELECT clock_timestamp() AS now')
+ return result.rows[0].now
+}
+
+async function resetLocalPaymentProvider(targetOrganizationId) {
+ await db.query(
+ `UPDATE wallet SET
+ "paymentProviderCustomerId" = NULL,
+ "paymentProviderMethodId" = NULL,
+ "paymentMethodBrand" = NULL,
+ "paymentMethodLast4" = NULL,
+ "autoReloadEnabled" = false,
+ "autoReloadThresholdCents" = NULL,
+ "autoReloadTargetCents" = NULL,
+ "autoReloadNextAttemptAt" = NULL
+ WHERE "organizationId" = $1`,
+ [targetOrganizationId],
+ )
+}
+
+async function loadPaidTopUp(targetOrganizationId) {
+ const result = await db.query(
+ `SELECT id, status, "amountCents", "providerReference", "receiptUrl"
+ FROM top_up_record
+ WHERE "organizationId" = $1 AND source = 'manual' AND "createdAt" >= $2
+ ORDER BY "createdAt" DESC LIMIT 1`,
+ [targetOrganizationId, runStartedAt],
+ )
+ const topUp = result.rows[0]
+ return topUp?.status === 'paid' ? topUp : null
+}
+
+async function loadLedgerTopUpIds(topUpId) {
+ const result = await db.query(
+ `SELECT metadata->>'topUpId' AS "topUpId"
+ FROM wallet_transaction
+ WHERE kind = 'top_up' AND metadata->>'topUpId' = $1`,
+ [topUpId],
+ )
+ return result.rows.map((row) => row.topUpId)
+}
+
+async function loadProviderEventTypes() {
+ const result = await db.query(
+ `SELECT "eventType" FROM payment_provider_event WHERE "createdAt" >= $1 ORDER BY "createdAt"`,
+ [runStartedAt],
+ )
+ return result.rows.map((row) => row.eventType)
+}
+
+async function waitFor(check, label) {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ const result = await check()
+ if (result) return result
+ await new Promise((resolve) => setTimeout(resolve, 250))
+ }
+ throw new Error(`Timed out waiting for ${label}`)
+}
+
+function assertNoLocalHttpErrors() {
+ if (localHttpErrors.length > 0) throw new Error(`Local HTTP failures:\n${localHttpErrors.join('\n')}`)
+}
+
+function isExpectedAdminAccessProbe(response) {
+ return response.status() === 403 && new URL(response.url()).pathname === '/api/admin/overview'
+}
+
+function isLocalUrl(value) {
+ const hostname = new URL(value).hostname
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname.endsWith('.localhost')
+}
+
+function stripTrailingSlash(value) {
+ return value.replace(/\/+$/, '')
+}
diff --git a/apps/scripts/billing-stripe-sandbox-listener.mjs b/apps/scripts/billing-stripe-sandbox-listener.mjs
new file mode 100644
index 000000000..5bc1b10b3
--- /dev/null
+++ b/apps/scripts/billing-stripe-sandbox-listener.mjs
@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+
+import { execFile, spawn } from 'node:child_process'
+import process from 'node:process'
+import readline from 'node:readline'
+import { promisify } from 'node:util'
+import {
+ assertMatchingStripeAccounts,
+ assertMatchingWebhookSecrets,
+ assertStripeSandboxSecrets,
+ redactStripeSecrets,
+ stripeListenArguments,
+} from './billing-stripe-sandbox-config.mjs'
+
+const execFileAsync = promisify(execFile)
+const secretKey = process.env.STRIPE_SECRET_KEY
+const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
+const forwardUrl = process.env.STRIPE_WEBHOOK_FORWARD_URL || 'http://localhost:3001/api/billing/webhooks/payment'
+
+assertStripeSandboxSecrets(secretKey, webhookSecret)
+
+const cliEnvironment = { ...process.env }
+delete cliEnvironment.STRIPE_API_KEY
+const [apiAccountId, cliAccountId, cliWebhookSecret] = await Promise.all([
+ stripeAccountId({ ...cliEnvironment, STRIPE_API_KEY: secretKey }),
+ stripeAccountId(cliEnvironment),
+ stripeWebhookSecret(cliEnvironment),
+])
+assertStripeSandboxSecrets(secretKey, cliWebhookSecret)
+assertMatchingStripeAccounts(apiAccountId, cliAccountId)
+assertMatchingWebhookSecrets(webhookSecret, cliWebhookSecret)
+
+const listener = spawn('stripe', stripeListenArguments(forwardUrl), {
+ env: cliEnvironment,
+ stdio: ['ignore', 'pipe', 'pipe'],
+})
+pipeRedactedLines(listener.stdout, process.stdout)
+pipeRedactedLines(listener.stderr, process.stderr)
+
+for (const signal of ['SIGINT', 'SIGTERM']) {
+ process.once(signal, () => listener.kill(signal))
+}
+
+const exit = await new Promise((resolve, reject) => {
+ listener.once('error', reject)
+ listener.once('exit', (code, signal) => resolve({ code, signal }))
+})
+if (exit.code !== 0 && exit.signal !== 'SIGINT' && exit.signal !== 'SIGTERM') {
+ throw new Error(`Stripe listener stopped unexpectedly (${exit.signal ?? `exit ${exit.code}`})`)
+}
+
+async function stripeAccountId(environment) {
+ const { stdout } = await execFileAsync('stripe', ['get', '/v1/account', '--color', 'off'], {
+ env: environment,
+ maxBuffer: 1024 * 1024,
+ })
+ const account = JSON.parse(stdout)
+ return account.id
+}
+
+async function stripeWebhookSecret(environment) {
+ const { stdout } = await execFileAsync('stripe', ['listen', '--skip-update', '--print-secret', '--color', 'off'], {
+ env: environment,
+ maxBuffer: 1024 * 1024,
+ })
+ return stdout.trim()
+}
+
+function pipeRedactedLines(input, output) {
+ const lines = readline.createInterface({ input })
+ lines.on('line', (line) => output.write(`${redactStripeSecrets(line)}\n`))
+}
diff --git a/apps/scripts/billing-test-suite.mjs b/apps/scripts/billing-test-suite.mjs
index e81dfc689..56e48b94c 100644
--- a/apps/scripts/billing-test-suite.mjs
+++ b/apps/scripts/billing-test-suite.mjs
@@ -54,6 +54,10 @@ const stages = {
'api/src/billing/payment/payment.controller.spec.ts',
'api/src/billing/payment/payment.service.spec.ts',
]),
+ stripe: {
+ label: 'Stripe Sandbox configuration',
+ args: ['test:billing:stripe'],
+ },
read: jest('Billing read API', [
'api/src/billing/billing-read.service.spec.ts',
'api/src/billing/billing.controller.spec.ts',
@@ -79,6 +83,9 @@ const stages = {
'dashboard/src/billing-api',
'dashboard/src/hooks/mutations',
'scripts/billing-local-e2e.mjs',
+ 'scripts/billing-stripe-sandbox-config.mjs',
+ 'scripts/billing-stripe-sandbox-e2e.mjs',
+ 'scripts/billing-stripe-sandbox-listener.mjs',
'scripts/billing-test-suite.mjs',
],
},
@@ -97,21 +104,22 @@ const suites = {
usage: { description: 'PR1 usage lifecycle tests', stages: [stages.usage] },
rating: { description: 'PR2 rating and price snapshots', stages: [stages.rating] },
wallet: { description: 'PR3 wallet and settlement', stages: [stages.wallet] },
- payment: { description: 'PR5 providers, webhooks, and top-ups', stages: [stages.payment] },
+ payment: { description: 'PR5 providers, webhooks, and top-ups', stages: [stages.payment, stages.stripe] },
+ stripe: { description: 'PR6 Stripe Sandbox configuration', stages: [stages.stripe] },
read: { description: 'PR4 billing read API', stages: [stages.read] },
ui: { description: 'PR4/PR5 dashboard behavior', stages: [stages.ui] },
db: { description: 'Real PostgreSQL concurrency and recovery', stages: [stages.db] },
quick: {
description: 'Fast deterministic API and UI regression suite',
- stages: [stages.apiUnit, stages.ui],
+ stages: [stages.apiUnit, stages.stripe, stages.ui],
},
all: {
description: 'Complete deterministic Billing collection',
- stages: [stages.apiUnit, stages.db, stages.ui],
+ stages: [stages.apiUnit, stages.stripe, stages.db, stages.ui],
},
verify: {
description: 'Complete collection plus lint and builds',
- stages: [stages.apiUnit, stages.db, stages.ui, stages.lint, stages.apiBuild, stages.dashboardBuild],
+ stages: [stages.apiUnit, stages.stripe, stages.db, stages.ui, stages.lint, stages.apiBuild, stages.dashboardBuild],
},
}