diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index fe4f2548..4113125f 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -62,7 +62,7 @@ Every issue inherits `AGENTS.md` and the definition of done in `IMPLEMENTATION_P | 10 | ABUSE-001 | Enforce native App Check and privacy-preserving abuse limits | P0 | L | partial: Enterprise browser source/tests tracked in [#159](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/159); provider config, native enforcement, rate limits, and live proof open | CI-001 baseline | | 11 | PAY-001 | Add strict request schemas and immutable monetary snapshots | P0 | L | partial: PAY-001A source/tests tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157); B/C/D and endpoint adoption open | ABUSE-001 interface agreed | | 12 | PROMO-001 | Disable unmodeled Stripe promotions until discounts are authoritative | P0 | S | source/tests complete under open GitHub issue #102; provider inventory and deployment remain owner action | PAY-001 for any future discount contract | -| 13 | PAY-002 | Implement idempotent payment commands and explicit state machines | P0 | L | ready after PAY-001 | CONFIG-001, PAY-001 | +| 13 | PAY-002 | Implement idempotent payment commands and explicit state machines | P0 | L | partial: PAY-002A1 pure source/tests tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161); runtime adoption, migration, and B-D open | CONFIG-001, PAY-001 | | 14 | PAY-003 | Build idempotent, async-aware Stripe webhook ingestion | P0 | L | PAY-003A source merged in #101; PAY-003B/C remain open; not deployed | CONFIG-001, PAY-001/PAY-002 target contract | | 15 | RACE-001 | Add transactional race-capacity reservations | P0 | L | proposed | PAY-002, PAY-003 event contract | | 16 | MERCH-001 | Add SKU variants and transactional inventory reservations | P0 | L | proposed | PAY-002, PAY-003 event contract | @@ -624,16 +624,22 @@ The local webhook/creator patch is a safety default, not a complete discount fea ## PAY-002 — Implement idempotent payment commands and explicit state machines **Labels:** `priority:P0`, `type:reliability`, `type:security`, `area:stripe`, `area:firebase`, `size:L`, `needs-migration` -**Status:** Ready after PAY-001 +**Status:** Partial. PAY-002A1 pure source/tests are tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). Runtime adoption, real legacy inventory/migration, command identity, and checkout sagas remain open. **Depends on:** CONFIG-001 and PAY-001 +### Current atomic boundary + +PAY-002A1 defines only a dependency-free version-1 target: separate payment, registration, fulfillment, confirmed-refund, and per-dispute reducers; fixed cross-dimension validation; and a synthetic legacy classifier/redacted aggregate report. Orders/registrations have no canonical singular `disputeStatus`; each dispute is validated separately against paid payment context. Operational `fulfilled`, `transferred`, and `cancelled` legacy values require separate compatible payment evidence rather than manufacturing payment state. It does not import Firebase/Stripe, expose a Function, change the webhook, write a compatibility field, enumerate a real record, or deploy. + +The next boundaries remain separate. A later inventory/migration child must use #113-approved data categories and a dry-run before writes. PAY-002B owns caller-scoped command/attempt identity and idempotency journals. PAY-002C/D own persistence-first race/shop sagas. PAY-003B/#106 owns webhook adoption and Stripe API-version adaptation. PAY-004/PAY-005 own cancellation and refund operations. + ### Problem Checkout Session, Stripe Product/Price/Payment Link, and Refund creation have no idempotency keys. One overloaded `status` field mixes payment, registration, fulfillment, cancellation, refund, transfer, and dispute state. Network loss/retry can duplicate external operations; impossible transitions are currently allowed. ### Scope -- Define separate `paymentStatus`, `registrationStatus`, `fulfillmentStatus`, `refundStatus`, and `disputeStatus` with an allowed transition matrix. +- Define separate `paymentStatus`, `registrationStatus`, `fulfillmentStatus`, confirmed `refundStatus`, and one record/state per dispute with allowed structural transition matrices. Any singular dispute summary is derived compatibility only. - Keep/derive legacy `status` during an additive migration. - Require client/admin `commandId` UUIDs; hash caller scope + ID into `checkoutRequests`/`commands` records with payload fingerprint. - Reusing the same ID/payload returns the same result; different payload fails. diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index 9260a8a0..d9e1d742 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -60,8 +60,9 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | PAY-001B | proposed; PAY-001A | Apply race/volunteer/free schemas, validate server event custom fields/options, and persist price/waiver snapshots. | Positive/hostile dynamic-field and snapshot tests. | | PAY-001C | proposed; PAY-001A | Apply merchandise/variant/quantity schemas and immutable merchandise price snapshots. | Variant/quantity/money/payload tests. | | PAY-001D | proposed; PAY-001A, AUTH-003A | Apply bounded schemas to lookup/refund/cancel/substitute/late/comp/role/tracking/export commands. | Endpoint matrix with hostile/boundary/no-side-effect tests. | -| PAY-002A | proposed; PAY-001A | Build pure separate payment/registration/fulfillment/refund/dispute reducers and additive legacy mapping. | Exhaustive allowed/forbidden/reordered transition tests and dry-run map. | -| PAY-002B | proposed; PAY-002A | Build caller-scoped command journal, UUID/payload fingerprint, same-result replay, conflict rejection, lease/attempt/error/audit, and Stripe key scheme. | Concurrent duplicate/conflict and injected-failure emulator tests. | +| PAY-002A1 | source/tests tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161); PAY-001A; no runtime adoption/migration/deploy | Build pure version-1 payment/registration/fulfillment/confirmed-refund/per-dispute reducers, cross-dimension validation, and a synthetic legacy classifier/redacted aggregate report. | Exhaust every state pair, retry/reorder/terminal/composite/legacy/hostile-input case; no Firebase/Stripe/logger call or record patch. | +| PAY-002A2 | proposed; PAY-002A1, #113 owner-approved inventory | Use an owner-approved private read-only inventory for any real legacy/provider references (no mutation/export or copying into tests/issues), define one context-aware compatibility derivation, and use only synthetic emulator fixtures for the dry-run/idempotent migration plan. | Redacted counts/reasons for every approved fixture; repeat synthetic dry-run is identical; no production write until separately approved. | +| PAY-002B | proposed; PAY-002A1 | Build caller-scoped command journal, UUID/payload fingerprint, same-result replay, conflict rejection, lease/attempt/error/audit, and Stripe key scheme. | Concurrent duplicate/conflict and injected-failure emulator tests. | | PAY-002C | proposed; PAY-001B, PAY-002B, RACE-001B | Convert race checkout to persistence-first record/hold plus deterministic Stripe Session saga. | One record/Session on retry at every failure boundary. | | PAY-002D | proposed; PAY-001C, PAY-002B, MERCH-001B | Convert merchandise/catalog creation to persistence-first order/hold plus deterministic Stripe keys. | One order/Session/catalog object on retry; failure recovery tests. | @@ -70,11 +71,11 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | ID | Status/dependency | Bounded deliverable | Close evidence | | --- | --- | --- | --- | | PAY-003A | source merged in [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) via [PR #140](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/140); not deployed | Review/land current ingress slice: signature/method, explicit livemode, payment mode, event ledger, reference resolution, money/adjustment checks, monotonic refunds, terminal guards, quarantine, generic errors, webhook-only secret. | Node 20 Functions lint and signed positive/negative/duplicate/out-of-order unit suite passed; protected deployment, Stripe destination rehearsal, and live verification remain open. | -| PAY-003B | proposed; PAY-002A–D, RACE/MERCH reducers | Move event handling to strict versioned schemas and canonical reducers; atomically finalize/release counters and outbox projections. | Emulator transactions for both domains, distinct duplicates, reorder, async and terminal cases. | +| PAY-003B | proposed; PAY-002A1, PAY-002B-D, RACE-001B, MERCH-001B; PAY-002A2/#113 before any legacy/provider-path adoption | Inventory/validate the Stripe endpoint Event API version, move handling to strict versioned schemas/canonical reducers, and atomically finalize/release counters and outbox projections. Same-state Events must still apply monotonic amount/evidence changes after Event-ID dedupe. | Versioned/mismatch signed fixtures, emulator transactions for both domains, distinct duplicates, reorder, direct async failure, same-state higher partial-refund total, inquiry escalation, late win, and terminal cases. | | PAY-003C | owner_action; A/B, CI-001A | Add retry/quarantine worker/lease semantics, TTL/dead-letter monitoring, and Stripe CLI test-mode delivery rehearsal. | Redacted signed immediate/delayed/retry/rotation evidence and alerts. | -| RACE-001A | proposed; PAY-002A | Add capacity/hold schema, expiry/indexes, compatibility reader, and dry-run counter backfill. | Full/closed/legacy fixture report and idempotent rerun. | -| RACE-001B | proposed; A, PAY-002B | Implement transaction-safe reserve/confirm/release across paid/free/comp/volunteer/admin paths. | Repeated concurrent last-seat, duplicate, expiry and nonnegative-counter tests. | -| MERCH-001A | proposed; PAY-001C, PAY-002A | Add canonical SKU/variant and conventional `onHand`, `reserved`, `sold` schema plus catalog migration: `available = onHand - reserved`. | Legacy/size/color/zero-stock migration fixtures and invariant report. | +| RACE-001A | proposed; PAY-002A1, PAY-002A2 for legacy/backfill adoption | Add capacity/hold schema, expiry/indexes, compatibility reader, and dry-run counter backfill. | Full/closed/legacy fixture report and idempotent rerun. | +| RACE-001B | proposed; RACE-001A, PAY-002B | Implement transaction-safe reserve/confirm/release across paid/free/comp/volunteer/admin paths. | Repeated concurrent last-seat, duplicate, expiry and nonnegative-counter tests. | +| MERCH-001A | proposed; PAY-001C, PAY-002A1, PAY-002A2 for legacy/backfill adoption | Add canonical SKU/variant and conventional `onHand`, `reserved`, `sold` schema plus catalog migration: `available = onHand - reserved`. | Legacy/size/color/zero-stock migration fixtures and invariant report. | | MERCH-001B | proposed; A, PAY-002B | Implement deterministic multi-SKU reserve; on paid decrement `reserved` and `onHand`, increment `sold`; release only `reserved`; approved restock increments `onHand`. | Concurrent last-SKU/multi-line/retry/return tests; no negative stock. | | PAY-004A | proposed; PAY-002B/C, RACE-001B | Implement one-record cancellation that expires Session/releases hold with already-paid/expired/network race recovery. | Retry and payment/cancel race tests; no resurrection. | | PAY-004B | proposed; A, RACE/MERCH services | Implement bulk event/product closure as bounded idempotent jobs over active Sessions/holds. | Partial failure/resume/rerun report. | @@ -84,8 +85,8 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | ID | Status/dependency | Bounded deliverable | Close evidence | | --- | --- | --- | --- | -| PAY-005A | proposed; PAY-001D, PAY-002A/B | Add idempotent refund commands, remaining-balance/concurrency validation, stable Stripe keys, and event-finalized totals. | Same/conflicting/concurrent partial/full/timeout tests. | -| PAY-005B | proposed; PAY-003B | Model dispute lifecycle for orders and registrations with finance alert references. | Duplicate/reordered created/updated/lost/won fixtures. | +| PAY-005A | proposed; PAY-001D, PAY-002A1, PAY-002B, PAY-003B | Add idempotent refund commands, remaining-balance/concurrency validation, stable Stripe keys, and event-finalized totals. | Same/conflicting/concurrent partial/full/timeout and repeated `partially_refunded` status with increasing confirmed total tests. | +| PAY-005B | proposed; PAY-003B | Model per-dispute lifecycle for orders and registrations with finance alert references. | Duplicate/reordered warning lifecycle, inquiry escalation, created/updated/lost/won, rare lost-to-won late win, and `prevented` only under an explicitly validated upgraded webhook Event version. | | PAY-005C | proposed; A/B | Build read-only scheduled/operator reconciliation and mismatch/quarantine report. | Orphan/mismatch/refund/event/counter synthetic report. | | PAY-005D | proposed; C, AUTH-003B | Build separately authorized idempotent repair commands with reason and append audit. | Dry-run/apply/rerun and forbidden-capability tests. | | MAIL-001A | proposed; PAY-003B | Build deterministic escaped transactional outbox and truthful queued/delivery state for registration/order confirmation. | Retry/concurrency/hostile-HTML/provider-failure tests. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 8cea0248..3bcde717 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -125,7 +125,7 @@ Exit gate: - Concurrent last-seat and last-SKU tests prove no oversell. - Session creation is persistence-first, and known failures release holds once. -**Current work:** A PAY-003 webhook safety slice is implemented and unit-verified in this repository pass after the high-level design was recorded. It must not be treated as the complete payment core until PAY-001/PAY-002, capacity, inventory, provider/emulator integration, and production operations close. +**Current work:** A PAY-003 webhook safety slice is implemented and unit-verified. PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for the pure versioned structural transition contract and synthetic legacy classification only. It does not prove duplicate Events/commands, evidence, authorization, provider state, or policy. Runtime adoption, command identity, real migration, capacity, inventory, provider/emulator integration, and production operations remain open. ### Phase 3 — Complete lifecycle and customer safety @@ -213,7 +213,7 @@ The first tranche deliberately combines one urgent safety repair with the most c 3. **SEC-001 — Firestore trust boundaries:** #100 merged through [PR #123](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/123) at `a7fc301e85b0aeabe396e771faea21d3fc8e7b2b`. Source and 295 Rules tests remove recursive browser-admin authority. Firebase deployment explicitly skipped, so deployed/live Rules remain unproven under #105. 4. **PROMO-001 — fail-safe monetary policy:** [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) disables promotion entry and automatic tax in both current creators, adds exact payload tests, and quarantines unknown or nonzero discount/tax/shipping adjustments on success, failure, and expiry. The private pre-change Session/provider inventory, protected deployment, provider readback, and live verification remain owner actions; no discount, tax, or shipping feature is authorized. 5. **Identity/config next:** AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) merged at `ce22c110`, proving source/tests for unverified-target rejection at the existing role-grant endpoints. Its Firebase deployment skipped, so backend-live status is unproven and the remaining AUTH-001 parent work stays open. CONFIG-001A merged through [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) / PR #150 as `2c62b8dd`, but remains undeployed. CONFIG-001B1 is tracked in [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) for fail-closed read-only admission; B2 retains the protected audited operator control, provider-object recovery, and drill. Follow the live issues and do not duplicate these slices. -6. **PAY-001/PAY-002:** PAY-001A source/tests are tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-001B/C/D must still wire exact endpoint schemas and immutable snapshots before PAY-002 can make checkout creation, refunds, and later handlers idempotent. +6. **PAY-001/PAY-002:** PAY-001A source/tests are tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-002A1 is tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for pure separate state reducers and synthetic legacy classification. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; PAY-002B-D must add command identity and persistence-first sagas; PAY-003B must adopt the shared reducer. No current endpoint or record changes in A1. 7. **Then:** RACE-001 and MERCH-001 transactional reservations. Why this order: SAFETY-001 removes immediate accidental-production and callback hazards without schema impact. PAY-003 closes the most dangerous “unpaid looks paid” and replay gaps while establishing test fixtures. The subsequent trust-boundary and command/state work completes the surrounding architecture; no live launch is authorized by the partial webhook patch alone. diff --git a/SECURITY.md b/SECURITY.md index 18d97387..a6fa360d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -77,7 +77,7 @@ The findings below describe the repository at the start of the 2026-07-12 assess | ID | Finding | Required treatment | | --- | --- | --- | -| RISK-030 | Stripe API version is pinned to `2023-10-16` with an old Stripe SDK and no documented upgrade cadence. | Schedule test-mode API/SDK upgrades, inspect breaking changes, and pin intentionally. | +| RISK-030 | Server-side Stripe client requests select `2023-10-16`, but webhook endpoint Event versioning is configured independently at Stripe and is unverified; ingress does not validate `event.api_version`. The SDK is old and no upgrade cadence is documented. | Privately inventory the endpoint Event version; validate/pin the supported contract; schedule test-mode API/SDK and webhook-version upgrades with breaking-change fixtures before accepting newer enums. | | RISK-031 | Lazy Stripe Product creation is concurrency-prone and reachable from anonymous checkout traffic. | Move catalog synchronization to authenticated/idempotent management functions. | | RISK-032 | `auditLog` arrays grow within primary documents and are editable by broad admins. | Move to append-oriented, immutable-by-client audit records with retention/export rules. | | RISK-033 | CSV export mitigates formula injection, but roster export has no explicit re-auth, download audit, row limit/streaming limit, or data-minimization profiles. | Add scoped exports, reason/re-auth, audit, minimum columns, safe filename IDs, and large-export controls. | @@ -95,6 +95,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Risks | Source result | Remaining closure evidence | | --- | --- | --- | +| Foundation only for parts of RISK-011, RISK-020, RISK-021 | PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). The source-only child defines versioned pure payment, registration, fulfillment, confirmed-refund, and per-dispute structural reducers; fixed cross-dimension review/rejection; and a deterministic synthetic legacy classifier/aggregate report with no IDs. Orders/registrations carry no singular canonical dispute state. Forbidden regressions fail closed, refunds/disputes do not overwrite payment state, and `paid_after_cancellation` remains paid plus cancelled plus review rather than becoming a payment enum. | This does not yet mitigate those risks in a runtime. An unchanged enum does not prove duplicate Event/command/evidence, authorization, amount monotonicity, provider state, or club policy. No endpoint imports the library and no record is read/written or migrated. PAY-002B must add command/attempt identity; PAY-002C/D persistence-first checkout; PAY-003B Event/version/evidence adoption including same-state amount changes and separate dispute records; PAY-004/PAY-005 cancellation/refund operations; MERCH-002/LEGAL-001 return/policy gates; #113 and a later migration child real inventory/dry-run. Firebase/Stripe deployment and live behavior remain unproven. | | RISK-008 | ABUSE-001A1 is tracked in live [#159](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/159). The source-only slice replaces the browser reCAPTCHA v3 constructor with the pinned Firebase SDK's Enterprise provider and preserves local/test/capability-callback shutdown, missing-key behavior, token refresh, Analytics shutdown, and fixed redacted failure diagnostics. Synthetic tests use no real key or provider call. | No Enterprise key, allowed-domain policy, provider console setting, protected build variable, token exchange, staged metric, callable enforcement, website/Firebase publication, or live behavior is configured or proven. Release `29254280177` failed closed before build because the public site-key variable was absent. Complete #113/#133, ABUSE-001A2, #136/WEB-001, staged metrics, and provider readback before enforcement or a live claim. | | RISK-017 | PAY-001A is tracked in live [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157). The source slice adds dependency-free strict-root-object, root-budget, bounded string/array, integer-cents, fixed-currency, calendar-date, canonical-URL, conservative email, fixed-error, and closed-enum safe-log primitives. Synthetic hostile-input tests prove deep immutable copies and reject prototypes, accessors, and unknown configured root keys. The libraries contain no network, Firebase, Stripe, logger, clock, or random dependency and make no such call themselves; a supplied array item parser must be trusted, pure, and synchronous. | This library is not imported by an endpoint and is not deployed. PAY-001B/C/D must define exact race, merchandise, lookup, refund, admin, nested-object, and custom-field schemas; apply smaller business limits before every write/provider call; store immutable schema/price snapshots; and add endpoint no-side-effect tests. Safe-log primitives do not replace approved observability purpose, retention, access, transport, or provider controls. | | RISK-001, RISK-002, RISK-034 | PAY-003A [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) source merged as `87bd1210575882cdd06214bcb98ed38ce00f71c2`, adding durable Stripe Event deduplication, transactional transition/processed marking, paid/amount/currency/Checkout-mode/reference-shape and explicitly configured livemode checks, async outcomes, monotonic refund/terminal guards, quarantine, generic signature failures, and a webhook-secret-only binding. | Firebase was not deployed. Complete CONFIG/PAY-001/PAY-002 contracts, metadata schema allowlist/migration, canonical reducers/reservations/outbox, retry/dead-letter/TTL/alerts, emulator integration, Stripe test-mode rehearsal, protected deploy, and reconcile. | diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 5f13324e..2de09421 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -362,7 +362,7 @@ Store actual total, discount, tax, shipping, Stripe customer reference if requir ## 12. Payment and business state machines -Payment and operational state should be separate. +Payment, registration, fulfillment, confirmed refunds, and each dispute are separate dimensions. PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) as the pure version-1 source/test contract. It does not change the current webhook, write a record, or migrate/deploy anything. ### Payment state @@ -374,28 +374,46 @@ stateDiagram-v2 checkout_creating --> checkout_failed checkout_open --> processing checkout_open --> paid + checkout_open --> failed checkout_open --> expired checkout_open --> cancelled processing --> paid processing --> failed - paid --> refund_pending - refund_pending --> partially_refunded - refund_pending --> refunded - paid --> disputed - partially_refunded --> disputed - disputed --> paid: won/closed without loss - disputed --> refunded: lost/returned ``` +Text alternative: one Checkout attempt moves forward from creating to open, then to processing or one terminal result. A raw state regression never starts a second attempt; PAY-002B must give that attempt its own command identity. + ### Registration state -`reserved -> confirmed -> attended|no_show|transferred|cancelled`. Waiver acceptance and eligibility are attributes/evidence, not inferred from payment state. A substitute must accept the applicable waiver; an admin cannot silently transfer the original person's acceptance. +`reserved -> confirmed|cancelled`; then `confirmed -> attended|no_show|transferred|cancelled`. Waiver acceptance and eligibility are attributes/evidence, not inferred from payment state. A substitute must accept the applicable waiver; an admin cannot silently transfer the original person's acceptance. ### Fulfillment state -`unfulfilled -> picking -> packed -> shipped|ready_for_pickup -> delivered|picked_up`, with separate `cancelled`, `return_requested`, `returned`, and `written_off` paths as needed. +`unfulfilled -> picking -> packed -> shipped|ready_for_pickup -> delivered|picked_up`. The three active pre-handoff states (`unfulfilled`, `picking`, and `packed`) may instead move to `cancelled`. Completed legacy orders map only to `fulfilled_legacy`; that state does not pretend delivery or pickup is known. Completed states may enter `return_requested -> returned -> written_off`. Cancellation is a separate terminal operational result. + +### Confirmed refund aggregate + +```mermaid +stateDiagram-v2 + [*] --> none + none --> partially_refunded + none --> refunded + partially_refunded --> refunded +``` + +Text alternative: the status summarizes refund evidence already confirmed by Stripe. Verified cumulative integer cents remain a separate PAY-003B/PAY-005 field, and another confirmed partial refund can raise that total while the status stays `partially_refunded`. A pending refund command is a separate PAY-005 operation, so a later request cannot erase an earlier confirmed partial refund. -During migration, the existing single `status` remains as a derived compatibility field. New code writes explicit states first and derives the legacy field in one place. +### Per-dispute state + +Each Stripe dispute has its own state. An order or registration never stores one canonical `disputeStatus`, because a payment can rarely have more than one dispute. A separately authorized per-dispute record stores its own status; any singular business-record summary is derived compatibility only. Formal disputes move from `needs_response` to `under_review`, `won`, or `lost`. Inquiries use `warning_needs_response`, `warning_under_review`, and `warning_closed`, but an inquiry can escalate to `needs_response` on the same Dispute. Stripe also documents a rare late-win correction from `lost` to `won`. The current [Stripe Dispute object](https://docs.stripe.com/api/disputes/object) includes `prevented`, which was added in [API version 2025-08-27.basil](https://docs.stripe.com/changelog/basil/2025-08-27/add-preventions-to-dispute). + +The server's Stripe client requests select `2023-10-16`, but [webhook endpoint Event versioning](https://docs.stripe.com/webhooks/versioning) is independent and the provider setting is unverified. Current webhook source neither accepts `prevented` nor validates `event.api_version`. PAY-003B must inventory and pin/validate the endpoint Event contract, then add fixtures before adopting any newer value. + +A same-state observation is `unchanged`, not proven duplicate. A different Event can carry a higher confirmed partial-refund total or new dispute evidence without changing the enum. PAY-003's Event inbox and PAY-002B/PAY-005 command IDs—not this reducer—prove replay. Because Stripe can deliver the first observed Event out of order, the pure reducer can accept a structurally supported first observation while the provider adapter remains responsible for signature, object, version, ownership, amount, currency, environment, and evidence checks. + +Every allowed edge is only a structurally possible change. It never authorizes a caller or proves a waiver, refund decision, Session state, return/write-off decision, or provider fact. PAY-004, PAY-005, MERCH-002, LEGAL-001, scoped authorization, and provider validation must supply those gates before runtime adoption. + +The #161 classifier uses only synthetic fixtures. It can split known legacy meaning into a canonical projection and fixed review reasons, but emits no write patch. Stored Session, Payment Link, PaymentIntent, Charge, refund, and dispute IDs prove only references, never provider state. Stripe IDs are opaque bounded strings; A1 does not infer their type from a fixed prefix or format. A reference that could contradict the legacy status returns review-required with no guessed projection; dispute references require separate records. Operational legacy `fulfilled`, `transferred`, or `cancelled` status never proves payment: without separate compatible payment evidence, classification returns review-required with no projection. A later inventory/adoption child must keep the existing single `status` readable, define one write-time compatibility derivation with domain context, retrieve provider truth safely, and dry-run before any additive migration. ## 13. Cancellation, expiry, and late registration @@ -405,6 +423,8 @@ During migration, the existing single `status` remains as a derived compatibilit - If Session expiry fails transiently, keep a cancellation-pending state and retry; do not merely mark local cancelled while payment remains possible. - Cancelling a paid record requires an explicit refund/no-refund policy and permission. It cannot be the same operation as unpaid cancellation. +`cancellation_pending` is a PAY-004 command/saga operation state, not a canonical payment status in PAY-002A1. Payment remains at its last verified value until the provider result and compensating action are known. + ### Expiry - Handle `checkout.session.expired` for both registrations and orders. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index ec817e75..2b526eed 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -251,12 +251,16 @@ The first release may continue using `admin` in the UI, but server endpoints and | `products/{id}/variants/{variantId}` | SKU, option values, price, on-hand, reserved, and sold counts | | `orders.paymentStatus` and `orders.fulfillmentStatus` | Separate money state from physical fulfillment state | | `registrations.paymentStatus` and `registrations.registrationStatus` | Separate payment lifecycle from attendance/transfer/cancellation lifecycle | +| `orders/registrations.stateSchemaVersion` and `refundStatus` | Version the split business-record state contract and keep confirmed refund status/total separate from payment/operational state | +| server-only per-dispute records | Keep one state per Stripe dispute; never collapse multiple disputes into one canonical order/registration `disputeStatus` | | `retentionJobs/{jobId}` | Optional operational record of scheduled minimization/deletion work | | `systemConfig/commerce` | Versioned global/domain command admission; browser read/write denied; protected writer is not available yet | | `events/{id}.checkoutEnabled` and `products/{id}.checkoutEnabled` | Explicit server-owned resource admission; missing means disabled | Large `auditLog` arrays on registration and order documents should be replaced before they approach Firestore document-size and write-contention limits. New audit data should be append-oriented. +PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). Numeric local `stateSchemaVersion: 1` is distinct from string Stripe metadata `schemaVersion: "1"`, which only versions provider reference binding. The reducers and legacy classifier are a pure source/test target: they import no Firebase or Stripe code, make no call, and are not used by an endpoint. Current records, webhook behavior, compatibility writes, real migration, deployment, and live state remain unchanged until later PAY-002/PAY-003 children adopt the contract. + ## 7. Business invariants The following are correctness rules, not UI preferences: @@ -359,7 +363,7 @@ Merchandise uses the same checkout saga but reserves a specific SKU/variant. Str ### 8.4 Refund -An authorized finance action creates a refund request with a stable idempotency key. Stripe executes the money movement; the webhook is the canonical confirmation. The local record may show `refund_pending` while waiting. Repeated clicks or network retries must return the original refund rather than create another one. A full registration refund may release capacity according to event policy; a merchandise refund does not modify inventory until the return/stock disposition is explicitly recorded. +An authorized finance action creates a separate refund-operation record with a stable idempotency key. Stripe executes the money movement; the webhook is the canonical confirmation. That operation may show `refund_pending` while waiting, but the order/registration aggregate `refundStatus` remains at its last confirmed value. Repeated clicks or network retries must return the original refund rather than create another one. A full registration refund may release capacity according to event policy; a merchandise refund does not modify inventory until the return/stock disposition is explicitly recorded. ### 8.5 Webhook processing diff --git a/functions/commerceState.js b/functions/commerceState.js new file mode 100644 index 00000000..c1ecda80 --- /dev/null +++ b/functions/commerceState.js @@ -0,0 +1,991 @@ +const { types: { isProxy } } = require('node:util'); + +const stateSchemaVersion = 1; + +function immutableEnum(values) { + return Object.freeze(Object.fromEntries(values.map((value) => [ + value.toUpperCase(), + value, + ]))); +} + +const PaymentState = immutableEnum([ + 'not_required', + 'checkout_creating', + 'checkout_open', + 'checkout_failed', + 'processing', + 'paid', + 'failed', + 'expired', + 'cancelled', +]); + +const RegistrationState = immutableEnum([ + 'reserved', + 'confirmed', + 'attended', + 'no_show', + 'transferred', + 'cancelled', +]); + +const FulfillmentState = immutableEnum([ + 'unfulfilled', + 'picking', + 'packed', + 'shipped', + 'ready_for_pickup', + 'delivered', + 'picked_up', + 'return_requested', + 'returned', + 'written_off', + 'cancelled', + 'fulfilled_legacy', +]); + +const ConfirmedRefundState = immutableEnum([ + 'none', + 'partially_refunded', + 'refunded', +]); + +const DisputeState = immutableEnum([ + 'none', + 'warning_needs_response', + 'warning_under_review', + 'warning_closed', + 'needs_response', + 'under_review', + 'won', + 'lost', + 'prevented', +]); + +const PAYMENT_STATES = new Set(Object.values(PaymentState)); +const REGISTRATION_STATES = new Set(Object.values(RegistrationState)); +const FULFILLMENT_STATES = new Set(Object.values(FulfillmentState)); +const CONFIRMED_REFUND_STATES = new Set(Object.values(ConfirmedRefundState)); +const DISPUTE_STATES = new Set(Object.values(DisputeState)); +const LEGACY_REGISTRATION_STATUSES = new Set([ + 'pending', + 'paid', + 'comp', + 'cancelled', + 'transferred', + 'partially_refunded', + 'refunded', +]); +const LEGACY_ORDER_STATUSES = new Set([ + 'pending', + 'paid', + 'fulfilled', + 'cancelled', + 'partially_refunded', + 'refunded', +]); + +const PAYMENT_TRANSITIONS = Object.freeze({ + checkout_creating: Object.freeze(['checkout_open', 'checkout_failed']), + checkout_open: Object.freeze(['processing', 'paid', 'failed', 'expired', 'cancelled']), + processing: Object.freeze(['paid', 'failed']), +}); + +const REGISTRATION_TRANSITIONS = Object.freeze({ + reserved: Object.freeze(['confirmed', 'cancelled']), + confirmed: Object.freeze(['attended', 'no_show', 'transferred', 'cancelled']), +}); + +const FULFILLMENT_TRANSITIONS = Object.freeze({ + unfulfilled: Object.freeze(['picking', 'cancelled']), + picking: Object.freeze(['packed', 'cancelled']), + packed: Object.freeze(['shipped', 'ready_for_pickup', 'cancelled']), + shipped: Object.freeze(['delivered']), + ready_for_pickup: Object.freeze(['picked_up']), + delivered: Object.freeze(['return_requested']), + picked_up: Object.freeze(['return_requested']), + fulfilled_legacy: Object.freeze(['return_requested']), + return_requested: Object.freeze(['returned']), + returned: Object.freeze(['written_off']), +}); + +const REFUND_TRANSITIONS = Object.freeze({ + none: Object.freeze(['partially_refunded', 'refunded']), + partially_refunded: Object.freeze(['refunded']), +}); + +const DISPUTE_TRANSITIONS = Object.freeze({ + none: Object.freeze(Object.values(DisputeState).filter((state) => state !== 'none')), + needs_response: Object.freeze(['under_review', 'won', 'lost']), + under_review: Object.freeze(['won', 'lost']), + warning_needs_response: Object.freeze([ + 'warning_under_review', + 'warning_closed', + 'needs_response', + ]), + warning_under_review: Object.freeze(['warning_closed', 'needs_response']), + lost: Object.freeze(['won']), +}); + +const FIXED_REASONS = Object.freeze({ + APPLIED: 'transition_applied', + SAME_STATE: 'same_state', + FORBIDDEN: 'transition_forbidden', + UNKNOWN_STATE: 'unknown_state', + INVALID_KIND: 'invalid_kind', + INVALID_RECORD: 'invalid_record', + INVALID_CANONICAL_STATE: 'invalid_canonical_state', + REFUND_WITHOUT_PAID: 'refund_without_paid', + DISPUTE_WITHOUT_PAID: 'dispute_without_paid', + REGISTRATION_WITHOUT_PAYMENT: 'registration_without_payment', + FULFILLMENT_WITHOUT_PAYMENT: 'fulfillment_without_payment', + PAID_DOMAIN_CANCELLED: 'paid_domain_cancelled', + NONTERMINAL_PAYMENT_CANCELLED: 'nonterminal_payment_domain_cancelled', + LEGACY_STATUS_UNKNOWN: 'legacy_status_unknown', + LEGACY_PROVIDER_REFERENCE_INVALID: 'legacy_provider_reference_invalid', + LEGACY_PROVIDER_STATE_UNVERIFIED: 'legacy_provider_state_unverified', + LEGACY_PAYMENT_STATE_UNVERIFIED: 'legacy_payment_state_unverified', + LEGACY_DISPUTE_REQUIRES_SEPARATE_RECORD: 'legacy_dispute_requires_separate_record', + LEGACY_OPERATION_CONFLICT: 'legacy_operational_evidence_conflict', + LEGACY_PAYMENT_CONFLICT: 'legacy_payment_evidence_conflict', + LEGACY_TIMESTAMP_INVALID: 'legacy_timestamp_invalid', + LEGACY_PAID_AFTER_CANCELLATION: 'legacy_paid_after_cancellation', + INVALID_BATCH: 'invalid_batch', + INVALID_BATCH_ENTRY: 'invalid_batch_entry', +}); + +function frozenReasons(reasons) { + return Object.freeze([...new Set(reasons)].sort()); +} + +function transitionResult(accepted, outcome, changed, state, reason) { + return Object.freeze({ accepted, outcome, changed, state, reason }); +} + +function reduceState(current, next, knownStates, transitions) { + const currentKnown = typeof current === 'string' && knownStates.has(current); + const nextKnown = typeof next === 'string' && knownStates.has(next); + if (!currentKnown || !nextKnown) { + return transitionResult( + false, + 'rejected', + false, + currentKnown ? current : null, + FIXED_REASONS.UNKNOWN_STATE, + ); + } + if (current === next) { + return transitionResult(true, 'unchanged', false, current, FIXED_REASONS.SAME_STATE); + } + if ((transitions[current] || []).includes(next)) { + return transitionResult(true, 'applied', true, next, FIXED_REASONS.APPLIED); + } + return transitionResult(false, 'rejected', false, current, FIXED_REASONS.FORBIDDEN); +} + +function reducePaymentState(current, next) { + return reduceState(current, next, PAYMENT_STATES, PAYMENT_TRANSITIONS); +} + +function reduceRegistrationState(current, next) { + return reduceState(current, next, REGISTRATION_STATES, REGISTRATION_TRANSITIONS); +} + +function reduceFulfillmentState(current, next) { + return reduceState(current, next, FULFILLMENT_STATES, FULFILLMENT_TRANSITIONS); +} + +function reduceConfirmedRefundState(current, next) { + return reduceState(current, next, CONFIRMED_REFUND_STATES, REFUND_TRANSITIONS); +} + +function reduceDisputeState(current, next) { + return reduceState(current, next, DISPUTE_STATES, DISPUTE_TRANSITIONS); +} + +function safeOwnData(value, maximumEntries = 100) { + if (value === null || typeof value !== 'object' || isProxy(value)) return null; + + let prototype; + let keys; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + return null; + } + if (prototype !== Object.prototype || keys.length > maximumEntries) return null; + + const entries = new Map(); + for (const key of keys) { + if (typeof key !== 'string') return null; + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + return null; + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + return null; + } + entries.set(key, descriptor.value); + } + + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) return null; + } + return entries; +} + +function freezeProjection(kind, values) { + const projection = kind === 'registration' + ? { + stateSchemaVersion, + paymentStatus: values.paymentStatus, + registrationStatus: values.registrationStatus, + refundStatus: values.refundStatus, + } + : { + stateSchemaVersion, + paymentStatus: values.paymentStatus, + fulfillmentStatus: values.fulfillmentStatus, + refundStatus: values.refundStatus, + }; + return Object.freeze(projection); +} + +function validationResult(accepted, status, reasons, projection) { + return Object.freeze({ + accepted, + status, + reasons: frozenReasons(reasons), + projection, + }); +} + +function canonicalCandidate(kind, entries) { + if (kind === 'registration') { + return { + stateSchemaVersion: entries.get('stateSchemaVersion'), + paymentStatus: entries.get('paymentStatus'), + registrationStatus: entries.get('registrationStatus'), + refundStatus: entries.get('refundStatus'), + }; + } + return { + stateSchemaVersion: entries.get('stateSchemaVersion'), + paymentStatus: entries.get('paymentStatus'), + fulfillmentStatus: entries.get('fulfillmentStatus'), + refundStatus: entries.get('refundStatus'), + }; +} + +function hasExactCanonicalKeys(kind, entries) { + const expected = kind === 'registration' + ? ['stateSchemaVersion', 'paymentStatus', 'registrationStatus', 'refundStatus'] + : ['stateSchemaVersion', 'paymentStatus', 'fulfillmentStatus', 'refundStatus']; + return entries.size === expected.length && expected.every((key) => entries.has(key)); +} + +function validateCommerceState(kind, candidate) { + if (kind !== 'registration' && kind !== 'order') { + return validationResult(false, 'rejected', [FIXED_REASONS.INVALID_KIND], null); + } + const entries = safeOwnData(candidate, 4); + if (!entries || !hasExactCanonicalKeys(kind, entries)) { + return validationResult(false, 'rejected', [FIXED_REASONS.INVALID_RECORD], null); + } + const values = canonicalCandidate(kind, entries); + const domainState = kind === 'registration' + ? values.registrationStatus + : values.fulfillmentStatus; + if (values.stateSchemaVersion !== stateSchemaVersion + || !PAYMENT_STATES.has(values.paymentStatus) + || !CONFIRMED_REFUND_STATES.has(values.refundStatus) + || (kind === 'registration' && !REGISTRATION_STATES.has(domainState)) + || (kind === 'order' && !FULFILLMENT_STATES.has(domainState))) { + return validationResult( + false, + 'rejected', + [FIXED_REASONS.INVALID_CANONICAL_STATE], + null, + ); + } + + const projection = freezeProjection(kind, values); + const rejected = []; + const review = []; + if (values.refundStatus !== 'none' && values.paymentStatus !== 'paid') { + rejected.push(FIXED_REASONS.REFUND_WITHOUT_PAID); + } + if (kind === 'registration' + && ['confirmed', 'attended', 'no_show', 'transferred'].includes(domainState) + && !['paid', 'not_required'].includes(values.paymentStatus)) { + rejected.push(FIXED_REASONS.REGISTRATION_WITHOUT_PAYMENT); + } + if (kind === 'order' + && !['unfulfilled', 'cancelled'].includes(domainState) + && values.paymentStatus !== 'paid') { + rejected.push(FIXED_REASONS.FULFILLMENT_WITHOUT_PAYMENT); + } + if (domainState === 'cancelled' && values.paymentStatus === 'paid') { + review.push(FIXED_REASONS.PAID_DOMAIN_CANCELLED); + } + if (domainState === 'cancelled' + && ['checkout_creating', 'checkout_open', 'processing'].includes(values.paymentStatus)) { + review.push(FIXED_REASONS.NONTERMINAL_PAYMENT_CANCELLED); + } + + if (rejected.length > 0) { + return validationResult(false, 'rejected', rejected, projection); + } + if (review.length > 0) { + return validationResult(true, 'review_required', review, projection); + } + return validationResult(true, 'valid', [], projection); +} + +function validateDisputeState(candidate) { + const entries = safeOwnData(candidate, 3); + if (!entries + || entries.size !== 3 + || !['stateSchemaVersion', 'paymentStatus', 'disputeStatus'] + .every((key) => entries.has(key))) { + return validationResult(false, 'rejected', [FIXED_REASONS.INVALID_RECORD], null); + } + const version = entries.get('stateSchemaVersion'); + const paymentStatus = entries.get('paymentStatus'); + const disputeStatus = entries.get('disputeStatus'); + if (version !== stateSchemaVersion + || !PAYMENT_STATES.has(paymentStatus) + || !DISPUTE_STATES.has(disputeStatus)) { + return validationResult( + false, + 'rejected', + [FIXED_REASONS.INVALID_CANONICAL_STATE], + null, + ); + } + const projection = Object.freeze({ + stateSchemaVersion, + status: disputeStatus, + }); + if (disputeStatus !== 'none' && paymentStatus !== 'paid') { + return validationResult( + false, + 'rejected', + [FIXED_REASONS.DISPUTE_WITHOUT_PAID], + projection, + ); + } + return validationResult(true, 'valid', [], projection); +} + +function classificationResult(accepted, status, reasons, projection) { + return Object.freeze({ + accepted, + status, + reasons: frozenReasons(reasons), + projection, + }); +} + +function presentDataValue(value) { + return value !== undefined && value !== null; +} + +function providerReferenceIsSafe(value) { + return typeof value === 'string' + && value.length > 0 + && value.length <= 255; +} + +function providerReference(entries, key) { + const value = entries.get(key); + if (!presentDataValue(value)) return { present: false, invalid: false }; + if (!providerReferenceIsSafe(value)) { + return { present: false, invalid: true }; + } + return { present: true, invalid: false }; +} + +function providerReferenceList(entries, key) { + const value = entries.get(key); + if (!presentDataValue(value)) return { present: false, invalid: false }; + const values = safeArrayValues(value, 100); + if (!values || values.some((item) => !providerReferenceIsSafe(item))) { + return { present: false, invalid: true }; + } + return { present: values.length > 0, invalid: false }; +} + +function disputeEvidencePresent(entries) { + const singular = providerReference(entries, 'stripeDisputeId'); + const list = providerReferenceList(entries, 'stripeDisputeIds'); + return singular.present + || singular.invalid + || list.present + || list.invalid + || (entries.has('stripeDisputes') + && presentDataValue(entries.get('stripeDisputes'))); +} + +function safeTimestampData(value) { + if (value === null || typeof value !== 'object' || isProxy(value)) return null; + let keys; + try { + keys = Reflect.ownKeys(value); + } catch { + return null; + } + if (keys.length !== 2 || keys.some((key) => typeof key !== 'string')) return null; + const entries = new Map(); + for (const key of keys) { + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + return null; + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + return null; + } + entries.set(key, descriptor.value); + } + return entries; +} + +function timestampDataIsValid(entries, secondsKey, nanosecondsKey) { + return entries.size === 2 + && entries.has(secondsKey) + && entries.has(nanosecondsKey) + && Number.isSafeInteger(entries.get(secondsKey)) + && entries.get(secondsKey) >= -62135596800 + && entries.get(secondsKey) <= 253402300799 + && Number.isSafeInteger(entries.get(nanosecondsKey)) + && entries.get(nanosecondsKey) >= 0 + && entries.get(nanosecondsKey) <= 999999999; +} + +function timestampEvidence(entries, key, reasons) { + const value = entries.get(key); + if (!presentDataValue(value)) return false; + const data = safeTimestampData(value); + const serialized = data && ( + timestampDataIsValid(data, 'seconds', 'nanoseconds') + || timestampDataIsValid(data, '_seconds', '_nanoseconds') + ); + if (!serialized) { + reasons.push(FIXED_REASONS.LEGACY_TIMESTAMP_INVALID); + return null; + } + return true; +} + +function readLegacyEvidence(entries, reasons) { + const evidence = { + paidAt: timestampEvidence(entries, 'paidAt', reasons), + refundedAt: timestampEvidence(entries, 'refundedAt', reasons), + fulfilledAt: timestampEvidence(entries, 'fulfilledAt', reasons), + cancelledAt: timestampEvidence(entries, 'cancelledAt', reasons), + }; + return Object.values(evidence).includes(null) ? null : Object.freeze(evidence); +} + +function compatiblePaymentState(value) { + if (PAYMENT_STATES.has(value)) return value; + if (value === 'paid_after_cancellation') return 'paid'; + if (value === 'partially_refunded' || value === 'refunded') return 'paid'; + return null; +} + +function legacyPaymentForCancelled(entries, reasons) { + const rawPayment = entries.get('paymentStatus'); + if (!presentDataValue(rawPayment)) { + reasons.push(FIXED_REASONS.LEGACY_PAYMENT_STATE_UNVERIFIED); + return null; + } + if (rawPayment === 'partially_refunded' || rawPayment === 'refunded') { + reasons.push(FIXED_REASONS.LEGACY_PAYMENT_CONFLICT); + return null; + } + const compatible = compatiblePaymentState(rawPayment); + if (!compatible) { + reasons.push(FIXED_REASONS.LEGACY_PAYMENT_CONFLICT); + return null; + } + if (rawPayment === 'paid_after_cancellation') { + reasons.push(FIXED_REASONS.LEGACY_PAID_AFTER_CANCELLATION); + } + return compatible; +} + +function applyCompatiblePayment(entries, expected, alternatives, reasons) { + const rawPayment = entries.get('paymentStatus'); + if (!presentDataValue(rawPayment)) return expected; + if ((rawPayment === 'partially_refunded' || rawPayment === 'refunded') + && rawPayment !== entries.get('status')) { + reasons.push(FIXED_REASONS.LEGACY_PAYMENT_CONFLICT); + return null; + } + if (rawPayment === 'paid_after_cancellation' + && entries.get('status') !== 'cancelled') { + reasons.push(FIXED_REASONS.LEGACY_PAYMENT_CONFLICT); + return null; + } + const compatible = compatiblePaymentState(rawPayment); + if (!compatible) { + reasons.push(FIXED_REASONS.LEGACY_PAYMENT_CONFLICT); + return null; + } + if (compatible === expected || alternatives.includes(compatible)) { + return compatible; + } + reasons.push(FIXED_REASONS.LEGACY_PAYMENT_CONFLICT); + return null; +} + +function requireCompatiblePayment(entries, expected, alternatives, reasons) { + if (!presentDataValue(entries.get('paymentStatus'))) { + reasons.push(FIXED_REASONS.LEGACY_PAYMENT_STATE_UNVERIFIED); + return null; + } + return applyCompatiblePayment(entries, expected, alternatives, reasons); +} + +function legacyRegistrationProjection(entries, evidence, reasons) { + const status = entries.get('status'); + const { cancelledAt } = evidence; + const common = { refundStatus: 'none' }; + switch (status) { + case 'pending': { + const paymentStatus = applyCompatiblePayment(entries, 'checkout_creating', [ + 'checkout_open', + 'checkout_failed', + 'processing', + 'failed', + 'expired', + 'cancelled', + ], reasons); + if (!paymentStatus) return null; + return { + ...common, + paymentStatus, + registrationStatus: 'reserved', + }; + } + case 'paid': { + const paymentStatus = applyCompatiblePayment(entries, 'paid', [], reasons); + if (!paymentStatus) return null; + return { + ...common, + paymentStatus, + registrationStatus: cancelledAt ? 'cancelled' : 'confirmed', + }; + } + case 'comp': { + const paymentStatus = applyCompatiblePayment(entries, 'not_required', [], reasons); + if (!paymentStatus) return null; + return { + ...common, + paymentStatus, + registrationStatus: cancelledAt ? 'cancelled' : 'confirmed', + }; + } + case 'transferred': { + const paymentStatus = requireCompatiblePayment( + entries, + 'paid', + ['not_required'], + reasons, + ); + if (!paymentStatus) return null; + return { + ...common, + paymentStatus, + registrationStatus: 'transferred', + }; + } + case 'partially_refunded': + case 'refunded': { + const paymentStatus = applyCompatiblePayment(entries, 'paid', [], reasons); + if (!paymentStatus) return null; + return { + ...common, + paymentStatus, + registrationStatus: cancelledAt ? 'cancelled' : 'confirmed', + refundStatus: status, + }; + } + case 'cancelled': { + const paymentStatus = legacyPaymentForCancelled(entries, reasons); + if (!paymentStatus) return null; + return { + ...common, + paymentStatus, + registrationStatus: 'cancelled', + }; + } + default: + return null; + } +} + +function legacyOrderProjection(entries, evidence, reasons) { + const status = entries.get('status'); + const { fulfilledAt, cancelledAt } = evidence; + const common = { refundStatus: 'none' }; + if (fulfilledAt && cancelledAt) { + reasons.push(FIXED_REASONS.LEGACY_OPERATION_CONFLICT); + return null; + } + + let fulfillmentStatus = 'unfulfilled'; + if (fulfilledAt || status === 'fulfilled') fulfillmentStatus = 'fulfilled_legacy'; + if (cancelledAt || status === 'cancelled') fulfillmentStatus = 'cancelled'; + + switch (status) { + case 'pending': { + const paymentStatus = applyCompatiblePayment(entries, 'checkout_creating', [ + 'checkout_open', + 'checkout_failed', + 'processing', + 'failed', + 'expired', + 'cancelled', + ], reasons); + if (!paymentStatus) return null; + return { + ...common, + paymentStatus, + fulfillmentStatus, + }; + } + case 'paid': { + const paymentStatus = applyCompatiblePayment(entries, 'paid', [], reasons); + if (!paymentStatus) return null; + return { + ...common, + paymentStatus, + fulfillmentStatus, + }; + } + case 'fulfilled': { + const paymentStatus = requireCompatiblePayment(entries, 'paid', [], reasons); + if (!paymentStatus) return null; + return { + ...common, + paymentStatus, + fulfillmentStatus, + }; + } + case 'partially_refunded': + case 'refunded': { + const paymentStatus = applyCompatiblePayment(entries, 'paid', [], reasons); + if (!paymentStatus) return null; + return { + ...common, + paymentStatus, + fulfillmentStatus, + refundStatus: status, + }; + } + case 'cancelled': { + const paymentStatus = legacyPaymentForCancelled(entries, reasons); + if (!paymentStatus) return null; + return { + ...common, + paymentStatus, + fulfillmentStatus, + }; + } + default: + return null; + } +} + +function appendLegacyEvidenceConflicts(kind, entries, evidence, reasons) { + const status = entries.get('status'); + const { + paidAt, + refundedAt, + fulfilledAt, + cancelledAt, + } = evidence; + + if (fulfilledAt && cancelledAt + || (status === 'pending' && (fulfilledAt || cancelledAt)) + || (kind === 'order' && status === 'fulfilled' && cancelledAt) + || (kind === 'order' && status === 'cancelled' && fulfilledAt) + || (kind === 'registration' && fulfilledAt) + || (kind === 'registration' + && ['paid', 'comp', 'transferred'].includes(status) + && cancelledAt)) { + reasons.push(FIXED_REASONS.LEGACY_OPERATION_CONFLICT); + } + + const statusesCompatibleWithPaidAt = kind === 'registration' + ? ['paid', 'transferred', 'partially_refunded', 'refunded'] + : ['paid', 'fulfilled', 'partially_refunded', 'refunded']; + if (paidAt && !statusesCompatibleWithPaidAt.includes(status)) { + reasons.push(FIXED_REASONS.LEGACY_PAYMENT_CONFLICT); + } + if (refundedAt && !['partially_refunded', 'refunded'].includes(status)) { + reasons.push(FIXED_REASONS.LEGACY_PAYMENT_CONFLICT); + } +} + +function classifyLegacyRecord(kind, record) { + if (kind !== 'registration' && kind !== 'order') { + return classificationResult(false, 'rejected', [FIXED_REASONS.INVALID_KIND], null); + } + const entries = safeOwnData(record); + if (!entries) { + return classificationResult(false, 'rejected', [FIXED_REASONS.INVALID_RECORD], null); + } + + if (entries.has('stateSchemaVersion')) { + if (entries.has('disputeStatus')) { + return classificationResult( + false, + 'rejected', + [FIXED_REASONS.INVALID_CANONICAL_STATE], + null, + ); + } + if (disputeEvidencePresent(entries)) { + return classificationResult( + true, + 'review_required', + [FIXED_REASONS.LEGACY_DISPUTE_REQUIRES_SEPARATE_RECORD], + null, + ); + } + const validation = validateCommerceState(kind, canonicalCandidate(kind, entries)); + if (!validation.accepted) { + return classificationResult( + false, + 'rejected', + [FIXED_REASONS.INVALID_CANONICAL_STATE, ...validation.reasons], + validation.projection, + ); + } + if (validation.status === 'review_required') { + return classificationResult(true, 'review_required', validation.reasons, + validation.projection); + } + return classificationResult(true, 'already_canonical', [], validation.projection); + } + + const legacyStatuses = kind === 'registration' + ? LEGACY_REGISTRATION_STATUSES + : LEGACY_ORDER_STATUSES; + if (!legacyStatuses.has(entries.get('status'))) { + return classificationResult( + false, + 'rejected', + [FIXED_REASONS.LEGACY_STATUS_UNKNOWN], + null, + ); + } + + const reasons = []; + const paymentReferences = [ + providerReference(entries, 'stripeSessionId'), + providerReference(entries, 'stripePaymentLinkId'), + providerReference(entries, 'stripePaymentIntentId'), + providerReference(entries, 'stripeChargeId'), + ]; + const refundReferences = [ + providerReferenceList(entries, 'stripeRefundIds'), + ]; + const allReferences = [ + ...paymentReferences, + ...refundReferences, + ]; + if (allReferences.some((reference) => reference.invalid)) { + reasons.push(FIXED_REASONS.LEGACY_PROVIDER_REFERENCE_INVALID); + return classificationResult(true, 'review_required', reasons, null); + } + const paymentProviderPresent = paymentReferences.some((reference) => reference.present); + const refundProviderPresent = refundReferences.some((reference) => reference.present); + if (disputeEvidencePresent(entries) + || (entries.has('disputeStatus') && entries.get('disputeStatus') !== 'none')) { + reasons.push(FIXED_REASONS.LEGACY_DISPUTE_REQUIRES_SEPARATE_RECORD); + return classificationResult(true, 'review_required', reasons, null); + } + if (refundProviderPresent + && !['partially_refunded', 'refunded'].includes(entries.get('status'))) { + reasons.push(FIXED_REASONS.LEGACY_PROVIDER_STATE_UNVERIFIED); + return classificationResult(true, 'review_required', reasons, null); + } + const evidence = readLegacyEvidence(entries, reasons); + if (!evidence) { + return classificationResult(true, 'review_required', reasons, null); + } + appendLegacyEvidenceConflicts(kind, entries, evidence, reasons); + if (reasons.includes(FIXED_REASONS.LEGACY_OPERATION_CONFLICT) + || reasons.includes(FIXED_REASONS.LEGACY_PAYMENT_CONFLICT)) { + return classificationResult(true, 'review_required', reasons, null); + } + if (paymentProviderPresent + && (['pending', 'cancelled', 'comp'].includes(entries.get('status')) + || entries.get('paymentStatus') === 'not_required')) { + reasons.push(FIXED_REASONS.LEGACY_PROVIDER_STATE_UNVERIFIED); + return classificationResult(true, 'review_required', reasons, null); + } + const values = kind === 'registration' + ? legacyRegistrationProjection(entries, evidence, reasons) + : legacyOrderProjection(entries, evidence, reasons); + + if (!values && reasons.length > 0) { + return classificationResult(true, 'review_required', reasons, null); + } + if (!values) { + return classificationResult( + false, + 'rejected', + [FIXED_REASONS.LEGACY_STATUS_UNKNOWN], + null, + ); + } + + const projection = freezeProjection(kind, values); + const validation = validateCommerceState(kind, projection); + if (!validation.accepted) { + return classificationResult(false, 'rejected', validation.reasons, projection); + } + const combinedReasons = [...reasons, ...validation.reasons]; + if (combinedReasons.length > 0) { + return classificationResult(true, 'review_required', combinedReasons, projection); + } + return classificationResult(true, 'mapped', [], projection); +} + +function safeArrayValues(value, maximumEntries = 1000) { + if (value === null || typeof value !== 'object' || isProxy(value)) return null; + if (!Array.isArray(value)) return null; + + let prototype; + let keys; + let lengthDescriptor; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + } catch { + return null; + } + if (prototype !== Array.prototype + || !lengthDescriptor + || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0 + || lengthDescriptor.value > maximumEntries) { + return null; + } + const length = lengthDescriptor.value; + const values = new Array(length); + const found = new Set(); + for (const key of keys) { + if (key === 'length') continue; + if (typeof key !== 'string' || !/^(0|[1-9][0-9]*)$/.test(key)) return null; + const index = Number(key); + if (!Number.isSafeInteger(index) || index >= length) return null; + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + return null; + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + return null; + } + values[index] = descriptor.value; + found.add(index); + } + if (found.size !== length) return null; + return values; +} + +function fixedCountObject(keys, source = {}) { + return Object.freeze(Object.fromEntries(keys.map((key) => [key, source[key] || 0]))); +} + +function summaryResult(accepted, total, statuses, reasons) { + const fixedStatuses = fixedCountObject([ + 'mapped', + 'already_canonical', + 'review_required', + 'rejected', + ], statuses); + const fixedReasonCounts = Object.freeze(Object.fromEntries( + Object.entries(reasons).sort(([left], [right]) => left.localeCompare(right)), + )); + return Object.freeze({ + accepted, + total, + statuses: fixedStatuses, + reasons: fixedReasonCounts, + }); +} + +function summarizeLegacyRecords(batch) { + const values = safeArrayValues(batch); + if (!values) { + return summaryResult(false, 0, { rejected: 1 }, { [FIXED_REASONS.INVALID_BATCH]: 1 }); + } + + const statuses = {}; + const reasons = {}; + for (const entry of values) { + const data = safeOwnData(entry, 2); + let result; + if (!data || data.size !== 2 || !data.has('kind') || !data.has('record')) { + result = classificationResult( + false, + 'rejected', + [FIXED_REASONS.INVALID_BATCH_ENTRY], + null, + ); + } else { + result = classifyLegacyRecord(data.get('kind'), data.get('record')); + } + statuses[result.status] = (statuses[result.status] || 0) + 1; + result.reasons.forEach((reason) => { + reasons[reason] = (reasons[reason] || 0) + 1; + }); + } + return summaryResult(true, values.length, statuses, reasons); +} + +module.exports = Object.freeze({ + stateSchemaVersion, + PaymentState, + RegistrationState, + FulfillmentState, + ConfirmedRefundState, + DisputeState, + reducePaymentState, + reduceRegistrationState, + reduceFulfillmentState, + reduceConfirmedRefundState, + reduceDisputeState, + validateCommerceState, + validateDisputeState, + classifyLegacyRecord, + summarizeLegacyRecords, +}); diff --git a/functions/commerceState.test.js b/functions/commerceState.test.js new file mode 100644 index 00000000..574663f5 --- /dev/null +++ b/functions/commerceState.test.js @@ -0,0 +1,1094 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const { + stateSchemaVersion, + PaymentState, + RegistrationState, + FulfillmentState, + ConfirmedRefundState, + DisputeState, + reducePaymentState, + reduceRegistrationState, + reduceFulfillmentState, + reduceConfirmedRefundState, + reduceDisputeState, + validateCommerceState, + validateDisputeState, + classifyLegacyRecord, + summarizeLegacyRecords, +} = require('./commerceState'); + +const HOSTILE_CANARY = 'private-runner@example.test/token?secret=do-not-copy'; + +const MATRICES = [ + { + name: 'payment', + states: Object.values(PaymentState), + reduce: reducePaymentState, + allowed: { + checkout_creating: ['checkout_open', 'checkout_failed'], + checkout_open: ['processing', 'paid', 'failed', 'expired', 'cancelled'], + processing: ['paid', 'failed'], + }, + }, + { + name: 'registration', + states: Object.values(RegistrationState), + reduce: reduceRegistrationState, + allowed: { + reserved: ['confirmed', 'cancelled'], + confirmed: ['attended', 'no_show', 'transferred', 'cancelled'], + }, + }, + { + name: 'fulfillment', + states: Object.values(FulfillmentState), + reduce: reduceFulfillmentState, + allowed: { + unfulfilled: ['picking', 'cancelled'], + picking: ['packed', 'cancelled'], + packed: ['shipped', 'ready_for_pickup', 'cancelled'], + shipped: ['delivered'], + ready_for_pickup: ['picked_up'], + delivered: ['return_requested'], + picked_up: ['return_requested'], + fulfilled_legacy: ['return_requested'], + return_requested: ['returned'], + returned: ['written_off'], + }, + }, + { + name: 'refund', + states: Object.values(ConfirmedRefundState), + reduce: reduceConfirmedRefundState, + allowed: { + none: ['partially_refunded', 'refunded'], + partially_refunded: ['refunded'], + }, + }, + { + name: 'dispute', + states: Object.values(DisputeState), + reduce: reduceDisputeState, + allowed: { + none: Object.values(DisputeState).filter((state) => state !== 'none'), + needs_response: ['under_review', 'won', 'lost'], + under_review: ['won', 'lost'], + warning_needs_response: ['warning_under_review', 'warning_closed', 'needs_response'], + warning_under_review: ['warning_closed', 'needs_response'], + lost: ['won'], + }, + }, +]; + +function registrationState(overrides = {}) { + return { + stateSchemaVersion: 1, + paymentStatus: 'checkout_open', + registrationStatus: 'reserved', + refundStatus: 'none', + ...overrides, + }; +} + +function orderState(overrides = {}) { + return { + stateSchemaVersion: 1, + paymentStatus: 'checkout_open', + fulfillmentStatus: 'unfulfilled', + refundStatus: 'none', + ...overrides, + }; +} + +function timestamp(seconds = 1) { + return { seconds, nanoseconds: 0 }; +} + +describe.each(MATRICES)('$name reducer', ({ states, reduce, allowed }) => { + test('exhausts every known state pair against the declared matrix', () => { + let pairCount = 0; + states.forEach((current) => { + states.forEach((next) => { + pairCount += 1; + const result = reduce(current, next); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.keys(result)).toEqual([ + 'accepted', + 'outcome', + 'changed', + 'state', + 'reason', + ]); + if (current === next) { + expect(result).toEqual({ + accepted: true, + outcome: 'unchanged', + changed: false, + state: current, + reason: 'same_state', + }); + } else if ((allowed[current] || []).includes(next)) { + expect(result).toEqual({ + accepted: true, + outcome: 'applied', + changed: true, + state: next, + reason: 'transition_applied', + }); + } else { + expect(result).toEqual({ + accepted: false, + outcome: 'rejected', + changed: false, + state: current, + reason: 'transition_forbidden', + }); + } + }); + }); + expect(pairCount).toBe(states.length ** 2); + }); + + test('rejects unknown and non-string states without echoing input', () => { + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const first = reduce(HOSTILE_CANARY, states[0]); + const second = reduce(states[0], { canary: HOSTILE_CANARY }); + expect(first.reason).toBe('unknown_state'); + expect(first.state).toBeNull(); + expect(second.reason).toBe('unknown_state'); + expect(JSON.stringify([first, second])).not.toContain(HOSTILE_CANARY); + expect(consoleLog).not.toHaveBeenCalled(); + expect(consoleWarn).not.toHaveBeenCalled(); + } finally { + consoleLog.mockRestore(); + consoleWarn.mockRestore(); + } + }); +}); + +describe('immutable state contract', () => { + test('exports version one and frozen closed enums', () => { + expect(stateSchemaVersion).toBe(1); + for (const stateEnum of [ + PaymentState, + RegistrationState, + FulfillmentState, + ConfirmedRefundState, + DisputeState, + ]) { + expect(Object.isFrozen(stateEnum)).toBe(true); + const before = JSON.stringify(stateEnum); + stateEnum.EXTRA = HOSTILE_CANARY; + expect(JSON.stringify(stateEnum)).toBe(before); + } + expect(PaymentState).not.toHaveProperty('REFUND_PENDING'); + expect(PaymentState).not.toHaveProperty('PAID_AFTER_CANCELLATION'); + expect(FulfillmentState.FULFILLED_LEGACY).toBe('fulfilled_legacy'); + expect(DisputeState.PREVENTED).toBe('prevented'); + }); + + test('blocks late payment regressions and terminal refund/dispute regressions', () => { + for (const next of ['checkout_open', 'processing', 'failed', 'expired']) { + expect(reducePaymentState('paid', next).accepted).toBe(false); + } + expect(reduceConfirmedRefundState('refunded', 'partially_refunded')).toEqual({ + accepted: false, + outcome: 'rejected', + changed: false, + state: 'refunded', + reason: 'transition_forbidden', + }); + for (const terminal of ['won', 'warning_closed', 'prevented']) { + expect(reduceDisputeState(terminal, 'under_review').accepted).toBe(false); + expect(reduceDisputeState(terminal, terminal).outcome).toBe('unchanged'); + } + expect(reduceDisputeState('lost', 'won').outcome).toBe('applied'); + }); + + test('same refund status is unchanged and never claims amount or event deduplication', () => { + expect(reduceConfirmedRefundState('partially_refunded', 'partially_refunded')).toEqual({ + accepted: true, + outcome: 'unchanged', + changed: false, + state: 'partially_refunded', + reason: 'same_state', + }); + expect(reducePaymentState('checkout_open', 'failed').outcome).toBe('applied'); + expect(reduceDisputeState('warning_under_review', 'needs_response').outcome) + .toBe('applied'); + }); +}); + +describe('cross-dimension validation', () => { + test.each([ + ['registration reservation', 'registration', registrationState()], + ['paid registration', 'registration', registrationState({ + paymentStatus: 'paid', + registrationStatus: 'confirmed', + })], + ['free registration', 'registration', registrationState({ + paymentStatus: 'not_required', + registrationStatus: 'confirmed', + })], + ['open order', 'order', orderState()], + ['fulfilled order', 'order', orderState({ + paymentStatus: 'paid', + fulfillmentStatus: 'delivered', + })], + ['refunded order', 'order', orderState({ + paymentStatus: 'paid', + refundStatus: 'refunded', + })], + ])('accepts a valid %s', (_name, kind, input) => { + const before = JSON.stringify(input); + const result = validateCommerceState(kind, input); + expect(result.status).toBe('valid'); + expect(result.accepted).toBe(true); + expect(result.reasons).toEqual([]); + expect(result.projection).toEqual(input); + expect(result.projection).not.toBe(input); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.reasons)).toBe(true); + expect(Object.isFrozen(result.projection)).toBe(true); + expect(JSON.stringify(input)).toBe(before); + expect(Object.isFrozen(input)).toBe(false); + }); + + test.each([ + ['refund without paid', 'order', orderState({ refundStatus: 'partially_refunded' }), + 'refund_without_paid'], + ['confirmed registration without payment', 'registration', registrationState({ + registrationStatus: 'confirmed', + }), 'registration_without_payment'], + ['fulfillment without payment', 'order', orderState({ fulfillmentStatus: 'packed' }), + 'fulfillment_without_payment'], + ])('rejects %s', (_name, kind, input, reason) => { + const result = validateCommerceState(kind, input); + expect(result.accepted).toBe(false); + expect(result.status).toBe('rejected'); + expect(result.reasons).toContain(reason); + }); + + test.each([ + ['paid cancelled registration', 'registration', registrationState({ + paymentStatus: 'paid', + registrationStatus: 'cancelled', + }), 'paid_domain_cancelled'], + ['paid cancelled order', 'order', orderState({ + paymentStatus: 'paid', + fulfillmentStatus: 'cancelled', + }), 'paid_domain_cancelled'], + ['open cancelled order', 'order', orderState({ + fulfillmentStatus: 'cancelled', + }), 'nonterminal_payment_domain_cancelled'], + ['processing cancelled registration', 'registration', registrationState({ + paymentStatus: 'processing', + registrationStatus: 'cancelled', + }), 'nonterminal_payment_domain_cancelled'], + ['creating cancelled registration', 'registration', registrationState({ + paymentStatus: 'checkout_creating', + registrationStatus: 'cancelled', + }), 'nonterminal_payment_domain_cancelled'], + ])('preserves but requires review for %s', (_name, kind, input, reason) => { + const result = validateCommerceState(kind, input); + expect(result.accepted).toBe(true); + expect(result.status).toBe('review_required'); + expect(result.reasons).toEqual([reason]); + expect(result.projection).toEqual(input); + }); + + test('rejects missing, extra, wrong-version, and unknown canonical fields', () => { + for (const input of [ + null, + [], + { ...orderState(), extra: HOSTILE_CANARY }, + orderState({ stateSchemaVersion: 2 }), + orderState({ paymentStatus: HOSTILE_CANARY }), + orderState({ fulfillmentStatus: HOSTILE_CANARY }), + orderState({ refundStatus: HOSTILE_CANARY }), + { ...orderState(), disputeStatus: HOSTILE_CANARY }, + ]) { + const result = validateCommerceState('order', input); + expect(result.accepted).toBe(false); + expect(result.status).toBe('rejected'); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + } + }); + + test('does not invoke accessors or proxy traps and emits no diagnostic', () => { + const getter = jest.fn(() => HOSTILE_CANARY); + const accessor = orderState(); + Object.defineProperty(accessor, 'paymentStatus', { + enumerable: true, + get: getter, + }); + + const proxy = new Proxy(orderState(), { + ownKeys() { + throw new Error(HOSTILE_CANARY); + }, + }); + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + for (const input of [accessor, proxy]) { + const result = validateCommerceState('order', input); + expect(result).toEqual({ + accepted: false, + status: 'rejected', + reasons: ['invalid_record'], + projection: null, + }); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + } + expect(getter).not.toHaveBeenCalled(); + expect(consoleLog).not.toHaveBeenCalled(); + expect(consoleWarn).not.toHaveBeenCalled(); + } finally { + consoleLog.mockRestore(); + consoleWarn.mockRestore(); + } + }); +}); + +describe('per-dispute validation', () => { + test.each(Object.values(DisputeState))( + 'validates supported dispute state %s separately from the business record', + (disputeStatus) => { + const result = validateDisputeState({ + stateSchemaVersion: 1, + paymentStatus: 'paid', + disputeStatus, + }); + expect(result).toEqual({ + accepted: true, + status: 'valid', + reasons: [], + projection: { + stateSchemaVersion: 1, + status: disputeStatus, + }, + }); + expect(Object.isFrozen(result.projection)).toBe(true); + }, + ); + + test('requires paid payment context for a real dispute', () => { + const result = validateDisputeState({ + stateSchemaVersion: 1, + paymentStatus: 'processing', + disputeStatus: 'needs_response', + }); + expect(result).toEqual({ + accepted: false, + status: 'rejected', + reasons: ['dispute_without_paid'], + projection: { + stateSchemaVersion: 1, + status: 'needs_response', + }, + }); + }); + + test('rejects malformed, future-version, and extra dispute validation input', () => { + for (const candidate of [ + null, + { + stateSchemaVersion: 2, + paymentStatus: 'paid', + disputeStatus: 'won', + }, + { + stateSchemaVersion: 1, + paymentStatus: 'paid', + disputeStatus: HOSTILE_CANARY, + }, + { + stateSchemaVersion: 1, + paymentStatus: 'paid', + disputeStatus: 'won', + disputeId: HOSTILE_CANARY, + }, + ]) { + const result = validateDisputeState(candidate); + expect(result.accepted).toBe(false); + expect(result.status).toBe('rejected'); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + } + }); +}); + +describe('legacy classification', () => { + test.each([ + ['pending', { status: 'pending' }, 'checkout_creating', 'reserved', 'none'], + ['paid', { status: 'paid' }, 'paid', 'confirmed', 'none'], + ['comp', { status: 'comp' }, 'not_required', 'confirmed', 'none'], + ['cancelled', { + status: 'cancelled', + paymentStatus: 'cancelled', + }, 'cancelled', 'cancelled', 'none'], + ['transferred', { + status: 'transferred', + paymentStatus: 'paid', + }, 'paid', 'transferred', 'none'], + ['partially refunded', { status: 'partially_refunded' }, 'paid', 'confirmed', + 'partially_refunded'], + ['refunded', { status: 'refunded' }, 'paid', 'confirmed', 'refunded'], + ])('maps registration %s', (_name, record, payment, registration, refund) => { + const result = classifyLegacyRecord('registration', record); + expect(result.accepted).toBe(true); + expect(result.status).toBe('mapped'); + expect(result.projection).toEqual(registrationState({ + paymentStatus: payment, + registrationStatus: registration, + refundStatus: refund, + })); + }); + + test.each([ + ['pending', { status: 'pending' }, 'checkout_creating', 'unfulfilled', 'none'], + ['paid', { status: 'paid' }, 'paid', 'unfulfilled', 'none'], + ['fulfilled', { + status: 'fulfilled', + paymentStatus: 'paid', + }, 'paid', 'fulfilled_legacy', 'none'], + ['cancelled', { + status: 'cancelled', + paymentStatus: 'cancelled', + }, 'cancelled', 'cancelled', 'none'], + ['partially refunded', { status: 'partially_refunded' }, 'paid', 'unfulfilled', + 'partially_refunded'], + ['refunded', { status: 'refunded' }, 'paid', 'unfulfilled', 'refunded'], + ])('maps order %s', (_name, record, payment, fulfillment, refund) => { + const result = classifyLegacyRecord('order', record); + expect(result.accepted).toBe(true); + expect(result.status).toBe('mapped'); + expect(result.projection).toEqual(orderState({ + paymentStatus: payment, + fulfillmentStatus: fulfillment, + refundStatus: refund, + })); + }); + + test.each([ + ['transferred registration', 'registration', { status: 'transferred' }], + ['fulfilled order', 'order', { status: 'fulfilled' }], + ['cancelled registration', 'registration', { status: 'cancelled' }], + ['cancelled order', 'order', { status: 'cancelled' }], + ])('quarantines %s when operational state is the only payment evidence', + (_name, kind, record) => { + const result = classifyLegacyRecord(kind, record); + expect(result).toEqual({ + accepted: true, + status: 'review_required', + reasons: ['legacy_payment_state_unverified'], + projection: null, + }); + }); + + test('maps a transferred comp only when payment is explicitly not required', () => { + const result = classifyLegacyRecord('registration', { + status: 'transferred', + paymentStatus: 'not_required', + }); + expect(result.status).toBe('mapped'); + expect(result.projection.paymentStatus).toBe('not_required'); + expect(result.projection.registrationStatus).toBe('transferred'); + }); + + test('quarantines unverified provider references without exposing IDs or guessing state', () => { + const session = 'cs_test_synthetic_1'; + const pending = classifyLegacyRecord('registration', { + status: 'pending', + stripeSessionId: session, + }); + const cancelled = classifyLegacyRecord('order', { + status: 'cancelled', + stripePaymentLinkId: 'plink_synthetic_1', + }); + expect(pending.status).toBe('review_required'); + expect(pending.projection).toBeNull(); + expect(cancelled.status).toBe('review_required'); + expect(cancelled.projection).toBeNull(); + expect(pending.reasons).toEqual(['legacy_provider_state_unverified']); + expect(cancelled.reasons).toEqual(['legacy_provider_state_unverified']); + expect(JSON.stringify([pending, cancelled])).not.toContain(HOSTILE_CANARY); + }); + + test.each([ + ['PaymentIntent on pending registration', 'registration', { + status: 'pending', + stripePaymentIntentId: 'pi_synthetic_1', + }], + ['Charge on cancelled order', 'order', { + status: 'cancelled', + stripeChargeId: 'ch_synthetic_1', + }], + ['refund reference without refund status', 'order', { + status: 'paid', + stripeRefundIds: ['re_synthetic_1'], + }], + ['Charge reference on comp registration', 'registration', { + status: 'comp', + stripeChargeId: 'opaque_synthetic_charge', + }], + ['Session reference on transferred no-payment registration', 'registration', { + status: 'transferred', + paymentStatus: 'not_required', + stripeSessionId: 'opaque_synthetic_session', + }], + ])('quarantines %s without inferring provider state', (_name, kind, record) => { + const result = classifyLegacyRecord(kind, record); + expect(result).toEqual({ + accepted: true, + status: 'review_required', + reasons: ['legacy_provider_state_unverified'], + projection: null, + }); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + }); + + test.each([ + { status: 'paid', stripeDisputeId: 'dp_synthetic_1' }, + { status: 'paid', stripeDisputeIds: ['dp_synthetic_1'] }, + ])('quarantines provider dispute references as separate records', (record) => { + const result = classifyLegacyRecord('order', record); + expect(result).toEqual({ + accepted: true, + status: 'review_required', + reasons: ['legacy_dispute_requires_separate_record'], + projection: null, + }); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + }); + + test('quarantines malformed provider references without touching nested values', () => { + const nested = new Proxy({ canary: HOSTILE_CANARY }, { + get() { + throw new Error(HOSTILE_CANARY); + }, + }); + const result = classifyLegacyRecord('registration', { + status: 'pending', + stripeRefundIds: nested, + }); + expect(result).toEqual({ + accepted: true, + status: 'review_required', + reasons: ['legacy_provider_reference_invalid'], + projection: null, + }); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + }); + + test.each([ + { status: 'paid', stripeChargeId: '' }, + { status: 'refunded', stripeRefundIds: [HOSTILE_CANARY.repeat(20)] }, + { status: 'paid', stripePaymentIntentId: { canary: HOSTILE_CANARY } }, + ])('quarantines wrong-type, empty, or overlong provider references', (record) => { + const result = classifyLegacyRecord('order', record); + expect(result).toEqual({ + accepted: true, + status: 'review_required', + reasons: ['legacy_provider_reference_invalid'], + projection: null, + }); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + }); + + test('treats bounded Stripe IDs as opaque while keeping their contents redacted', () => { + const result = classifyLegacyRecord('registration', { + status: 'pending', + stripeChargeId: HOSTILE_CANARY, + }); + expect(result).toEqual({ + accepted: true, + status: 'review_required', + reasons: ['legacy_provider_state_unverified'], + projection: null, + }); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + }); + + test('still quarantines a pending provider reference beside a local payment string', () => { + const result = classifyLegacyRecord('registration', { + status: 'pending', + paymentStatus: 'processing', + stripeSessionId: 'cs_test_synthetic_1', + }); + expect(result.status).toBe('review_required'); + expect(result.reasons).toEqual(['legacy_provider_state_unverified']); + expect(result.projection).toBeNull(); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + }); + + test.each([ + ['processing', 'processing', 'nonterminal_payment_domain_cancelled'], + ['failed', 'failed', null], + ['expired', 'expired', null], + ['paid_after_cancellation', 'paid', 'legacy_paid_after_cancellation'], + ])('classifies webhook-only payment state %s', (paymentStatus, expected, reason) => { + const result = classifyLegacyRecord('registration', { + status: 'cancelled', + paymentStatus, + }); + expect(result.projection.paymentStatus).toBe(expected); + if (reason) { + expect(result.status).toBe('review_required'); + expect(result.reasons).toContain(reason); + } else { + expect(result.status).toBe('mapped'); + } + }); + + test('does not infer cancellation from a refund without cancellation evidence', () => { + const ordinary = classifyLegacyRecord('registration', { status: 'refunded' }); + const cancelled = classifyLegacyRecord('registration', { + status: 'refunded', + cancelledAt: timestamp(), + }); + expect(ordinary.projection.registrationStatus).toBe('confirmed'); + expect(cancelled.projection.registrationStatus).toBe('cancelled'); + expect(cancelled.status).toBe('review_required'); + expect(cancelled.reasons).toContain('paid_domain_cancelled'); + }); + + test('preserves legacy fulfillment without inventing delivery or pickup', () => { + const result = classifyLegacyRecord('order', { + status: 'refunded', + fulfilledAt: timestamp(), + }); + expect(result.status).toBe('mapped'); + expect(result.projection.fulfillmentStatus).toBe('fulfilled_legacy'); + expect(result.projection.refundStatus).toBe('refunded'); + }); + + test('quarantines conflicting fulfillment and cancellation evidence without a projection', () => { + const result = classifyLegacyRecord('order', { + status: 'refunded', + fulfilledAt: timestamp(), + cancelledAt: timestamp(2), + }); + expect(result).toEqual({ + accepted: true, + status: 'review_required', + reasons: ['legacy_operational_evidence_conflict'], + projection: null, + }); + }); + + test.each([ + ['fulfilled status with cancellation time', 'order', { + status: 'fulfilled', + cancelledAt: timestamp(), + }, 'legacy_operational_evidence_conflict'], + ['cancelled status with fulfillment time', 'order', { + status: 'cancelled', + fulfilledAt: timestamp(), + }, 'legacy_operational_evidence_conflict'], + ['pending status with payment time', 'registration', { + status: 'pending', + paidAt: timestamp(), + }, 'legacy_payment_evidence_conflict'], + ['cancelled status with payment time', 'order', { + status: 'cancelled', + paidAt: timestamp(), + }, 'legacy_payment_evidence_conflict'], + ['paid status with refund time', 'registration', { + status: 'paid', + refundedAt: timestamp(), + }, 'legacy_payment_evidence_conflict'], + ['confirmed legacy registration with cancellation time', 'registration', { + status: 'paid', + cancelledAt: timestamp(), + }, 'legacy_operational_evidence_conflict'], + ['pending registration with cancellation time', 'registration', { + status: 'pending', + cancelledAt: timestamp(), + }, 'legacy_operational_evidence_conflict'], + ['registration with fulfillment time', 'registration', { + status: 'refunded', + fulfilledAt: timestamp(), + }, 'legacy_operational_evidence_conflict'], + ['pending order with fulfillment time', 'order', { + status: 'pending', + fulfilledAt: timestamp(), + }, 'legacy_operational_evidence_conflict'], + ])('quarantines %s instead of letting a timestamp override status', + (_name, kind, record, reason) => { + const result = classifyLegacyRecord(kind, record); + expect(result).toEqual({ + accepted: true, + status: 'review_required', + reasons: [reason], + projection: null, + }); + }); + + test.each([ + false, + { seconds: Number.MAX_SAFE_INTEGER, nanoseconds: 0 }, + { seconds: 1, nanoseconds: 1000000000 }, + ])('quarantines malformed or out-of-range timestamp evidence', (fulfilledAt) => { + const result = classifyLegacyRecord('order', { + status: 'paid', + fulfilledAt, + }); + expect(result).toEqual({ + accepted: true, + status: 'review_required', + reasons: ['legacy_timestamp_invalid'], + projection: null, + }); + }); + + test('accepts exact Firestore Timestamp own-data shape without invoking its prototype', () => { + class FirestoreTimestampShape { + constructor() { + this._seconds = 1; + this._nanoseconds = 0; + } + } + const result = classifyLegacyRecord('order', { + status: 'refunded', + fulfilledAt: new FirestoreTimestampShape(), + }); + expect(result.status).toBe('mapped'); + expect(result.projection.fulfillmentStatus).toBe('fulfilled_legacy'); + }); + + test('maps the same ordinary legacy fixture byte-equivalently without mutation or calls', () => { + const record = { + status: 'refunded', + fulfilledAt: timestamp(), + }; + const before = JSON.stringify(record); + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const first = classifyLegacyRecord('order', record); + const second = classifyLegacyRecord('order', record); + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + expect(first.status).toBe('mapped'); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.reasons)).toBe(true); + expect(Object.isFrozen(first.projection)).toBe(true); + expect(JSON.stringify(record)).toBe(before); + expect(Object.isFrozen(record)).toBe(false); + expect(consoleLog).not.toHaveBeenCalled(); + expect(consoleWarn).not.toHaveBeenCalled(); + } finally { + consoleLog.mockRestore(); + consoleWarn.mockRestore(); + } + }); + + test('returns a deterministic no-op for valid v1 and rejects invalid v1', () => { + const valid = Object.freeze({ + ...orderState({ paymentStatus: 'paid' }), + ordinaryLegacyField: 'synthetic', + }); + const first = classifyLegacyRecord('order', valid); + const second = classifyLegacyRecord('order', valid); + expect(first.status).toBe('already_canonical'); + expect(first.projection).toEqual(orderState({ paymentStatus: 'paid' })); + expect(first.projection).not.toBe(valid); + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + + const invalid = classifyLegacyRecord('order', orderState({ + stateSchemaVersion: 2, + })); + expect(invalid.accepted).toBe(false); + expect(invalid.status).toBe('rejected'); + expect(invalid.reasons).toContain('invalid_canonical_state'); + + const singularDispute = classifyLegacyRecord('order', { + ...orderState({ paymentStatus: 'paid' }), + disputeStatus: 'won', + }); + expect(singularDispute).toEqual({ + accepted: false, + status: 'rejected', + reasons: ['invalid_canonical_state'], + projection: null, + }); + + const nestedDispute = new Proxy({ status: 'won' }, { + get() { + throw new Error(HOSTILE_CANARY); + }, + ownKeys() { + throw new Error(HOSTILE_CANARY); + }, + }); + for (const disputeEvidence of [ + { stripeDisputeIds: ['du_synthetic_1'] }, + { stripeDisputes: nestedDispute }, + ]) { + const result = classifyLegacyRecord('order', { + ...orderState({ paymentStatus: 'paid' }), + ...disputeEvidence, + }); + expect(result).toEqual({ + accepted: true, + status: 'review_required', + reasons: ['legacy_dispute_requires_separate_record'], + projection: null, + }); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + } + }); + + test.each([ + { disputeStatus: 'needs_response' }, + { stripeDisputeIds: ['du_synthetic_1'] }, + { stripeDisputes: new Proxy({}, { + get() { + throw new Error(HOSTILE_CANARY); + }, + }) }, + ])('quarantines singular or nested legacy dispute evidence', (disputeEvidence) => { + const result = classifyLegacyRecord('registration', { + status: 'paid', + ...disputeEvidence, + }); + expect(result).toEqual({ + accepted: true, + status: 'review_required', + reasons: ['legacy_dispute_requires_separate_record'], + projection: null, + }); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + }); + + test.each([ + ['invalid provider reference', { + status: HOSTILE_CANARY, + stripeSessionId: { nested: HOSTILE_CANARY }, + }], + ['conflicting timestamp', { + status: HOSTILE_CANARY, + paidAt: timestamp(), + }], + ])('keeps unknown status authoritative over %s', (_name, record) => { + const result = classifyLegacyRecord('order', record); + expect(result).toEqual({ + accepted: false, + status: 'rejected', + reasons: ['legacy_status_unknown'], + projection: null, + }); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + }); + + test('keeps local numeric state version separate from Stripe metadata schemaVersion', () => { + const metadataOnly = classifyLegacyRecord('registration', { + status: 'pending', + schemaVersion: '1', + }); + expect(metadataOnly.status).toBe('mapped'); + expect(metadataOnly.projection.stateSchemaVersion).toBe(1); + + const stringLocalVersion = classifyLegacyRecord('registration', { + ...registrationState(), + stateSchemaVersion: '1', + }); + expect(stringLocalVersion.accepted).toBe(false); + expect(stringLocalVersion.status).toBe('rejected'); + expect(stringLocalVersion.reasons).toContain('invalid_canonical_state'); + }); + + test.each([ + ['failed payment beside paid status', { status: 'paid', paymentStatus: 'failed' }], + ['refund payment beside cancelled status', { + status: 'cancelled', + paymentStatus: 'refunded', + }], + ['paid-after-cancellation beside paid status', { + status: 'paid', + paymentStatus: 'paid_after_cancellation', + }], + ['full-refund payment beside partial-refund status', { + status: 'partially_refunded', + paymentStatus: 'refunded', + }], + ['partial-refund payment beside full-refund status', { + status: 'refunded', + paymentStatus: 'partially_refunded', + }], + ])('quarantines %s without guessing a patch', (_name, record) => { + const result = classifyLegacyRecord('order', record); + expect(result).toEqual({ + accepted: true, + status: 'review_required', + reasons: ['legacy_payment_evidence_conflict'], + projection: null, + }); + }); + + test('fails closed on unknown, prototype, accessor, and proxy records', () => { + const getter = jest.fn(() => HOSTILE_CANARY); + const accessor = { status: 'pending' }; + Object.defineProperty(accessor, 'private', { + enumerable: true, + get: getter, + }); + const proxy = new Proxy({ status: 'pending' }, { + getOwnPropertyDescriptor() { + throw new Error(HOSTILE_CANARY); + }, + }); + const polluted = Object.create({ inherited: HOSTILE_CANARY }); + polluted.status = 'pending'; + const nestedProxy = new Proxy(timestamp(), { + get() { + throw new Error(HOSTILE_CANARY); + }, + ownKeys() { + throw new Error(HOSTILE_CANARY); + }, + }); + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + const consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const results = [ + classifyLegacyRecord('order', { status: HOSTILE_CANARY }), + classifyLegacyRecord('unknown', { status: 'pending' }), + classifyLegacyRecord('order', accessor), + classifyLegacyRecord('order', proxy), + classifyLegacyRecord('order', polluted), + ]; + results.forEach((result) => { + expect(result.accepted).toBe(false); + expect(result.status).toBe('rejected'); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + }); + const nested = classifyLegacyRecord('order', { + status: 'refunded', + fulfilledAt: nestedProxy, + }); + expect(nested).toEqual({ + accepted: true, + status: 'review_required', + reasons: ['legacy_timestamp_invalid'], + projection: null, + }); + expect(JSON.stringify(nested)).not.toContain(HOSTILE_CANARY); + expect(getter).not.toHaveBeenCalled(); + expect(consoleLog).not.toHaveBeenCalled(); + expect(consoleWarn).not.toHaveBeenCalled(); + } finally { + consoleLog.mockRestore(); + consoleWarn.mockRestore(); + } + }); +}); + +describe('redacted synthetic dry-run summary', () => { + test('returns counts and fixed reasons only, without source records or identifiers', () => { + const batch = [ + { kind: 'registration', record: { status: 'pending' } }, + { + kind: 'order', + record: { + status: 'cancelled', + stripeSessionId: 'cs_test_synthetic_1', + }, + }, + { kind: 'order', record: { status: HOSTILE_CANARY } }, + { kind: 'registration', record: registrationState({ + paymentStatus: 'paid', + registrationStatus: 'confirmed', + }) }, + ]; + const first = summarizeLegacyRecords(batch); + const second = summarizeLegacyRecords(batch); + expect(first).toEqual({ + accepted: true, + total: 4, + statuses: { + mapped: 1, + already_canonical: 1, + review_required: 1, + rejected: 1, + }, + reasons: { + legacy_provider_state_unverified: 1, + legacy_status_unknown: 1, + }, + }); + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + expect(JSON.stringify(first)).not.toContain(HOSTILE_CANARY); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.statuses)).toBe(true); + expect(Object.isFrozen(first.reasons)).toBe(true); + expect(first).not.toHaveProperty('records'); + expect(first).not.toHaveProperty('items'); + }); + + test('rejects sparse, accessor, proxy, and malformed batches with fixed output', () => { + const sparse = new Array(1); + const accessor = []; + Object.defineProperty(accessor, '0', { + enumerable: true, + get() { + return HOSTILE_CANARY; + }, + }); + accessor.length = 1; + const proxy = new Proxy([], { + ownKeys() { + throw new Error(HOSTILE_CANARY); + }, + }); + for (const value of [null, {}, sparse, accessor, proxy]) { + const result = summarizeLegacyRecords(value); + expect(result).toEqual({ + accepted: false, + total: 0, + statuses: { + mapped: 0, + already_canonical: 0, + review_required: 0, + rejected: 1, + }, + reasons: { invalid_batch: 1 }, + }); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + } + }); + + test('counts malformed entries without accessing record data', () => { + const result = summarizeLegacyRecords([{ kind: 'order' }]); + expect(result.statuses.rejected).toBe(1); + expect(result.reasons).toEqual({ invalid_batch_entry: 1 }); + }); +}); + +describe('source boundary', () => { + test('contains no runtime, provider, filesystem, environment, clock, random, or logger call', () => { + const source = fs.readFileSync(path.join(__dirname, 'commerceState.js'), 'utf8'); + for (const forbidden of [ + "require('firebase", + "require(\"firebase", + "require('stripe", + "require(\"stripe", + "require('node:fs')", + 'process.env', + 'Date.now', + 'Math.random', + 'console.', + '.log(', + 'fetch(', + ]) { + expect(source).not.toContain(forbidden); + } + expect(source).not.toContain('paid_after_cancellation:'); + expect(source).not.toContain('refund_pending'); + }); +});