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], }, }