From fbc9494c60b22f25c3c30134580f4bd93a02a715 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 19:10:30 -0700 Subject: [PATCH] PAY-005A2: validate Stripe refund results --- GITHUB_ISSUES.md | 9 +- GITHUB_ISSUE_SLICES.md | 1 + OPERATIONS_RUNBOOK.md | 2 +- SECURITY.md | 1 + docs/officers/EVENTS_SHOP_MEMBERS.md | 34 +-- functions/adminOrderAction.js | 77 ++++-- functions/adminRegistrationAction.js | 81 ++++-- functions/commerceControlEntryPoints.test.js | 246 ++++++++++++++++++- functions/refundResponseValidation.js | 167 +++++++++++++ functions/refundResponseValidation.test.js | 208 ++++++++++++++++ 10 files changed, 756 insertions(+), 70 deletions(-) create mode 100644 functions/refundResponseValidation.js create mode 100644 functions/refundResponseValidation.test.js diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 7e04a9bc..2a95d468 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -69,7 +69,7 @@ Every issue inherits `AGENTS.md` and the definition of done in `IMPLEMENTATION_P | 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 | -| 18 | PAY-005 | Build idempotent refunds, disputes, and reconciliation | P0 | L | proposed | PAY-002, PAY-003 | +| 18 | PAY-005 | Build idempotent refunds, disputes, and reconciliation | P0 | L | partial: immediate amount/result containment tracked by #200/#204; complete lifecycle proposed | PAY-002, PAY-003 | | 19 | MAIL-001 | Build an escaped, idempotent transactional email outbox | P1 | M | proposed | PAY-003 | | 20 | DATA-001 | Replace confirmation bearer URLs and implement PII minimization | P1 | L | proposed | PAY-002, PAY-003 | | 21 | DATA-002 | Create immutable, truthful waiver evidence | P1 | M | blocked_owner_decision | RACE-001, LEGAL-001 decision | @@ -859,7 +859,7 @@ Do not mark local cancelled before considering the live Stripe Session and recov ## PAY-005 — Build idempotent refunds, disputes, and reconciliation **Labels:** `priority:P0`, `type:security`, `type:reliability`, `area:stripe`, `size:L` -**Status:** Partial. Immediate amount-containment source/tests are tracked in [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200); the broader refund/dispute/reconciliation system remains proposed. +**Status:** Partial. Immediate amount containment is tracked in [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200), and resolved-result containment source/tests are tracked in [#204](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/204); the broader refund/dispute/reconciliation system remains proposed. **Depends on:** PAY-002, PAY-003, AUTH-003 for final permissions ### Problem @@ -868,6 +868,8 @@ Refund functions call Stripe without idempotency, immediately overwrite local st PAY-005A1 [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200) is the narrow immediate containment outcome. It makes both current partial-refund entry points accept only a primitive positive safe-integer amount below the stored original cents, requires every admitted partial request to send that exact `amount` to Stripe, and derives partial/full classification from the explicit action. Invalid caller or stored values stop before Stripe construction/refund creation and before business-record writes. A provider exception returns a fixed result-not-confirmed, do-not-retry, and escalate message because Stripe may already have accepted the request. This does not add idempotency, remaining-refundable provider truth, concurrency control, event-finalized totals, capabilities, deployment, or live proof. +PAY-005A2 [#204](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/204) is the companion resolved-result containment outcome. Before Stripe construction, both endpoints require one exact stored `pi_` target, positive safe-integer original cents, and lowercase `usd`. After one provider call, only a plain `refund` object with a bounded `re_` ID, exact `succeeded` status, matching primitive PaymentIntent/currency, and a permitted positive safe-integer amount can reach the local success write. A partial must equal requested cents; an explicit full action accepts Stripe's returned full-remaining amount up to the original total and audits that validated actual amount. A thrown, malformed, mismatched, pending, action-required, failed, cancelled, or unknown Stripe result gets the same fixed do-not-retry response and no local success write is attempted. If the later local write reports an error or its acknowledgement is lost, the same fixed response is returned but the local record is unknown because the write may have committed. This still does not create a durable pre-send operation, idempotency, provider retrieval/reconciliation, event-finalized totals, concurrency control, capabilities, deployment, or live proof. + ### Scope - Create refund command records with stable ID, actor/capability, reason, requested integer cents, payment snapshot, status, and Stripe idempotency key. @@ -892,10 +894,11 @@ PAY-005A1 [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200) is t - Failure injection before/after Stripe response; duplicate/concurrent partials; pending/failed/succeeded events; full-after-partial; dispute lifecycle; reconciliation fixture for every category; repair idempotency. - Staging finance review of report totals. +- PAY-005A2/#204: a 47-case pure validator matrix rejects coercion, prototypes, accessors, malformed/mismatched values, and every non-succeeded status while returning only a frozen validated projection. The initial dual-endpoint red matrix exposed 46 unsafe preflight/result cases, and two additional red cases exposed raw post-provider storage failures. The final 174 focused tests prove no Stripe call for invalid stored targets, one call for admitted requests, no local success write attempt for a rejected Stripe result, no success response after synthetic pre-commit or commit-then-error local-write failures, the same fixed do-not-retry response at both ambiguous boundaries, and validated full-remaining cents in the audit. The commit-then-error case proves why a lost acknowledgement leaves local state unknown and requires reconciliation. ### Agent handoff -Do not duplicate PAY-005A1 [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200). After its immediate containment, separate refund command/state from the broader reconciliation worker into sequential PRs. Never hand-edit Firestore to make reconciliation pass. +Do not duplicate PAY-005A1 [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200) or PAY-005A2 [#204](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/204). These are immediate containment only. Separate durable refund command/state from the broader reconciliation worker into sequential PRs. Never hand-edit Firestore to make reconciliation pass, and never retry an ambiguous provider result blindly. --- diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index 265312ad..962ad7ab 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -96,6 +96,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | ID | Status/dependency | Bounded deliverable | Close evidence | | --- | --- | --- | --- | | PAY-005A1 | tracked by [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200); immediate containment only | Reject malformed/equal/over partial amounts in both existing admin Functions before Stripe/write side effects; every admitted partial request carries the exact integer amount and only explicit full refund omits it. | Red/green dual-endpoint matrices for caller/stored values, valid boundaries, provider failure, exact payload/classification; source/merge/deploy/provider/live states separated. | +| PAY-005A2 | source/tests tracked by [#204](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/204); A1; immediate containment only | Validate the stored PaymentIntent/currency/original before Stripe and accept a resolved Refund only when its type, ID, succeeded status, PaymentIntent, currency, and amount rule match. Partial cents must be exact; a full action accepts Stripe's positive full-remaining amount up to the original total. | Pure hostile-object matrix plus both endpoint no-write tests for malformed targets, malformed/mismatched/non-succeeded results, provider throw, one-call behavior, and validated actual-cents audit. Pre-commit and commit-then-error local-save tests return no success and the same fixed do-not-retry result; a lost acknowledgement leaves local state unknown and requires reconciliation. Source/merge/deploy/provider/live states stay separate. | | PAY-005A | proposed; PAY-001D, PAY-002A1, PAY-002B2C3C, PAY-003B | Add idempotent refund commands, remaining-balance/concurrency validation, stable Stripe keys, and event-finalized totals. | Same/conflicting/concurrent partial/full/timeout and repeated `partially_refunded` status with increasing confirmed total tests. | | PAY-005B | proposed; PAY-003B | Model per-dispute lifecycle for orders and registrations with finance alert references. | Duplicate/reordered warning lifecycle, inquiry escalation, created/updated/lost/won, rare lost-to-won late win, and `prevented` only under an explicitly validated upgraded webhook Event version. | | PAY-005C | proposed; A/B | Build read-only scheduled/operator reconciliation and mismatch/quarantine report. | Orphan/mismatch/refund/event/counter synthetic report. | diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 7e2f629a..6eecde6c 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -479,7 +479,7 @@ Capture the commit, test IDs, timestamps, screenshots without secrets/PII, and t ## 12. Refund procedure -**Status: NOT AVAILABLE YET.** PAY-005A1 [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200) adds one source safeguard: an invalid partial amount stops instead of being omitted and becoming a full refund. It accepts only a positive whole number of cents below the stored original total, and only the explicit full-refund action may omit an amount. An unconfirmed provider result says do not retry and escalate because Stripe may already have accepted it. This source guard is not deployed or proven live and does not supply idempotency, remaining-balance/concurrency protection, verified final totals, finance capabilities, or approved policy. Do not use the steps below in production. +**Status: NOT AVAILABLE YET.** PAY-005A1 [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200) adds one source amount guard: an invalid partial amount stops instead of being omitted and becoming a full refund. PAY-005A2 [#204](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/204) adds one source returned-result guard: the stored payment/currency/original must be valid before Stripe, and local success requires a final succeeded Refund bound to that PaymentIntent and currency. A partial must return its exact requested cents. A full action omits the amount and records Stripe's validated full-remaining cents. A rejected Stripe result causes no local success write attempt. Any provider-result or later local-save uncertainty says do not retry and escalate; after a local-save error, both Stripe and local-record state require reconciliation because the write may have committed. These source guards are not deployed or proven live and do not supply idempotency, remaining-balance/concurrency protection, verified cumulative/event-finalized totals, finance capabilities, or approved policy. Do not use the steps below in production. 1. Authenticate with finance capability and satisfy recent-auth/MFA policy. 2. Locate the local business record and corresponding Stripe PaymentIntent/Charge. diff --git a/SECURITY.md b/SECURITY.md index 88f1ed19..4c634b4b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -96,6 +96,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Risks | Source result | Remaining closure evidence | | --- | --- | --- | | Immediate input-containment part of RISK-020 | PAY-005A1 is tracked in live [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200). Both existing admin refund Functions share one non-coercing validator: a partial amount must be a primitive positive safe integer below the stored positive safe-integer original cents. Invalid caller or stored values stop before Stripe construction/refund creation and Firestore mutation; valid partials always include the exact `amount`; only the explicit full action omits it. Provider exceptions expose no detail and return a fixed result-not-confirmed, do-not-retry, and escalate message. Focused tests cover both race and merchandise paths, fixed errors, valid boundaries, provider failure, and malformed/equal/over/non-finite/coerced inputs. | This immediate guard does not prove the remaining refundable Stripe balance, cumulative prior refunds, command identity/idempotency, concurrency safety, pending/event-finalized state, capability/MFA/recent-auth, approved policy, Firebase deployment, Stripe configuration, or live behavior. An unconfirmed provider result remains ambiguous and requires reconciliation, not a blind retry. Complete PAY-005A, its PAY-002/PAY-003 dependencies, AUTH-003, and every listed residual gate before authorizing production refunds. | +| Immediate resolved-result containment part of RISK-020 | PAY-005A2 is tracked in live [#204](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/204). Both existing refund Functions build one immutable expectation before Stripe from a bounded stored PaymentIntent, positive original cents, exact `usd`, action, and admitted partial cents. Only a plain succeeded Refund with a bounded ID, exact primitive PaymentIntent/currency binding, and permitted amount returns a frozen minimal projection. Partial cents must match exactly; explicit full accepts the positive full-remaining amount up to the original total. Invalid stored targets stop before Stripe. Thrown, malformed, mismatched, pending, action-required, failed, cancelled, unknown, or post-provider local-write failures return the same fixed ambiguous message and never return local success. A rejected Stripe result causes no local success write attempt; a later local-write error leaves the record state unknown. The audit uses only the validated refund ID and actual returned cents. | The provider call precedes response validation and the local write, so an unconfirmed provider result may already have moved money. A local write that reports an error may have committed or may remain unchanged, especially when its acknowledgement is lost. Do not retry either outcome; reconcile it. This slice adds no durable pre-send operation/ID, idempotency key, remaining-balance retrieval, cumulative amount, concurrency fence, webhook-finalized state, capability/MFA/recent-auth, approved policy, provider rehearsal, Firebase deployment, or live proof. Complete PAY-002B2C3B/C, PAY-005A, PAY-003B, AUTH-003, reconciliation, and protected release before authorizing production refunds. | | Foundation only for parts of RISK-011, RISK-020, RISK-021 | PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). The source-only child defines versioned pure payment, registration, fulfillment, confirmed-refund, and per-dispute structural reducers; fixed cross-dimension review/rejection; and a deterministic synthetic legacy classifier/aggregate report with no IDs. Orders/registrations carry no singular canonical dispute state. Forbidden regressions fail closed, refunds/disputes do not overwrite payment state, and `paid_after_cancellation` remains paid plus cancelled plus review rather than becoming a payment enum. | This does not yet mitigate those risks in a runtime. An unchanged enum does not prove duplicate Event/command/evidence, authorization, amount monotonicity, provider state, or club policy. No endpoint imports the library and no business record is read/written or migrated. PAY-002B1 [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) supplies pure command/attempt keys; PAY-002B2A [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) supplies only unused registration/audit persistence; PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) supplies unused lease/fence/terminal-commitment source and tests without provider permission; PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) supplies only unused lease-bound attempt-1 plan commitments/audit; PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) supplies only unused pre-send evidence/audit and the exact persisted retry cutoff; PAY-002B2C3A [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) supplies only an unused pure closed evidence classifier with no persistence or authorization; C3B/C3C must still persist verified proof and authorize/version any later attempt; PAY-002C/D persistence-first checkout; PAY-003B Event/version/evidence adoption including same-state amount changes and separate dispute records; PAY-004/PAY-005 cancellation/refund operations; MERCH-002/LEGAL-001 return/policy gates; #113 and a later migration child real inventory/dry-run. Firebase/Stripe deployment and live behavior remain unproven. | | Foundation only for retry/duplication parts of RISK-003 and RISK-020 | PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163). The pure source child defines a canonical UUID v4 boundary, a domain-separated caller/environment command hash, a command-type plus canonical frozen-payload fingerprint, and a deterministic environment/mode/operation/command/provider-attempt Stripe key. Invalid production/test or non-production/live pairs fail. Raw caller, UUID, operation, and payload values never appear in outputs. PAY-002B2C1 is tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) for an immutable lease-bound attempt-1 plan and deterministic audit whose account, canonical parameters, and B1 key are stored only as command-bound commitments. PAY-002B2C2 is tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) for a separate atomic pre-send marker/audit, a complete command-bound C1-plan digest in both partners, a post-transaction lease/deadline freshness check, and persisted 23-hour attempt-1 cutoff. PAY-002B2C3A is tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) for the pure exact attempt-1 reconciliation evidence policy. | Hashes and commitments are pseudonymous server-only equality evidence—not anonymization, authorization, configured-account proof, durable replay, or provider-execution proof. No endpoint imports these libraries. PAY-002B2A/#165 registers only a command/audit pair. PAY-002B2B/#169 adds a separate fixed-duration lease, holder fingerprint, monotonic fence, terminal commitment, and transition audit without provider permission. C1 does not permit a send. C2's fixed `send_permitted` is only a retry-safety classification under the exact lease/fence/plan; it is not caller authorization, configured-account proof, a Stripe call/outcome, or response replay. C2 makes no provider request and never advances attempt `1`. A changed/replaced plan fails its stored digest. Lease/deadline equality, later time, rollback, or missing/unreadable paired time returns `reconciliation_required`. C3A returns only fixed `existing_attempt_found`, `new_attempt_candidate`, or `reconciliation_required` results from closed enum evidence; a candidate is not send/advance permission. Timeout, connection loss, provider error, old/pruned key, missing/not-found object, partial/empty search, processing/unknown state, and conflict cannot become a candidate. C3B/C3C must persist verified evidence and separately authorize/version any later attempt. PAY-002C/D and PAY-005 must adopt the complete B2C chain with endpoint schemas and failure-boundary tests. Deployment, provider configuration, and live behavior remain unproven. | | Foundation only for registration/audit parts of RISK-003, RISK-020, and RISK-032 | PAY-002B2A is tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). The unused server-only transaction creates one immutable `checkoutRequests/{commandKeyHash}` registered record and deterministic revision-1 `auditEvents` partner atomically. Exact retries are read-only. Same-key type/schema/payload conflicts and corrupt, orphan, future, or unexpected stored pairs fail closed without repair. Environment/caller-scope changes derive different B1 keys; a stored mismatch under one key is corruption, not a cross-scope lookup. Fixed results expose no raw identity, UUID, payload, hash, path, or Firestore detail. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs its dedicated exact demo-project/opt-in suite as a named hosted job and protected-release prerequisite. | Registration is neither authorization nor execution permission. #169 keeps the root and revision-1 audit immutable, places lifecycle state in the separate server-only subcollection, fails closed on malformed partners, and proves in synthetic tests that stale workers cannot finalize. The hosted emulator result is still synthetic source proof, not deployment or provider proof. No TTL is safe: [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable duplicate barrier before deletion. Source/tests/merge do not prove Firebase deployment, Stripe configuration, or live behavior. | diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index e24aea02..9862d11f 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -241,46 +241,52 @@ Officer steps after every prerequisite has proof: **Escalation:** membership lead plus privacy/platform owner; use the private incident path under #112 if exposure is suspected. -## Partial-refund amount guard — SOURCE ONLY, NOT LIVE +## Refund amount and returned-result guards — SOURCE ONLY, NOT LIVE -**Purpose:** make an invalid partial-refund amount stop. It must never silently become a full refund. +**Purpose:** make an invalid partial amount stop, and record a refund complete only when Stripe returns a matching final success. **Approver:** treasurer plus platform/security owner. -**Prerequisites:** the pull request for issue [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200) is merged, a protected staging Firebase project, both exact refund Functions are deployed and read back there, Stripe test mode, made-up order and race records, and an approved refund policy. The broader safe refund procedure is still **NOT AVAILABLE YET**. +**Prerequisites:** the pull requests for issues [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200) and [#204](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/204) are merged, a protected staging Firebase project, both exact refund Functions are deployed and read back there, Stripe test mode, made-up order and race records, and an approved refund policy. The broader safe refund procedure is still **NOT AVAILABLE YET**. ```mermaid flowchart TD - Ask["Request a partial refund"] --> Stored{"Stored original is valid whole cents?"} + Ask["Request a partial refund"] --> Stored{"Stored payment, usd currency, and original cents are valid?"} Stored -- "No" --> Stop["Stop before Stripe and before a record change"] Stored -- "Yes" --> Check{"Requested amount is positive whole cents and lower?"} Check -- "No" --> Stop["Stop before Stripe and before a record change"] Check -- "Yes" --> Partial["Send the exact partial amount"] - Partial --> Result{"Did Stripe return a refund record?"} - Result -- "No; outcome is unknown" --> Unknown["Keep local record unchanged\nDo not retry; escalate"] - Result -- "Yes" --> Returned["Use returned result\nLater reconciliation still required"] + Partial --> Result{"Succeeded result matches payment, currency, and amount rule?"} + Result -- "No or unclear" --> Unknown["Do not attempt a local success write\nDo not retry; escalate"] + Result -- "Yes" --> Save["Try to save validated refund ID and actual cents"] + Save --> Saved{"Local save response confirmed?"} + Saved -- "Yes" --> Returned["Return success\nLater reconciliation still required"] + Saved -- "No or lost" --> LocalUnknown["Local record state is unknown\nDo not retry; reconcile"] Full["Explicit full-refund request"] --> Omit["Only path allowed to omit the amount"] Omit --> Result ``` -In words: a missing or malformed stored original, or an invalid, equal, or over-limit partial amount, stops before Stripe or a record change; only a deliberate full-refund request can omit the amount. If the server cannot confirm whether Stripe accepted the request, the local record stays unchanged, the officer must not retry, and later reconciliation is required. +In words: a missing or malformed stored payment, currency, or original amount stops before Stripe. An invalid, equal, or over-limit partial amount also stops. A Stripe result that is not a matching final success causes no local success write attempt. A partial must match the requested cents. A full request uses the actual remaining cents Stripe returned. If the later local save reports an error or loses its response, the record may or may not have changed. In either unclear case, the officer must not retry and reconciliation is required. Officer review steps after every prerequisite has proof: 1. Keep all live website refunds unavailable. 2. Ask the platform specialist to show the fixed synthetic test report for both race and shop refunds. -3. Confirm a missing, non-number, non-positive, fraction, non-finite, or too-large stored original stops before any Stripe refund call or record change. +3. Confirm a missing or malformed stored payment ID, `usd` currency, or original whole-cent amount stops before any Stripe refund call or record change. 4. Confirm the report rejects missing, non-number, fraction, zero, negative, equal, over-limit, and non-finite partial amounts. -5. Confirm every rejected caller case also shows no Stripe refund call and no order or registration change. +5. Confirm every rejected caller or stored-record case shows no Stripe refund call and no order or registration change. 6. Confirm the smallest valid amount and one cent below the stored original send the exact test-mode amount and stay partial. -7. Confirm an unconfirmed Stripe test result leaves the made-up record unchanged and says: do not retry; escalate to the treasurer and platform owner. -8. Stop after this review. Do not approve a production refund until the remaining PAY-005 safety work and provider/deployment proof are complete. +7. Confirm only a final succeeded result for the same payment, currency, and permitted amount can change the made-up record. +8. Confirm malformed, mismatched, pending, action-required, failed, cancelled, and unknown Stripe results do not attempt a local success write and say: do not retry; escalate to the treasurer and platform owner. +9. Confirm a local-save error after a valid Stripe success returns no success response, treats the local record as unknown, and gives the same do-not-retry instruction. +10. Confirm a full test refund records the actual returned remaining cents, not the original amount guessed from the local record. +11. Stop after this review. Do not approve a production refund until the remaining PAY-005 safety work and provider/deployment proof are complete. -**Expected result:** a malformed stored original or rejected partial amount causes one fixed error and no provider or record change. An admitted partial request always carries its exact amount. Only the explicit full action can omit it. If the provider result cannot be confirmed, the page says not to retry and to escalate; it does not claim failure. +**Expected result:** a malformed stored target or rejected partial amount causes one fixed preflight error and no provider or record change. An admitted partial request always carries its exact amount. Only the explicit full action can omit it. A rejected Stripe result causes no local success write attempt. The Function returns success only after a matching final result is saved with its actual cents. If the Stripe result or local save cannot be confirmed, the page says not to retry and to escalate; it does not claim that Stripe failed or whether the local record changed. **Stop conditions:** any real order, registration, member, card, Stripe payment record, refund, production Firebase project, Stripe live mode, missing deployment/readback, request to edit Firestore by hand, or retry after an unconfirmed result. -**Success proof:** exact #200 pull request and merge commit; red proof showing the old unsafe cases; green focused and full tests; readback of both Functions in staging; made-up Stripe test-mode results; and a dated treasurer/platform review. A green source workflow alone is not deployment or provider proof. +**Success proof:** exact #200 and #204 pull requests and merge commits; red proof showing the old unsafe amount and returned-result cases; green focused and full tests; readback of both Functions in staging; made-up Stripe test-mode results for every listed final/non-final outcome; and a dated treasurer/platform review. A green source workflow alone is not deployment or provider proof. **Undo:** use one reviewed two-Function revert or safe roll-forward through the protected backend release. Never undo by issuing another refund or changing a payment record. diff --git a/functions/adminOrderAction.js b/functions/adminOrderAction.js index d63d7bbc..60972567 100644 --- a/functions/adminOrderAction.js +++ b/functions/adminOrderAction.js @@ -17,6 +17,11 @@ const { REFUND_VALIDATION_REASONS, validatePartialRefundAmount, } = require('./refundValidation'); +const { + REFUND_RESULT_NOT_CONFIRMED_MESSAGE, + buildRefundExpectation, + validateSucceededRefundResponse, +} = require('./refundResponseValidation'); const ACTIONS = new Set([ 'mark_fulfilled', @@ -125,12 +130,6 @@ exports.adminOrderAction = functions } if (action === 'refund_full' || action === 'refund_partial') { - if (!order.stripePaymentIntentId) { - throw new functions.https.HttpsError( - 'failed-precondition', - 'No Stripe payment intent on this order', - ); - } let amountCents = null; if (action === 'refund_partial') { const validation = validatePartialRefundAmount({ @@ -150,10 +149,23 @@ exports.adminOrderAction = functions } amountCents = validation.amountCents; } + const expectation = buildRefundExpectation({ + action, + paymentIntentId: order.stripePaymentIntentId, + currency: order.currency, + originalAmountCents: order.amountCents, + requestedAmountCents: amountCents, + }); + if (!expectation.ok) { + throw new functions.https.HttpsError( + 'failed-precondition', + 'Stored refund target is unavailable', + ); + } const stripe = getStripe(); - const refundPayload = { payment_intent: order.stripePaymentIntentId }; + const refundPayload = { payment_intent: expectation.paymentIntentId }; if (action === 'refund_partial') { - refundPayload.amount = amountCents; + refundPayload.amount = expectation.requestedAmountCents; } let stripeRefund; try { @@ -161,25 +173,42 @@ exports.adminOrderAction = functions } catch { throw new functions.https.HttpsError( 'internal', - 'Refund result could not be confirmed. Do not retry. Escalate to the treasurer and platform owner.', + REFUND_RESULT_NOT_CONFIRMED_MESSAGE, ); } - const isFull = action === 'refund_full'; - await ref.update({ - status: isFull ? 'refunded' : 'partially_refunded', - refundedAt: Timestamp.now(), - updatedAt: Timestamp.now(), - stripeRefundIds: admin.firestore.FieldValue.arrayUnion(stripeRefund.id), - auditLog: admin.firestore.FieldValue.arrayUnion( - auditEntry({ - actorUid: actor.uid, - actorEmail: actor.email, - action: isFull ? 'admin.refund_full' : 'admin.refund_partial', - note: `refund=${stripeRefund.id} amount=${isFull ? order.amountCents : amountCents}`, - }), - ), + const validatedRefund = validateSucceededRefundResponse({ + refund: stripeRefund, + expectation, }); - return { ok: true, refundId: stripeRefund.id }; + if (!validatedRefund.ok) { + throw new functions.https.HttpsError( + 'internal', + REFUND_RESULT_NOT_CONFIRMED_MESSAGE, + ); + } + const isFull = action === 'refund_full'; + try { + await ref.update({ + status: isFull ? 'refunded' : 'partially_refunded', + refundedAt: Timestamp.now(), + updatedAt: Timestamp.now(), + stripeRefundIds: admin.firestore.FieldValue.arrayUnion(validatedRefund.refundId), + auditLog: admin.firestore.FieldValue.arrayUnion( + auditEntry({ + actorUid: actor.uid, + actorEmail: actor.email, + action: isFull ? 'admin.refund_full' : 'admin.refund_partial', + note: `refund=${validatedRefund.refundId} amount=${validatedRefund.amountCents}`, + }), + ), + }); + } catch { + throw new functions.https.HttpsError( + 'internal', + REFUND_RESULT_NOT_CONFIRMED_MESSAGE, + ); + } + return { ok: true, refundId: validatedRefund.refundId }; } throw new functions.https.HttpsError('internal', 'Unhandled action'); diff --git a/functions/adminRegistrationAction.js b/functions/adminRegistrationAction.js index 80df38e7..7eaaee8d 100644 --- a/functions/adminRegistrationAction.js +++ b/functions/adminRegistrationAction.js @@ -19,6 +19,11 @@ const { REFUND_VALIDATION_REASONS, validatePartialRefundAmount, } = require('./refundValidation'); +const { + REFUND_RESULT_NOT_CONFIRMED_MESSAGE, + buildRefundExpectation, + validateSucceededRefundResponse, +} = require('./refundResponseValidation'); const ACTIONS = new Set([ 'refund_full', @@ -37,11 +42,11 @@ function regRef(eventId, registrationId) { } async function refund({ - stripe, doc, reg, actor, action, amountCents, + stripe, doc, actor, action, expectation, }) { - const refundPayload = { payment_intent: reg.stripePaymentIntentId }; + const refundPayload = { payment_intent: expectation.paymentIntentId }; if (action === 'refund_partial') { - refundPayload.amount = amountCents; + refundPayload.amount = expectation.requestedAmountCents; } let stripeRefund; try { @@ -49,27 +54,44 @@ async function refund({ } catch { throw new functions.https.HttpsError( 'internal', - 'Refund result could not be confirmed. Do not retry. Escalate to the treasurer and platform owner.', + REFUND_RESULT_NOT_CONFIRMED_MESSAGE, + ); + } + const validatedRefund = validateSucceededRefundResponse({ + refund: stripeRefund, + expectation, + }); + if (!validatedRefund.ok) { + throw new functions.https.HttpsError( + 'internal', + REFUND_RESULT_NOT_CONFIRMED_MESSAGE, ); } const isFull = action === 'refund_full'; - await doc.ref.update({ - status: isFull ? 'refunded' : 'partially_refunded', - refundedAt: Timestamp.now(), - updatedAt: Timestamp.now(), - stripeRefundIds: admin.firestore.FieldValue.arrayUnion(stripeRefund.id), - auditLog: admin.firestore.FieldValue.arrayUnion( - auditEntry({ - actorUid: actor.uid, - actorEmail: actor.email, - action: isFull ? 'admin.refund_full' : 'admin.refund_partial', - note: `refund=${stripeRefund.id} amount=${isFull ? reg.amountCents : amountCents}`, - }), - ), - }); + try { + await doc.ref.update({ + status: isFull ? 'refunded' : 'partially_refunded', + refundedAt: Timestamp.now(), + updatedAt: Timestamp.now(), + stripeRefundIds: admin.firestore.FieldValue.arrayUnion(validatedRefund.refundId), + auditLog: admin.firestore.FieldValue.arrayUnion( + auditEntry({ + actorUid: actor.uid, + actorEmail: actor.email, + action: isFull ? 'admin.refund_full' : 'admin.refund_partial', + note: `refund=${validatedRefund.refundId} amount=${validatedRefund.amountCents}`, + }), + ), + }); + } catch { + throw new functions.https.HttpsError( + 'internal', + REFUND_RESULT_NOT_CONFIRMED_MESSAGE, + ); + } - return { ok: true, refundId: stripeRefund.id }; + return { ok: true, refundId: validatedRefund.refundId }; } async function cancel({ doc, actor, note }) { @@ -357,12 +379,6 @@ exports.adminRegistrationAction = functions } if (action === 'refund_full' || action === 'refund_partial') { - if (!reg.stripePaymentIntentId) { - throw new functions.https.HttpsError( - 'failed-precondition', - 'No Stripe payment intent recorded for this registration', - ); - } let amountCents = null; if (action === 'refund_partial') { const validation = validatePartialRefundAmount({ @@ -382,9 +398,22 @@ exports.adminRegistrationAction = functions } amountCents = validation.amountCents; } + const expectation = buildRefundExpectation({ + action, + paymentIntentId: reg.stripePaymentIntentId, + currency: reg.currency, + originalAmountCents: reg.amountCents, + requestedAmountCents: amountCents, + }); + if (!expectation.ok) { + throw new functions.https.HttpsError( + 'failed-precondition', + 'Stored refund target is unavailable', + ); + } const stripe = getStripe(); return refund({ - stripe, doc: snap, reg, actor, action, amountCents, + stripe, doc: snap, actor, action, expectation, }); } diff --git a/functions/commerceControlEntryPoints.test.js b/functions/commerceControlEntryPoints.test.js index 0e3b1747..74fbbab6 100644 --- a/functions/commerceControlEntryPoints.test.js +++ b/functions/commerceControlEntryPoints.test.js @@ -61,8 +61,9 @@ jest.mock('firebase-admin', () => { } async update(data) { - mockBusinessWrite('update', this.path, data); + const postCommitFailure = mockBusinessWrite('update', this.path, data); store.set(this.path, { ...(store.get(this.path) || {}), ...data }); + if (postCommitFailure instanceof Error) throw postCommitFailure; } } @@ -201,6 +202,63 @@ function partialRefundData(entry, amountCents) { }; } +function refundResponse(overrides = {}) { + return { + id: 're_synthetic', + object: 'refund', + amount: 2500, + currency: 'usd', + payment_intent: 'pi_synthetic', + status: 'succeeded', + ...overrides, + }; +} + +function accessorRefundResponse() { + const refund = refundResponse(); + Object.defineProperty(refund, 'id', { + enumerable: true, + get() { + throw new Error('refund accessor must not run'); + }, + }); + return refund; +} + +const INVALID_STORED_REFUND_TARGETS = [ + ['missing PaymentIntent', { stripePaymentIntentId: undefined }], + ['non-string PaymentIntent', { stripePaymentIntentId: ['pi_synthetic'] }], + ['malformed PaymentIntent', { stripePaymentIntentId: 'payment-1' }], + ['missing currency', { currency: undefined }], + ['uppercase currency', { currency: 'USD' }], + ['different currency', { currency: 'eur' }], + ['missing original amount', { amountCents: undefined }], + ['zero original amount', { amountCents: 0 }], + ['fractional original amount', { amountCents: 2500.5 }], +]; + +const INVALID_RESOLVED_REFUNDS = [ + ['missing response', undefined], + ['primitive response', 'refund'], + ['surprising-prototype response', Object.assign( + Object.create({ inherited: true }), + refundResponse(), + )], + ['accessor-backed response', accessorRefundResponse()], + ['wrong object type', refundResponse({ object: 'payment_intent' })], + ['malformed refund ID', refundResponse({ id: 'refund-1' })], + ['wrong PaymentIntent', refundResponse({ payment_intent: 'pi_different123' })], + ['expanded PaymentIntent', refundResponse({ payment_intent: { id: 'pi_synthetic' } })], + ['wrong currency', refundResponse({ currency: 'cad' })], + ['wrong partial amount', refundResponse({ amount: 999 })], + ['over-original amount', refundResponse({ amount: 2501 })], + ['pending status', refundResponse({ status: 'pending' })], + ['action-required status', refundResponse({ status: 'requires_action' })], + ['failed status', refundResponse({ status: 'failed' })], + ['canceled status', refundResponse({ status: 'canceled' })], + ['unknown status', refundResponse({ status: 'future_status' })], +]; + describe('commerce admission at admin entry points', () => { beforeEach(() => { admin.__clear(); @@ -216,7 +274,7 @@ describe('commerce admission at admin entry points', () => { prices: { create: mockPriceCreate }, paymentLinks: { create: mockPaymentLinkCreate }, }); - mockRefundCreate.mockResolvedValue({ id: 're_synthetic' }); + mockRefundCreate.mockResolvedValue(refundResponse()); mockProductCreate.mockResolvedValue({ id: 'prod_synthetic' }); mockPriceCreate.mockResolvedValue({ id: 'price_synthetic' }); mockPaymentLinkCreate.mockResolvedValue({ @@ -261,6 +319,7 @@ describe('commerce admission at admin entry points', () => { admin.__seed(entry.recordPath, { status: 'paid', amountCents: 2500, + currency: 'usd', stripePaymentIntentId: 'pi_synthetic', }); @@ -294,6 +353,7 @@ describe('commerce admission at admin entry points', () => { admin.__seed(entry.recordPath, { status: 'paid', amountCents: originalAmountCents, + currency: 'usd', stripePaymentIntentId: 'pi_synthetic', }); @@ -310,6 +370,41 @@ describe('commerce admission at admin entry points', () => { expect(mockBusinessWrite).not.toHaveBeenCalled(); }); + test.each(REFUND_ENTRY_POINTS.flatMap((entry) => ( + INVALID_STORED_REFUND_TARGETS.map(([caseName, overrides]) => ([ + entry.name, + caseName, + entry, + overrides, + ])) + )))('%s rejects a %s before Stripe or business writes', async ( + _entryName, + _caseName, + entry, + overrides, + ) => { + admin.__seed('systemConfig/commerce', control()); + admin.__seed(entry.recordPath, { + status: 'paid', + amountCents: 2500, + currency: 'usd', + stripePaymentIntentId: 'pi_synthetic', + ...overrides, + }); + + await expect(entry.handler({ + ...entry.data, + action: 'refund_full', + }, adminContext())).rejects.toMatchObject({ + code: 'failed-precondition', + message: 'Stored refund target is unavailable', + }); + + expect(mockStripeConstructor).not.toHaveBeenCalled(); + expect(mockRefundCreate).not.toHaveBeenCalled(); + expect(mockBusinessWrite).not.toHaveBeenCalled(); + }); + test.each([ ['mark_comp', registrationInput(0)], ['add_late_registration', registrationInput(0)], @@ -395,6 +490,7 @@ describe('commerce admission at admin entry points', () => { admin.__seed(recordPath, { status: 'paid', amountCents: 2500, + currency: 'usd', stripePaymentIntentId: 'pi_synthetic', }); @@ -435,6 +531,7 @@ describe('commerce admission at admin entry points', () => { admin.__seed(recordPath, { status: 'paid', amountCents: 2500, + currency: 'usd', stripePaymentIntentId: 'pi_synthetic', }); @@ -467,8 +564,10 @@ describe('commerce admission at admin entry points', () => { admin.__seed(entry.recordPath, { status: 'paid', amountCents: 2500, + currency: 'usd', stripePaymentIntentId: 'pi_synthetic', }); + mockRefundCreate.mockResolvedValueOnce(refundResponse({ amount: amountCents })); await expect(entry.handler( partialRefundData(entry, amountCents), @@ -494,6 +593,7 @@ describe('commerce admission at admin entry points', () => { admin.__seed(entry.recordPath, { status: 'paid', amountCents: 2500, + currency: 'usd', stripePaymentIntentId: 'pi_synthetic', }); const hostileProviderDetail = [ @@ -529,6 +629,148 @@ describe('commerce admission at admin entry points', () => { }, ); + test.each(REFUND_ENTRY_POINTS.map((entry) => [entry.name, entry]))( + '%s returns do-not-retry when the local write fails before commit', + async (_entryName, entry) => { + admin.__seed('systemConfig/commerce', control()); + admin.__seed(entry.recordPath, { + status: 'paid', + amountCents: 2500, + currency: 'usd', + stripePaymentIntentId: 'pi_synthetic', + }); + mockRefundCreate.mockResolvedValueOnce(refundResponse({ amount: 1000 })); + const hostileStorageDetail = 'synthetic storage failure record=private-1'; + mockBusinessWrite.mockImplementationOnce(() => { + throw new Error(hostileStorageDetail); + }); + + const error = await entry.handler( + partialRefundData(entry, 1000), + adminContext(), + ).catch((received) => received); + + expect(error).toMatchObject({ + code: 'internal', + message: 'Refund result could not be confirmed. Do not retry. Escalate to the treasurer and platform owner.', + }); + expect(error.message).not.toContain(hostileStorageDetail); + expect(mockRefundCreate).toHaveBeenCalledTimes(1); + expect(mockBusinessWrite).toHaveBeenCalledTimes(1); + expect(admin.__get(entry.recordPath)).toMatchObject({ + status: 'paid', + amountCents: 2500, + }); + }, + ); + + test.each(REFUND_ENTRY_POINTS.map((entry) => [entry.name, entry]))( + '%s returns do-not-retry when the local write commits but loses its response', + async (_entryName, entry) => { + admin.__seed('systemConfig/commerce', control()); + admin.__seed(entry.recordPath, { + status: 'paid', + amountCents: 2500, + currency: 'usd', + stripePaymentIntentId: 'pi_synthetic', + }); + mockRefundCreate.mockResolvedValueOnce(refundResponse({ amount: 1000 })); + const hostileStorageDetail = 'synthetic lost acknowledgement record=private-1'; + mockBusinessWrite.mockReturnValueOnce(new Error(hostileStorageDetail)); + + const error = await entry.handler( + partialRefundData(entry, 1000), + adminContext(), + ).catch((received) => received); + + expect(error).toMatchObject({ + code: 'internal', + message: 'Refund result could not be confirmed. Do not retry. Escalate to the treasurer and platform owner.', + }); + expect(error.message).not.toContain(hostileStorageDetail); + expect(mockRefundCreate).toHaveBeenCalledTimes(1); + expect(mockBusinessWrite).toHaveBeenCalledTimes(1); + expect(admin.__get(entry.recordPath)).toMatchObject({ + status: 'partially_refunded', + amountCents: 2500, + stripeRefundIds: ['re_synthetic'], + }); + }, + ); + + test.each(REFUND_ENTRY_POINTS.flatMap((entry) => ( + INVALID_RESOLVED_REFUNDS.map(([caseName, refund]) => ([ + entry.name, + caseName, + entry, + refund, + ])) + )))('%s rejects a resolved %s without recording local success', async ( + _entryName, + _caseName, + entry, + refund, + ) => { + admin.__seed('systemConfig/commerce', control()); + admin.__seed(entry.recordPath, { + status: 'paid', + amountCents: 2500, + currency: 'usd', + stripePaymentIntentId: 'pi_synthetic', + }); + mockRefundCreate.mockResolvedValueOnce(refund); + + const error = await entry.handler( + partialRefundData(entry, 1000), + adminContext(), + ).catch((received) => received); + + expect(error).toMatchObject({ + code: 'internal', + message: 'Refund result could not be confirmed. Do not retry. Escalate to the treasurer and platform owner.', + }); + expect(mockRefundCreate).toHaveBeenCalledTimes(1); + expect(mockBusinessWrite).not.toHaveBeenCalled(); + expect(admin.__get(entry.recordPath)).toMatchObject({ + status: 'paid', + amountCents: 2500, + currency: 'usd', + stripePaymentIntentId: 'pi_synthetic', + }); + }); + + test.each(REFUND_ENTRY_POINTS.map((entry) => [entry.name, entry]))( + '%s records the validated full-remaining amount returned by Stripe', + async (_entryName, entry) => { + admin.__seed('systemConfig/commerce', control()); + admin.__seed(entry.recordPath, { + status: 'partially_refunded', + amountCents: 2500, + currency: 'usd', + stripePaymentIntentId: 'pi_synthetic', + stripeRefundIds: ['re_prior123'], + }); + mockRefundCreate.mockResolvedValueOnce(refundResponse({ amount: 500 })); + + await expect(entry.handler({ + ...entry.data, + action: 'refund_full', + }, adminContext())).resolves.toEqual({ ok: true, refundId: 're_synthetic' }); + + expect(mockRefundCreate).toHaveBeenCalledWith({ payment_intent: 'pi_synthetic' }); + expect(mockRefundCreate).toHaveBeenCalledTimes(1); + expect(admin.__get(entry.recordPath)).toMatchObject({ + status: 'refunded', + stripeRefundIds: ['re_synthetic'], + auditLog: [{ + action: 'admin.refund_full', + note: 'refund=re_synthetic amount=500', + }], + }); + expect(mockBusinessWrite).toHaveBeenCalledTimes(1); + }, + ); + test('existing-record note bypasses an absent runtime commerce control', async () => { process.env.COMMERCE_ENABLED = 'false'; admin.__seed('events/race-1/registrations/reg-1', { diff --git a/functions/refundResponseValidation.js b/functions/refundResponseValidation.js new file mode 100644 index 00000000..9cc3fc7a --- /dev/null +++ b/functions/refundResponseValidation.js @@ -0,0 +1,167 @@ +'use strict'; + +const REFUND_RESULT_NOT_CONFIRMED_MESSAGE = ( + 'Refund result could not be confirmed. Do not retry. ' + + 'Escalate to the treasurer and platform owner.' +); + +const REFUND_RESPONSE_VALIDATION_REASONS = Object.freeze({ + INVALID_EXPECTATION: 'invalid_expectation', + INVALID_RESPONSE: 'invalid_response', + UNCONFIRMED_RESPONSE: 'unconfirmed_response', + MISMATCHED_RESPONSE: 'mismatched_response', +}); + +const INVALID_EXPECTATION = Object.freeze({ + ok: false, + reason: REFUND_RESPONSE_VALIDATION_REASONS.INVALID_EXPECTATION, +}); +const INVALID_RESPONSE = Object.freeze({ + ok: false, + reason: REFUND_RESPONSE_VALIDATION_REASONS.INVALID_RESPONSE, +}); +const UNCONFIRMED_RESPONSE = Object.freeze({ + ok: false, + reason: REFUND_RESPONSE_VALIDATION_REASONS.UNCONFIRMED_RESPONSE, +}); +const MISMATCHED_RESPONSE = Object.freeze({ + ok: false, + reason: REFUND_RESPONSE_VALIDATION_REASONS.MISMATCHED_RESPONSE, +}); + +const PAYMENT_INTENT_ID_PATTERN = /^pi_[A-Za-z0-9]{8,252}$/u; +const REFUND_ID_PATTERN = /^re_[A-Za-z0-9]{8,252}$/u; +const REFUND_ACTIONS = new Set(['refund_full', 'refund_partial']); +const RESPONSE_FIELDS = Object.freeze([ + 'id', + 'object', + 'amount', + 'currency', + 'payment_intent', + 'status', +]); + +function isPlainRecord(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function readOwnDataProperties(record, fieldNames) { + const descriptors = Object.getOwnPropertyDescriptors(record); + const values = {}; + for (const fieldName of fieldNames) { + const descriptor = descriptors[fieldName]; + if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + return null; + } + values[fieldName] = descriptor.value; + } + return values; +} + +function buildRefundExpectation(input) { + if (!isPlainRecord(input)) return INVALID_EXPECTATION; + + const { + action, + paymentIntentId, + currency, + originalAmountCents, + requestedAmountCents, + } = input; + + if ( + !REFUND_ACTIONS.has(action) + || typeof paymentIntentId !== 'string' + || !PAYMENT_INTENT_ID_PATTERN.test(paymentIntentId) + || currency !== 'usd' + || !Number.isSafeInteger(originalAmountCents) + || originalAmountCents <= 0 + ) { + return INVALID_EXPECTATION; + } + + if (action === 'refund_partial') { + if ( + !Number.isSafeInteger(requestedAmountCents) + || requestedAmountCents <= 0 + || requestedAmountCents >= originalAmountCents + ) { + return INVALID_EXPECTATION; + } + } else if (requestedAmountCents !== null && requestedAmountCents !== undefined) { + return INVALID_EXPECTATION; + } + + return Object.freeze({ + ok: true, + action, + paymentIntentId, + currency, + originalAmountCents, + requestedAmountCents: action === 'refund_partial' ? requestedAmountCents : null, + }); +} + +function isValidExpectation(expectation) { + if (!isPlainRecord(expectation) || expectation.ok !== true) return false; + const rebuilt = buildRefundExpectation(expectation); + return rebuilt.ok; +} + +function validateSucceededRefundResponse({ refund, expectation } = {}) { + try { + if (!isValidExpectation(expectation) || !isPlainRecord(refund)) { + return INVALID_RESPONSE; + } + + const fields = readOwnDataProperties(refund, RESPONSE_FIELDS); + if ( + !fields + || typeof fields.id !== 'string' + || !REFUND_ID_PATTERN.test(fields.id) + || fields.object !== 'refund' + || !Number.isSafeInteger(fields.amount) + || fields.amount <= 0 + || typeof fields.currency !== 'string' + || typeof fields.payment_intent !== 'string' + || typeof fields.status !== 'string' + ) { + return INVALID_RESPONSE; + } + + if (fields.status !== 'succeeded') return UNCONFIRMED_RESPONSE; + + if ( + fields.payment_intent !== expectation.paymentIntentId + || fields.currency !== expectation.currency + || fields.amount > expectation.originalAmountCents + || ( + expectation.action === 'refund_partial' + && fields.amount !== expectation.requestedAmountCents + ) + ) { + return MISMATCHED_RESPONSE; + } + + return Object.freeze({ + ok: true, + refundId: fields.id, + amountCents: fields.amount, + currency: fields.currency, + paymentIntentId: fields.payment_intent, + }); + } catch { + return INVALID_RESPONSE; + } +} + +module.exports = { + REFUND_RESPONSE_VALIDATION_REASONS, + REFUND_RESULT_NOT_CONFIRMED_MESSAGE, + buildRefundExpectation, + validateSucceededRefundResponse, +}; diff --git a/functions/refundResponseValidation.test.js b/functions/refundResponseValidation.test.js new file mode 100644 index 00000000..7b3d9666 --- /dev/null +++ b/functions/refundResponseValidation.test.js @@ -0,0 +1,208 @@ +'use strict'; + +const { + REFUND_RESPONSE_VALIDATION_REASONS, + buildRefundExpectation, + validateSucceededRefundResponse, +} = require('./refundResponseValidation'); + +const VALID_PARTIAL_INPUT = Object.freeze({ + action: 'refund_partial', + paymentIntentId: 'pi_synthetic123', + currency: 'usd', + originalAmountCents: 2500, + requestedAmountCents: 1000, +}); + +function validResponse(overrides = {}) { + return { + id: 're_synthetic123', + object: 'refund', + amount: 1000, + currency: 'usd', + payment_intent: 'pi_synthetic123', + status: 'succeeded', + ...overrides, + }; +} + +describe('buildRefundExpectation', () => { + test.each([ + ['missing input', undefined], + ['unknown action', { ...VALID_PARTIAL_INPUT, action: 'refund_everything' }], + ['missing PaymentIntent', { ...VALID_PARTIAL_INPUT, paymentIntentId: undefined }], + ['non-string PaymentIntent', { ...VALID_PARTIAL_INPUT, paymentIntentId: ['pi_synthetic123'] }], + ['malformed PaymentIntent', { ...VALID_PARTIAL_INPUT, paymentIntentId: 'payment-1' }], + ['uppercase currency', { ...VALID_PARTIAL_INPUT, currency: 'USD' }], + ['different currency', { ...VALID_PARTIAL_INPUT, currency: 'eur' }], + ['missing original amount', { ...VALID_PARTIAL_INPUT, originalAmountCents: undefined }], + ['zero original amount', { ...VALID_PARTIAL_INPUT, originalAmountCents: 0 }], + ['fractional original amount', { ...VALID_PARTIAL_INPUT, originalAmountCents: 2500.5 }], + ['unsafe original amount', { + ...VALID_PARTIAL_INPUT, + originalAmountCents: Number.MAX_SAFE_INTEGER + 1, + }], + ['missing partial amount', { ...VALID_PARTIAL_INPUT, requestedAmountCents: undefined }], + ['zero partial amount', { ...VALID_PARTIAL_INPUT, requestedAmountCents: 0 }], + ['equal partial amount', { ...VALID_PARTIAL_INPUT, requestedAmountCents: 2500 }], + ['fractional partial amount', { ...VALID_PARTIAL_INPUT, requestedAmountCents: 1.5 }], + ])('rejects %s without coercion', (_caseName, input) => { + expect(buildRefundExpectation(input)).toEqual({ + ok: false, + reason: REFUND_RESPONSE_VALIDATION_REASONS.INVALID_EXPECTATION, + }); + }); + + test('returns a deeply immutable partial expectation', () => { + const result = buildRefundExpectation(VALID_PARTIAL_INPUT); + + expect(result).toEqual({ + ok: true, + action: 'refund_partial', + paymentIntentId: 'pi_synthetic123', + currency: 'usd', + originalAmountCents: 2500, + requestedAmountCents: 1000, + }); + expect(Object.isFrozen(result)).toBe(true); + }); + + test('returns a deeply immutable full-remaining expectation without a requested amount', () => { + const result = buildRefundExpectation({ + ...VALID_PARTIAL_INPUT, + action: 'refund_full', + requestedAmountCents: null, + }); + + expect(result).toEqual({ + ok: true, + action: 'refund_full', + paymentIntentId: 'pi_synthetic123', + currency: 'usd', + originalAmountCents: 2500, + requestedAmountCents: null, + }); + expect(Object.isFrozen(result)).toBe(true); + }); +}); + +describe('validateSucceededRefundResponse', () => { + let partialExpectation; + let fullExpectation; + + beforeEach(() => { + partialExpectation = buildRefundExpectation(VALID_PARTIAL_INPUT); + fullExpectation = buildRefundExpectation({ + ...VALID_PARTIAL_INPUT, + action: 'refund_full', + requestedAmountCents: null, + }); + }); + + test.each([ + ['missing', undefined], + ['null', null], + ['string', 'refund'], + ['array', [validResponse()]], + ['surprising prototype', Object.assign(Object.create({ inherited: true }), validResponse())], + ['missing ID', validResponse({ id: undefined })], + ['malformed ID', validResponse({ id: 'refund-1' })], + ['wrong object', validResponse({ object: 'payment_intent' })], + ['missing amount', validResponse({ amount: undefined })], + ['zero amount', validResponse({ amount: 0 })], + ['fraction amount', validResponse({ amount: 1000.5 })], + ['unsafe amount', validResponse({ amount: Number.MAX_SAFE_INTEGER + 1 })], + ['missing currency', validResponse({ currency: undefined })], + ['missing PaymentIntent', validResponse({ payment_intent: undefined })], + ['expanded PaymentIntent', validResponse({ payment_intent: { id: 'pi_synthetic123' } })], + ['missing status', validResponse({ status: undefined })], + ])('rejects a structurally invalid %s response', (_caseName, refund) => { + expect(validateSucceededRefundResponse({ + refund, + expectation: partialExpectation, + })).toEqual({ + ok: false, + reason: REFUND_RESPONSE_VALIDATION_REASONS.INVALID_RESPONSE, + }); + }); + + test('rejects accessor-backed fields without invoking them', () => { + const refund = validResponse(); + const getter = jest.fn(() => { + throw new Error('accessor must not run'); + }); + Object.defineProperty(refund, 'id', { enumerable: true, get: getter }); + + expect(validateSucceededRefundResponse({ + refund, + expectation: partialExpectation, + })).toEqual({ + ok: false, + reason: REFUND_RESPONSE_VALIDATION_REASONS.INVALID_RESPONSE, + }); + expect(getter).not.toHaveBeenCalled(); + }); + + test.each(['pending', 'requires_action', 'failed', 'canceled', 'future_status'])('rejects %s as unconfirmed', (status) => { + expect(validateSucceededRefundResponse({ + refund: validResponse({ status }), + expectation: partialExpectation, + })).toEqual({ + ok: false, + reason: REFUND_RESPONSE_VALIDATION_REASONS.UNCONFIRMED_RESPONSE, + }); + }); + + test.each([ + ['different PaymentIntent', { payment_intent: 'pi_different123' }], + ['different currency', { currency: 'cad' }], + ['different partial amount', { amount: 999 }], + ['over-original amount', { amount: 2501 }], + ])('rejects a %s', (_caseName, overrides) => { + expect(validateSucceededRefundResponse({ + refund: validResponse(overrides), + expectation: partialExpectation, + })).toEqual({ + ok: false, + reason: REFUND_RESPONSE_VALIDATION_REASONS.MISMATCHED_RESPONSE, + }); + }); + + test('returns only a frozen validated projection for an exact partial result', () => { + const refund = validResponse({ private_provider_detail: 'must-not-copy' }); + const result = validateSucceededRefundResponse({ refund, expectation: partialExpectation }); + + expect(result).toEqual({ + ok: true, + refundId: 're_synthetic123', + amountCents: 1000, + currency: 'usd', + paymentIntentId: 'pi_synthetic123', + }); + expect(Object.isFrozen(result)).toBe(true); + expect(result).not.toHaveProperty('private_provider_detail'); + }); + + test.each([1, 2500])('accepts full remaining provider amount %i', (amount) => { + expect(validateSucceededRefundResponse({ + refund: validResponse({ amount }), + expectation: fullExpectation, + })).toEqual({ + ok: true, + refundId: 're_synthetic123', + amountCents: amount, + currency: 'usd', + paymentIntentId: 'pi_synthetic123', + }); + }); + + test('rejects a full response above the stored original', () => { + expect(validateSucceededRefundResponse({ + refund: validResponse({ amount: 2501 }), + expectation: fullExpectation, + })).toEqual({ + ok: false, + reason: REFUND_RESPONSE_VALIDATION_REASONS.MISMATCHED_RESPONSE, + }); + }); +});