From c37606068c1d5e63cdd8c6747de981daa404b8ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:25:45 +0900 Subject: [PATCH 01/10] test(billing): define current subscription projection contract --- ...e-subscription-current-projection.test.mjs | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 tests/unit/stripe-subscription-current-projection.test.mjs diff --git a/tests/unit/stripe-subscription-current-projection.test.mjs b/tests/unit/stripe-subscription-current-projection.test.mjs new file mode 100644 index 00000000..b5171f5d --- /dev/null +++ b/tests/unit/stripe-subscription-current-projection.test.mjs @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import { + createSqliteStripeSubscriptionObservationRepository, + installStripeSubscriptionObservationSchema, +} from '../../server/stripe_subscription_observation_ledger.mjs'; +import { + createSqliteStripeSubscriptionCurrentProjection, +} from '../../server/stripe_subscription_current_projection.mjs'; + +function createDatabase() { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON'); + database.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE orgs ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + owner_id INTEGER NOT NULL REFERENCES users(id), + plan TEXT NOT NULL DEFAULT 'free' + ); + CREATE TABLE billing_stripe_webhook_events ( + event_id TEXT PRIMARY KEY + ); + INSERT INTO users(id, email, password_hash, name) + VALUES(1, 'owner@example.test', 'hash', 'Owner'); + INSERT INTO orgs(id, name, owner_id, plan) + VALUES(42, 'Acquisition-grade buyer', 1, 'free'), + (84, 'Other tenant', 1, 'free'); + `); + installStripeSubscriptionObservationSchema(database); + return database; +} + +function snapshot(overrides = {}) { + return Object.freeze({ + subscriptionId: 'sub_scopeweave_42', + customerId: 'cus_scopeweave_42', + organizationId: 42, + status: 'active', + cancelAtPeriodEnd: false, + currentPeriodStartSec: 1_787_000_000, + currentPeriodEndSec: 1_789_678_400, + canceledAtSec: null, + endedAtSec: null, + trialEndSec: null, + latestInvoiceId: 'in_scopeweave_42', + priceIds: Object.freeze(['price_scopeweave_pro', 'price_scopeweave_storage']), + ...overrides, + }); +} + +function setup(times = [1_787_000_100_000]) { + const database = createDatabase(); + let clockIndex = 0; + const observationRepository = createSqliteStripeSubscriptionObservationRepository(database, { + now: () => times[Math.min(clockIndex++, times.length - 1)], + }); + const projection = createSqliteStripeSubscriptionCurrentProjection(database); + return { database, observationRepository, projection }; +} + +test('current projection returns the newest accepted authoritative read with ordered provenance', () => { + const { database, observationRepository, projection } = setup([ + 1_787_000_100_000, + 1_787_000_100_000, + ]); + observationRepository.recordAuthoritativeObservation({ snapshot: snapshot() }); + const second = observationRepository.recordAuthoritativeObservation({ + snapshot: snapshot({ + status: 'past_due', + cancelAtPeriodEnd: true, + currentPeriodStartSec: 1_789_678_400, + currentPeriodEndSec: 1_792_357_200, + latestInvoiceId: 'in_scopeweave_42_retry', + priceIds: Object.freeze(['price_scopeweave_storage', 'price_scopeweave_pro']), + }), + }); + + const beforeObservationCount = database.prepare( + 'SELECT COUNT(*) AS count FROM billing_stripe_subscription_observations', + ).get().count; + const current = projection.getCurrentSubscription({ + organizationId: 42, + subscriptionId: 'sub_scopeweave_42', + }); + + assert.deepEqual(current, { + observationId: second.observationId, + observedAtMs: 1_787_000_100_000, + organizationId: 42, + customerId: 'cus_scopeweave_42', + subscriptionId: 'sub_scopeweave_42', + status: 'past_due', + cancelAtPeriodEnd: true, + currentPeriodStartSec: 1_789_678_400, + currentPeriodEndSec: 1_792_357_200, + canceledAtSec: null, + endedAtSec: null, + trialEndSec: null, + latestInvoiceId: 'in_scopeweave_42_retry', + sourceEventId: null, + priceIds: ['price_scopeweave_storage', 'price_scopeweave_pro'], + }); + assert.equal(Object.isFrozen(current), true); + assert.equal(Object.isFrozen(current.priceIds), true); + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM billing_stripe_subscription_observations').get().count, + beforeObservationCount, + 'read projection must not mutate authoritative evidence', + ); + assert.equal(database.prepare('SELECT plan FROM orgs WHERE id = 42').get().plan, 'free'); +}); + +test('projection is tenant-scoped and never reveals another organization subscription', () => { + const { observationRepository, projection } = setup(); + observationRepository.recordAuthoritativeObservation({ snapshot: snapshot() }); + + assert.equal(projection.getCurrentSubscription({ + organizationId: 84, + subscriptionId: 'sub_scopeweave_42', + }), null); + assert.deepEqual(projection.listCurrentSubscriptions({ organizationId: 84 }), []); +}); + +test('organization projection returns one newest row per subscription in stable identifier order', () => { + const { observationRepository, projection } = setup([ + 1_787_000_100_000, + 1_787_000_100_010, + 1_787_000_100_020, + ]); + observationRepository.recordAuthoritativeObservation({ + snapshot: snapshot({ + subscriptionId: 'sub_scopeweave_zeta', + customerId: 'cus_scopeweave_zeta', + priceIds: Object.freeze(['price_zeta']), + }), + }); + observationRepository.recordAuthoritativeObservation({ + snapshot: snapshot({ + subscriptionId: 'sub_scopeweave_alpha', + customerId: 'cus_scopeweave_alpha', + status: 'trialing', + latestInvoiceId: null, + priceIds: Object.freeze(['price_alpha']), + }), + }); + observationRepository.recordAuthoritativeObservation({ + snapshot: snapshot({ + subscriptionId: 'sub_scopeweave_zeta', + customerId: 'cus_scopeweave_zeta', + status: 'canceled', + cancelAtPeriodEnd: false, + canceledAtSec: 1_787_000_050, + endedAtSec: 1_787_000_060, + priceIds: Object.freeze(['price_zeta_replacement']), + }), + }); + + const subscriptions = projection.listCurrentSubscriptions({ organizationId: 42 }); + assert.equal(subscriptions.length, 2); + assert.deepEqual(subscriptions.map((entry) => [entry.subscriptionId, entry.status, entry.priceIds]), [ + ['sub_scopeweave_alpha', 'trialing', ['price_alpha']], + ['sub_scopeweave_zeta', 'canceled', ['price_zeta_replacement']], + ]); + assert.equal(Object.isFrozen(subscriptions), true); + assert.equal(subscriptions.every(Object.isFrozen), true); +}); + +test('projection returns null or an empty list when no accepted observation exists', () => { + const { projection } = setup(); + assert.equal(projection.getCurrentSubscription({ + organizationId: 42, + subscriptionId: 'sub_scopeweave_absent', + }), null); + assert.deepEqual(projection.listCurrentSubscriptions({ organizationId: 42 }), []); +}); + +test('projection rejects malformed local authority before querying', () => { + const { projection } = setup(); + for (const organizationId of [0, -1, 1.5, Number.NaN, {}, '']) { + assert.throws( + () => projection.listCurrentSubscriptions({ organizationId }), + TypeError, + ); + } + for (const subscriptionId of ['', ' ', {}, [], 'x'.repeat(256)]) { + assert.throws( + () => projection.getCurrentSubscription({ organizationId: 42, subscriptionId }), + TypeError, + ); + } +}); From 6c69aebe6df7bd741a894766f91381f2a0b6e078 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:26:56 +0900 Subject: [PATCH 02/10] test(billing): execute current projection contract --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index bc8df747..0da16b38 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/stripe-webhook.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.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", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.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", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --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 --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.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 && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.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 && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", From 19b5a9525046271da3e413e3ce353bac11082628 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:31:12 +0900 Subject: [PATCH 03/10] feat(billing): project current authoritative subscription state --- ...stripe_subscription_current_projection.mjs | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 server/stripe_subscription_current_projection.mjs diff --git a/server/stripe_subscription_current_projection.mjs b/server/stripe_subscription_current_projection.mjs new file mode 100644 index 00000000..64e447d7 --- /dev/null +++ b/server/stripe_subscription_current_projection.mjs @@ -0,0 +1,149 @@ +const MAX_PROVIDER_ID_LENGTH = 255; +const PROVIDER_IDENTIFIER_PATTERN = /^[A-Za-z0-9_:-]+$/u; + +function positiveOrganizationId(value) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new TypeError('organizationId must be a positive safe integer'); + } + return parsed; +} + +function requiredSubscriptionId(value) { + if (typeof value !== 'string' + || value.length === 0 + || value.length > MAX_PROVIDER_ID_LENGTH + || !PROVIDER_IDENTIFIER_PATTERN.test(value)) { + throw new TypeError('subscriptionId must be a bounded Stripe identifier'); + } + return value; +} + +function freezeProjection(row, priceIds) { + return Object.freeze({ + observationId: Number(row.observation_id), + observedAtMs: Number(row.observed_at_ms), + organizationId: Number(row.organization_id), + customerId: row.customer_id, + subscriptionId: row.subscription_id, + status: row.subscription_status, + cancelAtPeriodEnd: Number(row.cancel_at_period_end) === 1, + currentPeriodStartSec: Number(row.current_period_start_sec), + currentPeriodEndSec: Number(row.current_period_end_sec), + canceledAtSec: row.canceled_at_sec == null ? null : Number(row.canceled_at_sec), + endedAtSec: row.ended_at_sec == null ? null : Number(row.ended_at_sec), + trialEndSec: row.trial_end_sec == null ? null : Number(row.trial_end_sec), + latestInvoiceId: row.latest_invoice_id ?? null, + sourceEventId: row.source_event_id ?? null, + priceIds: Object.freeze(priceIds), + }); +} + +/** + * Create a read-only projection over accepted authoritative Stripe observations. + * + * The projection deliberately orders by the append-only observation identifier, + * not webhook delivery time. A webhook can arrive out of order, while every row + * represented here has already passed the tenant/customer/subscription binding + * checks in the authoritative observation repository. Reads are always scoped by + * the local organization identifier and never mutate `orgs.plan` or any other + * entitlement state. + * + * @param {import('node:sqlite').DatabaseSync} database bootstrapped database + * @returns {{ + * getCurrentSubscription(input: {organizationId: number|string, subscriptionId: string}): Readonly>|null, + * listCurrentSubscriptions(input: {organizationId: number|string}): readonly Readonly>[] + * }} tenant-scoped immutable current-state projection + */ +export function createSqliteStripeSubscriptionCurrentProjection(database) { + if (!database || typeof database.prepare !== 'function') { + throw new TypeError('database must provide SQLite prepare operations'); + } + + const selectCurrentSubscription = database.prepare(` + SELECT + observations.observation_id, + observations.observed_at_ms, + customers.organization_id, + subscriptions.customer_id, + observations.subscription_id, + observations.subscription_status, + observations.cancel_at_period_end, + observations.current_period_start_sec, + observations.current_period_end_sec, + observations.canceled_at_sec, + observations.ended_at_sec, + observations.trial_end_sec, + observations.latest_invoice_id, + observations.source_event_id + FROM billing_stripe_subscription_observations AS observations + JOIN billing_stripe_subscriptions AS subscriptions + ON subscriptions.subscription_id = observations.subscription_id + JOIN billing_stripe_customers AS customers + ON customers.customer_id = subscriptions.customer_id + WHERE customers.organization_id = ? + AND observations.subscription_id = ? + ORDER BY observations.observation_id DESC + LIMIT 1 + `); + + const selectCurrentSubscriptions = database.prepare(` + SELECT + observations.observation_id, + observations.observed_at_ms, + customers.organization_id, + subscriptions.customer_id, + observations.subscription_id, + observations.subscription_status, + observations.cancel_at_period_end, + observations.current_period_start_sec, + observations.current_period_end_sec, + observations.canceled_at_sec, + observations.ended_at_sec, + observations.trial_end_sec, + observations.latest_invoice_id, + observations.source_event_id + FROM billing_stripe_subscription_observations AS observations + JOIN ( + SELECT subscription_id, MAX(observation_id) AS observation_id + FROM billing_stripe_subscription_observations + GROUP BY subscription_id + ) AS current_observations + ON current_observations.observation_id = observations.observation_id + JOIN billing_stripe_subscriptions AS subscriptions + ON subscriptions.subscription_id = observations.subscription_id + JOIN billing_stripe_customers AS customers + ON customers.customer_id = subscriptions.customer_id + WHERE customers.organization_id = ? + ORDER BY observations.subscription_id ASC + `); + + const selectObservationPrices = database.prepare(` + SELECT price_id + FROM billing_stripe_subscription_observation_prices + WHERE observation_id = ? + ORDER BY position_index ASC + `); + + function projectRow(row) { + const priceIds = selectObservationPrices.all(row.observation_id).map(({ price_id: priceId }) => priceId); + return freezeProjection(row, priceIds); + } + + return Object.freeze({ + /** Return the latest accepted observation for one tenant-owned subscription. */ + getCurrentSubscription({ organizationId, subscriptionId }) { + const normalizedOrganizationId = positiveOrganizationId(organizationId); + const normalizedSubscriptionId = requiredSubscriptionId(subscriptionId); + const row = selectCurrentSubscription.get(normalizedOrganizationId, normalizedSubscriptionId); + return row ? projectRow(row) : null; + }, + + /** Return one latest accepted observation per subscription owned by a tenant. */ + listCurrentSubscriptions({ organizationId }) { + const normalizedOrganizationId = positiveOrganizationId(organizationId); + const rows = selectCurrentSubscriptions.all(normalizedOrganizationId); + return Object.freeze(rows.map(projectRow)); + }, + }); +} From dd67a5201e36bede2662e122c526d799ab724fcb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:31:40 +0900 Subject: [PATCH 04/10] test(billing): measure current projection coverage --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0da16b38..7b07161e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/stripe-webhook.test.mjs", "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.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", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --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 --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --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 --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-reconciliation.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 && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", From 4a91581a78b81429b2e8a4ea8183cd286948b306 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:43:13 +0900 Subject: [PATCH 05/10] test(billing): reject boolean tenant authority --- tests/unit/stripe-subscription-current-projection.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/stripe-subscription-current-projection.test.mjs b/tests/unit/stripe-subscription-current-projection.test.mjs index b5171f5d..0fd0ba85 100644 --- a/tests/unit/stripe-subscription-current-projection.test.mjs +++ b/tests/unit/stripe-subscription-current-projection.test.mjs @@ -184,7 +184,7 @@ test('projection returns null or an empty list when no accepted observation exis test('projection rejects malformed local authority before querying', () => { const { projection } = setup(); - for (const organizationId of [0, -1, 1.5, Number.NaN, {}, '']) { + for (const organizationId of [0, -1, 1.5, Number.NaN, {}, '', true, false]) { assert.throws( () => projection.listCurrentSubscriptions({ organizationId }), TypeError, From e5f86255b2784e2085426f66d344d32ecf5437a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 18:43:36 +0900 Subject: [PATCH 06/10] fix(billing): reject boolean tenant authority --- server/stripe_subscription_current_projection.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/stripe_subscription_current_projection.mjs b/server/stripe_subscription_current_projection.mjs index 64e447d7..a71a55ed 100644 --- a/server/stripe_subscription_current_projection.mjs +++ b/server/stripe_subscription_current_projection.mjs @@ -2,6 +2,9 @@ const MAX_PROVIDER_ID_LENGTH = 255; const PROVIDER_IDENTIFIER_PATTERN = /^[A-Za-z0-9_:-]+$/u; function positiveOrganizationId(value) { + if (typeof value !== 'number' && typeof value !== 'string') { + throw new TypeError('organizationId must be a positive safe integer'); + } const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed <= 0) { throw new TypeError('organizationId must be a positive safe integer'); From ec47c19000e5814f4a77f7626b0e29282c7a6e7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:44:24 +0900 Subject: [PATCH 07/10] test(billing): reject ambiguous tenant authority strings --- ...e-subscription-current-projection.test.mjs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/unit/stripe-subscription-current-projection.test.mjs b/tests/unit/stripe-subscription-current-projection.test.mjs index 0fd0ba85..3b53ae7b 100644 --- a/tests/unit/stripe-subscription-current-projection.test.mjs +++ b/tests/unit/stripe-subscription-current-projection.test.mjs @@ -184,7 +184,27 @@ test('projection returns null or an empty list when no accepted observation exis test('projection rejects malformed local authority before querying', () => { const { projection } = setup(); - for (const organizationId of [0, -1, 1.5, Number.NaN, {}, '', true, false]) { + assert.deepEqual( + projection.listCurrentSubscriptions({ organizationId: '42' }), + [], + 'canonical decimal route authority remains supported', + ); + for (const organizationId of [ + 0, + -1, + 1.5, + Number.NaN, + {}, + '', + true, + false, + ' 42', + '42 ', + '+42', + '0x2a', + '4.2e1', + '042', + ]) { assert.throws( () => projection.listCurrentSubscriptions({ organizationId }), TypeError, From d86018890306543d4a50f3fd5345d3a1999ebb3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:45:57 +0900 Subject: [PATCH 08/10] fix(billing): require canonical tenant authority strings --- server/stripe_subscription_current_projection.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/stripe_subscription_current_projection.mjs b/server/stripe_subscription_current_projection.mjs index a71a55ed..85da893e 100644 --- a/server/stripe_subscription_current_projection.mjs +++ b/server/stripe_subscription_current_projection.mjs @@ -1,7 +1,11 @@ const MAX_PROVIDER_ID_LENGTH = 255; const PROVIDER_IDENTIFIER_PATTERN = /^[A-Za-z0-9_:-]+$/u; +const CANONICAL_ORGANIZATION_ID_PATTERN = /^[1-9][0-9]*$/u; function positiveOrganizationId(value) { + if (typeof value === 'string' && !CANONICAL_ORGANIZATION_ID_PATTERN.test(value)) { + throw new TypeError('organizationId must be a positive safe integer'); + } if (typeof value !== 'number' && typeof value !== 'string') { throw new TypeError('organizationId must be a positive safe integer'); } From fdc7836087772dd4f620aa4f535fb257eee607a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:48:21 +0900 Subject: [PATCH 09/10] test(billing): lock current projection coverage --- tests/unit/coverage-script-contract.test.mjs | 15 ++++++++++++ ...e-subscription-current-projection.test.mjs | 23 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 58ec1fe2..95ebc690 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -64,6 +64,11 @@ assert.match( /--include=server\/stripe_subscription_observation_ledger\.mjs/, 'the authoritative Stripe subscription observation ledger is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/stripe_subscription_current_projection\.mjs/, + 'the current authoritative Stripe subscription projection is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, @@ -124,6 +129,11 @@ assert.match( /tests\/unit\/stripe-subscription-observation-ledger\.test\.mjs/, 'the authoritative Stripe subscription observation regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-subscription-current-projection\.test\.mjs/, + 'the current authoritative Stripe subscription projection regression executes under c8', +); assert.match( scripts['test:unit'], /tests\/unit\/stripe-webhook-recorder-integration\.test\.mjs/, @@ -144,6 +154,11 @@ assert.match( /tests\/unit\/stripe-subscription-observation-ledger\.test\.mjs/, 'normal unit CI executes the authoritative Stripe subscription observation regression', ); +assert.match( + scripts['test:unit'], + /tests\/unit\/stripe-subscription-current-projection\.test\.mjs/, + 'normal unit CI executes the current authoritative Stripe subscription projection regression', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, diff --git a/tests/unit/stripe-subscription-current-projection.test.mjs b/tests/unit/stripe-subscription-current-projection.test.mjs index 3b53ae7b..ffced5f9 100644 --- a/tests/unit/stripe-subscription-current-projection.test.mjs +++ b/tests/unit/stripe-subscription-current-projection.test.mjs @@ -118,6 +118,29 @@ test('current projection returns the newest accepted authoritative read with ord assert.equal(database.prepare('SELECT plan FROM orgs WHERE id = 42').get().plan, 'free'); }); +test('current projection retains non-null trial and source-event provenance', () => { + const { database, observationRepository, projection } = setup(); + database.prepare('INSERT INTO billing_stripe_webhook_events(event_id) VALUES(?)') + .run('evt_projection_trial'); + observationRepository.recordAuthoritativeObservation({ + snapshot: snapshot({ + status: 'trialing', + trialEndSec: 1_789_000_000, + latestInvoiceId: null, + priceIds: Object.freeze(['price_scopeweave_trial']), + }), + sourceEventId: 'evt_projection_trial', + }); + + const current = projection.getCurrentSubscription({ + organizationId: '42', + subscriptionId: 'sub_scopeweave_42', + }); + assert.equal(current.trialEndSec, 1_789_000_000); + assert.equal(current.sourceEventId, 'evt_projection_trial'); + assert.deepEqual(current.priceIds, ['price_scopeweave_trial']); +}); + test('projection is tenant-scoped and never reveals another organization subscription', () => { const { observationRepository, projection } = setup(); observationRepository.recordAuthoritativeObservation({ snapshot: snapshot() }); From dc0e3bc815e26852e0b7a1197703d67f68138c42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 19:49:41 +0900 Subject: [PATCH 10/10] docs(billing): trace current subscription projection --- CHANGELOG.md | 7 ++- .../stripe-subscription-current-projection.md | 60 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 docs/doctoring/stripe-subscription-current-projection.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b6479f79..f32fdbc9 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 +- Added a tenant-scoped read-only projection over accepted authoritative Stripe + Subscription observations, selecting current state by explicit append order, + preserving ordered Price and source-event provenance, rejecting ambiguous local + organization authority strings, and never treating provider status as local + entitlement authority. - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. - Persist authoritative Stripe Subscription reads as normalized append-only tenant-bound observations without mutating local entitlement state; atomic @@ -117,4 +122,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하던 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하던 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. \ No newline at end of file diff --git a/docs/doctoring/stripe-subscription-current-projection.md b/docs/doctoring/stripe-subscription-current-projection.md new file mode 100644 index 00000000..b0b8dc8b --- /dev/null +++ b/docs/doctoring/stripe-subscription-current-projection.md @@ -0,0 +1,60 @@ +# Current authoritative Stripe subscription projection + +## Status and scope + +This record describes **active PR work** stacked on the authoritative Stripe subscription observation ledger. It is not protected-`develop` shipped truth and it does not grant, revoke, or otherwise infer ScopeWeave entitlements. + +The slice adds a read-only SQLite projection that selects the latest accepted authoritative observation for one tenant-owned Stripe Subscription, or one latest observation per tenant-owned Subscription. It preserves ordered Price membership and source-event provenance, returns immutable values, and leaves `orgs.plan` and all other entitlement state unchanged. + +## Decision boundary + +Webhook delivery order is not used as current-state authority. Stripe subscription activity is asynchronous and webhook events are notifications that must be handled as such; the preceding stack independently retrieves tenant-verified authoritative Subscription state and appends accepted observations. This projection operates only over those accepted observations. + +Within the append-only observation ledger, `observation_id` is the explicit local append-order key. `getCurrentSubscription()` selects the greatest observation identifier for the requested Subscription. `listCurrentSubscriptions()` selects the greatest observation identifier per Subscription, then applies the owning-organization filter through the immutable Customer→Subscription binding. Result ordering is explicit by Subscription identifier; SQLite does not guarantee multi-row result order without `ORDER BY`. + +## Tenant and input authority + +Organization authority is local server state, not Stripe metadata or browser claims. The projection accepts positive safe-integer organization identifiers and canonical positive decimal string representations such as `"42"`. Ambiguous numeric spellings such as leading/trailing whitespace, signs, hexadecimal, exponent notation, and leading zeroes fail closed before a query. Subscription identifiers must remain bounded provider identifiers. + +The projection joins observations through `billing_stripe_subscriptions` and `billing_stripe_customers` and filters by the requested local organization. Cross-tenant lookup returns the same absent result as an unknown Subscription and never reveals another tenant's provider facts. + +## Evidence and coverage + +The realistic regression suite covers: + +- newest-observation selection when multiple accepted reads share the same wall-clock observation time; +- ordered Price membership; +- non-null trial expiry and verified source-event provenance; +- cross-tenant non-disclosure; +- one-current-row-per-Subscription list behavior and stable result ordering; +- immutable outputs and no mutation of authoritative evidence or `orgs.plan`; +- empty/absent behavior; and +- malformed and ambiguous local authority values. + +The module and its focused behavior suite are registered in both normal unit CI and the canonical `c8` owned-production coverage producer. The coverage-script contract explicitly locks those registrations so a future manifest edit cannot silently remove projection coverage. + +The initial behavior-only commit preceded the production module. A later authority-hardening test commit preceded its canonical-string implementation, but its hosted jobs were cancelled by subsequent branch movement before execution. Commit order therefore records the TDD sequence; cancelled predecessor runs are not represented as hosted RED evidence. Only terminal evidence on the unchanged current head is merge-relevant. + +## Security, privacy, and entitlement separation + +This module contains no Stripe secret, webhook raw body, session credential, or provider transport. It returns only normalized Subscription facts already accepted by the parent persistence boundary. An `active`, `trialing`, or other Stripe status remains provider evidence; it is not itself a ScopeWeave authorization or entitlement decision. A later monotonic policy layer must make any local entitlement transition transactionally, idempotently, and audibly. + +No mutable webhook arrival timestamp, browser-supplied organization, or Stripe event ordering is allowed to select the current observation. The projection is deliberately read-only and has no SQL mutation statement. + +## Rollback and recovery + +Before protected integration, rollback removes `server/stripe_subscription_current_projection.mjs`, its focused tests, coverage registrations, this doctoring record, and the matching Unreleased changelog entry together. No schema or customer data migration is required because the slice creates no database object and mutates no persisted state. + +After integration, rollback of this read capability must not delete the append-only authoritative observations owned by the preceding ledger. Reintroducing webhook-arrival ordering or direct entitlement mutation is not an acceptable rollback path. + +## Dependency and merge boundary + +This PR must remain stacked on the exact authoritative-observation parent and the preceding #488 Stripe trust chain. Final integration also requires the repository's exact-contributor-head workflow control to be protected-shipped or equivalently reconciled, followed by fresh exact-head CI, browser, owned coverage, security, dependency, supply-chain, package/provenance, resolved-review, and independent-approval evidence under the live protected rules. + +## References + +SQLite Consortium. (2026). *SELECT*. SQLite documentation. https://www.sqlite.org/lang_select.html + +Stripe. (2026). *Using webhooks with subscriptions*. Stripe documentation. https://docs.stripe.com/billing/subscriptions/webhooks + +Stripe. (2026). *Receive Stripe events in your webhook endpoint*. Stripe documentation. https://docs.stripe.com/webhooks