diff --git a/CHANGELOG.md b/CHANGELOG.md index 353e6be4..6612d0ca 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 bounded authoritative Stripe billing reconciliation that treats webhook + event identity as provenance only, re-reads current Subscription and Invoice + state before appending evidence, and evaluates durable entitlement claims only + after current provider evidence is persisted; one optimistic claim-head race + retries claim application only, while a second conflict and non-conflict causal + failures remain bounded and fail closed. - Applied current durable Stripe entitlement claims to project/member limit authorization without mutating `orgs.plan`: one unexpired tenant-owned claim unlocks Pro limits, expiry or a revoke head re-locks them, foreign claims are diff --git a/docs/doctoring/stripe-billing-authoritative-reconciliation.md b/docs/doctoring/stripe-billing-authoritative-reconciliation.md new file mode 100644 index 00000000..f5102458 --- /dev/null +++ b/docs/doctoring/stripe-billing-authoritative-reconciliation.md @@ -0,0 +1,67 @@ +# Authoritative Stripe billing reconciliation + +Status: **active stacked PR evidence; not protected-`develop` shipped truth**. + +## Buyer problem + +A verified webhook delivery proves that Stripe sent particular bytes, but it does not prove that the embedded Subscription or Invoice snapshot is still current when ScopeWeave processes the delivery. Stripe explicitly documents that webhook events are not guaranteed to arrive in generation order and recommends retrieving missing/current objects through the API when ordering differs. ScopeWeave therefore needs one service boundary that treats delivery identity as provenance and re-reads current provider authority before changing durable entitlement evidence. + +## Decision + +`server/stripe_billing_reconciliation.mjs` composes the already-separated provider and persistence boundaries without granting either the webhook payload or caller-selected evidence IDs new authority. + +For one tenant-owned Subscription the service performs this bounded sequence: + +1. validate the local organization/Subscription authority and all required ports before provider I/O; +2. retrieve the current Subscription through the authoritative bounded provider reader; +3. append the accepted Subscription observation, optionally linked to a previously verified webhook event only as provenance; +4. when that Subscription names a latest Invoice, retrieve the current Invoice using the exact organization, Customer, Subscription, and Invoice identities from accepted provider authority; +5. append the Invoice observation against the just-recorded Subscription observation; and +6. ask the durable claim repository to evaluate its own latest accepted evidence. + +The service returns only organization/Subscription identity and the resulting Subscription-observation, optional Invoice-observation, and claim-decision identifiers. It never returns the Stripe secret, raw provider response, webhook body, idempotency key, or other retry authority. + +## Ordering and convergence contract + +Webhook event IDs never choose which Subscription/Invoice state wins. Every invocation reads the current provider objects. An older event delivered after a newer event therefore causes another current-state read rather than overwriting entitlement from its historical payload. + +Concurrent reconciliation can legitimately race at the optimistic claim-head compare-and-swap boundary. The service handles exactly one such race by refreshing the durable current decision ID and retrying **only claim application**. It does not repeat the provider GETs or append another pair of provider observations during that retry. A second conflict is propagated for bounded job/operator retry instead of entering an unbounded loop. + +This composition complements, rather than replaces, Stripe request idempotency. Stripe documents idempotency keys as protection for safely retrying create/update API operations; this service performs authoritative GETs and local append/evaluate work, while the existing Checkout-attempt boundary remains the owner of Stripe POST retry identity. + +## Failure and authority boundaries + +- malformed local organization, Subscription, event provenance, or dependency ports fail before provider I/O; +- a provider seam that returns tenant/Subscription/Customer/Invoice identity inconsistent with the requested authority fails closed before local persistence; +- provider errors remain the sanitized errors owned by the Subscription/Invoice provider modules; +- persistence and policy errors remain causal and are not translated into false success; +- only `stripe_entitlement_claim_conflict` receives the single bounded retry described above; +- no `orgs.plan`, session, membership, RBAC, capability, or browser-selected evidence state is mutated here. + +The service is intentionally not yet a webhook background queue or public/operator HTTP endpoint. A later slice must decide invocation/queue ownership, retry scheduling, dead-letter/operator recovery, and audit exposure without making the webhook request lifetime depend on remote provider latency. + +## Verification traceability + +`tests/unit/stripe-billing-authoritative-reconciliation.test.mjs` exercises: + +- current Subscription → durable Subscription evidence → current Invoice → durable Invoice evidence → claim-decision ordering; +- newer-then-older event provenance while each trigger re-reads current provider state; +- Subscription snapshots without a latest Invoice; +- one optimistic claim-head race with claim-only retry; +- a second conflict remaining bounded; +- non-conflict causal-error preservation; and +- malformed local authority/ports failing before provider I/O. + +The module and focused regression are explicitly registered in `test:unit`, `test:coverage:cases`, and the c8 owned-production include set. Hosted exact-head evidence remains authoritative for integration. + +## Rollback + +Before this stacked slice is integrated, rollback is branch deletion. After integration, rollback removes the reconciliation service, focused tests, coverage registrations, doctoring record, and matching Unreleased changelog entry together. The service introduces no schema migration, so rollback does not require destructive data surgery; observations written by callers remain valid append-only provider evidence owned by their existing schemas. + +## References + +Stripe. (n.d.). *Receive Stripe events in your webhook endpoint*. Stripe Documentation. Retrieved August 20, 2026, from https://docs.stripe.com/webhooks + +Stripe. (n.d.). *Using webhooks with subscriptions*. Stripe Documentation. Retrieved August 20, 2026, from https://docs.stripe.com/billing/subscriptions/webhooks + +Stripe. (n.d.). *Idempotent requests*. Stripe API Reference. Retrieved August 20, 2026, from https://docs.stripe.com/api/idempotent_requests diff --git a/package.json b/package.json index a5373b4d..927feda6 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/billing-effective-plan-status.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.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/billing_status_response.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.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: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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.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/stripe-billing-authoritative-reconciliation.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/billing_status_response.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 --include=server/stripe_billing_reconciliation.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.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/stripe-billing-authoritative-reconciliation.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_billing_reconciliation.mjs b/server/stripe_billing_reconciliation.mjs new file mode 100644 index 00000000..09577d57 --- /dev/null +++ b/server/stripe_billing_reconciliation.mjs @@ -0,0 +1,264 @@ +import { fetchStripeInvoiceAuthoritative } from './stripe_invoice_provider.mjs'; +import { fetchStripeSubscriptionAuthoritative } from './stripe_subscription_provider.mjs'; + +const MAX_PROVIDER_ID_LENGTH = 255; +const SUBSCRIPTION_ID_PATTERN = /^sub_[A-Za-z0-9_]+$/u; +const INVOICE_ID_PATTERN = /^in_[A-Za-z0-9_]+$/u; +const CUSTOMER_ID_PATTERN = /^cus_[A-Za-z0-9_]+$/u; +const EVENT_ID_PATTERN = /^[A-Za-z0-9_:-]+$/u; +const CLAIM_CONFLICT_CODE = 'stripe_entitlement_claim_conflict'; + +function positiveOrganizationId(value) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError('organizationId must be a positive safe integer'); + } + return value; +} + +function boundedIdentifier(value, name, pattern) { + if (typeof value !== 'string' + || value.length === 0 + || value.length > MAX_PROVIDER_ID_LENGTH + || !pattern.test(value)) { + throw new TypeError(`${name} must be a bounded provider identifier`); + } + return value; +} + +function optionalEventId(value) { + if (value == null) return null; + return boundedIdentifier(value, 'sourceEventId', EVENT_ID_PATTERN); +} + +function requiredMethod(owner, methodName, ownerName) { + if (!owner || typeof owner !== 'object' || typeof owner[methodName] !== 'function') { + throw new TypeError(`${ownerName} must provide ${methodName}()`); + } +} + +function objectResult(value, name) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${name} must return a result object`); + } + return value; +} + +function positiveResultId(value, name) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`${name} must be a positive safe integer`); + } + return value; +} + +function assertSubscriptionSnapshot(snapshot, authority) { + if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) { + throw new TypeError('fetchSubscription must return a subscription snapshot'); + } + if (snapshot.organizationId !== authority.organizationId + || snapshot.subscriptionId !== authority.subscriptionId) { + throw new TypeError('subscription snapshot does not match local authority'); + } + boundedIdentifier(snapshot.customerId, 'snapshot.customerId', CUSTOMER_ID_PATTERN); + if (snapshot.latestInvoiceId != null) { + boundedIdentifier(snapshot.latestInvoiceId, 'snapshot.latestInvoiceId', INVOICE_ID_PATTERN); + } +} + +function assertInvoiceSnapshot(snapshot, authority) { + if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) { + throw new TypeError('fetchInvoice must return an invoice snapshot'); + } + if (snapshot.organizationId !== authority.organizationId + || snapshot.subscriptionId !== authority.subscriptionId + || snapshot.customerId !== authority.customerId + || snapshot.invoiceId !== authority.invoiceId) { + throw new TypeError('invoice snapshot does not match accepted subscription authority'); + } +} + +function subscriptionEvidenceId(evidence, authority) { + const result = objectResult(evidence, 'subscriptionRepository.recordAuthoritativeObservation()'); + const observationId = positiveResultId(result.observationId, 'subscriptionEvidence.observationId'); + if (result.subscriptionId !== authority.subscriptionId) { + throw new TypeError('subscription evidence does not match accepted Subscription authority'); + } + return observationId; +} + +function invoiceEvidenceId(evidence, authority, sourceSubscriptionObservationId) { + const result = objectResult(evidence, 'invoiceRepository.recordAuthoritativeObservation()'); + const observationId = positiveResultId(result.observationId, 'invoiceEvidence.observationId'); + if (result.invoiceId !== authority.invoiceId + || result.sourceSubscriptionObservationId !== sourceSubscriptionObservationId) { + throw new TypeError('invoice evidence does not match accepted Invoice authority'); + } + return observationId; +} + +function providerDependencies({ secretKey, fetchImpl, timeoutSignalFactory }) { + const dependencies = {}; + if (secretKey !== undefined) dependencies.secretKey = secretKey; + if (fetchImpl !== undefined) dependencies.fetchImpl = fetchImpl; + if (timeoutSignalFactory !== undefined) dependencies.timeoutSignalFactory = timeoutSignalFactory; + return dependencies; +} + +function claimDecisionId(claim, authority, sourceName) { + const result = objectResult(claim, sourceName); + const decisionId = positiveResultId(result.decisionId, 'claim.decisionId'); + if (Object.hasOwn(result, 'organizationId') && result.organizationId !== authority.organizationId) { + throw new TypeError(`${sourceName} returned a claim for another organization`); + } + if (Object.hasOwn(result, 'subscriptionId') && result.subscriptionId !== authority.subscriptionId) { + throw new TypeError(`${sourceName} returned a claim for another Subscription`); + } + return decisionId; +} + +function currentDecisionId(claim, authority) { + if (claim == null) return null; + return claimDecisionId(claim, authority, 'claimRepository.getCurrentClaim()'); +} + +function isClaimConflict(error) { + return error != null && typeof error === 'object' && error.code === CLAIM_CONFLICT_CODE; +} + +function applyClaimWithOneConflictRetry(claimRepository, authority) { + const initialClaim = claimRepository.getCurrentClaim(authority); + const initialDecisionId = currentDecisionId(initialClaim, authority); + + try { + return claimRepository.applyCurrentDecision({ + ...authority, + expectedPreviousDecisionId: initialDecisionId, + }); + } catch (error) { + if (!isClaimConflict(error)) throw error; + } + + const refreshedClaim = claimRepository.getCurrentClaim(authority); + const refreshedDecisionId = currentDecisionId(refreshedClaim, authority); + return claimRepository.applyCurrentDecision({ + ...authority, + expectedPreviousDecisionId: refreshedDecisionId, + }); +} + +/** + * Reconcile one tenant-owned Stripe Subscription from current provider authority. + * + * Webhook delivery order is deliberately not an ordering signal. Every invocation + * retrieves the current Subscription directly from Stripe, appends that accepted + * provider observation, optionally retrieves and appends the Subscription's current + * Invoice, and only then asks the durable claim repository to evaluate the latest + * accepted evidence. A webhook event identifier may be retained as provenance, but + * it never selects provider state or entitlement authority. + * + * Repository return values are treated as untrusted port data. Evidence identities + * must remain bound to the accepted Subscription/Invoice authority before their IDs + * enter the reconciliation receipt, and identity-bearing claim results must match the + * requested tenant and Subscription. This prevents a buggy adapter from substituting + * another tenant's durable evidence even after provider validation succeeded. + * + * One optimistic claim-head conflict is expected under concurrent reconciliation. + * In that case only claim application is retried after refreshing the current durable + * decision; provider reads and accepted observations are not repeated. A second + * conflict, provider failure, persistence failure, or policy failure remains causal + * and propagates to the caller for bounded operator/job retry. + * + * The returned value contains evidence and decision identifiers only. Provider + * payloads, secrets, retry authority, `orgs.plan`, sessions, and RBAC state never + * cross this boundary. + * + * @param {object} input reconciliation authority and dependency ports + * @param {number} input.organizationId positive ScopeWeave organization ID + * @param {string} input.subscriptionId exact tenant-owned Stripe Subscription ID + * @param {string|null} [input.sourceEventId=null] optional verified event provenance + * @param {Function} [input.fetchSubscription] authoritative Subscription read port + * @param {Function} [input.fetchInvoice] authoritative Invoice read port + * @param {object} input.subscriptionRepository append-only Subscription evidence port + * @param {object} input.invoiceRepository append-only Invoice evidence port + * @param {object} input.claimRepository durable entitlement claim decision port + * @param {string} [input.secretKey] server-owned Stripe secret forwarded to provider ports + * @param {typeof fetch} [input.fetchImpl] provider transport seam + * @param {() => AbortSignal} [input.timeoutSignalFactory] provider timeout seam + * @returns {Promise>} + * bounded reconciliation receipt + */ +export async function reconcileStripeBillingAuthoritatively({ + organizationId, + subscriptionId, + sourceEventId = null, + fetchSubscription = fetchStripeSubscriptionAuthoritative, + fetchInvoice = fetchStripeInvoiceAuthoritative, + subscriptionRepository, + invoiceRepository, + claimRepository, + secretKey = process.env.STRIPE_SECRET_KEY, + fetchImpl = globalThis.fetch, + timeoutSignalFactory, +}) { + const authority = Object.freeze({ + organizationId: positiveOrganizationId(organizationId), + subscriptionId: boundedIdentifier(subscriptionId, 'subscriptionId', SUBSCRIPTION_ID_PATTERN), + }); + const eventId = optionalEventId(sourceEventId); + + if (typeof fetchSubscription !== 'function') throw new TypeError('fetchSubscription must be a function'); + if (typeof fetchInvoice !== 'function') throw new TypeError('fetchInvoice must be a function'); + requiredMethod(subscriptionRepository, 'recordAuthoritativeObservation', 'subscriptionRepository'); + requiredMethod(invoiceRepository, 'recordAuthoritativeObservation', 'invoiceRepository'); + requiredMethod(claimRepository, 'getCurrentClaim', 'claimRepository'); + requiredMethod(claimRepository, 'applyCurrentDecision', 'claimRepository'); + + const providerOptions = providerDependencies({ secretKey, fetchImpl, timeoutSignalFactory }); + const subscriptionSnapshot = await fetchSubscription({ + ...authority, + ...providerOptions, + }); + assertSubscriptionSnapshot(subscriptionSnapshot, authority); + + const subscriptionEvidence = subscriptionRepository.recordAuthoritativeObservation({ + snapshot: subscriptionSnapshot, + sourceEventId: eventId, + }); + const subscriptionObservationId = subscriptionEvidenceId(subscriptionEvidence, authority); + + let invoiceObservationId = null; + if (subscriptionSnapshot.latestInvoiceId != null) { + const invoiceAuthority = Object.freeze({ + organizationId: authority.organizationId, + invoiceId: boundedIdentifier(subscriptionSnapshot.latestInvoiceId, 'latestInvoiceId', INVOICE_ID_PATTERN), + subscriptionId: authority.subscriptionId, + customerId: boundedIdentifier(subscriptionSnapshot.customerId, 'customerId', CUSTOMER_ID_PATTERN), + }); + const invoiceSnapshot = await fetchInvoice({ + ...invoiceAuthority, + ...providerOptions, + }); + assertInvoiceSnapshot(invoiceSnapshot, invoiceAuthority); + + const invoiceEvidence = invoiceRepository.recordAuthoritativeObservation({ + snapshot: invoiceSnapshot, + sourceSubscriptionObservationId: subscriptionObservationId, + sourceEventId: eventId, + }); + invoiceObservationId = invoiceEvidenceId( + invoiceEvidence, + invoiceAuthority, + subscriptionObservationId, + ); + } + + const claim = applyClaimWithOneConflictRetry(claimRepository, authority); + const claimDecision = claimDecisionId(claim, authority, 'claimRepository.applyCurrentDecision()'); + + return Object.freeze({ + organizationId: authority.organizationId, + subscriptionId: authority.subscriptionId, + subscriptionObservationId, + invoiceObservationId, + claimDecisionId: claimDecision, + }); +} diff --git a/tests/unit/stripe-billing-authoritative-reconciliation.test.mjs b/tests/unit/stripe-billing-authoritative-reconciliation.test.mjs new file mode 100644 index 00000000..151ab150 --- /dev/null +++ b/tests/unit/stripe-billing-authoritative-reconciliation.test.mjs @@ -0,0 +1,343 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { reconcileStripeBillingAuthoritatively } from '../../server/stripe_billing_reconciliation.mjs'; + +function subscriptionSnapshot(overrides = {}) { + return Object.freeze({ + organizationId: 42, + subscriptionId: 'sub_scopeweave', + customerId: 'cus_scopeweave', + status: 'active', + cancelAtPeriodEnd: false, + currentPeriodStartSec: 1_800_000_000, + currentPeriodEndSec: 1_802_592_000, + canceledAtSec: null, + endedAtSec: null, + trialEndSec: null, + latestInvoiceId: 'in_scopeweave', + priceIds: Object.freeze(['price_scopeweave']), + ...overrides, + }); +} + +function invoiceSnapshot(overrides = {}) { + return Object.freeze({ + organizationId: 42, + invoiceId: 'in_scopeweave', + subscriptionId: 'sub_scopeweave', + customerId: 'cus_scopeweave', + status: 'paid', + paid: true, + currency: 'krw', + amountDue: 19_900, + amountPaid: 19_900, + amountRemaining: 0, + createdSec: 1_800_000_010, + paidAtSec: 1_800_000_020, + ...overrides, + }); +} + +function baseDependencies(overrides = {}, fixtures = {}) { + const calls = []; + const subscription = fixtures.subscription || subscriptionSnapshot(); + const invoice = fixtures.invoice || invoiceSnapshot(); + + const dependencies = { + fetchSubscription: async (authority) => { + calls.push(['fetch-subscription', authority.organizationId, authority.subscriptionId]); + return subscription; + }, + fetchInvoice: async (authority) => { + calls.push(['fetch-invoice', authority.organizationId, authority.invoiceId, authority.subscriptionId, authority.customerId]); + return invoice; + }, + subscriptionRepository: { + recordAuthoritativeObservation(input) { + calls.push(['record-subscription', input.sourceEventId]); + assert.equal(input.snapshot, subscription); + return Object.freeze({ + observationId: 101, + subscriptionId: subscription.subscriptionId, + observedAtMs: 1_800_000_000_000, + }); + }, + }, + invoiceRepository: { + recordAuthoritativeObservation(input) { + calls.push(['record-invoice', input.sourceSubscriptionObservationId, input.sourceEventId]); + assert.equal(input.snapshot, invoice); + return Object.freeze({ + observationId: 201, + invoiceId: invoice.invoiceId, + observedAtMs: 1_800_000_000_100, + sourceSubscriptionObservationId: input.sourceSubscriptionObservationId, + }); + }, + }, + claimRepository: { + getCurrentClaim(input) { + calls.push(['get-claim', input.organizationId, input.subscriptionId]); + return Object.freeze({ decisionId: 301 }); + }, + applyCurrentDecision(input) { + calls.push(['apply-claim', input.organizationId, input.subscriptionId, input.expectedPreviousDecisionId]); + return Object.freeze({ + decisionId: 302, + organizationId: input.organizationId, + subscriptionId: input.subscriptionId, + entitled: true, + validUntilSec: 1_802_592_000, + }); + }, + }, + ...overrides, + }; + + return { dependencies, calls, subscription, invoice }; +} + +test('authoritative reconciliation reads current provider state before recording evidence and claim authority', async () => { + const { dependencies, calls } = baseDependencies(); + + const result = await reconcileStripeBillingAuthoritatively({ + organizationId: 42, + subscriptionId: 'sub_scopeweave', + sourceEventId: 'evt_older_delivery', + ...dependencies, + }); + + assert.deepEqual(calls, [ + ['fetch-subscription', 42, 'sub_scopeweave'], + ['record-subscription', 'evt_older_delivery'], + ['fetch-invoice', 42, 'in_scopeweave', 'sub_scopeweave', 'cus_scopeweave'], + ['record-invoice', 101, 'evt_older_delivery'], + ['get-claim', 42, 'sub_scopeweave'], + ['apply-claim', 42, 'sub_scopeweave', 301], + ]); + assert.deepEqual(result, { + organizationId: 42, + subscriptionId: 'sub_scopeweave', + subscriptionObservationId: 101, + invoiceObservationId: 201, + claimDecisionId: 302, + }); + assert.equal(Object.isFrozen(result), true); +}); + +test('event arrival order is provenance only: each trigger re-reads current Subscription and Invoice authority', async () => { + const calls = []; + let providerVersion = 0; + const currentSubscription = subscriptionSnapshot(); + const currentInvoice = invoiceSnapshot(); + + const common = { + organizationId: 42, + subscriptionId: 'sub_scopeweave', + fetchSubscription: async () => { + providerVersion += 1; + calls.push(`subscription-${providerVersion}`); + return currentSubscription; + }, + fetchInvoice: async () => { + calls.push(`invoice-${providerVersion}`); + return currentInvoice; + }, + subscriptionRepository: { + recordAuthoritativeObservation({ sourceEventId }) { + calls.push(`subscription-evidence-${sourceEventId}`); + return Object.freeze({ observationId: providerVersion * 10, subscriptionId: 'sub_scopeweave', observedAtMs: providerVersion }); + }, + }, + invoiceRepository: { + recordAuthoritativeObservation({ sourceSubscriptionObservationId, sourceEventId }) { + calls.push(`invoice-evidence-${sourceEventId}`); + return Object.freeze({ observationId: sourceSubscriptionObservationId + 1, invoiceId: 'in_scopeweave', observedAtMs: providerVersion, sourceSubscriptionObservationId }); + }, + }, + claimRepository: { + getCurrentClaim() { + return providerVersion === 1 ? null : Object.freeze({ decisionId: 900 + providerVersion - 1 }); + }, + applyCurrentDecision({ expectedPreviousDecisionId }) { + calls.push(`claim-${String(expectedPreviousDecisionId)}`); + return Object.freeze({ decisionId: 900 + providerVersion }); + }, + }, + }; + + const newer = await reconcileStripeBillingAuthoritatively({ ...common, sourceEventId: 'evt_newer' }); + const older = await reconcileStripeBillingAuthoritatively({ ...common, sourceEventId: 'evt_older' }); + + assert.equal(newer.claimDecisionId, 901); + assert.equal(older.claimDecisionId, 902); + assert.deepEqual(calls, [ + 'subscription-1', + 'subscription-evidence-evt_newer', + 'invoice-1', + 'invoice-evidence-evt_newer', + 'claim-null', + 'subscription-2', + 'subscription-evidence-evt_older', + 'invoice-2', + 'invoice-evidence-evt_older', + 'claim-901', + ]); +}); + +test('reconciliation omits Invoice I/O when the authoritative Subscription has no latest Invoice', async () => { + const noInvoiceSubscription = subscriptionSnapshot({ latestInvoiceId: null }); + const { dependencies, calls } = baseDependencies({ + fetchInvoice: async () => assert.fail('Invoice provider must not be called without latestInvoiceId'), + invoiceRepository: { + recordAuthoritativeObservation() { + assert.fail('Invoice evidence must not be recorded without latestInvoiceId'); + }, + }, + }, { subscription: noInvoiceSubscription }); + + const result = await reconcileStripeBillingAuthoritatively({ + organizationId: 42, + subscriptionId: 'sub_scopeweave', + sourceEventId: null, + ...dependencies, + }); + + assert.equal(result.invoiceObservationId, null); + assert.equal(result.claimDecisionId, 302); + assert.equal(calls.some(([name]) => name === 'record-invoice'), false); +}); + +test('one optimistic-claim conflict refreshes the current head and retries only the claim decision', async () => { + const { dependencies, calls } = baseDependencies(); + let claimReads = 0; + let claimWrites = 0; + dependencies.claimRepository = { + getCurrentClaim() { + claimReads += 1; + return Object.freeze({ decisionId: claimReads === 1 ? 10 : 11 }); + }, + applyCurrentDecision({ expectedPreviousDecisionId }) { + claimWrites += 1; + if (claimWrites === 1) { + const error = new Error('concurrent claim'); + error.code = 'stripe_entitlement_claim_conflict'; + throw error; + } + assert.equal(expectedPreviousDecisionId, 11); + return Object.freeze({ decisionId: 12 }); + }, + }; + + const result = await reconcileStripeBillingAuthoritatively({ + organizationId: 42, + subscriptionId: 'sub_scopeweave', + sourceEventId: 'evt_concurrent', + ...dependencies, + }); + + assert.equal(result.claimDecisionId, 12); + assert.equal(claimReads, 2); + assert.equal(claimWrites, 2); + assert.equal(calls.filter(([name]) => name === 'fetch-subscription').length, 1); + assert.equal(calls.filter(([name]) => name === 'fetch-invoice').length, 1); +}); + +test('a second optimistic-claim conflict is bounded and propagated instead of looping', async () => { + const { dependencies } = baseDependencies(); + let writes = 0; + dependencies.claimRepository = { + getCurrentClaim() { + return Object.freeze({ decisionId: 77 + writes }); + }, + applyCurrentDecision() { + writes += 1; + const error = new Error('still concurrent'); + error.code = 'stripe_entitlement_claim_conflict'; + throw error; + }, + }; + + await assert.rejects( + reconcileStripeBillingAuthoritatively({ + organizationId: 42, + subscriptionId: 'sub_scopeweave', + ...dependencies, + }), + (error) => error.code === 'stripe_entitlement_claim_conflict', + ); + assert.equal(writes, 2); +}); + +test('non-conflict claim failures preserve the causal error and are not retried', async () => { + const { dependencies } = baseDependencies(); + const causal = new Error('persistence unavailable'); + causal.code = 'stripe_entitlement_claim_invalid'; + let writes = 0; + dependencies.claimRepository = { + getCurrentClaim() { + return null; + }, + applyCurrentDecision() { + writes += 1; + throw causal; + }, + }; + + await assert.rejects( + reconcileStripeBillingAuthoritatively({ + organizationId: 42, + subscriptionId: 'sub_scopeweave', + ...dependencies, + }), + (error) => error === causal, + ); + assert.equal(writes, 1); +}); + +test('invalid local authority or missing persistence ports fail before provider I/O', async () => { + let providerCalls = 0; + const fetchSubscription = async () => { + providerCalls += 1; + return subscriptionSnapshot(); + }; + + await assert.rejects( + reconcileStripeBillingAuthoritatively({ + organizationId: 0, + subscriptionId: 'sub_scopeweave', + fetchSubscription, + fetchInvoice: async () => invoiceSnapshot(), + subscriptionRepository: {}, + invoiceRepository: {}, + claimRepository: {}, + }), + TypeError, + ); + await assert.rejects( + reconcileStripeBillingAuthoritatively({ + organizationId: 42, + subscriptionId: 'not-a-subscription', + fetchSubscription, + fetchInvoice: async () => invoiceSnapshot(), + subscriptionRepository: {}, + invoiceRepository: {}, + claimRepository: {}, + }), + TypeError, + ); + await assert.rejects( + reconcileStripeBillingAuthoritatively({ + organizationId: 42, + subscriptionId: 'sub_scopeweave', + fetchSubscription, + fetchInvoice: async () => invoiceSnapshot(), + subscriptionRepository: {}, + invoiceRepository: {}, + claimRepository: {}, + }), + TypeError, + ); + assert.equal(providerCalls, 0); +}); diff --git a/tests/unit/stripe-billing-reconciliation-port-integrity.test.mjs b/tests/unit/stripe-billing-reconciliation-port-integrity.test.mjs new file mode 100644 index 00000000..35fb113f --- /dev/null +++ b/tests/unit/stripe-billing-reconciliation-port-integrity.test.mjs @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { reconcileStripeBillingAuthoritatively } from '../../server/stripe_billing_reconciliation.mjs'; + +function subscriptionSnapshot() { + return Object.freeze({ + organizationId: 42, + subscriptionId: 'sub_scopeweave', + customerId: 'cus_scopeweave', + status: 'active', + cancelAtPeriodEnd: false, + currentPeriodStartSec: 1_800_000_000, + currentPeriodEndSec: 1_802_592_000, + canceledAtSec: null, + endedAtSec: null, + trialEndSec: null, + latestInvoiceId: 'in_scopeweave', + priceIds: Object.freeze(['price_scopeweave']), + }); +} + +function invoiceSnapshot() { + return Object.freeze({ + organizationId: 42, + invoiceId: 'in_scopeweave', + subscriptionId: 'sub_scopeweave', + customerId: 'cus_scopeweave', + status: 'paid', + paid: true, + currency: 'krw', + amountDue: 19_900, + amountPaid: 19_900, + amountRemaining: 0, + createdSec: 1_800_000_010, + paidAtSec: 1_800_000_020, + }); +} + +function dependencies(overrides = {}) { + return { + fetchSubscription: async () => subscriptionSnapshot(), + fetchInvoice: async () => invoiceSnapshot(), + subscriptionRepository: { + recordAuthoritativeObservation() { + return Object.freeze({ + observationId: 101, + subscriptionId: 'sub_scopeweave', + observedAtMs: 1_800_000_000_000, + }); + }, + }, + invoiceRepository: { + recordAuthoritativeObservation() { + return Object.freeze({ + observationId: 201, + invoiceId: 'in_scopeweave', + observedAtMs: 1_800_000_000_100, + sourceSubscriptionObservationId: 101, + }); + }, + }, + claimRepository: { + getCurrentClaim() { + return Object.freeze({ + decisionId: 301, + organizationId: 42, + subscriptionId: 'sub_scopeweave', + }); + }, + applyCurrentDecision() { + return Object.freeze({ + decisionId: 302, + organizationId: 42, + subscriptionId: 'sub_scopeweave', + }); + }, + }, + ...overrides, + }; +} + +async function reconcile(overrides = {}) { + return reconcileStripeBillingAuthoritatively({ + organizationId: 42, + subscriptionId: 'sub_scopeweave', + sourceEventId: 'evt_integrity', + ...dependencies(overrides), + }); +} + +test('subscription evidence identity cannot be substituted by a persistence port', async () => { + let invoiceCalls = 0; + let claimCalls = 0; + await assert.rejects( + reconcile({ + subscriptionRepository: { + recordAuthoritativeObservation() { + return Object.freeze({ + observationId: 101, + subscriptionId: 'sub_other_tenant', + observedAtMs: 1_800_000_000_000, + }); + }, + }, + fetchInvoice: async () => { + invoiceCalls += 1; + return invoiceSnapshot(); + }, + claimRepository: { + getCurrentClaim() { + claimCalls += 1; + return null; + }, + applyCurrentDecision() { + claimCalls += 1; + return Object.freeze({ decisionId: 302, organizationId: 42, subscriptionId: 'sub_scopeweave' }); + }, + }, + }), + TypeError, + ); + assert.equal(invoiceCalls, 0); + assert.equal(claimCalls, 0); +}); + +test('invoice evidence identity and source observation cannot be substituted by a persistence port', async () => { + for (const badEvidence of [ + Object.freeze({ observationId: 201, invoiceId: 'in_other', sourceSubscriptionObservationId: 101 }), + Object.freeze({ observationId: 201, invoiceId: 'in_scopeweave', sourceSubscriptionObservationId: 999 }), + ]) { + let claimCalls = 0; + await assert.rejects( + reconcile({ + invoiceRepository: { + recordAuthoritativeObservation() { + return badEvidence; + }, + }, + claimRepository: { + getCurrentClaim() { + claimCalls += 1; + return null; + }, + applyCurrentDecision() { + claimCalls += 1; + return Object.freeze({ decisionId: 302, organizationId: 42, subscriptionId: 'sub_scopeweave' }); + }, + }, + }), + TypeError, + ); + assert.equal(claimCalls, 0); + } +}); + +test('current and newly applied claims must remain bound to the requested tenant and Subscription', async () => { + await assert.rejects( + reconcile({ + claimRepository: { + getCurrentClaim() { + return Object.freeze({ decisionId: 301, organizationId: 7, subscriptionId: 'sub_scopeweave' }); + }, + applyCurrentDecision() { + assert.fail('a foreign current claim must fail before claim application'); + }, + }, + }), + TypeError, + ); + + await assert.rejects( + reconcile({ + claimRepository: { + getCurrentClaim() { + return Object.freeze({ decisionId: 301, organizationId: 42, subscriptionId: 'sub_scopeweave' }); + }, + applyCurrentDecision() { + return Object.freeze({ decisionId: 302, organizationId: 42, subscriptionId: 'sub_other_tenant' }); + }, + }, + }), + TypeError, + ); +}); diff --git a/tests/unit/stripe-entitlement-claim-package-contract.test.mjs b/tests/unit/stripe-entitlement-claim-package-contract.test.mjs index 0acbd845..aa322e25 100644 --- a/tests/unit/stripe-entitlement-claim-package-contract.test.mjs +++ b/tests/unit/stripe-entitlement-claim-package-contract.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import './stripe-entitlement-claim-head-integrity.test.mjs'; +import './stripe-billing-reconciliation-port-integrity.test.mjs'; const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')); const scripts = packageJson.scripts;