From 14d387fd664ae5b6f581a219e559fdc85efcd75e Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 03:27:36 -0700 Subject: [PATCH 1/4] Harden Stripe webhook event processing --- functions/stripeHelpers.js | 2 +- functions/stripeWebhook.js | 1348 ++++++++++++++++++++--- functions/stripeWebhook.test.js | 1771 +++++++++++++++++++++++++++++-- 3 files changed, 2873 insertions(+), 248 deletions(-) diff --git a/functions/stripeHelpers.js b/functions/stripeHelpers.js index 4e07e721..43fb0c28 100644 --- a/functions/stripeHelpers.js +++ b/functions/stripeHelpers.js @@ -122,7 +122,7 @@ async function countActiveRegistrations(eventId) { const snap = await admin.firestore() .collection('events').doc(eventId) .collection('registrations') - .where('status', 'in', ['paid', 'pending', 'comp']) + .where('status', 'in', ['paid', 'pending', 'comp', 'partially_refunded']) .get(); let count = 0; snap.forEach((d) => { diff --git a/functions/stripeWebhook.js b/functions/stripeWebhook.js index 85d61fbb..0396518b 100644 --- a/functions/stripeWebhook.js +++ b/functions/stripeWebhook.js @@ -1,204 +1,1240 @@ const functions = require('firebase-functions'); const admin = require('firebase-admin'); +const Stripe = require('stripe'); const { - getStripe, getWebhookSecret, auditEntry, Timestamp, } = require('./stripeHelpers'); +const LEDGER_RETENTION_DAYS = 90; +const CHECKOUT_EVENT_TYPES = new Set([ + 'checkout.session.completed', + 'checkout.session.async_payment_succeeded', + 'checkout.session.async_payment_failed', + 'checkout.session.expired', +]); +const DISPUTE_EVENT_TYPES = new Set([ + 'charge.dispute.created', + 'charge.dispute.updated', + 'charge.dispute.closed', +]); +const DISPUTE_TERMINAL_STATUSES = new Set(['won', 'lost', 'warning_closed']); +const DISPUTE_STATUSES = new Set([ + 'needs_response', + 'under_review', + 'won', + 'lost', + 'warning_needs_response', + 'warning_under_review', + 'warning_closed', +]); +const COMPLETION_TERMINAL_STATUSES = new Set([ + 'cancelled', + 'comp', + 'fulfilled', + 'partially_refunded', + 'refunded', + 'transferred', +]); +const REFUND_STATUS_PRESERVING_STATES = new Set([ + 'cancelled', + 'comp', + 'fulfilled', + 'transferred', +]); + +function isSupportedEventType(type) { + return CHECKOUT_EVENT_TYPES.has(type) + || type === 'charge.refunded' + || DISPUTE_EVENT_TYPES.has(type); +} + +function validateExpectedLivemode(event) { + const configured = process.env.STRIPE_LIVEMODE_EXPECTED; + if (configured === undefined || configured === '') { + throw new Error('STRIPE_LIVEMODE_EXPECTED must be explicitly configured'); + } + if (configured !== 'true' && configured !== 'false') { + throw new Error('STRIPE_LIVEMODE_EXPECTED must be true or false'); + } + const expected = configured === 'true'; + if (event.livemode !== expected) { + return { ok: false, expected, reason: 'livemode_mismatch' }; + } + return { ok: true, expected }; +} + +function isValidDocId(value) { + return typeof value === 'string' + && value.length > 0 + && value.length <= 1500 + && !value.includes('/'); +} + +function clientReferenceClaim(clientReferenceId) { + if (typeof clientReferenceId !== 'string' + || !clientReferenceId.startsWith('mprc:')) { + return null; + } + const parts = clientReferenceId.split(':'); + if (parts.length === 3 && parts[1] === 'order' && isValidDocId(parts[2])) { + return { + status: 'claimed', + kind: 'order', + source: 'client_reference_id', + metadata: { type: 'merch', orderId: parts[2] }, + }; + } + if (parts.length === 4 && parts[1] === 'registration' + && isValidDocId(parts[2]) && isValidDocId(parts[3])) { + return { + status: 'claimed', + kind: 'registration', + source: 'client_reference_id', + metadata: { eventId: parts[2], registrationId: parts[3] }, + }; + } + return { + status: 'malformed', + source: 'client_reference_id', + reason: 'invalid_client_reference_id', + }; +} + +function metadataClaim(metadata) { + const hasOrderKey = Object.prototype.hasOwnProperty.call(metadata, 'orderId'); + const hasEventKey = Object.prototype.hasOwnProperty.call(metadata, 'eventId'); + const hasRegistrationKey = Object.prototype.hasOwnProperty.call( + metadata, + 'registrationId', + ); + const claimsMerch = metadata.type === 'merch'; + const claimsRegistration = hasEventKey || hasRegistrationKey || metadata.late_add === 'true'; + const explicitlyMprc = metadata.integration === 'mprc'; + const claimsOrder = hasOrderKey || claimsMerch; + + if (claimsOrder && claimsRegistration) { + return { status: 'malformed', source: 'metadata', reason: 'conflicting_reference' }; + } + if (claimsOrder) { + if (isValidDocId(metadata.orderId)) { + return { + status: 'claimed', + kind: 'order', + source: 'metadata', + metadata, + }; + } + return { status: 'malformed', source: 'metadata', reason: 'invalid_order_reference' }; + } + if (claimsRegistration || explicitlyMprc) { + if (isValidDocId(metadata.eventId) && isValidDocId(metadata.registrationId)) { + return { + status: 'claimed', + kind: 'registration', + source: 'metadata', + metadata, + }; + } + return { + status: 'malformed', + source: 'metadata', + reason: 'invalid_registration_reference', + }; + } + + return { status: 'unrelated', source: null }; +} + +function claimsMatch(left, right) { + if (left.kind !== right.kind) return false; + if (left.kind === 'order') { + return left.metadata.orderId === right.metadata.orderId; + } + return left.metadata.eventId === right.metadata.eventId + && left.metadata.registrationId === right.metadata.registrationId; +} + +function classifyMprcReference(object) { + const metadata = object?.metadata || {}; + const fromMetadata = metadataClaim(metadata); + const fromClientReference = clientReferenceClaim(object?.client_reference_id); + + if (fromMetadata.status === 'malformed') return fromMetadata; + if (fromClientReference?.status === 'malformed') return fromClientReference; + if (fromMetadata.status === 'claimed' && fromClientReference?.status === 'claimed') { + if (!claimsMatch(fromMetadata, fromClientReference)) { + return { status: 'malformed', source: 'multiple', reason: 'conflicting_reference' }; + } + return { + ...fromMetadata, + source: 'metadata_and_client_reference', + }; + } + if (fromMetadata.status === 'claimed') return fromMetadata; + return fromClientReference || fromMetadata; +} + +function objectId(value) { + if (typeof value === 'string') return value; + return value?.id || null; +} + +function isStripeId(value, prefix) { + return typeof value === 'string' + && value.startsWith(prefix) + && value.length <= 255 + && /^[A-Za-z0-9_]+$/.test(value); +} + +function normalizedCurrency(value) { + if (typeof value !== 'string') return null; + const currency = value.toLowerCase(); + return /^[a-z]{3}$/.test(currency) ? currency : null; +} + +function uniqueDoc(snap) { + if (snap.size > 1) { + throw new Error('Ambiguous Stripe target binding'); + } + return snap.empty ? null : snap.docs[0]; +} + async function findRegistrationBySessionId(sessionId) { const snap = await admin.firestore() .collectionGroup('registrations') .where('stripeSessionId', '==', sessionId) - .limit(1) + .limit(2) .get(); - return snap.empty ? null : snap.docs[0]; + return uniqueDoc(snap); } -async function findRegistrationByPaymentIntent(piId) { +async function findRegistrationByPaymentIntent(paymentIntentId) { const snap = await admin.firestore() .collectionGroup('registrations') - .where('stripePaymentIntentId', '==', piId) - .limit(1) + .where('stripePaymentIntentId', '==', paymentIntentId) + .limit(2) .get(); - return snap.empty ? null : snap.docs[0]; + return uniqueDoc(snap); +} + +async function findRegistrationByChargeId(chargeId) { + const snap = await admin.firestore() + .collectionGroup('registrations') + .where('stripeChargeId', '==', chargeId) + .limit(2) + .get(); + return uniqueDoc(snap); +} + +async function findRegistrationByPaymentLinkId(paymentLinkId) { + const snap = await admin.firestore() + .collectionGroup('registrations') + .where('stripePaymentLinkId', '==', paymentLinkId) + .limit(2) + .get(); + return uniqueDoc(snap); } async function findOrderBySessionId(sessionId) { const snap = await admin.firestore() .collection('orders') .where('stripeSessionId', '==', sessionId) - .limit(1) + .limit(2) .get(); - return snap.empty ? null : snap.docs[0]; + return uniqueDoc(snap); } -async function findOrderByPaymentIntent(piId) { +async function findOrderByPaymentIntent(paymentIntentId) { const snap = await admin.firestore() .collection('orders') - .where('stripePaymentIntentId', '==', piId) - .limit(1) + .where('stripePaymentIntentId', '==', paymentIntentId) + .limit(2) .get(); - return snap.empty ? null : snap.docs[0]; + return uniqueDoc(snap); } -function isMerchSession(session) { - return session?.metadata?.type === 'merch'; +async function findOrderByChargeId(chargeId) { + const snap = await admin.firestore() + .collection('orders') + .where('stripeChargeId', '==', chargeId) + .limit(2) + .get(); + return uniqueDoc(snap); } -async function handleMerchCheckoutCompleted(session) { - const doc = await findOrderBySessionId(session.id); - if (!doc) { - console.warn('Webhook: no order for session', session.id); - return; +async function findOrderByPaymentLinkId(paymentLinkId) { + const snap = await admin.firestore() + .collection('orders') + .where('stripePaymentLinkId', '==', paymentLinkId) + .limit(2) + .get(); + return uniqueDoc(snap); +} + +function targetFromSnapshot(kind, snap, source) { + if (!snap?.exists) return null; + return { kind, ref: snap.ref, source }; +} + +function targetFromCandidates(order, registration, source) { + if (order && registration) { + throw new Error('Ambiguous Stripe target type'); } - const order = doc.data(); - if (order.status === 'paid') return; + if (order) return targetFromSnapshot('order', order, source); + return targetFromSnapshot('registration', registration, source); +} + +async function candidatesForProviderObject(type, id) { + switch (type) { + case 'checkout_session': + return Promise.all([findOrderBySessionId(id), findRegistrationBySessionId(id)]); + case 'payment_intent': + return Promise.all([ + findOrderByPaymentIntent(id), + findRegistrationByPaymentIntent(id), + ]); + case 'charge': + return Promise.all([findOrderByChargeId(id), findRegistrationByChargeId(id)]); + case 'payment_link': + return Promise.all([ + findOrderByPaymentLinkId(id), + findRegistrationByPaymentLinkId(id), + ]); + default: + return [null, null]; + } +} - const shipping = session.shipping_details?.address - ? { - line1: session.shipping_details.address.line1 || '', - line2: session.shipping_details.address.line2 || null, - city: session.shipping_details.address.city || '', - state: session.shipping_details.address.state || '', - postalCode: session.shipping_details.address.postal_code || '', - country: session.shipping_details.address.country || '', - recipientName: session.shipping_details.name || null, +function providerObjectsForEvent(event) { + const object = event?.data?.object || {}; + const descriptors = []; + if (CHECKOUT_EVENT_TYPES.has(event.type)) { + if (isStripeId(objectId(object), 'cs_')) { + descriptors.push({ type: 'checkout_session', id: objectId(object) }); } - : null; + if (isStripeId(objectId(object.payment_intent), 'pi_')) { + descriptors.push({ type: 'payment_intent', id: objectId(object.payment_intent) }); + } + if (isStripeId(objectId(object.payment_link), 'plink_')) { + descriptors.push({ type: 'payment_link', id: objectId(object.payment_link) }); + } + } else if (event.type === 'charge.refunded') { + if (isStripeId(objectId(object), 'ch_')) { + descriptors.push({ type: 'charge', id: objectId(object) }); + } + if (isStripeId(objectId(object.payment_intent), 'pi_')) { + descriptors.push({ type: 'payment_intent', id: objectId(object.payment_intent) }); + } + } else if (DISPUTE_EVENT_TYPES.has(event.type)) { + if (isStripeId(objectId(object), 'dp_')) { + descriptors.push({ type: 'dispute', id: objectId(object) }); + } + if (isStripeId(objectId(object.charge), 'ch_')) { + descriptors.push({ type: 'charge', id: objectId(object.charge) }); + } + if (isStripeId(objectId(object.payment_intent), 'pi_')) { + descriptors.push({ type: 'payment_intent', id: objectId(object.payment_intent) }); + } + } + return descriptors; +} - await doc.ref.update({ - status: 'paid', - stripePaymentIntentId: session.payment_intent || null, - shipping, - paidAt: Timestamp.now(), - updatedAt: Timestamp.now(), - auditLog: admin.firestore.FieldValue.arrayUnion( - auditEntry({ action: 'order.payment_completed', note: `pi=${session.payment_intent || ''}` }), - ), +async function assertExclusiveProviderOwnership(event, target) { + const descriptors = providerObjectsForEvent(event) + .filter(({ type }) => type !== 'dispute'); + // Existing legacy fields are checked before the transaction. The binding + // documents written in the transaction below close the concurrent-attach + // race for new processing. + const candidatesByDescriptor = await Promise.all(descriptors.map( + ({ type, id }) => candidatesForProviderObject(type, id), + )); + candidatesByDescriptor.forEach((candidates) => { + candidates.filter(Boolean).forEach((candidate) => { + if (candidate.ref.path !== target.ref.path) { + throw new Error('Conflicting Stripe target binding'); + } + }); }); } -async function handleMerchChargeRefunded(charge) { - const piId = charge.payment_intent; - if (!piId) return; - const doc = await findOrderByPaymentIntent(piId); - if (!doc) return; - const order = doc.data(); - const amountRefunded = charge.amount_refunded || 0; - const totalAmount = charge.amount || order.amountCents || 0; - const fullyRefunded = amountRefunded >= totalAmount && totalAmount > 0; - await doc.ref.update({ - status: fullyRefunded ? 'refunded' : 'partially_refunded', - stripeChargeId: charge.id, - refundedAt: Timestamp.now(), - updatedAt: Timestamp.now(), - auditLog: admin.firestore.FieldValue.arrayUnion( - auditEntry({ - action: fullyRefunded ? 'order.refund_full' : 'order.refund_partial', - note: `refunded=${amountRefunded} of ${totalAmount}`, +async function directOrderTarget(metadata, source = 'metadata') { + if (!isValidDocId(metadata?.orderId)) return null; + const snap = await admin.firestore().collection('orders').doc(metadata.orderId).get(); + return targetFromSnapshot('order', snap, source); +} + +async function directRegistrationTarget(metadata, source = 'metadata') { + if (!isValidDocId(metadata?.eventId) || !isValidDocId(metadata?.registrationId)) { + return null; + } + const snap = await admin.firestore() + .collection('events').doc(metadata.eventId) + .collection('registrations').doc(metadata.registrationId) + .get(); + return targetFromSnapshot('registration', snap, source); +} + +async function resolveSessionTarget(session) { + const reference = classifyMprcReference(session); + if (reference.status === 'claimed') { + return reference.kind === 'order' + ? directOrderTarget(reference.metadata, reference.source) + : directRegistrationTarget(reference.metadata, reference.source); + } + if (reference.status !== 'unrelated') return null; + if (!isStripeId(objectId(session), 'cs_')) return null; + + // Older records can predate namespaced metadata. They are safe to resolve + // only when the Session identifier maps to one local record and one type. + const [order, registration] = await Promise.all([ + findOrderBySessionId(session.id), + findRegistrationBySessionId(session.id), + ]); + return targetFromCandidates(order, registration, 'session_query'); +} + +async function resolvePaymentTarget(paymentIntentId, metadata = {}, chargeId = null) { + const reference = classifyMprcReference({ metadata }); + if (reference.status === 'claimed') { + return reference.kind === 'order' + ? directOrderTarget(reference.metadata, reference.source) + : directRegistrationTarget(reference.metadata, reference.source); + } + if (reference.status !== 'unrelated') return null; + + if (isStripeId(paymentIntentId, 'pi_')) { + const [order, registration] = await Promise.all([ + findOrderByPaymentIntent(paymentIntentId), + findRegistrationByPaymentIntent(paymentIntentId), + ]); + const target = targetFromCandidates(order, registration, 'payment_intent_query'); + if (target) return target; + } + + if (isStripeId(chargeId, 'ch_')) { + const [order, registration] = await Promise.all([ + findOrderByChargeId(chargeId), + findRegistrationByChargeId(chargeId), + ]); + return targetFromCandidates(order, registration, 'charge_query'); + } + return null; +} + +async function resolveTarget(event) { + const object = event?.data?.object || {}; + // Never let fallback queries rescue an object that explicitly claims two + // incompatible MPRC targets. A malformed signed object is quarantined, not + // interpreted according to whichever identifier happens to resolve first. + if (classifyMprcReference(object).status === 'malformed') return null; + if (CHECKOUT_EVENT_TYPES.has(event.type)) { + return resolveSessionTarget(object); + } + if (event.type === 'charge.refunded') { + return resolvePaymentTarget( + objectId(object.payment_intent), + object.metadata, + objectId(object), + ); + } + if (DISPUTE_EVENT_TYPES.has(event.type)) { + return resolvePaymentTarget( + objectId(object.payment_intent), + object.metadata, + objectId(object.charge), + ); + } + return null; +} + +function nonNegativeInteger(value, fallback = 0) { + return Number.isSafeInteger(value) && value >= 0 ? value : fallback; +} + +function validateSessionMoney(session, record) { + const expectedSubtotal = record.amountCents; + const expectedCurrency = normalizedCurrency(record.currency); + const actualCurrency = normalizedCurrency(session.currency); + const totalCents = session.amount_total; + const subtotalCents = session.amount_subtotal; + + if (!Number.isSafeInteger(expectedSubtotal) || expectedSubtotal < 0) { + return { ok: false, reason: 'invalid_expected_amount' }; + } + if (!expectedCurrency) { + return { ok: false, reason: 'invalid_expected_currency' }; + } + if (!actualCurrency || actualCurrency !== expectedCurrency) { + return { ok: false, reason: 'currency_mismatch' }; + } + if (!Number.isSafeInteger(totalCents) || totalCents < 0) { + return { ok: false, reason: 'invalid_stripe_total' }; + } + if (!Number.isSafeInteger(subtotalCents) || subtotalCents < 0) { + return { ok: false, reason: 'invalid_stripe_subtotal' }; + } + if (subtotalCents !== expectedSubtotal) { + return { ok: false, reason: 'amount_mismatch' }; + } + if (totalCents !== expectedSubtotal) { + return { ok: false, reason: 'total_mismatch' }; + } + + return { + ok: true, + summary: { + stripeAmountSubtotalCents: subtotalCents, + stripeAmountTotalCents: totalCents, + stripeCurrency: actualCurrency, + stripePaymentStatus: session.payment_status || null, + }, + }; +} + +function validateSessionBinding(session, record) { + if (session.object !== 'checkout.session' || session.mode !== 'payment') { + return { ok: false, reason: 'invalid_checkout_mode' }; + } + if (!isStripeId(objectId(session), 'cs_')) { + return { ok: false, reason: 'invalid_session_id' }; + } + const storedSessionId = record.stripeSessionId || null; + const storedPaymentLinkId = record.stripePaymentLinkId || null; + const sessionPaymentLinkId = objectId(session.payment_link); + const storedPaymentIntentId = record.stripePaymentIntentId || null; + const sessionPaymentIntentId = objectId(session.payment_intent); + + if (storedSessionId && storedSessionId !== session.id) { + return { ok: false, reason: 'session_mismatch' }; + } + if (storedSessionId && !isStripeId(storedSessionId, 'cs_')) { + return { ok: false, reason: 'invalid_stored_session_id' }; + } + if (storedPaymentLinkId && sessionPaymentLinkId + && storedPaymentLinkId !== sessionPaymentLinkId) { + return { ok: false, reason: 'payment_link_mismatch' }; + } + if (!storedSessionId && storedPaymentLinkId + && storedPaymentLinkId !== sessionPaymentLinkId) { + return { ok: false, reason: 'payment_link_mismatch' }; + } + if (!storedSessionId && !storedPaymentLinkId) { + return { ok: false, reason: 'unbound_session' }; + } + if (storedPaymentLinkId && !isStripeId(storedPaymentLinkId, 'plink_')) { + return { ok: false, reason: 'invalid_stored_payment_link_id' }; + } + if (sessionPaymentLinkId && !isStripeId(sessionPaymentLinkId, 'plink_')) { + return { ok: false, reason: 'invalid_payment_link_id' }; + } + if (storedPaymentIntentId && storedPaymentIntentId !== sessionPaymentIntentId) { + return { ok: false, reason: 'payment_intent_mismatch' }; + } + if (storedPaymentIntentId && !isStripeId(storedPaymentIntentId, 'pi_')) { + return { ok: false, reason: 'invalid_stored_payment_intent_id' }; + } + if (sessionPaymentIntentId && !isStripeId(sessionPaymentIntentId, 'pi_')) { + return { ok: false, reason: 'invalid_payment_intent_id' }; + } + return { ok: true }; +} + +function shippingFromSession(session) { + const details = session.shipping_details || session.collected_information?.shipping_details; + if (!details?.address) return null; + return { + line1: details.address.line1 || '', + line2: details.address.line2 || null, + city: details.address.city || '', + state: details.address.state || '', + postalCode: details.address.postal_code || '', + country: details.address.country || '', + recipientName: details.name || null, + }; +} + +function auditPatch({ target, action, note }) { + const prefix = target.kind === 'order' ? 'order.' : ''; + return admin.firestore.FieldValue.arrayUnion( + auditEntry({ action: `${prefix}${action}`, note }), + ); +} + +function reviewTransition({ target, event, reason }) { + return { + outcome: `needs_review:${reason}`, + requiresReview: true, + patch: { + paymentReviewRequired: true, + paymentReviewReason: reason, + lastStripeEventId: event.id, + updatedAt: Timestamp.now(), + auditLog: auditPatch({ + target, + action: 'payment.review_required', + note: `event=${event.id} reason=${reason}`, }), - ), - }); + }, + }; } -async function handleCheckoutCompleted(session) { - if (isMerchSession(session)) { - await handleMerchCheckoutCompleted(session); - return; +function successfulPaymentTransition({ event, session, target, record }) { + const binding = validateSessionBinding(session, record); + if (!binding.ok) return reviewTransition({ target, event, reason: binding.reason }); + + const money = validateSessionMoney(session, record); + if (!money.ok) return reviewTransition({ target, event, reason: money.reason }); + + const paymentIntentId = objectId(session.payment_intent); + const isPaid = session.payment_status === 'paid' + || (session.payment_status === 'no_payment_required' + && money.summary.stripeAmountTotalCents === 0); + const status = record.status; + + if (money.summary.stripeAmountTotalCents > 0 && !paymentIntentId) { + return reviewTransition({ target, event, reason: 'missing_payment_intent' }); + } + + if (!isPaid) { + if (event.type === 'checkout.session.async_payment_succeeded') { + return reviewTransition({ target, event, reason: 'async_success_not_paid' }); + } + if (status === 'fulfilled') { + return reviewTransition({ target, event, reason: 'fulfilled_without_verified_payment' }); + } + if (COMPLETION_TERMINAL_STATUSES.has(status) || status === 'paid') { + return { outcome: `unpaid_ignored:${status}`, patch: null }; + } + return { + outcome: 'awaiting_payment', + patch: { + ...money.summary, + paymentStatus: 'processing', + stripeSessionId: record.stripeSessionId || session.id, + stripePaymentIntentId: paymentIntentId || record.stripePaymentIntentId || null, + lastStripeEventId: event.id, + updatedAt: Timestamp.now(), + auditLog: auditPatch({ + target, + action: 'payment.processing', + note: `event=${event.id}`, + }), + }, + }; + } + + if (status === 'paid') return { outcome: 'already_paid', patch: null }; + if (status === 'partially_refunded' || status === 'refunded') { + return { + outcome: `payment_observed_after_${status}`, + patch: { + ...money.summary, + // Refund state is monotonic; a late completion event supplies missing + // payment evidence but must not downgrade it back to paid. + paymentStatus: record.paymentStatus || status, + stripeSessionId: record.stripeSessionId || session.id, + stripePaymentIntentId: paymentIntentId || record.stripePaymentIntentId || null, + paidAt: record.paidAt || Timestamp.now(), + lastStripeEventId: event.id, + updatedAt: Timestamp.now(), + auditLog: auditPatch({ + target, + action: 'payment.observed_after_refund', + note: `event=${event.id} state=${status}`, + }), + }, + }; + } + if (status === 'fulfilled' || status === 'transferred') { + const requiresReview = status === 'fulfilled'; + return { + outcome: requiresReview + ? 'needs_review:fulfilled_before_payment_confirmation' + : 'payment_observed_after_transferred', + requiresReview, + patch: { + ...money.summary, + paymentStatus: 'paid', + stripeSessionId: record.stripeSessionId || session.id, + stripePaymentIntentId: paymentIntentId || record.stripePaymentIntentId || null, + paidAt: record.paidAt || Timestamp.now(), + paymentReviewRequired: requiresReview, + paymentReviewReason: requiresReview ? 'fulfilled_before_payment_confirmation' : null, + lastStripeEventId: event.id, + updatedAt: Timestamp.now(), + auditLog: auditPatch({ + target, + action: requiresReview + ? 'payment.confirmed_after_fulfillment' + : 'payment.observed_after_transfer', + note: `event=${event.id}`, + }), + }, + }; } - const doc = await findRegistrationBySessionId(session.id); - if (!doc) { - console.warn('Webhook: no registration for session', session.id); - return; + if (status === 'comp') { + return reviewTransition({ target, event, reason: 'unexpected_payment_for_comp' }); + } + if (COMPLETION_TERMINAL_STATUSES.has(status)) { + if (status !== 'cancelled') { + return { outcome: `terminal_state_protected:${status}`, patch: null }; + } + return { + outcome: 'needs_review:paid_after_cancellation', + requiresReview: true, + patch: { + ...money.summary, + paymentStatus: 'paid_after_cancellation', + stripeSessionId: record.stripeSessionId || session.id, + stripePaymentIntentId: paymentIntentId || record.stripePaymentIntentId || null, + paymentReviewRequired: true, + paymentReviewReason: 'paid_after_cancellation', + paymentExceptionAt: Timestamp.now(), + lastStripeEventId: event.id, + updatedAt: Timestamp.now(), + auditLog: auditPatch({ + target, + action: 'payment.received_after_cancellation', + note: `event=${event.id} pi=${paymentIntentId || ''}`, + }), + }, + }; } - const reg = doc.data(); - if (reg.status === 'paid') return; - await doc.ref.update({ + const patch = { + ...money.summary, status: 'paid', - stripePaymentIntentId: session.payment_intent || null, - paidAt: Timestamp.now(), + paymentStatus: 'paid', + stripeSessionId: record.stripeSessionId || session.id, + stripePaymentIntentId: paymentIntentId || record.stripePaymentIntentId || null, + paymentReviewRequired: false, + paymentReviewReason: null, + paidAt: record.paidAt || Timestamp.now(), + lastStripeEventId: event.id, updatedAt: Timestamp.now(), - auditLog: admin.firestore.FieldValue.arrayUnion( - auditEntry({ action: 'payment.completed', note: `pi=${session.payment_intent || ''}` }), - ), - }); + auditLog: auditPatch({ + target, + action: 'payment.completed', + note: `event=${event.id} pi=${paymentIntentId || ''}`, + }), + }; + if (target.kind === 'order') { + const shipping = shippingFromSession(session); + if (shipping) patch.shipping = shipping; + } + return { outcome: 'payment_confirmed', patch }; } -async function handleCheckoutExpired(session) { - const doc = await findRegistrationBySessionId(session.id); - if (!doc) return; - const reg = doc.data(); - if (reg.status !== 'pending') return; - await doc.ref.update({ - status: 'cancelled', - cancelledAt: Timestamp.now(), - updatedAt: Timestamp.now(), - auditLog: admin.firestore.FieldValue.arrayUnion( - auditEntry({ action: 'session.expired' }), - ), - }); +function unsuccessfulSessionTransition({ event, session, target, record, reason }) { + const binding = validateSessionBinding(session, record); + if (!binding.ok) return reviewTransition({ target, event, reason: binding.reason }); + if (record.status !== 'pending') { + return { outcome: `${reason}_ignored:${record.status}`, patch: null }; + } + const paymentIntentId = objectId(session.payment_intent); + return { + outcome: `payment_${reason}`, + patch: { + status: 'cancelled', + paymentStatus: reason, + stripePaymentStatus: session.payment_status || null, + stripeSessionId: record.stripeSessionId || session.id, + stripePaymentIntentId: paymentIntentId || record.stripePaymentIntentId || null, + cancelledAt: record.cancelledAt || Timestamp.now(), + lastStripeEventId: event.id, + updatedAt: Timestamp.now(), + auditLog: auditPatch({ + target, + action: reason === 'expired' ? 'session.expired' : 'payment.async_failed', + note: `event=${event.id}`, + }), + }, + }; +} + +function refundIdsFromCharge(charge) { + const ids = (charge.refunds?.data || []).map((refund) => objectId(refund)).filter(Boolean); + return ids.length + ? admin.firestore.FieldValue.arrayUnion(...ids) + : null; +} + +function disputeStatusRank(status) { + if (DISPUTE_TERMINAL_STATUSES.has(status)) return 30; + if (status === 'under_review' || status === 'warning_under_review') return 20; + if (status === 'needs_response' || status === 'warning_needs_response') return 10; + return 0; } -async function handleChargeRefunded(charge) { - const paymentIntentId = charge.payment_intent; - if (!paymentIntentId) return; - if (charge.metadata?.type === 'merch') { - await handleMerchChargeRefunded(charge); - return; +function validateRefundBinding(charge, record) { + const paymentIntentId = objectId(charge.payment_intent); + const chargeId = objectId(charge); + if (charge.object !== 'charge' + || !isStripeId(chargeId, 'ch_') + || !isStripeId(paymentIntentId, 'pi_')) { + return { ok: false, reason: 'invalid_refund_binding' }; } - const doc = await findRegistrationByPaymentIntent(paymentIntentId); - if (!doc) { - // Might be a merch order whose metadata didn't surface on the charge - const merchDoc = await findOrderByPaymentIntent(paymentIntentId); - if (merchDoc) { - await handleMerchChargeRefunded(charge); - return; - } - console.warn('Webhook: no registration for payment_intent', paymentIntentId); - return; + if (record.stripePaymentIntentId + && !isStripeId(record.stripePaymentIntentId, 'pi_')) { + return { ok: false, reason: 'invalid_stored_payment_intent_id' }; + } + if (record.stripeChargeId && !isStripeId(record.stripeChargeId, 'ch_')) { + return { ok: false, reason: 'invalid_stored_charge_id' }; + } + if (record.stripePaymentIntentId && paymentIntentId + && record.stripePaymentIntentId !== paymentIntentId) { + return { ok: false, reason: 'payment_intent_mismatch' }; + } + if (record.stripeChargeId && record.stripeChargeId !== chargeId) { + return { ok: false, reason: 'charge_mismatch' }; } - const reg = doc.data(); - const amountRefunded = charge.amount_refunded || 0; - const totalAmount = charge.amount || reg.amountCents || 0; - const fullyRefunded = amountRefunded >= totalAmount && totalAmount > 0; - const newStatus = fullyRefunded ? 'refunded' : 'partially_refunded'; + return { ok: true, paymentIntentId, chargeId }; +} - await doc.ref.update({ - status: newStatus, - stripeChargeId: charge.id, +function refundTransition({ event, charge, target, record }) { + const binding = validateRefundBinding(charge, record); + if (!binding.ok) return reviewTransition({ target, event, reason: binding.reason }); + const { paymentIntentId, chargeId } = binding; + const expectedCurrency = normalizedCurrency(record.currency); + const chargeCurrency = normalizedCurrency(charge.currency); + if (!expectedCurrency) { + return reviewTransition({ target, event, reason: 'invalid_expected_currency' }); + } + if (chargeCurrency !== expectedCurrency) { + return reviewTransition({ target, event, reason: 'refund_currency_mismatch' }); + } + const amountRefunded = charge.amount_refunded; + const knownTotal = record.stripeAmountTotalCents ?? record.amountCents; + const totalAmount = charge.amount; + if (!Number.isSafeInteger(knownTotal) || knownTotal <= 0 + || totalAmount !== knownTotal) { + return reviewTransition({ target, event, reason: 'refund_total_mismatch' }); + } + if (!Number.isSafeInteger(amountRefunded) || amountRefunded <= 0 + || !Number.isSafeInteger(totalAmount) || totalAmount <= 0 + || amountRefunded > totalAmount) { + return reviewTransition({ target, event, reason: 'invalid_refund_amount' }); + } + + if ((record.status === 'refunded' || record.paymentStatus === 'refunded') + && amountRefunded < totalAmount) { + return { outcome: 'stale_refund_ignored', patch: null }; + } + + const previouslyRefunded = nonNegativeInteger(record.stripeAmountRefundedCents); + if (amountRefunded < previouslyRefunded) { + const staleRefundIds = refundIdsFromCharge(charge); + return { + outcome: 'stale_refund_ignored', + patch: staleRefundIds ? { + stripeRefundIds: staleRefundIds, + lastStripeEventId: event.id, + updatedAt: Timestamp.now(), + auditLog: auditPatch({ + target, + action: 'refund.stale_ignored', + note: `event=${event.id} refunded=${amountRefunded} current=${previouslyRefunded}`, + }), + } : null, + }; + } + + const fullyRefunded = amountRefunded === totalAmount; + const paymentStatus = fullyRefunded ? 'refunded' : 'partially_refunded'; + if (record.stripeChargeId === chargeId + && record.stripeAmountRefundedCents === amountRefunded + && record.paymentStatus === paymentStatus) { + return { outcome: `already_${paymentStatus}`, patch: null }; + } + const patch = { + paymentStatus, + stripePaymentIntentId: record.stripePaymentIntentId || paymentIntentId, + stripeChargeId: record.stripeChargeId || chargeId, + stripeAmountTotalCents: totalAmount, + stripeAmountRefundedCents: amountRefunded, + paidAt: record.paidAt || ( + Number.isSafeInteger(charge.created) + ? Timestamp.fromMillis(charge.created * 1000) + : Timestamp.now() + ), refundedAt: Timestamp.now(), + lastStripeEventId: event.id, updatedAt: Timestamp.now(), - auditLog: admin.firestore.FieldValue.arrayUnion( - auditEntry({ - action: fullyRefunded ? 'refund.full' : 'refund.partial', - note: `refunded=${amountRefunded} of ${totalAmount}`, - }), - ), - }); + auditLog: auditPatch({ + target, + action: fullyRefunded ? 'refund.full' : 'refund.partial', + note: `event=${event.id} refunded=${amountRefunded} of ${totalAmount}`, + }), + }; + if (!REFUND_STATUS_PRESERVING_STATES.has(record.status)) { + patch.status = paymentStatus; + } + const refundIds = refundIdsFromCharge(charge); + if (refundIds) patch.stripeRefundIds = refundIds; + return { outcome: paymentStatus, patch }; +} + +function validateDisputeBinding(dispute, record) { + const paymentIntentId = objectId(dispute.payment_intent); + const chargeId = objectId(dispute.charge); + const disputeId = objectId(dispute); + if (dispute.object !== 'dispute' + || !isStripeId(disputeId, 'dp_') + || !isStripeId(chargeId, 'ch_') + || (paymentIntentId && !isStripeId(paymentIntentId, 'pi_'))) { + return { ok: false, reason: 'invalid_dispute_binding' }; + } + if (record.stripePaymentIntentId + && !isStripeId(record.stripePaymentIntentId, 'pi_')) { + return { ok: false, reason: 'invalid_stored_payment_intent_id' }; + } + if (record.stripeChargeId && !isStripeId(record.stripeChargeId, 'ch_')) { + return { ok: false, reason: 'invalid_stored_charge_id' }; + } + if (record.stripePaymentIntentId && paymentIntentId + && record.stripePaymentIntentId !== paymentIntentId) { + return { ok: false, reason: 'payment_intent_mismatch' }; + } + if (record.stripeChargeId && record.stripeChargeId !== chargeId) { + return { ok: false, reason: 'charge_mismatch' }; + } + const existingDispute = (record.stripeDisputes || {})[disputeId] || null; + if (existingDispute?.chargeId && existingDispute.chargeId !== chargeId) { + return { ok: false, reason: 'charge_mismatch' }; + } + if (existingDispute?.paymentIntentId && paymentIntentId + && existingDispute.paymentIntentId !== paymentIntentId) { + return { ok: false, reason: 'payment_intent_mismatch' }; + } + return { + ok: true, + paymentIntentId, + chargeId, + disputeId, + existingDispute, + }; } -async function handleDisputeCreated(dispute) { - const piId = dispute.payment_intent; - if (!piId) return; - const doc = await findRegistrationByPaymentIntent(piId); - if (!doc) return; - await doc.ref.update({ +function disputeTransition({ event, dispute, target, record }) { + const binding = validateDisputeBinding(dispute, record); + if (!binding.ok) return reviewTransition({ target, event, reason: binding.reason }); + const { + paymentIntentId, + chargeId, + disputeId, + existingDispute, + } = binding; + const expectedCurrency = normalizedCurrency(record.currency); + const disputeCurrency = normalizedCurrency(dispute.currency); + if (!expectedCurrency) { + return reviewTransition({ target, event, reason: 'invalid_expected_currency' }); + } + if (disputeCurrency !== expectedCurrency) { + return reviewTransition({ target, event, reason: 'dispute_currency_mismatch' }); + } + const knownTotal = record.stripeAmountTotalCents ?? record.amountCents; + if (!Number.isSafeInteger(knownTotal) || knownTotal <= 0) { + return reviewTransition({ target, event, reason: 'invalid_expected_amount' }); + } + if (!Number.isSafeInteger(dispute.amount) + || dispute.amount <= 0 + || dispute.amount > knownTotal) { + return reviewTransition({ target, event, reason: 'invalid_dispute_amount' }); + } + const disputeStatus = dispute.status; + if (!DISPUTE_STATUSES.has(disputeStatus) + || (event.type === 'charge.dispute.closed' + && !DISPUTE_TERMINAL_STATUSES.has(disputeStatus))) { + return reviewTransition({ target, event, reason: 'invalid_dispute_status' }); + } + if (!Number.isSafeInteger(event.created) || event.created < 0) { + return reviewTransition({ target, event, reason: 'invalid_event_created' }); + } + const existingDisputes = record.stripeDisputes || {}; + const eventCreated = event.created; + const existingEventCreated = nonNegativeInteger(existingDispute?.lastEventCreated); + const incomingStatusRank = disputeStatusRank(disputeStatus); + const existingStatusRank = existingDispute + ? nonNegativeInteger( + existingDispute.statusRank, + disputeStatusRank(existingDispute.status), + ) + : 0; + const isDeterministicallyOlder = existingDispute && ( + eventCreated < existingEventCreated + || (eventCreated === existingEventCreated && incomingStatusRank < existingStatusRank) + || (eventCreated === existingEventCreated + && incomingStatusRank === existingStatusRank + && event.id.localeCompare(existingDispute.lastEventId || '') < 0) + ); + if (isDeterministicallyOlder) { + return { outcome: 'stale_dispute_ignored', patch: null }; + } + if (existingDispute + && DISPUTE_TERMINAL_STATUSES.has(existingDispute.status) + && disputeStatus !== existingDispute.status) { + return reviewTransition({ target, event, reason: 'terminal_dispute_regression' }); + } + if (existingDispute + && existingDispute.status === disputeStatus + && eventCreated === existingEventCreated) { + return { outcome: `already_dispute_${disputeStatus}`, patch: null }; + } + const disputeState = { + status: disputeStatus, + statusRank: incomingStatusRank, + amountCents: dispute.amount, + reason: dispute.reason || null, + chargeId, + paymentIntentId, + lastEventId: event.id, + lastEventCreated: eventCreated, updatedAt: Timestamp.now(), - auditLog: admin.firestore.FieldValue.arrayUnion( - auditEntry({ - action: 'dispute.created', - note: `reason=${dispute.reason || ''} amount=${dispute.amount || 0}`, - }), + }; + const shouldUpdateAggregate = eventCreated >= nonNegativeInteger( + record.lastDisputeEventCreated, + ); + const patch = { + stripeDisputeIds: admin.firestore.FieldValue.arrayUnion(disputeId), + stripeDisputes: { ...existingDisputes, [disputeId]: disputeState }, + stripePaymentIntentId: record.stripePaymentIntentId || paymentIntentId || null, + stripeChargeId: record.stripeChargeId || chargeId, + disputedAt: record.disputedAt || Timestamp.now(), + lastStripeEventId: event.id, + updatedAt: Timestamp.now(), + auditLog: auditPatch({ + target, + action: event.type.replace('charge.', ''), + note: `event=${event.id} status=${disputeStatus} amount=${dispute.amount || 0}`, + }), + }; + if (shouldUpdateAggregate) { + patch.disputeStatus = disputeStatus; + patch.stripeDisputeId = disputeId; + patch.disputedAmountCents = disputeState.amountCents; + patch.disputeReason = disputeState.reason; + patch.lastDisputeEventCreated = eventCreated; + } + if (event.type === 'charge.dispute.closed') patch.disputeClosedAt = Timestamp.now(); + return { + outcome: `dispute_${disputeStatus}`, + requiresReview: !DISPUTE_TERMINAL_STATUSES.has(disputeStatus) + || disputeStatus === 'lost', + patch, + }; +} + +function transitionForEvent(event, target, targetSnap, modeValidation, ownership) { + if (!modeValidation.ok) { + return { + outcome: `needs_review:${modeValidation.reason}`, + requiresReview: true, + patch: null, + }; + } + if (!target || !targetSnap?.exists) { + if (event.type === 'charge.refunded') { + return { + outcome: 'needs_review:unmatched_refund', + requiresReview: true, + patch: null, + }; + } + if (DISPUTE_EVENT_TYPES.has(event.type)) { + return { + outcome: 'needs_review:unmatched_dispute', + requiresReview: true, + patch: null, + }; + } + if (isSupportedEventType(event.type) && ownership.status === 'malformed') { + return { + outcome: `needs_review:${ownership.reason}`, + requiresReview: true, + patch: null, + }; + } + return { + outcome: isSupportedEventType(event.type) + ? 'ignored:unmatched_integration_event' + : 'ignored:unsupported_event', + requiresReview: false, + patch: null, + }; + } + + const object = event.data.object; + const record = targetSnap.data(); + switch (event.type) { + case 'checkout.session.completed': + case 'checkout.session.async_payment_succeeded': + return successfulPaymentTransition({ event, session: object, target, record }); + case 'checkout.session.async_payment_failed': + return unsuccessfulSessionTransition({ + event, session: object, target, record, reason: 'failed', + }); + case 'checkout.session.expired': + return unsuccessfulSessionTransition({ + event, session: object, target, record, reason: 'expired', + }); + case 'charge.refunded': + return refundTransition({ event, charge: object, target, record }); + case 'charge.dispute.created': + case 'charge.dispute.updated': + case 'charge.dispute.closed': + return disputeTransition({ event, dispute: object, target, record }); + default: + return { outcome: 'ignored:unsupported_event', patch: null }; + } +} + +function providerBindingIsVerified(event, record) { + const object = event.data.object; + if (CHECKOUT_EVENT_TYPES.has(event.type)) { + return validateSessionBinding(object, record).ok; + } + if (event.type === 'charge.refunded') { + return validateRefundBinding(object, record).ok; + } + if (DISPUTE_EVENT_TYPES.has(event.type)) { + return validateDisputeBinding(object, record).ok; + } + return false; +} + +function ledgerData(event, target, result, ownership) { + const now = Date.now(); + return { + eventId: event.id, + type: event.type, + objectId: objectId(event.data?.object) || null, + livemode: event.livemode === true, + stripeCreatedAt: Number.isSafeInteger(event.created) + ? Timestamp.fromMillis(event.created * 1000) + : null, + status: 'processed', + outcome: result.outcome, + requiresReview: result.requiresReview === true, + targetType: target?.kind || null, + targetPath: target?.ref?.path || null, + targetSource: target?.source || null, + ownership: target ? 'local_match' : ownership.status, + ownershipSource: target?.source || ownership.source || null, + ownershipReason: ownership.reason || null, + processedAt: Timestamp.fromMillis(now), + expiresAt: Timestamp.fromMillis( + now + LEDGER_RETENTION_DAYS * 24 * 60 * 60 * 1000, ), + }; +} + +function bindingRef(db, descriptor) { + return db.collection('stripeObjectBindings') + .doc(`${descriptor.type}:${descriptor.id}`); +} + +function bindingMatchesTarget(snapshot, target) { + if (!snapshot.exists) return true; + const data = snapshot.data() || {}; + return data.targetPath === target.ref.path && data.targetType === target.kind; +} + +async function processEvent(event) { + const db = admin.firestore(); + const eventRef = db.collection('stripeEvents').doc(event.id); + const existing = await eventRef.get(); + if (existing.exists && existing.data()?.status === 'processed') { + return { duplicate: true, outcome: existing.data().outcome }; + } + + const modeValidation = validateExpectedLivemode(event); + const ownership = classifyMprcReference(event.data.object); + const target = modeValidation.ok ? await resolveTarget(event) : null; + if (target) await assertExclusiveProviderOwnership(event, target); + // Checkout creation and the Firestore write are not atomic. A supported + // event carrying a valid MPRC reference can therefore beat its business + // record into Firestore. Do not write a final ledger marker in that case: + // return 5xx and let Stripe redeliver. Unrelated Stripe traffic is handled + // below without retrying forever. + if (modeValidation.ok && isSupportedEventType(event.type) + && ownership.status === 'claimed' && !target) { + throw new Error(`Retryable Stripe target not found for ${event.id}`); + } + let transactionResult = null; + await db.runTransaction(async (tx) => { + const eventSnap = await tx.get(eventRef); + if (eventSnap.exists && eventSnap.data()?.status === 'processed') { + transactionResult = { + duplicate: true, + outcome: eventSnap.data().outcome, + }; + return; + } + const targetSnap = target ? await tx.get(target.ref) : null; + if (modeValidation.ok && isSupportedEventType(event.type) + && target && !targetSnap?.exists) { + throw new Error(`Retryable Stripe target disappeared for ${event.id}`); + } + const bindingDescriptors = target ? providerObjectsForEvent(event) : []; + const bindingRefs = bindingDescriptors.map((descriptor) => bindingRef(db, descriptor)); + const bindingSnaps = await Promise.all(bindingRefs.map((ref) => tx.get(ref))); + const hasBindingConflict = bindingSnaps.some( + (snapshot) => !bindingMatchesTarget(snapshot, target), + ); + const providerBindingVerified = targetSnap?.exists + && providerBindingIsVerified(event, targetSnap.data()); + const result = hasBindingConflict + ? { + outcome: 'needs_review:provider_binding_conflict', + requiresReview: true, + patch: null, + } + : transitionForEvent( + event, + target, + targetSnap, + modeValidation, + ownership, + ); + if (result.patch && targetSnap?.exists) tx.update(target.ref, result.patch); + if (target && !hasBindingConflict && providerBindingVerified) { + bindingSnaps.forEach((snapshot, index) => { + if (!snapshot.exists) { + tx.set(bindingRefs[index], { + providerObjectType: bindingDescriptors[index].type, + providerObjectId: bindingDescriptors[index].id, + targetType: target.kind, + targetPath: target.ref.path, + firstEventId: event.id, + createdAt: Timestamp.now(), + }); + } + }); + } + tx.set(eventRef, ledgerData(event, target, result, ownership)); + transactionResult = { + duplicate: false, + outcome: result.outcome, + requiresReview: result.requiresReview === true, + }; }); + + if (transactionResult?.requiresReview) { + console.error('Stripe event requires review', { + eventId: event.id, + eventType: event.type, + outcome: transactionResult.outcome, + targetType: target?.kind || null, + }); + } + return transactionResult; } exports.stripeWebhook = functions - .runWith({ secrets: ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'] }) + .runWith({ secrets: ['STRIPE_WEBHOOK_SECRET'] }) .https.onRequest(async (request, response) => { if (request.method !== 'POST') { response.status(405).send('Method not allowed'); @@ -207,39 +1243,35 @@ exports.stripeWebhook = functions let event; try { - const stripe = getStripe(); const signature = request.get('stripe-signature'); - event = stripe.webhooks.constructEvent( + event = Stripe.webhooks.constructEvent( request.rawBody, signature, getWebhookSecret(), ); - } catch (err) { - console.error('Webhook signature verification failed', err.message); - response.status(400).send(`Webhook signature error: ${err.message}`); + if (event.object !== 'event' + || !isStripeId(event.id, 'evt_') + || typeof event.type !== 'string' + || !event.data?.object) { + throw new Error('Malformed Stripe event'); + } + } catch { + console.error('Stripe webhook signature verification failed', { + reason: 'invalid_signature_or_payload', + }); + response.status(400).send('Webhook signature error'); return; } try { - switch (event.type) { - case 'checkout.session.completed': - await handleCheckoutCompleted(event.data.object); - break; - case 'checkout.session.expired': - await handleCheckoutExpired(event.data.object); - break; - case 'charge.refunded': - await handleChargeRefunded(event.data.object); - break; - case 'charge.dispute.created': - await handleDisputeCreated(event.data.object); - break; - default: - break; - } - response.json({ received: true }); - } catch (err) { - console.error('Webhook handler error', err); + const result = await processEvent(event); + response.json({ received: true, ...result }); + } catch { + console.error('Stripe webhook handler error', { + eventId: event?.id || null, + eventType: event?.type || null, + reason: 'processing_failed', + }); response.status(500).send('Webhook handler error'); } }); diff --git a/functions/stripeWebhook.test.js b/functions/stripeWebhook.test.js index 768992c9..7eace2cc 100644 --- a/functions/stripeWebhook.test.js +++ b/functions/stripeWebhook.test.js @@ -1,122 +1,1715 @@ const crypto = require('crypto'); jest.mock('firebase-admin', () => { - const update = jest.fn().mockResolvedValue(); - const docRef = { update }; - const regDocSnap = { - ref: docRef, - data: () => ({ status: 'pending', amountCents: 5000 }), - }; - const query = { - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - get: jest.fn().mockResolvedValue({ - empty: false, - docs: [regDocSnap], - }), + const store = new Map(); + + const FieldValue = { + arrayUnion: (...values) => ({ __op: 'arrayUnion', values }), }; + + function getField(data, fieldPath) { + return fieldPath.split('.').reduce((value, key) => value?.[key], data); + } + + function applyPatch(current, patch, replace = false) { + const next = replace ? {} : { ...(current || {}) }; + Object.entries(patch).forEach(([key, value]) => { + if (value?.__op === 'arrayUnion') { + const existing = Array.isArray(next[key]) ? next[key] : []; + next[key] = [...existing, ...value.values]; + } else { + next[key] = value; + } + }); + return next; + } + + class FakeDocumentSnapshot { + constructor(ref) { + this.ref = ref; + this.id = ref.id; + this.exists = store.has(ref.path); + } + + data() { + return store.get(this.ref.path); + } + } + + class FakeDocumentReference { + constructor(path) { + this.path = path; + this.id = path.split('/').at(-1); + } + + collection(name) { + return new FakeCollectionReference(`${this.path}/${name}`); + } + + async get() { + return new FakeDocumentSnapshot(this); + } + + async set(data) { + store.set(this.path, applyPatch(null, data, true)); + } + + async update(patch) { + if (!store.has(this.path)) throw new Error(`Missing document: ${this.path}`); + store.set(this.path, applyPatch(store.get(this.path), patch)); + } + } + + class FakeQuery { + constructor({ collectionPath = null, collectionGroup = null, filters = [], max = null }) { + this.collectionPath = collectionPath; + this.collectionGroup = collectionGroup; + this.filters = filters; + this.max = max; + } + + where(field, operator, value) { + if (operator !== '==') throw new Error(`Unsupported operator: ${operator}`); + return new FakeQuery({ + collectionPath: this.collectionPath, + collectionGroup: this.collectionGroup, + filters: [...this.filters, { field, value }], + max: this.max, + }); + } + + limit(max) { + return new FakeQuery({ + collectionPath: this.collectionPath, + collectionGroup: this.collectionGroup, + filters: this.filters, + max, + }); + } + + async get() { + let docs = Array.from(store.keys()).filter((path) => { + const parts = path.split('/'); + if (this.collectionGroup) { + return parts.length >= 2 && parts.at(-2) === this.collectionGroup; + } + const collectionParts = this.collectionPath.split('/'); + return parts.length === collectionParts.length + 1 + && path.startsWith(`${this.collectionPath}/`); + }).map((path) => new FakeDocumentSnapshot(new FakeDocumentReference(path))); + + docs = docs.filter((doc) => this.filters.every(({ field, value }) => ( + getField(doc.data(), field) === value + ))); + if (this.max !== null) docs = docs.slice(0, this.max); + return { empty: docs.length === 0, docs, size: docs.length }; + } + } + + class FakeCollectionReference extends FakeQuery { + constructor(path) { + super({ collectionPath: path }); + this.path = path; + } + + doc(id) { + return new FakeDocumentReference(`${this.path}/${id}`); + } + } + const firestore = { - collectionGroup: jest.fn(() => query), + collection: (name) => new FakeCollectionReference(name), + collectionGroup: (name) => new FakeQuery({ collectionGroup: name }), + runTransaction: async (callback) => { + const writes = []; + const tx = { + get: (ref) => ref.get(), + update: (ref, patch) => writes.push({ kind: 'update', ref, patch }), + set: (ref, data) => writes.push({ kind: 'set', ref, data }), + }; + const result = await callback(tx); + writes.forEach((write) => { + if (write.kind === 'set') { + store.set(write.ref.path, applyPatch(null, write.data, true)); + } else { + if (!store.has(write.ref.path)) throw new Error(`Missing document: ${write.ref.path}`); + store.set( + write.ref.path, + applyPatch(store.get(write.ref.path), write.patch), + ); + } + }); + return result; + }, }; - firestore.FieldValue = { arrayUnion: (x) => x }; + return { initializeApp: jest.fn(), apps: [{}], - firestore: Object.assign(() => firestore, { FieldValue: firestore.FieldValue }), - __mockRef: docRef, + firestore: Object.assign(() => firestore, { FieldValue }), + __clear: () => store.clear(), + __seed: (path, data) => store.set(path, { ...data }), + __get: (path) => store.get(path), }; }); jest.mock('firebase-functions', () => { - const chain = { - https: { - onRequest: (fn) => fn, - onCall: (fn) => fn, - HttpsError: class HttpsError extends Error { - constructor(code, message) { - super(message); - this.code = code; - } - }, + const https = { + onRequest: (fn) => fn, + onCall: (fn) => fn, + HttpsError: class HttpsError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } }, }; - const config = () => ({}); return { - runWith: () => chain, - https: chain.https, - config, + runWith: () => ({ https }), + https, + config: () => ({}), + }; +}); + +jest.mock('firebase-admin/firestore', () => { + let tick = 1_800_000_000_000; + return { + Timestamp: { + now: () => ({ _milliseconds: tick += 1 }), + fromMillis: (milliseconds) => ({ _milliseconds: milliseconds }), + }, + FieldValue: { arrayUnion: (...values) => ({ __op: 'arrayUnion', values }) }, }; }); -jest.mock('firebase-admin/firestore', () => ({ - Timestamp: { now: () => ({ _seconds: Math.floor(Date.now() / 1000) }) }, - FieldValue: { arrayUnion: (x) => x }, -})); +const admin = require('firebase-admin'); + +const WEBHOOK_SECRET = 'whsec_testsecret'; +process.env.STRIPE_SECRET_KEY = 'sk_test_testing'; +process.env.STRIPE_WEBHOOK_SECRET = WEBHOOK_SECRET; + +const { stripeWebhook } = require('./stripeWebhook'); + +function stripeEvent(id, type, object) { + return { + id, + object: 'event', + api_version: '2023-10-16', + created: 1_800_000_000, + livemode: false, + pending_webhooks: 1, + request: null, + type, + data: { object }, + }; +} + +function registrationSession(overrides = {}) { + return { + id: 'cs_reg_1', + object: 'checkout.session', + mode: 'payment', + metadata: { + eventId: 'race-1', + registrationId: 'reg-1', + priceTier: 'nonMember', + }, + payment_status: 'paid', + amount_subtotal: 5000, + amount_total: 5000, + currency: 'usd', + payment_intent: 'pi_reg_1', + ...overrides, + }; +} + +function orderSession(overrides = {}) { + return { + id: 'cs_order_1', + object: 'checkout.session', + mode: 'payment', + metadata: { + type: 'merch', + orderId: 'order-1', + productSlug: 'hat', + }, + payment_status: 'paid', + amount_subtotal: 2000, + amount_total: 2000, + currency: 'usd', + payment_intent: 'pi_order_1', + shipping_details: { + name: 'Buyer Name', + address: { + line1: '1 Main St', + line2: null, + city: 'San Mateo', + state: 'CA', + postal_code: '94401', + country: 'US', + }, + }, + ...overrides, + }; +} + +function seedRegistration(overrides = {}) { + admin.__seed('events/race-1/registrations/reg-1', { + eventId: 'race-1', + runner: { email: 'runner@example.com' }, + amountCents: 5000, + currency: 'usd', + status: 'pending', + stripeSessionId: 'cs_reg_1', + stripePaymentIntentId: null, + stripeRefundIds: [], + auditLog: [], + ...overrides, + }); +} + +function seedOrder(overrides = {}) { + admin.__seed('orders/order-1', { + productSlug: 'hat', + buyer: { email: 'buyer@example.com' }, + amountCents: 2000, + currency: 'usd', + status: 'pending', + stripeSessionId: 'cs_order_1', + stripePaymentIntentId: null, + stripeRefundIds: [], + auditLog: [], + ...overrides, + }); +} + +function signedRequest(event, signatureOverride) { + const body = JSON.stringify(event); + const timestamp = Math.floor(Date.now() / 1000); + const signature = crypto + .createHmac('sha256', WEBHOOK_SECRET) + .update(`${timestamp}.${body}`, 'utf8') + .digest('hex'); + const header = signatureOverride || `t=${timestamp},v1=${signature}`; + return { + method: 'POST', + rawBody: Buffer.from(body), + get: (name) => (name.toLowerCase() === 'stripe-signature' ? header : undefined), + }; +} + +function mockResponse() { + return { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; +} + +async function deliver(event) { + const response = mockResponse(); + await stripeWebhook(signedRequest(event), response); + return response; +} describe('stripeWebhook', () => { - const WEBHOOK_SECRET = 'whsec_testsecret'; - const STRIPE_KEY = 'sk_test_testing'; + let consoleError; + + beforeAll(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); beforeEach(() => { - process.env.STRIPE_SECRET_KEY = STRIPE_KEY; - process.env.STRIPE_WEBHOOK_SECRET = WEBHOOK_SECRET; - jest.resetModules(); - }); - - function signedRequest(payload) { - const body = JSON.stringify(payload); - const timestamp = Math.floor(Date.now() / 1000); - const signed = `${timestamp}.${body}`; - const signature = crypto - .createHmac('sha256', WEBHOOK_SECRET) - .update(signed, 'utf8') - .digest('hex'); - return { - body, - rawBody: Buffer.from(body), - headers: { 'stripe-signature': `t=${timestamp},v1=${signature}` }, + admin.__clear(); + process.env.STRIPE_LIVEMODE_EXPECTED = 'false'; + consoleError.mockClear(); + }); + + 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(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_missing_mode_config')).toBeUndefined(); + }); + + afterAll(() => { + consoleError.mockRestore(); + }); + + test('rejects a missing or invalid signature', async () => { + 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('acknowledges and deduplicates unsupported event types', async () => { + const event = stripeEvent('evt_unsupported', 'customer.created', { + id: 'cus_1', + object: 'customer', + }); + + 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, + }); + expect(replayResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + duplicate: true, + })); + }); + + test('acknowledges an unrelated supported event without retrying forever', async () => { + const event = stripeEvent( + 'evt_other_checkout', + 'checkout.session.completed', + registrationSession({ + id: 'cs_other_integration', + metadata: { integration: 'another_application' }, + }), + ); + + const firstResponse = await deliver(event); + const replayResponse = await deliver(event); + + expect(firstResponse.status).not.toHaveBeenCalled(); + expect(firstResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: false, + outcome: 'ignored:unmatched_integration_event', + })); + expect(admin.__get('stripeEvents/evt_other_checkout')).toMatchObject({ + status: 'processed', + outcome: 'ignored:unmatched_integration_event', + requiresReview: false, + ownership: 'unrelated', + }); + expect(replayResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + duplicate: true, + })); + }); + + test('quarantines a malformed claimed MPRC reference without endless retries', async () => { + const event = stripeEvent( + 'evt_malformed_reference', + 'checkout.session.completed', + registrationSession({ + id: 'cs_malformed_reference', + metadata: { eventId: 'race-1' }, + }), + ); + + const response = await deliver(event); + + expect(response.status).not.toHaveBeenCalled(); + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: false, + outcome: 'needs_review:invalid_registration_reference', + requiresReview: true, + })); + expect(admin.__get('stripeEvents/evt_malformed_reference')).toMatchObject({ + status: 'processed', + requiresReview: true, + ownership: 'malformed', + ownershipReason: 'invalid_registration_reference', + }); + }); + + test('never resolves conflicting order and registration metadata', async () => { + seedOrder(); + seedRegistration(); + const event = stripeEvent( + 'evt_conflicting_reference', + 'checkout.session.completed', + orderSession({ + metadata: { + type: 'merch', + orderId: 'order-1', + eventId: 'race-1', + registrationId: 'reg-1', + }, + }), + ); + + await deliver(event); + + expect(admin.__get('orders/order-1').status).toBe('pending'); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_conflicting_reference')).toMatchObject({ + outcome: 'needs_review:conflicting_reference', + requiresReview: true, + targetPath: null, + ownership: 'malformed', + }); + }); + + test('never resolves metadata that conflicts with the client reference', async () => { + seedOrder(); + seedRegistration(); + const event = stripeEvent( + 'evt_conflicting_claim_sources', + 'checkout.session.completed', + orderSession({ + client_reference_id: 'mprc:registration:race-1:reg-1', + }), + ); + + const response = await deliver(event); + + expect(response.status).not.toHaveBeenCalled(); + expect(admin.__get('orders/order-1').status).toBe('pending'); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_conflicting_claim_sources')).toMatchObject({ + outcome: 'needs_review:conflicting_reference', + requiresReview: true, + targetPath: null, + }); + }); + + test('retries a missing claimed target without falling back to another Session owner', async () => { + seedRegistration({ stripeSessionId: 'cs_claimed_missing' }); + const event = stripeEvent( + 'evt_claimed_target_missing', + 'checkout.session.completed', + registrationSession({ + id: 'cs_claimed_missing', + metadata: { eventId: 'race-missing', registrationId: 'reg-missing' }, + }), + ); + + const response = await deliver(event); + + expect(response.status).toHaveBeenCalledWith(500); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_claimed_target_missing')).toBeUndefined(); + }); + + test('fails closed when a direct target conflicts with another Session owner', async () => { + seedRegistration(); + seedOrder({ stripeSessionId: 'cs_reg_1' }); + + const response = await deliver(stripeEvent( + 'evt_direct_session_conflict', + 'checkout.session.completed', + registrationSession(), + )); + + expect(response.status).toHaveBeenCalledWith(500); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('orders/order-1').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_direct_session_conflict')).toBeUndefined(); + }); + + test('resolves a namespaced MPRC client reference', async () => { + seedRegistration(); + await deliver(stripeEvent( + 'evt_client_reference', + 'checkout.session.completed', + registrationSession({ + metadata: {}, + client_reference_id: 'mprc:registration:race-1:reg-1', + }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('paid'); + expect(admin.__get('stripeEvents/evt_client_reference')).toMatchObject({ + outcome: 'payment_confirmed', + ownership: 'local_match', + targetSource: 'client_reference_id', + }); + }); + + test('quarantines a configured livemode mismatch without changing the target', async () => { + process.env.STRIPE_LIVEMODE_EXPECTED = 'true'; + seedRegistration(); + const event = stripeEvent( + 'evt_wrong_mode', + 'checkout.session.completed', + registrationSession(), + ); + + const response = await deliver(event); + const registration = admin.__get('events/race-1/registrations/reg-1'); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: false, + outcome: 'needs_review:livemode_mismatch', + requiresReview: true, + })); + expect(registration.status).toBe('pending'); + expect(registration.auditLog).toEqual([]); + expect(admin.__get('stripeEvents/evt_wrong_mode')).toMatchObject({ + status: 'processed', + outcome: 'needs_review:livemode_mismatch', + requiresReview: true, + targetPath: null, + }); + }); + + test('confirms a paid registration and records actual Stripe totals atomically', async () => { + seedRegistration(); + const event = stripeEvent( + 'evt_reg_paid', + 'checkout.session.completed', + registrationSession({ + amount_total: 5000, + }), + ); + + const response = await deliver(event); + const registration = admin.__get('events/race-1/registrations/reg-1'); + const ledger = admin.__get('stripeEvents/evt_reg_paid'); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: false, + outcome: 'payment_confirmed', + })); + expect(registration).toMatchObject({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_reg_1', + stripeAmountSubtotalCents: 5000, + stripeAmountTotalCents: 5000, + stripeCurrency: 'usd', + }); + expect(registration.auditLog).toHaveLength(1); + expect(ledger).toMatchObject({ + status: 'processed', + outcome: 'payment_confirmed', + targetType: 'registration', + targetPath: 'events/race-1/registrations/reg-1', + targetSource: 'metadata', + }); + expect(admin.__get('stripeObjectBindings/checkout_session:cs_reg_1')).toMatchObject({ + targetType: 'registration', + targetPath: 'events/race-1/registrations/reg-1', + firstEventId: 'evt_reg_paid', + }); + expect(admin.__get('stripeObjectBindings/payment_intent:pi_reg_1')).toMatchObject({ + targetType: 'registration', + targetPath: 'events/race-1/registrations/reg-1', + }); + }); + + test('quarantines a non-payment Checkout Session', async () => { + seedRegistration(); + + await deliver(stripeEvent( + 'evt_wrong_checkout_mode', + 'checkout.session.completed', + registrationSession({ mode: 'setup' }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'pending', + paymentReviewRequired: true, + paymentReviewReason: 'invalid_checkout_mode', + }); + }); + + test('records paid evidence and flags an order fulfilled before confirmation', async () => { + seedOrder({ status: 'fulfilled', paymentStatus: null }); + + await deliver(stripeEvent( + 'evt_paid_after_fulfilled', + 'checkout.session.completed', + orderSession(), + )); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'fulfilled', + paymentStatus: 'paid', + paymentReviewRequired: true, + paymentReviewReason: 'fulfilled_before_payment_confirmation', + stripeAmountTotalCents: 2000, + }); + expect(admin.__get('stripeEvents/evt_paid_after_fulfilled')).toMatchObject({ + outcome: 'needs_review:fulfilled_before_payment_confirmation', + requiresReview: true, + }); + }); + + test('confirms a paid merchandise order and captures shipping', async () => { + seedOrder(); + await deliver(stripeEvent( + 'evt_order_paid', + 'checkout.session.completed', + orderSession(), + )); + + const order = admin.__get('orders/order-1'); + expect(order).toMatchObject({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + stripeAmountSubtotalCents: 2000, + stripeAmountTotalCents: 2000, + shipping: { + line1: '1 Main St', + city: 'San Mateo', + postalCode: '94401', + country: 'US', + recipientName: 'Buyer Name', + }, + }); + }); + + test('does not mark an unpaid completed Session paid', async () => { + seedRegistration(); + const event = stripeEvent( + 'evt_reg_unpaid', + 'checkout.session.completed', + registrationSession({ payment_status: 'unpaid' }), + ); + + const response = await deliver(event); + const registration = admin.__get('events/race-1/registrations/reg-1'); + + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + outcome: 'awaiting_payment', + })); + expect(registration.status).toBe('pending'); + expect(registration.paymentStatus).toBe('processing'); + expect(registration.paidAt).toBeUndefined(); + }); + + test('confirms a delayed payment only after async success', async () => { + seedRegistration(); + await deliver(stripeEvent( + 'evt_async_started', + 'checkout.session.completed', + registrationSession({ payment_status: 'unpaid' }), + )); + await deliver(stripeEvent( + 'evt_async_succeeded', + 'checkout.session.async_payment_succeeded', + registrationSession({ payment_status: 'paid' }), + )); + + const registration = admin.__get('events/race-1/registrations/reg-1'); + expect(registration.status).toBe('paid'); + expect(registration.paymentStatus).toBe('paid'); + expect(registration.auditLog).toHaveLength(2); + }); + + test('quarantines a same-Session event that changes the bound PaymentIntent', async () => { + seedRegistration({ stripePaymentIntentId: 'pi_original' }); + + await deliver(stripeEvent( + 'evt_changed_payment_intent', + 'checkout.session.async_payment_succeeded', + registrationSession({ payment_intent: 'pi_replacement' }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'pending', + stripePaymentIntentId: 'pi_original', + paymentReviewRequired: true, + paymentReviewReason: 'payment_intent_mismatch', + }); + expect(admin.__get('stripeEvents/evt_changed_payment_intent')).toMatchObject({ + outcome: 'needs_review:payment_intent_mismatch', + requiresReview: true, + }); + }); + + test('cancels pending orders after an async payment failure', async () => { + seedOrder(); + await deliver(stripeEvent( + 'evt_async_failed', + 'checkout.session.async_payment_failed', + orderSession({ payment_status: 'unpaid' }), + )); + + const order = admin.__get('orders/order-1'); + expect(order.status).toBe('cancelled'); + expect(order.paymentStatus).toBe('failed'); + expect(order.cancelledAt).toBeDefined(); + }); + + test.each([ + ['failure', 'checkout.session.async_payment_failed', 'failed'], + ['expiry', 'checkout.session.expired', 'expired'], + ])('does not let an async %s event undo verified payment', async (_label, type, reason) => { + seedRegistration(); + await deliver(stripeEvent( + 'evt_paid_before_unsuccessful', + 'checkout.session.completed', + registrationSession(), + )); + await deliver(stripeEvent( + `evt_${reason}_after_paid`, + type, + registrationSession({ payment_status: 'unpaid' }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'paid', + paymentStatus: 'paid', + }); + expect(admin.__get(`stripeEvents/evt_${reason}_after_paid`)).toMatchObject({ + outcome: `${reason}_ignored:paid`, + }); + }); + + test.each([ + ['failure', 'checkout.session.async_payment_failed', 'failed'], + ['expiry', 'checkout.session.expired', 'expired'], + ])('does not resurrect an async %s cancellation after late success', async (_label, type, reason) => { + seedRegistration(); + await deliver(stripeEvent( + `evt_${reason}_before_success`, + type, + registrationSession({ payment_status: 'unpaid' }), + )); + await deliver(stripeEvent( + `evt_success_after_${reason}`, + 'checkout.session.async_payment_succeeded', + registrationSession(), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'cancelled', + paymentStatus: 'paid_after_cancellation', + paymentReviewReason: 'paid_after_cancellation', + }); + expect(admin.__get(`stripeEvents/evt_success_after_${reason}`)).toMatchObject({ + outcome: 'needs_review:paid_after_cancellation', + requiresReview: true, + }); + }); + + test('deduplicates replayed events without adding a second audit entry', async () => { + seedRegistration(); + const event = stripeEvent( + 'evt_duplicate', + 'checkout.session.completed', + registrationSession(), + ); + + await deliver(event); + const secondResponse = await deliver(event); + + const registration = admin.__get('events/race-1/registrations/reg-1'); + expect(registration.auditLog).toHaveLength(1); + expect(secondResponse.json).toHaveBeenCalledWith(expect.objectContaining({ + received: true, + duplicate: true, + outcome: 'payment_confirmed', + })); + }); + + test.each([ + ['amount', { amount_subtotal: 4999, amount_total: 4999 }, 'amount_mismatch'], + ['total', { amount_total: 4999 }, 'total_mismatch'], + ['currency', { currency: 'cad' }, 'currency_mismatch'], + ])('quarantines a %s mismatch without confirming payment', async (_label, patch, reason) => { + seedRegistration(); + const eventId = `evt_${reason}`; + await deliver(stripeEvent( + eventId, + 'checkout.session.completed', + registrationSession(patch), + )); + + const registration = admin.__get('events/race-1/registrations/reg-1'); + const ledger = admin.__get(`stripeEvents/${eventId}`); + expect(registration.status).toBe('pending'); + expect(registration).toMatchObject({ + paymentReviewRequired: true, + paymentReviewReason: reason, + }); + expect(ledger).toMatchObject({ + status: 'processed', + requiresReview: true, + outcome: `needs_review:${reason}`, + }); + }); + + test.each([ + ['missing stored currency', { currency: undefined }, {}, 'invalid_expected_currency'], + ['missing Stripe subtotal', {}, { amount_subtotal: null }, 'invalid_stripe_subtotal'], + ])('quarantines %s instead of inferring money fields', async ( + _label, + recordPatch, + sessionPatch, + reason, + ) => { + seedRegistration(recordPatch); + await deliver(stripeEvent( + `evt_${reason}`, + 'checkout.session.completed', + registrationSession(sessionPatch), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'pending', + paymentReviewRequired: true, + paymentReviewReason: reason, + }); + }); + + test('does not resurrect a cancelled registration when payment arrives', async () => { + seedRegistration({ status: 'cancelled' }); + await deliver(stripeEvent( + 'evt_paid_after_cancel', + 'checkout.session.completed', + registrationSession(), + )); + + const registration = admin.__get('events/race-1/registrations/reg-1'); + expect(registration.status).toBe('cancelled'); + expect(registration).toMatchObject({ + paymentStatus: 'paid_after_cancellation', + paymentReviewRequired: true, + paymentReviewReason: 'paid_after_cancellation', + }); + }); + + test('preserves fulfillment but records and flags a late payment confirmation', async () => { + seedOrder({ status: 'fulfilled' }); + const response = await deliver(stripeEvent( + 'evt_fulfilled_replay', + 'checkout.session.completed', + orderSession(), + )); + + const order = admin.__get('orders/order-1'); + expect(order.status).toBe('fulfilled'); + expect(order.paymentStatus).toBe('paid'); + expect(order.paymentReviewRequired).toBe(true); + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ + outcome: 'needs_review:fulfilled_before_payment_confirmation', + requiresReview: true, + })); + }); + + test('expires pending registrations and orders', async () => { + seedRegistration(); + seedOrder(); + + await deliver(stripeEvent( + 'evt_reg_expired', + 'checkout.session.expired', + registrationSession({ payment_status: 'unpaid', payment_intent: null }), + )); + await deliver(stripeEvent( + 'evt_order_expired', + 'checkout.session.expired', + orderSession({ payment_status: 'unpaid', payment_intent: null }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'cancelled', + paymentStatus: 'expired', + }); + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'cancelled', + paymentStatus: 'expired', + }); + }); + + test('falls back to the legacy Session query when metadata is absent', async () => { + seedRegistration(); + await deliver(stripeEvent( + 'evt_legacy_session', + 'checkout.session.completed', + registrationSession({ metadata: {} }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('paid'); + expect(admin.__get('stripeEvents/evt_legacy_session')).toMatchObject({ + targetSource: 'session_query', + targetType: 'registration', + }); + }); + + test('fails closed when two registrations own the same legacy Session', async () => { + seedRegistration(); + admin.__seed('events/race-2/registrations/reg-2', { + amountCents: 5000, + currency: 'usd', + status: 'pending', + stripeSessionId: 'cs_reg_1', + auditLog: [], + }); + + const response = await deliver(stripeEvent( + 'evt_duplicate_registration_session', + 'checkout.session.completed', + registrationSession({ metadata: {} }), + )); + + expect(response.status).toHaveBeenCalledWith(500); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('events/race-2/registrations/reg-2').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_duplicate_registration_session')).toBeUndefined(); + }); + + test('fails closed when an order and registration own the same legacy Session', async () => { + seedRegistration(); + seedOrder({ stripeSessionId: 'cs_reg_1' }); + + const response = await deliver(stripeEvent( + 'evt_cross_type_session', + 'checkout.session.completed', + registrationSession({ metadata: {} }), + )); + + expect(response.status).toHaveBeenCalledWith(500); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('orders/order-1').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_cross_type_session')).toBeUndefined(); + }); + + test('durably quarantines a transaction-time provider binding conflict', async () => { + seedRegistration(); + admin.__seed('stripeObjectBindings/checkout_session:cs_reg_1', { + providerObjectType: 'checkout_session', + providerObjectId: 'cs_reg_1', + targetType: 'order', + targetPath: 'orders/order-other', + }); + + const response = await deliver(stripeEvent( + 'evt_binding_conflict', + 'checkout.session.completed', + registrationSession(), + )); + + expect(response.status).not.toHaveBeenCalled(); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_binding_conflict')).toMatchObject({ + outcome: 'needs_review:provider_binding_conflict', + requiresReview: true, + }); + }); + + test('binds the first Session created by a matching legacy Payment Link', async () => { + seedRegistration({ stripeSessionId: null, stripePaymentLinkId: 'plink_late_1' }); + await deliver(stripeEvent( + 'evt_payment_link', + 'checkout.session.completed', + registrationSession({ + id: 'cs_from_link_1', + payment_link: 'plink_late_1', + metadata: { + eventId: 'race-1', + registrationId: 'reg-1', + late_add: 'true', + }, + }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'paid', + stripeSessionId: 'cs_from_link_1', + }); + }); + + test.each([ + ['Session', { stripeSessionId: 'cs_other' }, {}, 'session_mismatch'], + [ + 'Payment Link', + { stripeSessionId: null, stripePaymentLinkId: 'plink_expected' }, + { payment_link: 'plink_other' }, + 'payment_link_mismatch', + ], + [ + 'unbound Session', + { stripeSessionId: null, stripePaymentLinkId: null }, + {}, + 'unbound_session', + ], + ])('quarantines a %s binding failure', async (_label, recordPatch, sessionPatch, reason) => { + seedRegistration(recordPatch); + + await deliver(stripeEvent( + `evt_${reason}`, + 'checkout.session.completed', + registrationSession(sessionPatch), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'pending', + paymentReviewRequired: true, + paymentReviewReason: reason, + }); + expect(admin.__get(`stripeEvents/evt_${reason}`)).toMatchObject({ + outcome: `needs_review:${reason}`, + }); + }); + + test('retries a missing claimed refund target without falling back by PaymentIntent', async () => { + seedRegistration({ stripePaymentIntentId: 'pi_shared_refund' }); + const charge = { + id: 'ch_claimed_missing', + object: 'charge', + payment_intent: 'pi_shared_refund', + amount: 5000, + amount_refunded: 500, + currency: 'usd', + metadata: { type: 'merch', orderId: 'order-missing' }, }; - } - function mockResponse() { - return { - status: jest.fn().mockReturnThis(), - send: jest.fn().mockReturnThis(), - json: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), + const response = await deliver(stripeEvent( + 'evt_claimed_refund_missing', + 'charge.refunded', + charge, + )); + + expect(response.status).toHaveBeenCalledWith(500); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_claimed_refund_missing')).toBeUndefined(); + }); + + test('fails closed when a claimed refund target conflicts with another PI owner', async () => { + seedOrder({ status: 'paid', paymentStatus: 'paid' }); + seedRegistration({ stripePaymentIntentId: 'pi_shared_refund' }); + const response = await deliver(stripeEvent( + 'evt_direct_refund_pi_conflict', + 'charge.refunded', + { + id: 'ch_shared_refund', + object: 'charge', + payment_intent: 'pi_shared_refund', + amount: 2000, + amount_refunded: 500, + currency: 'usd', + metadata: { type: 'merch', orderId: 'order-1' }, + }, + )); + + expect(response.status).toHaveBeenCalledWith(500); + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'paid', + paymentStatus: 'paid', + }); + expect(admin.__get('events/race-1/registrations/reg-1').status).toBe('pending'); + expect(admin.__get('stripeEvents/evt_direct_refund_pi_conflict')).toBeUndefined(); + }); + + test('durably flags an unmatched refund for officer review', async () => { + const charge = { + id: 'ch_unmatched_refund', + object: 'charge', + payment_intent: 'pi_unmatched_refund', + amount: 5000, + amount_refunded: 500, + currency: 'usd', + metadata: {}, }; - } - test('rejects request with no signature', async () => { - const { stripeWebhook } = require('./stripeWebhook'); - const req = { - method: 'POST', - rawBody: Buffer.from('{}'), - get: () => undefined, + const response = await deliver(stripeEvent( + 'evt_unmatched_refund', + 'charge.refunded', + charge, + )); + + expect(response.status).not.toHaveBeenCalled(); + expect(admin.__get('stripeEvents/evt_unmatched_refund')).toMatchObject({ + outcome: 'needs_review:unmatched_refund', + requiresReview: true, + targetPath: null, + }); + }); + + test.each([ + ['charge', { id: 'ch_replacement' }, { stripeChargeId: 'ch_bound' }, 'charge_mismatch'], + ['currency', { currency: 'cad' }, {}, 'refund_currency_mismatch'], + ['total', { amount: 1999 }, {}, 'refund_total_mismatch'], + ['stored currency', {}, { currency: undefined }, 'invalid_expected_currency'], + ])('quarantines a refund %s mismatch', async (_label, chargePatch, recordPatch, reason) => { + seedOrder({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + stripeAmountTotalCents: 2000, + ...recordPatch, + }); + const charge = { + id: 'ch_order_1', + object: 'charge', + payment_intent: 'pi_order_1', + amount: 2000, + amount_refunded: 500, + currency: 'usd', + metadata: { type: 'merch', orderId: 'order-1' }, + refunds: { data: [{ id: 're_order_1' }] }, + ...chargePatch, + }; + + await deliver(stripeEvent(`evt_refund_${reason}`, 'charge.refunded', charge)); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'paid', + paymentStatus: 'paid', + paymentReviewRequired: true, + paymentReviewReason: reason, + }); + expect(admin.__get(`stripeEvents/evt_refund_${reason}`)).toMatchObject({ + outcome: `needs_review:${reason}`, + requiresReview: true, + }); + }); + + test('applies merchandise refunds idempotently and preserves fulfillment state', async () => { + seedOrder({ + status: 'fulfilled', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + }); + const charge = { + id: 'ch_order_1', + object: 'charge', + payment_intent: 'pi_order_1', + amount: 2000, + currency: 'usd', + amount_refunded: 500, + metadata: { type: 'merch', orderId: 'order-1' }, + refunds: { data: [{ id: 're_order_1' }] }, + }; + const event = stripeEvent('evt_order_refund', 'charge.refunded', charge); + + await deliver(event); + await deliver(event); + await deliver(stripeEvent( + 'evt_order_refund_duplicate_object', + 'charge.refunded', + charge, + )); + + const order = admin.__get('orders/order-1'); + expect(order).toMatchObject({ + status: 'fulfilled', + paymentStatus: 'partially_refunded', + stripeAmountRefundedCents: 500, + stripeChargeId: 'ch_order_1', + stripeRefundIds: ['re_order_1'], + }); + expect(order.auditLog).toHaveLength(1); + expect(admin.__get('stripeEvents/evt_order_refund_duplicate_object')).toMatchObject({ + outcome: 'already_partially_refunded', + }); + }); + + test.each(['cancelled', 'comp', 'fulfilled', 'transferred'])( + 'preserves terminal %s status while recording a refund', + async (status) => { + seedOrder({ + status, + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + stripeChargeId: 'ch_order_1', + }); + await deliver(stripeEvent(`evt_refund_${status}`, 'charge.refunded', { + id: 'ch_order_1', + object: 'charge', + payment_intent: 'pi_order_1', + amount: 2000, + amount_refunded: 500, + currency: 'usd', + metadata: { type: 'merch', orderId: 'order-1' }, + })); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status, + paymentStatus: 'partially_refunded', + stripeAmountRefundedCents: 500, + }); + }, + ); + + test('does not regress a legacy refunded record that lacks a refund counter', async () => { + seedOrder({ + status: 'refunded', + paymentStatus: 'refunded', + stripePaymentIntentId: 'pi_order_1', + stripeChargeId: 'ch_order_1', + stripeAmountRefundedCents: undefined, + }); + await deliver(stripeEvent('evt_legacy_refunded_partial', 'charge.refunded', { + id: 'ch_order_1', + object: 'charge', + payment_intent: 'pi_order_1', + amount: 2000, + amount_refunded: 500, + currency: 'usd', + metadata: { type: 'merch', orderId: 'order-1' }, + })); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'refunded', + paymentStatus: 'refunded', + }); + expect(admin.__get('orders/order-1').stripeAmountRefundedCents).toBeUndefined(); + expect(admin.__get('stripeEvents/evt_legacy_refunded_partial')).toMatchObject({ + outcome: 'stale_refund_ignored', + }); + }); + + test('does not let an older partial-refund event downgrade a full refund', async () => { + seedOrder({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + stripeAmountTotalCents: 2000, + }); + const charge = { + id: 'ch_order_1', + object: 'charge', + payment_intent: 'pi_order_1', + amount: 2000, + currency: 'usd', + metadata: { type: 'merch', orderId: 'order-1' }, }; - const res = mockResponse(); - await stripeWebhook(req, res); - expect(res.status).toHaveBeenCalledWith(400); - }); - - test('rejects request with tampered signature', async () => { - const { stripeWebhook } = require('./stripeWebhook'); - const { body, rawBody } = signedRequest({ id: 'evt_1', type: 'checkout.session.completed' }); - const req = { - method: 'POST', - rawBody, - body, - get: (h) => (h === 'stripe-signature' ? 't=1,v1=0000' : undefined), + + await deliver(stripeEvent('evt_refund_full', 'charge.refunded', { + ...charge, + amount_refunded: 2000, + refunds: { data: [{ id: 're_full' }] }, + })); + await deliver(stripeEvent('evt_refund_old_partial', 'charge.refunded', { + ...charge, + amount_refunded: 500, + refunds: { data: [{ id: 're_partial' }] }, + })); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'refunded', + paymentStatus: 'refunded', + stripeAmountRefundedCents: 2000, + stripeRefundIds: ['re_full'], + }); + expect(admin.__get('stripeEvents/evt_refund_old_partial')).toMatchObject({ + outcome: 'stale_refund_ignored', + }); + }); + + test('keeps refund state and records payment evidence when refund arrives first', async () => { + seedRegistration(); + const refundFirst = stripeEvent('evt_refund_before_completion', 'charge.refunded', { + id: 'ch_reg_1', + object: 'charge', + payment_intent: 'pi_reg_1', + amount: 5000, + currency: 'usd', + amount_refunded: 500, + created: 1_799_999_900, + metadata: { eventId: 'race-1', registrationId: 'reg-1' }, + refunds: { data: [{ id: 're_reg_partial' }] }, + }); + + await deliver(refundFirst); + await deliver(stripeEvent( + 'evt_completion_after_refund', + 'checkout.session.completed', + registrationSession(), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'partially_refunded', + paymentStatus: 'partially_refunded', + stripeAmountRefundedCents: 500, + stripeAmountTotalCents: 5000, + stripeSessionId: 'cs_reg_1', + stripePaymentIntentId: 'pi_reg_1', + }); + expect(admin.__get('events/race-1/registrations/reg-1').paidAt).toBeTruthy(); + expect(admin.__get('stripeEvents/evt_completion_after_refund')).toMatchObject({ + outcome: 'payment_observed_after_partially_refunded', + }); + }); + + test('records merchandise disputes without overwriting order status', async () => { + seedOrder({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + }); + const dispute = { + id: 'dp_order_1', + object: 'dispute', + charge: 'ch_order_1', + payment_intent: 'pi_order_1', + amount: 2000, + currency: 'usd', + reason: 'fraudulent', + status: 'needs_response', + metadata: { type: 'merch', orderId: 'order-1' }, }; - const res = mockResponse(); - await stripeWebhook(req, res); - expect(res.status).toHaveBeenCalledWith(400); + + await deliver(stripeEvent( + 'evt_order_dispute', + 'charge.dispute.created', + dispute, + )); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'paid', + disputeStatus: 'needs_response', + stripeDisputeId: 'dp_order_1', + disputedAmountCents: 2000, + disputeReason: 'fraudulent', + stripeDisputeIds: ['dp_order_1'], + stripeDisputes: { + dp_order_1: expect.objectContaining({ + status: 'needs_response', + paymentIntentId: 'pi_order_1', + }), + }, + }); + expect(admin.__get('stripeObjectBindings/dispute:dp_order_1')).toMatchObject({ + targetPath: 'orders/order-1', + targetType: 'order', + }); + expect(admin.__get('stripeObjectBindings/charge:ch_order_1')).toMatchObject({ + targetPath: 'orders/order-1', + }); }); - test('rejects non-POST', async () => { - const { stripeWebhook } = require('./stripeWebhook'); - const res = mockResponse(); - await stripeWebhook({ method: 'GET', get: () => undefined }, res); - expect(res.status).toHaveBeenCalledWith(405); + test('resolves a provider-normal PI-null dispute by Charge ID', async () => { + seedOrder({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + stripeChargeId: 'ch_order_1', + }); + const dispute = { + id: 'dp_charge_lookup', + object: 'dispute', + charge: 'ch_order_1', + payment_intent: null, + amount: 2000, + currency: 'usd', + reason: 'fraudulent', + status: 'needs_response', + metadata: {}, + }; + + await deliver(stripeEvent( + 'evt_dispute_charge_lookup', + 'charge.dispute.created', + dispute, + )); + + expect(admin.__get('orders/order-1')).toMatchObject({ + disputeStatus: 'needs_response', + stripeDisputeIds: ['dp_charge_lookup'], + }); + expect(admin.__get('stripeEvents/evt_dispute_charge_lookup')).toMatchObject({ + targetSource: 'charge_query', + outcome: 'dispute_needs_response', + }); + }); + + test.each([ + ['object type', { object: 'charge' }, {}, 'invalid_dispute_binding'], + ['missing Charge', { charge: null, payment_intent: null }, {}, 'invalid_dispute_binding'], + ['wrong Charge', { charge: 'ch_other', payment_intent: null }, {}, 'charge_mismatch'], + ['PaymentIntent', { payment_intent: 'pi_other' }, {}, 'payment_intent_mismatch'], + ['currency', { currency: 'cad' }, {}, 'dispute_currency_mismatch'], + ['zero amount', { amount: 0 }, {}, 'invalid_dispute_amount'], + ['excess amount', { amount: 2001 }, {}, 'invalid_dispute_amount'], + ['missing status', { status: undefined }, {}, 'invalid_dispute_status'], + ['invented closed status', { status: 'closed' }, {}, 'invalid_dispute_status'], + ['stored currency', {}, { currency: undefined }, 'invalid_expected_currency'], + ])('quarantines a dispute %s mismatch', async ( + _label, + disputePatch, + recordPatch, + reason, + ) => { + seedOrder({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + stripeChargeId: 'ch_order_1', + stripeAmountTotalCents: 2000, + ...recordPatch, + }); + const dispute = { + id: 'dp_invalid', + object: 'dispute', + charge: 'ch_order_1', + payment_intent: 'pi_order_1', + amount: 2000, + currency: 'usd', + reason: 'fraudulent', + status: 'needs_response', + metadata: { type: 'merch', orderId: 'order-1' }, + ...disputePatch, + }; + + await deliver(stripeEvent( + `evt_dispute_${reason}_${_label.replaceAll(' ', '_')}`, + 'charge.dispute.updated', + dispute, + )); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'paid', + paymentReviewRequired: true, + paymentReviewReason: reason, + }); + expect(admin.__get( + `stripeEvents/evt_dispute_${reason}_${_label.replaceAll(' ', '_')}`, + )).toMatchObject({ + outcome: `needs_review:${reason}`, + requiresReview: true, + }); + }); + + test('quarantines a closed dispute event without a provider terminal status', async () => { + seedOrder({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + stripeChargeId: 'ch_order_1', + }); + const dispute = { + id: 'dp_closed_missing_status', + object: 'dispute', + charge: 'ch_order_1', + payment_intent: 'pi_order_1', + amount: 2000, + currency: 'usd', + reason: 'fraudulent', + metadata: { type: 'merch', orderId: 'order-1' }, + }; + + await deliver(stripeEvent( + 'evt_closed_missing_status', + 'charge.dispute.closed', + dispute, + )); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'paid', + paymentReviewReason: 'invalid_dispute_status', + }); + expect(admin.__get('stripeEvents/evt_closed_missing_status')).toMatchObject({ + outcome: 'needs_review:invalid_dispute_status', + }); + }); + + test('quarantines an unmatched dispute instead of acknowledging it as unrelated', async () => { + const dispute = { + id: 'dp_unmatched', + object: 'dispute', + charge: 'ch_unknown', + payment_intent: null, + amount: 1000, + reason: 'fraudulent', + status: 'needs_response', + metadata: {}, + }; + + await deliver(stripeEvent( + 'evt_dispute_unmatched', + 'charge.dispute.created', + dispute, + )); + + expect(admin.__get('stripeEvents/evt_dispute_unmatched')).toMatchObject({ + outcome: 'needs_review:unmatched_dispute', + requiresReview: true, + }); + }); + + test('does not let an older dispute update regress a closed dispute', async () => { + seedOrder({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + }); + const base = { + id: 'dp_order_reordered', + object: 'dispute', + payment_intent: 'pi_order_1', + charge: 'ch_order_1', + amount: 2000, + currency: 'usd', + reason: 'fraudulent', + metadata: { type: 'merch', orderId: 'order-1' }, + }; + const closed = stripeEvent('evt_dispute_closed', 'charge.dispute.closed', { + ...base, + status: 'won', + }); + closed.created = 1_800_000_100; + const olderUpdate = stripeEvent('evt_dispute_old_update', 'charge.dispute.updated', { + ...base, + status: 'under_review', + }); + olderUpdate.created = 1_800_000_000; + + await deliver(closed); + await deliver(olderUpdate); + + expect(admin.__get('orders/order-1')).toMatchObject({ + disputeStatus: 'won', + stripeDisputes: { + dp_order_reordered: expect.objectContaining({ status: 'won' }), + }, + }); + expect(admin.__get('stripeEvents/evt_dispute_old_update')).toMatchObject({ + outcome: 'stale_dispute_ignored', + }); + }); + + test('uses dispute lifecycle rank when updates share the same second', async () => { + seedOrder({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + }); + const base = { + id: 'dp_equal_second', + object: 'dispute', + payment_intent: 'pi_order_1', + charge: 'ch_order_1', + amount: 2000, + currency: 'usd', + reason: 'fraudulent', + metadata: { type: 'merch', orderId: 'order-1' }, + }; + const underReview = stripeEvent( + 'evt_equal_under_review', + 'charge.dispute.updated', + { ...base, status: 'under_review' }, + ); + const needsResponse = stripeEvent( + 'evt_equal_needs_response', + 'charge.dispute.updated', + { ...base, status: 'needs_response' }, + ); + underReview.created = 1_800_000_000; + needsResponse.created = 1_800_000_000; + + await deliver(underReview); + await deliver(needsResponse); + + expect(admin.__get('orders/order-1')).toMatchObject({ + disputeStatus: 'under_review', + stripeDisputes: { + dp_equal_second: expect.objectContaining({ + status: 'under_review', + statusRank: 20, + }), + }, + }); + expect(admin.__get('stripeEvents/evt_equal_needs_response')).toMatchObject({ + outcome: 'stale_dispute_ignored', + }); + }); + + test('tracks multiple disputes for one payment independently', async () => { + seedOrder({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + }); + const disputeFor = (id, status) => ({ + id, + object: 'dispute', + payment_intent: 'pi_order_1', + charge: 'ch_order_1', + amount: 500, + currency: 'usd', + reason: 'fraudulent', + status, + metadata: { type: 'merch', orderId: 'order-1' }, + }); + + const first = stripeEvent( + 'evt_dispute_first', 'charge.dispute.created', disputeFor('dp_first', 'needs_response'), + ); + first.created = 1_800_000_000; + const second = stripeEvent( + 'evt_dispute_second', 'charge.dispute.created', disputeFor('dp_second', 'under_review'), + ); + second.created = 1_800_000_100; + await deliver(first); + await deliver(second); + + expect(admin.__get('orders/order-1')).toMatchObject({ + stripeDisputeIds: ['dp_first', 'dp_second'], + stripeDisputes: { + dp_first: expect.objectContaining({ status: 'needs_response' }), + dp_second: expect.objectContaining({ status: 'under_review' }), + }, + disputeStatus: 'under_review', + }); }); }); From bc3f4bd5946246a16a7dde9e6ab6adf07eb636c7 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 03:31:50 -0700 Subject: [PATCH 2/4] Guard unanchored Stripe bindings --- functions/stripeWebhook.js | 26 +++++++++++++++++++++----- functions/stripeWebhook.test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/functions/stripeWebhook.js b/functions/stripeWebhook.js index 0396518b..7b0c40be 100644 --- a/functions/stripeWebhook.js +++ b/functions/stripeWebhook.js @@ -1091,16 +1091,32 @@ function transitionForEvent(event, target, targetSnap, modeValidation, ownership } } -function providerBindingIsVerified(event, record) { +function providerBindingCanBeRecorded(event, record, result) { const object = event.data.object; if (CHECKOUT_EVENT_TYPES.has(event.type)) { return validateSessionBinding(object, record).ok; } if (event.type === 'charge.refunded') { - return validateRefundBinding(object, record).ok; + const binding = validateRefundBinding(object, record); + if (!binding.ok) return false; + const anchored = !!record.stripePaymentIntentId || !!record.stripeChargeId; + const accepted = result.outcome === 'partially_refunded' + || result.outcome === 'refunded' + || result.outcome === 'already_partially_refunded' + || result.outcome === 'already_refunded' + || result.outcome === 'stale_refund_ignored'; + return anchored || accepted; } if (DISPUTE_EVENT_TYPES.has(event.type)) { - return validateDisputeBinding(object, record).ok; + const binding = validateDisputeBinding(object, record); + if (!binding.ok) return false; + const anchored = !!record.stripePaymentIntentId + || !!record.stripeChargeId + || !!binding.existingDispute; + const accepted = result.outcome.startsWith('dispute_') + || result.outcome.startsWith('already_dispute_') + || result.outcome === 'stale_dispute_ignored'; + return anchored || accepted; } return false; } @@ -1184,8 +1200,6 @@ async function processEvent(event) { const hasBindingConflict = bindingSnaps.some( (snapshot) => !bindingMatchesTarget(snapshot, target), ); - const providerBindingVerified = targetSnap?.exists - && providerBindingIsVerified(event, targetSnap.data()); const result = hasBindingConflict ? { outcome: 'needs_review:provider_binding_conflict', @@ -1199,6 +1213,8 @@ async function processEvent(event) { modeValidation, ownership, ); + const providerBindingVerified = targetSnap?.exists + && providerBindingCanBeRecorded(event, targetSnap.data(), result); if (result.patch && targetSnap?.exists) tx.update(target.ref, result.patch); if (target && !hasBindingConflict && providerBindingVerified) { bindingSnaps.forEach((snapshot, index) => { diff --git a/functions/stripeWebhook.test.js b/functions/stripeWebhook.test.js index 7eace2cc..a6a8aecf 100644 --- a/functions/stripeWebhook.test.js +++ b/functions/stripeWebhook.test.js @@ -1389,6 +1389,34 @@ describe('stripeWebhook', () => { expect(admin.__get('stripeEvents/evt_completion_after_refund')).toMatchObject({ outcome: 'payment_observed_after_partially_refunded', }); + expect(admin.__get('stripeObjectBindings/charge:ch_reg_1')).toMatchObject({ + targetPath: 'events/race-1/registrations/reg-1', + }); + expect(admin.__get('stripeObjectBindings/payment_intent:pi_reg_1')).toMatchObject({ + targetPath: 'events/race-1/registrations/reg-1', + }); + }); + + test('does not bind an unanchored refund that fails money validation', async () => { + seedOrder({ status: 'paid', paymentStatus: 'paid' }); + await deliver(stripeEvent('evt_unanchored_bad_refund', 'charge.refunded', { + id: 'ch_unanchored_bad', + object: 'charge', + payment_intent: 'pi_unanchored_bad', + amount: 1999, + amount_refunded: 500, + currency: 'usd', + metadata: { type: 'merch', orderId: 'order-1' }, + })); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'paid', + paymentReviewReason: 'refund_total_mismatch', + }); + expect(admin.__get('stripeObjectBindings/charge:ch_unanchored_bad')).toBeUndefined(); + expect(admin.__get( + 'stripeObjectBindings/payment_intent:pi_unanchored_bad', + )).toBeUndefined(); }); test('records merchandise disputes without overwriting order status', async () => { From 5cecd1514af9f5a5ff6255bf48362a98952e061a Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 03:33:33 -0700 Subject: [PATCH 3/4] Require matching dispute anchors --- functions/stripeWebhook.js | 2 +- functions/stripeWebhook.test.js | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/functions/stripeWebhook.js b/functions/stripeWebhook.js index 7b0c40be..6ed0b54f 100644 --- a/functions/stripeWebhook.js +++ b/functions/stripeWebhook.js @@ -1110,7 +1110,7 @@ function providerBindingCanBeRecorded(event, record, result) { if (DISPUTE_EVENT_TYPES.has(event.type)) { const binding = validateDisputeBinding(object, record); if (!binding.ok) return false; - const anchored = !!record.stripePaymentIntentId + const anchored = (!!record.stripePaymentIntentId && !!binding.paymentIntentId) || !!record.stripeChargeId || !!binding.existingDispute; const accepted = result.outcome.startsWith('dispute_') diff --git a/functions/stripeWebhook.test.js b/functions/stripeWebhook.test.js index a6a8aecf..17969983 100644 --- a/functions/stripeWebhook.test.js +++ b/functions/stripeWebhook.test.js @@ -1591,6 +1591,33 @@ describe('stripeWebhook', () => { }); }); + test('does not anchor a PI-null dispute to an unrelated stored PaymentIntent', async () => { + seedOrder({ + status: 'paid', + paymentStatus: 'paid', + stripePaymentIntentId: 'pi_order_1', + stripeChargeId: null, + }); + await deliver(stripeEvent('evt_unanchored_bad_dispute', 'charge.dispute.updated', { + id: 'dp_unanchored_bad', + object: 'dispute', + charge: 'ch_unanchored_bad', + payment_intent: null, + amount: 2001, + currency: 'usd', + reason: 'fraudulent', + status: 'needs_response', + metadata: { type: 'merch', orderId: 'order-1' }, + })); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'paid', + paymentReviewReason: 'invalid_dispute_amount', + }); + expect(admin.__get('stripeObjectBindings/dispute:dp_unanchored_bad')).toBeUndefined(); + expect(admin.__get('stripeObjectBindings/charge:ch_unanchored_bad')).toBeUndefined(); + }); + test('quarantines an unmatched dispute instead of acknowledging it as unrelated', async () => { const dispute = { id: 'dp_unmatched', From fd89a8d05caeda98bb8f6dae2d1196f6936bcc2d Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 03:36:16 -0700 Subject: [PATCH 4/4] Protect legacy refund baselines --- functions/stripeWebhook.js | 21 +++++++- functions/stripeWebhook.test.js | 89 +++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/functions/stripeWebhook.js b/functions/stripeWebhook.js index 6ed0b54f..086bcd34 100644 --- a/functions/stripeWebhook.js +++ b/functions/stripeWebhook.js @@ -15,6 +15,7 @@ const CHECKOUT_EVENT_TYPES = new Set([ 'checkout.session.async_payment_failed', 'checkout.session.expired', ]); +const CHECKOUT_PAYMENT_STATUSES = new Set(['paid', 'unpaid', 'no_payment_required']); const DISPUTE_EVENT_TYPES = new Set([ 'charge.dispute.created', 'charge.dispute.updated', @@ -587,6 +588,9 @@ function successfulPaymentTransition({ event, session, target, record }) { const money = validateSessionMoney(session, record); if (!money.ok) return reviewTransition({ target, event, reason: money.reason }); + if (!CHECKOUT_PAYMENT_STATUSES.has(session.payment_status)) { + return reviewTransition({ target, event, reason: 'invalid_checkout_payment_status' }); + } const paymentIntentId = objectId(session.payment_intent); const isPaid = session.payment_status === 'paid' @@ -731,6 +735,12 @@ function successfulPaymentTransition({ event, session, target, record }) { function unsuccessfulSessionTransition({ event, session, target, record, reason }) { const binding = validateSessionBinding(session, record); if (!binding.ok) return reviewTransition({ target, event, reason: binding.reason }); + if (!CHECKOUT_PAYMENT_STATUSES.has(session.payment_status)) { + return reviewTransition({ target, event, reason: 'invalid_checkout_payment_status' }); + } + if (session.payment_status !== 'unpaid') { + return reviewTransition({ target, event, reason: 'unsuccessful_event_reports_paid' }); + } if (record.status !== 'pending') { return { outcome: `${reason}_ignored:${record.status}`, patch: null }; } @@ -824,7 +834,16 @@ function refundTransition({ event, charge, target, record }) { return { outcome: 'stale_refund_ignored', patch: null }; } - const previouslyRefunded = nonNegativeInteger(record.stripeAmountRefundedCents); + const storedRefundCounter = record.stripeAmountRefundedCents; + const hasUnknownPartialBaseline = ( + record.status === 'partially_refunded' + || record.paymentStatus === 'partially_refunded' + ) && (!Number.isSafeInteger(storedRefundCounter) || storedRefundCounter <= 0); + if (hasUnknownPartialBaseline && amountRefunded < totalAmount) { + return reviewTransition({ target, event, reason: 'unknown_refund_baseline' }); + } + + const previouslyRefunded = nonNegativeInteger(storedRefundCounter); if (amountRefunded < previouslyRefunded) { const staleRefundIds = refundIdsFromCharge(charge); return { diff --git a/functions/stripeWebhook.test.js b/functions/stripeWebhook.test.js index 17969983..cf84b747 100644 --- a/functions/stripeWebhook.test.js +++ b/functions/stripeWebhook.test.js @@ -743,6 +743,21 @@ describe('stripeWebhook', () => { expect(registration.paidAt).toBeUndefined(); }); + test('quarantines an unknown Checkout payment status', async () => { + seedRegistration(); + await deliver(stripeEvent( + 'evt_unknown_payment_status', + 'checkout.session.completed', + registrationSession({ payment_status: 'mystery' }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'pending', + paymentReviewRequired: true, + paymentReviewReason: 'invalid_checkout_payment_status', + }); + }); + test('confirms a delayed payment only after async success', async () => { seedRegistration(); await deliver(stripeEvent( @@ -797,6 +812,24 @@ describe('stripeWebhook', () => { expect(order.cancelledAt).toBeDefined(); }); + test.each([ + ['failure', 'checkout.session.async_payment_failed'], + ['expiry', 'checkout.session.expired'], + ])('quarantines an async %s event whose Session reports paid', async (_label, type) => { + seedRegistration(); + await deliver(stripeEvent( + `evt_paid_${_label}`, + type, + registrationSession({ payment_status: 'paid' }), + )); + + expect(admin.__get('events/race-1/registrations/reg-1')).toMatchObject({ + status: 'pending', + paymentReviewRequired: true, + paymentReviewReason: 'unsuccessful_event_reports_paid', + }); + }); + test.each([ ['failure', 'checkout.session.async_payment_failed', 'failed'], ['expiry', 'checkout.session.expired', 'expired'], @@ -1318,6 +1351,62 @@ describe('stripeWebhook', () => { }); }); + test('quarantines a legacy partial refund with an unknown cumulative baseline', async () => { + seedOrder({ + status: 'partially_refunded', + paymentStatus: 'partially_refunded', + stripePaymentIntentId: 'pi_order_1', + stripeChargeId: 'ch_order_1', + stripeAmountRefundedCents: undefined, + }); + await deliver(stripeEvent('evt_unknown_partial_baseline', 'charge.refunded', { + id: 'ch_order_1', + object: 'charge', + payment_intent: 'pi_order_1', + amount: 2000, + amount_refunded: 500, + currency: 'usd', + metadata: { type: 'merch', orderId: 'order-1' }, + })); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'partially_refunded', + paymentStatus: 'partially_refunded', + paymentReviewRequired: true, + paymentReviewReason: 'unknown_refund_baseline', + }); + expect(admin.__get('orders/order-1').stripeAmountRefundedCents).toBeUndefined(); + expect(admin.__get('stripeEvents/evt_unknown_partial_baseline')).toMatchObject({ + outcome: 'needs_review:unknown_refund_baseline', + requiresReview: true, + }); + }); + + test('can safely advance an unknown legacy partial baseline to fully refunded', async () => { + seedOrder({ + status: 'partially_refunded', + paymentStatus: 'partially_refunded', + stripePaymentIntentId: 'pi_order_1', + stripeChargeId: 'ch_order_1', + stripeAmountRefundedCents: undefined, + }); + await deliver(stripeEvent('evt_unknown_partial_to_full', 'charge.refunded', { + id: 'ch_order_1', + object: 'charge', + payment_intent: 'pi_order_1', + amount: 2000, + amount_refunded: 2000, + currency: 'usd', + metadata: { type: 'merch', orderId: 'order-1' }, + })); + + expect(admin.__get('orders/order-1')).toMatchObject({ + status: 'refunded', + paymentStatus: 'refunded', + stripeAmountRefundedCents: 2000, + }); + }); + test('does not let an older partial-refund event downgrade a full refund', async () => { seedOrder({ status: 'paid',