Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- Added a bounded authoritative Stripe Invoice reader that verifies exact tenant-
bound Invoice, Customer, and Subscription identities, supports current Basil
and legacy Subscription provenance without trusting metadata alone, bounds
provider transport/response parsing, and returns immutable payment evidence
without granting or persisting entitlement.
- 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
Expand Down
80 changes: 80 additions & 0 deletions docs/doctoring/stripe-invoice-authoritative-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Authoritative Stripe Invoice read boundary

## Status and authority

**Status: active stacked PR evidence, not protected-`develop` shipped truth.**

This record belongs to the bounded #488 Invoice-read slice stacked on the current Stripe entitlement-policy branch. Protected `develop` remains the shipped authority until the entire prerequisite stack is independently reviewed, protected-integrated, and revalidated on its final exact heads.

The slice adds payment evidence only. It does not persist Invoice observations, mutate an organization plan, write entitlement claims, grant session/API capability, or make an Invoice webhook authoritative by arrival order.

## Buyer and control objective

The entitlement policy already requires authoritative paid-Invoice evidence before an `active` Stripe Subscription can grant or extend paid access. A webhook payload is insufficient for that decision because delivery can be delayed, duplicated, or reordered. ScopeWeave therefore needs a separate provider-read boundary that retrieves the exact Invoice named by the current authoritative Subscription and verifies its tenant-bound identities before the Invoice can become policy evidence.

`server/stripe_invoice_provider.mjs` owns that boundary. It requires server-owned organization, Invoice, Subscription, and Customer authority and performs exactly one bounded `GET /v1/invoices/{invoice}` against Stripe's fixed HTTPS API origin.

## Provider contract

The reader:

- accepts only a positive safe-integer ScopeWeave organization ID and bounded `in_...`, `sub_...`, and `cus_...` provider identities;
- uses one hard-coded `https://api.stripe.com/v1/invoices/` GET, `redirect: "error"`, a 15-second abort budget, and no application retry loop;
- sends the server-owned Stripe secret only to that fixed authority;
- requires a successful JSON response and enforces a 256 KiB ceiling from both `Content-Length` and streamed bytes before JSON parsing;
- decodes UTF-8 fatally and maps malformed transport/provider data to stable sanitized application errors;
- distinguishes a provider 404 from transient/unavailable provider failure without exposing response bodies or network diagnostics;
- verifies exact Invoice ID, Customer ID, and Subscription ID before returning evidence; and
- returns an immutable normalized payment fact containing only bounded lifecycle, amount, currency, and timestamp fields needed by the later policy/persistence layers.

No response field can choose another ScopeWeave tenant. Subscription metadata is supplementary mismatch evidence only; exact server-owned Customer and Subscription identities remain mandatory.

## Stripe API-version compatibility boundary

ScopeWeave's current direct Stripe REST adapters intentionally inherit the Stripe account's configured default API version rather than silently pinning a new provider version inside one feature slice. That makes Invoice provenance shape an explicit compatibility concern.

Stripe's `2025-03-31.basil` breaking change introduced `invoice.parent` and moved Subscription provenance from the deprecated top-level `invoice.subscription` / `invoice.subscription_details` fields to `invoice.parent.subscription_details.subscription`, after verifying `invoice.parent.type === "subscription_details"`. The reader therefore accepts both generations:

- current Basil-style `parent.subscription_details.subscription`; and
- legacy pre-Basil top-level `subscription` plus optional `subscription_details.metadata`.

If both shapes are present, they must identify the same Subscription. A non-Subscription parent, malformed parent/details object, missing Subscription identity, or disagreement between old and new shapes fails closed. A future provider representation outside these validated contracts also fails closed. Explicit `Stripe-Version` migration remains a separately tested operator compatibility change with rollback evidence rather than an implicit behavior change here.

## Invoice lifecycle evidence

Stripe documents Invoice statuses `draft`, `open`, `paid`, `uncollectible`, and `void`, with payment moving an Invoice to `paid`. The reader accepts only those states and requires the provider `paid` boolean to agree with `status === "paid"`. It also requires `status_transitions.paid_at` exactly when the Invoice is paid. This catches contradictory provider representations before they reach entitlement policy.

Currency must be a three-letter lowercase code. `amount_due`, `amount_paid`, `amount_remaining`, `created`, and any `paid_at` timestamp must be non-negative safe integers. This slice preserves the provider amounts as evidence and does not infer tax, revenue recognition, refund, chargeback, or accounting policy from them.

## TDD and executable evidence

Test-only commit `a83b002210c2448f7cdfaaab94b506ab1c581473` registered `tests/unit/stripe-invoice-provider.test.mjs` before the production module existed, so the new contract initially failed at module resolution rather than obtaining a false green.

The completed focused regression set exercises:

- one exact HTTPS GET and bounded dependency seams;
- current Basil and legacy pre-Basil Subscription provenance;
- conflicting dual-shape provenance;
- exact Customer, Subscription, and optional tenant-metadata mismatch rejection;
- lifecycle/status/paid/paid-at contradictions;
- malformed identifiers, currency, amounts, timestamps, media type, JSON, and provider envelopes;
- 404, transient failure, stream-read failure, declared oversize, streamed oversize, and cancellation failure; and
- sanitized errors that never echo provider bodies, credentials, or network diagnostics.

Private focused execution after implementation produced 100% line, branch, and function coverage for `server/stripe_invoice_provider.mjs`. Hosted repository-native CI, security, dependency, supply-chain, and review evidence remains authoritative for PR integration and must be regenerated on the unchanged final contributor head.

## Privacy, rollback, and recovery

The boundary returns no customer email, postal address, hosted Invoice URL, payment method, secret, raw response, or arbitrary metadata. The expected tenant IDs are already server-owned routing authority; optional `orgId` metadata is used only to detect contradiction.

Rollback removes the Invoice reader, its focused tests/coverage registration, this doctoring record, and the matching Unreleased changelog entry together. It does not require a database migration because this slice introduces no persisted Invoice relation or entitlement mutation. A provider-read failure therefore leaves all existing entitlement state untouched and available for later operator reconciliation.

## References

Stripe. (n.d.). *Retrieve an invoice*. Stripe API Reference. https://docs.stripe.com/api/invoices/retrieve

Stripe. (n.d.). *The Invoice object*. Stripe API Reference. https://docs.stripe.com/api/invoices/object

Stripe. (2025, March 31). *Invoicing resources now specify how they were generated*. Stripe Documentation. https://docs.stripe.com/changelog/basil/2025-03-31/adds-new-parent-field-to-invoicing-objects

Stripe. (n.d.). *Status transitions and finalization*. Stripe Documentation. https://docs.stripe.com/invoicing/integration/workflow-transitions
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
"coverage": "npm run test:coverage",
"server": "node server/server.mjs",
"test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/stripe-webhook.test.mjs",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/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:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.mjs --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_provider.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && 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",
Expand Down
Loading