From b4f6b1d67c888d79284b7ac6b1afbe0fde5db406 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Sat, 1 Aug 2026 20:21:19 -0700 Subject: [PATCH] fix(stripe): reject impossible dispute chronology (#594) --- STRIPE_COMMERCE_DESIGN.md | 70 ++- functions/stripeWebhook.js | 8 + functions/stripeWebhook.test.js | 964 +++++++++++++++++++++++++++----- 3 files changed, 897 insertions(+), 145 deletions(-) diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 75d1c8a..b8298e8 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -805,7 +805,7 @@ flowchart TD Text alternative: after replay and the earlier realm, status, and metadata-version checks, a supported Dispute Event must carry a Firestore-representable whole Unix timestamp; unusable time evidence is recorded for review without resolving a target, while valid evidence proceeds to the remaining checks. -Verification for this source-only child requires separately named synthetic tests for all three Dispute lifecycles, hostile shapes and bounds, admission precedence, target and binding isolation, redacted logging, replay, valid lower and upper boundaries, and valid missing-target retry. Review must also confirm that the later redundant predicate is removed, non-Dispute admission is unchanged, and no schema, migration, package, Rule, workflow, provider, or deployment surface changed. The expected result is a target-free processed review for invalid time evidence and unchanged behavior for valid representable values. +Verification for this source-only child requires separately named synthetic tests for all three Dispute lifecycles, hostile shapes and bounds, admission precedence, target and binding isolation, redacted logging, replay, valid lower and upper boundaries, and valid missing-target retry. Review must also confirm that the later redundant predicate is removed, non-Dispute admission is unchanged, and no schema, migration, package, Rule, workflow, provider, or deployment surface changed. The expected result is a target-free processed review for invalid time evidence and unchanged behavior for valid representable values. At A7 delivery, the lower- and upper-bound controls did not compare the embedded Dispute time with the outer Event time. PAY-003A15 below changes only those historical fixtures to use equal chronology; A7's outer-time contract is unchanged. Residual work remains explicit. This check does not compare an Event time with the current clock, establish provider truth, select or verify a Stripe endpoint API version, retrieve a provider object, inspect or repair history, migrate stored data, change non-Dispute Event admission, or complete PAY-003B or PAY-003C. If a focused or full check shows target access, business mutation, binding activity, hostile-value logging, a precedence change, or a valid-boundary regression, stop the merge and escalate to the platform owner and payment reviewer. Undo is a small reviewed revert of the PAY-003A7 source, tests, and this named design section; do not edit production records or provider objects as an undo path. @@ -930,13 +930,13 @@ flowchart TD Text alternative: replay returns an existing ledger result first; a new refund keeps every earlier admission result, then rejects a Charge time later than its Event time without target work, while an equal or earlier Charge time may continue to normal refund processing. -The expected result is target-free durable review for impossible chronology and unchanged refund behavior for equal or earlier valid time. Success proof requires separately named registration and order tests for the contradiction; target, fallback, business, and binding isolation; valid outer ledger time; fixed redacted logging; claimed missing-target handling; all earlier admission precedence; exact replay; equality and earlier-time controls; the corrected PAY-003A9 upper-bound controls; and explicit Checkout Session and Dispute exclusions. The complete webhook suite and prior PAY-003A5/A8/A9 matrices must remain green. +The expected result is target-free durable review for impossible chronology and unchanged refund behavior for equal or earlier valid time. Success proof requires separately named registration and order tests for the contradiction; target, fallback, business, and binding isolation; valid outer ledger time; fixed redacted logging; claimed missing-target handling; all earlier admission precedence; exact replay; equality and earlier-time controls; the corrected PAY-003A9 upper-bound controls; and explicit Checkout Session and, at A10 delivery, Dispute-chronology exclusions. PAY-003A15 below supersedes only that historical Dispute-chronology compatibility control. The complete webhook suite and prior PAY-003A5/A8/A9 matrices must remain green. -Stop the merge if impossible chronology reaches a target or fallback query, binding, business mutation, timestamp disclosure, money check, or transition; if an earlier outcome changes; if equality or an earlier Charge time regresses; if Checkout or Dispute behavior changes in this child; or if a test needs a real credential, provider object, refund/payment, customer/member record, or production service. Escalate to the platform owner and payment reviewer. Undo is one small reviewed revert of the PAY-003A10 comparison, named tests, the three A9 compatibility-control corrections, and this section. Do not edit a provider object, processed ledger, or business record as an undo path. +Stop the A10 merge if impossible chronology reaches a target or fallback query, binding, business mutation, timestamp disclosure, money check, or transition; if an earlier outcome changes; if equality or an earlier Charge time regresses; if Checkout or Dispute behavior changes inside A10; or if a test needs a real credential, provider object, refund/payment, customer/member record, or production service. Escalate to the platform owner and payment reviewer. Undo is one small reviewed revert of the PAY-003A10 comparison, named tests, the three A9 compatibility-control corrections, and this section. Do not edit a provider object, processed ledger, or business record as an undo path. There is no schema, Rule, index, package, workflow, stored-data migration, or backfill. Valid chronology, earlier admission precedence, target resolution, bindings, money checks, refund transitions, and existing `paidAt` values remain compatible. A newly delivered impossible Event changes from target-capable behavior to durable pre-target review. Already-processed historical Events remain authoritative on replay. Previously stored ledger, binding, refund, and `paidAt` values are not inspected or repaired. -Residual work remains under #106: current-clock freshness and skew policy; Checkout Session expiry evidence; Dispute/Event chronology; provider endpoint and API-version proof; provider retrieval and history repair; PAY-003B/C; reconciliation; alerts; protected release; and live verification. This child proves no provider truth, payment/refund success, historical correctness, or production behavior. +At A10 delivery, Dispute/Event chronology remained under #106; PAY-003A15 below now owns that comparison. Other residual work remains under #106: current-clock freshness and skew policy; Checkout Session expiry evidence; provider endpoint and API-version proof; provider retrieval and history repair; PAY-003B/C; reconciliation; alerts; protected release; and live verification. A10 proves no provider truth, payment/refund success, historical correctness, or production behavior. PAY-003A10 delivery evidence must separately name source changed, synthetic tests passed, code merged, website published, `runmprc.com` verified, Firebase deployed and read back, Stripe/provider configured or acted on, production data changed, payment/refund/dispute activity, and live behavior verified. Source and synthetic tests prove none of the later states. Officer impact and officer documentation are both none: this server-only evidence-ordering boundary adds no officer screen, task, field, permission, approval, topology, or available recovery step. The remaining root design, security, implementation, operations, issue, and officer guides stay compatible. @@ -1055,15 +1055,15 @@ flowchart TD Text alternative: replay returns its stored result first; a new Checkout Event keeps every earlier admission result; a Session time later than the Event time writes a fixed review without target work, while an equal or earlier Session time may continue to existing business checks. -The expected result is durable, target-free review for impossible Checkout Session/Event chronology and unchanged Checkout behavior for equal or earlier chronology. Success proof requires separately named completion, asynchronous-success, asynchronous-failure, and expiry tests for registration and order targets; a one-second contradiction; equal and earlier cases; inclusive Unix-epoch and Firestore-maximum equality; fixed redacted logs; valid outer ledger time; exact ledger-read and timestamp isolation; metadata-only, client-reference-only, matching-dual, legacy Session, Payment Link, ambiguous-target, malformed-reference, claimed-missing-target, binding-conflict, object, mode, money, payment-status, PaymentIntent, and predecessor isolation; every earlier admission precedence; valid missing-target retry; exact replay; richer historical-ledger preservation; outer Event-derived `paidAt`; and explicit refund Charge, every Dispute lifecycle, and unsupported Event compatibility. These tests must reject inclusive, reversed, tolerance, off-by-one, wrong-gate, wrong-reason, late-check, timestamp-leak, retryable, replay-recomputation, Session-derived-ledger-time, Session-derived-`paidAt`, and non-Checkout-spillover implementations. The complete webhook and Functions suites and prior PAY-003A2/A4/A8 through A12 matrices must remain green. +The expected result is durable, target-free review for impossible Checkout Session/Event chronology and unchanged Checkout behavior for equal or earlier chronology. Success proof requires separately named completion, asynchronous-success, asynchronous-failure, and expiry tests for registration and order targets; a one-second contradiction; equal and earlier cases; inclusive Unix-epoch and Firestore-maximum equality; fixed redacted logs; valid outer ledger time; exact ledger-read and timestamp isolation; metadata-only, client-reference-only, matching-dual, legacy Session, Payment Link, ambiguous-target, malformed-reference, claimed-missing-target, binding-conflict, object, mode, money, payment-status, PaymentIntent, and predecessor isolation; every earlier admission precedence; valid missing-target retry; exact replay; richer historical-ledger preservation; outer Event-derived `paidAt`; and explicit refund Charge, every Dispute lifecycle, and unsupported Event compatibility. At A13 delivery, the Dispute-lifecycle compatibility controls used structurally valid embedded times later than their outer Event times. PAY-003A15 below corrects only those historical controls to equal chronology. These A13 tests must reject inclusive, reversed, tolerance, off-by-one, wrong-gate, wrong-reason, late-check, timestamp-leak, retryable, replay-recomputation, Session-derived-ledger-time, Session-derived-`paidAt`, and non-Checkout-spillover implementations. The complete webhook and Functions suites and prior PAY-003A2/A4/A8 through A12 matrices must remain green. -Stop the merge if a later Session time reaches reference classification with Firestore work, a target or fallback query, provider binding, business read or mutation, money or state evaluation, Session-time conversion, response detail, ledger detail, or log; if equality or an earlier Session time is rejected; if an earlier admission result changes; if a claimed missing target stays retryable despite contradictory chronology; if a valid missing target stops retrying; if the valid outer Event time is replaced by Session time in the ledger or `paidAt`; if Charge, Dispute, or unsupported Event behavior changes; or if a test needs a real credential, provider object or call, payment, customer or member record, or production service. Escalate to the platform owner and payment reviewer. +Stop the A13 merge if a later Session time reaches reference classification with Firestore work, a target or fallback query, provider binding, business read or mutation, money or state evaluation, Session-time conversion, response detail, ledger detail, or log; if equality or an earlier Session time is rejected; if an earlier admission result changes; if a claimed missing target stays retryable despite contradictory chronology; if a valid missing target stops retrying; if the valid outer Event time is replaced by Session time in the ledger or `paidAt`; if Charge, Dispute, or unsupported Event behavior changes inside A13; or if a test needs a real credential, provider object or call, payment, customer or member record, or production service. Escalate to the platform owner and payment reviewer. Success proof is a reviewed source diff plus synthetic test output only. Undo is one small reviewed revert of the Checkout-only comparison, fixed reason, named A13 tests, the three chronology-valid compatibility fixture corrections, and this section. Do not edit a Stripe object, processed ledger, binding, order, registration, or payment field as an undo path. There is no schema, Rule, index, package, workflow, API, provider-configuration, migration, backfill, or history-reprocessing change. Newly delivered contradictory Checkout Events change from target-capable behavior to pre-target durable review. Valid new Events keep their current outcomes. Already-processed ledgers remain authoritative on replay. Existing business records, bindings, payment fields, and historical ledger rows are not inspected or repaired. -Residual work remains under #106: current-clock freshness and skew; Session `expires_at`; endpoint API-version proof; provider retrieval and history repair; already-paid missing-time repair; paid-after-cancellation policy; other Charge and Dispute chronology decisions; PAY-003B/C; reconciliation; alerts; protected release; and live verification. This child proves no provider truth, payment or refund success, historical correctness, or production behavior. +At A13 delivery, other Charge and Dispute chronology decisions remained under #106; PAY-003A15 below now owns the Dispute/Event comparison. Other residual work remains under #106: current-clock freshness and skew; Session `expires_at`; endpoint API-version proof; provider retrieval and history repair; already-paid missing-time repair; paid-after-cancellation policy; other Charge chronology decisions; PAY-003B/C; reconciliation; alerts; protected release; and live verification. A13 proves no provider truth, payment or refund success, historical correctness, or production behavior. PAY-003A13 delivery evidence must separately name source changed, synthetic tests passed, code merged, website published, `runmprc.com` verified, Firebase deployed and read back, Stripe/provider configured or acted on, production data changed, payment/refund/dispute activity, and live behavior verified. Source and synthetic tests prove none of the later states. Officer impact is none. Officer documentation is `None — this server-only pre-target evidence comparison adds no officer screen, task, field, permission, approval, topology, incident step, or available recovery action.` The remaining root design, security, implementation, operations, issue, and officer guides stay compatible. @@ -1098,18 +1098,66 @@ flowchart TD Text alternative: replay returns its stored result first; a new supported Dispute Event keeps every earlier admission result; unusable embedded creation evidence writes a fixed review without target work, while usable evidence may continue to existing business checks. -The expected result is durable, target-free review for unusable Dispute-created evidence and unchanged Dispute processing for usable evidence. Success proof requires separately named registration and order tests across created, updated, and closed lifecycles; missing and hostile shapes; post-parser Proxy evidence without coercion; inclusive epoch and Firestore-maximum boundaries; fixed redacted logs; valid outer ledger time; exact ledger-read and timestamp isolation; metadata-only, client-reference-only, matching-dual, legacy PaymentIntent, legacy Charge, unmatched, ambiguous-target, malformed-reference, conflicting-reference, binding-conflict, claimed-missing-target, object, currency, amount, Charge, PaymentIntent, and predecessor isolation; every earlier admission precedence; valid missing-target retry; exact replay; richer historical-ledger preservation; structurally valid later Dispute time; and explicit Checkout Session, refund Charge, unsupported Event, and unsupported dispute-like Event compatibility. These tests must reject coercing, wrong-field, wrong-gate, wrong-lifecycle, wrong-domain, lower-bound, upper-bound, off-by-one, wrong-reason, retryable, late-check, value-disclosure, timestamp-conversion, replay-recomputation, clock-freshness, chronology, and broad-prefix implementations. The complete webhook and Functions suites and prior PAY-003A2/A4/A5 through A13 matrices must remain green. +The expected result is durable, target-free review for unusable Dispute-created evidence and unchanged Dispute processing for usable evidence. Success proof requires separately named registration and order tests across created, updated, and closed lifecycles; missing and hostile shapes; post-parser Proxy evidence without coercion; inclusive epoch and Firestore-maximum boundaries; fixed redacted logs; valid outer ledger time; exact ledger-read and timestamp isolation; metadata-only, client-reference-only, matching-dual, legacy PaymentIntent, legacy Charge, unmatched, ambiguous-target, malformed-reference, conflicting-reference, binding-conflict, claimed-missing-target, object, currency, amount, Charge, PaymentIntent, and predecessor isolation; every earlier admission precedence; valid missing-target retry; exact replay; richer historical-ledger preservation; at A14 delivery, structurally valid later Dispute-time compatibility; and explicit Checkout Session, refund Charge, unsupported Event, and unsupported dispute-like Event compatibility. PAY-003A15 below supersedes only that historical chronology control. These A14 tests must reject coercing, wrong-field, wrong-gate, wrong-lifecycle, wrong-domain, lower-bound, upper-bound, off-by-one, wrong-reason, retryable, late-check, value-disclosure, timestamp-conversion, replay-recomputation, clock-freshness, and broad-prefix implementations. The complete webhook and Functions suites and prior PAY-003A2/A4/A5 through A13 matrices must remain green. -Stop the merge if unusable Dispute time reaches a target or fallback query, provider-binding read or write, business read or mutation, money or predecessor evaluation, timestamp construction, response detail, ledger detail, or log; if a rejected value is coerced; if an earlier admission result changes; if either inclusive boundary regresses; if a valid later Dispute time is rejected; if a claimed missing target remains retryable despite unusable evidence; if a valid missing target stops retrying; if the outer Event ledger time changes; if Checkout, refund, unsupported, or unsupported dispute-like Event behavior changes; or if a test needs a real credential, provider object or call, payment, refund, dispute, customer or member record, or production service. Escalate to the platform owner and payment reviewer. +Stop the A14 merge if unusable Dispute time reaches a target or fallback query, provider-binding read or write, business read or mutation, money or predecessor evaluation, timestamp construction, response detail, ledger detail, or log; if a rejected value is coerced; if an earlier admission result changes; if either inclusive boundary regresses; if a valid later Dispute time is rejected inside A14; if a claimed missing target remains retryable despite unusable evidence; if a valid missing target stops retrying; if the outer Event ledger time changes; if Checkout, refund, unsupported, or unsupported dispute-like Event behavior changes; or if a test needs a real credential, provider object or call, payment, refund, dispute, customer or member record, or production service. Escalate to the platform owner and payment reviewer. Success proof is a reviewed source diff plus signed synthetic test output only. Undo is one small reviewed revert of the Dispute-only predicate and fixed reason, the shared synthetic fixture default, the PAY-003A9 and A12 compatibility corrections, the named A14 tests, and this section. Do not edit a Stripe object, processed ledger, binding, order, registration, dispute, or payment field as an undo path. -There is no schema, Rule, index, package, workflow, API, provider-configuration, migration, backfill, or history-reprocessing change. Newly delivered supported Dispute Events with unusable embedded creation evidence change from target-capable behavior to pre-target durable review. Valid new Events keep their current outcomes, including a structurally valid embedded time later than the outer Event time. Already-processed ledgers remain authoritative on replay. Existing business records, bindings, dispute fields, payment fields, and historical ledger rows are not inspected or repaired. +There is no schema, Rule, index, package, workflow, API, provider-configuration, migration, backfill, or history-reprocessing change. Newly delivered supported Dispute Events with unusable embedded creation evidence change from target-capable behavior to pre-target durable review. At A14 delivery, valid new Events kept their current outcomes, including a structurally valid embedded time later than the outer Event time. PAY-003A15 below now owns that later-versus-equal-or-earlier comparison. Already-processed ledgers remain authoritative on replay. Existing business records, bindings, dispute fields, payment fields, and historical ledger rows are not inspected or repaired. -Residual work remains under #106: Dispute/Event chronology; current-clock freshness and skew; endpoint and API-version proof; provider retrieval and history repair; PAY-003B/C; reconciliation; alerts; protected release; and live verification. This child proves no provider truth, payment, refund, or dispute success, historical correctness, or production behavior. +At A14 delivery, Dispute/Event chronology remained under #106; PAY-003A15 below now owns that comparison. Other residual work remains under #106: current-clock freshness and skew; endpoint and API-version proof; provider retrieval and history repair; PAY-003B/C; reconciliation; alerts; protected release; and live verification. A14 proves no provider truth, payment, refund, or dispute success, historical correctness, or production behavior. PAY-003A14 delivery evidence must separately name source changed, synthetic tests passed, code merged, website published, `runmprc.com` verified, Firebase deployed and read back, Stripe or another outside provider configured or acted on, production data changed, checkout/payment/refund/dispute activity, and live behavior verified. Source and synthetic tests prove none of the later states. Officer impact is none. Officer documentation is `None — this server-only pre-target evidence structure adds no officer screen, task, field, permission, approval, topology, incident step, or available recovery action.` The remaining root design, security, implementation, operations, issue, and officer guides stay compatible. +### PAY-003A15 Dispute/Event chronology — SOURCE ONLY, NOT LIVE + +PAY-003A15 [#594](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/594) closes the comparison half of PAY-003A14's Dispute-time residual. Its purpose is to reject a newly delivered supported Dispute Event when the embedded Dispute says it was created after the Event that carries it. Stripe describes [Dispute `created`](https://docs.stripe.com/api/disputes/object?api-version=2024-06-20) and [Event `created`](https://docs.stripe.com/api/events/object?api-version=2024-06-20) as their respective object-creation times in whole Unix seconds. Stripe's [Event object](https://docs.stripe.com/api/events/object?api-version=2024-06-20) describes `data.object` as the API resource relevant to the Event, and the [Event-type reference](https://docs.stripe.com/api/events/types?api-version=2024-06-20) says the three supported Dispute lifecycle Events carry a Dispute there. Stripe does not explicitly guarantee the cross-field inequality `Dispute.created <= Event.created`. MPRC adopts it as a conservative source-admission inference: the two fields in one Event are internally contradictory when the embedded object's creation second is later than the carrying Event's creation second. + +This inference is not a claim about delivery order. Stripe says [webhook endpoints are not guaranteed to receive Events in generation order](https://docs.stripe.com/webhooks#event-ordering). PAY-003A15 compares only two already-admitted fields inside the same signed Event. It does not compare separate Events, receipt order, the current clock, or ledger arrival order. It does not establish exact dispute-occurrence time, freshness, an endpoint API version, canonical provider history, provider truth, or live behavior. + +After both values pass their existing primitive safe-integer and Firestore-range checks, the embedded Dispute second must be less than or equal to the outer Event second. Equality is valid because both fields have whole-second resolution. An earlier Dispute second remains valid. A later value produces fixed target-free `needs_review:invalid_dispute_event_chronology`. The valid outer Event time remains the processed ledger's `stripeCreatedAt`; target fields remain null. Neither compared value is returned or logged. The embedded value is not converted to a timestamp or stored. The source adds no tolerance, skew allowance, receipt-time substitution, or current-clock freshness rule. + +Approvers are the platform owner and payment reviewer. Prerequisites are verified webhook signature handling; the processed-Event ledger; embedded Dispute realm admission; PAY-003A6 Event/status lifecycle admission; PAY-003A4 claimed-MPRC metadata-version admission; PAY-003A7 and A8 outer Event-time admission; PAY-003A14 embedded Dispute-time structure admission; signed synthetic fixtures; and the existing nullable ledger target fields. A new supported Dispute Event keeps this order: + +1. Return an exact processed-Event replay without changing its stored result. +2. Check the outer Event realm. +3. Check the embedded Dispute realm. +4. Check that the Dispute status is compatible with the Event lifecycle. +5. Check an explicit claimed-MPRC metadata schema version. +6. Check the outer Event-created value. +7. Check the embedded Dispute-created value. +8. Check that Dispute-created is not later than Event-created. +9. Only after those checks pass, perform target-capable reference and fallback resolution. +10. Then evaluate ownership, provider bindings, object identity, currency, amount, and predecessor state. + +```mermaid +flowchart TD + A["Verified supported Dispute Event"] --> B{"Already in the processed-Event ledger?"} + B -- "Yes" --> C["Return the stored result without changes"] + B -- "No" --> D{"Earlier realm, status, metadata, and time-shape checks pass?"} + D -- "No" --> E["Keep the existing earlier review result"] + D -- "Yes" --> F{"Dispute-created is no later than Event-created?"} + F -- "No" --> G["Write fixed review with no target"] + F -- "Yes" --> H["Continue to target, binding, money, and state checks"] +``` + +Text alternative: replay returns its stored result first; a new supported Dispute Event keeps every earlier admission result; a Dispute time later than the Event time writes a fixed review without target work, while an equal or earlier Dispute time may continue to existing business checks. + +The expected result is durable, target-free review for contradictory Dispute/Event chronology and unchanged Dispute behavior for equal or earlier chronology. Success proof requires separately named registration and order tests across created, updated, and closed lifecycles; a one-second contradiction; equal and earlier controls; inclusive Unix-epoch and Firestore-maximum equality; fixed redacted logs; valid outer ledger time; exact ledger-read and timestamp isolation; metadata-only, client-reference-only, matching-dual, legacy PaymentIntent, legacy Charge, unmatched, ambiguous-target, malformed-reference, conflicting-reference, binding-conflict, claimed-missing-target, object, currency, amount, Charge, PaymentIntent, and predecessor isolation; every earlier admission precedence, including unusable embedded Dispute time; valid missing-target retry; exact replay; richer historical-ledger preservation; and explicit Checkout Session, refund Charge, unsupported Event, and unsupported dispute-like Event compatibility. The complete webhook and Functions suites and prior PAY-003A2/A4/A5 through A14 matrices must remain green. + +The tests must reject implementations that reverse the comparison, use a strict `<` comparison that rejects equality, add tolerance, make an off-by-one error, check the wrong field, gate, lifecycle, or broad Event prefix, use the wrong reason or a retryable result, run the check late, disclose a value, convert the embedded time to a timestamp, substitute it for ledger time, recompute a replay, infer receipt or delivery order, add current-clock freshness, or spill into non-Dispute Events. The source and tests must not claim that Stripe explicitly guarantees the comparison or delivery order. + +Stop the A15 merge if a later Dispute time reaches target-capable reference or fallback work, a Firestore target query, provider-binding read or write, business read or mutation, money, or predecessor evaluation; if either compared value reaches response detail or a log; if the embedded Dispute value reaches ledger detail or timestamp construction; if equality or an earlier Dispute time is rejected; if any earlier admission result changes; if a claimed missing target remains retryable despite contradictory chronology; if a valid missing target stops retrying; if the outer Event ledger time changes; if receipt order, delivery order, a current-clock gate, tolerance, or freshness is introduced; if non-Dispute or unsupported behavior changes; or if a test needs a real credential, provider object or call, payment, refund, dispute, customer or member record, production data, or production service. Escalate to the platform owner and payment reviewer. + +Success proof is a reviewed source diff plus signed synthetic test output only. Undo is one small reviewed revert of the Dispute-only comparison and fixed reason, the named A15 tests, the chronology-only A7, A10, A13, and A14 historical-control corrections, and this section. Do not edit a Stripe object, processed ledger, binding, order, registration, dispute, payment field, or production record as an undo path. + +There is no schema, Rule, index, package, workflow, API, provider-configuration, migration, backfill, or history-reprocessing change. Newly delivered supported Dispute Events with contradictory chronology change from target-capable behavior to pre-target durable review. Valid equal or earlier new Events keep their current outcomes. Already-processed ledgers remain authoritative on replay and are not re-evaluated. Existing business records, bindings, dispute fields, payment fields, and historical ledger rows are not inspected or repaired. + +Residual work remains under #106: current-clock freshness and skew policy; endpoint and API-version proof; provider retrieval and canonical-history work; PAY-003B/C; reconciliation; alerts; protected release; and live verification. PAY-003A15 proves no provider truth, payment, refund, or dispute success, historical correctness, delivery ordering, or production behavior. + +PAY-003A15 delivery evidence must separately name source changed, synthetic tests passed, code merged, website published, `runmprc.com` verified, Firebase deployed and read back, Stripe or another outside provider configured or acted on, production data changed, checkout/payment/refund/dispute activity, and live behavior verified. Each state is distinct. Source and synthetic tests prove none of the later states. This child performs no website publication, `runmprc.com` verification, Firebase deployment or readback, Stripe or other provider configuration or action, production-data change, checkout, payment, refund, dispute, or live verification. Officer impact is none. Officer documentation is `None — this server-only pre-target evidence comparison adds no officer screen, task, field, permission, approval, topology, incident step, or available recovery action.` The remaining root design, security, implementation, operations, issue, and officer guides stay compatible. + Store actual total, discount, tax, shipping, Stripe customer reference if required, PaymentIntent ID, and Charge reference. An anomaly enters `payment_review`/quarantine and alerts operations; it never silently marks paid. ## 12. Payment and business state machines diff --git a/functions/stripeWebhook.js b/functions/stripeWebhook.js index c7c6e54..efd80c9 100644 --- a/functions/stripeWebhook.js +++ b/functions/stripeWebhook.js @@ -181,6 +181,14 @@ function validateEventAdmission(event, expectedLivemode) { reason: 'invalid_dispute_created', }; } + if (DISPUTE_EVENT_TYPES.has(event.type) + && event.data.object.created > event.created) { + return { + ok: false, + expected: expectedLivemode, + reason: 'invalid_dispute_event_chronology', + }; + } if (CHECKOUT_EVENT_TYPES.has(event.type) && !isValidStripeCreated(event.data?.object?.created)) { return { diff --git a/functions/stripeWebhook.test.js b/functions/stripeWebhook.test.js index e4c9a19..9f0b335 100644 --- a/functions/stripeWebhook.test.js +++ b/functions/stripeWebhook.test.js @@ -1061,6 +1061,28 @@ function expectedDisputeCreatedQuarantine(bindingPaths = []) { }; } +function expectedDisputeEventChronologyQuarantine(bindingPaths = []) { + return { + httpStatus: null, + response: { + received: true, + duplicate: false, + outcome: 'needs_review:invalid_dispute_event_chronology', + requiresReview: true, + }, + businessUnchanged: true, + ledger: { + status: 'processed', + outcome: 'needs_review:invalid_dispute_event_chronology', + requiresReview: true, + targetType: null, + targetPath: null, + targetSource: null, + }, + bindingPaths, + }; +} + function expectedEventCreatedQuarantine(bindingPaths = []) { return { httpStatus: null, @@ -2742,7 +2764,7 @@ describe('stripeWebhook', () => { ['created', 'charge.dispute.created', 'needs_response', 'dispute_needs_response'], ['updated', 'charge.dispute.updated', 'under_review', 'dispute_under_review'], ['closed', 'charge.dispute.closed', 'won', 'dispute_won'], - ])('leaves later %s Dispute embedded time unchanged', async ( + ])('leaves valid equal %s Dispute chronology unchanged', async ( lifecycle, type, status, @@ -2754,7 +2776,7 @@ describe('stripeWebhook', () => { type, orderDispute({ id: `dp_pay003a13_${lifecycle}`, - created: eventCreated + 1, + created: eventCreated, status, }), ); @@ -3347,7 +3369,7 @@ describe('stripeWebhook', () => { ([lifecycle, type, status, outcome]) => DISPUTE_CREATED_DOMAINS.map( (domain) => [lifecycle, domain, type, status, outcome], ), - ))('keeps later valid %s %s Dispute time compatible', async ( + ))('keeps equal %s %s Dispute chronology compatible', async ( lifecycle, domain, type, @@ -3358,9 +3380,9 @@ describe('stripeWebhook', () => { domain, type, status, - 'later_' + lifecycle + '_' + domain, + 'equal_' + lifecycle + '_' + domain, ); - event.data.object.created = event.created + 1; + event.data.object.created = event.created; const response = await deliver(event); @@ -3442,150 +3464,823 @@ describe('stripeWebhook', () => { }); }); - test('fails closed before persistence when expected Stripe mode is not configured', async () => { - delete process.env.STRIPE_LIVEMODE_EXPECTED; - seedRegistration(); - - const response = await deliver(stripeEvent( - 'evt_missing_mode_config', - 'checkout.session.completed', - registrationSession(), - )); - - expect(response.status).toHaveBeenCalledWith(500); - expect(response.send).toHaveBeenCalledWith('Server configuration is unavailable'); - expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); - expect(admin.__get('stripeEvents/evt_missing_mode_config')).toBeUndefined(); - }); - - test('reports a missing webhook secret as configuration failure before persistence', async () => { - delete process.env.STRIPE_WEBHOOK_SECRET; - seedRegistration(); + describe('PAY-003A15 Dispute/Event chronology', () => { + const eventCreated = 1_800_000_000; - const response = await deliver(stripeEvent( - 'evt_missing_webhook_secret', - 'checkout.session.completed', - registrationSession(), - )); + function laterDisputeFixture(domain, type, status, suffix, options = {}) { + const fixture = disputeCreatedFixture(domain, type, status, suffix, options); + fixture.event.created = eventCreated; + fixture.event.data.object.created = eventCreated + 1; + return fixture; + } - expect(response.status).toHaveBeenCalledWith(500); - expect(response.send).toHaveBeenCalledWith('Server configuration is unavailable'); - expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); - expect(admin.__get('stripeEvents/evt_missing_webhook_secret')).toBeUndefined(); - expect(consoleError).toHaveBeenCalledWith( - 'Stripe webhook configuration unavailable', - { reason: 'webhook_secret_missing' }, - ); - }); + test.each(DISPUTE_CREATED_LIFECYCLES.flatMap( + ([lifecycle, type, status]) => DISPUTE_CREATED_DOMAINS.map( + (domain) => [lifecycle, domain, type, status], + ), + ))( + 'rejects later %s %s Dispute time before target work', + async (lifecycle, domain, type, status) => { + const { event, businessPath } = disputeCreatedFixture( + domain, + type, + status, + 'red_' + lifecycle + '_' + domain, + ); + const before = storedCopy(businessPath); + event.created = eventCreated; + event.data.object.created = eventCreated + 1; - afterAll(() => { - consoleError.mockRestore(); - delete process.env.ENVIRONMENT_NAME; - delete process.env.SITE_ORIGIN; - delete process.env.STRIPE_LIVEMODE_EXPECTED; - delete process.env.STRIPE_SECRET_KEY; - delete process.env.STRIPE_WEBHOOK_SECRET; - delete process.env.COMMERCE_ENABLED; - }); + const response = await deliver(event); - test('rejects a missing or invalid signature', async () => { - delete process.env.ENVIRONMENT_NAME; - const event = stripeEvent( - 'evt_bad_sig', - 'checkout.session.completed', - registrationSession(), + expect(refundAdmissionObservation({ + response, + event, + businessSnapshots: [[businessPath, before]], + })).toEqual(expectedDisputeEventChronologyQuarantine()); + expect(admin.__get('stripeEvents/' + event.id)).toMatchObject({ + stripeCreatedAt: { _milliseconds: eventCreated * 1000 }, + }); + expectOnlyEventLedgerReads(event); + expectOnlyEventLedgerTimestamps(event); + expect(Timestamp.now).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + 'Stripe event requires review', + { + eventId: event.id, + eventType: type, + outcome: 'needs_review:invalid_dispute_event_chronology', + targetType: null, + }, + ); + expect(JSON.stringify({ + response: response.json.mock.calls, + ledger: admin.__get('stripeEvents/' + event.id), + logs: consoleError.mock.calls, + })).not.toContain(String(event.data.object.created)); + }, ); - const response = mockResponse(); - await stripeWebhook(signedRequest(event, 't=1,v1=0000'), response); + test('rejects a historical one-second contradiction without clock policy', async () => { + const { event, businessPath } = disputeCreatedFixture( + 'order', + 'charge.dispute.updated', + 'under_review', + 'historical_contradiction', + ); + const before = storedCopy(businessPath); + event.created = 0; + event.data.object.created = 1; - expect(response.status).toHaveBeenCalledWith(400); - expect(response.send).toHaveBeenCalledWith('Webhook signature error'); - expect(admin.__get('stripeEvents/evt_bad_sig')).toBeUndefined(); - expect(consoleError).toHaveBeenCalledWith( - 'Stripe webhook signature verification failed', - { reason: 'invalid_signature_or_payload' }, - ); - expect(JSON.stringify(consoleError.mock.calls)).not.toContain(WEBHOOK_SECRET); - }); + const response = await deliver(event); - test('rejects non-POST requests', async () => { - const response = mockResponse(); - await stripeWebhook({ method: 'GET' }, response); - expect(response.status).toHaveBeenCalledWith(405); - }); + expect(refundAdmissionObservation({ + response, + event, + businessSnapshots: [[businessPath, before]], + })).toEqual(expectedDisputeEventChronologyQuarantine()); + expect(admin.__get('stripeEvents/' + event.id)).toMatchObject({ + stripeCreatedAt: { _milliseconds: 0 }, + }); + expectOnlyEventLedgerReads(event); + expectOnlyEventLedgerTimestamps(event); + expect(Timestamp.now).not.toHaveBeenCalled(); + }); - test('retries a supported event until its target exists, then deduplicates it', async () => { - const event = stripeEvent( - 'evt_target_race', - 'checkout.session.completed', - registrationSession(), - ); + test.each([ + ['metadata-only claim', {}, {}], + [ + 'client-reference-only claim', + { + metadata: { schemaVersion: '1' }, + client_reference_id: 'mprc:order:order-1', + }, + {}, + ], + [ + 'matching dual claim', + { client_reference_id: 'mprc:order:order-1' }, + {}, + ], + ['legacy PaymentIntent fallback', { metadata: {} }, {}], + [ + 'legacy Charge fallback', + { metadata: {}, payment_intent: 'pi_pay003a15_unowned' }, + { stripePaymentIntentId: null }, + ], + ])('blocks impossible chronology before %s routing', async ( + label, + disputePatch, + recordPatch, + ) => { + seedPaidDisputeOrder(recordPatch); + const businessPath = 'orders/order-1'; + const before = storedCopy(businessPath); + const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '_'); + const event = stripeEvent( + 'evt_pay003a15_route_' + slug, + 'charge.dispute.updated', + orderDispute({ + id: 'dp_pay003a15_route_' + slug, + created: eventCreated + 1, + status: 'under_review', + ...disputePatch, + }), + ); + event.created = eventCreated; - const missingResponse = await deliver(event); - expect(missingResponse.status).toHaveBeenCalledWith(500); - expect(admin.__get('stripeEvents/evt_target_race')).toBeUndefined(); + const response = await deliver(event); - seedRegistration(); - const successfulResponse = await deliver(event); - expect(successfulResponse.json).toHaveBeenCalledWith(expect.objectContaining({ - received: true, - duplicate: false, - outcome: 'payment_confirmed', - })); - expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('paid'); - expect(admin.__get('stripeEvents/evt_target_race')).toMatchObject({ - status: 'processed', - outcome: 'payment_confirmed', + expect(refundAdmissionObservation({ + response, + event, + businessSnapshots: [[businessPath, before]], + })).toEqual(expectedDisputeEventChronologyQuarantine()); + expectOnlyEventLedgerReads(event); }); - const replayResponse = await deliver(event); - expect(replayResponse.json).toHaveBeenCalledWith(expect.objectContaining({ - received: true, - duplicate: true, - outcome: 'payment_confirmed', - })); - }); - - test('signed payment evidence continues while the new-commerce ceiling is off', async () => { - process.env.COMMERCE_ENABLED = 'false'; - seedRegistration(); - const event = stripeEvent( - 'evt_payment_during_pause', - 'checkout.session.completed', - registrationSession(), - ); - - const response = await deliver(event); + test('blocks impossible chronology before unmatched target handling', async () => { + const event = stripeEvent( + 'evt_pay003a15_unmatched', + 'charge.dispute.updated', + orderDispute({ + id: 'dp_pay003a15_unmatched', + charge: 'ch_pay003a15_unmatched', + payment_intent: 'pi_pay003a15_unmatched', + metadata: {}, + created: eventCreated + 1, + status: 'under_review', + }), + ); + event.created = eventCreated; - expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ - received: true, - outcome: 'payment_confirmed', - })); - expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('paid'); - expect(admin.__get('stripeEvents/evt_payment_during_pause')).toMatchObject({ - status: 'processed', - outcome: 'payment_confirmed', - }); - }); + const response = await deliver(event); - test('acknowledges and deduplicates unsupported event types', async () => { - const event = stripeEvent('evt_unsupported', 'customer.created', { - id: 'cus_1', - object: 'customer', - metadata: { schemaVersion: '2', type: 'merch', orderId: 'order-1' }, + expect(refundAdmissionObservation({ response, event })) + .toEqual(expectedDisputeEventChronologyQuarantine()); + expectOnlyEventLedgerReads(event); }); - const firstResponse = await deliver(event); - const replayResponse = await deliver(event); - - expect(firstResponse.json).toHaveBeenCalledWith(expect.objectContaining({ - received: true, - duplicate: false, - outcome: 'ignored:unsupported_event', - })); - expect(admin.__get('stripeEvents/evt_unsupported')).toMatchObject({ - status: 'processed', + test('blocks impossible chronology before ambiguous legacy fallback', async () => { + const paymentIntentId = 'pi_pay003a15_ambiguous'; + seedPaidDisputeOrder({ stripePaymentIntentId: paymentIntentId }); + seedRegistration({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: paymentIntentId, + stripeChargeId: 'ch_pay003a15_registration_ambiguous', + stripeAmountTotalCents: 5000, + }); + const snapshots = [ + ['orders/order-1', storedCopy('orders/order-1')], + [ + 'events/race-1/registrations/reg-1', + storedCopy('events/race-1/registrations/reg-1'), + ], + ]; + const event = stripeEvent( + 'evt_pay003a15_ambiguous', + 'charge.dispute.updated', + orderDispute({ + id: 'dp_pay003a15_ambiguous', + charge: 'ch_pay003a15_ambiguous', + payment_intent: paymentIntentId, + metadata: {}, + created: eventCreated + 1, + status: 'under_review', + }), + ); + event.created = eventCreated; + + const response = await deliver(event); + + expect(refundAdmissionObservation({ + response, + event, + businessSnapshots: snapshots, + })).toEqual(expectedDisputeEventChronologyQuarantine()); + expectOnlyEventLedgerReads(event); + }); + + test.each([ + ['malformed reference', (event) => { + Object.assign(event.data.object.metadata, { + eventId: 'race-1', + registrationId: 'reg-1', + }); + }], + ['conflicting reference', (event) => { + event.data.object.client_reference_id = 'mprc:order:order-other'; + }], + ])('keeps chronology ahead of %s handling', async (label, mutate) => { + const slug = label.replace(/[^a-z]+/g, '_'); + const { event, businessPath } = laterDisputeFixture( + 'order', + 'charge.dispute.updated', + 'under_review', + 'reference_' + slug, + ); + const before = storedCopy(businessPath); + mutate(event); + + const response = await deliver(event); + + expect(refundAdmissionObservation({ + response, + event, + businessSnapshots: [[businessPath, before]], + })).toEqual(expectedDisputeEventChronologyQuarantine()); + expectOnlyEventLedgerReads(event); + }); + + test.each(DISPUTE_CREATED_DOMAINS)( + 'makes impossible chronology durable before a missing %s target', + async (domain) => { + const { event } = laterDisputeFixture( + domain, + 'charge.dispute.updated', + 'under_review', + 'chronology_missing_' + domain, + { missingTarget: true }, + ); + + const response = await deliver(event); + + expect(refundAdmissionObservation({ response, event })) + .toEqual(expectedDisputeEventChronologyQuarantine()); + expect(response.status).not.toHaveBeenCalledWith(500); + expectOnlyEventLedgerReads(event); + expectOnlyEventLedgerTimestamps(event); + expect(Timestamp.now).not.toHaveBeenCalled(); + }, + ); + + test.each(DISPUTE_CREATED_DOMAINS.flatMap((domain) => [ + ['equal', domain, eventCreated], + ['earlier', domain, eventCreated - 1], + ]))( + 'keeps %s chronology with a claimed missing %s target retryable', + async (chronology, domain, disputeCreated) => { + const { event } = disputeCreatedFixture( + domain, + 'charge.dispute.updated', + 'under_review', + 'chronology_valid_missing_' + chronology + '_' + domain, + { missingTarget: true }, + ); + event.created = eventCreated; + event.data.object.created = disputeCreated; + + const response = await deliver(event); + + expect(response.status).toHaveBeenCalledWith(500); + expect(admin.__get('stripeEvents/' + event.id)).toBeUndefined(); + providerBindingPaths(event).forEach((path) => { + expect(admin.__get(path)).toBeUndefined(); + }); + }, + ); + + test.each([ + [ + 'outer Event realm', + 'livemode_mismatch', + (event) => { event.livemode = true; }, + { _milliseconds: eventCreated * 1000 }, + ], + [ + 'embedded Dispute realm', + 'dispute_livemode_mismatch', + (event) => { event.data.object.livemode = true; }, + { _milliseconds: eventCreated * 1000 }, + ], + [ + 'Event/status compatibility', + 'invalid_dispute_status', + (event) => { event.data.object.status = 'hostile-dispute-status-do-not-log'; }, + { _milliseconds: eventCreated * 1000 }, + ], + [ + 'metadata schema', + 'metadata_schema_version_mismatch', + (event) => { setMetadataSchema(event.data.object, '2'); }, + { _milliseconds: eventCreated * 1000 }, + ], + [ + 'outer Event time', + 'invalid_event_created', + (event) => { event.created = -1; }, + null, + ], + [ + 'embedded Dispute time', + 'invalid_dispute_created', + (event) => { event.data.object.created = -1; }, + { _milliseconds: eventCreated * 1000 }, + ], + ])('keeps %s precedence over chronology', async ( + label, + reason, + mutate, + expectedStripeCreatedAt, + ) => { + const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '_'); + const { event, businessPath } = laterDisputeFixture( + 'order', + 'charge.dispute.updated', + 'under_review', + 'chronology_precedence_' + slug, + ); + const before = storedCopy(businessPath); + mutate(event); + + const response = await deliver(event); + + expectCompatibilityQuarantine({ response, event, businessPath, before, reason }); + expect(admin.__get('stripeEvents/' + event.id)).toMatchObject({ + stripeCreatedAt: expectedStripeCreatedAt, + }); + expectOnlyEventLedgerReads(event); + expect(Timestamp.now).not.toHaveBeenCalled(); + }); + + test.each([ + ['object binding', (event) => { event.data.object.object = 'charge'; }], + ['currency', (event) => { event.data.object.currency = 'cad'; }], + ['amount', (event) => { event.data.object.amount = 0; }], + ['PaymentIntent binding', (event) => { + event.data.object.payment_intent = 'pi_pay003a15_other'; + }], + ['Charge binding', (event) => { + event.data.object.charge = 'ch_pay003a15_other'; + }], + ])('keeps chronology ahead of %s evaluation', async (label, mutate) => { + const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '_'); + const { event, businessPath } = laterDisputeFixture( + 'order', + 'charge.dispute.updated', + 'under_review', + 'chronology_later_' + slug, + ); + const before = storedCopy(businessPath); + mutate(event); + + const response = await deliver(event); + + expect(refundAdmissionObservation({ + response, + event, + businessSnapshots: [[businessPath, before]], + })).toEqual(expectedDisputeEventChronologyQuarantine()); + expectOnlyEventLedgerReads(event); + }); + + test('keeps chronology ahead of predecessor-state evaluation', async () => { + const { event, businessPath } = laterDisputeFixture( + 'order', + 'charge.dispute.updated', + 'under_review', + 'chronology_later_predecessor', + ); + seedOrder({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: event.data.object.payment_intent, + stripeChargeId: event.data.object.charge, + stripeAmountTotalCents: 2000, + disputeStatus: 'won', + lastDisputeEventCreated: event.created + 1, + }); + const before = storedCopy(businessPath); + + const response = await deliver(event); + + expect(refundAdmissionObservation({ + response, + event, + businessSnapshots: [[businessPath, before]], + })).toEqual(expectedDisputeEventChronologyQuarantine()); + expectOnlyEventLedgerReads(event); + }); + + test('blocks chronology before provider-binding conflict reads', async () => { + const { event, businessPath } = laterDisputeFixture( + 'order', + 'charge.dispute.updated', + 'under_review', + 'chronology_binding_conflict', + ); + const bindingPath = 'stripeObjectBindings/dispute:' + event.data.object.id; + admin.__seed(bindingPath, { + providerObjectType: 'dispute', + providerObjectId: event.data.object.id, + targetType: 'registration', + targetPath: 'events/other/registrations/other', + sentinel: 'preserve', + }); + const beforeBusiness = storedCopy(businessPath); + const beforeBinding = storedCopy(bindingPath); + + const response = await deliver(event); + + expect(refundAdmissionObservation({ + response, + event, + businessSnapshots: [[businessPath, beforeBusiness]], + })).toEqual(expectedDisputeEventChronologyQuarantine([bindingPath])); + expect(admin.__get(bindingPath)).toEqual(beforeBinding); + expectOnlyEventLedgerReads(event); + }); + + test('deduplicates rejected chronology without target work', async () => { + const { event, businessPath } = laterDisputeFixture( + 'order', + 'charge.dispute.updated', + 'under_review', + 'chronology_replay', + ); + const before = storedCopy(businessPath); + + const firstResponse = await deliver(event); + const ledgerPath = 'stripeEvents/' + event.id; + const afterFirstLedger = storedCopy(ledgerPath); + const replayResponse = await deliver(event); + + expect(refundAdmissionObservation({ + response: firstResponse, + event, + businessSnapshots: [[businessPath, before]], + })).toEqual(expectedDisputeEventChronologyQuarantine()); + expect(replayResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: true, + outcome: 'needs_review:invalid_dispute_event_chronology', + })); + expect(admin.__get(businessPath)).toEqual(before); + expect(admin.__get(ledgerPath)).toEqual(afterFirstLedger); + expect(admin.__readOperations()).toEqual([ + { kind: 'document', path: ledgerPath }, + { kind: 'document', path: ledgerPath }, + { kind: 'document', path: ledgerPath }, + ]); + expectOnlyEventLedgerTimestamps(event); + expect(Timestamp.now).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledTimes(1); + }); + + test('preserves a richer processed Event before chronology admission', async () => { + const { event, businessPath } = laterDisputeFixture( + 'order', + 'charge.dispute.updated', + 'under_review', + 'chronology_already_processed', + ); + const ledgerPath = 'stripeEvents/' + event.id; + admin.__seed(ledgerPath, { + status: 'processed', + outcome: 'legacy_processed_outcome', + targetPath: 'orders/legacy-target', + sentinel: { preserve: true }, + }); + const beforeBusiness = storedCopy(businessPath); + const beforeLedger = storedCopy(ledgerPath); + + const response = await deliver(event); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: true, + outcome: 'legacy_processed_outcome', + })); + expect(admin.__get(businessPath)).toEqual(beforeBusiness); + expect(admin.__get(ledgerPath)).toEqual(beforeLedger); + expect(admin.__readOperations()).toEqual([ + { kind: 'document', path: ledgerPath }, + ]); + providerBindingPaths(event).forEach((path) => { + expect(admin.__get(path)).toBeUndefined(); + }); + expect(Timestamp.now).not.toHaveBeenCalled(); + expect(Timestamp.fromMillis).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); + }); + + test.each(DISPUTE_CREATED_LIFECYCLES.flatMap( + ([lifecycle, type, status, outcome]) => DISPUTE_CREATED_DOMAINS.flatMap( + (domain) => [ + [lifecycle, domain, 'equal', type, status, outcome, eventCreated], + [lifecycle, domain, 'earlier', type, status, outcome, eventCreated - 1], + ], + ), + ))('admits %s %s with %s Dispute/Event chronology', async ( + lifecycle, + domain, + chronology, + type, + status, + outcome, + disputeCreated, + ) => { + const slug = (lifecycle + '_' + domain + '_' + chronology) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_'); + const { event, businessPath } = disputeCreatedFixture( + domain, + type, + status, + 'chronology_valid_' + slug, + ); + event.created = eventCreated; + event.data.object.created = disputeCreated; + + const response = await deliver(event); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: false, + outcome, + })); + expect(admin.__get(businessPath)).toMatchObject({ + disputeStatus: status, + stripeDisputeId: event.data.object.id, + }); + expect(admin.__get('stripeEvents/' + event.id)).toMatchObject({ + targetPath: businessPath, + stripeCreatedAt: { _milliseconds: eventCreated * 1000 }, + outcome, + }); + if (chronology === 'earlier') { + expect(Timestamp.fromMillis) + .not.toHaveBeenCalledWith(disputeCreated * 1000); + } + }); + + test.each(DISPUTE_CREATED_LIFECYCLES.flatMap( + ([lifecycle, type, status, outcome]) => DISPUTE_CREATED_DOMAINS.flatMap( + (domain) => [ + [lifecycle, domain, 'Unix epoch', type, status, outcome, 0], + [ + lifecycle, + domain, + 'Firestore maximum', + type, + status, + outcome, + FIRESTORE_TIMESTAMP_MAX_SECONDS, + ], + ], + ), + ))('admits equal %s %s chronology at the %s boundary', async ( + lifecycle, + domain, + boundary, + type, + status, + outcome, + created, + ) => { + const slug = (lifecycle + '_' + domain + '_' + boundary) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_'); + const { event, businessPath } = disputeCreatedFixture( + domain, + type, + status, + 'chronology_boundary_' + slug, + ); + event.created = created; + event.data.object.created = created; + + const response = await deliver(event); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: false, + outcome, + })); + expect(admin.__get(businessPath)).toMatchObject({ + disputeStatus: status, + stripeDisputeId: event.data.object.id, + }); + expect(admin.__get('stripeEvents/' + event.id)).toMatchObject({ + targetPath: businessPath, + stripeCreatedAt: { _milliseconds: created * 1000 }, + outcome, + }); + }); + + test.each([ + [ + 'Checkout Session chronology', + 'checkout.session.completed', + seedRegistration, + 'events/race-1/registrations/reg-1', + () => registrationSession({ created: eventCreated + 1 }), + expectedCheckoutSessionEventChronologyQuarantine, + ], + [ + 'refund Charge chronology', + 'charge.refunded', + seedPaidDisputeOrder, + 'orders/order-1', + () => orderRefundCharge({ created: eventCreated + 1 }), + expectedChargeEventChronologyQuarantine, + ], + ])('keeps %s on its existing outcome', async ( + label, + type, + seed, + businessPath, + providerObject, + expected, + ) => { + seed(); + const before = storedCopy(businessPath); + const event = stripeEvent( + 'evt_pay003a15_non_dispute_' + label.replace(/[^a-z]+/g, '_'), + type, + providerObject(), + ); + event.created = eventCreated; + + const response = await deliver(event); + + expect(refundAdmissionObservation({ + response, + event, + businessSnapshots: [[businessPath, before]], + })).toEqual(expected()); + }); + + test.each([ + ['customer Event', 'customer.created'], + ['unsupported dispute-like Event', 'charge.dispute.reopened'], + ])('leaves an unsupported %s unchanged', async (label, type) => { + const event = stripeEvent( + 'evt_pay003a15_unsupported_' + label.replace(/[^a-z]+/g, '_'), + type, + { + id: 'obj_pay003a15_unsupported_' + label.replace(/[^a-z]+/g, '_'), + object: 'dispute', + created: eventCreated + 1, + }, + ); + event.created = eventCreated; + + const response = await deliver(event); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: false, + outcome: 'ignored:unsupported_event', + })); + }); + }); + + test('fails closed before persistence when expected Stripe mode is not configured', async () => { + delete process.env.STRIPE_LIVEMODE_EXPECTED; + seedRegistration(); + + const response = await deliver(stripeEvent( + 'evt_missing_mode_config', + 'checkout.session.completed', + registrationSession(), + )); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.send).toHaveBeenCalledWith('Server configuration is unavailable'); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_missing_mode_config')).toBeUndefined(); + }); + + test('reports a missing webhook secret as configuration failure before persistence', async () => { + delete process.env.STRIPE_WEBHOOK_SECRET; + seedRegistration(); + + const response = await deliver(stripeEvent( + 'evt_missing_webhook_secret', + 'checkout.session.completed', + registrationSession(), + )); + + expect(response.status).toHaveBeenCalledWith(500); + expect(response.send).toHaveBeenCalledWith('Server configuration is unavailable'); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_missing_webhook_secret')).toBeUndefined(); + expect(consoleError).toHaveBeenCalledWith( + 'Stripe webhook configuration unavailable', + { reason: 'webhook_secret_missing' }, + ); + }); + + afterAll(() => { + consoleError.mockRestore(); + delete process.env.ENVIRONMENT_NAME; + delete process.env.SITE_ORIGIN; + delete process.env.STRIPE_LIVEMODE_EXPECTED; + delete process.env.STRIPE_SECRET_KEY; + delete process.env.STRIPE_WEBHOOK_SECRET; + delete process.env.COMMERCE_ENABLED; + }); + + test('rejects a missing or invalid signature', async () => { + delete process.env.ENVIRONMENT_NAME; + const event = stripeEvent( + 'evt_bad_sig', + 'checkout.session.completed', + registrationSession(), + ); + const response = mockResponse(); + + await stripeWebhook(signedRequest(event, 't=1,v1=0000'), response); + + expect(response.status).toHaveBeenCalledWith(400); + expect(response.send).toHaveBeenCalledWith('Webhook signature error'); + expect(admin.__get('stripeEvents/evt_bad_sig')).toBeUndefined(); + expect(consoleError).toHaveBeenCalledWith( + 'Stripe webhook signature verification failed', + { reason: 'invalid_signature_or_payload' }, + ); + expect(JSON.stringify(consoleError.mock.calls)).not.toContain(WEBHOOK_SECRET); + }); + + test('rejects non-POST requests', async () => { + const response = mockResponse(); + await stripeWebhook({ method: 'GET' }, response); + expect(response.status).toHaveBeenCalledWith(405); + }); + + test('retries a supported event until its target exists, then deduplicates it', async () => { + const event = stripeEvent( + 'evt_target_race', + 'checkout.session.completed', + registrationSession(), + ); + + const missingResponse = await deliver(event); + expect(missingResponse.status).toHaveBeenCalledWith(500); + expect(admin.__get('stripeEvents/evt_target_race')).toBeUndefined(); + + seedRegistration(); + const successfulResponse = await deliver(event); + expect(successfulResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: false, + outcome: 'payment_confirmed', + })); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('paid'); + expect(admin.__get('stripeEvents/evt_target_race')).toMatchObject({ + status: 'processed', + outcome: 'payment_confirmed', + }); + + const replayResponse = await deliver(event); + expect(replayResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: true, + outcome: 'payment_confirmed', + })); + }); + + test('signed payment evidence continues while the new-commerce ceiling is off', async () => { + process.env.COMMERCE_ENABLED = 'false'; + seedRegistration(); + const event = stripeEvent( + 'evt_payment_during_pause', + 'checkout.session.completed', + registrationSession(), + ); + + const response = await deliver(event); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + outcome: 'payment_confirmed', + })); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('paid'); + expect(admin.__get('stripeEvents/evt_payment_during_pause')).toMatchObject({ + status: 'processed', + outcome: 'payment_confirmed', + }); + }); + + test('acknowledges and deduplicates unsupported event types', async () => { + const event = stripeEvent('evt_unsupported', 'customer.created', { + id: 'cus_1', + object: 'customer', + metadata: { schemaVersion: '2', type: 'merch', orderId: 'order-1' }, + }); + + const firstResponse = await deliver(event); + const replayResponse = await deliver(event); + + expect(firstResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: false, + outcome: 'ignored:unsupported_event', + })); + expect(admin.__get('stripeEvents/evt_unsupported')).toMatchObject({ + status: 'processed', outcome: 'ignored:unsupported_event', requiresReview: false, }); @@ -5379,6 +6074,7 @@ describe('stripeWebhook', () => { orderDispute({ id: `dp_dispute_time_valid_${slug}` }), ); event.created = eventCreated; + event.data.object.created = eventCreated; const response = await deliver(event); @@ -6455,12 +7151,12 @@ describe('stripeWebhook', () => { })); }); - test('leaves Dispute chronology outside this child', async () => { + test('keeps valid equal Dispute chronology compatible', async () => { seedPaidDisputeOrder(); const event = stripeEvent( 'evt_pay003a10_dispute_compatibility', 'charge.dispute.updated', - orderDispute({ created: eventCreated + 1, status: 'under_review' }), + orderDispute({ created: eventCreated, status: 'under_review' }), ); event.created = eventCreated;