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
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function PaymentMethodCard({
<AlertDialogTitle>Confirm payment method setup</AlertDialogTitle>
<AlertDialogDescription>
{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.'}
</AlertDialogDescription>
</AlertDialogHeader>
Expand Down
26 changes: 26 additions & 0 deletions apps/infra-local/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 8 additions & 1 deletion apps/infra/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <NAME> "<value>" --stage <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 <NAME> "<value>" --stage <stage>` |
| **Cloudflare provider creds** — `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_DEFAULT_ACCOUNT_ID` | AWS SSM (`SecureString`, per stage) | `aws ssm put-parameter --type SecureString --name /boxlite/<stage>/cloudflare-…` |
| **Non-secret config** — `STACK_DOMAIN`, `OIDC_ISSUER_BASE_URL`, `OIDC_AUDIENCE`, toggles | local `.env` (gitignored) | edit `.env` |

Expand All @@ -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`.
Expand Down
15 changes: 15 additions & 0 deletions apps/infra/sst-billing-config.test.mjs
Original file line number Diff line number Diff line change
@@ -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/)
})
4 changes: 4 additions & 0 deletions apps/infra/sst.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
80 changes: 80 additions & 0 deletions apps/scripts/billing-stripe-sandbox-config.mjs
Original file line number Diff line number Diff line change
@@ -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')
}
}
81 changes: 81 additions & 0 deletions apps/scripts/billing-stripe-sandbox-config.test.mjs
Original file line number Diff line number Diff line change
@@ -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/)
})
Loading
Loading