From fc98edc4fab146bddd918923236a3254d45df291 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:45:52 -0700 Subject: [PATCH 1/2] test(billing): require authoritative Invoice observation persistence --- ...stripe-invoice-observation-ledger.test.mjs | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 tests/unit/stripe-invoice-observation-ledger.test.mjs diff --git a/tests/unit/stripe-invoice-observation-ledger.test.mjs b/tests/unit/stripe-invoice-observation-ledger.test.mjs new file mode 100644 index 00000000..635ffdb3 --- /dev/null +++ b/tests/unit/stripe-invoice-observation-ledger.test.mjs @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; + +import { + StripeInvoiceObservationError, + createSqliteStripeInvoiceObservationRepository, + installStripeInvoiceObservationSchema, +} from '../../server/stripe_invoice_observation_ledger.mjs'; + +function databaseWithAuthority() { + const db = new DatabaseSync(':memory:'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec(` + CREATE TABLE orgs(id INTEGER PRIMARY KEY); + CREATE TABLE billing_stripe_webhook_events(event_id TEXT PRIMARY KEY); + CREATE TABLE billing_stripe_customers( + customer_id TEXT PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES orgs(id), + first_observed_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_subscriptions( + subscription_id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL REFERENCES billing_stripe_customers(customer_id), + first_observed_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_subscription_observations( + observation_id INTEGER PRIMARY KEY, + subscription_id TEXT NOT NULL REFERENCES billing_stripe_subscriptions(subscription_id), + latest_invoice_id TEXT + ); + INSERT INTO orgs(id) VALUES(42),(77); + INSERT INTO billing_stripe_customers VALUES('cus_42',42,10),('cus_77',77,10); + INSERT INTO billing_stripe_subscriptions VALUES('sub_42','cus_42',10),('sub_77','cus_77',10); + INSERT INTO billing_stripe_subscription_observations VALUES(501,'sub_42','in_42'),(777,'sub_77','in_42'); + INSERT INTO billing_stripe_webhook_events VALUES('evt_invoice_paid'); + `); + installStripeInvoiceObservationSchema(db); + return db; +} + +function paidSnapshot(overrides = {}) { + return { + organizationId: 42, + invoiceId: 'in_42', + subscriptionId: 'sub_42', + customerId: 'cus_42', + status: 'paid', + paid: true, + currency: 'krw', + amountDue: 29000, + amountPaid: 29000, + amountRemaining: 0, + createdSec: 1_786_000_000, + paidAtSec: 1_786_000_100, + ...overrides, + }; +} + +function expectCode(code, status = 400) { + return (error) => { + assert.ok(error instanceof StripeInvoiceObservationError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }; +} + +test('schema is normalized append-only evidence and contains no entitlement or raw payload columns', () => { + const db = databaseWithAuthority(); + const tables = db.prepare("SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE 'billing_stripe_invoice%'").all(); + assert.deepEqual(tables.map((row) => row.name).sort(), [ + 'billing_stripe_invoice_observations', + 'billing_stripe_invoices', + ]); + const sql = tables.map((row) => row.sql).join('\n'); + assert.match(sql, /source_subscription_observation_id/); + assert.match(sql, /invoice_status/); + assert.doesNotMatch(sql, /raw|payload|entitlement|orgs\.plan|customer_id\s+TEXT/iu); + db.close(); +}); + +test('authoritative Invoice snapshot appends with exact Subscription provenance and optional event evidence', () => { + const db = databaseWithAuthority(); + const repo = createSqliteStripeInvoiceObservationRepository(db, { now: () => 1_000 }); + const result = repo.recordAuthoritativeObservation({ + snapshot: paidSnapshot(), + sourceSubscriptionObservationId: 501, + sourceEventId: 'evt_invoice_paid', + }); + assert.deepEqual(result, { + observationId: 1, + invoiceId: 'in_42', + observedAtMs: 1_000, + sourceSubscriptionObservationId: 501, + }); + assert.ok(Object.isFrozen(result)); + assert.deepEqual({...db.prepare('SELECT invoice_id, subscription_id FROM billing_stripe_invoices').get()}, { + invoice_id: 'in_42', + subscription_id: 'sub_42', + }); + assert.deepEqual({...db.prepare(` + SELECT source_subscription_observation_id, source_event_id, invoice_status, + paid, currency_code, amount_due_minor, amount_paid_minor, + amount_remaining_minor, provider_created_at_sec, paid_at_sec + FROM billing_stripe_invoice_observations + `).get()}, { + source_subscription_observation_id: 501, + source_event_id: 'evt_invoice_paid', + invoice_status: 'paid', + paid: 1, + currency_code: 'krw', + amount_due_minor: 29000, + amount_paid_minor: 29000, + amount_remaining_minor: 0, + provider_created_at_sec: 1_786_000_000, + paid_at_sec: 1_786_000_100, + }); + db.close(); +}); + +test('repeated authoritative reads append and local observation time never moves backwards', () => { + const db = databaseWithAuthority(); + const clocks = [2_000, 1_500, 1_600]; + const repo = createSqliteStripeInvoiceObservationRepository(db, { now: () => clocks.shift() }); + const first = repo.recordAuthoritativeObservation({ snapshot: paidSnapshot(), sourceSubscriptionObservationId: 501 }); + const second = repo.recordAuthoritativeObservation({ snapshot: paidSnapshot(), sourceSubscriptionObservationId: 501 }); + const third = repo.recordAuthoritativeObservation({ + snapshot: paidSnapshot({ status: 'open', paid: false, paidAtSec: null, amountPaid: 0, amountRemaining: 29000 }), + sourceSubscriptionObservationId: 501, + }); + assert.equal(first.observedAtMs, 2_000); + assert.equal(second.observedAtMs, 2_000); + assert.equal(third.observedAtMs, 2_000); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_invoices').get().count, 1); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_invoice_observations').get().count, 3); + db.close(); +}); + +test('accepted Subscription observation is mandatory routing authority and all tenant identities must match', () => { + const db = databaseWithAuthority(); + const repo = createSqliteStripeInvoiceObservationRepository(db, { now: () => 1_000 }); + const cases = [ + [{ snapshot: paidSnapshot(), sourceSubscriptionObservationId: 999 }, 'stripe_invoice_subscription_observation_unknown'], + [{ snapshot: paidSnapshot({ organizationId: 77 }), sourceSubscriptionObservationId: 501 }, 'stripe_invoice_identity_conflict'], + [{ snapshot: paidSnapshot({ customerId: 'cus_77' }), sourceSubscriptionObservationId: 501 }, 'stripe_invoice_identity_conflict'], + [{ snapshot: paidSnapshot({ subscriptionId: 'sub_77' }), sourceSubscriptionObservationId: 501 }, 'stripe_invoice_identity_conflict'], + [{ snapshot: paidSnapshot({ invoiceId: 'in_other' }), sourceSubscriptionObservationId: 501 }, 'stripe_invoice_identity_conflict'], + ]; + for (const [input, code] of cases) { + assert.throws(() => repo.recordAuthoritativeObservation(input), expectCode(code, 409)); + } + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_invoices').get().count, 0); + db.close(); +}); + +test('unknown optional webhook provenance and cross-Subscription Invoice rebinding fail closed', () => { + const db = databaseWithAuthority(); + const repo = createSqliteStripeInvoiceObservationRepository(db, { now: () => 1_000 }); + assert.throws(() => repo.recordAuthoritativeObservation({ + snapshot: paidSnapshot(), sourceSubscriptionObservationId: 501, sourceEventId: 'evt_unknown', + }), expectCode('stripe_invoice_source_event_unknown', 409)); + repo.recordAuthoritativeObservation({ snapshot: paidSnapshot(), sourceSubscriptionObservationId: 501 }); + assert.throws(() => repo.recordAuthoritativeObservation({ + snapshot: paidSnapshot({ organizationId: 77, subscriptionId: 'sub_77', customerId: 'cus_77' }), + sourceSubscriptionObservationId: 777, + }), expectCode('stripe_invoice_identity_conflict', 409)); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_invoices').get().count, 1); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_invoice_observations').get().count, 1); + db.close(); +}); + +test('malformed Invoice facts fail before persistence', () => { + const db = databaseWithAuthority(); + const repo = createSqliteStripeInvoiceObservationRepository(db, { now: () => 1_000 }); + const badSnapshots = [ + null, [], paidSnapshot({ organizationId: '42' }), paidSnapshot({ invoiceId: '' }), + paidSnapshot({ subscriptionId: 'sub bad' }), paidSnapshot({ customerId: 'cus bad' }), + paidSnapshot({ status: 'mystery' }), paidSnapshot({ paid: false }), paidSnapshot({ currency: 'KRW' }), + paidSnapshot({ amountDue: -1 }), paidSnapshot({ amountPaid: 1.5 }), paidSnapshot({ amountRemaining: -1 }), + paidSnapshot({ createdSec: -1 }), paidSnapshot({ paidAtSec: null }), + paidSnapshot({ status: 'open', paid: false, paidAtSec: 1 }), + ]; + for (const snapshot of badSnapshots) { + assert.throws(() => repo.recordAuthoritativeObservation({ snapshot, sourceSubscriptionObservationId: 501 }), expectCode('stripe_invoice_observation_invalid')); + } + assert.throws(() => repo.recordAuthoritativeObservation({ snapshot: paidSnapshot(), sourceSubscriptionObservationId: 0 }), expectCode('stripe_invoice_observation_invalid')); + assert.throws(() => repo.recordAuthoritativeObservation({ snapshot: paidSnapshot(), sourceSubscriptionObservationId: 501, sourceEventId: [] }), expectCode('stripe_invoice_observation_invalid')); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM billing_stripe_invoice_observations').get().count, 0); + db.close(); +}); + +test('repository dependency and clock contracts fail closed', () => { + assert.throws(() => createSqliteStripeInvoiceObservationRepository(null), TypeError); + const db = databaseWithAuthority(); + const defaultRepo = createSqliteStripeInvoiceObservationRepository(db); + assert.equal(typeof defaultRepo.recordAuthoritativeObservation, 'function'); + assert.throws(() => createSqliteStripeInvoiceObservationRepository(db, { now: null }), TypeError); + const repo = createSqliteStripeInvoiceObservationRepository(db, { now: () => -1 }); + assert.throws(() => repo.recordAuthoritativeObservation({ snapshot: paidSnapshot(), sourceSubscriptionObservationId: 501 }), expectCode('stripe_invoice_observation_invalid')); + db.close(); +}); + +test('savepoint cleanup preserves the causal write error and never releases unconfirmed failed state', () => { + const inner = databaseWithAuthority(); + const commands = []; + let failRollback = false; + let failCleanupRelease = false; + const wrapped = { + prepare(sql) { + const statement = inner.prepare(sql); + if (sql.includes('INSERT INTO billing_stripe_invoice_observations(')) { + return { run() { throw new Error('causal invoice observation write failure'); } }; + } + return statement; + }, + exec(sql) { + commands.push(sql); + if (sql.startsWith('ROLLBACK TO') && failRollback) throw new Error('rollback failed'); + if (sql.startsWith('RELEASE') && failCleanupRelease) throw new Error('cleanup release failed'); + return inner.exec(sql); + }, + }; + + let repo = createSqliteStripeInvoiceObservationRepository(wrapped, { now: () => 1_000 }); + failCleanupRelease = true; + assert.throws(() => repo.recordAuthoritativeObservation({ snapshot: paidSnapshot(), sourceSubscriptionObservationId: 501 }), /causal invoice observation write failure/); + assert.ok(commands.some((sql) => sql.startsWith('ROLLBACK TO'))); + assert.ok(commands.some((sql) => sql.startsWith('RELEASE'))); + + inner.exec('ROLLBACK'); + commands.length = 0; + failCleanupRelease = false; + failRollback = true; + repo = createSqliteStripeInvoiceObservationRepository(wrapped, { now: () => 1_000 }); + assert.throws(() => repo.recordAuthoritativeObservation({ snapshot: paidSnapshot(), sourceSubscriptionObservationId: 501 }), /causal invoice observation write failure/); + assert.ok(commands.some((sql) => sql.startsWith('ROLLBACK TO'))); + assert.equal(commands.filter((sql) => sql.startsWith('RELEASE')).length, 0); + inner.close(); +}); From 2d6b55a214e2b62a034adbe06282d135812ef610 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:50:59 -0700 Subject: [PATCH 2/2] feat(billing): persist authoritative Stripe invoice observations --- CHANGELOG.md | 5 + .../stripe-invoice-observation-ledger.md | 47 +++ package.json | 6 +- server/db.mjs | 6 + server/stripe_invoice_observation_ledger.mjs | 268 ++++++++++++++++++ tests/unit/coverage-script-contract.test.mjs | 15 + 6 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 docs/doctoring/stripe-invoice-observation-ledger.md create mode 100644 server/stripe_invoice_observation_ledger.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a2217eb5..a325de68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Persist authoritative Stripe Invoice reads as normalized append-only evidence + linked to the exact accepted Subscription observation that named the Invoice; + tenant/customer/subscription/invoice rebinding fails closed, local observation + order stays monotonic under clock rollback, and no Invoice row carries raw + provider payloads or entitlement state. - Added a bounded authoritative Stripe Invoice reader that verifies exact tenant- bound Invoice, Customer, and Subscription identities, supports current Basil and legacy Subscription provenance without trusting metadata alone, bounds diff --git a/docs/doctoring/stripe-invoice-observation-ledger.md b/docs/doctoring/stripe-invoice-observation-ledger.md new file mode 100644 index 00000000..1e6832d6 --- /dev/null +++ b/docs/doctoring/stripe-invoice-observation-ledger.md @@ -0,0 +1,47 @@ +# Authoritative Stripe Invoice observation ledger + +## Status and authority + +**Status: active stacked PR evidence, not protected-`develop` shipped truth.** + +This #488 slice is stacked on the authoritative Invoice reader. It persists accepted Invoice evidence only after an exact authoritative Subscription observation has already identified that same Invoice. Protected `develop` remains shipped authority until the stack is independently reviewed and integrated. + +## Control objective + +Invoice provider reads are transient. Entitlement reconciliation needs durable payment evidence that survives process restart without turning webhook order into authority or overwriting earlier facts. The ledger therefore separates immutable provider identities from append-only observations and keeps entitlement state out of the Invoice relations. + +`billing_stripe_invoices` stores one Invoice identity permanently bound to one Stripe Subscription. `billing_stripe_invoice_observations` appends each accepted authoritative read with the exact `source_subscription_observation_id` that named the Invoice, optional verified webhook-event provenance, local observation time, lifecycle status, currency, minor-unit amounts, provider creation time, and paid transition time. + +Customer and organization identities are not repeated in the Invoice table because they are functionally determined through the existing normalized Subscription → Customer → organization relations. Before a write, the repository joins that accepted Subscription observation back to those relations and requires exact organization, Customer, Subscription, and `latest_invoice_id` equality with the normalized Invoice snapshot. + +## Transaction and failure boundary + +Invoice identity creation and the corresponding observation append share one SQLite savepoint. On failure, ScopeWeave first attempts `ROLLBACK TO SAVEPOINT`; it releases the savepoint only after rollback is confirmed. Cleanup-release failure after a confirmed rollback cannot replace the causal operation error, while an unconfirmed rollback leaves the failed savepoint open instead of risking an accidental commit. + +This follows SQLite's documented savepoint semantics: `ROLLBACK TO` restores state after the named savepoint but leaves that savepoint active, while releasing the outermost savepoint can commit the transaction. The fail-closed cleanup order is therefore part of the data-integrity contract, not incidental error handling. + +## Validation and tenant isolation + +The ledger independently revalidates the bounded Invoice snapshot even though the provider reader already validated it. It rejects malformed identifiers, ambiguous organization authority, unknown lifecycle states, contradictory `paid`/status/`paidAtSec` combinations, malformed currency, unsafe amounts/timestamps, unknown source observations/events, tenant/Customer/Subscription/Invoice mismatches, and attempts to rebind an existing Invoice to another Subscription. + +Repeated provider reads append new observations. If the local wall clock moves backwards, `observed_at_ms` never decreases for the same Invoice. That local ordering is audit evidence only; it does not rewrite Stripe's provider timestamps and does not itself authorize access. + +No raw Invoice JSON, customer contact fields, payment credentials, Stripe secrets, arbitrary metadata, entitlement claim, or `orgs.plan` value is stored by this slice. + +## TDD and executable evidence + +Test-only commit on the child branch registered `tests/unit/stripe-invoice-observation-ledger.test.mjs` while `server/stripe_invoice_observation_ledger.mjs` did not exist, establishing a realistic RED module-resolution failure before production implementation. + +The completed focused suite covers normalized schema shape, exact Subscription-observation provenance, optional verified-event provenance, repeated appends, monotonic local observation time, tenant and identity conflicts, unknown provenance, malformed provider facts, dependency/clock contracts, cross-Subscription rebinding, and rollback/release cleanup failure. Private focused execution produced 100% line, branch, and function coverage for the production ledger module. + +## Rollback and recovery + +Rollback removes the Invoice observation module, bootstrap registration, focused test/coverage registration, this doctoring record, and the matching Unreleased changelog entry together. Because this remains an active stacked slice, no protected production migration is claimed. Once the schema is protected-shipped, rollback must preserve existing evidence tables until a separately reviewed migration/export/retention decision is available. + +## References + +SQLite. (n.d.). *Savepoints*. https://www.sqlite.org/lang_savepoint.html + +Stripe. (n.d.). *The Invoice object*. Stripe API Reference. https://docs.stripe.com/api/invoices/object + +Stripe. (n.d.). *Retrieve an invoice*. Stripe API Reference. https://docs.stripe.com/api/invoices/retrieve diff --git a/package.json b/package.json index d9b0e465..6492d1f3 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/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.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-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/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/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 --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-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 && npm run test:api", + "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-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/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/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 --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-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 && 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", diff --git a/server/db.mjs b/server/db.mjs index ff0bcb5e..f4ff916d 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -17,6 +17,10 @@ import { createSqliteStripeSubscriptionObservationRepository, installStripeSubscriptionObservationSchema, } from './stripe_subscription_observation_ledger.mjs'; +import { + createSqliteStripeInvoiceObservationRepository, + installStripeInvoiceObservationSchema, +} from './stripe_invoice_observation_ledger.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); @@ -198,6 +202,8 @@ export const stripeWebhookEvents = createSqliteStripeWebhookEventRepository(db); configureStripeWebhookEventRecorder((evidence) => stripeWebhookEvents.recordVerifiedEvent(evidence)); installStripeSubscriptionObservationSchema(db); export const stripeSubscriptionObservations = createSqliteStripeSubscriptionObservationRepository(db); +installStripeInvoiceObservationSchema(db); +export const stripeInvoiceObservations = createSqliteStripeInvoiceObservationRepository(db); // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. export const rowid = (r) => Number(r.lastInsertRowid); diff --git a/server/stripe_invoice_observation_ledger.mjs b/server/stripe_invoice_observation_ledger.mjs new file mode 100644 index 00000000..eaf9b3b7 --- /dev/null +++ b/server/stripe_invoice_observation_ledger.mjs @@ -0,0 +1,268 @@ +const MAX_PROVIDER_ID_LENGTH = 255; +const SAVEPOINT_NAME = 'billing_stripe_invoice_observation_write'; +const PROVIDER_IDENTIFIER_PATTERN = /^[A-Za-z0-9_:-]+$/u; +const CURRENCY_PATTERN = /^[a-z]{3}$/u; +const INVOICE_STATUSES = new Set(['draft', 'open', 'paid', 'uncollectible', 'void']); + +/** Stable fail-closed persistence error for authoritative Stripe Invoice observations. */ +export class StripeInvoiceObservationError extends Error { + /** + * @param {string} code stable machine-readable failure code + * @param {number} status HTTP status suitable for a future service adapter + */ + constructor(code, status = 400) { + super(code); + this.name = 'StripeInvoiceObservationError'; + this.code = code; + this.status = status; + } +} + +function observationError(code = 'stripe_invoice_observation_invalid', status = 400) { + return new StripeInvoiceObservationError(code, status); +} + +function requiredIdentifier(value) { + if (typeof value !== 'string' + || value.length === 0 + || value.length > MAX_PROVIDER_ID_LENGTH + || !PROVIDER_IDENTIFIER_PATTERN.test(value)) { + throw observationError(); + } + return value; +} + +function positiveInteger(value) { + if (!Number.isSafeInteger(value) || value <= 0) throw observationError(); + return value; +} + +function nonNegativeInteger(value) { + if (!Number.isSafeInteger(value) || value < 0) throw observationError(); + return value; +} + +function sourceEventIdentifier(value) { + return value == null ? null : requiredIdentifier(value); +} + +function safeNow(now) { + const value = Number(now()); + if (!Number.isSafeInteger(value) || value < 0) throw observationError(); + return value; +} + +function normalizeSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) throw observationError(); + const organizationId = positiveInteger(snapshot.organizationId); + const invoiceId = requiredIdentifier(snapshot.invoiceId); + const subscriptionId = requiredIdentifier(snapshot.subscriptionId); + const customerId = requiredIdentifier(snapshot.customerId); + if (!INVOICE_STATUSES.has(snapshot.status)) throw observationError(); + if (typeof snapshot.paid !== 'boolean' || snapshot.paid !== (snapshot.status === 'paid')) throw observationError(); + if (typeof snapshot.currency !== 'string' || !CURRENCY_PATTERN.test(snapshot.currency)) throw observationError(); + const paidAtSec = snapshot.paidAtSec == null ? null : nonNegativeInteger(snapshot.paidAtSec); + if ((snapshot.status === 'paid') !== (paidAtSec !== null)) throw observationError(); + return { + organizationId, + invoiceId, + subscriptionId, + customerId, + status: snapshot.status, + paid: snapshot.paid, + currency: snapshot.currency, + amountDue: nonNegativeInteger(snapshot.amountDue), + amountPaid: nonNegativeInteger(snapshot.amountPaid), + amountRemaining: nonNegativeInteger(snapshot.amountRemaining), + createdSec: nonNegativeInteger(snapshot.createdSec), + paidAtSec, + }; +} + +function withSavepoint(database, operation) { + database.exec(`SAVEPOINT ${SAVEPOINT_NAME}`); + try { + const result = operation(); + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + return result; + } 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 partial commit. + } + if (rollbackSucceeded) { + try { + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + } catch { + // Cleanup failure after a confirmed rollback never replaces the causal error. + } + } + throw error; + } +} + +/** + * Install normalized authoritative Stripe Invoice identity and observation relations. + * + * The Invoice identity is stored once and permanently bound to a Subscription. + * Every accepted provider read is appended separately and references the exact + * authoritative Subscription observation that named the Invoice. Payment facts + * never share a row with tenant identity or entitlement policy, preserving 3NF + * and keeping append-only evidence separate from authorization decisions. + * + * Installation belongs to bootstrap/migrations and must run after Subscription + * observation and verified webhook-event schemas exist. + * + * @param {import('node:sqlite').DatabaseSync} database bootstrapped database + * @returns {void} + */ +export function installStripeInvoiceObservationSchema(database) { + database.exec(` + CREATE TABLE IF NOT EXISTS billing_stripe_invoices ( + invoice_id TEXT PRIMARY KEY CHECK(length(invoice_id) BETWEEN 1 AND ${MAX_PROVIDER_ID_LENGTH}), + subscription_id TEXT NOT NULL REFERENCES billing_stripe_subscriptions(subscription_id) ON DELETE CASCADE, + first_observed_at_ms INTEGER NOT NULL CHECK(first_observed_at_ms >= 0) + ); + CREATE INDEX IF NOT EXISTS billing_stripe_subscription_invoices + ON billing_stripe_invoices(subscription_id, invoice_id); + + CREATE TABLE IF NOT EXISTS billing_stripe_invoice_observations ( + observation_id INTEGER PRIMARY KEY, + invoice_id TEXT NOT NULL REFERENCES billing_stripe_invoices(invoice_id) ON DELETE CASCADE, + source_subscription_observation_id INTEGER NOT NULL + REFERENCES billing_stripe_subscription_observations(observation_id) ON DELETE RESTRICT, + source_event_id TEXT REFERENCES billing_stripe_webhook_events(event_id) ON DELETE RESTRICT, + observed_at_ms INTEGER NOT NULL CHECK(observed_at_ms >= 0), + invoice_status TEXT NOT NULL CHECK(invoice_status IN ('draft','open','paid','uncollectible','void')), + paid INTEGER NOT NULL CHECK(paid IN (0,1)), + currency_code TEXT NOT NULL CHECK(length(currency_code) = 3), + amount_due_minor INTEGER NOT NULL CHECK(amount_due_minor >= 0), + amount_paid_minor INTEGER NOT NULL CHECK(amount_paid_minor >= 0), + amount_remaining_minor INTEGER NOT NULL CHECK(amount_remaining_minor >= 0), + provider_created_at_sec INTEGER NOT NULL CHECK(provider_created_at_sec >= 0), + paid_at_sec INTEGER CHECK(paid_at_sec IS NULL OR paid_at_sec >= 0), + CHECK((invoice_status = 'paid' AND paid = 1 AND paid_at_sec IS NOT NULL) + OR (invoice_status <> 'paid' AND paid = 0 AND paid_at_sec IS NULL)) + ); + CREATE INDEX IF NOT EXISTS billing_stripe_invoice_observation_history + ON billing_stripe_invoice_observations(invoice_id, observed_at_ms, observation_id); + CREATE INDEX IF NOT EXISTS billing_stripe_invoice_subscription_sources + ON billing_stripe_invoice_observations(source_subscription_observation_id, observation_id); + CREATE INDEX IF NOT EXISTS billing_stripe_invoice_event_sources + ON billing_stripe_invoice_observations(source_event_id, observation_id); + `); +} + +/** + * Create the SQLite append-only repository for authoritative Stripe Invoice reads. + * + * The exact accepted Subscription observation is required as durable routing + * authority. Its tenant, Customer, Subscription, and `latest_invoice_id` must + * match the normalized Invoice snapshot before any Invoice identity or observation + * can be stored. Rebinding one Invoice to another Subscription fails closed. + * + * @param {import('node:sqlite').DatabaseSync} database bootstrapped database + * @param {object} [dependencies] deterministic dependency seams + * @param {() => number} [dependencies.now] wall-clock milliseconds + * @returns {{recordAuthoritativeObservation(input: {snapshot: Record, sourceSubscriptionObservationId: number, sourceEventId?: string|null}): Readonly<{observationId: number, invoiceId: string, observedAtMs: number, sourceSubscriptionObservationId: number}>}} + */ +export function createSqliteStripeInvoiceObservationRepository(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 selectSubscriptionAuthority = database.prepare(` + SELECT o.observation_id, o.latest_invoice_id, s.subscription_id, + c.customer_id, c.organization_id + FROM billing_stripe_subscription_observations o + JOIN billing_stripe_subscriptions s ON s.subscription_id = o.subscription_id + JOIN billing_stripe_customers c ON c.customer_id = s.customer_id + WHERE o.observation_id = ? + `); + const selectSourceEvent = database.prepare( + 'SELECT event_id FROM billing_stripe_webhook_events WHERE event_id = ?', + ); + const selectInvoice = database.prepare( + 'SELECT subscription_id FROM billing_stripe_invoices WHERE invoice_id = ?', + ); + const insertInvoice = database.prepare(` + INSERT INTO billing_stripe_invoices(invoice_id, subscription_id, first_observed_at_ms) + VALUES(?,?,?) + `); + const selectLastObserved = database.prepare(` + SELECT MAX(observed_at_ms) AS observed_at_ms + FROM billing_stripe_invoice_observations + WHERE invoice_id = ? + `); + const insertObservation = database.prepare(` + INSERT INTO billing_stripe_invoice_observations( + invoice_id, source_subscription_observation_id, source_event_id, + observed_at_ms, invoice_status, paid, currency_code, + amount_due_minor, amount_paid_minor, amount_remaining_minor, + provider_created_at_sec, paid_at_sec + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + `); + + return { + /** Append one tenant-bound authoritative Invoice snapshot without granting entitlement. */ + recordAuthoritativeObservation({ snapshot, sourceSubscriptionObservationId, sourceEventId = null }) { + const normalized = normalizeSnapshot(snapshot); + const sourceObservation = positiveInteger(sourceSubscriptionObservationId); + const sourceEvent = sourceEventIdentifier(sourceEventId); + const clockMs = safeNow(now); + + return withSavepoint(database, () => { + const authority = selectSubscriptionAuthority.get(sourceObservation); + if (!authority) throw observationError('stripe_invoice_subscription_observation_unknown', 409); + if (Number(authority.organization_id) !== normalized.organizationId + || authority.customer_id !== normalized.customerId + || authority.subscription_id !== normalized.subscriptionId + || authority.latest_invoice_id !== normalized.invoiceId) { + throw observationError('stripe_invoice_identity_conflict', 409); + } + if (sourceEvent && !selectSourceEvent.get(sourceEvent)) { + throw observationError('stripe_invoice_source_event_unknown', 409); + } + + const existingInvoice = selectInvoice.get(normalized.invoiceId); + if (existingInvoice) { + if (existingInvoice.subscription_id !== normalized.subscriptionId) { + throw observationError('stripe_invoice_identity_conflict', 409); + } + } else { + insertInvoice.run(normalized.invoiceId, normalized.subscriptionId, clockMs); + } + + const priorObserved = selectLastObserved.get(normalized.invoiceId)?.observed_at_ms; + const observedAtMs = Number.isSafeInteger(priorObserved) + ? Math.max(clockMs, priorObserved) + : clockMs; + const observation = insertObservation.run( + normalized.invoiceId, + sourceObservation, + sourceEvent, + observedAtMs, + normalized.status, + normalized.paid ? 1 : 0, + normalized.currency, + normalized.amountDue, + normalized.amountPaid, + normalized.amountRemaining, + normalized.createdSec, + normalized.paidAtSec, + ); + const observationId = Number(observation.lastInsertRowid); + return Object.freeze({ + observationId, + invoiceId: normalized.invoiceId, + observedAtMs, + sourceSubscriptionObservationId: sourceObservation, + }); + }); + }, + }; +} diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 2faa8b9a..b8cf87fe 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -84,6 +84,11 @@ assert.match( /--include=server\/stripe_invoice_provider\.mjs/, 'the authoritative Stripe invoice reader is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/stripe_invoice_observation_ledger\.mjs/, + 'the authoritative Stripe invoice observation ledger is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, @@ -219,6 +224,16 @@ assert.match( /tests\/unit\/stripe-invoice-provider-edge\.test\.mjs/, 'normal unit CI executes the authoritative Stripe invoice edge regression', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-invoice-observation-ledger\.test\.mjs/, + 'the authoritative Stripe invoice observation regression executes under c8', +); +assert.match( + scripts['test:unit'], + /tests\/unit\/stripe-invoice-observation-ledger\.test\.mjs/, + 'normal unit CI executes the authoritative Stripe invoice observation regression', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/,