From 36edfee87786d0430ed7c2fc65ccc04c8228c0a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:19:44 -0700 Subject: [PATCH 01/15] test(billing): require durable webhook reconciliation queue --- ...ripe-webhook-reconciliation-queue.test.mjs | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 tests/unit/stripe-webhook-reconciliation-queue.test.mjs diff --git a/tests/unit/stripe-webhook-reconciliation-queue.test.mjs b/tests/unit/stripe-webhook-reconciliation-queue.test.mjs new file mode 100644 index 00000000..2b448a4c --- /dev/null +++ b/tests/unit/stripe-webhook-reconciliation-queue.test.mjs @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; + +import { installStripeWebhookEventSchema } from '../../server/stripe_webhook_event_ledger.mjs'; +import { + StripeWebhookReconciliationQueueError, + createSqliteStripeWebhookReconciliationQueue, + extractStripeSubscriptionReconciliationCandidate, + installStripeWebhookReconciliationQueueSchema, +} from '../../server/stripe_webhook_reconciliation_queue.mjs'; + +function subscriptionEvent(overrides = {}) { + return { + id: 'evt_subscription', + type: 'customer.subscription.updated', + created: 1_787_000_000, + data: { + object: { + id: 'sub_scopeweave', + object: 'subscription', + }, + }, + ...overrides, + }; +} + +function invoiceEvent(objectOverrides = {}) { + return { + id: 'evt_invoice', + type: 'invoice.paid', + created: 1_787_000_001, + data: { + object: { + id: 'in_scopeweave', + object: 'invoice', + parent: { + type: 'subscription_details', + subscription_details: { subscription: 'sub_scopeweave' }, + }, + ...objectOverrides, + }, + }, + }; +} + +function databaseWithEvent(eventId = 'evt_subscription') { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON'); + installStripeWebhookEventSchema(database); + installStripeWebhookReconciliationQueueSchema(database); + database.prepare(` + INSERT INTO billing_stripe_webhook_events( + event_id, provider_created_at_sec, event_type, object_id, object_type, + api_version, request_id, payload_sha256, first_received_at_ms + ) VALUES(?,?,?,?,?,?,?,?,?) + `).run( + eventId, + 1_787_000_000, + 'customer.subscription.updated', + 'sub_scopeweave', + 'subscription', + '2025-03-31.basil', + null, + 'a'.repeat(64), + 1_787_000_000_000, + ); + return database; +} + +test('subscription webhook candidates use the provider Subscription identity only as a reconciliation trigger', () => { + assert.equal( + extractStripeSubscriptionReconciliationCandidate(subscriptionEvent()), + 'sub_scopeweave', + ); +}); + +test('invoice webhook candidates support current Basil and legacy Subscription provenance', () => { + assert.equal( + extractStripeSubscriptionReconciliationCandidate(invoiceEvent()), + 'sub_scopeweave', + ); + assert.equal( + extractStripeSubscriptionReconciliationCandidate(invoiceEvent({ + parent: undefined, + subscription: 'sub_legacy', + })), + 'sub_legacy', + ); +}); + +test('invoice webhook candidates fail closed when current and legacy Subscription provenance disagree', () => { + assert.throws( + () => extractStripeSubscriptionReconciliationCandidate(invoiceEvent({ + subscription: 'sub_other', + })), + (error) => error instanceof StripeWebhookReconciliationQueueError + && error.code === 'stripe_reconciliation_trigger_invalid', + ); +}); + +test('non-subscription webhook objects and one-off invoices do not manufacture reconciliation authority', () => { + assert.equal( + extractStripeSubscriptionReconciliationCandidate({ + id: 'evt_customer', + type: 'customer.updated', + data: { object: { id: 'cus_scopeweave', object: 'customer' } }, + }), + null, + ); + assert.equal( + extractStripeSubscriptionReconciliationCandidate(invoiceEvent({ + parent: null, + subscription: null, + })), + null, + ); +}); + +test('reconciliation queue persists one normalized pending trigger per verified event', () => { + const database = databaseWithEvent(); + const queue = createSqliteStripeWebhookReconciliationQueue(database, { + now: () => 1_787_000_000_123, + }); + + assert.deepEqual(queue.enqueue({ + eventId: 'evt_subscription', + subscriptionId: 'sub_scopeweave', + }), { + eventId: 'evt_subscription', + subscriptionId: 'sub_scopeweave', + queued: true, + }); + + assert.deepEqual(database.prepare(` + SELECT event_id, subscription_id, queued_at_ms, processing_state + FROM billing_stripe_reconciliation_triggers + `).get(), { + event_id: 'evt_subscription', + subscription_id: 'sub_scopeweave', + queued_at_ms: 1_787_000_000_123, + processing_state: 'pending', + }); +}); + +test('exact webhook redelivery is idempotent but event identity cannot be rebound to another Subscription', () => { + const database = databaseWithEvent(); + const queue = createSqliteStripeWebhookReconciliationQueue(database, { now: () => 10 }); + + queue.enqueue({ eventId: 'evt_subscription', subscriptionId: 'sub_scopeweave' }); + assert.deepEqual( + queue.enqueue({ eventId: 'evt_subscription', subscriptionId: 'sub_scopeweave' }), + { eventId: 'evt_subscription', subscriptionId: 'sub_scopeweave', queued: false }, + ); + assert.throws( + () => queue.enqueue({ eventId: 'evt_subscription', subscriptionId: 'sub_other' }), + (error) => error instanceof StripeWebhookReconciliationQueueError + && error.code === 'stripe_reconciliation_trigger_conflict', + ); + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM billing_stripe_reconciliation_triggers').get().count, + 1, + ); +}); + +test('queue rejects unverified event identities and malformed trigger identifiers', () => { + const database = databaseWithEvent(); + const queue = createSqliteStripeWebhookReconciliationQueue(database, { now: () => 10 }); + + for (const input of [ + { eventId: 'evt_missing', subscriptionId: 'sub_scopeweave' }, + { eventId: '', subscriptionId: 'sub_scopeweave' }, + { eventId: 'evt_subscription', subscriptionId: 'cus_not_subscription' }, + ]) { + assert.throws( + () => queue.enqueue(input), + (error) => error instanceof StripeWebhookReconciliationQueueError, + ); + } + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM billing_stripe_reconciliation_triggers').get().count, + 0, + ); +}); From 6bfee282c4e2ba340a26d76599f95c4553687568 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:20:25 -0700 Subject: [PATCH 02/15] test(billing): execute reconciliation queue contract --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f9d71de7..e60850d6 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "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/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/billing-effective-plan-status.test.mjs && node tests/api/stripe-webhook.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-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 && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-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 && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/toast-accessibility.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/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/billing_status_response.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.mjs --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_provider.mjs --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --include=server/stripe_billing_reconciliation.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/orchestrator-attribution.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-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 && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && npm run test:api", "test:e2e": "playwright test", From 7a9e4d817af16812eb9fac25d0e3d299e3d2cdeb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:25:05 -0700 Subject: [PATCH 03/15] feat(billing): persist verified webhook reconciliation triggers --- .../stripe_webhook_reconciliation_queue.mjs | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 server/stripe_webhook_reconciliation_queue.mjs diff --git a/server/stripe_webhook_reconciliation_queue.mjs b/server/stripe_webhook_reconciliation_queue.mjs new file mode 100644 index 00000000..60d4fe92 --- /dev/null +++ b/server/stripe_webhook_reconciliation_queue.mjs @@ -0,0 +1,230 @@ +const MAX_PROVIDER_ID_LENGTH = 255; +const SUBSCRIPTION_ID_PATTERN = /^sub_[A-Za-z0-9_]+$/u; +const SAVEPOINT_NAME = 'billing_stripe_reconciliation_trigger_write'; + +/** Stable fail-closed error for verified-webhook reconciliation trigger handling. */ +export class StripeWebhookReconciliationQueueError extends Error { + /** + * Create one sanitized reconciliation-trigger failure. + * @param {string} code stable machine-readable failure code + * @param {number} [status=400] HTTP-compatible status for an adapter + */ + constructor(code, status = 400) { + super(code); + this.name = 'StripeWebhookReconciliationQueueError'; + this.code = code; + this.status = status; + } +} + +function queueError(code, status = 400) { + return new StripeWebhookReconciliationQueueError(code, status); +} + +function requiredBoundedString(value) { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_PROVIDER_ID_LENGTH + || /[\u0000-\u001f\u007f]/u.test(value) + ) { + throw queueError('stripe_reconciliation_trigger_invalid'); + } + return value; +} + +function normalizedSubscriptionId(value) { + const subscriptionId = requiredBoundedString(value); + if (!SUBSCRIPTION_ID_PATTERN.test(subscriptionId)) { + throw queueError('stripe_reconciliation_trigger_invalid'); + } + return subscriptionId; +} + +function optionalSubscriptionId(value) { + if (value == null) return null; + return normalizedSubscriptionId(value); +} + +function normalizedNow(now) { + const value = Number(now()); + if (!Number.isSafeInteger(value) || value < 0) { + throw queueError('stripe_reconciliation_trigger_invalid'); + } + return value; +} + +function requireEventEnvelope(event) { + if (!event || typeof event !== 'object' || Array.isArray(event)) { + throw queueError('stripe_reconciliation_trigger_invalid'); + } + const type = requiredBoundedString(event.type); + const object = event.data?.object; + if (!object || typeof object !== 'object' || Array.isArray(object)) { + throw queueError('stripe_reconciliation_trigger_invalid'); + } + return { type, object }; +} + +function currentInvoiceSubscription(object) { + if (object.parent == null) return null; + if (typeof object.parent !== 'object' || Array.isArray(object.parent)) { + throw queueError('stripe_reconciliation_trigger_invalid'); + } + if (object.parent.type !== 'subscription_details') return null; + const details = object.parent.subscription_details; + if (!details || typeof details !== 'object' || Array.isArray(details)) { + throw queueError('stripe_reconciliation_trigger_invalid'); + } + return optionalSubscriptionId(details.subscription); +} + +/** + * Extract the Subscription identity that a verified Stripe event should reconcile. + * + * The returned identifier is only a trigger key. It is never treated as current + * lifecycle or entitlement authority; the reconciliation service must re-fetch + * current provider state before evaluating durable claims. Irrelevant events and + * one-off invoices return `null`. Contradictory current/legacy Invoice provenance + * fails closed instead of selecting one representation. + * + * @param {Record} event cryptographically verified Stripe event + * @returns {string|null} bounded Subscription identity to reconcile + * @throws {StripeWebhookReconciliationQueueError} for malformed relevant events + */ +export function extractStripeSubscriptionReconciliationCandidate(event) { + const { type, object } = requireEventEnvelope(event); + + if (type.startsWith('customer.subscription.')) { + if (object.object !== 'subscription') { + throw queueError('stripe_reconciliation_trigger_invalid'); + } + return normalizedSubscriptionId(object.id); + } + + if (!type.startsWith('invoice.')) return null; + if (object.object !== 'invoice') { + throw queueError('stripe_reconciliation_trigger_invalid'); + } + + const current = currentInvoiceSubscription(object); + const legacy = optionalSubscriptionId(object.subscription); + if (current && legacy && current !== legacy) { + throw queueError('stripe_reconciliation_trigger_invalid'); + } + return current || legacy || null; +} + +/** + * Install the normalized verified-webhook reconciliation trigger relation. + * + * One verified Stripe Event may create at most one durable reconciliation trigger. + * Re-delivery therefore remains idempotent while a conflicting attempt to bind the + * same event identity to a different Subscription fails closed. Processing is a + * later worker concern; this slice records only pending work. + * + * @param {import('node:sqlite').DatabaseSync} database open SQLite database + * @returns {void} + */ +export function installStripeWebhookReconciliationQueueSchema(database) { + if (!database || typeof database.exec !== 'function') { + throw new TypeError('database must provide SQLite exec operations'); + } + database.exec(` + CREATE TABLE IF NOT EXISTS billing_stripe_reconciliation_triggers ( + event_id TEXT PRIMARY KEY + REFERENCES billing_stripe_webhook_events(event_id) ON DELETE CASCADE, + subscription_id TEXT NOT NULL + CHECK(length(subscription_id) BETWEEN 5 AND ${MAX_PROVIDER_ID_LENGTH}), + queued_at_ms INTEGER NOT NULL CHECK(queued_at_ms >= 0), + processing_state TEXT NOT NULL DEFAULT 'pending' + CHECK(processing_state = 'pending') + ); + CREATE INDEX IF NOT EXISTS billing_stripe_reconciliation_pending_triggers + ON billing_stripe_reconciliation_triggers(processing_state, queued_at_ms, event_id); + `); +} + +/** + * Create the durable queue port for already-verified Stripe webhook events. + * + * The constructor never creates schema. `enqueue` first proves the event identity + * already exists in the verified-event ledger, then appends one pending trigger. + * Exact replay is idempotent; event-to-Subscription rebinding is rejected. + * + * @param {import('node:sqlite').DatabaseSync} database bootstrapped SQLite database + * @param {object} [dependencies] deterministic test seams + * @param {() => number} [dependencies.now] wall-clock milliseconds + * @returns {{enqueue(input: {eventId: string, subscriptionId: string}): Readonly<{eventId: string, subscriptionId: string, queued: boolean}>}} + */ +export function createSqliteStripeWebhookReconciliationQueue(database, { 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 now !== 'function') throw new TypeError('now must be a function'); + + const selectVerifiedEvent = database.prepare(` + SELECT event_id FROM billing_stripe_webhook_events WHERE event_id = ? + `); + const selectTrigger = database.prepare(` + SELECT subscription_id FROM billing_stripe_reconciliation_triggers WHERE event_id = ? + `); + const insertTrigger = database.prepare(` + INSERT INTO billing_stripe_reconciliation_triggers( + event_id, subscription_id, queued_at_ms, processing_state + ) VALUES(?,?,?,'pending') + `); + + return Object.freeze({ + /** Persist one idempotent pending trigger for a verified provider event. */ + enqueue({ eventId, subscriptionId } = {}) { + const normalizedEventId = requiredBoundedString(eventId); + const normalizedSubscription = normalizedSubscriptionId(subscriptionId); + const queuedAtMs = normalizedNow(now); + + database.exec(`SAVEPOINT ${SAVEPOINT_NAME}`); + try { + const existing = selectTrigger.get(normalizedEventId); + if (existing) { + if (existing.subscription_id !== normalizedSubscription) { + throw queueError('stripe_reconciliation_trigger_conflict', 409); + } + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + return Object.freeze({ + eventId: normalizedEventId, + subscriptionId: normalizedSubscription, + queued: false, + }); + } + + if (!selectVerifiedEvent.get(normalizedEventId)) { + throw queueError('stripe_reconciliation_trigger_unverified', 409); + } + + insertTrigger.run(normalizedEventId, normalizedSubscription, queuedAtMs); + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + return Object.freeze({ + eventId: normalizedEventId, + subscriptionId: normalizedSubscription, + queued: true, + }); + } catch (error) { + let rollbackSucceeded = false; + try { + database.exec(`ROLLBACK TO SAVEPOINT ${SAVEPOINT_NAME}`); + rollbackSucceeded = true; + } catch { + // Keep an unconfirmed failed savepoint open instead of risking commit. + } + if (rollbackSucceeded) { + try { + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + } catch { + // Cleanup after confirmed rollback must not replace causal failure. + } + } + throw error; + } + }, + }); +} From de744e0878695bd44f7447d8242c2d895e20feee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:26:13 -0700 Subject: [PATCH 04/15] test(billing): require webhook-to-queue bootstrap wiring --- ...-reconciliation-queue-integration.test.mjs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs diff --git a/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs b/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs new file mode 100644 index 00000000..733f9a50 --- /dev/null +++ b/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import { test } from 'node:test'; + +process.env.SCOPEWEAVE_DB = ':memory:'; + +const { db } = await import('../../server/db.mjs'); +const { verifyStripeWebhookRequest } = await import('../../server/stripe_webhook.mjs'); + +const WEBHOOK_SECRET = 'whsec_scopeweave_queue_test'; +const NOW_SECONDS = 1_787_000_100; + +function signedRequest(event) { + const body = JSON.stringify(event); + const signature = createHmac('sha256', WEBHOOK_SECRET) + .update(String(NOW_SECONDS)) + .update('.') + .update(body) + .digest('hex'); + return new Request('https://scopeweave.invalid/api/stripe/webhook', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'stripe-signature': `t=${NOW_SECONDS},v1=${signature}`, + }, + body, + }); +} + +function subscriptionEvent() { + return { + id: 'evt_queue_subscription', + object: 'event', + api_version: '2025-03-31.basil', + created: NOW_SECONDS - 1, + type: 'customer.subscription.updated', + request: null, + data: { + object: { + id: 'sub_queue_subscription', + object: 'subscription', + }, + }, + }; +} + +function oneOffInvoiceEvent() { + return { + id: 'evt_queue_one_off_invoice', + object: 'event', + api_version: '2025-03-31.basil', + created: NOW_SECONDS - 1, + type: 'invoice.paid', + request: null, + data: { + object: { + id: 'in_queue_one_off', + object: 'invoice', + parent: null, + }, + }, + }; +} + +test('production webhook bootstrap durably queues a verified Subscription trigger', async () => { + const event = subscriptionEvent(); + const verified = await verifyStripeWebhookRequest(signedRequest(event), { + secret: WEBHOOK_SECRET, + nowSeconds: NOW_SECONDS, + }); + assert.equal(verified.id, event.id); + + assert.deepEqual(db.prepare(` + SELECT event_id, subscription_id, processing_state + FROM billing_stripe_reconciliation_triggers + WHERE event_id = ? + `).get(event.id), { + event_id: event.id, + subscription_id: 'sub_queue_subscription', + processing_state: 'pending', + }); +}); + +test('exact verified webhook redelivery records delivery evidence without duplicating queued work', async () => { + const event = subscriptionEvent(); + await verifyStripeWebhookRequest(signedRequest(event), { + secret: WEBHOOK_SECRET, + nowSeconds: NOW_SECONDS, + }); + + assert.equal(db.prepare(` + SELECT COUNT(*) AS count FROM billing_stripe_reconciliation_triggers WHERE event_id = ? + `).get(event.id).count, 1); + assert.equal(db.prepare(` + SELECT COUNT(*) AS count FROM billing_stripe_webhook_deliveries WHERE event_id = ? + `).get(event.id).count, 2); +}); + +test('verified one-off Invoice evidence is retained without manufacturing Subscription work', async () => { + const event = oneOffInvoiceEvent(); + await verifyStripeWebhookRequest(signedRequest(event), { + secret: WEBHOOK_SECRET, + nowSeconds: NOW_SECONDS, + }); + + assert.ok(db.prepare(` + SELECT event_id FROM billing_stripe_webhook_events WHERE event_id = ? + `).get(event.id)); + assert.equal(db.prepare(` + SELECT COUNT(*) AS count FROM billing_stripe_reconciliation_triggers WHERE event_id = ? + `).get(event.id).count, 0); +}); From eb92dbc28c82e598a9a45532080136c2b5a3fd67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:27:07 -0700 Subject: [PATCH 05/15] test(billing): execute webhook queue bootstrap regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e60850d6..0cbeece9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "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/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/billing-effective-plan-status.test.mjs && node tests/api/stripe-webhook.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-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 && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-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 && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/toast-accessibility.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/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/billing_status_response.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.mjs --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_provider.mjs --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --include=server/stripe_billing_reconciliation.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/orchestrator-attribution.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-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 && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && npm run test:api", "test:e2e": "playwright test", From 1ba72be00eca08225767d5623eaac69d8adcd224 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:28:14 -0700 Subject: [PATCH 06/15] feat(billing): wire verified webhooks into durable queue --- server/db.mjs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/server/db.mjs b/server/db.mjs index 0e06b45b..36df8f9c 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -13,6 +13,11 @@ import { createSqliteStripeWebhookEventRepository, installStripeWebhookEventSchema, } from './stripe_webhook_event_ledger.mjs'; +import { + createSqliteStripeWebhookReconciliationQueue, + extractStripeSubscriptionReconciliationCandidate, + installStripeWebhookReconciliationQueueSchema, +} from './stripe_webhook_reconciliation_queue.mjs'; import { createSqliteStripeSubscriptionObservationRepository, installStripeSubscriptionObservationSchema, @@ -204,7 +209,19 @@ installBillingCheckoutAttemptSchema(db); export const billingCheckoutAttempts = createSqliteBillingCheckoutAttemptRepository(db); installStripeWebhookEventSchema(db); export const stripeWebhookEvents = createSqliteStripeWebhookEventRepository(db); -configureStripeWebhookEventRecorder((evidence) => stripeWebhookEvents.recordVerifiedEvent(evidence)); +installStripeWebhookReconciliationQueueSchema(db); +export const stripeWebhookReconciliationQueue = createSqliteStripeWebhookReconciliationQueue(db); +configureStripeWebhookEventRecorder((evidence) => { + const eventReceipt = stripeWebhookEvents.recordVerifiedEvent(evidence); + const subscriptionId = extractStripeSubscriptionReconciliationCandidate(evidence.event); + if (subscriptionId) { + stripeWebhookReconciliationQueue.enqueue({ + eventId: eventReceipt.eventId, + subscriptionId, + }); + } + return eventReceipt; +}); installStripeSubscriptionObservationSchema(db); export const stripeSubscriptionObservations = createSqliteStripeSubscriptionObservationRepository(db); installStripeInvoiceObservationSchema(db); From 560ee43646fe3eb792b417a1c783bbd754946f31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:29:06 -0700 Subject: [PATCH 07/15] test(billing): isolate webhook queue replay evidence --- ...k-reconciliation-queue-integration.test.mjs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs b/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs index 733f9a50..ddcd9f02 100644 --- a/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs +++ b/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs @@ -27,9 +27,12 @@ function signedRequest(event) { }); } -function subscriptionEvent() { +function subscriptionEvent({ + eventId = 'evt_queue_subscription', + subscriptionId = 'sub_queue_subscription', +} = {}) { return { - id: 'evt_queue_subscription', + id: eventId, object: 'event', api_version: '2025-03-31.basil', created: NOW_SECONDS - 1, @@ -37,7 +40,7 @@ function subscriptionEvent() { request: null, data: { object: { - id: 'sub_queue_subscription', + id: subscriptionId, object: 'subscription', }, }, @@ -82,7 +85,14 @@ test('production webhook bootstrap durably queues a verified Subscription trigge }); test('exact verified webhook redelivery records delivery evidence without duplicating queued work', async () => { - const event = subscriptionEvent(); + const event = subscriptionEvent({ + eventId: 'evt_queue_redelivery', + subscriptionId: 'sub_queue_redelivery', + }); + await verifyStripeWebhookRequest(signedRequest(event), { + secret: WEBHOOK_SECRET, + nowSeconds: NOW_SECONDS, + }); await verifyStripeWebhookRequest(signedRequest(event), { secret: WEBHOOK_SECRET, nowSeconds: NOW_SECONDS, From 6952f58aa996bf56007a31290f91f4fccab957c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:31:10 -0700 Subject: [PATCH 08/15] test(billing): instrument webhook reconciliation queue --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0cbeece9..dede6464 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "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/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/billing-effective-plan-status.test.mjs && node tests/api/stripe-webhook.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-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 && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/toast-accessibility.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/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/billing_status_response.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.mjs --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_provider.mjs --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --include=server/stripe_billing_reconciliation.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/orchestrator-attribution.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-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 && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && npm run test:api", + "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/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/billing_status_response.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.mjs --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_provider.mjs --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --include=server/stripe_billing_reconciliation.mjs --include=server/stripe_webhook_reconciliation_queue.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/orchestrator-attribution.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-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 && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.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 tests/e2e/toast-accessibility.spec.js", From 72bd46e27bfbac1b9dadd58376c711718027994f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:32:32 -0700 Subject: [PATCH 09/15] test(billing): lock webhook queue coverage contract --- tests/unit/coverage-script-contract.test.mjs | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index b8cf87fe..199596af 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -59,6 +59,11 @@ assert.match( /--include=server\/stripe_webhook_event_ledger\.mjs/, 'the verified Stripe webhook event ledger is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/stripe_webhook_reconciliation_queue\.mjs/, + 'the verified Stripe reconciliation trigger queue is instrumented', +); assert.match( scripts['test:coverage'], /--include=server\/stripe_subscription_provider\.mjs/, @@ -144,6 +149,26 @@ assert.match( /tests\/unit\/stripe-webhook-recorder-integration\.test\.mjs/, 'normal unit CI executes the verified-event recorder integration regression', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-webhook-reconciliation-queue\.test\.mjs/, + 'the durable Stripe reconciliation queue regression executes under c8', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-webhook-reconciliation-queue-integration\.test\.mjs/, + 'the verified webhook-to-queue integration executes under c8', +); +assert.match( + scripts['test:unit'], + /tests\/unit\/stripe-webhook-reconciliation-queue\.test\.mjs/, + 'normal unit CI executes the durable Stripe reconciliation queue regression', +); +assert.match( + scripts['test:unit'], + /tests\/unit\/stripe-webhook-reconciliation-queue-integration\.test\.mjs/, + 'normal unit CI executes the verified webhook-to-queue integration', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/stripe-subscription-provider\.test\.mjs/, From 171a75cbe12590d492b03448ad41b1e83f390cdf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:34:00 -0700 Subject: [PATCH 10/15] test(billing): make replay independent of wall clock --- ...ripe-webhook-reconciliation-queue.test.mjs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit/stripe-webhook-reconciliation-queue.test.mjs b/tests/unit/stripe-webhook-reconciliation-queue.test.mjs index 2b448a4c..d9fd7696 100644 --- a/tests/unit/stripe-webhook-reconciliation-queue.test.mjs +++ b/tests/unit/stripe-webhook-reconciliation-queue.test.mjs @@ -163,6 +163,27 @@ test('exact webhook redelivery is idempotent but event identity cannot be reboun ); }); +test('exact redelivery does not depend on a fresh wall-clock read after durable queueing', () => { + const database = databaseWithEvent(); + let clockReads = 0; + const queue = createSqliteStripeWebhookReconciliationQueue(database, { + now: () => { + clockReads += 1; + return clockReads === 1 ? 10 : Number.NaN; + }, + }); + + assert.equal(queue.enqueue({ + eventId: 'evt_subscription', + subscriptionId: 'sub_scopeweave', + }).queued, true); + assert.equal(queue.enqueue({ + eventId: 'evt_subscription', + subscriptionId: 'sub_scopeweave', + }).queued, false); + assert.equal(clockReads, 1); +}); + test('queue rejects unverified event identities and malformed trigger identifiers', () => { const database = databaseWithEvent(); const queue = createSqliteStripeWebhookReconciliationQueue(database, { now: () => 10 }); From 081f2a90211719af0f8c993f93d61f1c9ff267ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:41:18 -0700 Subject: [PATCH 11/15] fix(billing): avoid wall-clock reads on queue replay --- server/stripe_webhook_reconciliation_queue.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/stripe_webhook_reconciliation_queue.mjs b/server/stripe_webhook_reconciliation_queue.mjs index 60d4fe92..08f0b91d 100644 --- a/server/stripe_webhook_reconciliation_queue.mjs +++ b/server/stripe_webhook_reconciliation_queue.mjs @@ -180,7 +180,6 @@ export function createSqliteStripeWebhookReconciliationQueue(database, { now = D enqueue({ eventId, subscriptionId } = {}) { const normalizedEventId = requiredBoundedString(eventId); const normalizedSubscription = normalizedSubscriptionId(subscriptionId); - const queuedAtMs = normalizedNow(now); database.exec(`SAVEPOINT ${SAVEPOINT_NAME}`); try { @@ -201,6 +200,7 @@ export function createSqliteStripeWebhookReconciliationQueue(database, { now = D throw queueError('stripe_reconciliation_trigger_unverified', 409); } + const queuedAtMs = normalizedNow(now); insertTrigger.run(normalizedEventId, normalizedSubscription, queuedAtMs); database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); return Object.freeze({ From 0bc93b447464ec9e3c4260fc715bf64f597cf2b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:42:33 -0700 Subject: [PATCH 12/15] test(billing): require atomic webhook trigger recording --- ...-reconciliation-queue-integration.test.mjs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs b/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs index ddcd9f02..1e5e49c5 100644 --- a/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs +++ b/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs @@ -106,6 +106,42 @@ test('exact verified webhook redelivery records delivery evidence without duplic `).get(event.id).count, 2); }); +test('verified event evidence and its reconciliation trigger commit atomically', async () => { + const event = subscriptionEvent({ + eventId: 'evt_queue_atomic_failure', + subscriptionId: 'sub_queue_atomic_failure', + }); + db.exec(` + CREATE TEMP TRIGGER billing_stripe_reconciliation_force_failure + BEFORE INSERT ON billing_stripe_reconciliation_triggers + WHEN NEW.event_id = 'evt_queue_atomic_failure' + BEGIN + SELECT RAISE(ABORT, 'forced reconciliation queue failure'); + END; + `); + + try { + await assert.rejects( + verifyStripeWebhookRequest(signedRequest(event), { + secret: WEBHOOK_SECRET, + nowSeconds: NOW_SECONDS, + }), + ); + } finally { + db.exec('DROP TRIGGER billing_stripe_reconciliation_force_failure'); + } + + assert.equal(db.prepare(` + SELECT COUNT(*) AS count FROM billing_stripe_webhook_events WHERE event_id = ? + `).get(event.id).count, 0); + assert.equal(db.prepare(` + SELECT COUNT(*) AS count FROM billing_stripe_webhook_deliveries WHERE event_id = ? + `).get(event.id).count, 0); + assert.equal(db.prepare(` + SELECT COUNT(*) AS count FROM billing_stripe_reconciliation_triggers WHERE event_id = ? + `).get(event.id).count, 0); +}); + test('verified one-off Invoice evidence is retained without manufacturing Subscription work', async () => { const event = oneOffInvoiceEvent(); await verifyStripeWebhookRequest(signedRequest(event), { From 8604c661eba3b5a5b97bea32eaa54bac4dcd2972 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:43:18 -0700 Subject: [PATCH 13/15] fix(billing): commit webhook evidence and queue atomically --- server/db.mjs | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/server/db.mjs b/server/db.mjs index 36df8f9c..45886c27 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -34,6 +34,7 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); +const STRIPE_WEBHOOK_RECONCILIATION_SAVEPOINT = 'billing_stripe_webhook_reconciliation_record'; export const db = new DatabaseSync(dbPath); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); @@ -212,15 +213,35 @@ export const stripeWebhookEvents = createSqliteStripeWebhookEventRepository(db); installStripeWebhookReconciliationQueueSchema(db); export const stripeWebhookReconciliationQueue = createSqliteStripeWebhookReconciliationQueue(db); configureStripeWebhookEventRecorder((evidence) => { - const eventReceipt = stripeWebhookEvents.recordVerifiedEvent(evidence); - const subscriptionId = extractStripeSubscriptionReconciliationCandidate(evidence.event); - if (subscriptionId) { - stripeWebhookReconciliationQueue.enqueue({ - eventId: eventReceipt.eventId, - subscriptionId, - }); + db.exec(`SAVEPOINT ${STRIPE_WEBHOOK_RECONCILIATION_SAVEPOINT}`); + try { + const eventReceipt = stripeWebhookEvents.recordVerifiedEvent(evidence); + const subscriptionId = extractStripeSubscriptionReconciliationCandidate(evidence.event); + if (subscriptionId) { + stripeWebhookReconciliationQueue.enqueue({ + eventId: eventReceipt.eventId, + subscriptionId, + }); + } + db.exec(`RELEASE SAVEPOINT ${STRIPE_WEBHOOK_RECONCILIATION_SAVEPOINT}`); + return eventReceipt; + } catch (error) { + let rollbackSucceeded = false; + try { + db.exec(`ROLLBACK TO SAVEPOINT ${STRIPE_WEBHOOK_RECONCILIATION_SAVEPOINT}`); + rollbackSucceeded = true; + } catch { + // Keep an unconfirmed failed savepoint open instead of risking a partial commit. + } + if (rollbackSucceeded) { + try { + db.exec(`RELEASE SAVEPOINT ${STRIPE_WEBHOOK_RECONCILIATION_SAVEPOINT}`); + } catch { + // Cleanup after confirmed rollback must not replace the causal operation error. + } + } + throw error; } - return eventReceipt; }); installStripeSubscriptionObservationSchema(db); export const stripeSubscriptionObservations = createSqliteStripeSubscriptionObservationRepository(db); From b52221459ed044ecd0636a22fddf8cc830328ade Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:08:37 -0700 Subject: [PATCH 14/15] test(billing): normalize SQLite row prototype in queue assertion --- tests/unit/stripe-webhook-reconciliation-queue.test.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/stripe-webhook-reconciliation-queue.test.mjs b/tests/unit/stripe-webhook-reconciliation-queue.test.mjs index d9fd7696..8529b1b5 100644 --- a/tests/unit/stripe-webhook-reconciliation-queue.test.mjs +++ b/tests/unit/stripe-webhook-reconciliation-queue.test.mjs @@ -132,10 +132,11 @@ test('reconciliation queue persists one normalized pending trigger per verified queued: true, }); - assert.deepEqual(database.prepare(` + const persistedTrigger = database.prepare(` SELECT event_id, subscription_id, queued_at_ms, processing_state FROM billing_stripe_reconciliation_triggers - `).get(), { + `).get(); + assert.deepEqual({ ...persistedTrigger }, { event_id: 'evt_subscription', subscription_id: 'sub_scopeweave', queued_at_ms: 1_787_000_000_123, From 56d2e8cc86419596754cde931048a3ac3a54c219 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:58:30 -0700 Subject: [PATCH 15/15] test(billing): normalize SQLite row prototype in queue regression --- .../stripe-webhook-reconciliation-queue-integration.test.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs b/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs index 1e5e49c5..00207c41 100644 --- a/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs +++ b/tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs @@ -73,11 +73,12 @@ test('production webhook bootstrap durably queues a verified Subscription trigge }); assert.equal(verified.id, event.id); - assert.deepEqual(db.prepare(` + const trigger = db.prepare(` SELECT event_id, subscription_id, processing_state FROM billing_stripe_reconciliation_triggers WHERE event_id = ? - `).get(event.id), { + `).get(event.id); + assert.deepEqual({ ...trigger }, { event_id: event.id, subscription_id: 'sub_queue_subscription', processing_state: 'pending',