diff --git a/docs/doctoring/stripe-checkout-reconciliation-operations.md b/docs/doctoring/stripe-checkout-reconciliation-operations.md new file mode 100644 index 00000000..92ef3f35 --- /dev/null +++ b/docs/doctoring/stripe-checkout-reconciliation-operations.md @@ -0,0 +1,145 @@ +# Stripe Checkout reconciliation operations — active PR #516 + +## Status and scope + +This document describes **active stacked PR #516**, based on active PR #511 at +`2743a58c9ac65ab125c3898ec94370a7dc91f095`. It is not protected-`develop` +shipped truth and it is not a billing-release claim. + +PR #511 deliberately moves stale or clock-ambiguous unresolved Checkout attempts +to `reconciliation_required` instead of minting a speculative fresh Stripe +idempotency key. PR #516 supplies the next bounded persistence/operator boundary: +a safe tenant-scoped inspection surface, a backlog count for an internal metric +adapter, and an atomic audited transition after an authorized caller has already +verified authoritative provider evidence. + +This slice does **not** expose an HTTP operator route, does not decide operator +authorization, does not verify a Stripe webhook signature, and does not treat a +caller-provided reference as provider truth. Those controls must exist in the +service adapter before reconciliation can be customer- or operator-invoked. + +## Evidence-to-control traceability + +| Primary evidence / requirement | ScopeWeave control in this active PR | Acceptance evidence | +| --- | --- | --- | +| Stripe's API v1 idempotency contract can retain a key for at least 24 hours and a reused key can become a new request after pruning. | A held attempt remains blocking after ScopeWeave's conservative 23-hour automatic-replay ceiling until an authoritative reconciliation decision closes it. | `tests/unit/billing-checkout-attempt.test.mjs`; `tests/unit/billing-checkout-reconciliation.test.mjs` | +| Stripe documents idempotency as protection against duplicate mutation when retrying an uncertain POST. | Reconciliation never exposes or replaces the held `idempotency_key`; safe inspection deliberately omits it. | credential non-disclosure and fresh-identity tests | +| Stripe requires the unmodified raw request body for webhook signature verification. | This persistence primitive refuses to implement provider-truth verification itself; the later webhook/service adapter must verify the raw signed request before calling resolution. | explicit public JSDoc boundary; no public reconciliation route in this slice | +| Stripe warns that webhook events may be delivered more than once. | This slice records one immutable reconciliation event per Checkout attempt through an `attempt_id` uniqueness constraint; broader Stripe event-ID deduplication remains separate webhook-ledger work. | schema normalization test | +| Stripe does not guarantee webhook event ordering. | Reconciliation outcome is an explicit authoritative terminal fact for one held attempt rather than an assumption based on arrival order. Subscription/entitlement ordering remains out of scope. | success/failure transition tests | +| Commercial operations need a recoverable path when an uncertain provider mutation is intentionally held. | `countReconciliationRequired` exposes low-cardinality backlog state, and `listReconciliationRequired` exposes only tenant-scoped non-credential metadata for an authorized management adapter. | tenant-filter/count/list regressions | +| An operator release must be auditable and must not silently succeed if audit persistence fails. | Attempt transition and `billing_checkout_reconciliation_events` insert execute inside one SQLite savepoint; an invalid/missing resolving user rolls the whole operation back. | foreign-key rollback regression | + +## Data model and normalization + +`billing_checkout_reconciliation_events` is a separate relation from +`billing_checkout_attempts`, rather than duplicating attempt/provider-retry +identity in the audit record. Its owned identifiers are descriptive multi-word +`snake_case` names. + +- `reconciliation_event_id`: audit-row primary key. +- `attempt_id`: unique foreign key to the held Checkout attempt. +- `resolved_by_user_id`: resolving actor foreign key to `users`; deletion is + restricted so an audit record cannot lose its actor through normal user-row + deletion. +- `provider_resolution`: exactly `provider_succeeded` or `provider_failed`. +- `provider_session_id`: required only for authoritative success. +- `evidence_reference`: bounded non-control-character reference to separately + verified provider evidence; it is not an authorization credential and is not + sufficient by itself to establish truth. +- `resolved_at_ms`: persisted monotonic resolution timestamp. + +The audit relation intentionally does not copy the Stripe idempotency key, +webhook signing secret, bearer token, session secret, credential hash, or other +reusable authority. A separate actor/time index supports audit investigation +without changing the normalized ownership of attempt state. + +## Authorization and tenant isolation contract + +The repository method requires the organization ID as part of the state-change +predicate. A valid attempt ID from another organization therefore cannot be used +to release the caller's tenant hold. Inspection likewise requires an explicit +organization ID and never returns the provider retry key. + +These are persistence-layer defense-in-depth controls, **not** an authorization +system. A service/HTTP adapter must, before calling `resolveReconciliation`: + +1. authenticate the operator using ScopeWeave's normal non-URL credential path; +2. prove owner/admin authority for the target organization under the current + tenant membership state; +3. retrieve or consume provider evidence through a trusted Stripe boundary; +4. verify any webhook signature against the exact raw request bytes and the + endpoint-specific secret before parsing that evidence as authoritative; +5. map the verified provider fact to the held attempt and organization; and +6. pass only a bounded audit reference to the persistence primitive. + +A route that merely accepts `{ outcome: "provider_succeeded" }` from an +authenticated browser would violate this contract. + +## Failure semantics and recovery + +- A stale/clock-ambiguous attempt remains `reconciliation_required` until exactly + one authorized authoritative resolution succeeds. +- Cross-tenant or already-terminal attempts fail without creating an audit row. +- `provider_succeeded` requires a bounded provider Session ID and stores it on + the attempt; `provider_failed` forbids inventing a provider Session ID. +- If the audit row cannot be persisted, the savepoint restores the held attempt. +- Persisted update and audit timestamps are clamped monotonically so wall-clock + rollback cannot invalidate a valid authoritative terminal decision. +- A successful resolution removes the unresolved uniqueness hold; a subsequent + deliberate Checkout receives a fresh local attempt and fresh provider + idempotency key. + +Rollback must preserve held attempts and reconciliation audit evidence. Do not +resolve rows merely to make a deployment rollback easier. If this slice is +withdrawn before protected integration, revert the child commits. If it has ever +been integrated with real provider traffic, retain/export the ledger until every +held attempt has an independently recoverable provider disposition. + +## TDD and failure-repair chronology + +The first child commit added the realistic SQLite reconciliation contract before +production support existed. The implementation then added the normalized audit +relation and repository operations, and the tests were registered in both the +canonical unit and c8 coverage paths. + +The first hosted exact-head run exposed a real bootstrap-contract mismatch: the +new audit statement references the existing `users` parent table, while two +legacy isolated Checkout-attempt fixtures created only `orgs`. The production +installer already documented that `users` and `orgs` must exist before billing +schema installation, so the repair changed the fixtures rather than adding +request-time DDL or weakening foreign keys. The first fixture repair made the +Checkout-attempt suite green and exposed the same mismatch in the separate +review-regression fixture. The second fixture repair aligned that fixture as +well. Exact-head `unit-and-api` is green on +`bea914e1d7fd674dd21e1e62b36a38efbf61bf86`; remaining exact-head workflows and +all Ready-event organization gates must still be treated independently before +integration. + +## Remaining #488 release blockers + +This active PR intentionally leaves these buyer-visible controls open: + +- a purpose-bound authorized reconciliation operator API/service adapter; +- exposure of reconciliation backlog on the sanitized metrics/operability + surface; +- Stripe webhook raw-body signature verification; +- durable Stripe event deduplication and replay handling; +- out-of-order subscription/payment/entitlement reconciliation; +- formal migration-ledger, backup/restore, retention, and deletion acceptance; +- end-to-end production billing recovery drill and incident runbook evidence. + +Until those controls converge, ScopeWeave must not claim complete production +Stripe lifecycle readiness. + +## References + +Stripe, Inc. (n.d.). *Idempotent requests*. Stripe API Reference. Retrieved +August 16, 2026, from https://docs.stripe.com/api/idempotent_requests + +Stripe, Inc. (n.d.). *Receive Stripe events in your webhook endpoint*. Stripe +Documentation. Retrieved August 16, 2026, from https://docs.stripe.com/webhooks + +Stripe, Inc. (n.d.). *Resolve webhook signature verification errors*. Stripe +Documentation. Retrieved August 16, 2026, from +https://docs.stripe.com/webhooks/signature diff --git a/package.json b/package.json index 86f0fbc0..8656d165 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", - "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.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/toast-accessibility.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/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/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 --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.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && npm run test:api", + "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 && 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/billing_checkout_attempt.mjs b/server/billing_checkout_attempt.mjs index 8ed8ffd9..7d950559 100644 --- a/server/billing_checkout_attempt.mjs +++ b/server/billing_checkout_attempt.mjs @@ -13,6 +13,8 @@ export const BILLING_CHECKOUT_REUSE_WINDOW_MS = 23 * 60 * 60 * 1000; const MAX_PRICE_ID_LENGTH = 255; const MAX_PROVIDER_SESSION_ID_LENGTH = 255; const MAX_IDENTIFIER_LENGTH = 255; +const MAX_EVIDENCE_REFERENCE_LENGTH = 512; +const MAX_RECONCILIATION_LIST_LIMIT = 100; const SAVEPOINT_NAME = 'billing_checkout_attempt_write'; /** @@ -32,17 +34,21 @@ export class BillingCheckoutReconciliationRequiredError extends Error { } } -function positiveOrganizationId(value) { +function positiveInteger(value, name) { if (typeof value !== 'number' && typeof value !== 'string') { - throw new TypeError('organizationId must be a positive integer'); + throw new TypeError(`${name} must be a positive integer`); } const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed <= 0) { - throw new TypeError('organizationId must be a positive integer'); + throw new TypeError(`${name} must be a positive integer`); } return parsed; } +function positiveOrganizationId(value) { + return positiveInteger(value, 'organizationId'); +} + function boundedRequiredString(value, name, maximumLength) { if (typeof value !== 'string') throw new TypeError(`${name} must be a non-empty string`); const normalized = value.trim(); @@ -52,6 +58,33 @@ function boundedRequiredString(value, name, maximumLength) { return normalized; } +function evidenceReference(value) { + const normalized = boundedRequiredString( + value, + 'evidenceReference', + MAX_EVIDENCE_REFERENCE_LENGTH, + ); + if(/[\u0000-\u001f\u007f]/u.test(normalized)) { + throw new TypeError('evidenceReference must not contain control characters'); + } + return normalized; +} + +function reconciliationLimit(value = MAX_RECONCILIATION_LIST_LIMIT) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > MAX_RECONCILIATION_LIST_LIMIT) { + throw new TypeError(`limit must be an integer between 1 and ${MAX_RECONCILIATION_LIST_LIMIT}`); + } + return parsed; +} + +function reconciliationOutcome(value) { + if (value !== 'provider_succeeded' && value !== 'provider_failed') { + throw new TypeError("outcome must be 'provider_succeeded' or 'provider_failed'"); + } + return value; +} + function safeNow(now) { const value = Number(now()); if (!Number.isSafeInteger(value) || value < 0) { @@ -90,15 +123,17 @@ function withSavepoint(database, operation) { } /** - * Install the durable Checkout-attempt schema during process/database bootstrap. + * Install durable Checkout-attempt and reconciliation-audit schemas at bootstrap. * - * The schema is intentionally separate from request handling. One row represents - * one provider-attempt identity; organization and price facts are referenced or - * recorded once, while provider outcome is a state of that same attempt. The - * partial unique index guarantees at most one unresolved or reconciliation-held - * identity for an organization/price pair. + * One attempt row owns provider retry identity and outcome. Reconciliation events + * form a separate normalized audit relation keyed to the attempt and resolving + * user; they intentionally never copy the Stripe idempotency key, session secret, + * authentication token, or other reusable credential. The unresolved partial + * unique index guarantees at most one pending/reconciliation-held identity for an + * organization/price pair. * - * @param {import('node:sqlite').DatabaseSync} database - Open SQLite database. + * @param {import('node:sqlite').DatabaseSync} database - Open SQLite database + * whose `users` and `orgs` parent tables have already been installed. * @returns {void} */ export function installBillingCheckoutAttemptSchema(database) { @@ -120,6 +155,22 @@ export function installBillingCheckoutAttemptSchema(database) { CREATE UNIQUE INDEX IF NOT EXISTS billing_checkout_unresolved_attempts ON billing_checkout_attempts(organization_id, price_id) WHERE attempt_state IN ('pending','reconciliation_required'); + + CREATE TABLE IF NOT EXISTS billing_checkout_reconciliation_events ( + reconciliation_event_id INTEGER PRIMARY KEY, + attempt_id TEXT NOT NULL UNIQUE REFERENCES billing_checkout_attempts(attempt_id) ON DELETE CASCADE, + resolved_by_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + provider_resolution TEXT NOT NULL CHECK(provider_resolution IN ('provider_succeeded','provider_failed')), + provider_session_id TEXT CHECK(provider_session_id IS NULL OR length(provider_session_id) BETWEEN 1 AND ${MAX_PROVIDER_SESSION_ID_LENGTH}), + evidence_reference TEXT NOT NULL CHECK(length(evidence_reference) BETWEEN 1 AND ${MAX_EVIDENCE_REFERENCE_LENGTH}), + resolved_at_ms INTEGER NOT NULL CHECK(resolved_at_ms >= 0), + CHECK( + (provider_resolution = 'provider_succeeded' AND provider_session_id IS NOT NULL) + OR (provider_resolution = 'provider_failed' AND provider_session_id IS NULL) + ) + ); + CREATE INDEX IF NOT EXISTS billing_checkout_reconciliation_actor_events + ON billing_checkout_reconciliation_events(resolved_by_user_id, resolved_at_ms); `); } @@ -132,6 +183,11 @@ export function installBillingCheckoutAttemptSchema(database) { * constructing the port cannot accidentally turn missing bootstrap into schema * creation or another hidden startup side effect. * + * Reconciliation methods are persistence/operator primitives, not authorization. + * HTTP/service adapters must enforce owner/admin operator authorization and must + * verify the supplied evidence against authoritative Stripe/webhook state before + * calling `resolveReconciliation`. + * * @param {import('node:sqlite').DatabaseSync} database - Bootstrapped database. * @param {object} [dependencies] - Deterministic seams for tests. * @param {() => string} [dependencies.randomUUID] - Cryptographic UUID source. @@ -139,7 +195,10 @@ export function installBillingCheckoutAttemptSchema(database) { * @returns {{ * startAttempt(input: {organizationId: string|number, priceId: string}): {attemptId: string, idempotencyKey: string, state: 'pending', reused: boolean}, * markProviderSucceeded(input: {attemptId: string, providerSessionId: string}): void, - * markProviderFailed(input: {attemptId: string}): void + * markProviderFailed(input: {attemptId: string}): void, + * countReconciliationRequired(input?: {organizationId?: string|number}): number, + * listReconciliationRequired(input: {organizationId: string|number, limit?: number}): Array<{attemptId: string, priceId: string, createdAtMs: number, updatedAtMs: number}>, + * resolveReconciliation(input: {organizationId: string|number, attemptId: string, resolvedByUserId: string|number, outcome: 'provider_succeeded'|'provider_failed', providerSessionId?: string, evidenceReference: string}): void * }} Checkout-attempt persistence port. */ export function createSqliteBillingCheckoutAttemptRepository( @@ -186,6 +245,48 @@ export function createSqliteBillingCheckoutAttemptRepository( SET attempt_state = 'provider_failed', updated_at_ms = MAX(?, created_at_ms) WHERE attempt_id = ? AND attempt_state = 'pending' `), + countReconciliation: database.prepare(` + SELECT COUNT(*) AS reconciliation_count + FROM billing_checkout_attempts + WHERE attempt_state = 'reconciliation_required' + `), + countReconciliationByOrganization: database.prepare(` + SELECT COUNT(*) AS reconciliation_count + FROM billing_checkout_attempts + WHERE attempt_state = 'reconciliation_required' AND organization_id = ? + `), + listReconciliationByOrganization: database.prepare(` + SELECT attempt_id, price_id, created_at_ms, updated_at_ms + FROM billing_checkout_attempts + WHERE attempt_state = 'reconciliation_required' AND organization_id = ? + ORDER BY created_at_ms ASC, attempt_id ASC + LIMIT ? + `), + resolveReconciliationSuccess: database.prepare(` + UPDATE billing_checkout_attempts + SET attempt_state = 'provider_succeeded', provider_session_id = ?, + updated_at_ms = MAX(?, created_at_ms, updated_at_ms) + WHERE attempt_id = ? + AND organization_id = ? + AND attempt_state = 'reconciliation_required' + `), + resolveReconciliationFailure: database.prepare(` + UPDATE billing_checkout_attempts + SET attempt_state = 'provider_failed', provider_session_id = NULL, + updated_at_ms = MAX(?, created_at_ms, updated_at_ms) + WHERE attempt_id = ? + AND organization_id = ? + AND attempt_state = 'reconciliation_required' + `), + insertReconciliationEvent: database.prepare(` + INSERT INTO billing_checkout_reconciliation_events( + attempt_id, resolved_by_user_id, provider_resolution, + provider_session_id, evidence_reference, resolved_at_ms + ) + SELECT attempt_id, ?, ?, ?, ?, updated_at_ms + FROM billing_checkout_attempts + WHERE attempt_id = ? AND organization_id = ? + `), }; return preparedStatements; }; @@ -271,5 +372,94 @@ export function createSqliteBillingCheckoutAttemptRepository( throw new Error('expected one pending checkout attempt for provider failure'); } }, + + /** + * Count reconciliation-held attempts for alerting/health signals. + * + * Omitting `organizationId` returns the process-wide repository count for an + * internal metric adapter. Supplying it returns only that tenant's count. + */ + countReconciliationRequired({ organizationId } = {}) { + const sql = statements(); + const row = organizationId === undefined + ? sql.countReconciliation.get() + : sql.countReconciliationByOrganization.get(positiveOrganizationId(organizationId)); + return Number(row.reconciliation_count); + }, + + /** + * Return safe operator inspection metadata for one organization. + * + * The list intentionally excludes the provider idempotency key and all + * reusable credentials; it is suitable for an authorized management adapter. + */ + listReconciliationRequired({ organizationId, limit = MAX_RECONCILIATION_LIST_LIMIT }) { + const organization = positiveOrganizationId(organizationId); + const boundedLimit = reconciliationLimit(limit); + return statements().listReconciliationByOrganization + .all(organization, boundedLimit) + .map((row) => ({ + attemptId: row.attempt_id, + priceId: row.price_id, + createdAtMs: Number(row.created_at_ms), + updatedAtMs: Number(row.updated_at_ms), + })); + }, + + /** + * Resolve one held attempt from already-verified authoritative provider state. + * + * The state transition and immutable audit event are one SQLite savepoint: if + * the actor/evidence audit record cannot be persisted, the attempt stays held. + * The caller must enforce operator authorization and verify the evidence before + * invoking this persistence primitive. + */ + resolveReconciliation({ + organizationId, + attemptId, + resolvedByUserId, + outcome, + providerSessionId, + evidenceReference: rawEvidenceReference, + }) { + const organization = positiveOrganizationId(organizationId); + const id = boundedRequiredString(attemptId, 'attemptId', MAX_IDENTIFIER_LENGTH); + const actor = positiveInteger(resolvedByUserId, 'resolvedByUserId'); + const resolution = reconciliationOutcome(outcome); + const evidence = evidenceReference(rawEvidenceReference); + const nowMs = safeNow(now); + let sessionId = null; + if (resolution === 'provider_succeeded') { + sessionId = boundedRequiredString( + providerSessionId, + 'providerSessionId', + MAX_PROVIDER_SESSION_ID_LENGTH, + ); + } else if (providerSessionId !== undefined && providerSessionId !== null) { + throw new TypeError('providerSessionId must be omitted for provider_failed reconciliation'); + } + + withSavepoint(database, () => { + const sql = statements(); + const transition = resolution === 'provider_succeeded' + ? sql.resolveReconciliationSuccess.run(sessionId, nowMs, id, organization) + : sql.resolveReconciliationFailure.run(nowMs, id, organization); + if (Number(transition.changes) !== 1) { + throw new Error('expected one reconciliation-required checkout attempt in organization'); + } + + const audit = sql.insertReconciliationEvent.run( + actor, + resolution, + sessionId, + evidence, + id, + organization, + ); + if (Number(audit.changes) !== 1) { + throw new Error('expected one durable checkout reconciliation audit event'); + } + }); + }, }; } diff --git a/tests/unit/billing-checkout-attempt.test.mjs b/tests/unit/billing-checkout-attempt.test.mjs index c0fbd3e7..66d93bad 100644 --- a/tests/unit/billing-checkout-attempt.test.mjs +++ b/tests/unit/billing-checkout-attempt.test.mjs @@ -11,6 +11,7 @@ import { function createDatabase() { const database = new DatabaseSync(':memory:'); database.exec('PRAGMA foreign_keys = ON'); + database.exec('CREATE TABLE users (id INTEGER PRIMARY KEY)'); database.exec('CREATE TABLE orgs (id INTEGER PRIMARY KEY)'); database.prepare('INSERT INTO orgs(id) VALUES(?)').run(7); database.prepare('INSERT INTO orgs(id) VALUES(?)').run(8); diff --git a/tests/unit/billing-checkout-reconciliation-authority.test.mjs b/tests/unit/billing-checkout-reconciliation-authority.test.mjs new file mode 100644 index 00000000..5d7eca26 --- /dev/null +++ b/tests/unit/billing-checkout-reconciliation-authority.test.mjs @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { + createSqliteBillingCheckoutAttemptRepository, + installBillingCheckoutAttemptSchema, +} from '../../server/billing_checkout_attempt.mjs'; + +function authorityDatabase() { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON'); + database.exec('CREATE TABLE users (id INTEGER PRIMARY KEY)'); + database.exec('CREATE TABLE orgs (id INTEGER PRIMARY KEY)'); + database.prepare('INSERT INTO users(id) VALUES(?)').run(41); + database.prepare('INSERT INTO orgs(id) VALUES(?)').run(1); + installBillingCheckoutAttemptSchema(database); + return database; +} + +function repositoryFor(database) { + let uuidCounter = 0; + return createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: () => `00000000-0000-4000-8000-${String(++uuidCounter).padStart(12, '0')}`, + now: () => 1_000, + }); +} + +test('checkout reconciliation rejects JavaScript-coerced tenant authority before persistence lookup', () => { + const database = authorityDatabase(); + const repository = repositoryFor(database); + + for (const organizationId of [true, new Number(1), [1]]) { + assert.throws( + () => repository.startAttempt({ organizationId, priceId: 'price_pro' }), + TypeError, + ); + assert.throws( + () => repository.countReconciliationRequired({ organizationId }), + TypeError, + ); + assert.throws( + () => repository.listReconciliationRequired({ organizationId }), + TypeError, + ); + } + + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM billing_checkout_attempts').get().count, + 0, + 'malformed local tenant authority cannot create provider retry authority', + ); + database.close(); +}); + +test('checkout reconciliation rejects JavaScript-coerced operator authority before state lookup', () => { + const database = authorityDatabase(); + const repository = repositoryFor(database); + + for (const resolvedByUserId of [true, new Number(41), [41]]) { + assert.throws( + () => repository.resolveReconciliation({ + organizationId: 1, + attemptId: 'attempt_unknown', + resolvedByUserId, + outcome: 'provider_failed', + evidenceReference: 'stripe:test:authority', + }), + TypeError, + ); + } + + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM billing_checkout_reconciliation_events').get().count, + 0, + 'malformed operator authority cannot create reconciliation audit evidence', + ); + database.close(); +}); diff --git a/tests/unit/billing-checkout-reconciliation.test.mjs b/tests/unit/billing-checkout-reconciliation.test.mjs new file mode 100644 index 00000000..27d6ff4c --- /dev/null +++ b/tests/unit/billing-checkout-reconciliation.test.mjs @@ -0,0 +1,286 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { + BILLING_CHECKOUT_REUSE_WINDOW_MS, + createSqliteBillingCheckoutAttemptRepository, + installBillingCheckoutAttemptSchema, +} from '../../server/billing_checkout_attempt.mjs'; + +function createDatabase() { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON'); + database.exec('CREATE TABLE users (id INTEGER PRIMARY KEY)'); + database.exec('CREATE TABLE orgs (id INTEGER PRIMARY KEY)'); + database.prepare('INSERT INTO users(id) VALUES(?)').run(41); + database.prepare('INSERT INTO users(id) VALUES(?)').run(42); + database.prepare('INSERT INTO orgs(id) VALUES(?)').run(7); + database.prepare('INSERT INTO orgs(id) VALUES(?)').run(8); + installBillingCheckoutAttemptSchema(database); + return database; +} + +function deterministicIds() { + const values = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', + '44444444-4444-4444-8444-444444444444', + '55555555-5555-4555-8555-555555555555', + '66666666-6666-4666-8666-666666666666', + ]; + return () => { + const value = values.shift(); + assert.ok(value, 'test UUID source must not be exhausted'); + return value; + }; +} + +function createRepository(database, initialNow = 1_000_000) { + let nowMs = initialNow; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => nowMs, + }); + return { + repository, + advanceBy(milliseconds) { + nowMs += milliseconds; + }, + setNow(milliseconds) { + nowMs = milliseconds; + }, + }; +} + +function forceReconciliation(repository, advanceBy, organizationId = 7, priceId = 'price_pro') { + const attempt = repository.startAttempt({ organizationId, priceId }); + advanceBy(BILLING_CHECKOUT_REUSE_WINDOW_MS); + assert.throws( + () => repository.startAttempt({ organizationId, priceId }), + (error) => error?.code === 'billing_checkout_reconciliation_required', + ); + return attempt; +} + +test('reconciliation schema is normalized and audit events never duplicate provider secrets', () => { + const database = createDatabase(); + const table = database.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'billing_checkout_reconciliation_events'", + ).get(); + assert.ok(table, 'reconciliation audit table must be installed during bootstrap'); + assert.match(table.sql, /attempt_id TEXT NOT NULL UNIQUE/); + assert.match(table.sql, /resolved_by_user_id INTEGER NOT NULL/); + assert.match(table.sql, /evidence_reference TEXT NOT NULL/); + + const columns = database.prepare("PRAGMA table_info('billing_checkout_reconciliation_events')") + .all() + .map((row) => row.name); + assert.deepEqual(columns, [ + 'reconciliation_event_id', + 'attempt_id', + 'resolved_by_user_id', + 'provider_resolution', + 'provider_session_id', + 'evidence_reference', + 'resolved_at_ms', + ]); + assert.equal(columns.some((name) => /idempotency|secret|token|hash/i.test(name)), false); + + const foreignKeys = database.prepare("PRAGMA foreign_key_list('billing_checkout_reconciliation_events')") + .all(); + assert.equal(foreignKeys.some((row) => row.table === 'billing_checkout_attempts' && row.from === 'attempt_id'), true); + assert.equal(foreignKeys.some((row) => row.table === 'users' && row.from === 'resolved_by_user_id'), true); +}); + +test('inspection and metric expose reconciliation work without idempotency authority', () => { + const database = createDatabase(); + const { repository, advanceBy } = createRepository(database); + const attempt = forceReconciliation(repository, advanceBy); + + assert.equal(repository.countReconciliationRequired(), 1); + assert.equal(repository.countReconciliationRequired({ organizationId: 7 }), 1); + assert.equal(repository.countReconciliationRequired({ organizationId: 8 }), 0); + + const rows = repository.listReconciliationRequired({ organizationId: 7, limit: 10 }); + assert.deepEqual(rows, [{ + attemptId: attempt.attemptId, + priceId: 'price_pro', + createdAtMs: 1_000_000, + updatedAtMs: 1_000_000 + BILLING_CHECKOUT_REUSE_WINDOW_MS, + }]); + assert.equal(JSON.stringify(rows).includes(attempt.idempotencyKey), false); + assert.deepEqual(repository.listReconciliationRequired({ organizationId: 8 }), []); +}); + +test('authoritative success resolution is tenant-scoped, audited, and unblocks a fresh checkout identity', () => { + const database = createDatabase(); + const { repository, advanceBy } = createRepository(database); + const attempt = forceReconciliation(repository, advanceBy); + + assert.throws( + () => repository.resolveReconciliation({ + organizationId: 8, + attemptId: attempt.attemptId, + resolvedByUserId: 41, + outcome: 'provider_succeeded', + providerSessionId: 'cs_live_wrong_tenant', + evidenceReference: 'stripe:event:evt_wrong_tenant', + }), + /reconciliation-required checkout attempt/i, + ); + assert.equal(repository.countReconciliationRequired({ organizationId: 7 }), 1); + + repository.resolveReconciliation({ + organizationId: 7, + attemptId: attempt.attemptId, + resolvedByUserId: 41, + outcome: 'provider_succeeded', + providerSessionId: 'cs_live_authoritative_123', + evidenceReference: 'stripe:event:evt_authoritative_123', + }); + + assert.equal(repository.countReconciliationRequired({ organizationId: 7 }), 0); + const resolved = database.prepare(` + SELECT attempt_state, provider_session_id + FROM billing_checkout_attempts + WHERE attempt_id = ? + `).get(attempt.attemptId); + assert.deepEqual({ ...resolved }, { + attempt_state: 'provider_succeeded', + provider_session_id: 'cs_live_authoritative_123', + }); + + const audit = database.prepare(` + SELECT attempt_id, resolved_by_user_id, provider_resolution, + provider_session_id, evidence_reference, resolved_at_ms + FROM billing_checkout_reconciliation_events + WHERE attempt_id = ? + `).get(attempt.attemptId); + assert.deepEqual({ ...audit }, { + attempt_id: attempt.attemptId, + resolved_by_user_id: 41, + provider_resolution: 'provider_succeeded', + provider_session_id: 'cs_live_authoritative_123', + evidence_reference: 'stripe:event:evt_authoritative_123', + resolved_at_ms: 1_000_000 + BILLING_CHECKOUT_REUSE_WINDOW_MS, + }); + assert.equal(JSON.stringify(audit).includes(attempt.idempotencyKey), false); + + const fresh = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + assert.notEqual(fresh.attemptId, attempt.attemptId); + assert.notEqual(fresh.idempotencyKey, attempt.idempotencyKey); +}); + +test('authoritative failure resolution closes the held identity without inventing a provider session', () => { + const database = createDatabase(); + const { repository, advanceBy } = createRepository(database, 2_000_000); + const attempt = forceReconciliation(repository, advanceBy); + + repository.resolveReconciliation({ + organizationId: 7, + attemptId: attempt.attemptId, + resolvedByUserId: 42, + outcome: 'provider_failed', + evidenceReference: 'stripe:request-log:req_confirmed_failed', + }); + + const resolved = database.prepare(` + SELECT attempt_state, provider_session_id + FROM billing_checkout_attempts + WHERE attempt_id = ? + `).get(attempt.attemptId); + assert.deepEqual({ ...resolved }, { + attempt_state: 'provider_failed', + provider_session_id: null, + }); + const audit = database.prepare(` + SELECT provider_resolution, provider_session_id, resolved_by_user_id + FROM billing_checkout_reconciliation_events + WHERE attempt_id = ? + `).get(attempt.attemptId); + assert.deepEqual({ ...audit }, { + provider_resolution: 'provider_failed', + provider_session_id: null, + resolved_by_user_id: 42, + }); +}); + +test('resolution validates evidence, actor, outcome, provider session, limits, and tenant identifiers', () => { + const database = createDatabase(); + const { repository, advanceBy } = createRepository(database); + const attempt = forceReconciliation(repository, advanceBy); + + const base = { + organizationId: 7, + attemptId: attempt.attemptId, + resolvedByUserId: 41, + outcome: 'provider_failed', + evidenceReference: 'stripe:event:evt_known_failure', + }; + const invalidInputs = [ + [{ ...base, organizationId: 0 }, /organizationId/], + [{ ...base, attemptId: '' }, /attemptId/], + [{ ...base, resolvedByUserId: 0 }, /resolvedByUserId/], + [{ ...base, outcome: 'unknown' }, /outcome/], + [{ ...base, evidenceReference: ' ' }, /evidenceReference/], + [{ ...base, evidenceReference: 'stripe:event:\nforged' }, /evidenceReference/], + [{ ...base, evidenceReference: 'x'.repeat(513) }, /evidenceReference/], + [{ ...base, outcome: 'provider_succeeded' }, /providerSessionId/], + [{ ...base, outcome: 'provider_failed', providerSessionId: 'cs_should_not_exist' }, /providerSessionId/], + ]; + for (const [input, expected] of invalidInputs) { + assert.throws(() => repository.resolveReconciliation(input), expected); + } + for (const limit of [0, -1, 101, 1.5]) { + assert.throws( + () => repository.listReconciliationRequired({ organizationId: 7, limit }), + /limit/, + ); + } + assert.throws( + () => repository.countReconciliationRequired({ organizationId: -1 }), + /organizationId/, + ); + assert.equal(repository.countReconciliationRequired({ organizationId: 7 }), 1); +}); + +test('audit persistence failure rolls the state transition back and clock rollback stays monotonic', () => { + const database = createDatabase(); + const { repository, advanceBy, setNow } = createRepository(database, 8_000_000); + const attempt = forceReconciliation(repository, advanceBy); + + assert.throws( + () => repository.resolveReconciliation({ + organizationId: 7, + attemptId: attempt.attemptId, + resolvedByUserId: 999, + outcome: 'provider_failed', + evidenceReference: 'stripe:event:evt_missing_actor', + }), + /FOREIGN KEY|constraint/i, + ); + assert.equal(repository.countReconciliationRequired({ organizationId: 7 }), 1); + assert.equal( + database.prepare('SELECT COUNT(*) AS n FROM billing_checkout_reconciliation_events').get().n, + 0, + ); + + setNow(1); + repository.resolveReconciliation({ + organizationId: 7, + attemptId: attempt.attemptId, + resolvedByUserId: 41, + outcome: 'provider_failed', + evidenceReference: 'stripe:event:evt_clock_rollback_resolved', + }); + const timestamps = database.prepare(` + SELECT a.created_at_ms, a.updated_at_ms, e.resolved_at_ms + FROM billing_checkout_attempts a + JOIN billing_checkout_reconciliation_events e ON e.attempt_id = a.attempt_id + WHERE a.attempt_id = ? + `).get(attempt.attemptId); + assert.equal(timestamps.updated_at_ms >= timestamps.created_at_ms, true); + assert.equal(timestamps.resolved_at_ms, timestamps.updated_at_ms); +}); diff --git a/tests/unit/billing-checkout-review-regressions.test.mjs b/tests/unit/billing-checkout-review-regressions.test.mjs index a028ae83..b754c69f 100644 --- a/tests/unit/billing-checkout-review-regressions.test.mjs +++ b/tests/unit/billing-checkout-review-regressions.test.mjs @@ -108,6 +108,7 @@ test('malformed successful Stripe responses keep the durable retry identity unre function createCheckoutAttemptFixture(startTimeMs) { const database = new DatabaseSync(':memory:'); database.exec('PRAGMA foreign_keys = ON'); + database.exec('CREATE TABLE users (id INTEGER PRIMARY KEY)'); database.exec('CREATE TABLE orgs (id INTEGER PRIMARY KEY)'); database.prepare('INSERT INTO orgs(id) VALUES(?)').run(7); installBillingCheckoutAttemptSchema(database); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 82b9c8a3..802d0dd9 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -49,6 +49,11 @@ assert.match( /tests\/unit\/billing-checkout-attempt\.test\.mjs/, 'the durable Checkout-attempt regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/billing-checkout-reconciliation\.test\.mjs/, + 'the Checkout reconciliation operator regression executes under c8', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/billing-provider-boundary\.test\.mjs/,