From 3f93a9af6eeb1c4b1965fbb2ed99265ebb0444e5 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Sat, 1 Aug 2026 18:04:29 -0700 Subject: [PATCH] fix: project checkout paidAt from event evidence (#586) --- STRIPE_COMMERCE_DESIGN.md | 42 +++ functions/stripeWebhook.js | 6 +- functions/stripeWebhook.test.js | 440 ++++++++++++++++++++++++++++++++ 3 files changed, 485 insertions(+), 3 deletions(-) diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 32dee1e..7f28eed 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -940,6 +940,48 @@ Residual work remains under #106: current-clock freshness and skew policy, Check 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. +### PAY-003A11 Checkout payment-time projection — SOURCE ONLY, NOT LIVE + +PAY-003A11 [#586](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/586) closes the Checkout receipt-clock residual left after PAY-003A8 through A10. Its purpose is to ensure that a newly admitted supported Checkout payment transition which fills a missing `paidAt` uses the already admitted outer Stripe Event `created` second, not the webhook receipt or server clock. The Event value is admitted provider evidence time; this source does not prove an exact underlying payment-occurrence time or canonical provider truth. Existing `paidAt` remains authoritative. This changes no Event admission, business outcome, status transition, provider binding, money check, or operational clock. + +Approvers are the platform owner and payment reviewer. Prerequisites are verified webhook signature handling; the processed-Event ledger; PAY-003A8 outer Event-time admission; PAY-003A9 and A10 refund-evidence compatibility; signed synthetic fixtures; the existing Checkout lifecycle, metadata, target, ownership, binding, mode, money, payment-status, PaymentIntent, and predecessor-state checks; and the existing nullable `paidAt` field. A new supported Checkout 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 Checkout Session lifecycle. +4. Check an explicit claimed-MPRC metadata schema version. +5. Check the outer Event-created value. +6. Resolve the target and validate ownership and provider bindings. +7. Validate mode, money, payment status, PaymentIntent, and predecessor state. +8. Select the existing successful-payment branch and outcome. +9. Only when that branch must fill a missing `paidAt`, persist `Timestamp.fromMillis(event.created * 1000)`; otherwise preserve the existing value. + +The projection applies only to the three existing missing-`paidAt` fallbacks: ordinary payment confirmation; payment observed after `partially_refunded` or `refunded`; and payment observed after `fulfilled` or `transferred`. Their current statuses, review flags, and outcomes remain unchanged. `updatedAt`, audit and action time, ledger processing and expiry, provider-binding creation, payment exceptions, refunds, and other operational timestamps remain server or action-clock values. Already-paid records, exact replay, unpaid completion, asynchronous failure, expiry, and paid-after-cancellation do not gain a new `paidAt` path. + +```mermaid +flowchart TD + A["Verified supported Checkout Event"] --> B{"Already in the processed-Event ledger?"} + B -- "Yes" --> C["Return the stored result without changes"] + B -- "No" --> D{"Earlier admission, target, binding, money, and state checks pass?"} + D -- "No" --> E["Keep the existing earlier outcome"] + D -- "Yes" --> F{"Successful-payment branch writes a missing paidAt?"} + F -- "No" --> G["Keep the existing value or no-write path"] + F -- "Yes" --> H["Project the admitted Event-created second"] + H --> I["Persist the existing outcome with server-clock operational times"] +``` + +Text alternative: replay returns the stored result first; a new Checkout Event keeps every earlier admission or business outcome; only an existing successful-payment branch that needs to fill a missing payment time uses the admitted Event time, while existing payment time and operational server times remain unchanged. + +The expected result is admitted provider-evidence time in a newly filled Checkout `paidAt` and unchanged payment, refund, and fulfillment behavior. Success proof requires separately named synthetic tests for registration and order completion; delayed asynchronous success; payment observed after partially-refunded, refunded, fulfilled, and transferred states; existing-`paidAt` preservation in all three branch families; refund-first Charge-derived `paidAt` preservation; exact replay; admitted epoch and Firestore-maximum Event seconds; unpaid completion, failure, expiry, and paid-after-cancellation exclusions; unchanged outcomes and statuses; and proof that `updatedAt`, audit and action, binding, ledger processing and expiry, exception, and refund times still use the applicable server or action clock. The complete webhook and Functions suites and prior PAY-003A8, A9, and A10 matrices must remain green. + +Stop the merge if receipt or server time still supplies a missing Checkout `paidAt`; if Event time is projected before its existing admission; if an existing `paidAt` is overwritten; if any branch outcome, status, or review result changes; if an excluded path gains `paidAt`; if an operational timestamp changes to provider Event time; if replay or history is repaired; or if a test needs a real credential, provider object or call, payment or refund, customer or member record, or production service. Escalate to the platform owner and payment reviewer. Undo is one small reviewed revert of the three Checkout `paidAt` substitutions, the separately named A11 tests, and this section. Do not edit a provider object, processed ledger, 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 admitted Checkout Events change only the value used when one of the three existing successful-payment branches fills missing `paidAt`. Existing values, processed-Event replay, target resolution, bindings, money checks, and business outcomes remain compatible. Historical receipt-clock values and already-paid records with missing time are not inspected or repaired. + +Residual work remains under #106: Checkout Session-created admission and chronology; current-clock freshness and skew; already-paid missing-time repair; paid-after-cancellation policy; other Charge and Dispute decisions; 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 or refund success, historical correctness, or production behavior. + +PAY-003A11 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-time projection 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. + 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 44332f0..0c2d3e2 100644 --- a/functions/stripeWebhook.js +++ b/functions/stripeWebhook.js @@ -891,7 +891,7 @@ function successfulPaymentTransition({ event, session, target, record }) { paymentStatus: record.paymentStatus || status, stripeSessionId: record.stripeSessionId || session.id, stripePaymentIntentId: paymentIntentId || record.stripePaymentIntentId || null, - paidAt: record.paidAt || Timestamp.now(), + paidAt: record.paidAt || Timestamp.fromMillis(event.created * 1000), lastStripeEventId: event.id, updatedAt: Timestamp.now(), auditLog: auditPatch({ @@ -914,7 +914,7 @@ function successfulPaymentTransition({ event, session, target, record }) { paymentStatus: 'paid', stripeSessionId: record.stripeSessionId || session.id, stripePaymentIntentId: paymentIntentId || record.stripePaymentIntentId || null, - paidAt: record.paidAt || Timestamp.now(), + paidAt: record.paidAt || Timestamp.fromMillis(event.created * 1000), paymentReviewRequired: requiresReview, paymentReviewReason: requiresReview ? 'fulfilled_before_payment_confirmation' : null, lastStripeEventId: event.id, @@ -966,7 +966,7 @@ function successfulPaymentTransition({ event, session, target, record }) { stripePaymentIntentId: paymentIntentId || record.stripePaymentIntentId || null, paymentReviewRequired: false, paymentReviewReason: null, - paidAt: record.paidAt || Timestamp.now(), + paidAt: record.paidAt || Timestamp.fromMillis(event.created * 1000), lastStripeEventId: event.id, updatedAt: Timestamp.now(), auditLog: auditPatch({ diff --git a/functions/stripeWebhook.test.js b/functions/stripeWebhook.test.js index 264b9a9..99e8063 100644 --- a/functions/stripeWebhook.test.js +++ b/functions/stripeWebhook.test.js @@ -957,6 +957,446 @@ describe('stripeWebhook', () => { consoleError.mockClear(); }); + describe('PAY-003A11 Checkout payment-time projection', () => { + const eventCreated = 1_700_000_123; + const domains = [ + [ + 'registration', + 'events/race-1/registrations/reg-1', + seedRegistration, + registrationSession, + ], + ['order', 'orders/order-1', seedOrder, orderSession], + ]; + + function eventAt(id, type, session, created = eventCreated) { + const event = stripeEvent(id, type, session); + event.created = created; + return event; + } + + function expectOperationalTime(value, providerTime) { + expect(value?._milliseconds).toEqual(expect.any(Number)); + expect(value).not.toEqual(providerTime); + } + + test.each([ + ...domains, + ])('projects admitted Event time for a new %s payment confirmation', async ( + domain, + businessPath, + seed, + session, + ) => { + // Current creators persist this nullable field explicitly. + seed({ paidAt: null }); + const event = eventAt( + `evt_pay003a11_red_${domain}`, + 'checkout.session.completed', + session({ created: 1_600_000_000 }), + ); + + const response = await deliver(event); + const record = admin.__get(businessPath); + const ledger = admin.__get(`stripeEvents/${event.id}`); + const expectedPaidAt = { _milliseconds: event.created * 1000 }; + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: false, + outcome: 'payment_confirmed', + })); + expect(ledger).toMatchObject({ + outcome: 'payment_confirmed', + targetPath: businessPath, + stripeCreatedAt: expectedPaidAt, + }); + expect(record).toMatchObject({ + status: 'paid', + paymentStatus: 'paid', + paidAt: expectedPaidAt, + }); + expectOperationalTime(record.updatedAt, expectedPaidAt); + expectOperationalTime(record.auditLog[0].ts, expectedPaidAt); + expectOperationalTime( + admin.__get(`stripeObjectBindings/checkout_session:${event.data.object.id}`).createdAt, + expectedPaidAt, + ); + expectOperationalTime( + admin.__get( + `stripeObjectBindings/payment_intent:${event.data.object.payment_intent}`, + ).createdAt, + expectedPaidAt, + ); + expectOperationalTime(ledger.processedAt, expectedPaidAt); + expectOperationalTime(ledger.expiresAt, expectedPaidAt); + expect(Timestamp.now).toHaveBeenCalled(); + }); + + test.each(domains)( + 'uses the async-success Event time for delayed %s payment', + async (domain, businessPath, seed, session) => { + seed({ paidAt: null }); + const pendingEvent = eventAt( + `evt_pay003a11_async_pending_${domain}`, + 'checkout.session.completed', + session({ payment_status: 'unpaid' }), + eventCreated - 100, + ); + const pendingResponse = await deliver(pendingEvent); + + expect(pendingResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + outcome: 'awaiting_payment', + })); + expect(admin.__get(businessPath).paidAt).toBeNull(); + + const successEvent = eventAt( + `evt_pay003a11_async_success_${domain}`, + 'checkout.session.async_payment_succeeded', + session(), + eventCreated + 100, + ); + const successResponse = await deliver(successEvent); + const record = admin.__get(businessPath); + + expect(successResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + outcome: 'payment_confirmed', + })); + expect(record).toMatchObject({ + status: 'paid', + paymentStatus: 'paid', + paidAt: { _milliseconds: successEvent.created * 1000 }, + }); + expect(record.auditLog).toHaveLength(2); + }, + ); + + test.each([ + [ + 'partially_refunded', + 'events/race-1/registrations/reg-1', + seedRegistration, + registrationSession, + 'partially_refunded', + 'payment_observed_after_partially_refunded', + false, + ], + [ + 'refunded', + 'events/race-1/registrations/reg-1', + seedRegistration, + registrationSession, + 'refunded', + 'payment_observed_after_refunded', + false, + ], + [ + 'fulfilled', + 'orders/order-1', + seedOrder, + orderSession, + null, + 'needs_review:fulfilled_before_payment_confirmation', + true, + ], + [ + 'transferred', + 'orders/order-1', + seedOrder, + orderSession, + null, + 'payment_observed_after_transferred', + false, + ], + ])('projects Event time after the %s predecessor state', async ( + status, + businessPath, + seed, + session, + paymentStatus, + outcome, + requiresReview, + ) => { + seed({ status, paymentStatus, paidAt: null }); + const event = eventAt( + `evt_pay003a11_after_${status}`, + 'checkout.session.completed', + session(), + ); + + const response = await deliver(event); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + outcome, + requiresReview, + })); + expect(admin.__get(businessPath)).toMatchObject({ + status, + paymentStatus: status === 'partially_refunded' || status === 'refunded' + ? status + : 'paid', + paidAt: { _milliseconds: event.created * 1000 }, + }); + }); + + test.each([ + [ + 'ordinary confirmation', + 'events/race-1/registrations/reg-1', + seedRegistration, + registrationSession, + { status: 'pending' }, + 'payment_confirmed', + ], + [ + 'payment observed after refund', + 'events/race-1/registrations/reg-1', + seedRegistration, + registrationSession, + { status: 'refunded', paymentStatus: 'refunded' }, + 'payment_observed_after_refunded', + ], + [ + 'payment observed after fulfillment', + 'orders/order-1', + seedOrder, + orderSession, + { status: 'fulfilled', paymentStatus: null }, + 'needs_review:fulfilled_before_payment_confirmation', + ], + ])('preserves existing paidAt during %s', async ( + label, + businessPath, + seed, + session, + recordPatch, + outcome, + ) => { + const existingPaidAt = { _milliseconds: 1_500_000_000_000 }; + seed({ ...recordPatch, paidAt: existingPaidAt }); + const event = eventAt( + `evt_pay003a11_preserve_${label.replace(/[^a-z]+/g, '_')}`, + 'checkout.session.completed', + session(), + ); + + const response = await deliver(event); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ outcome })); + expect(admin.__get(businessPath).paidAt).toEqual(existingPaidAt); + }); + + test('preserves refund-first Charge time after later Checkout evidence', async () => { + seedRegistration({ paidAt: null }); + const chargeCreated = 1_600_000_000; + const refundEvent = eventAt( + 'evt_pay003a11_refund_first', + 'charge.refunded', + { + id: 'ch_pay003a11_refund_first', + object: 'charge', + payment_intent: 'pi_reg_1', + amount: 5000, + amount_refunded: 500, + currency: 'usd', + created: chargeCreated, + metadata: { + schemaVersion: '1', + eventId: 'race-1', + registrationId: 'reg-1', + }, + refunds: { data: [{ id: 're_pay003a11_refund_first' }] }, + }, + eventCreated, + ); + await deliver(refundEvent); + const recordAfterRefund = admin.__get('events/race-1/registrations/reg-1'); + expect(recordAfterRefund.paidAt).toEqual({ + _milliseconds: chargeCreated * 1000, + }); + expectOperationalTime(recordAfterRefund.refundedAt, { + _milliseconds: chargeCreated * 1000, + }); + + const checkoutEvent = eventAt( + 'evt_pay003a11_checkout_after_refund', + 'checkout.session.completed', + registrationSession(), + eventCreated + 100, + ); + const response = await deliver(checkoutEvent); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + outcome: 'payment_observed_after_partially_refunded', + })); + expect(admin.__get('events/race-1/registrations/reg-1').paidAt).toEqual({ + _milliseconds: chargeCreated * 1000, + }); + }); + + test('keeps exact replay read-only instead of repairing missing paidAt', async () => { + const businessPath = 'events/race-1/registrations/reg-1'; + seedRegistration({ status: 'paid', paymentStatus: 'paid', paidAt: null }); + const event = eventAt( + 'evt_pay003a11_processed_replay', + 'checkout.session.completed', + registrationSession(), + ); + const ledgerPath = `stripeEvents/${event.id}`; + admin.__seed(ledgerPath, { + status: 'processed', + outcome: 'payment_confirmed', + sentinel: 'preserve', + }); + const beforeRecord = storedCopy(businessPath); + const beforeLedger = storedCopy(ledgerPath); + + const response = await deliver(event); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + duplicate: true, + outcome: 'payment_confirmed', + })); + expect(admin.__get(businessPath)).toEqual(beforeRecord); + expect(admin.__get(ledgerPath)).toEqual(beforeLedger); + expect(Timestamp.now).not.toHaveBeenCalled(); + expect(Timestamp.fromMillis).not.toHaveBeenCalled(); + }); + + test('does not repair an already-paid record with missing paidAt', async () => { + seedOrder({ status: 'paid', paymentStatus: 'paid', paidAt: null }); + const event = eventAt( + 'evt_pay003a11_already_paid_missing_time', + 'checkout.session.completed', + orderSession(), + ); + + const response = await deliver(event); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + outcome: 'already_paid', + })); + expect(admin.__get('orders/order-1').paidAt).toBeNull(); + }); + + test.each([ + ['epoch', 0, 'events/race-1/registrations/reg-1', seedRegistration, registrationSession], + [ + 'Firestore maximum', + FIRESTORE_TIMESTAMP_MAX_SECONDS, + 'orders/order-1', + seedOrder, + orderSession, + ], + ])('projects the admitted %s Event boundary exactly', async ( + label, + created, + businessPath, + seed, + session, + ) => { + seed({ paidAt: null }); + const event = eventAt( + `evt_pay003a11_${label.replace(/[^a-z]+/gi, '_').toLowerCase()}`, + 'checkout.session.completed', + session(), + created, + ); + + const response = await deliver(event); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + outcome: 'payment_confirmed', + })); + expect(admin.__get(businessPath).paidAt).toEqual({ + _milliseconds: created * 1000, + }); + }); + + test.each([ + [ + 'unpaid completion', + seedRegistration, + {}, + 'events/race-1/registrations/reg-1', + 'checkout.session.completed', + () => registrationSession({ payment_status: 'unpaid' }), + 'awaiting_payment', + ], + [ + 'async failure', + seedRegistration, + {}, + 'events/race-1/registrations/reg-1', + 'checkout.session.async_payment_failed', + () => registrationSession({ payment_status: 'unpaid' }), + 'payment_failed', + ], + [ + 'expiry', + seedOrder, + {}, + 'orders/order-1', + 'checkout.session.expired', + () => orderSession({ payment_status: 'unpaid' }), + 'payment_expired', + ], + [ + 'unpaid async success', + seedRegistration, + {}, + 'events/race-1/registrations/reg-1', + 'checkout.session.async_payment_succeeded', + () => registrationSession({ payment_status: 'unpaid' }), + 'needs_review:async_success_not_paid', + ], + [ + 'paid after cancellation', + seedRegistration, + { status: 'cancelled', paymentStatus: 'cancelled' }, + 'events/race-1/registrations/reg-1', + 'checkout.session.completed', + registrationSession, + 'needs_review:paid_after_cancellation', + ], + [ + 'unexpected paid comp', + seedRegistration, + { status: 'comp', paymentStatus: 'comp' }, + 'events/race-1/registrations/reg-1', + 'checkout.session.completed', + registrationSession, + 'needs_review:unexpected_payment_for_comp', + ], + ])('does not fabricate paidAt for %s', async ( + label, + seed, + recordPatch, + businessPath, + type, + session, + outcome, + ) => { + seed({ ...recordPatch, paidAt: null }); + const event = eventAt( + `evt_pay003a11_excluded_${label.replace(/[^a-z]+/g, '_')}`, + type, + session(), + ); + + const response = await deliver(event); + const record = admin.__get(businessPath); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ outcome })); + expect(record.paidAt).toBeNull(); + if (outcome === 'needs_review:paid_after_cancellation') { + expectOperationalTime(record.paymentExceptionAt, { + _milliseconds: event.created * 1000, + }); + } + }); + }); + test('fails closed before persistence when expected Stripe mode is not configured', async () => { delete process.env.STRIPE_LIVEMODE_EXPECTED; seedRegistration();