diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index de5f6556..b47817a9 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -61,9 +61,9 @@ Every issue inherits `AGENTS.md` and the definition of done in `IMPLEMENTATION_P | 9 | AUTH-003 | Introduce scoped admin capabilities, MFA, and recent authentication | P1 | L | proposed | AUTH-001, AUTH-002, SEC-001 | | 10 | ABUSE-001 | Enforce native App Check and privacy-preserving abuse limits | P0 | L | ready | CI-001 baseline | | 11 | PAY-001 | Add strict request schemas and immutable monetary snapshots | P0 | L | ready | ABUSE-001 interface agreed | -| 12 | PROMO-001 | Disable unmodeled Stripe promotions until discounts are authoritative | P0 | S | partial_implemented_locally | PAY-001 for final discount contract | +| 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 | -| 14 | PAY-003 | Build idempotent, async-aware Stripe webhook ingestion | P0 | L | partial_implemented_locally | CONFIG-001, PAY-001/PAY-002 target contract | +| 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 | | 17 | PAY-004 | Make cancellation authoritative and replace reusable late Payment Links | P0 | L | proposed | PAY-002, PAY-003, RACE-001 | @@ -567,7 +567,7 @@ Keep schemas close to the trusted Functions boundary and expose derived TypeScri ## PROMO-001 — Disable unmodeled Stripe promotions until discounts are authoritative **Labels:** `priority:P0`, `type:security`, `area:stripe`, `size:S` -**Status:** `partial_implemented_locally`; creation and webhook defenses exist, but direct creator-payload tests and the future discount contract remain open +**Status:** source and exact tests are complete under open GitHub issue [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102); pre-change Session/provider inventory, protected deployment, provider readback, and live verification remain open, so do not close the GitHub issue yet **Depends on:** PAY-001 for any future enabled discount model ### Problem @@ -577,17 +577,18 @@ The original race and merchandise Checkout creators enabled arbitrary Dashboard ### Scope - Set `allow_promotion_codes: false` in every current Checkout Session creator. -- Treat any nonzero Stripe `amount_discount` as a permanent anomaly requiring review while discounts are disabled. +- Add `schemaVersion: "1"` only to newly created Session and PaymentIntent metadata. This is an additive marker, not schema enforcement: legacy metadata remains accepted, and allowlisting/migration stays with PAY-003B. +- Require a complete Stripe adjustment breakdown and treat unknown or nonzero discount, tax, or shipping amounts as permanent anomalies requiring review while those features are disabled. - Add creator payload tests and signed webhook tests, including a 100% discount and legacy outstanding Session. - Inventory and disable/restrict active promotion configuration or outstanding Sessions in test/staging before release; production provider changes belong to OPS-001. - If promotions are later desired, open a new issue under PAY-001/PAY-002 for server-approved code/campaign eligibility, immutable discount snapshot, actual total/refund/reconciliation, limits, expiry, audit, and migration before changing this guard. ### Acceptance criteria -- [ ] Neither race nor merchandise Session permits Stripe promotion-code entry. -- [ ] A nonzero discounted Session never becomes locally paid/fulfilled and is durably quarantined/alerted. -- [ ] Zero-discount Sessions continue through normal amount/currency validation. -- [ ] No UI or API claims a promotion is accepted. +- [x] Neither race nor merchandise Session payload permits Stripe promotion-code entry; automatic tax is also explicit-off. +- [x] Unknown or nonzero discount, tax, or shipping adjustments never make a local record paid/fulfilled and create durable review evidence. +- [x] Complete all-zero adjustment Sessions continue through normal amount/currency validation. +- [x] No checkout UI or API claims a promotion is accepted. - [ ] Provider inventory/release checklist accounts for Sessions created before the change. ### Tests/evidence diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index c8ae543b..1d962d47 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -49,7 +49,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | --- | --- | --- | --- | | CONFIG-001A | ready; CI-001A | Add typed server configuration requiring environment name, validated HTTPS/local origin, explicit Stripe mode, and environment/key compatibility; remove production fallbacks. | Missing/malformed/local-live tests fail before Firestore/Stripe/email. | | CONFIG-001B | proposed; CONFIG-001A | Implement a server-owned global/per-domain commerce kill switch checked by every Session/refund/catalog command while webhook/reconciliation remain active. | Disabled-command tests prove no external call; webhook/reconcile tests still pass. | -| PROMO-001 | implemented_locally; PAY-001A for completion | Disable promotion codes in both Session creators and quarantine any nonzero Stripe discount until an approved snapshot contract exists. | Session payload assertions plus signed discounted-Session quarantine tests. | +| PROMO-001 | source/tests complete in [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102); provider inventory and deployment remain owner action; PAY-001A for future enablement | Disable promotion entry and automatic tax in both Session creators; require a complete Stripe breakdown; quarantine unknown or nonzero discount, tax, or shipping adjustments. | Exact Session payload assertions and signed zero/partial/full/malformed/failed/expired adjustment tests pass; pre-change Session/provider inventory and live verification remain open. | | PAY-001A | ready; CI-001A | Add shared strict-object, bounds/depth, integer-cents/currency/date/URL/email primitives, safe errors, and redacted logging. | Prototype/deep/Unicode/money/unknown-key tests prove no external call. | | 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. | @@ -63,7 +63,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | ID | Status/dependency | Bounded deliverable | Close evidence | | --- | --- | --- | --- | -| PAY-003A | implemented_locally | Review/land current ingress slice: signature/method, explicit livemode, payment mode, event ledger, reference resolution, money/discount checks, monotonic refunds, terminal guards, quarantine, generic errors, webhook-only secret. | Functions lint; signed positive/negative/duplicate/out-of-order unit suite and residual-gap notes. | +| 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-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. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 7c365e2c..181cad69 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -207,9 +207,9 @@ Use structured correlation across checkout request, business record, Stripe Sess The first tranche deliberately combines one urgent safety repair with the most complex payment foundation: 1. **SAFETY-001 — callback and emulator isolation:** #99 supplies the source/test boundary for same-origin callback preservation and loopback Auth/Firestore/Functions development. Provider calls, optimized previews, staging, and live callbacks remain separately gated. -2. **PAY-003 — webhook event inbox and safe reducer:** add event replay protection, async-aware payment confirmation, expected total/currency validation, direct metadata lookup, terminal-state guards, and lifecycle tests. +2. **PAY-003 — webhook event inbox and safe reducer:** PAY-003A [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) source merged through [PR #140](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/140) as `87bd1210575882cdd06214bcb98ed38ce00f71c2`, adding event replay protection, async-aware payment confirmation, expected total/currency validation, direct metadata lookup, terminal-state guards, and lifecycle tests. Firebase and Stripe live behavior remain unproven; PAY-003B/C remain open. 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:** disable promotion entry and quarantine unexplained discount/tax/shipping adjustments. This safety default is implemented locally; creator tests/provider inventory remain. +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. Continue CONFIG-001 and the uncompleted AUTH-001 guards/Rules/refresh/audit scope without duplicating #98. 6. **Then:** PAY-001/PAY-002 so checkout creation, refunds, and every later handler share strict schemas and idempotent state. 7. **Then:** RACE-001 and MERCH-001 transactional reservations. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index d492848f..0cea4c6e 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -298,6 +298,36 @@ Required initial event set is documented in [STRIPE_COMMERCE_DESIGN.md](./STRIPE For secret rotation, support the provider-approved overlap or controlled switchover, send a signed test event, verify the new secret, then retire the old secret and record evidence. +### Promotion-adjustment safety release — BLOCKED + +**Purpose:** prevent an older promotion-enabled Stripe Session or provider setting from bypassing the repository guard. + +**Approver:** treasurer plus platform owner; the Stripe account owner performs provider readback. + +**Prerequisites:** merged [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102), green exact-commit checks, an isolated staging Firebase project, Stripe test mode, and a reviewed protected release plan that names `createCheckoutSession`, `createMerchCheckout`, and `stripeWebhook` together. + +The current protected release plan names only the profile-recovery Functions. It cannot deploy this Stripe slice. Do not call #102 deployed until a separately reviewed immutable plan includes all three Stripe Functions and verifies them after deployment. + +1. Have the Stripe account owner privately count still-open Sessions created before the #102 release candidate. +2. Record only redacted counts, mode, date range, and disposition. Do not copy customer details, Session URLs, promotion-code values, or provider identifiers into GitHub, email, screenshots, or AI. +3. Privately confirm whether promotion codes, coupons, automatic tax, or shipping rates can affect the test/staging Checkout configuration. +4. Expire or reconcile test/staging Sessions under the approved plan. Do not change production provider state in this procedure. +5. Run made-up race and merchandise payments in Stripe test mode. A complete all-zero completion must continue. An adjustment or missing breakdown on completion, failure, or expiry must create review evidence and never become paid/fulfilled. An ordinary all-zero failure or expiry cancels without creating a new warning. +6. Reconcile Stripe test totals with the synthetic Firebase records. +7. Release the three named Functions together only through the protected exact-commit gate. +8. Read back the deployed Functions and Stripe event destination separately. +9. Record Firebase deployment, provider readback, and observed behavior as three different results. + +**Expected result:** new Sessions cannot accept promotion entry or automatic tax, and the webhook accepts only a complete all-zero adjustment breakdown. Failed or expired Sessions still cancel while keeping any review flag. + +**Stop conditions:** missing owner, production mode, real customer/member data, an old open Session with no approved disposition, a release plan missing one Function, skipped/partial Firebase work, or unavailable provider readback. + +**Success proof:** redacted private inventory, exact merge commit, exact CI run, protected release run, three Function readbacks, test-mode event evidence, and separate Stripe owner confirmation. + +**Undo:** use one reviewed safe roll-forward or revert through the same three-Function gate. Do not re-enable promotions, edit payment records, delete webhook ledgers, or expire production Sessions by hand. + +**Escalation:** treasurer plus platform owner; add the security owner for a suspected bypass and the privacy owner if customer information appeared. + ## 9. Staging dress rehearsal Before every major payment release: diff --git a/SECURITY.md b/SECURITY.md index db13a598..1d58cd43 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,7 +50,7 @@ The findings below describe the repository at the start of the 2026-07-12 assess | RISK-010 | Development/test now use synthetic loopback Auth, Firestore, and Functions, but optimized previews still target production Firebase and emulated Functions can call outside providers. | A maintainer can mistake a preview or Firebase emulator for complete staging and affect production/provider systems. | Prohibit private actions in previews; add dedicated staging/config under #105/CONFIG; prove test keys/sinks before provider flows. | | RISK-011 | Local cancellation only changes Firestore; it does not expire the active Stripe Session. The webhook can later move a cancelled record back to paid. | Customers can pay a supposedly cancelled registration/order; capacity/inventory and support state diverge. | Add explicit cancellation saga with Stripe Session expiry and allowed-state transitions. | | RISK-012 | Late registration creates reusable Stripe Payment Links, but records have no Session ID and the webhook searches only by Session/PaymentIntent. | Late payments do not reconcile; reuse can produce multiple charges/Sessions for one logical registration. | Replace with one-off idempotent Checkout Sessions or fully constrain/map Payment Links. | -| RISK-013 | `allow_promotion_codes: true` permits Stripe discounts while Firestore stores base price and webhook does not validate/store discount/actual total. | Reporting, refund limits, and local payment confirmation can be wrong. | Disable promotions until a server-approved discount and total-reconciliation model exists. | +| RISK-013 | The assessment baseline permitted Stripe promotions while Firestore stored base price. Repository source now disables promotion entry and automatic tax, requires a complete adjustment breakdown, and quarantines unknown or nonzero discount/tax/shipping amounts; deployment and provider state are unproven. | An older open Session, provider drift, skipped backend release, or later guard regression could still make reporting, refunds, and local payment confirmation disagree. | Privately inventory pre-change Sessions/provider settings, release through the protected gate, verify Firebase and Stripe separately, and keep every adjustment disabled until an approved authoritative model exists. | | RISK-014 | The 2026-07-13 Node 20 root production audit reports 22 advisories (1 critical, 8 high) after #127 removes unused `gpxparser` and its obsolete `request/jsdom/form-data` chain; the Functions audit still reports 9 advisories (1 high). Direct affected dependencies still include the router, Firebase client, and Firebase Admin. | Known vulnerable or unmaintained runtime/build chains increase XSS, request, DoS, and supply-chain exposure. | Patch direct dependencies, then stage SDK/build-system upgrades with tests and continuous scanning. | | RISK-015 | `Privacy.jsx` and `Terms.jsx` explicitly say `REPLACE WITH DATE` and `Placeholder template`; tax, refund, shipping, waiver, retention, and customer-support policies are not approved. | Users are asked for sensitive data and money without final disclosures/policies; disputes and regulatory obligations are unmanaged. | Obtain appropriate legal/tax/insurance review, approve versioned policies, and block live-mode configuration until complete. | @@ -94,12 +94,12 @@ These entries are implementation evidence, not a production risk-acceptance deci | Risks | Source result | Remaining closure evidence | | --- | --- | --- | -| RISK-001, RISK-002, RISK-034 | PAY-003 safety slice adds durable Stripe Event deduplication, transactional transition/processed marking, paid/amount/currency/Checkout-mode/reference-shape and explicitly configured livemode checks, zero-discount enforcement, async outcomes, monotonic refund/terminal guards, quarantine, generic signature failures, and a webhook-secret-only binding. | 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, deploy, and reconcile. | +| 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. | | RISK-006, part of RISK-024/RISK-032 | #100 merged at `a7fc301e85b0aeabe396e771faea21d3fc8e7b2b`. OAuth secrets and server-owned financial/operational collections are denied; current catalog writes are restricted to inert drafts/display fields. | Firebase deployment skipped. Stage/deploy the exact Rules revision under #105, verify synthetic deny behavior, then move remaining catalog mutation to scoped APIs under ADMIN-001. | | RISK-009 | #99 preserves same-origin path, query, and fragment state through the Pages fallback, clears temporary state, rejects unsafe targets, and suppresses monitoring startup on initial capability callbacks. #126 adds the standalone suite as the named blocking `Run SPA callback safety tests` hosted step. | Keep exact hosted evidence on #126; verify exact website publication and later run approved test-mode Stripe/Strava callbacks; WEB-001 still owns controlled hosting. | | RISK-010 | #99 uses a fully synthetic `demo-mprc-local` configuration, connects Functions/Auth/Firestore to loopback in development/test, routes the direct CSV Function URL locally, fails startup on connector errors, and completed a demo-only three-port CLI smoke. | Current optimized previews still target production; provider calls are not isolated by Firebase. Add protected staging/config and test-provider/sink evidence before end-to-end flows. | | Part of RISK-007 | AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) was merged through [PR #107](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/107) as `ce22c110f2132b157bd8a0d43b065585e0b43cb5`. Source and focused tests reject unverified targets at the two existing role-grant endpoints. | The workflow skipped Firebase because `FIREBASE_SERVICE_ACCOUNT` was absent, so backend-live behavior is unproven. Remaining AUTH-001 guards, Rules, verification mirror/refresh, demotion propagation, audit, and authoritative membership work stay open. | -| RISK-013 | PROMO-001 sets both current Checkout creators to `allow_promotion_codes: false`; the webhook quarantines nonzero discounts plus unconfigured tax/shipping totals. | Add exact creator-payload tests, inventory pre-change Sessions/provider promotion configuration, keep the guard through staging/deploy, and implement an authoritative discount/tax/shipping contract before ever enabling it. | +| RISK-013 | PROMO-001 [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) sets both current Checkout creators to promotion entry off and automatic tax off. Exact payload and signed event tests require an explicit all-zero breakdown; malformed or nonzero discount, tax, or shipping data creates durable review evidence while definitive failed/expired Sessions still cancel. | Privately inventory pre-change Sessions/provider promotion settings, keep the guard through protected staging/deploy, verify provider and live state separately, and implement an approved authoritative discount/tax/shipping contract before ever enabling it. | | Part of RISK-014 | SUPPLY-001A [#127](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/127) removes the unused direct `gpxparser` dependency and 50 lockfile entries without changing any retained package version, source URL, or integrity hash. A fresh Node 20 install lowers the root production audit from 33 findings (3 critical, 9 high) to 22 (1 critical, 8 high), and the optimized production asset hashes remain identical. | Remediate or time-bound the remaining root and Functions findings through the separately tested router, Firebase, Stripe, build-system, and scanning slices; do not use a forced audit upgrade. | | Part of RISK-024 | SEC-001 protects token documents from every browser client. | OAUTH-001 must still add transactional refresh/versioning, scope/account binding, disconnect/revocation, IAM/encryption decision, and redacted lifecycle audit. | | Part of RISK-027 | #99 blocks App Check, Firebase Analytics, and Sentry in local/test and on initial capability-callback startup. OBS-001A1 [#134](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/134) source disables Sentry replay, tracing, browser session tracking, client reports, member user context, breadcrumbs, attachments, transactions, and unapproved default integrations. OBS-001A2 [#139](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/139) source removes every application runtime Firebase Analytics import, initialization, and emission, preserves existing wrapper calls as provider-free no-ops, and removes the direct Waiver SDK bypass. Synthetic tests use mocks/canaries and scan CRA runtime source plus public HTML/JavaScript entrypoints; they make no provider call. | #110/#111 still own any future approved Analytics purpose, consent, event/parameter schema, provider settings, and residual hosted telemetry boundary. Source/tests and merge evidence do not prove website publication, the exact `runmprc.com` revision, provider transport/collection, IP/cookie behavior, historical data deletion, retention, access, consent, deletion, or vendor terms. Sentry provider behavior is likewise unverified. Callback protected handoffs remain with ABUSE-001A, DATA-001A, and OAUTH-001C in #88. | diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 6d65167d..c769ee44 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -1,14 +1,14 @@ # Stripe, Race Registration, and Merchandise Design **Status:** Required production design; current code is a test-mode prototype -**Last reviewed:** 2026-07-12 +**Last reviewed:** 2026-07-13 **Related:** [SYSTEM_DESIGN.md](./SYSTEM_DESIGN.md), [SECURITY.md](./SECURITY.md), [OPERATIONS_RUNBOOK.md](./OPERATIONS_RUNBOOK.md) This document defines how MPRC should configure Stripe and how race-registration and merchandise money flows must behave. It deliberately separates repository implementation from external Stripe account configuration: source code can declare required secret names and behavior, but it cannot prove that the production Stripe account, event destination, bank account, roles, tax settings, Radar controls, or secrets are correctly configured. ## 1. Launch status -**Do not enable or advertise live checkout yet.** The initial 2026-07-12 assessment found unsafe payment confirmation, no Event-ID ledger, enabled unmodeled promotions, and callback loss. A working-tree safety slice now validates paid/amount/currency/mode/explicit livemode, rejects discounts, deduplicates Events, protects terminal/refund state, and preserves callbacks. That slice is not deployed evidence and does not close the payment architecture. These residual conditions still prevent safe production use: +**Do not enable or advertise live checkout yet.** The initial 2026-07-12 assessment found unsafe payment confirmation, no Event-ID ledger, enabled unmodeled promotions, and callback loss. Repository safety slices now validate paid/amount/currency/mode/explicit livemode, reject unapproved adjustments, deduplicate Events, protect terminal/refund state, and preserve callbacks. PAY-003A [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) is merged source; PROMO-001 [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) supplies the adjustment guard and exact tests. Neither statement is deployment, provider, or live-behavior evidence, and these slices do not close the payment architecture. These residual conditions still prevent safe production use: - Checkout creation lacks validated fail-closed environment configuration, command idempotency, a persistence-first saga, and the canonical versioned state/schema contract. - The webhook still needs canonical reducer/reservation/outbox integration, explicit metadata schema allowlisting/migration, retry/dead-letter operations, TTL/alerts, emulator integration, and Stripe test-mode delivery rehearsal. @@ -16,8 +16,8 @@ This document defines how MPRC should configure Stripe and how race-registration - Late-registration Payment Links cannot be reliably mapped back to their registration and are reusable. - Race capacity is checked with a non-atomic count; merchandise has no SKU inventory reservation. - Cancelling locally does not expire an open Stripe Session, so a later payment can reopen the record. -- Promotion codes are disabled and discounted Sessions quarantined in the working tree, but exact creator tests and outstanding pre-change Session/provider inventory remain PROMO-001. -- Success credentials remain in query strings even though the working-tree Pages fallback now preserves them; DATA-001 must remove long-lived plaintext capabilities and scrub browser/monitoring state. +- Promotion entry and automatic tax are disabled in both current Session creators. Exact creator/webhook tests quarantine any nonzero discount, tax, or shipping charge. Outstanding pre-change Session/provider inventory, deployment, and live verification remain open under PROMO-001. +- Success credentials remain in query strings even though the Pages fallback now preserves them; DATA-001 must remove long-lived plaintext capabilities and scrub browser/monitoring state. - Production App Check enforcement is optional and fail-open. - Legal, privacy, cancellation/refund, tax, shipping, and waiver content is not approved for launch. @@ -270,7 +270,7 @@ For every Session: - Shipping collection only for flows that ship physical goods, with an approved country list. - Explicit payment-method policy. - Deliberate expiration for reserved capacity/inventory. -- Promotion codes disabled under PROMO-001 until PAY-001 defines server-approved monetary snapshots and PAY-002 implements the corresponding idempotent eligibility/discount/state contract. The working tree sets `allow_promotion_codes: false` and quarantines nonzero discounts; creator-payload tests and pre-change Session/provider inventory remain open. +- Promotion codes and automatic tax disabled under PROMO-001 until approved monetary snapshots and policies exist. Exact creator-payload tests cover both current Session creators. The webhook requires a complete Stripe adjustment breakdown and quarantines any nonzero discount, tax, or shipping amount. Pre-change Session/provider inventory, deployment, and live verification remain open. - A stable Stripe idempotency key supplied in request options. Do not put the confirmation bearer token in `success_url`. Use Stripe's `{CHECKOUT_SESSION_ID}` placeholder. The success API retrieves and validates the Session server-side and returns a sanitized business projection. The success page is informational; fulfillment remains webhook-driven. @@ -387,7 +387,18 @@ Do not create a reusable Payment Link per registrant. Prefer a one-off Checkout ### Promotions -The assessment baseline's `allow_promotion_codes: true` was not a complete promotion system. PROMO-001 now disables it in the working tree; before enabling discounts in any future issue: +The assessment baseline's `allow_promotion_codes: true` was not a complete promotion system. PROMO-001 repository source disables it and records explicit zero adjustments; before enabling discounts in any future issue: + +```mermaid +flowchart LR + Create["Server creates Checkout Session"] --> Disabled["Promotion entry and automatic tax disabled"] + Disabled --> Event["Signed Stripe event"] + Event --> Check{"Complete zero adjustment breakdown?"} + Check -- "Yes" --> Continue["Continue normal payment checks"] + Check -- "No, unknown, or nonzero" --> Review["Never mark paid or fulfilled; record review evidence"] +``` + +Text alternative: the server disables unapproved price adjustments when creating Checkout; the signed webhook continues payment confirmation only with a complete all-zero breakdown. Otherwise it records review evidence and never marks paid or fulfilled; a definitively failed or expired Session still cancels. - Decide whether codes live in Stripe, Firestore, or both. - Restrict eligible products/events, redemption counts, dates, currencies, and customer scope. @@ -396,7 +407,7 @@ The assessment baseline's `allow_promotion_codes: true` was not a complete promo - Include discounts in reconciliation and exports. - Audit code creation and changes. -Until that work is complete, keep `allow_promotion_codes: false` and quarantine any nonzero discount. +Until that work is complete, keep promotion entry and automatic tax disabled and quarantine unknown or nonzero discount, tax, or shipping adjustments. ### Tax diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 31baea4e..777cd775 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -55,6 +55,43 @@ There is currently no proven no-code switch that safely stops all new Stripe pay If any proof is missing, report the change as **not live**. +## Checkout adjustment guard — SOURCE ONLY, NOT LIVE + +**Purpose:** prevent an unknown discount, tax, or shipping charge from being treated as a valid payment. + +**Approver:** treasurer plus platform owner. + +**Prerequisites:** source for issue [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) has merged, a private Stripe-owner inventory, made-up test payments, and one protected release plan covering all three affected server Functions. + +```mermaid +flowchart LR + A["Website asks Stripe for Checkout"] --> B["Promotion entry and automatic tax stay off"] + B --> C["Stripe sends a signed result"] + C --> D{"All adjustment amounts are present and zero?"} + D -- "Yes" --> E["Other payment checks continue"] + D -- "No or unknown" --> F["Keep for review; never mark paid"] +``` + +In words: Checkout starts with unapproved adjustments off; the server accepts the money result only when Stripe explicitly reports zero discount, tax, and shipping. + +Officer steps: + +1. Keep live race and shop checkout unavailable. +2. Do not create a promotion code, tax rule, or shipping rate for the website. +3. Ask the Stripe owner to review older open Sessions privately. +4. Do not put Session links, code values, customer details, screenshots, or provider IDs in GitHub or AI. +5. Wait for separate proof of source merge, Firebase deployment, Stripe readback, and made-up test behavior. + +**Expected result:** a complete all-zero Stripe breakdown may continue through the other checks. Unknown or nonzero adjustments stay under review. A failed or expired Session closes locally; it keeps an adjustment or earlier warning, while an ordinary all-zero failure does not create a new warning. + +**Stop conditions:** any real payment/customer data, production-mode test, missing private inventory, missing server Function, skipped Firebase work, or request to “temporarily” enable a discount. + +**Success proof:** exact pull request/commit, green exact-commit checks, private redacted inventory, three named Function readbacks, Stripe test-mode results, and separate provider-owner confirmation. + +**Undo:** use one reviewed three-Function revert or safe roll-forward. Do not edit a payment record, delete a webhook event, or change production Stripe settings by hand. + +**Escalation:** treasurer plus platform owner; add security if an adjustment reached paid/fulfilled state. + ## Profile permission error **Status: AUTOMATIC REPAIR NOT LIVE YET** diff --git a/functions/checkoutPromotionPolicy.test.js b/functions/checkoutPromotionPolicy.test.js new file mode 100644 index 00000000..5136676f --- /dev/null +++ b/functions/checkoutPromotionPolicy.test.js @@ -0,0 +1,264 @@ +const mockCheckoutCreate = jest.fn(); +const mockProductCreate = jest.fn(); + +jest.mock('firebase-functions', () => { + class HttpsError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } + } + const https = { + HttpsError, + onCall: (handler) => handler, + onRequest: (handler) => handler, + }; + return { + https, + runWith: () => ({ https }), + }; +}); + +jest.mock('firebase-admin', () => { + const store = new Map(); + let registrationSequence = 0; + let orderSequence = 0; + + class Snapshot { + constructor(ref) { + this.ref = ref; + this.id = ref.id; + this.exists = store.has(ref.path); + } + + data() { + return store.get(this.ref.path); + } + } + + class DocumentReference { + constructor(path) { + this.path = path; + this.id = path.split('/').at(-1); + } + + collection(name) { + return new CollectionReference(`${this.path}/${name}`); + } + + async get() { + return new Snapshot(this); + } + + async set(data) { + store.set(this.path, { ...data }); + } + + async update(data) { + store.set(this.path, { ...(store.get(this.path) || {}), ...data }); + } + } + + class CollectionReference { + constructor(path) { + this.path = path; + } + + doc(id) { + let resolvedId = id; + if (!resolvedId && this.path.endsWith('/registrations')) { + registrationSequence += 1; + resolvedId = `reg-new-${registrationSequence}`; + } else if (!resolvedId && this.path === 'orders') { + orderSequence += 1; + resolvedId = `order-new-${orderSequence}`; + } + return new DocumentReference(`${this.path}/${resolvedId}`); + } + } + + const firestore = { + collection: (name) => new CollectionReference(name), + }; + + return { + apps: [{}], + firestore: () => firestore, + __clear: () => { + store.clear(); + registrationSequence = 0; + orderSequence = 0; + }, + __seed: (path, data) => store.set(path, { ...data }), + __get: (path) => store.get(path), + }; +}); + +jest.mock('./stripeHelpers', () => ({ + getStripe: () => ({ + checkout: { sessions: { create: mockCheckoutCreate } }, + products: { create: mockProductCreate }, + }), + generateToken: () => 'synthetic-confirmation-token', + resolveCallerRole: async () => null, + requireAppCheck: () => {}, + validateRunner: () => [], + pickPriceCents: (event, tier) => event.pricing?.[`${tier}Cents`] ?? null, + isEarlyBirdActive: () => false, + isRegistrationOpen: () => true, + countActiveRegistrations: async () => 0, + auditEntry: ({ action, note }) => ({ action, note }), + isValidEmail: () => true, + Timestamp: { now: () => ({ _milliseconds: 1_800_000_000_000 }) }, +})); + +jest.mock('./rateLimit', () => ({ + checkRateLimit: async () => {}, + extractIp: () => 'synthetic-test-ip', +})); + +const admin = require('firebase-admin'); +const { createCheckoutSession } = require('./createCheckoutSession'); +const { createMerchCheckout } = require('./createMerchCheckout'); + +describe('Checkout promotion policy', () => { + beforeEach(() => { + admin.__clear(); + mockCheckoutCreate.mockReset(); + mockProductCreate.mockReset(); + mockCheckoutCreate.mockImplementation(async (payload) => ({ + id: payload.metadata.type === 'merch' ? 'cs_order_policy' : 'cs_registration_policy', + url: 'https://checkout.stripe.test/synthetic-session', + })); + process.env.SITE_ORIGIN = 'https://runmprc.test'; + }); + + afterAll(() => { + delete process.env.SITE_ORIGIN; + }); + + test('race checkout sends the exact disabled-adjustment payload', async () => { + admin.__seed('events/race-1', { + title: 'Synthetic Race', + description: 'Synthetic test event', + slug: 'synthetic-race', + status: 'open', + visibility: 'public', + pricing: { nonMemberCents: 5000 }, + stripeProductId: 'prod_race_policy', + waiverVersion: 'synthetic-v1', + }); + + const result = await createCheckoutSession({ + eventId: 'race-1', + runner: { + firstName: 'Test', + lastName: 'Runner', + email: 'runner@example.test', + }, + priceTier: 'nonMember', + acceptedWaiver: true, + }, { auth: null, rawRequest: {} }); + + expect(mockCheckoutCreate).toHaveBeenCalledTimes(1); + const expectedMetadata = { + schemaVersion: '1', + eventId: 'race-1', + registrationId: 'reg-new-1', + priceTier: 'nonMember', + }; + expect(mockCheckoutCreate).toHaveBeenCalledWith({ + mode: 'payment', + customer_email: 'runner@example.test', + allow_promotion_codes: false, + automatic_tax: { enabled: false }, + line_items: [{ + price_data: { + currency: 'usd', + unit_amount: 5000, + product: 'prod_race_policy', + }, + quantity: 1, + }], + success_url: 'https://runmprc.test/register/success?session_id={CHECKOUT_SESSION_ID}®=reg-new-1&token=synthetic-confirmation-token&event=race-1', + cancel_url: 'https://runmprc.test/events/synthetic-race?cancelled=1', + metadata: expectedMetadata, + payment_intent_data: { metadata: expectedMetadata }, + }); + expect(result).toMatchObject({ + sessionId: 'cs_registration_policy', + registrationId: 'reg-new-1', + }); + expect(admin.__get('events/race-1/registrations/reg-new-1')).toMatchObject({ + amountCents: 5000, + currency: 'usd', + promoCode: null, + stripeSessionId: 'cs_registration_policy', + }); + expect(mockProductCreate).not.toHaveBeenCalled(); + }); + + test('merchandise checkout sends the exact disabled-adjustment payload', async () => { + admin.__seed('products/hat', { + title: 'Synthetic Hat', + description: 'Synthetic test product', + slug: 'hat', + status: 'active', + priceCents: 2000, + sizes: ['M'], + colors: ['blue'], + stripeProductId: 'prod_hat_policy', + }); + + const result = await createMerchCheckout({ + productSlug: 'hat', + buyer: { + firstName: 'Test', + lastName: 'Buyer', + email: 'buyer@example.test', + }, + size: 'M', + color: 'blue', + }, { auth: null, rawRequest: {} }); + + expect(mockCheckoutCreate).toHaveBeenCalledTimes(1); + const expectedMetadata = { + schemaVersion: '1', + type: 'merch', + productSlug: 'hat', + orderId: 'order-new-1', + size: 'M', + color: 'blue', + }; + expect(mockCheckoutCreate).toHaveBeenCalledWith({ + mode: 'payment', + customer_email: 'buyer@example.test', + allow_promotion_codes: false, + automatic_tax: { enabled: false }, + line_items: [{ + price_data: { + currency: 'usd', + unit_amount: 2000, + product: 'prod_hat_policy', + }, + quantity: 1, + }], + shipping_address_collection: { allowed_countries: ['US', 'CA'] }, + phone_number_collection: { enabled: true }, + success_url: 'https://runmprc.test/shop/purchase/success?session_id={CHECKOUT_SESSION_ID}&order=order-new-1&token=synthetic-confirmation-token', + cancel_url: 'https://runmprc.test/shop/hat?cancelled=1', + metadata: expectedMetadata, + payment_intent_data: { metadata: expectedMetadata }, + }); + expect(result).toMatchObject({ + sessionId: 'cs_order_policy', + orderId: 'order-new-1', + }); + expect(admin.__get('orders/order-new-1')).toMatchObject({ + amountCents: 2000, + currency: 'usd', + stripeSessionId: 'cs_order_policy', + }); + expect(mockProductCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/createCheckoutSession.js b/functions/createCheckoutSession.js index 80864cb3..3e812f62 100644 --- a/functions/createCheckoutSession.js +++ b/functions/createCheckoutSession.js @@ -247,16 +247,21 @@ exports.createCheckoutSession = functions }, quantity: 1, }], - allow_promotion_codes: true, + // Discounts remain disabled until a server-approved discount snapshot + // and reconciliation contract is implemented (PROMO-001). + allow_promotion_codes: false, + automatic_tax: { enabled: false }, success_url: `${origin}${SUCCESS_PATH}?session_id={CHECKOUT_SESSION_ID}®=${regRef.id}&token=${confirmationToken}&event=${eventId}`, cancel_url: `${origin}${CANCEL_PATH_PREFIX}${eventSlug}?cancelled=1`, metadata: { + schemaVersion: '1', eventId, registrationId: regRef.id, priceTier, }, payment_intent_data: { metadata: { + schemaVersion: '1', eventId, registrationId: regRef.id, priceTier, diff --git a/functions/createMerchCheckout.js b/functions/createMerchCheckout.js index 7bce46de..5833d17a 100644 --- a/functions/createMerchCheckout.js +++ b/functions/createMerchCheckout.js @@ -149,7 +149,10 @@ exports.createMerchCheckout = functions }, quantity: 1, }], - allow_promotion_codes: true, + // Discounts remain disabled until a server-approved discount snapshot + // and reconciliation contract is implemented (PROMO-001). + allow_promotion_codes: false, + automatic_tax: { enabled: false }, shipping_address_collection: { allowed_countries: ['US', 'CA'], }, @@ -157,6 +160,7 @@ exports.createMerchCheckout = functions success_url: `${origin}/shop/purchase/success?session_id={CHECKOUT_SESSION_ID}&order=${orderRef.id}&token=${confirmationToken}`, cancel_url: `${origin}/shop/${productSlug}?cancelled=1`, metadata: { + schemaVersion: '1', type: 'merch', productSlug, orderId: orderRef.id, @@ -165,6 +169,7 @@ exports.createMerchCheckout = functions }, payment_intent_data: { metadata: { + schemaVersion: '1', type: 'merch', productSlug, orderId: orderRef.id, diff --git a/functions/stripeWebhook.js b/functions/stripeWebhook.js index 086bcd34..7db2228f 100644 --- a/functions/stripeWebhook.js +++ b/functions/stripeWebhook.js @@ -455,12 +455,120 @@ function nonNegativeInteger(value, fallback = 0) { return Number.isSafeInteger(value) && value >= 0 ? value : fallback; } +function requiredNullableAdjustment(object, field) { + if (!Object.prototype.hasOwnProperty.call(object, field)) { + return { ok: false, reason: 'invalid_stripe_adjustment' }; + } + const value = object[field]; + if (value === null) return { ok: true, value: 0, present: true }; + return requiredAdjustment(value); +} + +function requiredAdjustment(value) { + if (!Number.isSafeInteger(value) || value < 0) { + return { ok: false, reason: 'invalid_stripe_adjustment' }; + } + return { ok: true, value, present: true }; +} + +function validateSessionAdjustments(session) { + const details = session.total_details; + const shippingCost = session.shipping_cost; + // Equality between subtotal and total proves only a net-zero change. Stripe + // must provide the component breakdown so offsetting adjustments cannot be + // mistaken for an unadjusted payment. + if (!details || typeof details !== 'object' || Array.isArray(details)) { + return { ok: false, reason: 'invalid_stripe_adjustment' }; + } + if (shippingCost !== undefined && shippingCost !== null + && (typeof shippingCost !== 'object' || Array.isArray(shippingCost))) { + return { ok: false, reason: 'invalid_stripe_adjustment' }; + } + + const discount = requiredAdjustment(details.amount_discount); + const tax = requiredAdjustment(details.amount_tax); + const detailShipping = requiredNullableAdjustment(details, 'amount_shipping'); + const hasShippingCost = shippingCost !== undefined && shippingCost !== null; + const costShippingSubtotal = hasShippingCost + ? requiredAdjustment(shippingCost.amount_subtotal) + : { ok: true, value: 0, present: false }; + const costShippingTax = hasShippingCost + ? requiredAdjustment(shippingCost.amount_tax) + : { ok: true, value: 0, present: false }; + const costShippingTotal = hasShippingCost + ? requiredAdjustment(shippingCost.amount_total) + : { ok: true, value: 0, present: false }; + const values = [ + discount, + tax, + detailShipping, + costShippingSubtotal, + costShippingTax, + costShippingTotal, + ]; + if (values.some(({ ok }) => !ok)) { + return { ok: false, reason: 'invalid_stripe_adjustment' }; + } + const computedShippingTotal = costShippingSubtotal.value + costShippingTax.value; + if (hasShippingCost && (!Number.isSafeInteger(computedShippingTotal) + || computedShippingTotal !== costShippingTotal.value)) { + return { ok: false, reason: 'stripe_shipping_breakdown_mismatch' }; + } + if (hasShippingCost && detailShipping.value !== costShippingTotal.value) { + return { ok: false, reason: 'stripe_shipping_breakdown_mismatch' }; + } + + return { + ok: true, + discountCents: discount.value, + taxCents: tax.value, + shippingCents: detailShipping.value, + shippingSubtotalCents: costShippingSubtotal.value, + shippingTaxCents: costShippingTax.value, + }; +} + +function validateSessionAdjustmentBreakdown(session, adjustments) { + if (!Number.isSafeInteger(session.amount_total) || session.amount_total < 0) { + return { ok: false, reason: 'invalid_stripe_total' }; + } + if (!Number.isSafeInteger(session.amount_subtotal) || session.amount_subtotal < 0) { + return { ok: false, reason: 'invalid_stripe_subtotal' }; + } + const computedTotalCents = session.amount_subtotal + - adjustments.discountCents + + adjustments.taxCents + + adjustments.shippingCents; + if (!Number.isSafeInteger(computedTotalCents) + || computedTotalCents !== session.amount_total) { + return { ok: false, reason: 'stripe_total_breakdown_mismatch' }; + } + return { ok: true }; +} + +function adjustmentPolicyFailure(adjustments) { + if (!adjustments.ok) return adjustments; + if (adjustments.discountCents !== 0) { + return { ok: false, reason: 'discount_not_allowed' }; + } + if (adjustments.taxCents !== 0) { + return { ok: false, reason: 'tax_not_configured' }; + } + if (adjustments.shippingCents !== 0 + || adjustments.shippingSubtotalCents !== 0 + || adjustments.shippingTaxCents !== 0) { + return { ok: false, reason: 'shipping_charge_not_configured' }; + } + return { ok: true }; +} + function validateSessionMoney(session, record) { const expectedSubtotal = record.amountCents; const expectedCurrency = normalizedCurrency(record.currency); const actualCurrency = normalizedCurrency(session.currency); const totalCents = session.amount_total; const subtotalCents = session.amount_subtotal; + const adjustments = validateSessionAdjustments(session); if (!Number.isSafeInteger(expectedSubtotal) || expectedSubtotal < 0) { return { ok: false, reason: 'invalid_expected_amount' }; @@ -477,18 +585,35 @@ function validateSessionMoney(session, record) { if (!Number.isSafeInteger(subtotalCents) || subtotalCents < 0) { return { ok: false, reason: 'invalid_stripe_subtotal' }; } + if (!adjustments.ok) return adjustments; if (subtotalCents !== expectedSubtotal) { return { ok: false, reason: 'amount_mismatch' }; } - if (totalCents !== expectedSubtotal) { + const { + discountCents, + taxCents, + shippingCents, + } = adjustments; + const hasAdjustments = discountCents !== 0 || taxCents !== 0 || shippingCents !== 0; + if (!hasAdjustments && totalCents !== expectedSubtotal) { return { ok: false, reason: 'total_mismatch' }; } + const computedTotalCents = subtotalCents - discountCents + taxCents + shippingCents; + if (!Number.isSafeInteger(computedTotalCents) + || computedTotalCents !== totalCents) { + return { ok: false, reason: 'stripe_total_breakdown_mismatch' }; + } + const adjustmentPolicy = adjustmentPolicyFailure(adjustments); + if (!adjustmentPolicy.ok) return adjustmentPolicy; return { ok: true, summary: { stripeAmountSubtotalCents: subtotalCents, stripeAmountTotalCents: totalCents, + stripeDiscountCents: discountCents, + stripeTaxCents: taxCents, + stripeShippingCents: shippingCents, stripeCurrency: actualCurrency, stripePaymentStatus: session.payment_status || null, }, @@ -735,6 +860,16 @@ function successfulPaymentTransition({ event, session, target, record }) { function unsuccessfulSessionTransition({ event, session, target, record, reason }) { const binding = validateSessionBinding(session, record); if (!binding.ok) return reviewTransition({ target, event, reason: binding.reason }); + const adjustments = validateSessionAdjustments(session); + let adjustmentReviewReason = adjustments.ok ? null : adjustments.reason; + if (!adjustmentReviewReason) { + const breakdown = validateSessionAdjustmentBreakdown(session, adjustments); + if (!breakdown.ok) adjustmentReviewReason = breakdown.reason; + } + if (!adjustmentReviewReason) { + const adjustmentPolicy = adjustmentPolicyFailure(adjustments); + if (!adjustmentPolicy.ok) adjustmentReviewReason = adjustmentPolicy.reason; + } if (!CHECKOUT_PAYMENT_STATUSES.has(session.payment_status)) { return reviewTransition({ target, event, reason: 'invalid_checkout_payment_status' }); } @@ -742,11 +877,19 @@ function unsuccessfulSessionTransition({ event, session, target, record, reason return reviewTransition({ target, event, reason: 'unsuccessful_event_reports_paid' }); } if (record.status !== 'pending') { + if (adjustmentReviewReason) { + return reviewTransition({ target, event, reason: adjustmentReviewReason }); + } return { outcome: `${reason}_ignored:${record.status}`, patch: null }; } const paymentIntentId = objectId(session.payment_intent); + let auditAction = reason === 'expired' ? 'session.expired' : 'payment.async_failed'; + if (adjustmentReviewReason) auditAction = 'payment.unsuccessful_review_required'; return { - outcome: `payment_${reason}`, + outcome: adjustmentReviewReason + ? `needs_review:${adjustmentReviewReason}` + : `payment_${reason}`, + requiresReview: !!adjustmentReviewReason, patch: { status: 'cancelled', paymentStatus: reason, @@ -754,12 +897,18 @@ function unsuccessfulSessionTransition({ event, session, target, record, reason stripeSessionId: record.stripeSessionId || session.id, stripePaymentIntentId: paymentIntentId || record.stripePaymentIntentId || null, cancelledAt: record.cancelledAt || Timestamp.now(), + ...(adjustmentReviewReason ? { + paymentReviewRequired: true, + paymentReviewReason: adjustmentReviewReason, + } : {}), lastStripeEventId: event.id, updatedAt: Timestamp.now(), auditLog: auditPatch({ target, - action: reason === 'expired' ? 'session.expired' : 'payment.async_failed', - note: `event=${event.id}`, + action: auditAction, + note: adjustmentReviewReason + ? `event=${event.id} reason=${adjustmentReviewReason}` + : `event=${event.id}`, }), }, }; diff --git a/functions/stripeWebhook.test.js b/functions/stripeWebhook.test.js index cf84b747..9e61409b 100644 --- a/functions/stripeWebhook.test.js +++ b/functions/stripeWebhook.test.js @@ -219,6 +219,11 @@ function registrationSession(overrides = {}) { amount_total: 5000, currency: 'usd', payment_intent: 'pi_reg_1', + total_details: { + amount_discount: 0, + amount_shipping: 0, + amount_tax: 0, + }, ...overrides, }; } @@ -238,6 +243,11 @@ function orderSession(overrides = {}) { amount_total: 2000, currency: 'usd', payment_intent: 'pi_order_1', + total_details: { + amount_discount: 0, + amount_shipping: 0, + amount_tax: 0, + }, shipping_details: { name: 'Buyer Name', address: { @@ -640,6 +650,9 @@ describe('stripeWebhook', () => { stripePaymentIntentId: 'pi_reg_1', stripeAmountSubtotalCents: 5000, stripeAmountTotalCents: 5000, + stripeDiscountCents: 0, + stripeTaxCents: 0, + stripeShippingCents: 0, stripeCurrency: 'usd', }); expect(registration.auditLog).toHaveLength(1); @@ -661,6 +674,340 @@ describe('stripeWebhook', () => { }); }); + test.each([ + ['partial', 4500, 'paid', 'pi_reg_1', 500], + ['100 percent', 0, 'no_payment_required', null, 5000], + ])('quarantines a %s unapproved promotion discount', async ( + _label, + total, + paymentStatus, + paymentIntent, + discount, + ) => { + seedRegistration(); + const eventId = `evt_discount_${discount}`; + await deliver(stripeEvent( + eventId, + 'checkout.session.completed', + registrationSession({ + amount_total: total, + payment_status: paymentStatus, + payment_intent: paymentIntent, + total_details: { + amount_discount: discount, + amount_shipping: 0, + amount_tax: 0, + }, + }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'pending', + paymentReviewRequired: true, + paymentReviewReason: 'discount_not_allowed', + }); + expect(admin.__get(`stripeEvents/${eventId}`)).toMatchObject({ + outcome: 'needs_review:discount_not_allowed', + requiresReview: true, + }); + }); + + test('quarantines a discounted pre-change Session that permitted promotions', async () => { + seedRegistration(); + await deliver(stripeEvent( + 'evt_legacy_promotion_discount', + 'checkout.session.completed', + registrationSession({ + allow_promotion_codes: true, + amount_total: 4500, + total_details: { + amount_discount: 500, + amount_shipping: 0, + amount_tax: 0, + }, + }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'pending', + paymentReviewRequired: true, + paymentReviewReason: 'discount_not_allowed', + }); + expect(admin.__get('stripeEvents/evt_legacy_promotion_discount')).toMatchObject({ + outcome: 'needs_review:discount_not_allowed', + requiresReview: true, + }); + }); + + test('accepts Stripe null shipping as an explicit zero adjustment', async () => { + seedRegistration(); + await deliver(stripeEvent( + 'evt_null_shipping_zero', + 'checkout.session.completed', + registrationSession({ + total_details: { + amount_discount: 0, + amount_shipping: null, + amount_tax: 0, + }, + }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'paid', + stripeDiscountCents: 0, + stripeTaxCents: 0, + stripeShippingCents: 0, + }); + }); + + test('accepts a complete all-zero Stripe shipping cost breakdown', async () => { + seedOrder(); + await deliver(stripeEvent( + 'evt_zero_shipping_cost', + 'checkout.session.completed', + orderSession({ + shipping_cost: { amount_subtotal: 0, amount_tax: 0, amount_total: 0 }, + }), + )); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'paid', + stripeDiscountCents: 0, + stripeTaxCents: 0, + stripeShippingCents: 0, + }); + }); + + test.each([ + [ + 'tax', + { amount_discount: 0, amount_shipping: 0, amount_tax: 100 }, + null, + 2100, + 'tax_not_configured', + ], + [ + 'shipping charge', + { amount_discount: 0, amount_shipping: 250, amount_tax: 0 }, + { amount_subtotal: 250, amount_tax: 0, amount_total: 250 }, + 2250, + 'shipping_charge_not_configured', + ], + ])('quarantines an unconfigured %s', async ( + _label, + totalDetails, + shippingCost, + total, + reason, + ) => { + seedOrder(); + const eventId = `evt_${reason}_${_label.replace(/[^A-Za-z0-9]+/g, '_')}`; + await deliver(stripeEvent( + eventId, + 'checkout.session.completed', + orderSession({ + amount_total: total, + total_details: totalDetails, + shipping_cost: shippingCost, + }), + )); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'pending', + paymentReviewRequired: true, + paymentReviewReason: reason, + }); + expect(admin.__get(`stripeEvents/${eventId}`)).toMatchObject({ + outcome: `needs_review:${reason}`, + requiresReview: true, + }); + }); + + test.each([ + ['missing details', { total_details: undefined }], + ['null details', { total_details: null }], + ['missing discount', { + total_details: { amount_shipping: 0, amount_tax: 0 }, + }], + ['null discount', { + total_details: { amount_discount: null, amount_shipping: 0, amount_tax: 0 }, + }], + ['missing tax', { + total_details: { amount_discount: 0, amount_shipping: 0 }, + }], + ['null tax', { + total_details: { amount_discount: 0, amount_shipping: 0, amount_tax: null }, + }], + ['missing detail shipping', { + total_details: { amount_discount: 0, amount_tax: 0 }, + }], + ['negative discount', { + total_details: { amount_discount: -1, amount_shipping: 0, amount_tax: 0 }, + }], + ['unsafe discount', { + total_details: { + amount_discount: Number.MAX_SAFE_INTEGER + 1, + amount_shipping: 0, + amount_tax: 0, + }, + }], + ['fractional tax', { + total_details: { amount_discount: 0, amount_shipping: 0, amount_tax: 0.5 }, + }], + ['string shipping', { + total_details: { amount_discount: 0, amount_shipping: '0', amount_tax: 0 }, + }], + ['non-object details', { total_details: 'none' }], + ['non-object shipping cost', { shipping_cost: 'none' }], + ['missing shipping cost subtotal', { + shipping_cost: { amount_tax: 0, amount_total: 0 }, + }], + ['missing shipping cost tax', { + shipping_cost: { amount_subtotal: 0, amount_total: 0 }, + }], + ['missing shipping cost total', { + shipping_cost: { amount_subtotal: 0, amount_tax: 0 }, + }], + ['null shipping cost subtotal', { + shipping_cost: { amount_subtotal: null, amount_tax: 0, amount_total: 0 }, + }], + ['null shipping cost tax', { + shipping_cost: { amount_subtotal: 0, amount_tax: null, amount_total: 0 }, + }], + ['null shipping cost total', { + shipping_cost: { amount_subtotal: 0, amount_tax: 0, amount_total: null }, + }], + ])('quarantines an invalid adjustment shape: %s', async (_label, patch) => { + seedRegistration(); + const eventId = `evt_invalid_adjustment_${_label.replace(/[^A-Za-z0-9]+/g, '_')}`; + await deliver(stripeEvent( + eventId, + 'checkout.session.completed', + registrationSession(patch), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'pending', + paymentReviewReason: 'invalid_stripe_adjustment', + }); + expect(admin.__get(`stripeEvents/${eventId}`)).toMatchObject({ + outcome: 'needs_review:invalid_stripe_adjustment', + }); + }); + + test('quarantines conflicting shipping total sources', async () => { + seedOrder(); + await deliver(stripeEvent( + 'evt_shipping_source_conflict', + 'checkout.session.completed', + orderSession({ + amount_total: 2250, + total_details: { + amount_discount: 0, + amount_shipping: 0, + amount_tax: 0, + }, + shipping_cost: { amount_subtotal: 250, amount_tax: 0, amount_total: 250 }, + }), + )); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'pending', + paymentReviewReason: 'stripe_shipping_breakdown_mismatch', + }); + }); + + test.each([ + ['hidden subtotal', { amount_subtotal: 100, amount_tax: 0, amount_total: 0 }], + ['hidden tax', { amount_subtotal: 0, amount_tax: 100, amount_total: 0 }], + ])('quarantines a shipping cost with a %s', async (_label, shippingCost) => { + seedOrder(); + const eventId = `evt_shipping_${_label.replace(/[^A-Za-z0-9]+/g, '_')}`; + await deliver(stripeEvent( + eventId, + 'checkout.session.completed', + orderSession({ shipping_cost: shippingCost }), + )); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'pending', + paymentReviewReason: 'stripe_shipping_breakdown_mismatch', + }); + expect(admin.__get(`stripeEvents/${eventId}`)).toMatchObject({ + outcome: 'needs_review:stripe_shipping_breakdown_mismatch', + requiresReview: true, + }); + }); + + test('quarantines an inconsistent Stripe total breakdown', async () => { + seedRegistration(); + await deliver(stripeEvent( + 'evt_total_breakdown_mismatch', + 'checkout.session.completed', + registrationSession({ + amount_total: 5000, + total_details: { + amount_discount: 500, + amount_shipping: 0, + amount_tax: 0, + }, + }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'pending', + paymentReviewReason: 'stripe_total_breakdown_mismatch', + }); + }); + + test('quarantines an adjustment breakdown whose computed total is unsafe', async () => { + seedRegistration(); + await deliver(stripeEvent( + 'evt_total_breakdown_overflow', + 'checkout.session.completed', + registrationSession({ + amount_total: Number.MAX_SAFE_INTEGER, + total_details: { + amount_discount: 0, + amount_shipping: Number.MAX_SAFE_INTEGER, + amount_tax: Number.MAX_SAFE_INTEGER, + }, + }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'pending', + paymentReviewReason: 'stripe_total_breakdown_mismatch', + }); + expect(admin.__get('stripeEvents/evt_total_breakdown_overflow')).toMatchObject({ + outcome: 'needs_review:stripe_total_breakdown_mismatch', + requiresReview: true, + }); + }); + + test('never infers a missing subtotal when adjustments are present', async () => { + seedRegistration(); + await deliver(stripeEvent( + 'evt_adjusted_missing_subtotal', + 'checkout.session.completed', + registrationSession({ + amount_subtotal: null, + amount_total: 4500, + total_details: { + amount_discount: 500, + amount_shipping: 0, + amount_tax: 0, + }, + }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'pending', + paymentReviewReason: 'invalid_stripe_subtotal', + }); + }); + test('quarantines a non-payment Checkout Session', async () => { seedRegistration(); @@ -714,6 +1061,9 @@ describe('stripeWebhook', () => { stripePaymentIntentId: 'pi_order_1', stripeAmountSubtotalCents: 2000, stripeAmountTotalCents: 2000, + stripeDiscountCents: 0, + stripeTaxCents: 0, + stripeShippingCents: 0, shipping: { line1: '1 Main St', city: 'San Mateo', @@ -812,6 +1162,137 @@ describe('stripeWebhook', () => { expect(order.cancelledAt).toBeDefined(); }); + test.each([ + ['failure', 'checkout.session.async_payment_failed', 'failed'], + ['expiry', 'checkout.session.expired', 'expired'], + ])('an ordinary async %s preserves an earlier review flag', async (_label, type, status) => { + seedRegistration({ + paymentReviewRequired: true, + paymentReviewReason: 'existing_review_reason', + }); + await deliver(stripeEvent( + `evt_preserve_review_${status}`, + type, + registrationSession({ payment_status: 'unpaid' }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'cancelled', + paymentStatus: status, + paymentReviewRequired: true, + paymentReviewReason: 'existing_review_reason', + }); + }); + + test.each([ + [ + 'failure discount', + 'checkout.session.async_payment_failed', + { + amount_total: 1900, + payment_status: 'unpaid', + total_details: { amount_discount: 100, amount_shipping: 0, amount_tax: 0 }, + }, + 'discount_not_allowed', + ], + [ + 'expiry discount', + 'checkout.session.expired', + { + amount_total: 1900, + payment_status: 'unpaid', + total_details: { amount_discount: 100, amount_shipping: 0, amount_tax: 0 }, + }, + 'discount_not_allowed', + ], + [ + 'failure tax', + 'checkout.session.async_payment_failed', + { + amount_total: 2100, + payment_status: 'unpaid', + total_details: { amount_discount: 0, amount_shipping: 0, amount_tax: 100 }, + }, + 'tax_not_configured', + ], + [ + 'expiry tax', + 'checkout.session.expired', + { + amount_total: 2100, + payment_status: 'unpaid', + total_details: { amount_discount: 0, amount_shipping: 0, amount_tax: 100 }, + }, + 'tax_not_configured', + ], + [ + 'failure shipping', + 'checkout.session.async_payment_failed', + { + amount_total: 2100, + payment_status: 'unpaid', + total_details: { amount_discount: 0, amount_shipping: 100, amount_tax: 0 }, + shipping_cost: { amount_subtotal: 100, amount_tax: 0, amount_total: 100 }, + }, + 'shipping_charge_not_configured', + ], + [ + 'expiry shipping', + 'checkout.session.expired', + { + amount_total: 2100, + payment_status: 'unpaid', + total_details: { amount_discount: 0, amount_shipping: 100, amount_tax: 0 }, + shipping_cost: { amount_subtotal: 100, amount_tax: 0, amount_total: 100 }, + }, + 'shipping_charge_not_configured', + ], + [ + 'failure malformed breakdown', + 'checkout.session.async_payment_failed', + { payment_status: 'unpaid', total_details: null }, + 'invalid_stripe_adjustment', + ], + [ + 'expiry malformed breakdown', + 'checkout.session.expired', + { + payment_status: 'unpaid', + total_details: { amount_discount: 0, amount_shipping: 0, amount_tax: 0 }, + shipping_cost: { amount_subtotal: 0, amount_tax: 0 }, + }, + 'invalid_stripe_adjustment', + ], + [ + 'failure hidden shipping subtotal', + 'checkout.session.async_payment_failed', + { + payment_status: 'unpaid', + shipping_cost: { amount_subtotal: 100, amount_tax: 0, amount_total: 0 }, + }, + 'stripe_shipping_breakdown_mismatch', + ], + ])('quarantines an adjusted async %s Session', async (_label, type, patch, reason) => { + seedOrder(); + const eventId = `evt_adjusted_${_label.replace(/[^A-Za-z0-9]+/g, '_')}`; + await deliver(stripeEvent(eventId, type, orderSession(patch))); + + const order = admin.__get('orders/order-1'); + expect(order).toMatchObject({ + status: 'cancelled', + paymentStatus: type === 'checkout.session.expired' ? 'expired' : 'failed', + paymentReviewRequired: true, + paymentReviewReason: reason, + }); + expect(order.cancelledAt).toBeDefined(); + expect(order.paidAt).toBeUndefined(); + expect(order.fulfilledAt).toBeUndefined(); + expect(admin.__get(`stripeEvents/${eventId}`)).toMatchObject({ + outcome: `needs_review:${reason}`, + requiresReview: true, + }); + }); + test.each([ ['failure', 'checkout.session.async_payment_failed'], ['expiry', 'checkout.session.expired'],