diff --git a/CHANGELOG.md b/CHANGELOG.md index 3700da60..2ca066d0 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 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 diff --git a/package.json b/package.json index 24009c8c..c9235da7 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/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 --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 && 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/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 --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 && 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/stripe_subscription_current_projection.mjs b/server/stripe_subscription_current_projection.mjs new file mode 100644 index 00000000..85da893e --- /dev/null +++ b/server/stripe_subscription_current_projection.mjs @@ -0,0 +1,156 @@ +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'); + } + 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)); + }, + }); +} diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index bb1e34d0..a1e3c2e4 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -69,6 +69,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/, @@ -144,6 +149,16 @@ assert.match( /tests\/unit\/stripe-subscription-observation-ledger\.test\.mjs/, 'normal unit CI executes the authoritative Stripe subscription observation regression', ); +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-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 new file mode 100644 index 00000000..ffced5f9 --- /dev/null +++ b/tests/unit/stripe-subscription-current-projection.test.mjs @@ -0,0 +1,242 @@ +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('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() }); + + 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(); + 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, + ); + } + for (const subscriptionId of ['', ' ', {}, [], 'x'.repeat(256)]) { + assert.throws( + () => projection.getCurrentSubscription({ organizationId: 42, subscriptionId }), + TypeError, + ); + } +});