diff --git a/CHANGELOG.md b/CHANGELOG.md index 68dc5009..c4749c58 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 deterministic Stripe entitlement-policy decisions as append-only, + tenant-bound claim evidence with optimistic previous-decision concurrency, + automatic current Subscription/Invoice evidence selection, exact source + provenance, and a separate current-head pointer; this layer still does not + mutate plans, session authority, or access capabilities. - Added a tenant-scoped read-only projection over accepted authoritative Stripe Invoice observations, selecting current state by append identity rather than webhook/provider time, preserving payment/provenance facts, and rejecting diff --git a/docs/doctoring/stripe-entitlement-claim-ledger.md b/docs/doctoring/stripe-entitlement-claim-ledger.md new file mode 100644 index 00000000..c45961dc --- /dev/null +++ b/docs/doctoring/stripe-entitlement-claim-ledger.md @@ -0,0 +1,92 @@ +# Transactional Stripe entitlement claim ledger + +## Status and authority + +**Status: active stacked PR evidence, not protected-`develop` shipped truth.** + +This bounded #488 slice is stacked on the current authoritative Invoice projection. Protected `develop` remains shipped authority until the prerequisite billing stack is independently reviewed, protected-integrated, and revalidated on unchanged exact heads. + +The slice persists deterministic policy decisions and a current claim pointer. It does **not** mutate `orgs.plan`, issue session/API capabilities, change membership/RBAC, or itself authorize product access. Application of a persisted claim to an authorization boundary remains a later separately tested slice. + +## Buyer and control objective + +The prior stack can now produce current tenant-scoped Subscription and Invoice evidence, while `stripe_entitlement_policy.mjs` derives a deterministic candidate from those facts. Commercial use additionally requires durable provenance and concurrency semantics: a process restart must not erase why access was granted or denied, and two concurrent reconcilers must not silently overwrite each other. + +`server/stripe_entitlement_claim_ledger.mjs` therefore owns two normalized relations: + +- `billing_stripe_entitlement_decisions` is append-only audit history. Each row records the evaluated current Subscription observation, optional evaluated current Invoice observation, previous decision link, deterministic action/reason, resulting claim facts, the exact claim source Subscription/Invoice observations, and bounded evaluation/recording times. +- `billing_stripe_entitlement_claim_heads` contains exactly one current decision pointer per Stripe Subscription. It is a projection pointer, not an authorization token. + +Tenant identity is not duplicated in those relations because Subscription → Customer → organization already determines it. Every read and write re-scopes through that chain. + +## Current-evidence authority + +Callers invoke `applyCurrentDecision` with only server-owned organization/Subscription identity and an optional `expectedPreviousDecisionId` compare-and-swap token. They cannot choose a Subscription or Invoice observation ID. + +Inside one SQLite savepoint, the repository: + +1. selects the highest accepted Subscription observation for the exact tenant-owned Subscription; +2. selects the highest accepted Invoice observation for that Subscription's current `latest_invoice_id`, when present; +3. reconstructs the durable previous claim from the current decision head; +4. checks optimistic concurrency against `expectedPreviousDecisionId`; +5. invokes the deterministic entitlement policy with those persisted current facts; +6. independently validates the policy output and exact source provenance; +7. appends one decision; and +8. atomically advances the Subscription's current head. + +This prevents stale-evidence injection through an API parameter and makes competing reconcilers observable as a stable conflict instead of last-writer-wins authorization history. + +## Claim provenance and retention semantics + +A resulting claim always identifies the exact Subscription observation from which the policy derived it. If the claim uses paid Invoice evidence, the decision also stores the exact Invoice observation—not merely the Invoice ID. A later `past_due` evaluation can retain a still-valid prior paid claim even when the newest Subscription observation no longer names an Invoice; in that case the new decision preserves the historical Invoice observation that originally authorized the retained window while separately recording that the current evaluation had no Invoice evidence. + +This separation lets an operator answer both "what did the reconciler see now?" and "what evidence still supports the retained claim?" without rewriting old provider facts. + +## Transaction and failure safety + +Decision insertion and head advancement share one named SQLite savepoint. On an operation failure, ScopeWeave first attempts `ROLLBACK TO SAVEPOINT`. The savepoint is released only after rollback is confirmed. Cleanup-release failure after confirmed rollback cannot replace the causal operation error, while failed rollback leaves the savepoint open rather than risking an outermost `RELEASE` that could commit partial state. + +SQLite documents that `ROLLBACK TO` restores state after a savepoint but does not remove that savepoint, while `RELEASE` of an outermost savepoint can commit. The cleanup sequence is therefore part of the data-integrity boundary. + +## TDD and executable evidence + +A test-only branch commit introduced `tests/unit/stripe-entitlement-claim-ledger.test.mjs` while the production module was absent, establishing a realistic RED module-resolution failure before implementation. + +The focused acceptance suite exercises: + +- normalized append-only schema shape with no plan/session/RBAC authority; +- automatic current Subscription and Invoice selection; +- atomic grant and retain decision chains; +- optimistic previous-decision conflicts; +- tenant isolation and unknown Subscription behavior; +- trial/no-Invoice and false-claim handling; +- malformed policy output and impossible source provenance; +- missing current Invoice evidence; +- retained historical paid-Invoice provenance; +- malformed authority, dependency seams, and clocks; +- default clock behavior; and +- rollback/release cleanup failure. + +Private focused execution after implementation passed all claim-ledger subtests and produced **100% line / 100% branch / 100% function coverage** for `server/stripe_entitlement_claim_ledger.mjs`. A focused package contract locks the production module and regression into both normal unit CI and canonical c8 execution. Hosted exact-head CI/security/dependency/review evidence remains authoritative for integration. + +## Bootstrap composition + +`server/db.mjs` installs the claim schema only after Subscription and Invoice evidence schemas, then creates the repository with the production `deriveStripeSubscriptionEntitlement` function. Request handlers do not create billing schema. This wires deterministic policy and persistence without giving the persistence module authority to select a different policy implementation at runtime. + +## Privacy and compliance posture + +The ledger stores provider identifiers and minimal decision provenance needed for auditability. It stores no raw Stripe responses, webhook payloads, customer contact data, payment credentials, Stripe secrets, session tokens, arbitrary metadata, or human-readable provider error text. This supports purpose-bound audit evidence and later retention/export controls without claiming SOC 2 or other certification. + +## Rollback and recovery + +While this remains an active stacked PR, rollback removes the claim module, bootstrap registration, focused tests/package contract, this doctoring record, and the matching Unreleased changelog entry. No protected production migration is claimed. After protected integration, persisted decision history must be retained or exported until a separately reviewed migration/retention change exists; rollback of authorization application must not erase the evidence that produced prior access decisions. + +## References + +SQLite. (n.d.). *Savepoints*. https://www.sqlite.org/lang_savepoint.html + +SQLite. (n.d.). *UPSERT*. https://www.sqlite.org/lang_upsert.html + +Stripe. (n.d.). *The Subscription object*. Stripe API Reference. https://docs.stripe.com/api/subscriptions/object + +Stripe. (n.d.). *The Invoice object*. Stripe API Reference. https://docs.stripe.com/api/invoices/object diff --git a/package.json b/package.json index 36c2df00..634275ee 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/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/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 --include=server/stripe_invoice_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 && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && 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/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/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 --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_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 && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && 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 f4ff916d..0e06b45b 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -21,6 +21,11 @@ import { createSqliteStripeInvoiceObservationRepository, installStripeInvoiceObservationSchema, } from './stripe_invoice_observation_ledger.mjs'; +import { deriveStripeSubscriptionEntitlement } from './stripe_entitlement_policy.mjs'; +import { + createSqliteStripeEntitlementClaimRepository, + installStripeEntitlementClaimSchema, +} from './stripe_entitlement_claim_ledger.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); @@ -204,6 +209,10 @@ installStripeSubscriptionObservationSchema(db); export const stripeSubscriptionObservations = createSqliteStripeSubscriptionObservationRepository(db); installStripeInvoiceObservationSchema(db); export const stripeInvoiceObservations = createSqliteStripeInvoiceObservationRepository(db); +installStripeEntitlementClaimSchema(db); +export const stripeEntitlementClaims = createSqliteStripeEntitlementClaimRepository(db, { + deriveEntitlement: deriveStripeSubscriptionEntitlement, +}); // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. export const rowid = (r) => Number(r.lastInsertRowid); diff --git a/server/stripe_entitlement_claim_ledger.mjs b/server/stripe_entitlement_claim_ledger.mjs new file mode 100644 index 00000000..316a77e3 --- /dev/null +++ b/server/stripe_entitlement_claim_ledger.mjs @@ -0,0 +1,332 @@ +const PROVIDER_IDENTIFIER_PATTERN = /^[A-Za-z0-9_:-]+$/u; +const MAX_PROVIDER_ID_LENGTH = 255; +const MAX_REASON_LENGTH = 120; +const SAVEPOINT_NAME = 'billing_stripe_entitlement_claim_write'; +const DECISION_ACTIONS = new Set(['ignore', 'grant', 'retain', 'extend', 'revoke', 'deny']); + +/** Stable persistence/concurrency error for Stripe entitlement claim decisions. */ +export class StripeEntitlementClaimError extends Error { + constructor(code, status = 400) { + super(code); + this.name = 'StripeEntitlementClaimError'; + this.code = code; + this.status = status; + } +} + +function claimError(code = 'stripe_entitlement_claim_invalid', status = 400) { + return new StripeEntitlementClaimError(code, status); +} + +function positiveSafeInteger(value) { + if (!Number.isSafeInteger(value) || value <= 0) throw claimError(); + return value; +} + +function nonNegativeSafeInteger(value) { + if (!Number.isSafeInteger(value) || value < 0) throw claimError(); + return value; +} + +function providerId(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PROVIDER_ID_LENGTH || !PROVIDER_IDENTIFIER_PATTERN.test(value)) { + throw claimError(); + } + return value; +} + +function optionalDecisionId(value) { + return value == null ? null : positiveSafeInteger(value); +} + +function nowValue(clock) { + return nonNegativeSafeInteger(Number(clock())); +} + +function freezeClaim(row) { + if (!row) return null; + return Object.freeze({ + decisionId: Number(row.decision_id), + organizationId: Number(row.organization_id), + subscriptionId: row.subscription_id, + entitled: Number(row.entitled) === 1, + validUntilSec: row.valid_until_sec == null ? null : Number(row.valid_until_sec), + sourceObservationId: Number(row.claim_subscription_observation_id), + sourceInvoiceId: row.claim_invoice_id ?? null, + sourceInvoiceObservationId: row.claim_invoice_observation_id == null ? null : Number(row.claim_invoice_observation_id), + action: row.decision_action, + reason: row.decision_reason, + evaluatedSubscriptionObservationId: Number(row.evaluated_subscription_observation_id), + evaluatedInvoiceObservationId: row.evaluated_invoice_observation_id == null ? null : Number(row.evaluated_invoice_observation_id), + evaluatedAtSec: Number(row.evaluated_at_sec), + }); +} + +function withSavepoint(database, operation) { + database.exec(`SAVEPOINT ${SAVEPOINT_NAME}`); + try { + const result = operation(); + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + return result; + } catch (error) { + let rolledBack = false; + try { + database.exec(`ROLLBACK TO SAVEPOINT ${SAVEPOINT_NAME}`); + rolledBack = true; + } catch { + // Do not release unconfirmed state: outermost RELEASE could commit it. + } + if (rolledBack) { + try { database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); } catch { /* causal error wins */ } + } + throw error; + } +} + +function policyTransition(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw claimError('stripe_entitlement_policy_invalid', 500); + if (!DECISION_ACTIONS.has(value.action)) throw claimError('stripe_entitlement_policy_invalid', 500); + if (typeof value.reason !== 'string' || value.reason.length === 0 || value.reason.length > MAX_REASON_LENGTH) { + throw claimError('stripe_entitlement_policy_invalid', 500); + } + const claim = value.claim; + if (!claim || typeof claim !== 'object' || Array.isArray(claim)) throw claimError('stripe_entitlement_policy_invalid', 500); + if (typeof claim.entitled !== 'boolean') throw claimError('stripe_entitlement_policy_invalid', 500); + const validUntilSec = claim.validUntilSec == null ? null : nonNegativeSafeInteger(claim.validUntilSec); + if (claim.entitled && validUntilSec == null) throw claimError('stripe_entitlement_policy_invalid', 500); + return { + action: value.action, + reason: value.reason, + claim: { + organizationId: positiveSafeInteger(claim.organizationId), + subscriptionId: providerId(claim.subscriptionId), + entitled: claim.entitled, + validUntilSec, + sourceObservationId: positiveSafeInteger(claim.sourceObservationId), + sourceInvoiceId: claim.sourceInvoiceId == null ? null : providerId(claim.sourceInvoiceId), + }, + }; +} + +/** Install append-only Stripe entitlement decisions and the per-Subscription current head. */ +export function installStripeEntitlementClaimSchema(database) { + database.exec(` + CREATE TABLE IF NOT EXISTS billing_stripe_entitlement_decisions ( + decision_id INTEGER PRIMARY KEY, + subscription_id TEXT NOT NULL REFERENCES billing_stripe_subscriptions(subscription_id) ON DELETE CASCADE, + evaluated_subscription_observation_id INTEGER NOT NULL REFERENCES billing_stripe_subscription_observations(observation_id) ON DELETE RESTRICT, + evaluated_invoice_observation_id INTEGER REFERENCES billing_stripe_invoice_observations(observation_id) ON DELETE RESTRICT, + previous_decision_id INTEGER REFERENCES billing_stripe_entitlement_decisions(decision_id) ON DELETE RESTRICT, + decision_action TEXT NOT NULL CHECK(decision_action IN ('ignore','grant','retain','extend','revoke','deny')), + decision_reason TEXT NOT NULL CHECK(length(decision_reason) BETWEEN 1 AND ${MAX_REASON_LENGTH}), + entitled INTEGER NOT NULL CHECK(entitled IN (0,1)), + valid_until_sec INTEGER CHECK(valid_until_sec IS NULL OR valid_until_sec >= 0), + claim_subscription_observation_id INTEGER NOT NULL REFERENCES billing_stripe_subscription_observations(observation_id) ON DELETE RESTRICT, + claim_invoice_observation_id INTEGER REFERENCES billing_stripe_invoice_observations(observation_id) ON DELETE RESTRICT, + evaluated_at_sec INTEGER NOT NULL CHECK(evaluated_at_sec >= 0), + recorded_at_ms INTEGER NOT NULL CHECK(recorded_at_ms >= 0), + CHECK((entitled = 1 AND valid_until_sec IS NOT NULL) OR entitled = 0), + UNIQUE(subscription_id, decision_id) + ); + CREATE INDEX IF NOT EXISTS billing_stripe_entitlement_decision_history + ON billing_stripe_entitlement_decisions(subscription_id, decision_id); + CREATE INDEX IF NOT EXISTS billing_stripe_entitlement_subscription_sources + ON billing_stripe_entitlement_decisions(evaluated_subscription_observation_id, decision_id); + CREATE INDEX IF NOT EXISTS billing_stripe_entitlement_invoice_sources + ON billing_stripe_entitlement_decisions(evaluated_invoice_observation_id, decision_id); + + CREATE TABLE IF NOT EXISTS billing_stripe_entitlement_claim_heads ( + subscription_id TEXT PRIMARY KEY REFERENCES billing_stripe_subscriptions(subscription_id) ON DELETE CASCADE, + decision_id INTEGER NOT NULL UNIQUE, + FOREIGN KEY(subscription_id, decision_id) + REFERENCES billing_stripe_entitlement_decisions(subscription_id, decision_id) ON DELETE RESTRICT + ); + `); +} + +/** + * Create the transactional claim-decision repository. + * + * The repository chooses the latest accepted Subscription observation and the + * latest accepted Invoice observation for its `latest_invoice_id` itself, then + * passes those persisted facts plus the current durable claim to the deterministic + * entitlement policy. Callers cannot choose stale provider evidence. The expected + * previous decision ID is an optimistic concurrency token; every successful + * evaluation appends an audit decision and atomically advances the current head. + * + * This layer persists claim decisions only. It never writes `orgs.plan`, session + * capabilities, or any external authorization state. + */ +export function createSqliteStripeEntitlementClaimRepository(database, { + deriveEntitlement, + nowSec = () => Math.floor(Date.now() / 1000), + nowMs = Date.now, +} = {}) { + if (!database || typeof database.prepare !== 'function' || typeof database.exec !== 'function') { + throw new TypeError('database must provide SQLite prepare/exec operations'); + } + if (typeof deriveEntitlement !== 'function') throw new TypeError('deriveEntitlement must be a function'); + if (typeof nowSec !== 'function' || typeof nowMs !== 'function') throw new TypeError('clock dependencies must be functions'); + + const selectCurrentSubscription = database.prepare(` + SELECT o.observation_id, c.organization_id, s.customer_id, o.subscription_id, + o.subscription_status, o.cancel_at_period_end, o.current_period_end_sec, + o.trial_end_sec, o.latest_invoice_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 c.organization_id = ? AND o.subscription_id = ? + ORDER BY o.observation_id DESC LIMIT 1 + `); + const selectCurrentInvoice = database.prepare(` + SELECT o.observation_id, i.subscription_id, o.invoice_id, o.invoice_status + FROM billing_stripe_invoice_observations o + JOIN billing_stripe_invoices i ON i.invoice_id = o.invoice_id + WHERE i.subscription_id = ? AND o.invoice_id = ? + ORDER BY o.observation_id DESC LIMIT 1 + `); + const selectHead = database.prepare(` + SELECT h.decision_id, c.organization_id, d.subscription_id, d.entitled, + d.valid_until_sec, d.claim_subscription_observation_id, + ci.invoice_id AS claim_invoice_id, d.claim_invoice_observation_id, + d.decision_action, d.decision_reason, + d.evaluated_subscription_observation_id, + d.evaluated_invoice_observation_id, d.evaluated_at_sec + FROM billing_stripe_entitlement_claim_heads h + JOIN billing_stripe_entitlement_decisions d ON d.decision_id = h.decision_id + JOIN billing_stripe_subscriptions s ON s.subscription_id = d.subscription_id + JOIN billing_stripe_customers c ON c.customer_id = s.customer_id + LEFT JOIN billing_stripe_invoice_observations cio ON cio.observation_id = d.claim_invoice_observation_id + LEFT JOIN billing_stripe_invoices ci ON ci.invoice_id = cio.invoice_id + WHERE h.subscription_id = ? + `); + const selectClaimSubscription = database.prepare(` + SELECT o.observation_id, c.organization_id, o.subscription_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 selectClaimInvoice = database.prepare(` + SELECT o.observation_id, i.invoice_id, i.subscription_id + FROM billing_stripe_invoice_observations o + JOIN billing_stripe_invoices i ON i.invoice_id = o.invoice_id + WHERE o.observation_id = ? + `); + const insertDecision = database.prepare(` + INSERT INTO billing_stripe_entitlement_decisions( + subscription_id, evaluated_subscription_observation_id, evaluated_invoice_observation_id, + previous_decision_id, decision_action, decision_reason, entitled, valid_until_sec, + claim_subscription_observation_id, claim_invoice_observation_id, + evaluated_at_sec, recorded_at_ms + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + `); + const upsertHead = database.prepare(` + INSERT INTO billing_stripe_entitlement_claim_heads(subscription_id, decision_id) + VALUES(?,?) + ON CONFLICT(subscription_id) DO UPDATE SET decision_id = excluded.decision_id + `); + + function getCurrentClaimById(subscriptionId) { + return freezeClaim(selectHead.get(subscriptionId)); + } + + return Object.freeze({ + /** Return the durable current claim for one exact tenant-owned Subscription. */ + getCurrentClaim({ organizationId, subscriptionId }) { + const orgId = positiveSafeInteger(organizationId); + const subId = providerId(subscriptionId); + const currentSubscription = selectCurrentSubscription.get(orgId, subId); + if (!currentSubscription) return null; + return getCurrentClaimById(subId); + }, + + /** Re-evaluate current persisted evidence and atomically append/advance one claim decision. */ + applyCurrentDecision({ organizationId, subscriptionId, expectedPreviousDecisionId = null }) { + const orgId = positiveSafeInteger(organizationId); + const subId = providerId(subscriptionId); + const expected = optionalDecisionId(expectedPreviousDecisionId); + const evaluatedAtSec = nowValue(nowSec); + const recordedAtMs = nowValue(nowMs); + + return withSavepoint(database, () => { + const subscriptionRow = selectCurrentSubscription.get(orgId, subId); + if (!subscriptionRow) throw claimError('stripe_entitlement_subscription_unknown', 404); + const previous = getCurrentClaimById(subId); + const currentDecisionId = previous?.decisionId ?? null; + if (currentDecisionId !== expected) throw claimError('stripe_entitlement_claim_conflict', 409); + + const invoiceRow = subscriptionRow.latest_invoice_id == null + ? null + : selectCurrentInvoice.get(subId, subscriptionRow.latest_invoice_id) ?? null; + const subscription = { + observationId: Number(subscriptionRow.observation_id), + organizationId: Number(subscriptionRow.organization_id), + subscriptionId: subscriptionRow.subscription_id, + status: subscriptionRow.subscription_status, + cancelAtPeriodEnd: Number(subscriptionRow.cancel_at_period_end) === 1, + currentPeriodEndSec: Number(subscriptionRow.current_period_end_sec), + trialEndSec: subscriptionRow.trial_end_sec == null ? null : Number(subscriptionRow.trial_end_sec), + latestInvoiceId: subscriptionRow.latest_invoice_id ?? null, + }; + const invoice = invoiceRow == null ? null : { + invoiceId: invoiceRow.invoice_id, + subscriptionId: invoiceRow.subscription_id, + status: invoiceRow.invoice_status, + }; + const previousClaim = previous == null ? null : { + organizationId: previous.organizationId, + subscriptionId: previous.subscriptionId, + entitled: previous.entitled, + validUntilSec: previous.validUntilSec, + sourceObservationId: previous.sourceObservationId, + sourceInvoiceId: previous.sourceInvoiceId, + }; + + const transition = policyTransition(deriveEntitlement({ subscription, invoice, previousClaim, nowSec: evaluatedAtSec })); + if (transition.claim.organizationId !== orgId || transition.claim.subscriptionId !== subId) { + throw claimError('stripe_entitlement_policy_invalid', 500); + } + const claimSubscription = selectClaimSubscription.get(transition.claim.sourceObservationId); + if (!claimSubscription + || Number(claimSubscription.organization_id) !== orgId + || claimSubscription.subscription_id !== subId) { + throw claimError('stripe_entitlement_policy_invalid', 500); + } + + let claimInvoiceObservationId = null; + if (transition.claim.sourceInvoiceId != null) { + if (invoiceRow && invoiceRow.invoice_id === transition.claim.sourceInvoiceId) { + claimInvoiceObservationId = Number(invoiceRow.observation_id); + } else if (previous?.sourceInvoiceId === transition.claim.sourceInvoiceId) { + claimInvoiceObservationId = previous.sourceInvoiceObservationId; + } + const claimInvoice = claimInvoiceObservationId == null ? null : selectClaimInvoice.get(claimInvoiceObservationId); + if (!claimInvoice + || claimInvoice.invoice_id !== transition.claim.sourceInvoiceId + || claimInvoice.subscription_id !== subId) { + throw claimError('stripe_entitlement_policy_invalid', 500); + } + } + + const result = insertDecision.run( + subId, + Number(subscriptionRow.observation_id), + invoiceRow == null ? null : Number(invoiceRow.observation_id), + currentDecisionId, + transition.action, + transition.reason, + transition.claim.entitled ? 1 : 0, + transition.claim.validUntilSec, + transition.claim.sourceObservationId, + claimInvoiceObservationId, + evaluatedAtSec, + recordedAtMs, + ); + const decisionId = Number(result.lastInsertRowid); + upsertHead.run(subId, decisionId); + return getCurrentClaimById(subId); + }); + }, + }); +} diff --git a/tests/unit/stripe-entitlement-claim-head-integrity.test.mjs b/tests/unit/stripe-entitlement-claim-head-integrity.test.mjs new file mode 100644 index 00000000..33bd993b --- /dev/null +++ b/tests/unit/stripe-entitlement-claim-head-integrity.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import { installStripeEntitlementClaimSchema } from '../../server/stripe_entitlement_claim_ledger.mjs'; + +function decisionSql(id, subscriptionId, observationId) { + return ` + INSERT INTO billing_stripe_entitlement_decisions( + decision_id, subscription_id, evaluated_subscription_observation_id, + evaluated_invoice_observation_id, previous_decision_id, decision_action, + decision_reason, entitled, valid_until_sec, + claim_subscription_observation_id, claim_invoice_observation_id, + evaluated_at_sec, recorded_at_ms + ) VALUES( + ${id}, '${subscriptionId}', ${observationId}, + NULL, NULL, 'grant', 'paid_active_subscription', 1, 3000, + ${observationId}, NULL, 1000, 1000 + ); + `; +} + +test('claim heads cannot point at entitlement decisions for another Subscription', () => { + const db = new DatabaseSync(':memory:'); + try { + db.exec('PRAGMA foreign_keys = ON'); + db.exec(` + CREATE TABLE billing_stripe_subscriptions(subscription_id TEXT PRIMARY KEY); + CREATE TABLE billing_stripe_subscription_observations( + observation_id INTEGER PRIMARY KEY, + subscription_id TEXT NOT NULL REFERENCES billing_stripe_subscriptions(subscription_id) + ); + CREATE TABLE billing_stripe_invoice_observations(observation_id INTEGER PRIMARY KEY); + INSERT INTO billing_stripe_subscriptions VALUES('sub_alpha'),('sub_beta'); + INSERT INTO billing_stripe_subscription_observations VALUES(101,'sub_alpha'),(201,'sub_beta'),(102,'sub_alpha'); + `); + installStripeEntitlementClaimSchema(db); + db.exec(decisionSql(1, 'sub_alpha', 101)); + db.exec(decisionSql(2, 'sub_beta', 201)); + db.exec(decisionSql(3, 'sub_alpha', 102)); + db.exec("INSERT INTO billing_stripe_entitlement_claim_heads(subscription_id,decision_id) VALUES('sub_alpha',1)"); + + assert.throws( + () => db.exec("UPDATE billing_stripe_entitlement_claim_heads SET decision_id=2 WHERE subscription_id='sub_alpha'"), + /FOREIGN KEY constraint failed/iu, + 'a tenant-owned Subscription head must not accept another Subscription decision', + ); + assert.equal( + db.prepare("SELECT decision_id FROM billing_stripe_entitlement_claim_heads WHERE subscription_id='sub_alpha'").get().decision_id, + 1, + 'a rejected cross-Subscription rebind must leave the original head intact', + ); + + db.exec("UPDATE billing_stripe_entitlement_claim_heads SET decision_id=3 WHERE subscription_id='sub_alpha'"); + assert.equal( + db.prepare("SELECT decision_id FROM billing_stripe_entitlement_claim_heads WHERE subscription_id='sub_alpha'").get().decision_id, + 3, + 'a same-Subscription head advance remains valid', + ); + } finally { + db.close(); + } +}); diff --git a/tests/unit/stripe-entitlement-claim-ledger.test.mjs b/tests/unit/stripe-entitlement-claim-ledger.test.mjs new file mode 100644 index 00000000..52d41b76 --- /dev/null +++ b/tests/unit/stripe-entitlement-claim-ledger.test.mjs @@ -0,0 +1,280 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; +import { + StripeEntitlementClaimError, + createSqliteStripeEntitlementClaimRepository, + installStripeEntitlementClaimSchema, +} from '../../server/stripe_entitlement_claim_ledger.mjs'; + +function fixture() { + const db = new DatabaseSync(':memory:'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec(` + CREATE TABLE orgs(id INTEGER PRIMARY KEY); + CREATE TABLE billing_stripe_customers(customer_id TEXT PRIMARY KEY, organization_id INTEGER NOT NULL REFERENCES orgs(id)); + CREATE TABLE billing_stripe_subscriptions(subscription_id TEXT PRIMARY KEY, customer_id TEXT NOT NULL REFERENCES billing_stripe_customers(customer_id)); + CREATE TABLE billing_stripe_subscription_observations( + observation_id INTEGER PRIMARY KEY, subscription_id TEXT NOT NULL REFERENCES billing_stripe_subscriptions(subscription_id), + subscription_status TEXT NOT NULL, cancel_at_period_end INTEGER NOT NULL, + current_period_end_sec INTEGER NOT NULL, trial_end_sec INTEGER, latest_invoice_id TEXT + ); + CREATE TABLE billing_stripe_invoices(invoice_id TEXT PRIMARY KEY, subscription_id TEXT NOT NULL REFERENCES billing_stripe_subscriptions(subscription_id)); + CREATE TABLE billing_stripe_invoice_observations( + observation_id INTEGER PRIMARY KEY, invoice_id TEXT NOT NULL REFERENCES billing_stripe_invoices(invoice_id), + invoice_status TEXT NOT NULL + ); + INSERT INTO orgs VALUES(42),(77); + INSERT INTO billing_stripe_customers VALUES('cus_42',42),('cus_77',77); + INSERT INTO billing_stripe_subscriptions VALUES('sub_42','cus_42'),('sub_77','cus_77'); + INSERT INTO billing_stripe_subscription_observations VALUES + (101,'sub_42','active',0,2000,NULL,'in_42'), + (102,'sub_42','active',0,3000,NULL,'in_42'), + (201,'sub_77','active',0,4000,NULL,'in_77'); + INSERT INTO billing_stripe_invoices VALUES('in_42','sub_42'),('in_77','sub_77'); + INSERT INTO billing_stripe_invoice_observations VALUES + (501,'in_42','open'),(502,'in_42','paid'),(701,'in_77','paid'); + `); + installStripeEntitlementClaimSchema(db); + return db; +} + +function expectCode(code, status) { + return (error) => { + assert.ok(error instanceof StripeEntitlementClaimError); + assert.equal(error.code, code); + if (status != null) assert.equal(error.status, status); + return true; + }; +} + +function paidPolicy({ subscription, invoice, previousClaim, nowSec }) { + assert.equal(subscription.observationId, 102); + assert.equal(subscription.organizationId, 42); + assert.equal(subscription.subscriptionId, 'sub_42'); + assert.equal(subscription.status, 'active'); + assert.equal(subscription.cancelAtPeriodEnd, false); + assert.equal(subscription.currentPeriodEndSec, 3000); + assert.equal(subscription.latestInvoiceId, 'in_42'); + assert.deepEqual(invoice, { invoiceId: 'in_42', subscriptionId: 'sub_42', status: 'paid' }); + assert.equal(nowSec, 1000); + return { + action: previousClaim ? 'retain' : 'grant', reason: 'paid_active_subscription', + claim: previousClaim ?? { + organizationId: 42, subscriptionId: 'sub_42', entitled: true, validUntilSec: 3000, + sourceObservationId: 102, sourceInvoiceId: 'in_42', + }, + }; +} + +test('schema is normalized append-only decision history plus one current head and has no plan/session authority', () => { + const db = fixture(); + const rows = db.prepare("SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE 'billing_stripe_entitlement_%'").all(); + assert.deepEqual(rows.map(({ name }) => name).sort(), ['billing_stripe_entitlement_claim_heads','billing_stripe_entitlement_decisions']); + const sql = rows.map(({ sql }) => sql).join('\n'); + assert.match(sql, /previous_decision_id/); + assert.match(sql, /claim_subscription_observation_id/); + assert.doesNotMatch(sql, /orgs\.plan|token|session|permission|role/iu); + db.close(); +}); + +test('repository chooses current persisted Subscription and Invoice evidence and atomically stores a grant head', () => { + const db = fixture(); + const repo = createSqliteStripeEntitlementClaimRepository(db, { deriveEntitlement: paidPolicy, nowSec: () => 1000, nowMs: () => 1000000 }); + const claim = repo.applyCurrentDecision({ organizationId: 42, subscriptionId: 'sub_42', expectedPreviousDecisionId: null }); + assert.deepEqual(claim, { + decisionId: 1, organizationId: 42, subscriptionId: 'sub_42', entitled: true, validUntilSec: 3000, + sourceObservationId: 102, sourceInvoiceId: 'in_42', sourceInvoiceObservationId: 502, + action: 'grant', reason: 'paid_active_subscription', evaluatedSubscriptionObservationId: 102, + evaluatedInvoiceObservationId: 502, evaluatedAtSec: 1000, + }); + assert.ok(Object.isFrozen(claim)); + assert.deepEqual(repo.getCurrentClaim({ organizationId: 42, subscriptionId: 'sub_42' }), claim); + assert.equal(db.prepare('SELECT COUNT(*) count FROM billing_stripe_entitlement_decisions').get().count, 1); + assert.equal(db.prepare('SELECT decision_id FROM billing_stripe_entitlement_claim_heads WHERE subscription_id=?').get('sub_42').decision_id, 1); + db.close(); +}); + +test('subsequent decision receives durable previous claim and CAS advances one audit chain', () => { + const db = fixture(); + let calls = 0; + const policy = (input) => { + calls += 1; + if (calls === 1) return paidPolicy(input); + assert.deepEqual(input.previousClaim, { + organizationId: 42, subscriptionId: 'sub_42', entitled: true, validUntilSec: 3000, + sourceObservationId: 102, sourceInvoiceId: 'in_42', + }); + return { action: 'retain', reason: 'paid_active_subscription', claim: input.previousClaim }; + }; + const repo = createSqliteStripeEntitlementClaimRepository(db, { deriveEntitlement: policy, nowSec: () => 1000, nowMs: () => 1000000 }); + const first = repo.applyCurrentDecision({ organizationId: 42, subscriptionId: 'sub_42' }); + const second = repo.applyCurrentDecision({ organizationId: 42, subscriptionId: 'sub_42', expectedPreviousDecisionId: first.decisionId }); + assert.equal(second.decisionId, 2); + assert.equal(second.action, 'retain'); + assert.equal(second.sourceInvoiceObservationId, 502); + assert.equal(db.prepare('SELECT previous_decision_id FROM billing_stripe_entitlement_decisions WHERE decision_id=2').get().previous_decision_id, 1); + assert.equal(db.prepare('SELECT decision_id FROM billing_stripe_entitlement_claim_heads WHERE subscription_id=?').get('sub_42').decision_id, 2); + db.close(); +}); + +test('optimistic concurrency, tenant isolation, and unknown subscriptions fail closed without appending', () => { + const db = fixture(); + const repo = createSqliteStripeEntitlementClaimRepository(db, { deriveEntitlement: paidPolicy, nowSec: () => 1000, nowMs: () => 1000 }); + const first = repo.applyCurrentDecision({ organizationId: 42, subscriptionId: 'sub_42' }); + assert.throws(() => repo.applyCurrentDecision({ organizationId: 42, subscriptionId: 'sub_42', expectedPreviousDecisionId: null }), expectCode('stripe_entitlement_claim_conflict',409)); + assert.equal(repo.getCurrentClaim({ organizationId: 77, subscriptionId: 'sub_42' }), null); + assert.throws(() => repo.applyCurrentDecision({ organizationId: 42, subscriptionId: 'sub_missing' }), expectCode('stripe_entitlement_subscription_unknown',404)); + assert.equal(db.prepare('SELECT COUNT(*) count FROM billing_stripe_entitlement_decisions').get().count, 1); + assert.equal(first.decisionId, 1); + db.close(); +}); + +test('trial/no-invoice decisions persist null Invoice provenance and false claims persist null validity', () => { + const db = fixture(); + db.exec("INSERT INTO billing_stripe_subscription_observations VALUES(103,'sub_42','trialing',0,3000,2500,NULL)"); + let phase = 0; + const policy = ({ subscription, invoice }) => { + phase += 1; + assert.equal(subscription.observationId, 103); + assert.equal(invoice, null); + if (phase === 1) return { action: 'grant', reason: 'trialing', claim: { organizationId:42, subscriptionId:'sub_42', entitled:true, validUntilSec:2500, sourceObservationId:103, sourceInvoiceId:null } }; + return { action: 'revoke', reason: 'trial_not_usable', claim: { organizationId:42, subscriptionId:'sub_42', entitled:false, validUntilSec:null, sourceObservationId:103, sourceInvoiceId:null } }; + }; + const repo = createSqliteStripeEntitlementClaimRepository(db, { deriveEntitlement: policy, nowSec: () => 1000, nowMs: () => 1000 }); + const first = repo.applyCurrentDecision({ organizationId:42, subscriptionId:'sub_42' }); + const second = repo.applyCurrentDecision({ organizationId:42, subscriptionId:'sub_42', expectedPreviousDecisionId:first.decisionId }); + assert.equal(first.sourceInvoiceId, null); + assert.equal(first.evaluatedInvoiceObservationId, null); + assert.equal(second.entitled, false); + assert.equal(second.validUntilSec, null); + db.close(); +}); + +test('malformed policy output and impossible claim provenance fail closed with no decision', () => { + const badPolicies = [ + () => null, + () => ({ action:'boom', reason:'x', claim:{} }), + () => ({ action:'grant', reason:'', claim:{} }), + () => ({ action:'grant', reason:'x', claim:null }), + () => ({ action:'grant', reason:'x', claim:{ organizationId:42, subscriptionId:'sub_42', entitled:'yes', validUntilSec:3000, sourceObservationId:102, sourceInvoiceId:'in_42' } }), + () => ({ action:'grant', reason:'x', claim:{ organizationId:42, subscriptionId:'sub_42', entitled:true, validUntilSec:null, sourceObservationId:102, sourceInvoiceId:'in_42' } }), + () => ({ action:'grant', reason:'x', claim:{ organizationId:77, subscriptionId:'sub_42', entitled:true, validUntilSec:3000, sourceObservationId:102, sourceInvoiceId:'in_42' } }), + () => ({ action:'grant', reason:'x', claim:{ organizationId:42, subscriptionId:'sub_42', entitled:true, validUntilSec:3000, sourceObservationId:999, sourceInvoiceId:'in_42' } }), + () => ({ action:'grant', reason:'x', claim:{ organizationId:42, subscriptionId:'sub_42', entitled:true, validUntilSec:3000, sourceObservationId:102, sourceInvoiceId:'in_wrong' } }), + ]; + for (const deriveEntitlement of badPolicies) { + const db = fixture(); + const repo = createSqliteStripeEntitlementClaimRepository(db, { deriveEntitlement, nowSec: () => 1000, nowMs: () => 1000 }); + assert.throws(() => repo.applyCurrentDecision({ organizationId:42, subscriptionId:'sub_42' }), expectCode('stripe_entitlement_policy_invalid',500)); + assert.equal(db.prepare('SELECT COUNT(*) count FROM billing_stripe_entitlement_decisions').get().count, 0); + db.close(); + } +}); + +test('missing current Invoice observation is passed to policy as absent evidence', () => { + const db = fixture(); + db.exec("INSERT INTO billing_stripe_invoices VALUES('in_missing_obs','sub_42')"); + db.exec("INSERT INTO billing_stripe_subscription_observations VALUES(103,'sub_42','active',0,3500,NULL,'in_missing_obs')"); + const repo = createSqliteStripeEntitlementClaimRepository(db, { + deriveEntitlement({ subscription, invoice }) { + assert.equal(subscription.observationId, 103); + assert.equal(subscription.latestInvoiceId, 'in_missing_obs'); + assert.equal(invoice, null); + return { action:'deny', reason:'paid_invoice_evidence_required', claim:{ organizationId:42, subscriptionId:'sub_42', entitled:false, validUntilSec:null, sourceObservationId:103, sourceInvoiceId:null } }; + }, + nowSec:()=>1000, nowMs:()=>1000, + }); + const claim=repo.applyCurrentDecision({organizationId:42,subscriptionId:'sub_42'}); + assert.equal(claim.evaluatedInvoiceObservationId,null); + assert.equal(claim.entitled,false); + db.close(); +}); + +test('retaining prior paid evidence preserves its exact Invoice observation when current Subscription has no Invoice', () => { + const db = fixture(); + let phase = 0; + const policy = ({ subscription, invoice, previousClaim }) => { + phase += 1; + if (phase === 1) return paidPolicy({ subscription, invoice, previousClaim, nowSec: 1000 }); + assert.equal(subscription.observationId, 103); + assert.equal(subscription.status, 'past_due'); + assert.equal(invoice, null); + assert.equal(previousClaim.sourceInvoiceId, 'in_42'); + return { action: 'retain', reason: 'past_due_no_extension', claim: previousClaim }; + }; + const repo = createSqliteStripeEntitlementClaimRepository(db, { deriveEntitlement: policy, nowSec: () => 1000, nowMs: () => 1000 }); + const first = repo.applyCurrentDecision({ organizationId:42, subscriptionId:'sub_42' }); + db.exec("INSERT INTO billing_stripe_subscription_observations VALUES(103,'sub_42','past_due',0,3000,NULL,NULL)"); + const second = repo.applyCurrentDecision({ organizationId:42, subscriptionId:'sub_42', expectedPreviousDecisionId:first.decisionId }); + assert.equal(second.sourceInvoiceId, 'in_42'); + assert.equal(second.sourceInvoiceObservationId, 502); + assert.equal(second.evaluatedInvoiceObservationId, null); + db.close(); +}); + +test('malformed authority, dependency seams, and clocks fail before durable work', () => { + assert.throws(() => createSqliteStripeEntitlementClaimRepository(null,{deriveEntitlement(){}}), TypeError); + const db = fixture(); + assert.throws(() => createSqliteStripeEntitlementClaimRepository(db), TypeError); + assert.throws(() => createSqliteStripeEntitlementClaimRepository(db,{deriveEntitlement(){},nowSec:null}), TypeError); + const repo = createSqliteStripeEntitlementClaimRepository(db, { deriveEntitlement: paidPolicy, nowSec: () => -1, nowMs: () => 0 }); + assert.throws(() => repo.applyCurrentDecision({ organizationId:42, subscriptionId:'sub_42' }), expectCode('stripe_entitlement_claim_invalid',400)); + for (const input of [ + { organizationId:0, subscriptionId:'sub_42' }, { organizationId:'42', subscriptionId:'sub_42' }, + { organizationId:42, subscriptionId:'' }, { organizationId:42, subscriptionId:'sub bad' }, + ]) assert.throws(() => repo.getCurrentClaim(input), StripeEntitlementClaimError); + const goodClockRepo = createSqliteStripeEntitlementClaimRepository(db, { deriveEntitlement: paidPolicy, nowSec: () => 1000, nowMs: () => 1000 }); + assert.throws(() => goodClockRepo.applyCurrentDecision({ organizationId:42, subscriptionId:'sub_42', expectedPreviousDecisionId:0 }), expectCode('stripe_entitlement_claim_invalid',400)); + db.close(); +}); + +test('default clocks are usable and produce bounded audit timestamps', () => { + const db = fixture(); + const repo = createSqliteStripeEntitlementClaimRepository(db, { + deriveEntitlement({ subscription, nowSec }) { + assert.ok(Number.isSafeInteger(nowSec) && nowSec > 0); + return { + action: 'deny', reason: 'default_clock_probe', + claim: { organizationId: 42, subscriptionId: 'sub_42', entitled: false, validUntilSec: null, sourceObservationId: subscription.observationId, sourceInvoiceId: null }, + }; + }, + }); + const claim = repo.applyCurrentDecision({ organizationId: 42, subscriptionId: 'sub_42' }); + assert.ok(claim.evaluatedAtSec > 0); + const recorded = db.prepare('SELECT recorded_at_ms FROM billing_stripe_entitlement_decisions WHERE decision_id=?').get(claim.decisionId); + assert.ok(Number(recorded.recorded_at_ms) > 0); + db.close(); +}); + +test('savepoint cleanup preserves causal insert failure and never releases an unconfirmed rollback', () => { + const inner = fixture(); + const commands=[]; + let failRollback=false; + let failCleanupRelease=false; + const wrapped={ + prepare(sql){ + const stmt=inner.prepare(sql); + if(sql.includes('INSERT INTO billing_stripe_entitlement_decisions(')) return {run(){throw new Error('causal decision write failure')}}; + return stmt; + }, + 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=createSqliteStripeEntitlementClaimRepository(wrapped,{deriveEntitlement:paidPolicy,nowSec:()=>1000,nowMs:()=>1000}); + failCleanupRelease=true; + assert.throws(()=>repo.applyCurrentDecision({organizationId:42,subscriptionId:'sub_42'}),/causal decision write failure/); + assert.ok(commands.some(x=>x.startsWith('ROLLBACK TO'))); + assert.ok(commands.some(x=>x.startsWith('RELEASE'))); + inner.exec('ROLLBACK'); + commands.length=0; failCleanupRelease=false; failRollback=true; + repo=createSqliteStripeEntitlementClaimRepository(wrapped,{deriveEntitlement:paidPolicy,nowSec:()=>1000,nowMs:()=>1000}); + assert.throws(()=>repo.applyCurrentDecision({organizationId:42,subscriptionId:'sub_42'}),/causal decision write failure/); + assert.ok(commands.some(x=>x.startsWith('ROLLBACK TO'))); + assert.equal(commands.filter(x=>x.startsWith('RELEASE')).length,0); + inner.close(); +}); diff --git a/tests/unit/stripe-entitlement-claim-package-contract.test.mjs b/tests/unit/stripe-entitlement-claim-package-contract.test.mjs new file mode 100644 index 00000000..0acbd845 --- /dev/null +++ b/tests/unit/stripe-entitlement-claim-package-contract.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import './stripe-entitlement-claim-head-integrity.test.mjs'; + +const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')); +const scripts = packageJson.scripts; + +assert.match( + scripts['test:coverage'], + /--include=server\/stripe_entitlement_claim_ledger\.mjs/, + 'the transactional Stripe entitlement claim ledger remains in owned production coverage', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-entitlement-claim-ledger\.test\.mjs/, + 'the transactional Stripe entitlement claim regression executes under c8', +); +assert.match( + scripts['test:unit'], + /tests\/unit\/stripe-entitlement-claim-ledger\.test\.mjs/, + 'normal unit CI executes the transactional Stripe entitlement claim regression', +); + +console.log('✓ Stripe entitlement claim package contract passed');