diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ca066d0..9234de5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Added a deterministic Stripe entitlement-policy boundary over authoritative + Subscription and Invoice evidence: paid `active` access requires an exact paid + Invoice match, `past_due` never provisions or extends access, terminal states + fail closed, stale observations cannot roll back newer claims, and one canceled + Subscription cannot erase another independent unexpired claim. This active PR + slice derives immutable candidates only and does not persist or authorize access. - 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 diff --git a/docs/doctoring/stripe-entitlement-policy.md b/docs/doctoring/stripe-entitlement-policy.md new file mode 100644 index 00000000..f5aa70f1 --- /dev/null +++ b/docs/doctoring/stripe-entitlement-policy.md @@ -0,0 +1,89 @@ +# Stripe entitlement policy + +## Status and scope + +This record describes **active stacked PR work** under issue #488. It is not protected-`develop` shipped truth and it does not by itself grant, revoke, persist, or authorize ScopeWeave access. The slice is a deterministic policy boundary over already-authoritative provider facts. Persistence, transactionality with owned entitlement state, invoice retrieval/storage, operator recovery, and runtime authorization remain follow-on integration work. + +The policy consumes the tenant-scoped current Subscription projection from the preceding stack plus independently authoritative invoice evidence when paid access is being considered. It returns an immutable transition candidate and never writes `orgs.plan`, a database row, a session, or a provider object. + +## Decision + +A Stripe Subscription status is provider lifecycle evidence, not sufficient local entitlement authority by itself. In particular, Stripe documents an `active` subscription as normally paid or inside a trial, while its lifecycle guidance also permits collection actions such as marking an invoice uncollectible without necessarily changing the underlying Subscription out of `active`. ScopeWeave therefore requires a matching authoritative `paid` Invoice fact before an `active` Subscription can create or extend paid access. + +The policy is deliberately conservative: + +| Subscription status | ScopeWeave policy candidate | +| --- | --- | +| `trialing` | Grant only until a future authoritative `trial_end`; exact/past expiry fails closed. | +| `active` | Grant/extend only when the Subscription's exact `latest_invoice` is independently observed as `paid` for the same Subscription and the current period is still future. | +| `past_due` | Never create or extend access. An already-paid, unexpired prior claim may be retained only through its existing expiry while dunning/recovery is handled elsewhere. | +| `unpaid` | Deny or revoke the affected Subscription claim. | +| `paused` | Deny or revoke the affected Subscription claim. | +| `canceled` | Deny or revoke the affected Subscription claim. | +| `incomplete` | Deny or revoke the affected Subscription claim. | +| `incomplete_expired` | Deny or revoke the affected Subscription claim. | + +`cancel_at_period_end` does not itself revoke an otherwise paid current period. The claim is already capped at `current_period_end`, so cancellation can become effective without inventing an earlier local cutoff. + +## Monotonic evidence rule + +Every claim records the authoritative Subscription `observation_id` that produced it. A Subscription observation older than the claim's source observation is ignored, preventing late or replayed evidence from rolling back a newer local decision candidate. + +Equal observation identity is not automatically ignored. That permits an initially inconclusive Subscription observation to be enriched later by independently retrieved matching Invoice evidence without manufacturing a newer Subscription read. The future persistence adapter must still perform compare-and-set/idempotent writes under one transaction so equal-source retries cannot duplicate audit or entitlement mutations. + +## Invoice evidence boundary + +Paid access requires all of the following at the same policy call: + +- bounded authoritative Subscription identity and tenant authority; +- a future current-period end; +- non-null Subscription `latest_invoice` identity; +- an authoritative Invoice with `status=paid`; +- exact Invoice identity equality with `latest_invoice`; and +- exact Invoice→Subscription identity equality. + +Missing, mismatched, open, draft, void, uncollectible, malformed, or cross-Subscription invoice evidence cannot create or extend paid access. An already-paid unexpired claim may remain unchanged when fresh paid evidence is temporarily unavailable, but it is never lengthened without new matching paid evidence. + +This slice deliberately does not infer payment from webhook arrival order or from browser/client plan state. + +## Multiple subscriptions + +Entitlement is first modeled per Subscription and only then aggregated for an organization. A canceled or unpaid Subscription cannot erase access independently supported by another unexpired Subscription claim. Organization aggregation therefore consumes exactly one current claim per Subscription identity, returns the set of currently active Subscription identities, and returns the furthest valid-until bound across them. + +Duplicate claims for the same Subscription identity are treated as upstream authority corruption and fail closed rather than being silently de-duplicated, ordered, or allowed to produce conflicting organization entitlement. The future persistence adapter should make the one-current-claim-per-Subscription invariant structural where possible; the pure policy still validates it because callers remain untrusted at this boundary. + +This is a projection contract, not a recommendation to encode all product packaging as one Boolean forever. Future feature/seat/quantity entitlements should remain normalized per product/price/grant dimension rather than overloading this aggregate. + +## Security and privacy + +Inputs are bounded and fail closed before a decision. Organization identifiers and observation identifiers must be positive safe integers. Provider identifiers are bounded structured strings. Cross-tenant previous claims and duplicate current claim identities are rejected. Output objects and Subscription-ID collections are immutable. + +The module accepts no Stripe secret, raw webhook body, browser session credential, HMAC material, or arbitrary provider response. No PII is introduced by this slice. + +## TDD and coverage evidence + +The first branch commit introduced the entitlement behavior contract while `server/stripe_entitlement_policy.mjs` was absent; focused local Node execution failed with `ERR_MODULE_NOT_FOUND` before production implementation. The production implementation followed on the same owning branch. + +The edge suite then locked fail-closed validation, exact-expiry behavior, same-observation invoice enrichment, stale-observation rejection, all supported lifecycle states, cross-tenant evidence rejection, and multi-Subscription aggregation. A later test-only regression added contradictory duplicate claims for one Subscription identity before the production aggregation guard existed; the source now rejects that ambiguity explicitly. Hosted exact-head c8 evidence remains mandatory before integration; local evidence and test registration are not substitutes for live protected checks. + +`package.json` registers all three focused policy suites in normal unit CI and canonical c8 execution, and `tests/unit/coverage-script-contract.test.mjs` prevents the module or any of those suites from silently dropping out of those paths. + +## Remaining executable work + +This pure policy does not complete #488. The next bounded slices must provide authoritative Invoice/payment retrieval and normalized persistence, transactional per-Subscription claim/audit storage, organization-entitlement aggregation persistence, out-of-order reconciliation that invokes this policy from authoritative provider reads, reversible grant/revoke application, operator recovery, API/authorization integration, migration/restart/concurrency acceptance, and incident/privacy/release evidence. + +No production route should call this module and directly mutate `orgs.plan` as a shortcut. The persistence boundary must preserve source observation/invoice identities, old/new claim state, actor/system reason, idempotency, and rollback in one auditable transaction. + +## Rollback and recovery + +Before persistence/runtime integration, rollback removes `server/stripe_entitlement_policy.mjs`, its three focused suites, coverage registrations, this doctoring record, and its Unreleased changelog line together. There is no data migration in this slice. + +After a future persistent entitlement adapter ships, rollback must not erase claim/audit history or restore webhook-arrival/provider-status overwrite behavior. Recovery must replay authoritative provider facts through a versioned policy and compare the reconstructed result against durable state before any corrective mutation. + +## References + +Stripe. (2026). *The Subscription object*. Stripe API Reference. https://docs.stripe.com/api/subscriptions/object + +Stripe. (2026). *How subscriptions work*. Stripe Documentation. https://docs.stripe.com/billing/subscriptions/overview + +Stripe. (2026). *Using webhooks with subscriptions*. Stripe Documentation. https://docs.stripe.com/billing/subscriptions/webhooks diff --git a/package.json b/package.json index c9235da7..72c50c32 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/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: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/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 --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 && 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_entitlement_policy.mjs b/server/stripe_entitlement_policy.mjs new file mode 100644 index 00000000..8a15f3ba --- /dev/null +++ b/server/stripe_entitlement_policy.mjs @@ -0,0 +1,285 @@ +const SUBSCRIPTION_STATUSES = new Set([ + 'incomplete', + 'incomplete_expired', + 'trialing', + 'active', + 'past_due', + 'canceled', + 'unpaid', + 'paused', +]); +const INVOICE_STATUSES = new Set(['draft', 'open', 'paid', 'uncollectible', 'void']); +const PROVIDER_ID_PATTERN = /^[A-Za-z0-9_:-]+$/u; +const MAX_PROVIDER_ID_LENGTH = 255; + +function positiveSafeInteger(value, name) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`${name} must be a positive safe integer`); + } + return value; +} + +function nonNegativeSafeInteger(value, name) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`${name} must be a non-negative safe integer`); + } + return value; +} + +function providerId(value, name) { + if (typeof value !== 'string' + || value.length === 0 + || value.length > MAX_PROVIDER_ID_LENGTH + || !PROVIDER_ID_PATTERN.test(value)) { + throw new TypeError(`${name} must be a bounded provider identifier`); + } + return value; +} + +function optionalProviderId(value, name) { + return value == null ? null : providerId(value, name); +} + +function normalizeSubscription(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('subscription must be an authoritative projection object'); + } + const status = value.status; + if (typeof status !== 'string' || !SUBSCRIPTION_STATUSES.has(status)) { + throw new TypeError('subscription.status must be a supported Stripe status'); + } + if (typeof value.cancelAtPeriodEnd !== 'boolean') { + throw new TypeError('subscription.cancelAtPeriodEnd must be boolean'); + } + return Object.freeze({ + observationId: positiveSafeInteger(value.observationId, 'subscription.observationId'), + organizationId: positiveSafeInteger(value.organizationId, 'subscription.organizationId'), + subscriptionId: providerId(value.subscriptionId, 'subscription.subscriptionId'), + status, + cancelAtPeriodEnd: value.cancelAtPeriodEnd, + currentPeriodEndSec: nonNegativeSafeInteger(value.currentPeriodEndSec, 'subscription.currentPeriodEndSec'), + trialEndSec: value.trialEndSec == null + ? null + : nonNegativeSafeInteger(value.trialEndSec, 'subscription.trialEndSec'), + latestInvoiceId: optionalProviderId(value.latestInvoiceId, 'subscription.latestInvoiceId'), + }); +} + +function normalizeInvoice(value) { + if (value == null) return null; + if (typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('invoice must be authoritative invoice evidence'); + } + if (typeof value.status !== 'string' || !INVOICE_STATUSES.has(value.status)) { + throw new TypeError('invoice.status must be a supported Stripe invoice status'); + } + return Object.freeze({ + invoiceId: providerId(value.invoiceId, 'invoice.invoiceId'), + subscriptionId: providerId(value.subscriptionId, 'invoice.subscriptionId'), + status: value.status, + }); +} + +function freezeClaim({ organizationId, subscriptionId, entitled, validUntilSec, sourceObservationId, sourceInvoiceId }) { + return Object.freeze({ + organizationId, + subscriptionId, + entitled, + validUntilSec, + sourceObservationId, + sourceInvoiceId, + }); +} + +function normalizePreviousClaim(value, subscription) { + if (value == null) return null; + if (typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('previousClaim must be a subscription entitlement claim'); + } + const organizationId = positiveSafeInteger(value.organizationId, 'previousClaim.organizationId'); + const subscriptionId = providerId(value.subscriptionId, 'previousClaim.subscriptionId'); + if (organizationId !== subscription.organizationId || subscriptionId !== subscription.subscriptionId) { + throw new TypeError('previousClaim must match the authoritative subscription identity'); + } + if (typeof value.entitled !== 'boolean') { + throw new TypeError('previousClaim.entitled must be boolean'); + } + const validUntilSec = value.validUntilSec == null + ? null + : nonNegativeSafeInteger(value.validUntilSec, 'previousClaim.validUntilSec'); + if (value.entitled && validUntilSec == null) { + throw new TypeError('an entitled previousClaim requires validUntilSec'); + } + return freezeClaim({ + organizationId, + subscriptionId, + entitled: value.entitled, + validUntilSec, + sourceObservationId: positiveSafeInteger(value.sourceObservationId, 'previousClaim.sourceObservationId'), + sourceInvoiceId: optionalProviderId(value.sourceInvoiceId, 'previousClaim.sourceInvoiceId'), + }); +} + +function inactiveClaim(subscription) { + return freezeClaim({ + organizationId: subscription.organizationId, + subscriptionId: subscription.subscriptionId, + entitled: false, + validUntilSec: null, + sourceObservationId: subscription.observationId, + sourceInvoiceId: null, + }); +} + +function activeClaim(subscription, validUntilSec, sourceInvoiceId) { + return freezeClaim({ + organizationId: subscription.organizationId, + subscriptionId: subscription.subscriptionId, + entitled: true, + validUntilSec, + sourceObservationId: subscription.observationId, + sourceInvoiceId, + }); +} + +function transition(action, reason, claim) { + return Object.freeze({ action, reason, claim }); +} + +function isUsablePrevious(previousClaim, nowSec) { + return Boolean(previousClaim?.entitled && previousClaim.validUntilSec > nowSec); +} + +/** + * Derive one monotonic Stripe-subscription entitlement transition from already + * authoritative subscription and invoice facts. + * + * Provider status is deliberately not treated as local authorization by itself. + * `active` requires a matching authoritative paid-invoice fact before it can + * grant or extend access. `past_due` can only retain an already paid, unexpired + * claim; it never creates or extends access. Terminal or non-provisioning + * statuses fail closed. An older observation can never roll back a claim based + * on newer accepted evidence. + * + * @param {{subscription: object, invoice?: object|null, previousClaim?: object|null, nowSec: number}} input policy input + * @returns {{action: string, reason: string, claim: Readonly}} immutable transition candidate + */ +export function deriveStripeSubscriptionEntitlement(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError('input must be an entitlement policy object'); + } + const subscription = normalizeSubscription(input.subscription); + const invoice = normalizeInvoice(input.invoice ?? null); + const nowSec = nonNegativeSafeInteger(input.nowSec, 'nowSec'); + const previousClaim = normalizePreviousClaim(input.previousClaim ?? null, subscription); + + if (previousClaim && subscription.observationId < previousClaim.sourceObservationId) { + return transition('ignore', 'stale_observation', previousClaim); + } + + if (subscription.status === 'trialing') { + if (subscription.trialEndSec != null && subscription.trialEndSec > nowSec) { + const claim = activeClaim(subscription, subscription.trialEndSec, null); + return transition(isUsablePrevious(previousClaim, nowSec) ? 'retain' : 'grant', 'trialing', claim); + } + return transition(isUsablePrevious(previousClaim, nowSec) ? 'revoke' : 'deny', 'trial_not_usable', inactiveClaim(subscription)); + } + + if (subscription.status === 'active') { + const paidInvoiceMatches = subscription.latestInvoiceId != null + && invoice != null + && invoice.status === 'paid' + && invoice.invoiceId === subscription.latestInvoiceId + && invoice.subscriptionId === subscription.subscriptionId; + + if (paidInvoiceMatches && subscription.currentPeriodEndSec > nowSec) { + const claim = activeClaim(subscription, subscription.currentPeriodEndSec, invoice.invoiceId); + if (!isUsablePrevious(previousClaim, nowSec)) { + return transition('grant', 'paid_active_subscription', claim); + } + const action = claim.validUntilSec > previousClaim.validUntilSec ? 'extend' : 'retain'; + return transition(action, 'paid_active_subscription', claim); + } + + if (isUsablePrevious(previousClaim, nowSec)) { + return transition('retain', 'paid_invoice_evidence_required', previousClaim); + } + const reason = subscription.currentPeriodEndSec <= nowSec + ? 'current_period_expired' + : 'paid_invoice_evidence_required'; + return transition('deny', reason, inactiveClaim(subscription)); + } + + if (subscription.status === 'past_due') { + if (isUsablePrevious(previousClaim, nowSec)) { + return transition('retain', 'past_due_no_extension', previousClaim); + } + return transition(previousClaim?.entitled ? 'revoke' : 'deny', 'past_due_no_active_claim', inactiveClaim(subscription)); + } + + return transition( + isUsablePrevious(previousClaim, nowSec) ? 'revoke' : 'deny', + `subscription_${subscription.status}`, + inactiveClaim(subscription), + ); +} + +/** + * Aggregate current per-subscription claims into one organization entitlement. + * A revoked or canceled subscription cannot erase another independent paid or + * trial claim for the same organization. Duplicate subscription identities are + * invalid because aggregation requires exactly one current claim per Subscription. + * + * @param {{organizationId: number, claims: object[], nowSec: number}} input aggregation input + * @returns {Readonly<{organizationId: number, entitled: boolean, validUntilSec: number|null, subscriptionIds: readonly string[]}>} organization entitlement view + */ +export function deriveOrganizationStripeEntitlement(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError('input must be an organization entitlement object'); + } + const organizationId = positiveSafeInteger(input.organizationId, 'organizationId'); + const nowSec = nonNegativeSafeInteger(input.nowSec, 'nowSec'); + if (!Array.isArray(input.claims)) { + throw new TypeError('claims must be an array'); + } + + const activeClaims = []; + const seenSubscriptionIds = new Set(); + for (const value of input.claims) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('each claim must be an entitlement claim object'); + } + if (positiveSafeInteger(value.organizationId, 'claim.organizationId') !== organizationId) { + throw new TypeError('claim organization must match aggregation authority'); + } + const subscriptionId = providerId(value.subscriptionId, 'claim.subscriptionId'); + if (seenSubscriptionIds.has(subscriptionId)) { + throw new TypeError('duplicate subscription claim identity'); + } + seenSubscriptionIds.add(subscriptionId); + if (typeof value.entitled !== 'boolean') { + throw new TypeError('claim.entitled must be boolean'); + } + const validUntilSec = value.validUntilSec == null + ? null + : nonNegativeSafeInteger(value.validUntilSec, 'claim.validUntilSec'); + if (value.entitled && validUntilSec == null) { + throw new TypeError('an entitled claim requires validUntilSec'); + } + if (value.entitled && validUntilSec > nowSec) { + activeClaims.push({ subscriptionId, validUntilSec }); + } + } + + activeClaims.sort((left, right) => left.subscriptionId.localeCompare(right.subscriptionId)); + const subscriptionIds = Object.freeze(activeClaims.map(({ subscriptionId }) => subscriptionId)); + const validUntilSec = activeClaims.length === 0 + ? null + : Math.max(...activeClaims.map((claim) => claim.validUntilSec)); + return Object.freeze({ + organizationId, + entitled: activeClaims.length > 0, + validUntilSec, + subscriptionIds, + }); +} diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index a1e3c2e4..b09ecf3e 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -74,6 +74,11 @@ assert.match( /--include=server\/stripe_subscription_current_projection\.mjs/, 'the current authoritative Stripe subscription projection is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/stripe_entitlement_policy\.mjs/, + 'the monotonic Stripe entitlement policy is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, @@ -159,6 +164,36 @@ assert.match( /tests\/unit\/stripe-subscription-current-projection\.test\.mjs/, 'normal unit CI executes the current authoritative Stripe subscription projection regression', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-entitlement-policy\.test\.mjs/, + 'the Stripe entitlement behavior regression executes under c8', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-entitlement-policy-edge\.test\.mjs/, + 'the Stripe entitlement edge regression executes under c8', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-entitlement-policy-duplicate-claims\.test\.mjs/, + 'duplicate subscription-claim corruption is exercised under c8', +); +assert.match( + scripts['test:unit'], + /tests\/unit\/stripe-entitlement-policy\.test\.mjs/, + 'normal unit CI executes the Stripe entitlement behavior regression', +); +assert.match( + scripts['test:unit'], + /tests\/unit\/stripe-entitlement-policy-edge\.test\.mjs/, + 'normal unit CI executes the Stripe entitlement edge regression', +); +assert.match( + scripts['test:unit'], + /tests\/unit\/stripe-entitlement-policy-duplicate-claims\.test\.mjs/, + 'normal unit CI executes duplicate subscription-claim corruption regression', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, diff --git a/tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs b/tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs new file mode 100644 index 00000000..5df9f04d --- /dev/null +++ b/tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { deriveOrganizationStripeEntitlement } from '../../server/stripe_entitlement_policy.mjs'; + +const NOW = 1_787_000_000; + +function claim(overrides = {}) { + return { + organizationId: 42, + subscriptionId: 'sub_scopeweave_42', + entitled: true, + validUntilSec: NOW + 3_600, + ...overrides, + }; +} + +test('organization aggregation rejects duplicate subscription identities', () => { + for (const claims of [ + [claim(), claim({ validUntilSec: NOW + 7_200 })], + [claim({ entitled: false, validUntilSec: null }), claim()], + ]) { + assert.throws( + () => deriveOrganizationStripeEntitlement({ organizationId: 42, claims, nowSec: NOW }), + /duplicate subscription/i, + ); + } +}); diff --git a/tests/unit/stripe-entitlement-policy-edge.test.mjs b/tests/unit/stripe-entitlement-policy-edge.test.mjs new file mode 100644 index 00000000..2c0f1c5e --- /dev/null +++ b/tests/unit/stripe-entitlement-policy-edge.test.mjs @@ -0,0 +1,369 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + deriveOrganizationStripeEntitlement, + deriveStripeSubscriptionEntitlement, +} from '../../server/stripe_entitlement_policy.mjs'; + +const NOW = 1_787_000_000; + +function subscription(overrides = {}) { + return { + observationId: 10, + organizationId: 42, + subscriptionId: 'sub_scopeweave_42', + status: 'active', + cancelAtPeriodEnd: false, + currentPeriodEndSec: NOW + 2_592_000, + trialEndSec: null, + latestInvoiceId: 'in_scopeweave_paid', + ...overrides, + }; +} + +function paidInvoice(overrides = {}) { + return { + invoiceId: 'in_scopeweave_paid', + subscriptionId: 'sub_scopeweave_42', + status: 'paid', + ...overrides, + }; +} + +function previousClaim(overrides = {}) { + return { + organizationId: 42, + subscriptionId: 'sub_scopeweave_42', + entitled: true, + validUntilSec: NOW + 86_400, + sourceObservationId: 9, + sourceInvoiceId: 'in_scopeweave_previous', + ...overrides, + }; +} + +test('active status at an expired provider period fails closed even with a paid invoice', () => { + const denied = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ currentPeriodEndSec: NOW }), + invoice: paidInvoice(), + nowSec: NOW, + }); + assert.equal(denied.action, 'deny'); + assert.equal(denied.reason, 'current_period_expired'); + assert.equal(denied.claim.entitled, false); +}); + +test('trialing grants only to a future authoritative trial end and exact expiry denies', () => { + const trial = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ + status: 'trialing', + trialEndSec: NOW + 3_600, + latestInvoiceId: null, + }), + nowSec: NOW, + }); + assert.equal(trial.action, 'grant'); + assert.equal(trial.reason, 'trialing'); + assert.equal(trial.claim.validUntilSec, NOW + 3_600); + assert.equal(trial.claim.sourceInvoiceId, null); + + for (const trialEndSec of [null, NOW, NOW - 1]) { + const denied = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status: 'trialing', trialEndSec, latestInvoiceId: null }), + previousClaim: previousClaim(), + nowSec: NOW, + }); + assert.equal(denied.action, 'revoke'); + assert.equal(denied.reason, 'trial_not_usable'); + } +}); + +test('past_due retains a previously paid unexpired claim but never provisions or extends it', () => { + const retained = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status: 'past_due' }), + invoice: paidInvoice(), + previousClaim: previousClaim(), + nowSec: NOW, + }); + assert.equal(retained.action, 'retain'); + assert.equal(retained.reason, 'past_due_no_extension'); + assert.deepEqual(retained.claim, previousClaim()); + + const denied = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status: 'past_due' }), + invoice: paidInvoice(), + nowSec: NOW, + }); + assert.equal(denied.action, 'deny'); + assert.equal(denied.reason, 'past_due_no_active_claim'); + + const revoked = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status: 'past_due', observationId: 11 }), + previousClaim: previousClaim({ validUntilSec: NOW, entitled: true }), + nowSec: NOW, + }); + assert.equal(revoked.action, 'revoke'); + assert.equal(revoked.claim.entitled, false); +}); + +test('non-provisioning and terminal subscription statuses revoke an unexpired claim', () => { + for (const status of ['incomplete', 'incomplete_expired', 'unpaid', 'paused', 'canceled']) { + const result = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status }), + previousClaim: previousClaim(), + nowSec: NOW, + }); + assert.equal(result.action, 'revoke'); + assert.equal(result.reason, `subscription_${status}`); + assert.equal(result.claim.entitled, false); + } +}); + +test('an older observation cannot roll back a newer accepted entitlement claim', () => { + const previous = previousClaim({ + sourceObservationId: 20, + validUntilSec: NOW + 2_592_000, + sourceInvoiceId: 'in_newer', + }); + const result = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ observationId: 19, status: 'canceled' }), + previousClaim: previous, + nowSec: NOW, + }); + assert.equal(result.action, 'ignore'); + assert.equal(result.reason, 'stale_observation'); + assert.deepEqual(result.claim, previous); +}); + +test('same observation can be safely enriched later by matching paid invoice evidence', () => { + const inconclusive = deriveStripeSubscriptionEntitlement({ + subscription: subscription(), + nowSec: NOW, + }); + assert.equal(inconclusive.claim.sourceObservationId, 10); + assert.equal(inconclusive.claim.entitled, false); + + const granted = deriveStripeSubscriptionEntitlement({ + subscription: subscription(), + invoice: paidInvoice(), + previousClaim: inconclusive.claim, + nowSec: NOW, + }); + assert.equal(granted.action, 'grant'); + assert.equal(granted.claim.sourceObservationId, 10); + assert.equal(granted.claim.entitled, true); +}); + +test('organization aggregation preserves access when another subscription is canceled or expired', () => { + const result = deriveOrganizationStripeEntitlement({ + organizationId: 42, + nowSec: NOW, + claims: [ + { organizationId: 42, subscriptionId: 'sub_zeta', entitled: false, validUntilSec: null }, + { organizationId: 42, subscriptionId: 'sub_alpha', entitled: true, validUntilSec: NOW + 3_600 }, + { organizationId: 42, subscriptionId: 'sub_expired', entitled: true, validUntilSec: NOW }, + { organizationId: 42, subscriptionId: 'sub_beta', entitled: true, validUntilSec: NOW + 7_200 }, + ], + }); + assert.deepEqual(result, { + organizationId: 42, + entitled: true, + validUntilSec: NOW + 7_200, + subscriptionIds: ['sub_alpha', 'sub_beta'], + }); + assert.equal(Object.isFrozen(result), true); + assert.equal(Object.isFrozen(result.subscriptionIds), true); + + assert.deepEqual(deriveOrganizationStripeEntitlement({ organizationId: 42, nowSec: NOW, claims: [] }), { + organizationId: 42, + entitled: false, + validUntilSec: null, + subscriptionIds: [], + }); +}); + +test('policy rejects malformed or cross-tenant evidence before making an entitlement decision', () => { + for (const bad of [null, [], 'x']) { + assert.throws(() => deriveStripeSubscriptionEntitlement(bad), TypeError); + } + for (const status of ['unknown', '', null]) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ subscription: subscription({ status }), nowSec: NOW }), TypeError); + } + assert.throws(() => deriveStripeSubscriptionEntitlement({ + subscription: subscription({ cancelAtPeriodEnd: 'false' }), + nowSec: NOW, + }), TypeError); + assert.throws(() => deriveStripeSubscriptionEntitlement({ + subscription: subscription(), + invoice: paidInvoice({ status: 'unknown' }), + nowSec: NOW, + }), TypeError); + assert.throws(() => deriveStripeSubscriptionEntitlement({ + subscription: subscription(), + previousClaim: previousClaim({ organizationId: 84 }), + nowSec: NOW, + }), TypeError); + assert.throws(() => deriveOrganizationStripeEntitlement({ + organizationId: 42, + nowSec: NOW, + claims: [{ organizationId: 84, subscriptionId: 'sub_other', entitled: false, validUntilSec: null }], + }), TypeError); +}); + +test('policy edge validation covers every bounded evidence field and aggregation invariant', () => { + const baseInput = { subscription: subscription(), nowSec: NOW }; + for (const badSubscription of [null, [], 'x']) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ ...baseInput, subscription: badSubscription }), TypeError); + } + + for (const observationId of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + subscription: subscription({ observationId }), + }), TypeError); + } + for (const organizationId of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + subscription: subscription({ organizationId }), + }), TypeError); + } + for (const subscriptionId of [null, '', ' ', 'x'.repeat(256)]) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + subscription: subscription({ subscriptionId }), + }), TypeError); + } + for (const currentPeriodEndSec of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + subscription: subscription({ currentPeriodEndSec }), + }), TypeError); + } + for (const trialEndSec of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + subscription: subscription({ status: 'trialing', trialEndSec, latestInvoiceId: null }), + }), TypeError); + } + for (const latestInvoiceId of ['', ' ', 'x'.repeat(256)]) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + subscription: subscription({ latestInvoiceId }), + }), TypeError); + } + for (const nowSec of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ ...baseInput, nowSec }), TypeError); + } + + for (const invoice of [[], 'x']) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ ...baseInput, invoice }), TypeError); + } + for (const invoiceId of [null, '', ' ', 'x'.repeat(256)]) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + invoice: paidInvoice({ invoiceId }), + }), TypeError); + } + for (const subscriptionId of [null, '', ' ', 'x'.repeat(256)]) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + invoice: paidInvoice({ subscriptionId }), + }), TypeError); + } + + for (const previousClaimValue of [[], 'x']) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ ...baseInput, previousClaim: previousClaimValue }), TypeError); + } + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + previousClaim: previousClaim({ subscriptionId: 'sub_other' }), + }), TypeError); + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + previousClaim: previousClaim({ entitled: 'true' }), + }), TypeError); + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + previousClaim: previousClaim({ entitled: true, validUntilSec: null }), + }), TypeError); + for (const sourceObservationId of [0, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + previousClaim: previousClaim({ sourceObservationId }), + }), TypeError); + } + for (const sourceInvoiceId of ['', ' ', 'x'.repeat(256)]) { + assert.throws(() => deriveStripeSubscriptionEntitlement({ + ...baseInput, + previousClaim: previousClaim({ sourceInvoiceId }), + }), TypeError); + } + + const inactivePrevious = previousClaim({ entitled: false, validUntilSec: null, sourceInvoiceId: null }); + assert.equal(deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status: 'canceled' }), + previousClaim: inactivePrevious, + nowSec: NOW, + }).action, 'deny'); + + assert.equal(deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status: 'trialing', trialEndSec: NOW + 3_600, latestInvoiceId: null }), + previousClaim: previousClaim({ validUntilSec: NOW + 7_200 }), + nowSec: NOW, + }).action, 'retain'); + + assert.equal(deriveStripeSubscriptionEntitlement({ + subscription: subscription({ currentPeriodEndSec: NOW + 3_600 }), + invoice: paidInvoice(), + previousClaim: previousClaim({ validUntilSec: NOW + 7_200 }), + nowSec: NOW, + }).action, 'retain'); + + assert.equal(deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status: 'active' }), + previousClaim: previousClaim({ validUntilSec: NOW }), + nowSec: NOW, + }).action, 'deny'); + + for (const input of [null, [], 'x']) { + assert.throws(() => deriveOrganizationStripeEntitlement(input), TypeError); + } + assert.throws(() => deriveOrganizationStripeEntitlement({ organizationId: 42, nowSec: NOW, claims: null }), TypeError); + for (const organizationId of [0, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => deriveOrganizationStripeEntitlement({ organizationId, nowSec: NOW, claims: [] }), TypeError); + } + for (const nowSec of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => deriveOrganizationStripeEntitlement({ organizationId: 42, nowSec, claims: [] }), TypeError); + } + for (const claim of [null, [], 'x']) { + assert.throws(() => deriveOrganizationStripeEntitlement({ organizationId: 42, nowSec: NOW, claims: [claim] }), TypeError); + } + for (const badClaim of [ + { organizationId: 42, subscriptionId: '', entitled: false, validUntilSec: null }, + { organizationId: 42, subscriptionId: 'sub_ok', entitled: 'true', validUntilSec: null }, + { organizationId: 42, subscriptionId: 'sub_ok', entitled: true, validUntilSec: null }, + { organizationId: 42, subscriptionId: 'sub_ok', entitled: false, validUntilSec: -1 }, + ]) { + assert.throws(() => deriveOrganizationStripeEntitlement({ organizationId: 42, nowSec: NOW, claims: [badClaim] }), TypeError); + } +}); + +test('active subscription without a latest invoice identifier remains non-provisioning', () => { + const denied = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ latestInvoiceId: null }), + invoice: paidInvoice(), + nowSec: NOW, + }); + assert.equal(denied.action, 'deny'); + assert.equal(denied.reason, 'paid_invoice_evidence_required'); +}); + +test('expired trial without prior access is denied rather than revoked', () => { + const denied = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status: 'trialing', trialEndSec: NOW, latestInvoiceId: null }), + nowSec: NOW, + }); + assert.equal(denied.action, 'deny'); + assert.equal(denied.reason, 'trial_not_usable'); +}); diff --git a/tests/unit/stripe-entitlement-policy.test.mjs b/tests/unit/stripe-entitlement-policy.test.mjs new file mode 100644 index 00000000..8cf707e7 --- /dev/null +++ b/tests/unit/stripe-entitlement-policy.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + deriveOrganizationStripeEntitlement, + deriveStripeSubscriptionEntitlement, +} from '../../server/stripe_entitlement_policy.mjs'; + +const NOW = 1_787_000_000; + +function subscription(overrides = {}) { + return { + observationId: 10, + organizationId: 42, + subscriptionId: 'sub_scopeweave_42', + status: 'active', + cancelAtPeriodEnd: false, + currentPeriodEndSec: NOW + 2_592_000, + trialEndSec: null, + latestInvoiceId: 'in_scopeweave_paid', + ...overrides, + }; +} + +function paidInvoice(overrides = {}) { + return { + invoiceId: 'in_scopeweave_paid', + subscriptionId: 'sub_scopeweave_42', + status: 'paid', + ...overrides, + }; +} + +function previousClaim(overrides = {}) { + return { + organizationId: 42, + subscriptionId: 'sub_scopeweave_42', + entitled: true, + validUntilSec: NOW + 86_400, + sourceObservationId: 9, + sourceInvoiceId: 'in_scopeweave_previous', + ...overrides, + }; +} + +test('paid active subscription grants or extends only through its authoritative current period', () => { + const granted = deriveStripeSubscriptionEntitlement({ + subscription: subscription(), + invoice: paidInvoice(), + nowSec: NOW, + }); + assert.deepEqual(granted, { + action: 'grant', + reason: 'paid_active_subscription', + claim: { + organizationId: 42, + subscriptionId: 'sub_scopeweave_42', + entitled: true, + validUntilSec: NOW + 2_592_000, + sourceObservationId: 10, + sourceInvoiceId: 'in_scopeweave_paid', + }, + }); + + const extended = deriveStripeSubscriptionEntitlement({ + subscription: subscription(), + invoice: paidInvoice(), + previousClaim: previousClaim(), + nowSec: NOW, + }); + assert.equal(extended.action, 'extend'); +}); + +test('active status without exact paid invoice evidence never creates or extends access', () => { + for (const invoice of [ + null, + paidInvoice({ status: 'open' }), + paidInvoice({ invoiceId: 'in_other' }), + paidInvoice({ subscriptionId: 'sub_other' }), + ]) { + const denied = deriveStripeSubscriptionEntitlement({ + subscription: subscription(), + invoice, + nowSec: NOW, + }); + assert.equal(denied.action, 'deny'); + assert.equal(denied.reason, 'paid_invoice_evidence_required'); + assert.equal(denied.claim.entitled, false); + + const retained = deriveStripeSubscriptionEntitlement({ + subscription: subscription(), + invoice, + previousClaim: previousClaim(), + nowSec: NOW, + }); + assert.equal(retained.action, 'retain'); + assert.equal(retained.claim.validUntilSec, NOW + 86_400); + } +}); + +test('trialing and past_due never manufacture paid renewal authority', () => { + const trial = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status: 'trialing', trialEndSec: NOW + 3_600, latestInvoiceId: null }), + nowSec: NOW, + }); + assert.equal(trial.action, 'grant'); + assert.equal(trial.claim.validUntilSec, NOW + 3_600); + + const retained = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status: 'past_due' }), + invoice: paidInvoice(), + previousClaim: previousClaim(), + nowSec: NOW, + }); + assert.equal(retained.action, 'retain'); + assert.equal(retained.reason, 'past_due_no_extension'); + assert.equal(retained.claim.validUntilSec, NOW + 86_400); + + const denied = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status: 'past_due' }), + invoice: paidInvoice(), + nowSec: NOW, + }); + assert.equal(denied.action, 'deny'); +}); + +test('terminal or non-provisioning states revoke one subscription claim and stale observations cannot roll back newer evidence', () => { + for (const status of ['incomplete', 'incomplete_expired', 'unpaid', 'paused', 'canceled']) { + const result = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ status }), + previousClaim: previousClaim(), + nowSec: NOW, + }); + assert.equal(result.action, 'revoke'); + assert.equal(result.claim.entitled, false); + } + + const newer = previousClaim({ sourceObservationId: 20, validUntilSec: NOW + 2_592_000, sourceInvoiceId: 'in_newer' }); + const stale = deriveStripeSubscriptionEntitlement({ + subscription: subscription({ observationId: 19, status: 'canceled' }), + previousClaim: newer, + nowSec: NOW, + }); + assert.equal(stale.action, 'ignore'); + assert.deepEqual(stale.claim, newer); +}); + +test('organization aggregation preserves access when another subscription is canceled', () => { + const result = deriveOrganizationStripeEntitlement({ + organizationId: 42, + nowSec: NOW, + claims: [ + { organizationId: 42, subscriptionId: 'sub_zeta', entitled: false, validUntilSec: null }, + { organizationId: 42, subscriptionId: 'sub_alpha', entitled: true, validUntilSec: NOW + 3_600 }, + { organizationId: 42, subscriptionId: 'sub_beta', entitled: true, validUntilSec: NOW + 7_200 }, + ], + }); + assert.deepEqual(result, { + organizationId: 42, + entitled: true, + validUntilSec: NOW + 7_200, + subscriptionIds: ['sub_alpha', 'sub_beta'], + }); + assert.equal(Object.isFrozen(result), true); + assert.equal(Object.isFrozen(result.subscriptionIds), true); +});