From 02f1728f0f271b258e7b0260c5806d51e6a68e2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:10:17 +0900 Subject: [PATCH 01/35] test(billing): define durable checkout attempt contract --- tests/unit/billing-checkout-attempt.test.mjs | 208 +++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/unit/billing-checkout-attempt.test.mjs diff --git a/tests/unit/billing-checkout-attempt.test.mjs b/tests/unit/billing-checkout-attempt.test.mjs new file mode 100644 index 00000000..09b66a3e --- /dev/null +++ b/tests/unit/billing-checkout-attempt.test.mjs @@ -0,0 +1,208 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { + BILLING_CHECKOUT_REUSE_WINDOW_MS, + createSqliteBillingCheckoutAttemptRepository, + installBillingCheckoutAttemptSchema, +} from '../../server/billing_checkout_attempt.mjs'; + +function createDatabase() { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON'); + database.exec('CREATE TABLE orgs (id INTEGER PRIMARY KEY)'); + database.prepare('INSERT INTO orgs(id) VALUES(?)').run(7); + database.prepare('INSERT INTO orgs(id) VALUES(?)').run(8); + return database; +} + +function deterministicIds() { + const values = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', + '44444444-4444-4444-8444-444444444444', + '55555555-5555-4555-8555-555555555555', + '66666666-6666-4666-8666-666666666666', + '77777777-7777-4777-8777-777777777777', + '88888888-8888-4888-8888-888888888888', + ]; + return () => { + const value = values.shift(); + assert.ok(value, 'test UUID source must not be exhausted'); + return value; + }; +} + +test('checkout-attempt bootstrap owns only compliant normalized objects', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + installBillingCheckoutAttemptSchema(database); + + const table = database.prepare( + "SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name = 'billing_checkout_attempts'", + ).get(); + assert.equal(table.name, 'billing_checkout_attempts'); + assert.match(table.sql, /CHECK\s*\(attempt_state IN \('pending','provider_succeeded','provider_failed','expired'\)\)/); + + const index = database.prepare( + "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND name = 'billing_checkout_pending_attempts'", + ).get(); + assert.equal(index.name, 'billing_checkout_pending_attempts'); + assert.match(index.sql, /WHERE attempt_state = 'pending'/); + + const columns = database.prepare("PRAGMA table_info('billing_checkout_attempts')").all().map((row) => row.name); + assert.deepEqual(columns, [ + 'attempt_id', + 'organization_id', + 'price_id', + 'idempotency_key', + 'attempt_state', + 'provider_session_id', + 'created_at_ms', + 'updated_at_ms', + ]); + assert.equal(columns.some((name) => /secret|token/i.test(name)), false); + + const foreignKeys = database.prepare("PRAGMA foreign_key_list('billing_checkout_attempts')").all(); + assert.equal(foreignKeys.length, 1); + assert.equal(foreignKeys[0].table, 'orgs'); + assert.equal(foreignKeys[0].from, 'organization_id'); + assert.equal(foreignKeys[0].on_delete, 'CASCADE'); +}); + +test('repository never performs request-time schema installation', () => { + const database = createDatabase(); + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => 1_000, + }); + + assert.throws( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }), + /billing_checkout_attempts/, + ); + const table = database.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'billing_checkout_attempts'", + ).get(); + assert.equal(table, undefined); +}); + +test('pending uncertain attempts reuse one durable Stripe idempotency key inside the safe window', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + let nowMs = 1_000_000; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => nowMs, + }); + + const first = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + assert.deepEqual(first, { + attemptId: '11111111-1111-4111-8111-111111111111', + idempotencyKey: '22222222-2222-4222-8222-222222222222', + state: 'pending', + reused: false, + }); + + nowMs += BILLING_CHECKOUT_REUSE_WINDOW_MS - 1; + const retry = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + assert.deepEqual(retry, { ...first, reused: true }); + + const otherTenant = repository.startAttempt({ organizationId: 8, priceId: 'price_pro' }); + assert.notEqual(otherTenant.attemptId, first.attemptId); + assert.notEqual(otherTenant.idempotencyKey, first.idempotencyKey); + + const persisted = database.prepare( + 'SELECT organization_id, price_id, idempotency_key, attempt_state FROM billing_checkout_attempts WHERE attempt_id = ?', + ).get(first.attemptId); + assert.deepEqual({ ...persisted }, { + organization_id: 7, + price_id: 'price_pro', + idempotency_key: first.idempotencyKey, + attempt_state: 'pending', + }); +}); + +test('terminal provider outcomes close the retry identity and a later checkout gets fresh authority', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + let nowMs = 2_000_000; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => nowMs, + }); + + const successAttempt = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + repository.markProviderSucceeded({ + attemptId: successAttempt.attemptId, + providerSessionId: 'cs_test_success_123', + }); + const successRow = database.prepare( + 'SELECT attempt_state, provider_session_id FROM billing_checkout_attempts WHERE attempt_id = ?', + ).get(successAttempt.attemptId); + assert.deepEqual({ ...successRow }, { + attempt_state: 'provider_succeeded', + provider_session_id: 'cs_test_success_123', + }); + + nowMs += 1; + const afterSuccess = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + assert.notEqual(afterSuccess.idempotencyKey, successAttempt.idempotencyKey); + + repository.markProviderFailed({ attemptId: afterSuccess.attemptId }); + nowMs += 1; + const afterFailure = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + assert.notEqual(afterFailure.idempotencyKey, afterSuccess.idempotencyKey); +}); + +test('pending identities are never reused at or beyond the Stripe retention safety window', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + let nowMs = 3_000_000; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => nowMs, + }); + + const oldAttempt = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + nowMs += BILLING_CHECKOUT_REUSE_WINDOW_MS; + const replacement = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + + assert.notEqual(replacement.attemptId, oldAttempt.attemptId); + assert.notEqual(replacement.idempotencyKey, oldAttempt.idempotencyKey); + const oldRow = database.prepare( + 'SELECT attempt_state FROM billing_checkout_attempts WHERE attempt_id = ?', + ).get(oldAttempt.attemptId); + assert.equal(oldRow.attempt_state, 'expired'); +}); + +test('repository rejects malformed identifiers and impossible terminal transitions', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => 4_000_000, + }); + + assert.throws( + () => repository.startAttempt({ organizationId: 0, priceId: 'price_pro' }), + /organizationId/, + ); + assert.throws( + () => repository.startAttempt({ organizationId: 7, priceId: ' ' }), + /priceId/, + ); + + const attempt = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + repository.markProviderFailed({ attemptId: attempt.attemptId }); + assert.throws( + () => repository.markProviderSucceeded({ attemptId: attempt.attemptId, providerSessionId: 'cs_too_late' }), + /pending checkout attempt/, + ); + assert.throws( + () => repository.markProviderFailed({ attemptId: 'not-an-attempt' }), + /pending checkout attempt/, + ); +}); From 06acaecf8f3d6f04d66345dc2f0acd24fbcb9bba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:11:43 +0900 Subject: [PATCH 02/35] feat(billing): persist durable checkout attempts --- server/billing_checkout_attempt.mjs | 209 ++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 server/billing_checkout_attempt.mjs diff --git a/server/billing_checkout_attempt.mjs b/server/billing_checkout_attempt.mjs new file mode 100644 index 00000000..7436f4fb --- /dev/null +++ b/server/billing_checkout_attempt.mjs @@ -0,0 +1,209 @@ +import { randomUUID as systemRandomUUID } from 'node:crypto'; + +/** + * Maximum age for reusing an unresolved Stripe idempotency identity. + * + * Stripe documents that idempotency keys may be pruned after at least 24 hours. + * ScopeWeave therefore uses a 23-hour ceiling so a locally reusable attempt never + * intentionally crosses the provider's documented retention boundary. + */ +export const BILLING_CHECKOUT_REUSE_WINDOW_MS = 23 * 60 * 60 * 1000; + +const MAX_PRICE_ID_LENGTH = 255; +const MAX_PROVIDER_SESSION_ID_LENGTH = 255; +const MAX_IDENTIFIER_LENGTH = 255; +const SAVEPOINT_NAME = 'billing_checkout_attempt_write'; + +function positiveOrganizationId(value) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new TypeError('organizationId must be a positive integer'); + } + return parsed; +} + +function boundedRequiredString(value, name, maximumLength) { + if (typeof value !== 'string') throw new TypeError(`${name} must be a non-empty string`); + const normalized = value.trim(); + if (normalized.length === 0 || normalized.length > maximumLength) { + throw new TypeError(`${name} must be a non-empty string no longer than ${maximumLength} characters`); + } + return normalized; +} + +function safeNow(now) { + const value = Number(now()); + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError('checkout attempt clock must return a non-negative safe integer'); + } + return value; +} + +function opaqueIdentifier(randomUUID, name) { + return boundedRequiredString(randomUUID(), name, MAX_IDENTIFIER_LENGTH); +} + +function withSavepoint(database, operation) { + database.exec(`SAVEPOINT ${SAVEPOINT_NAME}`); + try { + const result = operation(); + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + return result; + } catch (error) { + try { + database.exec(`ROLLBACK TO SAVEPOINT ${SAVEPOINT_NAME}`); + } finally { + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + } + throw error; + } +} + +/** + * Install the durable Checkout-attempt schema during process/database bootstrap. + * + * The schema is intentionally separate from request handling. One row represents + * one provider-attempt identity; organization and price facts are referenced or + * recorded once, while provider outcome is a state of that same attempt. The + * partial unique index guarantees at most one unresolved retry identity for an + * organization/price pair. + * + * @param {import('node:sqlite').DatabaseSync} database - Open SQLite database. + * @returns {void} + */ +export function installBillingCheckoutAttemptSchema(database) { + database.exec(` + CREATE TABLE IF NOT EXISTS billing_checkout_attempts ( + attempt_id TEXT PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + price_id TEXT NOT NULL CHECK(length(price_id) BETWEEN 1 AND ${MAX_PRICE_ID_LENGTH}), + idempotency_key TEXT NOT NULL UNIQUE CHECK(length(idempotency_key) BETWEEN 1 AND ${MAX_IDENTIFIER_LENGTH}), + attempt_state TEXT NOT NULL CHECK(attempt_state IN ('pending','provider_succeeded','provider_failed','expired')), + provider_session_id TEXT CHECK(provider_session_id IS NULL OR length(provider_session_id) BETWEEN 1 AND ${MAX_PROVIDER_SESSION_ID_LENGTH}), + created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0), + updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms), + CHECK( + (attempt_state = 'provider_succeeded' AND provider_session_id IS NOT NULL) + OR (attempt_state <> 'provider_succeeded' AND provider_session_id IS NULL) + ) + ); + CREATE UNIQUE INDEX IF NOT EXISTS billing_checkout_pending_attempts + ON billing_checkout_attempts(organization_id, price_id) + WHERE attempt_state = 'pending'; + `); +} + +/** + * Create the SQLite persistence port for Checkout attempt identities. + * + * This constructor never creates database objects. Call + * {@link installBillingCheckoutAttemptSchema} exactly from bootstrap/migration + * code before serving requests. The returned operations are synchronous because + * `node:sqlite` is synchronous and each mutation is protected by a savepoint. + * + * @param {import('node:sqlite').DatabaseSync} database - Bootstrapped database. + * @param {object} [dependencies] - Deterministic seams for tests. + * @param {() => string} [dependencies.randomUUID] - Cryptographic UUID source. + * @param {() => number} [dependencies.now] - Persisted wall-clock milliseconds. + * @returns {{ + * startAttempt(input: {organizationId: string|number, priceId: string}): {attemptId: string, idempotencyKey: string, state: 'pending', reused: boolean}, + * markProviderSucceeded(input: {attemptId: string, providerSessionId: string}): void, + * markProviderFailed(input: {attemptId: string}): void + * }} Checkout-attempt persistence port. + */ +export function createSqliteBillingCheckoutAttemptRepository( + database, + { randomUUID = systemRandomUUID, now = Date.now } = {}, +) { + if (!database || typeof database.prepare !== 'function' || typeof database.exec !== 'function') { + throw new TypeError('database must provide SQLite prepare/exec operations'); + } + if (typeof randomUUID !== 'function') throw new TypeError('randomUUID must be a function'); + if (typeof now !== 'function') throw new TypeError('now must be a function'); + + const selectPending = database.prepare(` + SELECT attempt_id, idempotency_key, created_at_ms + FROM billing_checkout_attempts + WHERE organization_id = ? AND price_id = ? AND attempt_state = 'pending' + LIMIT 1 + `); + const expirePending = database.prepare(` + UPDATE billing_checkout_attempts + SET attempt_state = 'expired', updated_at_ms = ? + WHERE attempt_id = ? AND attempt_state = 'pending' + `); + const insertAttempt = database.prepare(` + INSERT INTO billing_checkout_attempts( + attempt_id, organization_id, price_id, idempotency_key, + attempt_state, provider_session_id, created_at_ms, updated_at_ms + ) VALUES(?,?,?,?, 'pending', NULL, ?, ?) + `); + const succeedAttempt = database.prepare(` + UPDATE billing_checkout_attempts + SET attempt_state = 'provider_succeeded', provider_session_id = ?, updated_at_ms = ? + WHERE attempt_id = ? AND attempt_state = 'pending' + `); + const failAttempt = database.prepare(` + UPDATE billing_checkout_attempts + SET attempt_state = 'provider_failed', updated_at_ms = ? + WHERE attempt_id = ? AND attempt_state = 'pending' + `); + + return { + /** + * Reuse only a still-pending, same-tenant/same-price identity inside the + * provider retention safety window; otherwise create fresh opaque authority. + */ + startAttempt({ organizationId, priceId }) { + const organization = positiveOrganizationId(organizationId); + const price = boundedRequiredString(priceId, 'priceId', MAX_PRICE_ID_LENGTH); + const nowMs = safeNow(now); + + return withSavepoint(database, () => { + const pending = selectPending.get(organization, price); + if (pending) { + const ageMs = nowMs - Number(pending.created_at_ms); + if (ageMs >= 0 && ageMs < BILLING_CHECKOUT_REUSE_WINDOW_MS) { + return { + attemptId: pending.attempt_id, + idempotencyKey: pending.idempotency_key, + state: 'pending', + reused: true, + }; + } + expirePending.run(Math.max(nowMs, Number(pending.created_at_ms)), pending.attempt_id); + } + + const attemptId = opaqueIdentifier(randomUUID, 'attemptId'); + const idempotencyKey = opaqueIdentifier(randomUUID, 'idempotencyKey'); + insertAttempt.run(attemptId, organization, price, idempotencyKey, nowMs, nowMs); + return { attemptId, idempotencyKey, state: 'pending', reused: false }; + }); + }, + + /** Mark one unresolved attempt successful and bind its provider session ID. */ + markProviderSucceeded({ attemptId, providerSessionId }) { + const id = boundedRequiredString(attemptId, 'attemptId', MAX_IDENTIFIER_LENGTH); + const sessionId = boundedRequiredString( + providerSessionId, + 'providerSessionId', + MAX_PROVIDER_SESSION_ID_LENGTH, + ); + const nowMs = safeNow(now); + const result = withSavepoint(database, () => succeedAttempt.run(sessionId, nowMs, id)); + if (Number(result.changes) !== 1) { + throw new Error('expected one pending checkout attempt for provider success'); + } + }, + + /** Mark one unresolved attempt as a known provider failure. */ + markProviderFailed({ attemptId }) { + const id = boundedRequiredString(attemptId, 'attemptId', MAX_IDENTIFIER_LENGTH); + const nowMs = safeNow(now); + const result = withSavepoint(database, () => failAttempt.run(nowMs, id)); + if (Number(result.changes) !== 1) { + throw new Error('expected one pending checkout attempt for provider failure'); + } + }, + }; +} From 12d7fb8495a1c5707f3c3adb258a829ec9d23e60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:12:14 +0900 Subject: [PATCH 03/35] fix(billing): keep attempt repository bootstrap-only --- server/billing_checkout_attempt.mjs | 86 +++++++++++++++++------------ 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/server/billing_checkout_attempt.mjs b/server/billing_checkout_attempt.mjs index 7436f4fb..3897c272 100644 --- a/server/billing_checkout_attempt.mjs +++ b/server/billing_checkout_attempt.mjs @@ -98,8 +98,9 @@ export function installBillingCheckoutAttemptSchema(database) { * * This constructor never creates database objects. Call * {@link installBillingCheckoutAttemptSchema} exactly from bootstrap/migration - * code before serving requests. The returned operations are synchronous because - * `node:sqlite` is synchronous and each mutation is protected by a savepoint. + * code before serving requests. SQL statements are prepared lazily so merely + * constructing the port cannot accidentally turn missing bootstrap into schema + * creation or another hidden startup side effect. * * @param {import('node:sqlite').DatabaseSync} database - Bootstrapped database. * @param {object} [dependencies] - Deterministic seams for tests. @@ -121,33 +122,40 @@ export function createSqliteBillingCheckoutAttemptRepository( if (typeof randomUUID !== 'function') throw new TypeError('randomUUID must be a function'); if (typeof now !== 'function') throw new TypeError('now must be a function'); - const selectPending = database.prepare(` - SELECT attempt_id, idempotency_key, created_at_ms - FROM billing_checkout_attempts - WHERE organization_id = ? AND price_id = ? AND attempt_state = 'pending' - LIMIT 1 - `); - const expirePending = database.prepare(` - UPDATE billing_checkout_attempts - SET attempt_state = 'expired', updated_at_ms = ? - WHERE attempt_id = ? AND attempt_state = 'pending' - `); - const insertAttempt = database.prepare(` - INSERT INTO billing_checkout_attempts( - attempt_id, organization_id, price_id, idempotency_key, - attempt_state, provider_session_id, created_at_ms, updated_at_ms - ) VALUES(?,?,?,?, 'pending', NULL, ?, ?) - `); - const succeedAttempt = database.prepare(` - UPDATE billing_checkout_attempts - SET attempt_state = 'provider_succeeded', provider_session_id = ?, updated_at_ms = ? - WHERE attempt_id = ? AND attempt_state = 'pending' - `); - const failAttempt = database.prepare(` - UPDATE billing_checkout_attempts - SET attempt_state = 'provider_failed', updated_at_ms = ? - WHERE attempt_id = ? AND attempt_state = 'pending' - `); + let preparedStatements; + const statements = () => { + if (preparedStatements) return preparedStatements; + preparedStatements = { + selectPending: database.prepare(` + SELECT attempt_id, idempotency_key, created_at_ms + FROM billing_checkout_attempts + WHERE organization_id = ? AND price_id = ? AND attempt_state = 'pending' + LIMIT 1 + `), + expirePending: database.prepare(` + UPDATE billing_checkout_attempts + SET attempt_state = 'expired', updated_at_ms = ? + WHERE attempt_id = ? AND attempt_state = 'pending' + `), + insertAttempt: database.prepare(` + INSERT INTO billing_checkout_attempts( + attempt_id, organization_id, price_id, idempotency_key, + attempt_state, provider_session_id, created_at_ms, updated_at_ms + ) VALUES(?,?,?,?, 'pending', NULL, ?, ?) + `), + succeedAttempt: database.prepare(` + UPDATE billing_checkout_attempts + SET attempt_state = 'provider_succeeded', provider_session_id = ?, updated_at_ms = ? + WHERE attempt_id = ? AND attempt_state = 'pending' + `), + failAttempt: database.prepare(` + UPDATE billing_checkout_attempts + SET attempt_state = 'provider_failed', updated_at_ms = ? + WHERE attempt_id = ? AND attempt_state = 'pending' + `), + }; + return preparedStatements; + }; return { /** @@ -158,11 +166,13 @@ export function createSqliteBillingCheckoutAttemptRepository( const organization = positiveOrganizationId(organizationId); const price = boundedRequiredString(priceId, 'priceId', MAX_PRICE_ID_LENGTH); const nowMs = safeNow(now); + const sql = statements(); return withSavepoint(database, () => { - const pending = selectPending.get(organization, price); + const pending = sql.selectPending.get(organization, price); if (pending) { - const ageMs = nowMs - Number(pending.created_at_ms); + const createdAtMs = Number(pending.created_at_ms); + const ageMs = nowMs - createdAtMs; if (ageMs >= 0 && ageMs < BILLING_CHECKOUT_REUSE_WINDOW_MS) { return { attemptId: pending.attempt_id, @@ -171,12 +181,12 @@ export function createSqliteBillingCheckoutAttemptRepository( reused: true, }; } - expirePending.run(Math.max(nowMs, Number(pending.created_at_ms)), pending.attempt_id); + sql.expirePending.run(Math.max(nowMs, createdAtMs), pending.attempt_id); } const attemptId = opaqueIdentifier(randomUUID, 'attemptId'); const idempotencyKey = opaqueIdentifier(randomUUID, 'idempotencyKey'); - insertAttempt.run(attemptId, organization, price, idempotencyKey, nowMs, nowMs); + sql.insertAttempt.run(attemptId, organization, price, idempotencyKey, nowMs, nowMs); return { attemptId, idempotencyKey, state: 'pending', reused: false }; }); }, @@ -190,7 +200,10 @@ export function createSqliteBillingCheckoutAttemptRepository( MAX_PROVIDER_SESSION_ID_LENGTH, ); const nowMs = safeNow(now); - const result = withSavepoint(database, () => succeedAttempt.run(sessionId, nowMs, id)); + const result = withSavepoint( + database, + () => statements().succeedAttempt.run(sessionId, nowMs, id), + ); if (Number(result.changes) !== 1) { throw new Error('expected one pending checkout attempt for provider success'); } @@ -200,7 +213,10 @@ export function createSqliteBillingCheckoutAttemptRepository( markProviderFailed({ attemptId }) { const id = boundedRequiredString(attemptId, 'attemptId', MAX_IDENTIFIER_LENGTH); const nowMs = safeNow(now); - const result = withSavepoint(database, () => failAttempt.run(nowMs, id)); + const result = withSavepoint( + database, + () => statements().failAttempt.run(nowMs, id), + ); if (Number(result.changes) !== 1) { throw new Error('expected one pending checkout attempt for provider failure'); } From 2388e6fe51e973bd0a1ee7e976367b970cf6cb72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:13:23 +0900 Subject: [PATCH 04/35] feat(billing): bootstrap checkout attempt ledger --- server/db.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/db.mjs b/server/db.mjs index 122b70d6..7a27f461 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -4,6 +4,10 @@ import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { + createSqliteBillingCheckoutAttemptRepository, + installBillingCheckoutAttemptSchema, +} from './billing_checkout_attempt.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); @@ -177,5 +181,9 @@ try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAU try { db.exec('ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } try { db.exec("ALTER TABLE projects ADD COLUMN methodology TEXT NOT NULL DEFAULT 'waterfall'"); } catch { /* already there */ } +// Billing attempt state is installed at bootstrap only, after referenced orgs exist. +installBillingCheckoutAttemptSchema(db); +export const billingCheckoutAttempts = createSqliteBillingCheckoutAttemptRepository(db); + // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. -export const rowid = (r) => Number(r.lastInsertRowid); +export const rowid = (r) => Number(r.lastInsertRowid); \ No newline at end of file From 1f08b76d666217d3e141ba99f94a017fa7ce61aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:14:18 +0900 Subject: [PATCH 05/35] feat(billing): bind Stripe calls to durable idempotency --- server/billing.mjs | 133 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 108 insertions(+), 25 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index c1c914df..f4d410ab 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -8,6 +8,7 @@ const billingConfiguration = validateBillingStartupConfiguration(); const STRIPE_CHECKOUT_ENDPOINT = 'https://api.stripe.com/v1/checkout/sessions'; const STRIPE_REQUEST_TIMEOUT_MS = 15_000; const STRIPE_RESPONSE_MAX_BYTES = 1024 * 1024; +const STRIPE_PROVIDER_ID_MAX_LENGTH = 255; export const PLANS = { free: { name: 'Free', limits: { projects: 2, members: 3 }, priceKrw: 0 }, @@ -51,16 +52,32 @@ function billingUnavailableResponse() { ); } -function providerFailure(code, action) { - return new HTTPException(502, { +function checkoutStateFailure() { + return new HTTPException(503, { + res: jsonErrorResponse( + 503, + 'billing_checkout_state_unavailable', + 'Retry checkout after durable billing state is healthy; do not bypass the checkout-attempt ledger.', + ), + }); +} + +function providerFailure(code, action, { outcomeKnown = false } = {}) { + const error = new HTTPException(502, { res: jsonErrorResponse(502, code, action), }); + Object.defineProperty(error, 'providerOutcomeKnown', { + value: outcomeKnown, + enumerable: false, + }); + return error; } -function providerUnavailableFailure() { +function providerUnavailableFailure(outcomeKnown = false) { return providerFailure( 'billing_provider_unavailable', 'Retry checkout. If the problem persists, verify Stripe connectivity and service health before retrying.', + { outcomeKnown }, ); } @@ -68,6 +85,7 @@ function providerInvalidResponseFailure() { return providerFailure( 'billing_provider_invalid_response', 'Retry checkout. If the problem persists, verify the Stripe Checkout provider configuration and service health.', + { outcomeKnown: true }, ); } @@ -140,7 +158,7 @@ async function readBoundedProviderJson(response) { } } -async function createStripeSessionWithFetch(secretKey, payload) { +async function createStripeSessionWithFetch(secretKey, payload, idempotencyKey) { let response; try { response = await fetch(STRIPE_CHECKOUT_ENDPOINT, { @@ -150,15 +168,20 @@ async function createStripeSessionWithFetch(secretKey, payload) { headers: { authorization: `Bearer ${secretKey}`, 'content-type': 'application/x-www-form-urlencoded', + 'idempotency-key': idempotencyKey, }, body: stripeCheckoutForm(payload).toString(), }); } catch { - throw providerUnavailableFailure(); + // No HTTP response means the provider outcome is uncertain. Keep the durable + // pending attempt so the next caller reuses this exact idempotency key. + throw providerUnavailableFailure(false); } if (!response.ok) { - throw providerUnavailableFailure(); + // A received provider response is a known outcome for this attempt. The + // caller can close this retry identity before surfacing the stable error. + throw providerUnavailableFailure(true); } const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase(); @@ -196,6 +219,32 @@ function validateHostedCheckoutUrl(rawUrl) { return rawUrl; } +function validateProviderSessionId(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > STRIPE_PROVIDER_ID_MAX_LENGTH) { + throw providerInvalidResponseFailure(); + } + return value; +} + +function requireAttemptRepository(repository) { + if (!repository + || typeof repository.startAttempt !== 'function' + || typeof repository.markProviderSucceeded !== 'function' + || typeof repository.markProviderFailed !== 'function') { + throw checkoutStateFailure(); + } + return repository; +} + +function markKnownProviderFailure(repository, attemptId, error) { + if (error?.providerOutcomeKnown !== true) return; + try { + repository.markProviderFailed({ attemptId }); + } catch { + throw checkoutStateFailure(); + } +} + /** * Create one hosted checkout session from trusted server-owned configuration. * @@ -203,24 +252,30 @@ function validateHostedCheckoutUrl(rawUrl) { * URLs always derive from the canonical operator-configured public origin. The * successful mock exists only in explicit development mode; an unconfigured * production capability returns HTTP 503 instead of pretending checkout worked. - * Live provider calls use one direct HTTPS attempt with a 15-second total budget - * and a 1 MiB response-body ceiling until durable checkout-attempt idempotency - * state exists. The hosted destination must use Stripe's standard HTTPS authority; - * provider-issued client fragments are preserved verbatim. + * Live provider calls use one direct HTTPS attempt with a 15-second total budget, + * a 1 MiB response-body ceiling, and a durable per-attempt idempotency key. A + * network/abort failure keeps that attempt pending so a later call safely reuses + * the same key; a received provider failure closes it so a deliberate later + * checkout gets fresh provider authority. The hosted destination must use + * Stripe's standard HTTPS authority; provider-issued client fragments are + * preserved verbatim. * * @param {object} options - Checkout inputs and optional deterministic test seams. * @param {string|number} options.orgId - Organization that owns the checkout. * @param {{mode: 'disabled'|'mock'|'live', publicOrigin: string|null}} [options.configuration] * Validated billing capability; defaults to startup configuration. + * @param {{startAttempt: Function, markProviderSucceeded: Function, markProviderFailed: Function}} [options.attemptRepository] + * Durable live-mode Checkout-attempt persistence port. * @param {(secretKey: string) => Promise} [options.stripeClientFactory] * Optional Stripe-compatible test seam. Production uses the direct HTTPS transport. - * @returns {Promise<{url: string, live: boolean, mock?: boolean}>} Checkout target. - * @throws {HTTPException} HTTP 503 when production billing is not configured; + * @returns {Promise<{url: string, live: boolean, mock?: boolean, checkoutAttemptId?: string}>} Checkout target. + * @throws {HTTPException} HTTP 503 when production billing/state is unavailable; * HTTP 502 when the provider call fails or returns an untrusted destination. */ export async function createCheckout({ orgId, configuration = billingConfiguration, + attemptRepository, stripeClientFactory, }) { const { mode, publicOrigin } = configuration; @@ -229,9 +284,18 @@ export async function createCheckout({ } if (mode === 'live') { + const repository = requireAttemptRepository(attemptRepository); + const priceId = process.env.STRIPE_PRICE_ID; + let attempt; + try { + attempt = repository.startAttempt({ organizationId: orgId, priceId }); + } catch { + throw checkoutStateFailure(); + } + const payload = { mode: 'subscription', - line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }], + line_items: [{ price: priceId, quantity: 1 }], success_url: `${publicOrigin}/?billing=success`, cancel_url: `${publicOrigin}/?billing=cancel`, client_reference_id: String(orgId), @@ -239,22 +303,41 @@ export async function createCheckout({ }; let session; - if (stripeClientFactory) { - try { + try { + if (stripeClientFactory) { const stripe = await stripeClientFactory(process.env.STRIPE_SECRET_KEY); - session = await stripe.checkout.sessions.create(payload); + session = await stripe.checkout.sessions.create(payload, { + idempotencyKey: attempt.idempotencyKey, + }); + } else { + session = await createStripeSessionWithFetch( + process.env.STRIPE_SECRET_KEY, + payload, + attempt.idempotencyKey, + ); + } + + const providerSessionId = validateProviderSessionId(session?.id); + const hostedUrl = validateHostedCheckoutUrl(session?.url); + try { + repository.markProviderSucceeded({ + attemptId: attempt.attemptId, + providerSessionId, + }); } catch { - throw providerUnavailableFailure(); + throw checkoutStateFailure(); } - } else { - session = await createStripeSessionWithFetch(process.env.STRIPE_SECRET_KEY, payload); - } - return { - url: validateHostedCheckoutUrl(session?.url), - live: true, - }; + return { + url: hostedUrl, + live: true, + checkoutAttemptId: attempt.attemptId, + }; + } catch (error) { + markKnownProviderFailure(repository, attempt.attemptId, error); + throw error; + } } return { url: `${publicOrigin}/?billing=mock&org=${encodeURIComponent(String(orgId))}`, live: false, mock: true }; -} +} \ No newline at end of file From ee57400006942d22d72a6106479ad6853fca0879 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:15:29 +0900 Subject: [PATCH 06/35] fix(billing): resolve durable ledger on live route --- server/billing.mjs | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index f4d410ab..fa23d2b4 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -236,6 +236,19 @@ function requireAttemptRepository(repository) { return repository; } +async function resolveAttemptRepository(repository) { + if (repository !== undefined) return requireAttemptRepository(repository); + try { + // The app already owns the database singleton. Dynamic resolution keeps the + // billing domain directly testable without opening a database at import time, + // while the real live route still uses the bootstrap-installed durable port. + const { billingCheckoutAttempts } = await import('./db.mjs'); + return requireAttemptRepository(billingCheckoutAttempts); + } catch { + throw checkoutStateFailure(); + } +} + function markKnownProviderFailure(repository, attemptId, error) { if (error?.providerOutcomeKnown !== true) return; try { @@ -265,7 +278,8 @@ function markKnownProviderFailure(repository, attemptId, error) { * @param {{mode: 'disabled'|'mock'|'live', publicOrigin: string|null}} [options.configuration] * Validated billing capability; defaults to startup configuration. * @param {{startAttempt: Function, markProviderSucceeded: Function, markProviderFailed: Function}} [options.attemptRepository] - * Durable live-mode Checkout-attempt persistence port. + * Durable live-mode Checkout-attempt persistence port. Production resolves the + * bootstrap-installed database port when omitted; tests should inject a seam. * @param {(secretKey: string) => Promise} [options.stripeClientFactory] * Optional Stripe-compatible test seam. Production uses the direct HTTPS transport. * @returns {Promise<{url: string, live: boolean, mock?: boolean, checkoutAttemptId?: string}>} Checkout target. @@ -284,7 +298,7 @@ export async function createCheckout({ } if (mode === 'live') { - const repository = requireAttemptRepository(attemptRepository); + const repository = await resolveAttemptRepository(attemptRepository); const priceId = process.env.STRIPE_PRICE_ID; let attempt; try { @@ -305,10 +319,16 @@ export async function createCheckout({ let session; try { if (stripeClientFactory) { - const stripe = await stripeClientFactory(process.env.STRIPE_SECRET_KEY); - session = await stripe.checkout.sessions.create(payload, { - idempotencyKey: attempt.idempotencyKey, - }); + try { + const stripe = await stripeClientFactory(process.env.STRIPE_SECRET_KEY); + session = await stripe.checkout.sessions.create(payload, { + idempotencyKey: attempt.idempotencyKey, + }); + } catch { + // The injected seam models an SDK/network boundary. Without a concrete + // provider response, its outcome is uncertain and must remain retryable. + throw providerUnavailableFailure(false); + } } else { session = await createStripeSessionWithFetch( process.env.STRIPE_SECRET_KEY, From dc0ddb3d459cc6c01e3015faca1bf6b30931063f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:16:02 +0900 Subject: [PATCH 07/35] test(billing): verify durable Checkout idempotency wiring --- tests/unit/billing-checkout.test.mjs | 135 ++++++++++++++++++++++++--- 1 file changed, 124 insertions(+), 11 deletions(-) diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index b88d5396..e7363d33 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -7,6 +7,36 @@ const disabledConfiguration = { mode: 'disabled', publicOrigin: null }; const mockConfiguration = { mode: 'mock', publicOrigin: 'http://127.0.0.1:8787' }; const liveConfiguration = { mode: 'live', publicOrigin: 'https://planner.example.com' }; +function createAttemptRepository(overrides = {}) { + const events = []; + return { + events, + startAttempt(input) { + events.push({ type: 'start', input }); + if (overrides.startError) throw overrides.startError; + return { + attemptId: overrides.attemptId || 'attempt-test-001', + idempotencyKey: overrides.idempotencyKey || 'idem-test-001', + state: 'pending', + reused: false, + }; + }, + markProviderSucceeded(input) { + events.push({ type: 'success', input }); + if (overrides.successError) throw overrides.successError; + }, + markProviderFailed(input) { + events.push({ type: 'failure', input }); + if (overrides.failureError) throw overrides.failureError; + }, + }; +} + +async function responsePayloadFrom(error) { + assert.equal(typeof error.getResponse, 'function'); + return error.getResponse().json(); +} + test('unconfigured production checkout fails closed with actionable HTTP 503', async () => { let rejectedError; await assert.rejects( @@ -42,21 +72,25 @@ test('development mock uses only the operator-owned public origin', async () => assert.doesNotMatch(checkout.url, /attacker\.example/); }); -test('live checkout builds redirects from canonical configuration and preserves server identity', async () => { +test('live checkout binds SDK-style calls to the durable idempotency identity', async () => { const previousSecret = process.env.STRIPE_SECRET_KEY; const previousPrice = process.env.STRIPE_PRICE_ID; process.env.STRIPE_SECRET_KEY = 'sk_test_trusted'; process.env.STRIPE_PRICE_ID = 'price_trusted'; const calls = []; + const attemptRepository = createAttemptRepository(); const fakeStripeClientFactory = async (secretKey) => { assert.equal(secretKey, 'sk_test_trusted'); return { checkout: { sessions: { - async create(payload) { - calls.push(payload); - return { url: 'https://checkout.stripe.com/c/pay/cs_test_123' }; + async create(payload, requestOptions) { + calls.push({ payload, requestOptions }); + return { + id: 'cs_test_123', + url: 'https://checkout.stripe.com/c/pay/cs_test_123', + }; }, }, }, @@ -68,21 +102,33 @@ test('live checkout builds redirects from canonical configuration and preserves orgId: 73, origin: 'https://attacker.example', configuration: liveConfiguration, + attemptRepository, stripeClientFactory: fakeStripeClientFactory, }); assert.deepEqual(checkout, { url: 'https://checkout.stripe.com/c/pay/cs_test_123', live: true, + checkoutAttemptId: 'attempt-test-001', }); assert.deepEqual(calls, [{ - mode: 'subscription', - line_items: [{ price: 'price_trusted', quantity: 1 }], - success_url: 'https://planner.example.com/?billing=success', - cancel_url: 'https://planner.example.com/?billing=cancel', - client_reference_id: '73', - metadata: { orgId: '73' }, + payload: { + mode: 'subscription', + line_items: [{ price: 'price_trusted', quantity: 1 }], + success_url: 'https://planner.example.com/?billing=success', + cancel_url: 'https://planner.example.com/?billing=cancel', + client_reference_id: '73', + metadata: { orgId: '73' }, + }, + requestOptions: { idempotencyKey: 'idem-test-001' }, }]); + assert.deepEqual(attemptRepository.events, [ + { type: 'start', input: { organizationId: 73, priceId: 'price_trusted' } }, + { + type: 'success', + input: { attemptId: 'attempt-test-001', providerSessionId: 'cs_test_123' }, + }, + ]); } finally { if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; else process.env.STRIPE_SECRET_KEY = previousSecret; @@ -91,7 +137,7 @@ test('live checkout builds redirects from canonical configuration and preserves } }); -test('default live provider transport uses Stripe HTTPS without an undeclared runtime SDK', async () => { +test('default live provider transport sends the persisted Stripe Idempotency-Key', async () => { const previousSecret = process.env.STRIPE_SECRET_KEY; const previousPrice = process.env.STRIPE_PRICE_ID; const previousFetch = globalThis.fetch; @@ -99,9 +145,14 @@ test('default live provider transport uses Stripe HTTPS without an undeclared ru process.env.STRIPE_PRICE_ID = 'price_default_transport'; const calls = []; + const attemptRepository = createAttemptRepository({ + attemptId: 'attempt-default-transport', + idempotencyKey: 'idem-default-transport', + }); globalThis.fetch = async (url, options) => { calls.push({ url, options }); return new Response(JSON.stringify({ + id: 'cs_test_default_transport', url: 'https://checkout.stripe.com/c/pay/cs_test_default_transport', }), { status: 200, @@ -114,11 +165,13 @@ test('default live provider transport uses Stripe HTTPS without an undeclared ru orgId: 91, origin: 'https://attacker.example', configuration: liveConfiguration, + attemptRepository, }); assert.deepEqual(checkout, { url: 'https://checkout.stripe.com/c/pay/cs_test_default_transport', live: true, + checkoutAttemptId: 'attempt-default-transport', }); assert.equal(calls.length, 1); assert.equal(calls[0].url, 'https://api.stripe.com/v1/checkout/sessions'); @@ -127,6 +180,7 @@ test('default live provider transport uses Stripe HTTPS without an undeclared ru assert.ok(calls[0].options.signal instanceof AbortSignal); assert.equal(calls[0].options.headers.authorization, 'Bearer sk_test_default_transport'); assert.equal(calls[0].options.headers['content-type'], 'application/x-www-form-urlencoded'); + assert.equal(calls[0].options.headers['idempotency-key'], 'idem-default-transport'); const form = new URLSearchParams(calls[0].options.body); assert.equal(form.get('mode'), 'subscription'); @@ -136,6 +190,13 @@ test('default live provider transport uses Stripe HTTPS without an undeclared ru assert.equal(form.get('cancel_url'), 'https://planner.example.com/?billing=cancel'); assert.equal(form.get('client_reference_id'), '91'); assert.equal(form.get('metadata[orgId]'), '91'); + assert.deepEqual(attemptRepository.events.at(-1), { + type: 'success', + input: { + attemptId: 'attempt-default-transport', + providerSessionId: 'cs_test_default_transport', + }, + }); } finally { globalThis.fetch = previousFetch; if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; @@ -144,3 +205,55 @@ test('default live provider transport uses Stripe HTTPS without an undeclared ru else process.env.STRIPE_PRICE_ID = previousPrice; } }); + +test('live checkout fails closed when the durable attempt port cannot start or commit success', async () => { + const previousPrice = process.env.STRIPE_PRICE_ID; + process.env.STRIPE_PRICE_ID = 'price_state_failure'; + + try { + for (const attemptRepository of [ + {}, + createAttemptRepository({ startError: new Error('database unavailable') }), + ]) { + let rejected; + await assert.rejects( + createCheckout({ orgId: 73, configuration: liveConfiguration, attemptRepository }), + (error) => { + rejected = error; + assert.equal(error.status, 503); + return true; + }, + ); + assert.equal((await responsePayloadFrom(rejected)).error, 'billing_checkout_state_unavailable'); + } + + const attemptRepository = createAttemptRepository({ successError: new Error('commit failed') }); + const stripeClientFactory = async () => ({ + checkout: { + sessions: { + async create() { + return { id: 'cs_test_state_failure', url: 'https://checkout.stripe.com/c/pay/cs_test_state_failure' }; + }, + }, + }, + }); + let rejected; + await assert.rejects( + createCheckout({ + orgId: 73, + configuration: liveConfiguration, + attemptRepository, + stripeClientFactory, + }), + (error) => { + rejected = error; + assert.equal(error.status, 503); + return true; + }, + ); + assert.equal((await responsePayloadFrom(rejected)).error, 'billing_checkout_state_unavailable'); + } finally { + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +}); \ No newline at end of file From 5ebf4e1b2b89c711191a4db375b46026ba8f23d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:16:49 +0900 Subject: [PATCH 08/35] test(billing): distinguish uncertain and known provider outcomes --- tests/unit/billing-provider-boundary.test.mjs | 130 +++++++++++++++--- 1 file changed, 111 insertions(+), 19 deletions(-) diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 9933b0bb..14aa3a74 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -7,6 +7,38 @@ const liveConfiguration = { mode: 'live', publicOrigin: 'https://planner.example const hostedCheckoutUrl = 'https://checkout.stripe.com/c/pay/cs_test_boundary#fidkdWxOYHwnPyd1blpx'; const providerResponseLimitBytes = 1024 * 1024; +function createAttemptRepository(overrides = {}) { + const events = []; + return { + events, + startAttempt(input) { + events.push({ type: 'start', input }); + return { + attemptId: overrides.attemptId || 'attempt-provider-boundary', + idempotencyKey: overrides.idempotencyKey || 'idem-provider-boundary', + state: 'pending', + reused: false, + }; + }, + markProviderSucceeded(input) { + events.push({ type: 'success', input }); + }, + markProviderFailed(input) { + events.push({ type: 'failure', input }); + if (overrides.failureError) throw overrides.failureError; + }, + }; +} + +function liveCheckout(attemptRepository, extra = {}) { + return createCheckout({ + orgId: 73, + configuration: liveConfiguration, + attemptRepository, + ...extra, + }); +} + async function withStripeEnv(run) { const previousSecret = process.env.STRIPE_SECRET_KEY; const previousPrice = process.env.STRIPE_PRICE_ID; @@ -45,9 +77,10 @@ async function expectProviderError(run, expectedCode) { test('live Checkout uses one bounded direct Stripe HTTPS request and preserves the hosted URL', async () => { await withStripeEnv(async () => { const observed = []; + const attemptRepository = createAttemptRepository(); globalThis.fetch = async (url, options) => { observed.push({ url, options }); - const payload = JSON.stringify({ url: hostedCheckoutUrl }); + const payload = JSON.stringify({ id: 'cs_test_boundary', url: hostedCheckoutUrl }); return new Response(payload, { status: 200, headers: { @@ -57,12 +90,10 @@ test('live Checkout uses one bounded direct Stripe HTTPS request and preserves t }); }; - const result = await createCheckout({ - orgId: 73, - configuration: liveConfiguration, - }); + const result = await liveCheckout(attemptRepository); assert.equal(result.url, hostedCheckoutUrl, 'Stripe-hosted client fragment is preserved verbatim'); + assert.equal(result.checkoutAttemptId, 'attempt-provider-boundary'); assert.equal(observed.length, 1, 'checkout transport performs exactly one provider attempt'); assert.equal(observed[0].url, 'https://api.stripe.com/v1/checkout/sessions'); assert.equal(observed[0].options.method, 'POST'); @@ -70,6 +101,7 @@ test('live Checkout uses one bounded direct Stripe HTTPS request and preserves t assert.ok(observed[0].options.signal instanceof AbortSignal); assert.equal(observed[0].options.headers.authorization, 'Bearer sk_test_provider_boundary'); assert.equal(observed[0].options.headers['content-type'], 'application/x-www-form-urlencoded'); + assert.equal(observed[0].options.headers['idempotency-key'], 'idem-provider-boundary'); const form = new URLSearchParams(observed[0].options.body); assert.equal(form.get('mode'), 'subscription'); @@ -79,10 +111,17 @@ test('live Checkout uses one bounded direct Stripe HTTPS request and preserves t assert.equal(form.get('cancel_url'), 'https://planner.example.com/?billing=cancel'); assert.equal(form.get('client_reference_id'), '73'); assert.equal(form.get('metadata[orgId]'), '73'); + assert.deepEqual(attemptRepository.events.at(-1), { + type: 'success', + input: { + attemptId: 'attempt-provider-boundary', + providerSessionId: 'cs_test_boundary', + }, + }); }); }); -test('live Checkout rejects malformed or untrusted provider authorities', async () => { +test('live Checkout rejects malformed provider identities or untrusted browser authorities', async () => { const invalidUrls = [ null, '', @@ -95,77 +134,103 @@ test('live Checkout rejects malformed or untrusted provider authorities', async await withStripeEnv(async () => { for (const url of invalidUrls) { - globalThis.fetch = async () => new Response(JSON.stringify({ url }), { + const attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response(JSON.stringify({ id: 'cs_test_invalid_url', url }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + await expectProviderError( + () => liveCheckout(attemptRepository), + 'billing_provider_invalid_response', + ); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); + } + + for (const id of [null, '', 'x'.repeat(256)]) { + const attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response(JSON.stringify({ id, url: hostedCheckoutUrl }), { status: 200, headers: { 'content-type': 'application/json' }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); } }); }); -test('provider transport failures become a stable sanitized buyer-facing error', async () => { +test('uncertain transport failures stay pending and remain sanitized', async () => { await withStripeEnv(async () => { + const attemptRepository = createAttemptRepository(); globalThis.fetch = async () => { throw new Error('dial tcp 10.7.0.12:443 with sk_live_should_not_escape'); }; const payload = await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_unavailable', ); assert.doesNotMatch(payload, /10\.7\.0\.12|sk_live_should_not_escape/); + assert.deepEqual(attemptRepository.events.map((event) => event.type), ['start']); }); }); -test('provider HTTP and malformed-success responses fail with stable categories', async () => { +test('known provider HTTP and malformed-success outcomes close their retry identity', async () => { await withStripeEnv(async () => { + let attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response('provider secret body', { status: 503, headers: { 'content-type': 'text/plain' }, }); const unavailablePayload = await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_unavailable', ); assert.doesNotMatch(unavailablePayload, /provider secret body/); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); + attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response('not json', { status: 200, headers: { 'content-type': 'text/html' }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); + attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response('{malformed', { status: 200, headers: { 'content-type': 'application/json' }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); + attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response(null, { status: 200, headers: { 'content-type': 'application/json' }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); }); }); test('provider response declarations and streamed bytes are bounded before JSON parsing', async () => { await withStripeEnv(async () => { for (const declaredLength of ['not-a-number', '-1', String(providerResponseLimitBytes + 1)]) { - globalThis.fetch = async () => new Response(JSON.stringify({ url: hostedCheckoutUrl }), { + const attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response(JSON.stringify({ id: 'cs_test_bounded', url: hostedCheckoutUrl }), { status: 200, headers: { 'content-type': 'application/json', @@ -173,9 +238,10 @@ test('provider response declarations and streamed bytes are bounded before JSON }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); } let cancelled = false; @@ -193,15 +259,17 @@ test('provider response declarations and streamed bytes are bounded before JSON cancelled = true; }, }); + const attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response(oversizedBody, { status: 200, headers: { 'content-type': 'application/json' }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); assert.equal(cancelled, true, 'oversized streamed provider bodies are cancelled at the byte boundary'); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); }); }); @@ -212,15 +280,39 @@ test('provider stream read failures remain sanitized invalid responses', async ( controller.error(new Error('provider stream secret 10.9.0.7')); }, }); + const attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response(failingBody, { status: 200, headers: { 'content-type': 'application/json' }, }); const payload = await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); assert.doesNotMatch(payload, /provider stream secret|10\.9\.0\.7/); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); }); }); + +test('a known provider failure that cannot be durably closed fails as state unavailable', async () => { + await withStripeEnv(async () => { + const attemptRepository = createAttemptRepository({ failureError: new Error('disk full') }); + globalThis.fetch = async () => new Response('known failure', { + status: 500, + headers: { 'content-type': 'text/plain' }, + }); + + let rejected; + await assert.rejects( + () => liveCheckout(attemptRepository), + (error) => { + rejected = error; + assert.equal(error.status, 503); + return true; + }, + ); + const payload = await rejected.getResponse().json(); + assert.equal(payload.error, 'billing_checkout_state_unavailable'); + }); +}); \ No newline at end of file From fe3c62b029607539c37c8ee56b4a73bd9a4a5ac8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:17:11 +0900 Subject: [PATCH 09/35] test(billing): register checkout attempt coverage --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 86542958..4bce404a 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && npm run test:api", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", @@ -31,4 +31,4 @@ "c8": "12.0.0", "fast-check": "4.9.0" } -} +} \ No newline at end of file From dcef667de651b1ba82bc9a0c09734cf9300036a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:17:28 +0900 Subject: [PATCH 10/35] test(coverage): lock billing attempt instrumentation --- tests/unit/coverage-script-contract.test.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index c5b58fa1..82b9c8a3 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,11 +34,21 @@ assert.match( /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/billing_checkout_attempt\.mjs/, + 'the durable Checkout-attempt repository is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, 'the Clearfolio signal and HTTP failure regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/billing-checkout-attempt\.test\.mjs/, + 'the durable Checkout-attempt regression executes under c8', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/billing-provider-boundary\.test\.mjs/, @@ -50,4 +60,4 @@ assert.doesNotMatch( 'coverage cases never recursively invoke a coverage wrapper', ); -console.log('✓ coverage script contract tests passed'); +console.log('✓ coverage script contract tests passed'); \ No newline at end of file From 665bf081fe664a799d9b33468a807286586025f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:18:27 +0900 Subject: [PATCH 11/35] test(billing): cover checkout ledger failure boundaries --- tests/unit/billing-checkout-attempt.test.mjs | 117 +++++++++++++++++-- 1 file changed, 109 insertions(+), 8 deletions(-) diff --git a/tests/unit/billing-checkout-attempt.test.mjs b/tests/unit/billing-checkout-attempt.test.mjs index 09b66a3e..bc788b0c 100644 --- a/tests/unit/billing-checkout-attempt.test.mjs +++ b/tests/unit/billing-checkout-attempt.test.mjs @@ -178,6 +178,26 @@ test('pending identities are never reused at or beyond the Stripe retention safe assert.equal(oldRow.attempt_state, 'expired'); }); +test('clock rollback expires an unresolved identity instead of replaying it', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + let nowMs = 5_000_000; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => nowMs, + }); + + const first = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + nowMs -= 1_000; + const replacement = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + + assert.notEqual(replacement.idempotencyKey, first.idempotencyKey); + assert.equal( + database.prepare('SELECT attempt_state FROM billing_checkout_attempts WHERE attempt_id = ?').get(first.attemptId).attempt_state, + 'expired', + ); +}); + test('repository rejects malformed identifiers and impossible terminal transitions', () => { const database = createDatabase(); installBillingCheckoutAttemptSchema(database); @@ -186,16 +206,26 @@ test('repository rejects malformed identifiers and impossible terminal transitio now: () => 4_000_000, }); - assert.throws( - () => repository.startAttempt({ organizationId: 0, priceId: 'price_pro' }), - /organizationId/, - ); - assert.throws( - () => repository.startAttempt({ organizationId: 7, priceId: ' ' }), - /priceId/, - ); + for (const organizationId of [0, -1, 1.5, 'not-an-id']) { + assert.throws( + () => repository.startAttempt({ organizationId, priceId: 'price_pro' }), + /organizationId/, + ); + } + for (const priceId of [null, ' ', 'x'.repeat(256)]) { + assert.throws( + () => repository.startAttempt({ organizationId: 7, priceId }), + /priceId/, + ); + } const attempt = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + for (const providerSessionId of [null, '', 'x'.repeat(256)]) { + assert.throws( + () => repository.markProviderSucceeded({ attemptId: attempt.attemptId, providerSessionId }), + /providerSessionId/, + ); + } repository.markProviderFailed({ attemptId: attempt.attemptId }); assert.throws( () => repository.markProviderSucceeded({ attemptId: attempt.attemptId, providerSessionId: 'cs_too_late' }), @@ -205,4 +235,75 @@ test('repository rejects malformed identifiers and impossible terminal transitio () => repository.markProviderFailed({ attemptId: 'not-an-attempt' }), /pending checkout attempt/, ); + assert.throws( + () => repository.markProviderFailed({ attemptId: '' }), + /attemptId/, + ); }); + +test('dependency seams fail closed and default UUID/clock dependencies are usable', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + + assert.throws( + () => createSqliteBillingCheckoutAttemptRepository(null), + /database/, + ); + assert.throws( + () => createSqliteBillingCheckoutAttemptRepository(database, { randomUUID: 'not-a-function' }), + /randomUUID/, + ); + assert.throws( + () => createSqliteBillingCheckoutAttemptRepository(database, { now: 'not-a-function' }), + /now/, + ); + + const repository = createSqliteBillingCheckoutAttemptRepository(database); + const attempt = repository.startAttempt({ organizationId: 7, priceId: 'price_default' }); + assert.match(attempt.attemptId, /^[0-9a-f-]{36}$/i); + assert.match(attempt.idempotencyKey, /^[0-9a-f-]{36}$/i); + repository.markProviderFailed({ attemptId: attempt.attemptId }); +}); + +test('invalid clock and identifier sources roll back without leaving a pending row', () => { + let database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + let repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => -1, + }); + assert.throws( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_bad_clock' }), + /clock/, + ); + + database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: () => '', + now: () => 6_000_000, + }); + assert.throws( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_bad_uuid' }), + /attemptId/, + ); + assert.equal( + database.prepare('SELECT COUNT(*) AS n FROM billing_checkout_attempts').get().n, + 0, + ); + + database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => 7_000_000, + }); + assert.throws( + () => repository.startAttempt({ organizationId: 999, priceId: 'price_missing_org' }), + /FOREIGN KEY|constraint/i, + ); + assert.equal( + database.prepare('SELECT COUNT(*) AS n FROM billing_checkout_attempts').get().n, + 0, + ); +}); \ No newline at end of file From 35571be0c0e81359dff09238f5815ed13dcf0440 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:20:53 +0900 Subject: [PATCH 12/35] test(billing): preserve Stripe server-error retry identity --- tests/unit/billing-provider-boundary.test.mjs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 14aa3a74..97efde50 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -177,18 +177,34 @@ test('uncertain transport failures stay pending and remain sanitized', async () }); }); -test('known provider HTTP and malformed-success outcomes close their retry identity', async () => { +test('Stripe server errors remain indeterminate while 4xx and malformed successes close retry identity', async () => { await withStripeEnv(async () => { let attemptRepository = createAttemptRepository(); - globalThis.fetch = async () => new Response('provider secret body', { + globalThis.fetch = async () => new Response('provider incident body', { status: 503, headers: { 'content-type': 'text/plain' }, }); - const unavailablePayload = await expectProviderError( + const serverErrorPayload = await expectProviderError( () => liveCheckout(attemptRepository), 'billing_provider_unavailable', ); - assert.doesNotMatch(unavailablePayload, /provider secret body/); + assert.doesNotMatch(serverErrorPayload, /provider incident body/); + assert.deepEqual( + attemptRepository.events.map((event) => event.type), + ['start'], + '5xx is indeterminate and must preserve the same retry identity', + ); + + attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response('invalid request body detail', { + status: 400, + headers: { 'content-type': 'application/json' }, + }); + const clientErrorPayload = await expectProviderError( + () => liveCheckout(attemptRepository), + 'billing_provider_unavailable', + ); + assert.doesNotMatch(clientErrorPayload, /invalid request body detail/); assert.equal(attemptRepository.events.at(-1).type, 'failure'); attemptRepository = createAttemptRepository(); @@ -299,8 +315,8 @@ test('a known provider failure that cannot be durably closed fails as state unav await withStripeEnv(async () => { const attemptRepository = createAttemptRepository({ failureError: new Error('disk full') }); globalThis.fetch = async () => new Response('known failure', { - status: 500, - headers: { 'content-type': 'text/plain' }, + status: 400, + headers: { 'content-type': 'application/json' }, }); let rejected; From fee01e7dd3055f1aedc0ef12e094536d7af05d13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:21:44 +0900 Subject: [PATCH 13/35] fix(billing): keep Stripe 5xx outcomes indeterminate --- server/billing.mjs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index fa23d2b4..679f792d 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -179,9 +179,13 @@ async function createStripeSessionWithFetch(secretKey, payload, idempotencyKey) } if (!response.ok) { - // A received provider response is a known outcome for this attempt. The - // caller can close this retry identity before surfacing the stable error. - throw providerUnavailableFailure(true); + // Stripe explicitly treats 5xx mutations, especially 500, as indeterminate: + // the original request can have produced side effects even though the client + // received an error. Preserve the pending identity for every server error so + // no later caller silently creates a second Checkout Session with a fresh key. + // Stripe's documented safest strategy for 4xx is a fresh idempotency key. + const outcomeKnown = response.status < 500; + throw providerUnavailableFailure(outcomeKnown); } const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase(); @@ -266,12 +270,12 @@ function markKnownProviderFailure(repository, attemptId, error) { * successful mock exists only in explicit development mode; an unconfigured * production capability returns HTTP 503 instead of pretending checkout worked. * Live provider calls use one direct HTTPS attempt with a 15-second total budget, - * a 1 MiB response-body ceiling, and a durable per-attempt idempotency key. A - * network/abort failure keeps that attempt pending so a later call safely reuses - * the same key; a received provider failure closes it so a deliberate later - * checkout gets fresh provider authority. The hosted destination must use - * Stripe's standard HTTPS authority; provider-issued client fragments are - * preserved verbatim. + * a 1 MiB response-body ceiling, and a durable per-attempt idempotency key. + * Network/abort and Stripe 5xx failures keep the attempt pending so a later call + * reuses the same key; known 4xx and validated malformed-success outcomes close + * the attempt so a deliberate later checkout gets fresh provider authority. The + * hosted destination must use Stripe's standard HTTPS authority; provider-issued + * client fragments are preserved verbatim. * * @param {object} options - Checkout inputs and optional deterministic test seams. * @param {string|number} options.orgId - Organization that owns the checkout. From 390a5f25adae2a081eb646cb4c48cb69a0199211 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:23:30 +0900 Subject: [PATCH 14/35] docs(billing): doctor durable Checkout idempotency --- .../stripe-checkout-attempt-idempotency.md | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/doctoring/stripe-checkout-attempt-idempotency.md diff --git a/docs/doctoring/stripe-checkout-attempt-idempotency.md b/docs/doctoring/stripe-checkout-attempt-idempotency.md new file mode 100644 index 00000000..94bfe274 --- /dev/null +++ b/docs/doctoring/stripe-checkout-attempt-idempotency.md @@ -0,0 +1,133 @@ +# Stripe Checkout attempt idempotency — active PR #511 + +## Status and decision + +This document describes **active stacked PR #511**, based on PR #507 at +`770e69f009985ce8f0f186c946ef2282cdbe0c1e`. It is not protected-`develop` +shipped truth and it is not a production-readiness claim. The slice exists to +make an uncertain Checkout Session creation retryable without silently creating +a second provider object. + +The decision is to persist a ScopeWeave-owned Checkout attempt before the live +Stripe POST and bind exactly one opaque idempotency key to that attempt. The live +transport sends the key as `Idempotency-Key`; an unresolved attempt is reused +only for the same organization and price and only inside a 23-hour local safety +window. Terminal provider outcomes close the local attempt. No authentication +secret or bearer token is stored in the ledger. + +The 23-hour window is intentionally shorter than Stripe's documented 24-hour +retry horizon / at-least-24-hour key retention boundary. It is a conservative +local ceiling, not a claim that Stripe purges every key at exactly 24 hours. + +## Evidence-to-control traceability + +| Primary evidence | ScopeWeave control | Acceptance evidence | +| --- | --- | --- | +| Stripe recommends sufficiently unique keys such as UUID v4 and permits keys up to 255 characters. | Generate opaque UUID-backed `attempt_id` and `idempotency_key`; never derive the provider key from a secret. | `tests/unit/billing-checkout-attempt.test.mjs` | +| Stripe records POST results by idempotency key and compares parameters on reuse. | Persist one organization/price attempt identity and reuse the same key only while that exact attempt is unresolved. | repository reuse/terminal-state tests plus transport header tests | +| A network failure can leave the client unable to know whether Stripe executed the mutation. | Network/abort failures leave the attempt `pending`; the next caller reuses the same key. | `tests/unit/billing-provider-boundary.test.mjs` | +| Stripe documents server errors, especially HTTP 500, as indeterminate and warns that a fresh key can duplicate side effects. | All Stripe 5xx responses keep the attempt `pending`; no fresh key is issued merely because a server-error response arrived. | regression commit `35571be0c0e81359dff09238f5815ed13dcf0440` followed by the production fix | +| Stripe's safest documented strategy for 4xx errors is a fresh idempotency key after correcting/retrying the request. | A received 4xx closes the current local attempt as `provider_failed`; a later deliberate Checkout obtains fresh authority. | provider-boundary 4xx regression | +| Checkout Sessions expose `client_reference_id` for reconciliation with internal systems. | Send organization identity as `client_reference_id` and metadata while retaining a separate opaque local attempt ID. | transport form assertions | + +## Data model + +`billing_checkout_attempts` is the only new persisted object in this slice. Its +owned names are descriptive multi-word `snake_case` identifiers. + +- `attempt_id`: opaque local primary key. +- `organization_id`: tenant boundary; foreign key to the existing organization + row and cascade-deleted with it. +- `price_id`: server-owned Stripe price identity used for the request. +- `idempotency_key`: unique opaque Stripe POST identity. +- `attempt_state`: `pending`, `provider_succeeded`, `provider_failed`, or + `expired`. +- `provider_session_id`: populated only after a validated successful provider + response. +- `created_at_ms` / `updated_at_ms`: bounded local lifecycle timestamps. + +A partial unique index on `(organization_id, price_id)` while `pending` prevents +two unresolved retry identities for the same tenant/price. The repository uses a +savepoint around each synchronous state mutation. Clock rollback is fail-safe: +a pending attempt whose calculated age is negative is expired rather than +silently replayed. + +The table is installed only during database bootstrap after the referenced +organization table exists. Repository construction and request handling do not +perform DDL. This is compatible with the repository's current bootstrap pattern, +but it is **not** a substitute for the formal migration-ledger/recovery work that +must converge before billing release approval. + +## Failure semantics + +1. **No provider response / transport abort** — customer receives stable no-store + `billing_provider_unavailable`; local attempt remains pending. +2. **Stripe 5xx** — customer receives the same sanitized 502; local attempt + remains pending because provider side effects are indeterminate. +3. **Stripe 4xx** — customer receives sanitized 502; the local attempt becomes + `provider_failed` so a later corrected Checkout can use a fresh key. +4. **Successful HTTP response with malformed/unbounded/untrusted content** — the + local attempt becomes `provider_failed`; no provider body, network address, or + credential is reflected to the caller. +5. **Validated provider success** — persist `provider_session_id` and + `provider_succeeded` before returning the hosted URL. +6. **Provider success but local success-state commit fails** — fail closed with + `billing_checkout_state_unavailable`; the attempt remains pending. A later + request can replay the same provider key and recover the cached Session rather + than create a new one. +7. **Known provider failure but local failure-state commit fails** — fail closed + with `billing_checkout_state_unavailable`; do not pretend the local ledger is + authoritative. + +## TDD chronology + +The first child commit, `02f1728f0f271b258e7b0260c5806d51e6a68e2a`, added the +durable-attempt contract while `server/billing_checkout_attempt.mjs` did not yet +exist. Subsequent implementation commits added the repository, bootstrap wiring, +provider binding, coverage registration, and real failure-boundary tests. + +During primary-source reconciliation, Stripe's server-error guidance exposed a +semantic defect in the first implementation: every received non-2xx response was +being treated as a known terminal failure. Regression commit +`35571be0c0e81359dff09238f5815ed13dcf0440` changed the test contract first so a +503 must keep the attempt pending while a 400 closes it. Production commit +`fee01e7dd3055f1aedc0ef12e094536d7af05d13` then made all 5xx responses +indeterminate. Exact-head Server Tests, Dependency Review, and OSV Scanner all +completed successfully for that implementation head; later documentation heads +must obtain their own exact-head evidence before integration. + +## Security, privacy, and operability boundaries + +The ledger stores operational identifiers, not Stripe credentials. Tenant scope +is explicit in every lookup and the pending uniqueness constraint. Error payloads +remain no-store and sanitized. The new local attempt ID is suitable for audit and +support correlation, but customer-facing workflows should not treat it as an +authorization credential. + +This slice still does **not** provide raw-body webhook verification, durable event +deduplication, out-of-order subscription reconciliation, normalized +customer/subscription/payment/entitlement state, retention cleanup policy, +operator-visible attempt inspection, formal schema migrations, restore proof, or +release acceptance. In particular, webhook reconciliation is required to resolve +Stripe 5xx cases that later produce provider-side objects. + +## Rollback + +Do not drop the table as an emergency rollback step. First disable the complete +live Stripe configuration and restart so no new live attempts are created. Revert +the live-route/idempotency code only after preserving any `pending` or +`provider_succeeded` rows needed for incident reconciliation. Schema removal, if +ever required, belongs in a reviewed reversible migration with export/restore +proof; deleting the ledger during an unresolved provider incident would destroy +the evidence needed to avoid duplicate Checkout Sessions. + +## References + +Stripe, Inc. (n.d.). *Advanced error handling*. Stripe Documentation. Retrieved +August 16, 2026, from https://docs.stripe.com/error-low-level + +Stripe, Inc. (n.d.). *Create a Checkout Session*. Stripe API Reference. Retrieved +August 16, 2026, from https://docs.stripe.com/api/checkout/sessions/create + +Stripe, Inc. (n.d.). *Idempotent requests*. Stripe API Reference. Retrieved +August 16, 2026, from https://docs.stripe.com/api/idempotent_requests From 3a0a5c67c2ce28504ebb729eedbf2bfd8bf2c03e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:24:15 +0900 Subject: [PATCH 15/35] docs(billing): document durable Checkout retry semantics --- docs/billing-production.md | 133 +++++++++++++++++++++++++------------ 1 file changed, 92 insertions(+), 41 deletions(-) diff --git a/docs/billing-production.md b/docs/billing-production.md index ff6dff15..0a13e4c8 100644 --- a/docs/billing-production.md +++ b/docs/billing-production.md @@ -52,13 +52,12 @@ request host cannot replace that origin. ## Provider trust boundary -> Active PR state: this section describes the stacked provider-boundary work in -> PR #507. It is not protected-`develop` shipped truth until its parent PR #505 -> and this PR are independently approved and integrated. +> Active PR state: the provider-boundary behavior originates in stacked PR #507; +> the durable retry behavior below is active PR #511. Neither is protected- +> `develop` shipped truth until the stack is independently approved and integrated. The live hosted-Checkout adapter performs one direct server-side HTTPS request to -Stripe until a later lifecycle slice introduces durable checkout-attempt and -idempotency state: +Stripe for each ScopeWeave attempt: - endpoint: exact constant `https://api.stripe.com/v1/checkout/sessions`; - method/body: one POST with `application/x-www-form-urlencoded` fields; @@ -66,12 +65,12 @@ idempotency state: constant Stripe API authority; - total request budget: 15,000 ms using an abort signal; - redirect policy: provider HTTP redirects are rejected; -- automatic application retries: none; +- automatic in-request retry loop: none; - successful response budget: at most 1 MiB before UTF-8 decoding and JSON parsing; invalid, negative, or oversized `Content-Length` declarations are rejected, and a streamed body that crosses the ceiling is cancelled; -- network, abort, or non-2xx provider failures: stable HTTP 502 - `billing_provider_unavailable` with `Cache-Control: no-store`; +- provider/network failures: stable HTTP 502 `billing_provider_unavailable` with + `Cache-Control: no-store`; - successful non-JSON, bodyless, unreadable, malformed JSON, or oversized responses: stable HTTP 502 `billing_provider_invalid_response` with `Cache-Control: no-store`; @@ -83,9 +82,9 @@ idempotency state: This exact-host check deliberately rejects suffix-confusion names such as `checkout.stripe.com.evil.example`. Stripe's current Checkout Session API reference shows a standard hosted `checkout.stripe.com` URL containing an opaque -`#fidk...` fragment. ScopeWeave therefore preserves provider-issued fragments -verbatim after the authority checks instead of treating a fragment as an origin -or hostname decision. +client fragment. ScopeWeave preserves provider-issued fragments verbatim after +the authority checks because fragments do not participate in HTTPS authority +selection. Stripe Checkout custom domains are not silently trusted. Supporting one requires a separate operator-owned allowlist or canonical-domain configuration contract @@ -98,24 +97,66 @@ customer receives a retry/diagnostic next action rather than downstream internal The provider boundary intentionally uses the documented Stripe HTTPS API instead of dynamically importing an undeclared runtime SDK. A clean deployment therefore does not depend on a hidden `stripe` package merely to create the hosted Session. -Package provenance remains part of the normal application supply-chain gate, but -there is no Stripe SDK package gate for this direct adapter. -## Current lifecycle boundary +## Durable Checkout attempt and idempotency boundary + +> Active PR state: this section describes PR #511 only. It is not yet release or +> protected-`develop` truth. + +Before the live POST, ScopeWeave persists a `billing_checkout_attempts` row with +an opaque local attempt ID, tenant/price scope, and an opaque Stripe idempotency +key. A partial unique index permits at most one `pending` attempt for the same +organization and price. The generated key is sent as the Stripe +`Idempotency-Key` header; no secret key, bearer token, or webhook secret is stored +in this ledger. + +An unresolved attempt is reused only while its age is non-negative and strictly +less than 23 hours. The 23-hour local ceiling is intentionally shorter than +Stripe's documented 24-hour safe-retry horizon / at-least-24-hour key-retention +boundary. At or beyond that local ceiling, or after a local clock rollback, the +old unresolved attempt becomes `expired` before a fresh key can be created. + +Provider outcomes are intentionally asymmetric: + +- **network/abort/no HTTP response** — provider outcome is unknown; keep the + attempt `pending` so the next checkout reuses the exact key; +- **Stripe 5xx** — keep the attempt `pending`; Stripe explicitly documents 500 + mutations as indeterminate and warns that retrying with a fresh key can repeat + side effects; +- **Stripe 4xx** — close as `provider_failed`; Stripe's low-level guidance says + the safest 4xx strategy is a fresh idempotency key when trying again; +- **validated 2xx Checkout Session** — validate provider session ID and hosted + destination, persist `provider_succeeded` plus the provider session ID, then + return the hosted URL; +- **2xx with malformed, over-budget, or untrusted content** — close as + `provider_failed` and return only the stable sanitized error contract; +- **provider success followed by local persistence failure** — return + `billing_checkout_state_unavailable` and leave the attempt pending. A later + checkout can replay the same key instead of creating a second provider object. + +Repository construction and request handling perform no DDL. The schema is +installed during database bootstrap after the organization table exists. That +matches the repository's current migration style, but billing release approval +remains blocked until this schema is reconciled with the formal migration-ledger, +restore, and rollback work elsewhere in the repository. + +`docs/doctoring/stripe-checkout-attempt-idempotency.md` records the evidence, +TDD chronology, data model, threat/rollback reasoning, and APA 7 references. -The trusted-configuration and provider-trust slices do **not** declare the Stripe -subscription lifecycle production complete. Before production billing can be -release-approved, ScopeWeave still needs the remaining #488 controls, including: +## Current lifecycle boundary -a durable checkout-attempt UUID and stable idempotency key; raw-body webhook -signature verification and streaming size limits; durable event deduplication; -out-of-order reconciliation; normalized customer/subscription/payment/entitlement -state; transactional reversible entitlement changes; migration and restore -evidence; privacy/incident runbooks; and provider smoke plus release acceptance. +The trusted-configuration, provider-trust, and durable-attempt slices do **not** +declare the Stripe subscription lifecycle production complete. Before production +billing can be release-approved, ScopeWeave still needs the remaining #488 +controls, including raw-body webhook signature verification and streaming size +limits; durable event deduplication; out-of-order reconciliation; normalized +customer/subscription/payment/entitlement state; transactional reversible +entitlement changes; migration and restore evidence; retention/privacy/incident +runbooks; and provider smoke plus release acceptance. -No automatic provider retry should be enabled before durable idempotency exists. No custom Checkout domain should be accepted before an operator-owned trust -configuration exists. +configuration exists. No unresolved attempt record should be deleted merely to +force a retry with a fresh provider key. ## Operator verification @@ -131,23 +172,33 @@ Before a billing-enabled rollout: 4. Capture the canary's outbound request destination and verify exactly one POST goes to `api.stripe.com/v1/checkout/sessions`, redirects are not followed, and the request aborts within the configured 15-second total budget. -5. Exercise network failure, non-2xx response, non-JSON success, malformed JSON, - bodyless success, invalid/oversized declared response length, streamed - response overflow, stream-read failure, and provider timeout handling. - Confirm response bodies above 1 MiB are not buffered/parsed and callers - receive only the stable no-store 502 contract without provider body, network, - stream, or credential detail. -6. Reject null, malformed, plaintext, credential-bearing, non-standard-port, +5. Confirm the outbound request contains an opaque `Idempotency-Key`, then force a + transport timeout and retry the same organization/price inside the local + safety window. The second request must reuse the same key and local attempt ID. +6. Exercise HTTP 400 and HTTP 503 responses separately. A 400 must close the + attempt so a later deliberate checkout receives a fresh key; a 503 must leave + the attempt pending so a later retry cannot silently duplicate provider side + effects. +7. Exercise non-JSON success, malformed JSON, bodyless success, + invalid/oversized declared response length, streamed response overflow, + stream-read failure, and provider timeout handling. Confirm response bodies + above 1 MiB are not buffered/parsed and callers receive only the stable + no-store error contract without provider body, network, stream, or credential + detail. +8. Reject null, malformed, plaintext, credential-bearing, non-standard-port, and hostname-confusion Checkout destinations; accept and preserve the exact standard `https://checkout.stripe.com/...#...` hosted destination, including its provider-issued fragment. -7. Keep the rollout blocked until the remaining #488 lifecycle controls are - implemented and their exact-head security, coverage, review, rollback, and - recovery gates pass together. - -Rollback for the trusted-configuration/provider-boundary stack is data-neutral: -revert the validation and provider-boundary source, tests, documentation, and -CHANGELOG entries together. No database migration or persisted billing state is -introduced by these slices. If billing must be disabled while investigating a -provider outage, remove the complete live provider tuple and restart; never -substitute a production mock. +9. Simulate a successful Stripe response followed by a local state-write failure. + The customer must receive `billing_checkout_state_unavailable`, and a later + retry must preserve the original idempotency identity rather than minting a + duplicate Checkout Session. +10. Keep the rollout blocked until the remaining #488 lifecycle controls and the + formal migration/restore path are implemented and their exact-head security, + coverage, review, rollback, and recovery gates pass together. + +Rollback is no longer data-neutral once PR #511 exists. Disable the complete live +Stripe configuration and restart before reverting request-path code. Preserve +`pending` and `provider_succeeded` attempt rows for reconciliation. Do not drop +or truncate the ledger during a provider incident; any eventual schema removal +must be a reviewed reversible migration with export/restore evidence. \ No newline at end of file From 0d368290fcc8efdcc1ca3e6862b0862bb8a41153 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:24:57 +0900 Subject: [PATCH 16/35] docs(changelog): record durable Checkout idempotency --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18851a2f..527265d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Persisted a tenant/price-scoped Stripe Checkout attempt identity and opaque + idempotency key before live Session creation, reusing unresolved identity only + inside a 23-hour safety window; network/abort and Stripe 5xx outcomes remain + pending to avoid duplicate side effects, while known 4xx and invalid successful + responses close the attempt before a later deliberate Checkout receives fresh + authority. - Bounded hosted Stripe Checkout provider calls to one 15-second, no-retry attempt with a 1 MiB response ceiling before JSON parsing until durable idempotency exists; validated returned destinations as exact HTTPS @@ -95,4 +101,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하던 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. \ No newline at end of file From acb2deb15337154248bf04262fba4c0a9c552a85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:25:45 +0900 Subject: [PATCH 17/35] test(billing): prove live retry idempotency through API route --- tests/api/billing-live-checkout.test.mjs | 135 +++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/api/billing-live-checkout.test.mjs diff --git a/tests/api/billing-live-checkout.test.mjs b/tests/api/billing-live-checkout.test.mjs new file mode 100644 index 00000000..e8058e78 --- /dev/null +++ b/tests/api/billing-live-checkout.test.mjs @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +delete process.env.SCOPEWEAVE_DEV; +process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://planner.example.com'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.STRIPE_SECRET_KEY = 'sk_test_live_route'; +process.env.STRIPE_PRICE_ID = 'price_live_route'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_live_route'; + +const originalFetch = globalThis.fetch; +const providerCalls = []; +let providerAttempt = 0; + +globalThis.fetch = async (url, options) => { + providerAttempt += 1; + providerCalls.push({ url, options }); + if (providerAttempt === 1) { + throw new Error('simulated connection loss after request dispatch'); + } + return new Response(JSON.stringify({ + id: 'cs_test_live_route_recovered', + url: 'https://checkout.stripe.com/c/pay/cs_test_live_route_recovered', + }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); +}; + +const { app } = await import('../../server/app.mjs'); +const { db } = await import('../../server/db.mjs'); + +const jsonHeaders = { 'content-type': 'application/json' }; + +async function createOwner() { + const signup = await app.request('https://edge.example/api/auth/signup', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + email: 'billing-live-route@example.test', + password: 'password123', + name: 'Billing Live Route', + }), + }); + assert.equal(signup.status, 200); + const { token } = await signup.json(); + assert.ok(token); + + const me = await app.request('https://edge.example/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(me.status, 200); + const profile = await me.json(); + assert.equal(profile.orgs.length, 1); + return { token, orgId: profile.orgs[0].id }; +} + +test('uncertain live Checkout retries reuse one persisted provider identity end to end', async () => { + try { + const { token, orgId } = await createOwner(); + const requestOptions = { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }; + + const first = await app.request( + `https://untrusted-proxy.example/api/orgs/${orgId}/checkout`, + requestOptions, + ); + assert.equal(first.status, 502); + assert.deepEqual(await first.json(), { + error: 'billing_provider_unavailable', + action: 'Retry checkout. If the problem persists, verify Stripe connectivity and service health before retrying.', + }); + + const pendingRows = db.prepare(` + SELECT attempt_id, organization_id, price_id, idempotency_key, attempt_state, + provider_session_id + FROM billing_checkout_attempts + WHERE organization_id = ? + `).all(orgId); + assert.equal(pendingRows.length, 1); + assert.equal(pendingRows[0].attempt_state, 'pending'); + assert.equal(pendingRows[0].provider_session_id, null); + + const second = await app.request( + `https://different-proxy.example/api/orgs/${orgId}/checkout`, + requestOptions, + ); + assert.equal(second.status, 200); + const recovered = await second.json(); + assert.equal(recovered.live, true); + assert.equal( + recovered.url, + 'https://checkout.stripe.com/c/pay/cs_test_live_route_recovered', + ); + assert.equal(recovered.checkoutAttemptId, pendingRows[0].attempt_id); + + assert.equal(providerCalls.length, 2); + assert.equal(providerCalls[0].url, 'https://api.stripe.com/v1/checkout/sessions'); + assert.equal(providerCalls[1].url, providerCalls[0].url); + assert.equal( + providerCalls[1].options.headers['idempotency-key'], + providerCalls[0].options.headers['idempotency-key'], + 'the retry must reuse the first uncertain attempt idempotency key', + ); + assert.equal( + providerCalls[0].options.headers['idempotency-key'], + pendingRows[0].idempotency_key, + ); + + for (const call of providerCalls) { + const form = new URLSearchParams(call.options.body); + assert.equal(form.get('success_url'), 'https://planner.example.com/?billing=success'); + assert.equal(form.get('cancel_url'), 'https://planner.example.com/?billing=cancel'); + assert.equal(form.get('line_items[0][price]'), 'price_live_route'); + assert.equal(form.get('client_reference_id'), String(orgId)); + assert.equal(form.get('metadata[orgId]'), String(orgId)); + } + + const settledRows = db.prepare(` + SELECT attempt_id, attempt_state, provider_session_id + FROM billing_checkout_attempts + WHERE organization_id = ? + `).all(orgId); + assert.deepEqual(settledRows.map((row) => ({ ...row })), [{ + attempt_id: pendingRows[0].attempt_id, + attempt_state: 'provider_succeeded', + provider_session_id: 'cs_test_live_route_recovered', + }]); + } finally { + globalThis.fetch = originalFetch; + } +}); From 5db30f2aefa9aa07732ec58e924f25575c51788c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:26:17 +0900 Subject: [PATCH 18/35] test(billing): run live Checkout route acceptance --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4bce404a..dc9129ac 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs", "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && npm run test:api", From 95ca21a953dc4b8b95ef7f74c9d046bebb26f83a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:28:30 +0900 Subject: [PATCH 19/35] test(billing): block stale uncertain Checkout duplication --- tests/unit/billing-checkout-attempt.test.mjs | 69 ++++++++++++++------ 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/tests/unit/billing-checkout-attempt.test.mjs b/tests/unit/billing-checkout-attempt.test.mjs index bc788b0c..cf84e8b9 100644 --- a/tests/unit/billing-checkout-attempt.test.mjs +++ b/tests/unit/billing-checkout-attempt.test.mjs @@ -35,6 +35,14 @@ function deterministicIds() { }; } +function expectReconciliationRequired(run) { + assert.throws(run, (error) => { + assert.equal(error.code, 'billing_checkout_reconciliation_required'); + assert.match(error.message, /reconcil/i); + return true; + }); +} + test('checkout-attempt bootstrap owns only compliant normalized objects', () => { const database = createDatabase(); installBillingCheckoutAttemptSchema(database); @@ -44,13 +52,13 @@ test('checkout-attempt bootstrap owns only compliant normalized objects', () => "SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name = 'billing_checkout_attempts'", ).get(); assert.equal(table.name, 'billing_checkout_attempts'); - assert.match(table.sql, /CHECK\s*\(attempt_state IN \('pending','provider_succeeded','provider_failed','expired'\)\)/); + assert.match(table.sql, /CHECK\s*\(attempt_state IN \('pending','provider_succeeded','provider_failed','reconciliation_required'\)\)/); const index = database.prepare( - "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND name = 'billing_checkout_pending_attempts'", + "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND name = 'billing_checkout_unresolved_attempts'", ).get(); - assert.equal(index.name, 'billing_checkout_pending_attempts'); - assert.match(index.sql, /WHERE attempt_state = 'pending'/); + assert.equal(index.name, 'billing_checkout_unresolved_attempts'); + assert.match(index.sql, /WHERE attempt_state IN \('pending','reconciliation_required'\)/); const columns = database.prepare("PRAGMA table_info('billing_checkout_attempts')").all().map((row) => row.name); assert.deepEqual(columns, [ @@ -157,7 +165,7 @@ test('terminal provider outcomes close the retry identity and a later checkout g assert.notEqual(afterFailure.idempotencyKey, afterSuccess.idempotencyKey); }); -test('pending identities are never reused at or beyond the Stripe retention safety window', () => { +test('stale uncertain attempts fail closed for reconciliation instead of minting a duplicate key', () => { const database = createDatabase(); installBillingCheckoutAttemptSchema(database); let nowMs = 3_000_000; @@ -168,17 +176,33 @@ test('pending identities are never reused at or beyond the Stripe retention safe const oldAttempt = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); nowMs += BILLING_CHECKOUT_REUSE_WINDOW_MS; - const replacement = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); - - assert.notEqual(replacement.attemptId, oldAttempt.attemptId); - assert.notEqual(replacement.idempotencyKey, oldAttempt.idempotencyKey); - const oldRow = database.prepare( - 'SELECT attempt_state FROM billing_checkout_attempts WHERE attempt_id = ?', - ).get(oldAttempt.attemptId); - assert.equal(oldRow.attempt_state, 'expired'); + expectReconciliationRequired( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }), + ); + + const rows = database.prepare(` + SELECT attempt_id, idempotency_key, attempt_state + FROM billing_checkout_attempts + WHERE organization_id = ? AND price_id = ? + `).all(7, 'price_pro'); + assert.deepEqual(rows.map((row) => ({ ...row })), [{ + attempt_id: oldAttempt.attemptId, + idempotency_key: oldAttempt.idempotencyKey, + attempt_state: 'reconciliation_required', + }]); + + nowMs += 1; + expectReconciliationRequired( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }), + ); + assert.equal( + database.prepare('SELECT COUNT(*) AS n FROM billing_checkout_attempts WHERE organization_id = ?').get(7).n, + 1, + 'retries cannot create a second attempt until authoritative reconciliation resolves the first', + ); }); -test('clock rollback expires an unresolved identity instead of replaying it', () => { +test('clock rollback requires reconciliation instead of guessing the provider retention age', () => { const database = createDatabase(); installBillingCheckoutAttemptSchema(database); let nowMs = 5_000_000; @@ -189,13 +213,18 @@ test('clock rollback expires an unresolved identity instead of replaying it', () const first = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); nowMs -= 1_000; - const replacement = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); - - assert.notEqual(replacement.idempotencyKey, first.idempotencyKey); - assert.equal( - database.prepare('SELECT attempt_state FROM billing_checkout_attempts WHERE attempt_id = ?').get(first.attemptId).attempt_state, - 'expired', + expectReconciliationRequired( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }), ); + + const row = database.prepare( + 'SELECT attempt_id, idempotency_key, attempt_state FROM billing_checkout_attempts WHERE attempt_id = ?', + ).get(first.attemptId); + assert.deepEqual({ ...row }, { + attempt_id: first.attemptId, + idempotency_key: first.idempotencyKey, + attempt_state: 'reconciliation_required', + }); }); test('repository rejects malformed identifiers and impossible terminal transitions', () => { From f47858fc004b044c81f6f52b4898ffeecfd0f510 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:29:26 +0900 Subject: [PATCH 20/35] fix(billing): require reconciliation after retry window --- server/billing_checkout_attempt.mjs | 83 +++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 23 deletions(-) diff --git a/server/billing_checkout_attempt.mjs b/server/billing_checkout_attempt.mjs index 3897c272..75150bd5 100644 --- a/server/billing_checkout_attempt.mjs +++ b/server/billing_checkout_attempt.mjs @@ -1,11 +1,12 @@ import { randomUUID as systemRandomUUID } from 'node:crypto'; /** - * Maximum age for reusing an unresolved Stripe idempotency identity. + * Maximum age for automatically replaying an unresolved Stripe idempotency key. * - * Stripe documents that idempotency keys may be pruned after at least 24 hours. - * ScopeWeave therefore uses a 23-hour ceiling so a locally reusable attempt never - * intentionally crosses the provider's documented retention boundary. + * Stripe documents a 24-hour safe-retry horizon for POST idempotency. ScopeWeave + * stops automatic replay one hour earlier. Crossing this local ceiling never + * authorizes a fresh key: the attempt moves to `reconciliation_required` so a + * potentially side-effectful provider outcome cannot be duplicated by guesswork. */ export const BILLING_CHECKOUT_REUSE_WINDOW_MS = 23 * 60 * 60 * 1000; @@ -14,6 +15,23 @@ const MAX_PROVIDER_SESSION_ID_LENGTH = 255; const MAX_IDENTIFIER_LENGTH = 255; const SAVEPOINT_NAME = 'billing_checkout_attempt_write'; +/** + * Signals that a stale or temporally ambiguous provider attempt must be resolved + * from authoritative provider/webhook state before another Checkout can begin. + */ +export class BillingCheckoutReconciliationRequiredError extends Error { + /** @param {string} attemptId - Opaque local attempt requiring reconciliation. */ + constructor(attemptId) { + super('checkout attempt requires authoritative reconciliation before retry'); + this.name = 'BillingCheckoutReconciliationRequiredError'; + this.code = 'billing_checkout_reconciliation_required'; + Object.defineProperty(this, 'attemptId', { + value: attemptId, + enumerable: false, + }); + } +} + function positiveOrganizationId(value) { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed <= 0) { @@ -65,8 +83,8 @@ function withSavepoint(database, operation) { * The schema is intentionally separate from request handling. One row represents * one provider-attempt identity; organization and price facts are referenced or * recorded once, while provider outcome is a state of that same attempt. The - * partial unique index guarantees at most one unresolved retry identity for an - * organization/price pair. + * partial unique index guarantees at most one unresolved or reconciliation-held + * identity for an organization/price pair. * * @param {import('node:sqlite').DatabaseSync} database - Open SQLite database. * @returns {void} @@ -78,7 +96,7 @@ export function installBillingCheckoutAttemptSchema(database) { organization_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, price_id TEXT NOT NULL CHECK(length(price_id) BETWEEN 1 AND ${MAX_PRICE_ID_LENGTH}), idempotency_key TEXT NOT NULL UNIQUE CHECK(length(idempotency_key) BETWEEN 1 AND ${MAX_IDENTIFIER_LENGTH}), - attempt_state TEXT NOT NULL CHECK(attempt_state IN ('pending','provider_succeeded','provider_failed','expired')), + attempt_state TEXT NOT NULL CHECK(attempt_state IN ('pending','provider_succeeded','provider_failed','reconciliation_required')), provider_session_id TEXT CHECK(provider_session_id IS NULL OR length(provider_session_id) BETWEEN 1 AND ${MAX_PROVIDER_SESSION_ID_LENGTH}), created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0), updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms), @@ -87,9 +105,9 @@ export function installBillingCheckoutAttemptSchema(database) { OR (attempt_state <> 'provider_succeeded' AND provider_session_id IS NULL) ) ); - CREATE UNIQUE INDEX IF NOT EXISTS billing_checkout_pending_attempts + CREATE UNIQUE INDEX IF NOT EXISTS billing_checkout_unresolved_attempts ON billing_checkout_attempts(organization_id, price_id) - WHERE attempt_state = 'pending'; + WHERE attempt_state IN ('pending','reconciliation_required'); `); } @@ -126,15 +144,17 @@ export function createSqliteBillingCheckoutAttemptRepository( const statements = () => { if (preparedStatements) return preparedStatements; preparedStatements = { - selectPending: database.prepare(` - SELECT attempt_id, idempotency_key, created_at_ms + selectUnresolved: database.prepare(` + SELECT attempt_id, idempotency_key, attempt_state, created_at_ms FROM billing_checkout_attempts - WHERE organization_id = ? AND price_id = ? AND attempt_state = 'pending' + WHERE organization_id = ? + AND price_id = ? + AND attempt_state IN ('pending','reconciliation_required') LIMIT 1 `), - expirePending: database.prepare(` + requireReconciliation: database.prepare(` UPDATE billing_checkout_attempts - SET attempt_state = 'expired', updated_at_ms = ? + SET attempt_state = 'reconciliation_required', updated_at_ms = ? WHERE attempt_id = ? AND attempt_state = 'pending' `), insertAttempt: database.prepare(` @@ -159,8 +179,9 @@ export function createSqliteBillingCheckoutAttemptRepository( return { /** - * Reuse only a still-pending, same-tenant/same-price identity inside the - * provider retention safety window; otherwise create fresh opaque authority. + * Reuse a still-pending same-tenant/same-price identity only inside the safe + * replay window. Stale, clock-ambiguous, or already-held attempts fail closed + * for authoritative reconciliation and never mint a speculative fresh key. */ startAttempt({ organizationId, priceId }) { const organization = positiveOrganizationId(organizationId); @@ -168,20 +189,29 @@ export function createSqliteBillingCheckoutAttemptRepository( const nowMs = safeNow(now); const sql = statements(); - return withSavepoint(database, () => { - const pending = sql.selectPending.get(organization, price); - if (pending) { - const createdAtMs = Number(pending.created_at_ms); + const result = withSavepoint(database, () => { + const unresolved = sql.selectUnresolved.get(organization, price); + if (unresolved) { + if (unresolved.attempt_state === 'reconciliation_required') { + return { reconciliationRequiredAttemptId: unresolved.attempt_id }; + } + + const createdAtMs = Number(unresolved.created_at_ms); const ageMs = nowMs - createdAtMs; if (ageMs >= 0 && ageMs < BILLING_CHECKOUT_REUSE_WINDOW_MS) { return { - attemptId: pending.attempt_id, - idempotencyKey: pending.idempotency_key, + attemptId: unresolved.attempt_id, + idempotencyKey: unresolved.idempotency_key, state: 'pending', reused: true, }; } - sql.expirePending.run(Math.max(nowMs, createdAtMs), pending.attempt_id); + + sql.requireReconciliation.run( + Math.max(nowMs, createdAtMs), + unresolved.attempt_id, + ); + return { reconciliationRequiredAttemptId: unresolved.attempt_id }; } const attemptId = opaqueIdentifier(randomUUID, 'attemptId'); @@ -189,6 +219,13 @@ export function createSqliteBillingCheckoutAttemptRepository( sql.insertAttempt.run(attemptId, organization, price, idempotencyKey, nowMs, nowMs); return { attemptId, idempotencyKey, state: 'pending', reused: false }; }); + + if (result.reconciliationRequiredAttemptId) { + throw new BillingCheckoutReconciliationRequiredError( + result.reconciliationRequiredAttemptId, + ); + } + return result; }, /** Mark one unresolved attempt successful and bind its provider session ID. */ From ebc7424ba505c4b944bb0f6693d3047536efdce8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:30:20 +0900 Subject: [PATCH 21/35] test(billing): surface reconciliation-required Checkout state --- tests/unit/billing-checkout.test.mjs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index e7363d33..3e009290 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -256,4 +256,32 @@ test('live checkout fails closed when the durable attempt port cannot start or c if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; else process.env.STRIPE_PRICE_ID = previousPrice; } +}); + +test('stale uncertain Checkout state tells the customer not to mint a speculative retry', async () => { + const previousPrice = process.env.STRIPE_PRICE_ID; + process.env.STRIPE_PRICE_ID = 'price_reconciliation_required'; + const reconciliationError = new Error('provider outcome must be reconciled'); + reconciliationError.code = 'billing_checkout_reconciliation_required'; + const attemptRepository = createAttemptRepository({ startError: reconciliationError }); + + try { + let rejected; + await assert.rejects( + createCheckout({ orgId: 73, configuration: liveConfiguration, attemptRepository }), + (error) => { + rejected = error; + assert.equal(error.status, 503); + return true; + }, + ); + const payload = await responsePayloadFrom(rejected); + assert.equal(payload.error, 'billing_checkout_reconciliation_required'); + assert.match(payload.action, /reconcil/i); + assert.match(payload.action, /do not start|do not retry|before/i); + assert.deepEqual(attemptRepository.events.map((event) => event.type), ['start']); + } finally { + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } }); \ No newline at end of file From e7d1e776355a333aadfa1597999acf24251c44c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:38:51 +0900 Subject: [PATCH 22/35] fix(billing): preserve reconciliation-required checkout state --- server/billing.mjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index 679f792d..dfea9371 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -62,6 +62,16 @@ function checkoutStateFailure() { }); } +function checkoutReconciliationRequiredFailure() { + return new HTTPException(503, { + res: jsonErrorResponse( + 503, + 'billing_checkout_reconciliation_required', + 'Reconcile the existing Checkout attempt with authoritative Stripe or webhook state before starting or retrying Checkout.', + ), + }); +} + function providerFailure(code, action, { outcomeKnown = false } = {}) { const error = new HTTPException(502, { res: jsonErrorResponse(502, code, action), @@ -307,7 +317,10 @@ export async function createCheckout({ let attempt; try { attempt = repository.startAttempt({ organizationId: orgId, priceId }); - } catch { + } catch (error) { + if (error?.code === 'billing_checkout_reconciliation_required') { + throw checkoutReconciliationRequiredFailure(); + } throw checkoutStateFailure(); } @@ -364,4 +377,4 @@ export async function createCheckout({ } return { url: `${publicOrigin}/?billing=mock&org=${encodeURIComponent(String(orgId))}`, live: false, mock: true }; -} \ No newline at end of file +} From b775edddd4677b122dffc8293fb79a2a1376004d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 05:22:57 +0900 Subject: [PATCH 23/35] test(billing): reproduce uncertain 2xx and clock rollback regressions --- ...lling-checkout-review-regressions.test.mjs | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 tests/unit/billing-checkout-review-regressions.test.mjs diff --git a/tests/unit/billing-checkout-review-regressions.test.mjs b/tests/unit/billing-checkout-review-regressions.test.mjs new file mode 100644 index 00000000..a028ae83 --- /dev/null +++ b/tests/unit/billing-checkout-review-regressions.test.mjs @@ -0,0 +1,214 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { createCheckout } from '../../server/billing.mjs'; +import { + createSqliteBillingCheckoutAttemptRepository, + installBillingCheckoutAttemptSchema, +} from '../../server/billing_checkout_attempt.mjs'; + +const liveConfiguration = { + mode: 'live', + publicOrigin: 'https://planner.example.com', +}; + +function createAttemptRepository() { + const events = []; + return { + events, + startAttempt(input) { + events.push({ type: 'start', input }); + return { + attemptId: 'attempt-review-regression', + idempotencyKey: 'idem-review-regression', + state: 'pending', + reused: false, + }; + }, + markProviderSucceeded(input) { + events.push({ type: 'success', input }); + }, + markProviderFailed(input) { + events.push({ type: 'failure', input }); + }, + }; +} + +async function withStripeEnv(run) { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + const previousFetch = globalThis.fetch; + process.env.STRIPE_SECRET_KEY = 'sk_test_review_regression'; + process.env.STRIPE_PRICE_ID = 'price_review_regression'; + try { + await run(); + } finally { + globalThis.fetch = previousFetch; + if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = previousSecret; + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +} + +async function expectProviderInvalidResponse(run) { + let rejected; + await assert.rejects(run, (error) => { + rejected = error; + assert.equal(error.status, 502); + return true; + }); + const response = rejected.getResponse(); + const payload = await response.json(); + assert.equal(payload.error, 'billing_provider_invalid_response'); +} + +test('malformed successful Stripe responses keep the durable retry identity unresolved', async () => { + await withStripeEnv(async () => { + const cases = [ + { + name: 'non-JSON 2xx response', + response: () => new Response('unexpected', { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + }, + { + name: 'untrusted hosted URL in a 2xx response', + response: () => new Response(JSON.stringify({ + id: 'cs_test_review_regression', + url: 'https://checkout.stripe.com.evil.example/c/pay/session', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + }, + ]; + + for (const scenario of cases) { + const repository = createAttemptRepository(); + globalThis.fetch = async () => scenario.response(); + + await expectProviderInvalidResponse(() => createCheckout({ + orgId: 73, + configuration: liveConfiguration, + attemptRepository: repository, + })); + + assert.deepEqual( + repository.events.map((event) => event.type), + ['start'], + `${scenario.name} is an uncertain provider outcome and must retain the same idempotency key`, + ); + } + }); +}); + +function createCheckoutAttemptFixture(startTimeMs) { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON'); + database.exec('CREATE TABLE orgs (id INTEGER PRIMARY KEY)'); + database.prepare('INSERT INTO orgs(id) VALUES(?)').run(7); + installBillingCheckoutAttemptSchema(database); + + let nowMs = startTimeMs; + const identifiers = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + ]; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + now: () => nowMs, + randomUUID: () => identifiers.shift(), + }); + const attempt = repository.startAttempt({ + organizationId: 7, + priceId: 'price_review_regression', + }); + return { + database, + repository, + attempt, + rollbackClock() { + nowMs = startTimeMs - 1_000; + }, + }; +} + +test('terminal success remains durable when the wall clock moves behind attempt creation', () => { + const fixture = createCheckoutAttemptFixture(5_000_000); + fixture.rollbackClock(); + + fixture.repository.markProviderSucceeded({ + attemptId: fixture.attempt.attemptId, + providerSessionId: 'cs_test_clock_rollback', + }); + + const row = fixture.database.prepare(` + SELECT attempt_state, provider_session_id, created_at_ms, updated_at_ms + FROM billing_checkout_attempts + WHERE attempt_id = ? + `).get(fixture.attempt.attemptId); + assert.deepEqual({ ...row }, { + attempt_state: 'provider_succeeded', + provider_session_id: 'cs_test_clock_rollback', + created_at_ms: 5_000_000, + updated_at_ms: 5_000_000, + }); +}); + +test('terminal failure remains durable when the wall clock moves behind attempt creation', () => { + const fixture = createCheckoutAttemptFixture(6_000_000); + fixture.rollbackClock(); + + fixture.repository.markProviderFailed({ attemptId: fixture.attempt.attemptId }); + + const row = fixture.database.prepare(` + SELECT attempt_state, created_at_ms, updated_at_ms + FROM billing_checkout_attempts + WHERE attempt_id = ? + `).get(fixture.attempt.attemptId); + assert.deepEqual({ ...row }, { + attempt_state: 'provider_failed', + created_at_ms: 6_000_000, + updated_at_ms: 6_000_000, + }); +}); + +test('live Checkout reports missing price configuration before touching durable state', async () => { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + process.env.STRIPE_SECRET_KEY = 'sk_test_review_regression'; + delete process.env.STRIPE_PRICE_ID; + let startAttemptCalled = false; + try { + let rejected; + await assert.rejects( + () => createCheckout({ + orgId: 73, + configuration: liveConfiguration, + attemptRepository: { + startAttempt() { + startAttemptCalled = true; + throw new Error('must not reach durable state without price configuration'); + }, + markProviderSucceeded() {}, + markProviderFailed() {}, + }, + }), + (error) => { + rejected = error; + assert.equal(error.status, 503); + return true; + }, + ); + const payload = await rejected.getResponse().json(); + assert.equal(payload.error, 'billing_not_configured'); + assert.equal(startAttemptCalled, false); + } finally { + if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = previousSecret; + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +}); From e8abdf9bddb609aa5504a5c680e104772408d5d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 05:24:04 +0900 Subject: [PATCH 24/35] test(billing): register review regressions in gates --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index dc9129ac..efba8b88 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", From d0a826ccc654288b6d096ef4e199d0e4da5b9b1b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 05:29:59 +0900 Subject: [PATCH 25/35] fix(billing): preserve uncertain checkout retry identity --- docs/billing-production.md | 35 ++++++---- .../stripe-checkout-attempt-idempotency.md | 66 ++++++++++++------- server/billing.mjs | 20 ++++-- server/billing_checkout_attempt.mjs | 5 +- tests/unit/billing-provider-boundary.test.mjs | 40 ++++++----- 5 files changed, 103 insertions(+), 63 deletions(-) diff --git a/docs/billing-production.md b/docs/billing-production.md index 0a13e4c8..46c32b06 100644 --- a/docs/billing-production.md +++ b/docs/billing-production.md @@ -105,16 +105,19 @@ does not depend on a hidden `stripe` package merely to create the hosted Session Before the live POST, ScopeWeave persists a `billing_checkout_attempts` row with an opaque local attempt ID, tenant/price scope, and an opaque Stripe idempotency -key. A partial unique index permits at most one `pending` attempt for the same -organization and price. The generated key is sent as the Stripe -`Idempotency-Key` header; no secret key, bearer token, or webhook secret is stored -in this ledger. +key. A partial unique index permits at most one unresolved attempt (`pending` or +`reconciliation_required`) for the same organization and price. The generated +key is sent as the Stripe `Idempotency-Key` header; no secret key, bearer token, +or webhook secret is stored in this ledger. An unresolved attempt is reused only while its age is non-negative and strictly less than 23 hours. The 23-hour local ceiling is intentionally shorter than Stripe's documented 24-hour safe-retry horizon / at-least-24-hour key-retention boundary. At or beyond that local ceiling, or after a local clock rollback, the -old unresolved attempt becomes `expired` before a fresh key can be created. +old unresolved attempt becomes `reconciliation_required`; checkout then fails +closed until authoritative provider/webhook reconciliation resolves that held +identity. ScopeWeave does not mint a fresh key merely because local time is stale +or contradictory. Provider outcomes are intentionally asymmetric: @@ -128,8 +131,10 @@ Provider outcomes are intentionally asymmetric: - **validated 2xx Checkout Session** — validate provider session ID and hosted destination, persist `provider_succeeded` plus the provider session ID, then return the hosted URL; -- **2xx with malformed, over-budget, or untrusted content** — close as - `provider_failed` and return only the stable sanitized error contract; +- **2xx with malformed, over-budget, unreadable, or untrusted content** — the + provider may already have committed the mutation, so keep the attempt `pending` + and return only the stable sanitized error contract; a later retry must reuse + the same idempotency key rather than create a speculative second Session; - **provider success followed by local persistence failure** — return `billing_checkout_state_unavailable` and leave the attempt pending. A later checkout can replay the same key instead of creating a second provider object. @@ -152,7 +157,8 @@ controls, including raw-body webhook signature verification and streaming size limits; durable event deduplication; out-of-order reconciliation; normalized customer/subscription/payment/entitlement state; transactional reversible entitlement changes; migration and restore evidence; retention/privacy/incident -runbooks; and provider smoke plus release acceptance. +runbooks; provider smoke plus release acceptance; and operator-visible alerting, +inspection, and audited resolution for `reconciliation_required` attempts. No custom Checkout domain should be accepted before an operator-owned trust configuration exists. No unresolved attempt record should be deleted merely to @@ -182,9 +188,9 @@ Before a billing-enabled rollout: 7. Exercise non-JSON success, malformed JSON, bodyless success, invalid/oversized declared response length, streamed response overflow, stream-read failure, and provider timeout handling. Confirm response bodies - above 1 MiB are not buffered/parsed and callers receive only the stable - no-store error contract without provider body, network, stream, or credential - detail. + above 1 MiB are not buffered/parsed, callers receive only the stable no-store + error contract without provider body/network/credential detail, and every + malformed 2xx case remains pending for same-key retry/reconciliation. 8. Reject null, malformed, plaintext, credential-bearing, non-standard-port, and hostname-confusion Checkout destinations; accept and preserve the exact standard `https://checkout.stripe.com/...#...` hosted destination, including @@ -199,6 +205,7 @@ Before a billing-enabled rollout: Rollback is no longer data-neutral once PR #511 exists. Disable the complete live Stripe configuration and restart before reverting request-path code. Preserve -`pending` and `provider_succeeded` attempt rows for reconciliation. Do not drop -or truncate the ledger during a provider incident; any eventual schema removal -must be a reviewed reversible migration with export/restore evidence. \ No newline at end of file +`pending`, `reconciliation_required`, and `provider_succeeded` attempt rows for +reconciliation. Do not drop or truncate the ledger during a provider incident; +any eventual schema removal must be a reviewed reversible migration with +export/restore evidence. \ No newline at end of file diff --git a/docs/doctoring/stripe-checkout-attempt-idempotency.md b/docs/doctoring/stripe-checkout-attempt-idempotency.md index 94bfe274..a3dae6c9 100644 --- a/docs/doctoring/stripe-checkout-attempt-idempotency.md +++ b/docs/doctoring/stripe-checkout-attempt-idempotency.md @@ -27,6 +27,7 @@ local ceiling, not a claim that Stripe purges every key at exactly 24 hours. | Stripe records POST results by idempotency key and compares parameters on reuse. | Persist one organization/price attempt identity and reuse the same key only while that exact attempt is unresolved. | repository reuse/terminal-state tests plus transport header tests | | A network failure can leave the client unable to know whether Stripe executed the mutation. | Network/abort failures leave the attempt `pending`; the next caller reuses the same key. | `tests/unit/billing-provider-boundary.test.mjs` | | Stripe documents server errors, especially HTTP 500, as indeterminate and warns that a fresh key can duplicate side effects. | All Stripe 5xx responses keep the attempt `pending`; no fresh key is issued merely because a server-error response arrived. | regression commit `35571be0c0e81359dff09238f5815ed13dcf0440` followed by the production fix | +| A successful HTTP response can still be unusable locally after the provider has performed the mutation. | Malformed, unreadable, over-budget, or untrusted 2xx responses remain unresolved and reuse the same idempotency key instead of closing the attempt. | `tests/unit/billing-checkout-review-regressions.test.mjs` and provider-boundary regressions | | Stripe's safest documented strategy for 4xx errors is a fresh idempotency key after correcting/retrying the request. | A received 4xx closes the current local attempt as `provider_failed`; a later deliberate Checkout obtains fresh authority. | provider-boundary 4xx regression | | Checkout Sessions expose `client_reference_id` for reconciliation with internal systems. | Send organization identity as `client_reference_id` and metadata while retaining a separate opaque local attempt ID. | transport form assertions | @@ -41,16 +42,19 @@ owned names are descriptive multi-word `snake_case` identifiers. - `price_id`: server-owned Stripe price identity used for the request. - `idempotency_key`: unique opaque Stripe POST identity. - `attempt_state`: `pending`, `provider_succeeded`, `provider_failed`, or - `expired`. + `reconciliation_required`. - `provider_session_id`: populated only after a validated successful provider response. - `created_at_ms` / `updated_at_ms`: bounded local lifecycle timestamps. -A partial unique index on `(organization_id, price_id)` while `pending` prevents -two unresolved retry identities for the same tenant/price. The repository uses a -savepoint around each synchronous state mutation. Clock rollback is fail-safe: -a pending attempt whose calculated age is negative is expired rather than -silently replayed. +A partial unique index on `(organization_id, price_id)` while the state is +`pending` or `reconciliation_required` prevents two unresolved retry identities +for the same tenant/price. The repository uses a savepoint around each synchronous +state mutation. Clock rollback is fail-safe: a pending attempt whose calculated +age is negative is moved to `reconciliation_required` rather than silently +replayed, and terminal writes clamp `updated_at_ms` to at least `created_at_ms` +so a provider outcome can still be recorded without violating the timestamp +constraint. The table is installed only during database bootstrap after the referenced organization table exists. Repository construction and request handling do not @@ -67,8 +71,9 @@ must converge before billing release approval. 3. **Stripe 4xx** — customer receives sanitized 502; the local attempt becomes `provider_failed` so a later corrected Checkout can use a fresh key. 4. **Successful HTTP response with malformed/unbounded/untrusted content** — the - local attempt becomes `provider_failed`; no provider body, network address, or - credential is reflected to the caller. + provider may already have committed the mutation, so the local attempt remains + pending; no provider body, network address, or credential is reflected to the + caller, and a later retry reuses the same idempotency key. 5. **Validated provider success** — persist `provider_session_id` and `provider_succeeded` before returning the hosted URL. 6. **Provider success but local success-state commit fails** — fail closed with @@ -78,6 +83,9 @@ must converge before billing release approval. 7. **Known provider failure but local failure-state commit fails** — fail closed with `billing_checkout_state_unavailable`; do not pretend the local ledger is authoritative. +8. **Stale or clock-ambiguous unresolved attempt** — move it to + `reconciliation_required` and fail closed. No fresh key is issued until an + authoritative reconciliation path resolves that held identity. ## TDD chronology @@ -92,34 +100,46 @@ being treated as a known terminal failure. Regression commit `35571be0c0e81359dff09238f5815ed13dcf0440` changed the test contract first so a 503 must keep the attempt pending while a 400 closes it. Production commit `fee01e7dd3055f1aedc0ef12e094536d7af05d13` then made all 5xx responses -indeterminate. Exact-head Server Tests, Dependency Review, and OSV Scanner all -completed successfully for that implementation head; later documentation heads -must obtain their own exact-head evidence before integration. +indeterminate. + +A later current-head review exposed two additional causal defects and one +defensive configuration diagnostic: malformed 2xx outcomes were being closed as +known failures, and terminal ledger writes failed the timestamp CHECK after wall- +clock rollback. Regression file `tests/unit/billing-checkout-review-regressions.test.mjs` +was registered in the real unit/coverage gates before the production fix. The +exact merge checkout for head `e8abdf9bddb609aa5504a5c680e104772408d5d3` +failed all four targeted assertions, including both SQLite CHECK violations and +the missing-price diagnostic mismatch. The production fix must obtain its own +exact-head GREEN evidence before integration; predecessor success is not reused. ## Security, privacy, and operability boundaries The ledger stores operational identifiers, not Stripe credentials. Tenant scope -is explicit in every lookup and the pending uniqueness constraint. Error payloads -remain no-store and sanitized. The new local attempt ID is suitable for audit and -support correlation, but customer-facing workflows should not treat it as an -authorization credential. +is explicit in every lookup and the unresolved uniqueness constraint. Error +payloads remain no-store and sanitized. The new local attempt ID is suitable for +audit and support correlation, but customer-facing workflows should not treat it +as an authorization credential. This slice still does **not** provide raw-body webhook verification, durable event deduplication, out-of-order subscription reconciliation, normalized customer/subscription/payment/entitlement state, retention cleanup policy, -operator-visible attempt inspection, formal schema migrations, restore proof, or -release acceptance. In particular, webhook reconciliation is required to resolve -Stripe 5xx cases that later produce provider-side objects. +operator-visible attempt inspection/alerting/audited resolution, formal schema +migrations, restore proof, or release acceptance. In particular, webhook or +another authoritative provider reconciliation path is required to resolve Stripe +5xx and malformed-2xx cases that may have produced provider-side objects, and +`reconciliation_required` remains intentionally blocking until that follow-up +slice exists. ## Rollback Do not drop the table as an emergency rollback step. First disable the complete live Stripe configuration and restart so no new live attempts are created. Revert -the live-route/idempotency code only after preserving any `pending` or -`provider_succeeded` rows needed for incident reconciliation. Schema removal, if -ever required, belongs in a reviewed reversible migration with export/restore -proof; deleting the ledger during an unresolved provider incident would destroy -the evidence needed to avoid duplicate Checkout Sessions. +the live-route/idempotency code only after preserving any `pending`, +`reconciliation_required`, or `provider_succeeded` rows needed for incident +reconciliation. Schema removal, if ever required, belongs in a reviewed reversible +migration with export/restore proof; deleting the ledger during an unresolved +provider incident would destroy the evidence needed to avoid duplicate Checkout +Sessions. ## References diff --git a/server/billing.mjs b/server/billing.mjs index dfea9371..c5849c8b 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -92,10 +92,15 @@ function providerUnavailableFailure(outcomeKnown = false) { } function providerInvalidResponseFailure() { + // This error is raised only after Stripe has returned a successful HTTP status + // or an SDK-style call has returned a session-like value. The provider may + // already have committed the mutation, so the outcome is not known merely + // because the response representation is unusable. Preserve the durable + // idempotency identity for an authoritative replay/reconciliation path. return providerFailure( 'billing_provider_invalid_response', 'Retry checkout. If the problem persists, verify the Stripe Checkout provider configuration and service health.', - { outcomeKnown: true }, + { outcomeKnown: false }, ); } @@ -281,11 +286,11 @@ function markKnownProviderFailure(repository, attemptId, error) { * production capability returns HTTP 503 instead of pretending checkout worked. * Live provider calls use one direct HTTPS attempt with a 15-second total budget, * a 1 MiB response-body ceiling, and a durable per-attempt idempotency key. - * Network/abort and Stripe 5xx failures keep the attempt pending so a later call - * reuses the same key; known 4xx and validated malformed-success outcomes close - * the attempt so a deliberate later checkout gets fresh provider authority. The - * hosted destination must use Stripe's standard HTTPS authority; provider-issued - * client fragments are preserved verbatim. + * Network/abort, Stripe 5xx, and malformed/untrusted 2xx response outcomes keep + * the attempt pending so a later call reuses the same key; known 4xx responses + * close the attempt so a deliberate later checkout gets fresh provider authority. + * The hosted destination must use Stripe's standard HTTPS authority; provider- + * issued client fragments are preserved verbatim. * * @param {object} options - Checkout inputs and optional deterministic test seams. * @param {string|number} options.orgId - Organization that owns the checkout. @@ -314,6 +319,9 @@ export async function createCheckout({ if (mode === 'live') { const repository = await resolveAttemptRepository(attemptRepository); const priceId = process.env.STRIPE_PRICE_ID; + if (typeof priceId !== 'string' || priceId.trim().length === 0) { + throw new HTTPException(503, { res: billingUnavailableResponse() }); + } let attempt; try { attempt = repository.startAttempt({ organizationId: orgId, priceId }); diff --git a/server/billing_checkout_attempt.mjs b/server/billing_checkout_attempt.mjs index 75150bd5..18e95977 100644 --- a/server/billing_checkout_attempt.mjs +++ b/server/billing_checkout_attempt.mjs @@ -165,12 +165,13 @@ export function createSqliteBillingCheckoutAttemptRepository( `), succeedAttempt: database.prepare(` UPDATE billing_checkout_attempts - SET attempt_state = 'provider_succeeded', provider_session_id = ?, updated_at_ms = ? + SET attempt_state = 'provider_succeeded', provider_session_id = ?, + updated_at_ms = MAX(?, created_at_ms) WHERE attempt_id = ? AND attempt_state = 'pending' `), failAttempt: database.prepare(` UPDATE billing_checkout_attempts - SET attempt_state = 'provider_failed', updated_at_ms = ? + SET attempt_state = 'provider_failed', updated_at_ms = MAX(?, created_at_ms) WHERE attempt_id = ? AND attempt_state = 'pending' `), }; diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 97efde50..1f4dd293 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -74,6 +74,14 @@ async function expectProviderError(run, expectedCode) { return JSON.stringify(payload); } +function expectUnresolved(attemptRepository, message) { + assert.deepEqual( + attemptRepository.events.map((event) => event.type), + ['start'], + message, + ); +} + test('live Checkout uses one bounded direct Stripe HTTPS request and preserves the hosted URL', async () => { await withStripeEnv(async () => { const observed = []; @@ -121,7 +129,7 @@ test('live Checkout uses one bounded direct Stripe HTTPS request and preserves t }); }); -test('live Checkout rejects malformed provider identities or untrusted browser authorities', async () => { +test('live Checkout rejects malformed provider identities or untrusted browser authorities without closing retry identity', async () => { const invalidUrls = [ null, '', @@ -143,7 +151,7 @@ test('live Checkout rejects malformed provider identities or untrusted browser a () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); - assert.equal(attemptRepository.events.at(-1).type, 'failure'); + expectUnresolved(attemptRepository, 'untrusted 2xx destination remains unresolved'); } for (const id of [null, '', 'x'.repeat(256)]) { @@ -156,7 +164,7 @@ test('live Checkout rejects malformed provider identities or untrusted browser a () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); - assert.equal(attemptRepository.events.at(-1).type, 'failure'); + expectUnresolved(attemptRepository, 'malformed 2xx provider identity remains unresolved'); } }); }); @@ -173,11 +181,11 @@ test('uncertain transport failures stay pending and remain sanitized', async () 'billing_provider_unavailable', ); assert.doesNotMatch(payload, /10\.7\.0\.12|sk_live_should_not_escape/); - assert.deepEqual(attemptRepository.events.map((event) => event.type), ['start']); + expectUnresolved(attemptRepository, 'transport failure remains unresolved'); }); }); -test('Stripe server errors remain indeterminate while 4xx and malformed successes close retry identity', async () => { +test('Stripe server and malformed-success outcomes remain indeterminate while 4xx closes retry identity', async () => { await withStripeEnv(async () => { let attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response('provider incident body', { @@ -189,11 +197,7 @@ test('Stripe server errors remain indeterminate while 4xx and malformed successe 'billing_provider_unavailable', ); assert.doesNotMatch(serverErrorPayload, /provider incident body/); - assert.deepEqual( - attemptRepository.events.map((event) => event.type), - ['start'], - '5xx is indeterminate and must preserve the same retry identity', - ); + expectUnresolved(attemptRepository, '5xx is indeterminate and must preserve the same retry identity'); attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response('invalid request body detail', { @@ -216,7 +220,7 @@ test('Stripe server errors remain indeterminate while 4xx and malformed successe () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); - assert.equal(attemptRepository.events.at(-1).type, 'failure'); + expectUnresolved(attemptRepository, 'non-JSON 2xx remains unresolved'); attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response('{malformed', { @@ -227,7 +231,7 @@ test('Stripe server errors remain indeterminate while 4xx and malformed successe () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); - assert.equal(attemptRepository.events.at(-1).type, 'failure'); + expectUnresolved(attemptRepository, 'malformed JSON 2xx remains unresolved'); attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response(null, { @@ -238,7 +242,7 @@ test('Stripe server errors remain indeterminate while 4xx and malformed successe () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); - assert.equal(attemptRepository.events.at(-1).type, 'failure'); + expectUnresolved(attemptRepository, 'bodyless 2xx remains unresolved'); }); }); @@ -257,7 +261,7 @@ test('provider response declarations and streamed bytes are bounded before JSON () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); - assert.equal(attemptRepository.events.at(-1).type, 'failure'); + expectUnresolved(attemptRepository, 'invalid successful response declaration remains unresolved'); } let cancelled = false; @@ -285,11 +289,11 @@ test('provider response declarations and streamed bytes are bounded before JSON 'billing_provider_invalid_response', ); assert.equal(cancelled, true, 'oversized streamed provider bodies are cancelled at the byte boundary'); - assert.equal(attemptRepository.events.at(-1).type, 'failure'); + expectUnresolved(attemptRepository, 'oversized successful response remains unresolved'); }); }); -test('provider stream read failures remain sanitized invalid responses', async () => { +test('provider stream read failures remain sanitized invalid responses without closing retry identity', async () => { await withStripeEnv(async () => { const failingBody = new ReadableStream({ pull(controller) { @@ -307,7 +311,7 @@ test('provider stream read failures remain sanitized invalid responses', async ( 'billing_provider_invalid_response', ); assert.doesNotMatch(payload, /provider stream secret|10\.9\.0\.7/); - assert.equal(attemptRepository.events.at(-1).type, 'failure'); + expectUnresolved(attemptRepository, 'unreadable successful response remains unresolved'); }); }); @@ -331,4 +335,4 @@ test('a known provider failure that cannot be durably closed fails as state unav const payload = await rejected.getResponse().json(); assert.equal(payload.error, 'billing_checkout_state_unavailable'); }); -}); \ No newline at end of file +}); From 2cd6ee5c4039906a84092fd120972cfce3f31f89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:02:09 +0900 Subject: [PATCH 26/35] test(billing): preserve Checkout attempt causal rollback failure --- tests/unit/billing-checkout-attempt.test.mjs | 40 +++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/unit/billing-checkout-attempt.test.mjs b/tests/unit/billing-checkout-attempt.test.mjs index cf84e8b9..c0fbd3e7 100644 --- a/tests/unit/billing-checkout-attempt.test.mjs +++ b/tests/unit/billing-checkout-attempt.test.mjs @@ -335,4 +335,42 @@ test('invalid clock and identifier sources roll back without leaving a pending r database.prepare('SELECT COUNT(*) AS n FROM billing_checkout_attempts').get().n, 0, ); -}); \ No newline at end of file +}); + +test('rollback cleanup preserves the causal attempt write failure and never releases unconfirmed state', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + database.exec(` + CREATE TRIGGER billing_checkout_test_attempt_failure + BEFORE INSERT ON billing_checkout_attempts + BEGIN + SELECT RAISE(ABORT, 'causal checkout attempt write failure'); + END; + `); + + const executed = []; + const guardedDatabase = { + prepare: database.prepare.bind(database), + exec(sql) { + executed.push(sql); + if (sql === 'ROLLBACK TO SAVEPOINT billing_checkout_attempt_write') { + throw new Error('simulated rollback cleanup failure'); + } + return database.exec(sql); + }, + }; + const repository = createSqliteBillingCheckoutAttemptRepository(guardedDatabase, { + randomUUID: deterministicIds(), + now: () => 8_000_000, + }); + + assert.throws( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_rollback_failure' }), + /causal checkout attempt write failure/, + ); + assert.equal( + executed.filter((sql) => sql === 'RELEASE SAVEPOINT billing_checkout_attempt_write').length, + 0, + 'failed rollback must not release an unconfirmed savepoint and accidentally commit partial state', + ); +}); From 03fdbffae72da828db57484d01e98bf00eeb6542 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:04:05 +0900 Subject: [PATCH 27/35] fix(billing): fail closed on Checkout attempt savepoint cleanup --- server/billing_checkout_attempt.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server/billing_checkout_attempt.mjs b/server/billing_checkout_attempt.mjs index 18e95977..54579b64 100644 --- a/server/billing_checkout_attempt.mjs +++ b/server/billing_checkout_attempt.mjs @@ -68,10 +68,19 @@ function withSavepoint(database, operation) { database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); return result; } catch (error) { + let rollbackSucceeded = false; try { database.exec(`ROLLBACK TO SAVEPOINT ${SAVEPOINT_NAME}`); - } finally { - database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + rollbackSucceeded = true; + } catch { + // Keep an unconfirmed failed savepoint open instead of risking a partial commit. + } + if (rollbackSucceeded) { + try { + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + } catch { + // Cleanup after a confirmed rollback must not replace the causal operation error. + } } throw error; } From 5b1071fcfa00db2b887195c9d87bfaf3e43f3c73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:46:39 +0900 Subject: [PATCH 28/35] test(billing): reject coerced checkout tenant authority --- ...illing-checkout-attempt-authority.test.mjs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/unit/billing-checkout-attempt-authority.test.mjs diff --git a/tests/unit/billing-checkout-attempt-authority.test.mjs b/tests/unit/billing-checkout-attempt-authority.test.mjs new file mode 100644 index 00000000..bc7800f4 --- /dev/null +++ b/tests/unit/billing-checkout-attempt-authority.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { + createSqliteBillingCheckoutAttemptRepository, + installBillingCheckoutAttemptSchema, +} from '../../server/billing_checkout_attempt.mjs'; + +function authorityDatabase() { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON'); + database.exec('CREATE TABLE orgs (id INTEGER PRIMARY KEY)'); + database.prepare('INSERT INTO orgs(id) VALUES(?)').run(1); + installBillingCheckoutAttemptSchema(database); + return database; +} + +test('checkout-attempt authority rejects non-number/string values before tenant lookup', () => { + const database = authorityDatabase(); + let uuidCounter = 0; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: () => `00000000-0000-4000-8000-${String(++uuidCounter).padStart(12, '0')}`, + now: () => 1_000, + }); + + for (const organizationId of [true, new Number(1), [1]]) { + assert.throws( + () => repository.startAttempt({ organizationId, priceId: 'price_pro' }), + TypeError, + 'tenant authority must not be synthesized through JavaScript numeric coercion', + ); + } + + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM billing_checkout_attempts').get().count, + 0, + 'malformed local authority cannot create provider retry authority', + ); + database.close(); +}); From 9b5312276e50d445a8025c640e7f241cffc58f7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:48:00 +0900 Subject: [PATCH 29/35] test(billing): execute tenant-authority regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9b535767..926a58cd 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", From 83ac40fd8c9079f844e668bc3973047e832b8351 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:48:32 +0900 Subject: [PATCH 30/35] fix(billing): reject coerced checkout tenant authority --- server/billing_checkout_attempt.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/billing_checkout_attempt.mjs b/server/billing_checkout_attempt.mjs index 54579b64..8ed8ffd9 100644 --- a/server/billing_checkout_attempt.mjs +++ b/server/billing_checkout_attempt.mjs @@ -33,6 +33,9 @@ export class BillingCheckoutReconciliationRequiredError extends Error { } function positiveOrganizationId(value) { + if (typeof value !== 'number' && typeof value !== 'string') { + throw new TypeError('organizationId must be a positive integer'); + } const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed <= 0) { throw new TypeError('organizationId must be a positive integer'); From 9731999da14a84a60c0b298c976415914f37155c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:59:07 -0700 Subject: [PATCH 31/35] test(billing): inherit provider body cleanup contract --- tests/unit/billing-provider-boundary.test.mjs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 1f4dd293..108a0e21 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -246,6 +246,70 @@ test('Stripe server and malformed-success outcomes remain indeterminate while 4x }); }); +test('rejected Stripe responses cancel unread bodies while preserving retry-state semantics', async () => { + await withStripeEnv(async () => { + for (const scenario of [ + { status: 503, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: false }, + { status: 400, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: true }, + { status: 200, contentType: 'text/html', expectedCode: 'billing_provider_invalid_response', closesAttempt: false }, + ]) { + let cancelled = false; + const unreadBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('provider bytes that must not remain leased')); + }, + cancel() { + cancelled = true; + }, + }); + const attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response(unreadBody, { + status: scenario.status, + headers: { 'content-type': scenario.contentType }, + }); + + await expectProviderError( + () => liveCheckout(attemptRepository), + scenario.expectedCode, + ); + assert.equal(cancelled, true, `${scenario.expectedCode} cancels its unread response body`); + if (scenario.closesAttempt) { + assert.equal(attemptRepository.events.at(-1).type, 'failure'); + } else { + expectUnresolved(attemptRepository, 'indeterminate provider response keeps the durable retry identity'); + } + } + }); +}); + +test('response-body cleanup failure never replaces provider error or attempt outcome semantics', async () => { + await withStripeEnv(async () => { + let cancelCalls = 0; + const unreadBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('provider body')); + }, + cancel() { + cancelCalls += 1; + throw new Error('cleanup secret must not escape'); + }, + }); + const attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response(unreadBody, { + status: 400, + headers: { 'content-type': 'application/json' }, + }); + + const payload = await expectProviderError( + () => liveCheckout(attemptRepository), + 'billing_provider_unavailable', + ); + assert.equal(cancelCalls, 1); + assert.doesNotMatch(payload, /cleanup secret/); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); + }); +}); + test('provider response declarations and streamed bytes are bounded before JSON parsing', async () => { await withStripeEnv(async () => { for (const declaredLength of ['not-a-number', '-1', String(providerResponseLimitBytes + 1)]) { From 149840889c2c6892702baa5346e7dd890eaf0a81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:00:02 -0700 Subject: [PATCH 32/35] fix(billing): inherit provider response cleanup --- server/billing.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/billing.mjs b/server/billing.mjs index c5849c8b..bf6ff2b6 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -173,6 +173,14 @@ async function readBoundedProviderJson(response) { } } +async function cancelUnreadProviderBody(response) { + try { + await response.body.cancel(); + } catch { + // Cleanup failure must never replace the stable provider failure returned below. + } +} + async function createStripeSessionWithFetch(secretKey, payload, idempotencyKey) { let response; try { @@ -200,11 +208,13 @@ async function createStripeSessionWithFetch(secretKey, payload, idempotencyKey) // no later caller silently creates a second Checkout Session with a fresh key. // Stripe's documented safest strategy for 4xx is a fresh idempotency key. const outcomeKnown = response.status < 500; + await cancelUnreadProviderBody(response); throw providerUnavailableFailure(outcomeKnown); } const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase(); if (mediaType !== 'application/json') { + await cancelUnreadProviderBody(response); throw providerInvalidResponseFailure(); } From 6d00b5ad96fd00bf747f65d4712297535cc5f9b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 16:06:16 +0900 Subject: [PATCH 33/35] fix(billing): abort transaction after savepoint rollback failure --- CHANGELOG.md | 3 +++ .../doctoring/stripe-checkout-attempt-idempotency.md | 12 +++++++----- server/billing_checkout_attempt.mjs | 4 +++- tests/unit/billing-checkout-attempt.test.mjs | 9 +++++++-- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f1a14cb..ff183e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Aborted the shared SQLite transaction when a Checkout-attempt savepoint + rollback cannot confirm state, preserving the causal write error and marking + a connection that also cannot roll back as unsafe to reuse. - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. - Persisted a tenant/price-scoped Stripe Checkout attempt identity and opaque idempotency key before live Session creation, reusing unresolved identity only diff --git a/docs/doctoring/stripe-checkout-attempt-idempotency.md b/docs/doctoring/stripe-checkout-attempt-idempotency.md index 4db3d95c..76faf0bb 100644 --- a/docs/doctoring/stripe-checkout-attempt-idempotency.md +++ b/docs/doctoring/stripe-checkout-attempt-idempotency.md @@ -50,11 +50,13 @@ owned names are descriptive multi-word `snake_case` identifiers. A partial unique index on `(organization_id, price_id)` while the state is `pending` or `reconciliation_required` prevents two unresolved retry identities for the same tenant/price. The repository uses a savepoint around each synchronous -state mutation. Clock rollback is fail-safe: a pending attempt whose calculated -age is negative is moved to `reconciliation_required` rather than silently -replayed, and terminal writes clamp `updated_at_ms` to at least `created_at_ms` -so a provider outcome can still be recorded without violating the timestamp -constraint. +state mutation; if savepoint rollback cannot confirm the state, it aborts the +shared transaction and preserves the causal error, and a connection that also +cannot roll back must be discarded. Clock rollback is fail-safe: a pending +attempt whose calculated age is negative is moved to `reconciliation_required` +rather than silently replayed, and terminal writes clamp `updated_at_ms` to at +least `created_at_ms` so a provider outcome can still be recorded without +violating the timestamp constraint. The table is installed only during database bootstrap after the referenced organization table exists. Repository construction and request handling do not diff --git a/server/billing_checkout_attempt.mjs b/server/billing_checkout_attempt.mjs index 8ed8ffd9..139c9f68 100644 --- a/server/billing_checkout_attempt.mjs +++ b/server/billing_checkout_attempt.mjs @@ -76,7 +76,9 @@ function withSavepoint(database, operation) { database.exec(`ROLLBACK TO SAVEPOINT ${SAVEPOINT_NAME}`); rollbackSucceeded = true; } catch { - // Keep an unconfirmed failed savepoint open instead of risking a partial commit. + // Abort the shared transaction when savepoint rollback cannot confirm state. + // If this also fails, the caller must discard the database connection. + try { database.exec('ROLLBACK'); } catch { /* connection is no longer trustworthy */ } } if (rollbackSucceeded) { try { diff --git a/tests/unit/billing-checkout-attempt.test.mjs b/tests/unit/billing-checkout-attempt.test.mjs index c0fbd3e7..453ab571 100644 --- a/tests/unit/billing-checkout-attempt.test.mjs +++ b/tests/unit/billing-checkout-attempt.test.mjs @@ -337,7 +337,7 @@ test('invalid clock and identifier sources roll back without leaving a pending r ); }); -test('rollback cleanup preserves the causal attempt write failure and never releases unconfirmed state', () => { +test('rollback cleanup aborts the shared transaction and preserves the causal write failure', () => { const database = createDatabase(); installBillingCheckoutAttemptSchema(database); database.exec(` @@ -371,6 +371,11 @@ test('rollback cleanup preserves the causal attempt write failure and never rele assert.equal( executed.filter((sql) => sql === 'RELEASE SAVEPOINT billing_checkout_attempt_write').length, 0, - 'failed rollback must not release an unconfirmed savepoint and accidentally commit partial state', + 'failed rollback must not release an unconfirmed savepoint', + ); + assert.equal( + executed.filter((sql) => sql === 'ROLLBACK').length, + 1, + 'failed savepoint rollback must abort the shared transaction', ); }); From f9d5b9e0f41c61789cec9be2b31c0be2f149250e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 16:21:02 +0900 Subject: [PATCH 34/35] fix(billing): preserve retry identity for Stripe conflicts --- CHANGELOG.md | 3 +- docs/billing-production.md | 8 ++-- .../stripe-checkout-attempt-idempotency.md | 8 ++-- server/billing.mjs | 30 +++++++++---- tests/unit/billing-checkout.test.mjs | 42 ++++++++++++++++++- tests/unit/billing-provider-boundary.test.mjs | 20 ++++++++- 6 files changed, 95 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff183e10..5b8ae490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 inside a 23-hour safety window; network/abort, Stripe 5xx, malformed or untrusted successful responses, and local success-persistence failures remain pending for same-key retry or reconciliation, while known Stripe 4xx outcomes - close the attempt before a later deliberate Checkout receives fresh authority. + other than concurrent 409 conflicts close the attempt before a later deliberate + Checkout receives fresh authority. - Bounded hosted Stripe Checkout provider calls to one 15-second, no-retry attempt with a 1 MiB response ceiling before JSON parsing until durable idempotency exists; validated returned destinations as exact HTTPS diff --git a/docs/billing-production.md b/docs/billing-production.md index 46c32b06..951dbf01 100644 --- a/docs/billing-production.md +++ b/docs/billing-production.md @@ -126,8 +126,10 @@ Provider outcomes are intentionally asymmetric: - **Stripe 5xx** — keep the attempt `pending`; Stripe explicitly documents 500 mutations as indeterminate and warns that retrying with a fresh key can repeat side effects; -- **Stripe 4xx** — close as `provider_failed`; Stripe's low-level guidance says - the safest 4xx strategy is a fresh idempotency key when trying again; +- **Stripe 4xx other than concurrent 409** — close as `provider_failed`; a + corrected deliberate retry can use fresh provider authority. A 409 caused by + a concurrent request remains unresolved because Stripe says endpoint execution + did not begin and the same idempotency key may be retried; - **validated 2xx Checkout Session** — validate provider session ID and hosted destination, persist `provider_succeeded` plus the provider session ID, then return the hosted URL; @@ -208,4 +210,4 @@ Stripe configuration and restart before reverting request-path code. Preserve `pending`, `reconciliation_required`, and `provider_succeeded` attempt rows for reconciliation. Do not drop or truncate the ledger during a provider incident; any eventual schema removal must be a reviewed reversible migration with -export/restore evidence. \ No newline at end of file +export/restore evidence. diff --git a/docs/doctoring/stripe-checkout-attempt-idempotency.md b/docs/doctoring/stripe-checkout-attempt-idempotency.md index 76faf0bb..74654891 100644 --- a/docs/doctoring/stripe-checkout-attempt-idempotency.md +++ b/docs/doctoring/stripe-checkout-attempt-idempotency.md @@ -28,7 +28,7 @@ local ceiling, not a claim that Stripe purges every key at exactly 24 hours. | A network failure can leave the client unable to know whether Stripe executed the mutation. | Network/abort failures leave the attempt `pending`; the next caller reuses the same key. | `tests/unit/billing-provider-boundary.test.mjs` | | Stripe documents server errors, especially HTTP 500, as indeterminate and warns that a fresh key can duplicate side effects. | All Stripe 5xx responses keep the attempt `pending`; no fresh key is issued merely because a server-error response arrived. | regression commit `35571be0c0e81359dff09238f5815ed13dcf0440` followed by the production fix | | A successful HTTP response can still be unusable locally after the provider has performed the mutation. | Malformed, unreadable, over-budget, or untrusted 2xx responses remain unresolved and reuse the same idempotency key instead of closing the attempt. | `tests/unit/billing-checkout-review-regressions.test.mjs` and provider-boundary regressions | -| Stripe's safest documented strategy for 4xx errors is a fresh idempotency key after correcting/retrying the request. | A received 4xx closes the current local attempt as `provider_failed`; a later deliberate Checkout obtains fresh authority. | provider-boundary 4xx regression | +| A received 4xx normally identifies a correctable request failure, but Stripe does not begin endpoint execution for a concurrent idempotency conflict. | Known 4xx responses other than 409 close the current local attempt as `provider_failed`; a concurrent 409 remains pending so the same key can be retried. | provider-boundary 4xx/409 regression | | Checkout Sessions expose `client_reference_id` for reconciliation with internal systems. | Send organization identity as `client_reference_id` and metadata while retaining a separate opaque local attempt ID. | transport form assertions | ## Data model @@ -70,8 +70,10 @@ must converge before billing release approval. `billing_provider_unavailable`; local attempt remains pending. 2. **Stripe 5xx** — customer receives the same sanitized 502; local attempt remains pending because provider side effects are indeterminate. -3. **Stripe 4xx** — customer receives sanitized 502; the local attempt becomes - `provider_failed` so a later corrected Checkout can use a fresh key. +3. **Stripe known 4xx other than concurrent 409** — customer receives sanitized + 502; the local attempt becomes `provider_failed` so a later corrected Checkout + can use a fresh key. A concurrent 409 remains pending because Stripe allows + retrying the same idempotency key when endpoint execution did not begin. 4. **Successful HTTP response with malformed/unbounded/untrusted content** — the provider may already have committed the mutation, so the local attempt remains pending; no provider body, network address, or credential is reflected to the diff --git a/server/billing.mjs b/server/billing.mjs index bf6ff2b6..80a4b460 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -104,6 +104,18 @@ function providerInvalidResponseFailure() { ); } +function providerOutcomeKnownForStatus(status) { + const statusCode = Number(status); + return Number.isInteger(statusCode) + && statusCode >= 400 + && statusCode < 500 + && statusCode !== 409; +} + +function providerOutcomeKnownForError(error) { + return providerOutcomeKnownForStatus(error?.statusCode ?? error?.status); +} + function stripeCheckoutForm(payload) { return new URLSearchParams([ ['mode', payload.mode], @@ -206,8 +218,10 @@ async function createStripeSessionWithFetch(secretKey, payload, idempotencyKey) // the original request can have produced side effects even though the client // received an error. Preserve the pending identity for every server error so // no later caller silently creates a second Checkout Session with a fresh key. - // Stripe's documented safest strategy for 4xx is a fresh idempotency key. - const outcomeKnown = response.status < 500; + // A concurrent idempotent request returns 409 before endpoint execution and + // remains retryable with the same key. Other received 4xx responses are known + // request failures and can safely receive fresh authority after correction. + const outcomeKnown = providerOutcomeKnownForStatus(response.status); await cancelUnreadProviderBody(response); throw providerUnavailableFailure(outcomeKnown); } @@ -298,7 +312,8 @@ function markKnownProviderFailure(repository, attemptId, error) { * a 1 MiB response-body ceiling, and a durable per-attempt idempotency key. * Network/abort, Stripe 5xx, and malformed/untrusted 2xx response outcomes keep * the attempt pending so a later call reuses the same key; known 4xx responses - * close the attempt so a deliberate later checkout gets fresh provider authority. + * other than concurrent 409 conflicts close the attempt so a deliberate later + * Checkout gets fresh provider authority. * The hosted destination must use Stripe's standard HTTPS authority; provider- * issued client fragments are preserved verbatim. * @@ -328,7 +343,7 @@ export async function createCheckout({ if (mode === 'live') { const repository = await resolveAttemptRepository(attemptRepository); - const priceId = process.env.STRIPE_PRICE_ID; + const priceId = process.env.STRIPE_PRICE_ID?.trim(); if (typeof priceId !== 'string' || priceId.trim().length === 0) { throw new HTTPException(503, { res: billingUnavailableResponse() }); } @@ -359,10 +374,11 @@ export async function createCheckout({ session = await stripe.checkout.sessions.create(payload, { idempotencyKey: attempt.idempotencyKey, }); - } catch { + } catch (error) { // The injected seam models an SDK/network boundary. Without a concrete - // provider response, its outcome is uncertain and must remain retryable. - throw providerUnavailableFailure(false); + // provider response, its outcome is uncertain; Stripe SDK status codes + // preserve the same known-4xx/409 semantics as the direct transport. + throw providerUnavailableFailure(providerOutcomeKnownForError(error)); } } else { session = await createStripeSessionWithFetch( diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index 3e009290..e9ff5a99 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -137,6 +137,46 @@ test('live checkout binds SDK-style calls to the durable idempotency identity', } }); +test('SDK-reported 409 conflicts preserve the durable retry identity', async () => { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + process.env.STRIPE_SECRET_KEY = 'sk_test_sdk_conflict'; + process.env.STRIPE_PRICE_ID = 'price_sdk_conflict'; + const attemptRepository = createAttemptRepository(); + const stripeClientFactory = async () => ({ + checkout: { + sessions: { + async create() { + const error = new Error('concurrent idempotency conflict'); + error.statusCode = 409; + throw error; + }, + }, + }, + }); + + try { + await assert.rejects( + createCheckout({ + orgId: 73, + configuration: liveConfiguration, + attemptRepository, + stripeClientFactory, + }), + (error) => { + assert.equal(error.status, 502); + return true; + }, + ); + assert.deepEqual(attemptRepository.events.map((event) => event.type), ['start']); + } finally { + if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = previousSecret; + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +}); + test('default live provider transport sends the persisted Stripe Idempotency-Key', async () => { const previousSecret = process.env.STRIPE_SECRET_KEY; const previousPrice = process.env.STRIPE_PRICE_ID; @@ -284,4 +324,4 @@ test('stale uncertain Checkout state tells the customer not to mint a speculativ if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; else process.env.STRIPE_PRICE_ID = previousPrice; } -}); \ No newline at end of file +}); diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 108a0e21..b7f5ff1f 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -84,6 +84,7 @@ function expectUnresolved(attemptRepository, message) { test('live Checkout uses one bounded direct Stripe HTTPS request and preserves the hosted URL', async () => { await withStripeEnv(async () => { + process.env.STRIPE_PRICE_ID = ' price_provider_boundary '; const observed = []; const attemptRepository = createAttemptRepository(); globalThis.fetch = async (url, options) => { @@ -119,6 +120,10 @@ test('live Checkout uses one bounded direct Stripe HTTPS request and preserves t assert.equal(form.get('cancel_url'), 'https://planner.example.com/?billing=cancel'); assert.equal(form.get('client_reference_id'), '73'); assert.equal(form.get('metadata[orgId]'), '73'); + assert.deepEqual(attemptRepository.events[0], { + type: 'start', + input: { organizationId: 73, priceId: 'price_provider_boundary' }, + }); assert.deepEqual(attemptRepository.events.at(-1), { type: 'success', input: { @@ -185,7 +190,7 @@ test('uncertain transport failures stay pending and remain sanitized', async () }); }); -test('Stripe server and malformed-success outcomes remain indeterminate while 4xx closes retry identity', async () => { +test('Stripe server and malformed-success outcomes remain indeterminate while known 4xx closes retry identity', async () => { await withStripeEnv(async () => { let attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response('provider incident body', { @@ -211,6 +216,18 @@ test('Stripe server and malformed-success outcomes remain indeterminate while 4x assert.doesNotMatch(clientErrorPayload, /invalid request body detail/); assert.equal(attemptRepository.events.at(-1).type, 'failure'); + attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response('concurrent request', { + status: 409, + headers: { 'content-type': 'application/json' }, + }); + const conflictPayload = await expectProviderError( + () => liveCheckout(attemptRepository), + 'billing_provider_unavailable', + ); + assert.doesNotMatch(conflictPayload, /concurrent request/); + expectUnresolved(attemptRepository, '409 concurrent idempotency conflict remains retryable with the same key'); + attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response('not json', { status: 200, @@ -251,6 +268,7 @@ test('rejected Stripe responses cancel unread bodies while preserving retry-stat for (const scenario of [ { status: 503, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: false }, { status: 400, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: true }, + { status: 409, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: false }, { status: 200, contentType: 'text/html', expectedCode: 'billing_provider_invalid_response', closesAttempt: false }, ]) { let cancelled = false; From 01be3a5d59979e4c52d17bfaa4428c177dec0237 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 19:24:41 +0900 Subject: [PATCH 35/35] fix: handle bodyless Stripe error responses --- server/billing.mjs | 2 +- tests/unit/billing-provider-boundary.test.mjs | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index 80a4b460..6b5f54e5 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -187,7 +187,7 @@ async function readBoundedProviderJson(response) { async function cancelUnreadProviderBody(response) { try { - await response.body.cancel(); + await response.body?.cancel(); } catch { // Cleanup failure must never replace the stable provider failure returned below. } diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index b7f5ff1f..d76259de 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -267,7 +267,7 @@ test('rejected Stripe responses cancel unread bodies while preserving retry-stat await withStripeEnv(async () => { for (const scenario of [ { status: 503, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: false }, - { status: 400, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: true }, + { status: 400, body: null, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: true }, { status: 409, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: false }, { status: 200, contentType: 'text/html', expectedCode: 'billing_provider_invalid_response', closesAttempt: false }, ]) { @@ -281,16 +281,23 @@ test('rejected Stripe responses cancel unread bodies while preserving retry-stat }, }); const attemptRepository = createAttemptRepository(); - globalThis.fetch = async () => new Response(unreadBody, { - status: scenario.status, - headers: { 'content-type': scenario.contentType }, - }); + globalThis.fetch = async () => new Response( + scenario.body === null ? null : unreadBody, + { + status: scenario.status, + headers: { 'content-type': scenario.contentType }, + }, + ); await expectProviderError( () => liveCheckout(attemptRepository), scenario.expectedCode, ); - assert.equal(cancelled, true, `${scenario.expectedCode} cancels its unread response body`); + assert.equal( + cancelled, + scenario.body === null ? false : true, + `${scenario.expectedCode} cancels its unread response body when one exists`, + ); if (scenario.closesAttempt) { assert.equal(attemptRepository.events.at(-1).type, 'failure'); } else {