backend-manager 5.0.175 → 5.0.176

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.0.176] - 2026-03-30
18
+ ### Fixed
19
+ - Chargeblast `alert.created` events use `alertId` instead of `id` — normalizer now accepts either field
20
+ - Dispute charge matching now uses `charges.search()` instead of invoice search, fixing cases where Stripe invoices had `charge: null` even when paid (via balance/credit). Single reliable strategy: amount + ±2 day date window + card last4
21
+ ### Changed
22
+ - Dispute `on-write` trigger is now processor-agnostic — Stripe-specific match/refund logic extracted to `processors/stripe.js`, matching the pattern used by payments-webhooks
23
+
17
24
  # [5.0.174] - 2026-03-27
18
25
  ### Fixed
19
26
  - Payments-orders `metadata.created` timestamp no longer overwritten on subsequent webhook events (renewals, cancellations, payment failures)
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Test: Payment Journey - Zombie/Stale Subscription Guard
3
+ * Simulates: user has active sub A → webhook arrives for old sub B (cancellation)
4
+ *
5
+ * Verifies that on-write.js does NOT overwrite the user doc when a webhook
6
+ * arrives for a subscription that doesn't match the user's current resourceId.
7
+ * The order doc should still be updated, but user.subscription must remain unchanged.
8
+ */
9
+ module.exports = {
10
+ description: 'Payment journey: zombie subscription webhook does not overwrite current subscription',
11
+ type: 'suite',
12
+ timeout: 30000,
13
+
14
+ tests: [
15
+ {
16
+ name: 'setup-active-subscription',
17
+ async run({ accounts, firestore, assert, state, config, http, waitFor }) {
18
+ const uid = accounts['journey-payments-zombie-sub'].uid;
19
+
20
+ // Resolve first paid product from config
21
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
22
+ assert.ok(paidProduct, 'Config should have at least one paid product');
23
+
24
+ state.uid = uid;
25
+ state.paidProductId = paidProduct.id;
26
+ state.paidProductName = paidProduct.name;
27
+ state.paidStripeProductId = paidProduct.stripe?.productId;
28
+
29
+ // Create subscription via test intent
30
+ const response = await http.as('journey-payments-zombie-sub').post('payments/intent', {
31
+ processor: 'test',
32
+ productId: paidProduct.id,
33
+ frequency: 'monthly',
34
+ });
35
+ assert.isSuccess(response, 'Intent should succeed');
36
+ state.orderId = response.data.orderId;
37
+
38
+ // Wait for subscription to activate
39
+ await waitFor(async () => {
40
+ const userDoc = await firestore.get(`users/${uid}`);
41
+ return userDoc?.subscription?.product?.id === paidProduct.id;
42
+ }, 15000, 500);
43
+
44
+ const userDoc = await firestore.get(`users/${uid}`);
45
+ assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should be ${paidProduct.id}`);
46
+ assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
47
+
48
+ // Save the current (real) subscription resourceId
49
+ state.currentResourceId = userDoc.subscription.payment.resourceId;
50
+ assert.ok(state.currentResourceId, 'Should have a resourceId');
51
+ },
52
+ },
53
+
54
+ {
55
+ name: 'send-zombie-cancellation-webhook',
56
+ async run({ http, assert, state, config }) {
57
+ // Send a cancellation webhook for a DIFFERENT subscription (the "zombie")
58
+ state.zombieResourceId = 'sub_zombie_old_dead_' + Date.now();
59
+ state.zombieEventId = `_test-evt-zombie-cancel-${Date.now()}`;
60
+
61
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
62
+ id: state.zombieEventId,
63
+ type: 'customer.subscription.deleted',
64
+ data: {
65
+ object: {
66
+ id: state.zombieResourceId,
67
+ object: 'subscription',
68
+ status: 'canceled',
69
+ metadata: { uid: state.uid },
70
+ cancel_at_period_end: false,
71
+ canceled_at: Math.floor(Date.now() / 1000),
72
+ current_period_end: Math.floor(Date.now() / 1000),
73
+ current_period_start: Math.floor(Date.now() / 1000) - 86400 * 30,
74
+ start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
75
+ trial_start: null,
76
+ trial_end: null,
77
+ plan: { product: state.paidStripeProductId, interval: 'month' },
78
+ },
79
+ },
80
+ });
81
+
82
+ assert.isSuccess(response, 'Webhook should be accepted');
83
+ },
84
+ },
85
+
86
+ {
87
+ name: 'user-doc-unchanged',
88
+ async run({ firestore, assert, state, waitFor }) {
89
+ // Wait for the zombie webhook to complete processing
90
+ await waitFor(async () => {
91
+ const doc = await firestore.get(`payments-webhooks/${state.zombieEventId}`);
92
+ return doc?.status === 'completed';
93
+ }, 15000, 500);
94
+
95
+ // Verify the webhook completed but did NOT trigger a transition
96
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.zombieEventId}`);
97
+ assert.equal(webhookDoc.status, 'completed', 'Webhook should complete successfully');
98
+ assert.equal(webhookDoc.transition, null, 'Transition should be null (suppressed for zombie sub)');
99
+
100
+ // Verify user doc was NOT overwritten — subscription should still be active with original resourceId
101
+ const userDoc = await firestore.get(`users/${state.uid}`);
102
+ assert.equal(userDoc.subscription.status, 'active', 'User subscription should still be active');
103
+ assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should still be ${state.paidProductId}`);
104
+ assert.equal(userDoc.subscription.payment.resourceId, state.currentResourceId, 'ResourceId should still be the current subscription, not the zombie');
105
+ assert.notEqual(userDoc.subscription.payment.resourceId, state.zombieResourceId, 'ResourceId should NOT be the zombie subscription');
106
+ },
107
+ },
108
+
109
+ {
110
+ name: 'send-zombie-suspension-webhook',
111
+ async run({ http, assert, state, config }) {
112
+ // Also test that a payment failure for a zombie sub doesn't suspend the user
113
+ state.zombieSuspendEventId = `_test-evt-zombie-suspend-${Date.now()}`;
114
+
115
+ const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
116
+ id: state.zombieSuspendEventId,
117
+ type: 'invoice.payment_failed',
118
+ data: {
119
+ object: {
120
+ id: state.zombieResourceId,
121
+ object: 'subscription',
122
+ status: 'active',
123
+ metadata: { uid: state.uid },
124
+ cancel_at_period_end: false,
125
+ canceled_at: null,
126
+ current_period_end: Math.floor(Date.now() / 1000) + 86400 * 30,
127
+ current_period_start: Math.floor(Date.now() / 1000),
128
+ start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
129
+ trial_start: null,
130
+ trial_end: null,
131
+ plan: { product: state.paidStripeProductId, interval: 'month' },
132
+ },
133
+ },
134
+ });
135
+
136
+ assert.isSuccess(response, 'Webhook should be accepted');
137
+ },
138
+ },
139
+
140
+ {
141
+ name: 'user-still-active-after-zombie-suspension',
142
+ async run({ firestore, assert, state, waitFor }) {
143
+ await waitFor(async () => {
144
+ const doc = await firestore.get(`payments-webhooks/${state.zombieSuspendEventId}`);
145
+ return doc?.status === 'completed';
146
+ }, 15000, 500);
147
+
148
+ const webhookDoc = await firestore.get(`payments-webhooks/${state.zombieSuspendEventId}`);
149
+ assert.equal(webhookDoc.status, 'completed', 'Webhook should complete');
150
+ assert.equal(webhookDoc.transition, null, 'Transition should be null (suppressed for zombie sub)');
151
+
152
+ // User should STILL be active — zombie payment failure must not suspend them
153
+ const userDoc = await firestore.get(`users/${state.uid}`);
154
+ assert.equal(userDoc.subscription.status, 'active', 'User should still be active after zombie payment failure');
155
+ assert.equal(userDoc.subscription.payment.resourceId, state.currentResourceId, 'ResourceId should still be the current subscription');
156
+ },
157
+ },
158
+ ],
159
+ };
@@ -0,0 +1,442 @@
1
+ const powertools = require('node-powertools');
2
+ const transitions = require('./transitions/index.js');
3
+ const { trackPayment } = require('./analytics.js');
4
+
5
+ /**
6
+ * Firestore trigger: payments-webhooks/{eventId} onWrite
7
+ *
8
+ * Processes pending webhook events:
9
+ * 1. Loads the processor library
10
+ * 2. Fetches the latest resource from the processor API (not the stale webhook payload)
11
+ * 3. Branches on event.category to transform + write:
12
+ * - subscription → toUnifiedSubscription → users/{uid}.subscription + payments-orders/{orderId}
13
+ * - one-time → toUnifiedOneTime → payments-orders/{orderId}
14
+ * 4. Detects state transitions and dispatches handler files (non-blocking)
15
+ * 5. Marks the webhook as completed
16
+ */
17
+ module.exports = async ({ assistant, change, context }) => {
18
+ const Manager = assistant.Manager;
19
+ const admin = Manager.libraries.admin;
20
+
21
+ const dataAfter = change.after.data();
22
+
23
+ // Short-circuit: deleted doc or non-pending status
24
+ if (!dataAfter || dataAfter.status !== 'pending') {
25
+ return;
26
+ }
27
+
28
+ const eventId = context.params.eventId;
29
+ const webhookRef = admin.firestore().doc(`payments-webhooks/${eventId}`);
30
+
31
+ // Set status to processing
32
+ await webhookRef.set({ status: 'processing' }, { merge: true });
33
+
34
+ // Hoisted so orderId is available in catch block for audit trail
35
+ let orderId = null;
36
+
37
+ try {
38
+ const processor = dataAfter.processor;
39
+ let uid = dataAfter.owner;
40
+ const raw = dataAfter.raw;
41
+ const eventType = dataAfter.event?.type;
42
+ const category = dataAfter.event?.category;
43
+ const resourceType = dataAfter.event?.resourceType;
44
+ const resourceId = dataAfter.event?.resourceId;
45
+
46
+ assistant.log(`Processing webhook ${eventId}: processor=${processor}, eventType=${eventType}, category=${category}, resourceType=${resourceType}, resourceId=${resourceId}, uid=${uid || 'null'}`);
47
+
48
+ // Validate category
49
+ if (!category) {
50
+ throw new Error(`Webhook event has no category — cannot process`);
51
+ }
52
+
53
+ // Load the shared library for this processor
54
+ let library;
55
+ try {
56
+ library = require(`../../../libraries/payment/processors/${processor}.js`);
57
+ } catch (e) {
58
+ throw new Error(`Unknown processor library: ${processor}`);
59
+ }
60
+
61
+ // Fetch the latest resource from the processor API
62
+ // This ensures we always work with the most current state, not stale webhook data
63
+ const rawFallback = raw.data?.object || {};
64
+ const resource = await library.fetchResource(resourceType, resourceId, rawFallback, { admin, eventType, config: Manager.config });
65
+
66
+ assistant.log(`Fetched resource: type=${resourceType}, id=${resourceId}, status=${resource.status || 'unknown'}`);
67
+
68
+ // Resolve UID from the fetched resource if not available from webhook parse
69
+ // This handles events like PAYMENT.SALE where the Sale object doesn't carry custom_id
70
+ // but the parent subscription (fetched via fetchResource) does
71
+ if (!uid && library.getUid) {
72
+ uid = library.getUid(resource);
73
+ assistant.log(`UID resolved from fetched resource: uid=${uid || 'null'}, processor=${processor}, resourceType=${resourceType}`);
74
+
75
+ // Update the webhook doc with the resolved UID so it's persisted for debugging
76
+ if (uid) {
77
+ await webhookRef.set({ owner: uid }, { merge: true });
78
+ }
79
+ }
80
+
81
+ // Fallback: resolve UID from the hosted page's pass_thru_content
82
+ // Chargebee hosted page checkouts don't forward subscription[meta_data] to the subscription,
83
+ // but pass_thru_content is stored on the hosted page and contains our UID + orderId
84
+ let resolvedFromPassThru = false;
85
+ let passThruOrderId = null;
86
+ if (!uid && library.resolveUidFromHostedPage) {
87
+ const passThruResult = await library.resolveUidFromHostedPage(resourceId, assistant);
88
+ if (passThruResult) {
89
+ uid = passThruResult.uid;
90
+ passThruOrderId = passThruResult.orderId || null;
91
+ resolvedFromPassThru = true;
92
+ assistant.log(`UID resolved from hosted page pass_thru_content: uid=${uid}, orderId=${passThruOrderId}, resourceId=${resourceId}`);
93
+
94
+ await webhookRef.set({ owner: uid }, { merge: true });
95
+ }
96
+ }
97
+
98
+ // Validate UID — must have one by now
99
+ if (!uid) {
100
+ throw new Error(`Webhook event has no UID — could not extract from webhook parse, fetched ${resourceType} resource, or hosted page pass_thru_content`);
101
+ }
102
+
103
+ // Backfill: if UID was resolved from pass_thru_content, set meta_data on the subscription
104
+ // so future webhooks (renewals, cancellations) can resolve the UID directly
105
+ if (resolvedFromPassThru && resourceType === 'subscription' && library.setMetaData) {
106
+ library.setMetaData(resource, { uid, orderId: passThruOrderId })
107
+ .then(() => assistant.log(`Backfilled meta_data on subscription ${resourceId} + customer: uid=${uid}, orderId=${passThruOrderId}`))
108
+ .catch((e) => assistant.error(`Failed to backfill meta_data on ${resourceType} ${resourceId}: ${e.message}`));
109
+ }
110
+
111
+ // Build timestamps
112
+ const now = powertools.timestamp(new Date(), { output: 'string' });
113
+ const nowUNIX = powertools.timestamp(now, { output: 'unix' });
114
+ const webhookReceivedUNIX = dataAfter.metadata?.created?.timestampUNIX || nowUNIX;
115
+
116
+ // Extract orderId from resource (processor-agnostic)
117
+ // Falls back to pass_thru_content orderId when meta_data wasn't available on the resource
118
+ orderId = library.getOrderId(resource) || passThruOrderId;
119
+
120
+ // Process the payment event (subscription or one-time)
121
+ if (category !== 'subscription' && category !== 'one-time') {
122
+ throw new Error(`Unknown event category: ${category}`);
123
+ }
124
+
125
+ const transitionName = await processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant, raw });
126
+
127
+ // Mark webhook as completed (include transition name for auditing/testing)
128
+ await webhookRef.set({
129
+ status: 'completed',
130
+ owner: uid,
131
+ orderId: orderId,
132
+ transition: transitionName,
133
+ metadata: {
134
+ completed: {
135
+ timestamp: now,
136
+ timestampUNIX: nowUNIX,
137
+ },
138
+ },
139
+ }, { merge: true });
140
+
141
+ assistant.log(`Webhook ${eventId} completed`);
142
+ } catch (e) {
143
+ assistant.error(`Webhook ${eventId} failed: ${e.message}`, e);
144
+
145
+ const now = powertools.timestamp(new Date(), { output: 'string' });
146
+ const nowUNIX = powertools.timestamp(now, { output: 'unix' });
147
+
148
+ // Mark as failed with error message
149
+ await webhookRef.set({
150
+ status: 'failed',
151
+ error: e.message || String(e),
152
+ }, { merge: true });
153
+
154
+ // Mark intent as failed if we resolved the orderId before the error
155
+ if (orderId) {
156
+ await admin.firestore().doc(`payments-intents/${orderId}`).set({
157
+ status: 'failed',
158
+ error: e.message || String(e),
159
+ metadata: {
160
+ completed: {
161
+ timestamp: now,
162
+ timestampUNIX: nowUNIX,
163
+ },
164
+ },
165
+ }, { merge: true });
166
+ }
167
+ }
168
+ };
169
+
170
+ /**
171
+ * Process a payment event (subscription or one-time)
172
+ * 1. Staleness check
173
+ * 2. Read user doc (for transition detection)
174
+ * 3. Transform raw resource → unified object
175
+ * 4. Build order object
176
+ * 5. Detect and dispatch transition handlers (non-blocking)
177
+ * 6. Track analytics (non-blocking)
178
+ * 7. Write to Firestore (user doc for subscriptions + payments-orders)
179
+ */
180
+ async function processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant, raw }) {
181
+ const Manager = assistant.Manager;
182
+ const admin = Manager.libraries.admin;
183
+ const isSubscription = category === 'subscription';
184
+
185
+ // Staleness check: skip if a newer webhook already wrote to this order
186
+ if (orderId) {
187
+ const existingDoc = await admin.firestore().doc(`payments-orders/${orderId}`).get();
188
+ if (existingDoc.exists) {
189
+ const existingUpdatedUNIX = existingDoc.data()?.metadata?.updated?.timestampUNIX || 0;
190
+ if (webhookReceivedUNIX < existingUpdatedUNIX) {
191
+ assistant.log(`Stale webhook ${eventId}: received=${webhookReceivedUNIX}, existing updated=${existingUpdatedUNIX}, skipping`);
192
+ return null;
193
+ }
194
+ }
195
+ }
196
+
197
+ // Read current user doc (needed for transition detection + handler context)
198
+ const userDoc = await admin.firestore().doc(`users/${uid}`).get();
199
+ const userData = userDoc.exists ? userDoc.data() : {};
200
+ const before = isSubscription ? (userData.subscription || null) : null;
201
+
202
+ assistant.log(`User doc for ${uid}: exists=${userDoc.exists}, email=${userData?.auth?.email || 'null'}, name=${userData?.personal?.name?.first || 'null'}, subscription=${userData?.subscription?.product?.id || 'null'}`);
203
+
204
+ // Auto-fill user name from payment processor if not already set
205
+ if (!userData?.personal?.name?.first) {
206
+ const customerName = extractCustomerName(resource, resourceType);
207
+ if (customerName?.first) {
208
+ await admin.firestore().doc(`users/${uid}`).set({
209
+ personal: { name: customerName },
210
+ }, { merge: true });
211
+ assistant.log(`Auto-filled user name from ${resourceType}: ${customerName.first} ${customerName.last || ''}`);
212
+ }
213
+ }
214
+
215
+ // Transform raw resource → unified object
216
+ const transformOptions = { config: Manager.config, eventName: eventType, eventId: eventId };
217
+ const unified = isSubscription
218
+ ? library.toUnifiedSubscription(resource, transformOptions)
219
+ : library.toUnifiedOneTime(resource, transformOptions);
220
+
221
+ // Override: immediately suspend on payment denial
222
+ // Processors keep the sub active while retrying, but we revoke access right away.
223
+ // If the retry succeeds (e.g. PAYMENT.SALE.COMPLETED), it will restore active status.
224
+ // PayPal: PAYMENT.SALE.DENIED, Stripe: invoice.payment_failed, Chargebee: payment_failed
225
+ const PAYMENT_DENIED_EVENTS = ['PAYMENT.SALE.DENIED', 'invoice.payment_failed', 'payment_failed'];
226
+ if (isSubscription && PAYMENT_DENIED_EVENTS.includes(eventType) && unified.status === 'active') {
227
+ assistant.log(`Overriding status to suspended: ${eventType} received but provider still says active`);
228
+ unified.status = 'suspended';
229
+ }
230
+
231
+ assistant.log(`Unified ${category}: product=${unified.product.id}, status=${unified.status}`, unified);
232
+
233
+ // Read checkout context from payments-intents (attribution, discount, supplemental)
234
+ let intentData = {};
235
+ if (orderId) {
236
+ const intentDoc = await admin.firestore().doc(`payments-intents/${orderId}`).get();
237
+ intentData = intentDoc.exists ? intentDoc.data() : {};
238
+ }
239
+
240
+ // Build the order object (single source of truth for handlers + Firestore)
241
+ const order = {
242
+ id: orderId,
243
+ type: category,
244
+ owner: uid,
245
+ productId: unified.product.id,
246
+ processor: processor,
247
+ resourceId: resourceId,
248
+ unified: unified,
249
+ attribution: intentData.attribution || {},
250
+ discount: intentData.discount || null,
251
+ supplemental: intentData.supplemental || {},
252
+ metadata: {
253
+ created: {
254
+ timestamp: now,
255
+ timestampUNIX: nowUNIX,
256
+ },
257
+ updated: {
258
+ timestamp: now,
259
+ timestampUNIX: nowUNIX,
260
+ },
261
+ updatedBy: {
262
+ event: {
263
+ name: eventType,
264
+ id: eventId,
265
+ },
266
+ },
267
+ },
268
+ };
269
+
270
+ // Guard: check if this webhook is for the user's current subscription
271
+ const currentResourceId = userData?.subscription?.payment?.resourceId;
272
+ const isCurrentSub = !currentResourceId || currentResourceId === resourceId;
273
+
274
+ // Detect and dispatch transition (non-blocking)
275
+ // Only run transitions for the user's current subscription — zombie webhooks should not trigger emails
276
+ const shouldRunHandlers = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
277
+ const transitionName = isCurrentSub
278
+ ? transitions.detectTransition(category, before, unified, eventType)
279
+ : null;
280
+
281
+ if (!isCurrentSub && isSubscription) {
282
+ const wouldBeTransition = transitions.detectTransition(category, before, unified, eventType);
283
+ if (wouldBeTransition) {
284
+ assistant.log(`Transition suppressed for stale sub: ${category}/${wouldBeTransition} (webhook resourceId=${resourceId} != current resourceId=${currentResourceId})`);
285
+ }
286
+ }
287
+
288
+ if (transitionName) {
289
+ assistant.log(`Transition detected: ${category}/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
290
+
291
+ if (shouldRunHandlers) {
292
+ // Extract unified refund details from the processor library (keeps handlers processor-agnostic)
293
+ const refundDetails = (transitionName === 'payment-refunded' && library.getRefundDetails)
294
+ ? library.getRefundDetails(raw)
295
+ : null;
296
+
297
+ transitions.dispatch(transitionName, category, {
298
+ before, after: unified, order, uid, userDoc: userData, assistant, refundDetails,
299
+ });
300
+ } else {
301
+ assistant.log(`Transition handler skipped (testing mode): ${category}/${transitionName}`);
302
+ }
303
+ }
304
+
305
+ // Track payment analytics (non-blocking)
306
+ // Fires independently of transitions — renewals have no transition but still need tracking
307
+ if (shouldRunHandlers) {
308
+ trackPayment({ category, transitionName, eventType, unified, order, uid, processor, assistant });
309
+ }
310
+
311
+ // Write unified subscription to user doc (subscriptions only)
312
+ if (isSubscription) {
313
+ if (isCurrentSub) {
314
+ await admin.firestore().doc(`users/${uid}`).set({ subscription: unified }, { merge: true });
315
+ assistant.log(`Updated users/${uid}.subscription: status=${unified.status}, product=${unified.product.id}`);
316
+
317
+ // Sync marketing contact with updated subscription data (non-blocking)
318
+ if (shouldRunHandlers) {
319
+ const email = Manager.Email(assistant);
320
+ const updatedUserDoc = { ...userData, subscription: unified };
321
+ email.sync(updatedUserDoc)
322
+ .then((r) => assistant.log('Marketing sync after payment:', r))
323
+ .catch((e) => assistant.error('Marketing sync after payment failed:', e));
324
+ }
325
+ } else {
326
+ assistant.log(`Skipping user doc update: webhook resourceId=${resourceId} does not match current subscription resourceId=${currentResourceId}. This is a stale/zombie subscription webhook — only the order doc will be updated.`);
327
+ }
328
+ }
329
+
330
+ // Write to payments-orders/{orderId}
331
+ if (orderId) {
332
+ const orderRef = admin.firestore().doc(`payments-orders/${orderId}`);
333
+ const orderSnap = await orderRef.get();
334
+
335
+ if (!orderSnap.exists) {
336
+ // Initialize requests on first creation only (avoid overwriting cancel/refund data set by endpoints)
337
+ order.requests = {
338
+ cancellation: null,
339
+ refund: null,
340
+ };
341
+ } else {
342
+ // Preserve original created timestamp on subsequent webhook events
343
+ order.metadata.created = orderSnap.data().metadata?.created || order.metadata.created;
344
+ }
345
+
346
+ await orderRef.set(order, { merge: true });
347
+ assistant.log(`Updated payments-orders/${orderId}: type=${category}, uid=${uid}, eventType=${eventType}`);
348
+ }
349
+
350
+ // Update payments-intents/{orderId} status to match webhook outcome
351
+ if (orderId) {
352
+ await admin.firestore().doc(`payments-intents/${orderId}`).set({
353
+ status: 'completed',
354
+ metadata: {
355
+ completed: {
356
+ timestamp: now,
357
+ timestampUNIX: nowUNIX,
358
+ },
359
+ },
360
+ }, { merge: true });
361
+ assistant.log(`Updated payments-intents/${orderId}: status=completed`);
362
+ }
363
+
364
+ // Mark abandoned cart as completed (non-blocking, fire-and-forget)
365
+ const { COLLECTION } = require('../../../libraries/abandoned-cart-config.js');
366
+ admin.firestore().doc(`${COLLECTION}/${uid}`).set({
367
+ status: 'completed',
368
+ metadata: {
369
+ updated: {
370
+ timestamp: now,
371
+ timestampUNIX: nowUNIX,
372
+ },
373
+ },
374
+ }, { merge: true })
375
+ .then(() => assistant.log(`Updated ${COLLECTION}/${uid}: status=completed`))
376
+ .catch((e) => {
377
+ // Ignore not-found — cart may not exist for this user
378
+ if (e.code !== 5) {
379
+ assistant.error(`Failed to update ${COLLECTION}/${uid}: ${e.message}`);
380
+ }
381
+ });
382
+
383
+ return transitionName;
384
+ }
385
+
386
+ /**
387
+ * Extract customer name from a raw payment processor resource
388
+ *
389
+ * @param {object} resource - Raw processor resource (Stripe subscription, session, invoice)
390
+ * @param {string} resourceType - 'subscription' | 'session' | 'invoice'
391
+ * @returns {{ first: string, last: string }|null}
392
+ */
393
+ function extractCustomerName(resource, resourceType) {
394
+ let fullName = null;
395
+
396
+ // Checkout sessions have customer_details.name
397
+ if (resourceType === 'session') {
398
+ fullName = resource.customer_details?.name;
399
+ }
400
+
401
+ // Invoices have customer_name
402
+ if (resourceType === 'invoice') {
403
+ fullName = resource.customer_name;
404
+ }
405
+
406
+ // PayPal orders have payer.name
407
+ if (resourceType === 'order') {
408
+ const givenName = resource.payer?.name?.given_name;
409
+ const surname = resource.payer?.name?.surname;
410
+
411
+ if (givenName) {
412
+ const { capitalize } = require('../../../libraries/infer-contact.js');
413
+ return {
414
+ first: capitalize(givenName) || null,
415
+ last: capitalize(surname) || null,
416
+ };
417
+ }
418
+ }
419
+
420
+ // Chargebee subscriptions carry shipping_address / billing_address with first_name + last_name
421
+ if (resourceType === 'subscription') {
422
+ const addr = resource.shipping_address || resource.billing_address;
423
+ if (addr?.first_name) {
424
+ const { capitalize } = require('../../../libraries/infer-contact.js');
425
+ return {
426
+ first: capitalize(addr.first_name) || null,
427
+ last: capitalize(addr.last_name) || null,
428
+ };
429
+ }
430
+ }
431
+
432
+ if (!fullName) {
433
+ return null;
434
+ }
435
+
436
+ const { capitalize } = require('../../../libraries/infer-contact.js');
437
+ const parts = fullName.trim().split(/\s+/);
438
+ return {
439
+ first: capitalize(parts[0]) || null,
440
+ last: capitalize(parts.slice(1).join(' ')) || null,
441
+ };
442
+ }
@@ -0,0 +1,21 @@
1
+ diff --git a/src/test/test-accounts.js b/src/test/test-accounts.js
2
+ index 796f4c3..5b5efc4 100644
3
+ --- a/src/test/test-accounts.js
4
+ +++ b/src/test/test-accounts.js
5
+ @@ -417,6 +417,16 @@ const JOURNEY_ACCOUNTS = {
6
+ subscription: { product: { id: 'basic' }, status: 'active' },
7
+ },
8
+ },
9
+ + // Journey: zombie/stale subscription guard (webhook for old sub should not overwrite current sub)
10
+ + 'journey-payments-zombie-sub': {
11
+ + id: 'journey-payments-zombie-sub',
12
+ + uid: '_test-journey-payments-zombie-sub',
13
+ + email: '_test.journey-payments-zombie-sub@{domain}',
14
+ + properties: {
15
+ + roles: {},
16
+ + subscription: { product: { id: 'basic' }, status: 'active' },
17
+ + },
18
+ + },
19
+ // Journey: legacy product ID resolution (webhook with legacy product ID maps to correct product)
20
+ 'journey-payments-legacy-product': {
21
+ id: 'journey-payments-legacy-product',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.175",
3
+ "version": "5.0.176",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -1,16 +1,15 @@
1
- const moment = require('moment');
1
+ const path = require('path');
2
2
  const powertools = require('node-powertools');
3
3
 
4
4
  /**
5
5
  * Firestore trigger: payments-disputes/{alertId} onWrite
6
6
  *
7
7
  * Processes pending dispute alerts:
8
- * 1. Tries direct match via charge ID or payment intent (from Chargeblast alert.updated)
9
- * 2. Falls back to searching Stripe invoices by date range + amount + card last4
10
- * 3. Issues full refund on matched charge
11
- * 4. Cancels subscription immediately (Stripe fires webhook → existing pipeline handles user doc)
12
- * 5. Sends email alert to brand contact
13
- * 6. Updates dispute document with results
8
+ * 1. Loads the processor module for the alert's payment processor
9
+ * 2. Searches for the matching charge via processor.searchAndMatch()
10
+ * 3. Issues refund + cancels subscription via processor.processDispute()
11
+ * 4. Sends email alert to brand contact
12
+ * 5. Updates dispute document with results
14
13
  */
15
14
  module.exports = async ({ assistant, change, context }) => {
16
15
  const Manager = assistant.Manager;
@@ -35,13 +34,16 @@ module.exports = async ({ assistant, change, context }) => {
35
34
 
36
35
  assistant.log(`Processing dispute ${alertId}: processor=${processor}, amount=${alert.amount}, card=****${alert.card.last4}, date=${alert.transactionDate}, chargeId=${alert.chargeId || 'none'}, paymentIntentId=${alert.paymentIntentId || 'none'}`);
37
36
 
38
- // Only Stripe is supported for now
39
- if (processor !== 'stripe') {
40
- throw new Error(`Unsupported processor: ${processor}. Only 'stripe' is currently supported.`);
37
+ // Load the processor module
38
+ let processorModule;
39
+ try {
40
+ processorModule = require(path.resolve(__dirname, `processors/${processor}.js`));
41
+ } catch (e) {
42
+ throw new Error(`Unsupported dispute processor: ${processor}`);
41
43
  }
42
44
 
43
45
  // Search for the matching charge
44
- const match = await searchAndMatch({ alert, assistant });
46
+ const match = await processorModule.searchAndMatch(alert, assistant);
45
47
 
46
48
  // Build timestamps
47
49
  const now = powertools.timestamp(new Date(), { output: 'string' });
@@ -80,13 +82,12 @@ module.exports = async ({ assistant, change, context }) => {
80
82
  }
81
83
 
82
84
  // Process refund and cancel
83
- const result = await processDispute({ match, alert, assistant });
85
+ const result = await processorModule.processDispute(match, alert, assistant);
84
86
 
85
87
  // Update dispute document with results
86
88
  await disputeRef.set({
87
89
  status: 'resolved',
88
90
  match: {
89
- method: match.method,
90
91
  invoiceId: match.invoiceId || null,
91
92
  subscriptionId: match.subscriptionId || null,
92
93
  customerId: match.customerId,
@@ -131,253 +132,6 @@ module.exports = async ({ assistant, change, context }) => {
131
132
  }
132
133
  };
133
134
 
134
- /**
135
- * Try to match the dispute alert to a Stripe charge.
136
- *
137
- * Strategy (in order):
138
- * 1. Direct lookup via charge ID (externalOrder from Chargeblast)
139
- * 2. Direct lookup via payment intent ID (metadata from Chargeblast)
140
- * 3. Fallback: search invoices by date range + amount + card last4
141
- *
142
- * @param {object} options
143
- * @param {object} options.alert - Normalized alert data
144
- * @param {object} options.assistant - Assistant instance
145
- * @returns {object|null} Match details or null
146
- */
147
- async function searchAndMatch({ alert, assistant }) {
148
- const StripeLib = require('../../../libraries/payment/processors/stripe.js');
149
- const stripe = StripeLib.init();
150
-
151
- // Strategy 1: Direct charge lookup
152
- if (alert.chargeId && alert.chargeId.startsWith('ch_')) {
153
- assistant.log(`Trying direct charge lookup: ${alert.chargeId}`);
154
-
155
- try {
156
- const charge = await stripe.charges.retrieve(alert.chargeId, {
157
- expand: ['invoice', 'customer'],
158
- });
159
-
160
- const match = await resolveMatchFromCharge({ charge, stripe, assistant, method: 'charge-id' });
161
-
162
- if (match) {
163
- return match;
164
- }
165
- } catch (e) {
166
- assistant.log(`Direct charge lookup failed for ${alert.chargeId}: ${e.message}`);
167
- }
168
- }
169
-
170
- // Strategy 2: Direct payment intent lookup
171
- if (alert.paymentIntentId && alert.paymentIntentId.startsWith('pi_')) {
172
- assistant.log(`Trying direct payment intent lookup: ${alert.paymentIntentId}`);
173
-
174
- try {
175
- const pi = await stripe.paymentIntents.retrieve(alert.paymentIntentId, {
176
- expand: ['latest_charge', 'latest_charge.invoice', 'latest_charge.customer'],
177
- });
178
-
179
- const charge = pi.latest_charge;
180
- if (charge) {
181
- const match = await resolveMatchFromCharge({ charge, stripe, assistant, method: 'payment-intent' });
182
-
183
- if (match) {
184
- return match;
185
- }
186
- }
187
- } catch (e) {
188
- assistant.log(`Direct payment intent lookup failed for ${alert.paymentIntentId}: ${e.message}`);
189
- }
190
- }
191
-
192
- // Strategy 3: Fallback — search invoices by date range + amount + card last4
193
- return searchInvoicesFallback({ alert, stripe, assistant });
194
- }
195
-
196
- /**
197
- * Build a match object from a Stripe charge
198
- */
199
- async function resolveMatchFromCharge({ charge, stripe, assistant, method }) {
200
- if (!charge || charge.status !== 'succeeded') {
201
- assistant.log(`Charge ${charge?.id} status=${charge?.status}, skipping`);
202
- return null;
203
- }
204
-
205
- // Resolve UID from customer metadata
206
- let uid = null;
207
- let email = null;
208
- const customerId = typeof charge.customer === 'string' ? charge.customer : charge.customer?.id;
209
-
210
- if (charge.customer && typeof charge.customer === 'object') {
211
- uid = charge.customer.metadata?.uid || null;
212
- email = charge.customer.email || null;
213
- } else if (customerId) {
214
- try {
215
- const customer = await stripe.customers.retrieve(customerId);
216
- uid = customer.metadata?.uid || null;
217
- email = customer.email || null;
218
- } catch (e) {
219
- assistant.error(`Failed to retrieve customer ${customerId}: ${e.message}`);
220
- }
221
- }
222
-
223
- // Resolve invoice/subscription
224
- const invoiceId = typeof charge.invoice === 'string'
225
- ? charge.invoice
226
- : charge.invoice?.id || null;
227
- const subscriptionId = typeof charge.invoice === 'object'
228
- ? charge.invoice?.subscription || null
229
- : null;
230
-
231
- assistant.log(`Matched via ${method}: charge=${charge.id}, customer=${customerId}, uid=${uid}, invoice=${invoiceId}`);
232
-
233
- return {
234
- method: method,
235
- invoiceId: invoiceId,
236
- subscriptionId: subscriptionId,
237
- customerId: customerId,
238
- uid: uid,
239
- email: email,
240
- chargeId: charge.id,
241
- };
242
- }
243
-
244
- /**
245
- * Fallback: search Stripe invoices by date range + amount and match card last4
246
- */
247
- async function searchInvoicesFallback({ alert, stripe, assistant }) {
248
- const amountCents = Math.round(alert.amount * 100);
249
- const alertDate = moment(alert.transactionDate);
250
-
251
- if (!alertDate.isValid()) {
252
- throw new Error(`Invalid transactionDate: ${alert.transactionDate}`);
253
- }
254
-
255
- const start = alertDate.clone().subtract(2, 'days').unix();
256
- const end = alertDate.clone().add(2, 'days').unix();
257
-
258
- assistant.log(`Fallback: searching Stripe invoices: amount=${amountCents} cents, range=${moment.unix(start).format('YYYY-MM-DD')} to ${moment.unix(end).format('YYYY-MM-DD')}`);
259
-
260
- // Search invoices by date range and amount
261
- const invoices = await stripe.invoices.search({
262
- limit: 100,
263
- query: `created>${start} AND created<${end} AND total:${amountCents}`,
264
- expand: ['data.payment_intent.payment_method'],
265
- });
266
-
267
- if (!invoices.data.length) {
268
- assistant.log(`No invoices found for amount=${amountCents} in date range`);
269
- return null;
270
- }
271
-
272
- if (invoices.data.length >= 100) {
273
- assistant.log(`Warning: 100+ invoices found, results may be truncated`);
274
- }
275
-
276
- assistant.log(`Found ${invoices.data.length} invoice(s), matching card last4=${alert.card.last4}`);
277
-
278
- // Loop through invoices and match card last4
279
- for (const invoice of invoices.data) {
280
- const invoiceLast4 = invoice?.payment_intent?.payment_method?.card?.last4;
281
-
282
- assistant.log(`Checking invoice ${invoice.id}: card last4=${invoiceLast4 || 'unknown'}`);
283
-
284
- if (!invoiceLast4 || invoiceLast4 !== alert.card.last4) {
285
- continue;
286
- }
287
-
288
- assistant.log(`Matched invoice ${invoice.id}: card last4=${invoiceLast4}`);
289
-
290
- // Resolve UID from customer metadata
291
- let uid = null;
292
- let email = null;
293
- const customerId = invoice.customer;
294
- if (customerId) {
295
- try {
296
- const customer = await stripe.customers.retrieve(customerId);
297
- uid = customer.metadata?.uid || null;
298
- email = customer.email || null;
299
- } catch (e) {
300
- assistant.error(`Failed to retrieve customer ${customerId}: ${e.message}`);
301
- }
302
- }
303
-
304
- return {
305
- method: 'invoice-search',
306
- invoiceId: invoice.id,
307
- subscriptionId: invoice.subscription || null,
308
- customerId: customerId,
309
- uid: uid,
310
- email: email,
311
- chargeId: invoice.charge || null,
312
- };
313
- }
314
-
315
- assistant.log(`No invoice matched card last4=${alert.card.last4}`);
316
- return null;
317
- }
318
-
319
- /**
320
- * Process refund + cancellation for a matched dispute
321
- *
322
- * @param {object} options
323
- * @param {object} options.match - Match details from searchAndMatch
324
- * @param {object} options.alert - Normalized alert data
325
- * @param {object} options.assistant - Assistant instance
326
- * @returns {object} Result with statuses
327
- */
328
- async function processDispute({ match, alert, assistant }) {
329
- const StripeLib = require('../../../libraries/payment/processors/stripe.js');
330
- const stripe = StripeLib.init();
331
-
332
- const amountCents = Math.round(alert.amount * 100);
333
- const result = {
334
- refundId: null,
335
- amountRefunded: null,
336
- currency: null,
337
- refundStatus: 'skipped',
338
- cancelStatus: 'skipped',
339
- errors: [],
340
- };
341
-
342
- // Issue full refund
343
- if (match.chargeId) {
344
- try {
345
- const refund = await stripe.refunds.create({
346
- charge: match.chargeId,
347
- amount: amountCents,
348
- });
349
-
350
- result.refundId = refund.id;
351
- result.amountRefunded = amountCents;
352
- result.currency = refund.currency;
353
- result.refundStatus = 'success';
354
-
355
- assistant.log(`Refund success: refundId=${refund.id}, amount=${amountCents}, charge=${match.chargeId}`);
356
- } catch (e) {
357
- result.refundStatus = 'failed';
358
- result.errors.push(`Refund failed: ${e.message}`);
359
- assistant.error(`Refund failed for charge ${match.chargeId}: ${e.message}`);
360
- }
361
- }
362
-
363
- // Cancel subscription immediately
364
- // Stripe fires customer.subscription.deleted webhook → existing pipeline handles user doc update
365
- if (match.subscriptionId) {
366
- try {
367
- await stripe.subscriptions.cancel(match.subscriptionId);
368
- result.cancelStatus = 'success';
369
-
370
- assistant.log(`Subscription cancelled: sub=${match.subscriptionId}`);
371
- } catch (e) {
372
- result.cancelStatus = 'failed';
373
- result.errors.push(`Cancel failed: ${e.message}`);
374
- assistant.error(`Cancel failed for sub ${match.subscriptionId}: ${e.message}`);
375
- }
376
- }
377
-
378
- return result;
379
- }
380
-
381
135
  /**
382
136
  * Send dispute alert email to brand contact (fire-and-forget)
383
137
  *
@@ -0,0 +1,213 @@
1
+ const moment = require('moment');
2
+
3
+ const StripeLib = require('../../../../libraries/payment/processors/stripe.js');
4
+
5
+ /**
6
+ * Stripe dispute processor
7
+ *
8
+ * Implements the dispute processor interface for Stripe:
9
+ * - searchAndMatch(alert, assistant) → match | null
10
+ * - processDispute(match, alert, assistant) → result
11
+ *
12
+ * Match strategy: search charges by amount + date range, then confirm card last4.
13
+ * If alert.chargeId is provided (alert.updated events), verify it directly — otherwise
14
+ * fall back to the charge search. Both paths go through the same resolveMatchFromCharge()
15
+ * so the returned match shape is always identical.
16
+ *
17
+ * We use charges.search() rather than invoices.search() because Stripe invoices can have
18
+ * a null charge field even when paid (payment applied via credit/balance), making invoice
19
+ * search unreliable. Charges always have payment_method_details.card.last4.
20
+ */
21
+
22
+ /**
23
+ * Find the Stripe charge matching this dispute alert.
24
+ *
25
+ * If alert.chargeId is present (Chargeblast alert.updated), verify it directly.
26
+ * Otherwise search charges by amount + ±2 day window and match card last4.
27
+ *
28
+ * @param {object} alert - Normalized alert data
29
+ * @param {object} assistant - Assistant instance
30
+ * @returns {object|null} Match details or null
31
+ */
32
+ async function searchAndMatch(alert, assistant) {
33
+ const stripe = StripeLib.init();
34
+
35
+ // If Chargeblast already gave us the charge ID, verify it directly
36
+ if (alert.chargeId && alert.chargeId.startsWith('ch_')) {
37
+ assistant.log(`Direct charge lookup: ${alert.chargeId}`);
38
+
39
+ try {
40
+ const charge = await stripe.charges.retrieve(alert.chargeId, {
41
+ expand: ['invoice', 'invoice.subscription', 'customer'],
42
+ });
43
+ return resolveMatchFromCharge({ charge, stripe, assistant });
44
+ } catch (e) {
45
+ assistant.log(`Direct charge lookup failed for ${alert.chargeId}: ${e.message}`);
46
+ }
47
+ }
48
+
49
+ // Search charges by amount + date range, match card last4
50
+ const amountCents = Math.round(alert.amount * 100);
51
+ const alertDate = moment(alert.transactionDate);
52
+
53
+ if (!alertDate.isValid()) {
54
+ throw new Error(`Invalid transactionDate: ${alert.transactionDate}`);
55
+ }
56
+
57
+ const start = alertDate.clone().subtract(2, 'days').unix();
58
+ const end = alertDate.clone().add(2, 'days').unix();
59
+
60
+ assistant.log(`Searching charges: amount=${amountCents} cents, range=${moment.unix(start).format('YYYY-MM-DD')} to ${moment.unix(end).format('YYYY-MM-DD')}, last4=${alert.card.last4}`);
61
+
62
+ const charges = await stripe.charges.search({
63
+ limit: 100,
64
+ query: `amount:${amountCents} AND created>${start} AND created<${end}`,
65
+ });
66
+
67
+ if (!charges.data.length) {
68
+ assistant.log(`No charges found for amount=${amountCents} in date range`);
69
+ return null;
70
+ }
71
+
72
+ if (charges.data.length >= 100) {
73
+ assistant.log(`Warning: 100+ charges found, results may be truncated`);
74
+ }
75
+
76
+ assistant.log(`Found ${charges.data.length} charge(s), matching last4=${alert.card.last4}`);
77
+
78
+ for (const charge of charges.data) {
79
+ const chargeLast4 = charge.payment_method_details?.card?.last4;
80
+
81
+ if (!chargeLast4 || chargeLast4 !== alert.card.last4) {
82
+ continue;
83
+ }
84
+
85
+ // Fetch full charge with invoice + subscription expanded
86
+ try {
87
+ const fullCharge = await stripe.charges.retrieve(charge.id, {
88
+ expand: ['invoice', 'invoice.subscription', 'customer'],
89
+ });
90
+ return resolveMatchFromCharge({ charge: fullCharge, stripe, assistant });
91
+ } catch (e) {
92
+ assistant.log(`Failed to expand charge ${charge.id}: ${e.message}`);
93
+ }
94
+ }
95
+
96
+ assistant.log(`No charge matched last4=${alert.card.last4}`);
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Issue a refund and cancel the subscription for a matched dispute.
102
+ *
103
+ * @param {object} match - Match details from searchAndMatch
104
+ * @param {object} alert - Normalized alert data
105
+ * @param {object} assistant - Assistant instance
106
+ * @returns {object} Result with statuses
107
+ */
108
+ async function processDispute(match, alert, assistant) {
109
+ const stripe = StripeLib.init();
110
+
111
+ const amountCents = Math.round(alert.amount * 100);
112
+ const result = {
113
+ refundId: null,
114
+ amountRefunded: null,
115
+ currency: null,
116
+ refundStatus: 'skipped',
117
+ cancelStatus: 'skipped',
118
+ errors: [],
119
+ };
120
+
121
+ // Issue full refund
122
+ if (match.chargeId) {
123
+ try {
124
+ const refund = await stripe.refunds.create({
125
+ charge: match.chargeId,
126
+ amount: amountCents,
127
+ });
128
+
129
+ result.refundId = refund.id;
130
+ result.amountRefunded = amountCents;
131
+ result.currency = refund.currency;
132
+ result.refundStatus = 'success';
133
+
134
+ assistant.log(`Refund success: refundId=${refund.id}, amount=${amountCents}, charge=${match.chargeId}`);
135
+ } catch (e) {
136
+ result.refundStatus = 'failed';
137
+ result.errors.push(`Refund failed: ${e.message}`);
138
+ assistant.error(`Refund failed for charge ${match.chargeId}: ${e.message}`);
139
+ }
140
+ }
141
+
142
+ // Cancel subscription immediately
143
+ // Stripe fires customer.subscription.deleted webhook → existing pipeline handles user doc update
144
+ if (match.subscriptionId) {
145
+ try {
146
+ await stripe.subscriptions.cancel(match.subscriptionId);
147
+ result.cancelStatus = 'success';
148
+
149
+ assistant.log(`Subscription cancelled: sub=${match.subscriptionId}`);
150
+ } catch (e) {
151
+ result.cancelStatus = 'failed';
152
+ result.errors.push(`Cancel failed: ${e.message}`);
153
+ assistant.error(`Cancel failed for sub ${match.subscriptionId}: ${e.message}`);
154
+ }
155
+ }
156
+
157
+ return result;
158
+ }
159
+
160
+ module.exports = { searchAndMatch, processDispute };
161
+
162
+ // ---
163
+
164
+ /**
165
+ * Build a match object from a Stripe charge (with invoice + subscription already expanded).
166
+ *
167
+ * @param {object} options.charge - Stripe charge with invoice + customer expanded
168
+ * @param {object} options.stripe - Stripe SDK instance
169
+ * @param {object} options.assistant - Assistant instance
170
+ * @returns {object|null}
171
+ */
172
+ async function resolveMatchFromCharge({ charge, stripe, assistant }) {
173
+ if (!charge || charge.status !== 'succeeded') {
174
+ assistant.log(`Charge ${charge?.id} status=${charge?.status}, skipping`);
175
+ return null;
176
+ }
177
+
178
+ // Resolve UID + email from customer
179
+ let uid = null;
180
+ let email = null;
181
+ const customerId = typeof charge.customer === 'string' ? charge.customer : charge.customer?.id;
182
+
183
+ if (charge.customer && typeof charge.customer === 'object') {
184
+ uid = charge.customer.metadata?.uid || null;
185
+ email = charge.customer.email || null;
186
+ } else if (customerId) {
187
+ try {
188
+ const customer = await stripe.customers.retrieve(customerId);
189
+ uid = customer.metadata?.uid || null;
190
+ email = customer.email || null;
191
+ } catch (e) {
192
+ assistant.error(`Failed to retrieve customer ${customerId}: ${e.message}`);
193
+ }
194
+ }
195
+
196
+ // Resolve invoice + subscription from expanded invoice
197
+ const invoice = typeof charge.invoice === 'object' ? charge.invoice : null;
198
+ const invoiceId = invoice?.id || (typeof charge.invoice === 'string' ? charge.invoice : null);
199
+ const subscriptionId = invoice?.subscription
200
+ ? (typeof invoice.subscription === 'object' ? invoice.subscription.id : invoice.subscription)
201
+ : null;
202
+
203
+ assistant.log(`Matched charge=${charge.id}, customer=${customerId}, uid=${uid}, invoice=${invoiceId}, subscription=${subscriptionId}`);
204
+
205
+ return {
206
+ chargeId: charge.id,
207
+ invoiceId: invoiceId,
208
+ subscriptionId: subscriptionId,
209
+ customerId: customerId,
210
+ uid: uid,
211
+ email: email,
212
+ };
213
+ }
@@ -14,7 +14,7 @@ module.exports = {
14
14
  * @returns {object} Normalized dispute alert
15
15
  */
16
16
  normalize(body) {
17
- if (!body.id) {
17
+ if (!body.id && !body.alertId) {
18
18
  throw new Error('Missing required field: id');
19
19
  }
20
20
  if (!body.card) {
@@ -30,7 +30,7 @@ module.exports = {
30
30
  const cardStr = String(body.card);
31
31
 
32
32
  return {
33
- id: String(body.id),
33
+ id: String(body.id || body.alertId),
34
34
  card: {
35
35
  last4: cardStr.slice(-4),
36
36
  brand: body.cardBrand ? String(body.cardBrand).toLowerCase() : null,
@@ -115,6 +115,14 @@ module.exports = {
115
115
  amount: 29.99,
116
116
  transactionDate: '2026-03-07 14:30:00',
117
117
  processor: 'stripe',
118
+ alertType: 'FRAUD',
119
+ customerEmail: 'test@example.com',
120
+ externalOrder: 'ch_test123',
121
+ metadata: 'pi_test456',
122
+ externalUrl: 'https://dashboard.stripe.com/charges/ch_test123',
123
+ reasonCode: 'WIP',
124
+ subprovider: 'Ethoca',
125
+ isRefunded: false,
118
126
  });
119
127
 
120
128
  assert.isSuccess(response, 'Should accept valid Chargeblast alert');
@@ -129,13 +137,23 @@ module.exports = {
129
137
  'Status should be pending or processing',
130
138
  );
131
139
 
132
- // Verify normalized alert data
140
+ // Verify core normalized alert data
133
141
  assert.equal(doc.alert.card.last4, '4242', 'Should extract last4 from full card number');
134
142
  assert.equal(doc.alert.card.brand, 'visa', 'Should lowercase card brand');
135
143
  assert.equal(doc.alert.amount, 29.99, 'Amount should be preserved');
136
144
  assert.equal(doc.alert.transactionDate, '2026-03-07', 'Should extract date without time');
137
145
  assert.equal(doc.alert.processor, 'stripe', 'Processor should be stripe');
138
146
 
147
+ // Verify new normalized fields
148
+ assert.equal(doc.alert.alertType, 'FRAUD', 'Alert type should be preserved');
149
+ assert.equal(doc.alert.customerEmail, 'test@example.com', 'Customer email should be preserved');
150
+ assert.equal(doc.alert.chargeId, 'ch_test123', 'Charge ID should be extracted from externalOrder');
151
+ assert.equal(doc.alert.paymentIntentId, 'pi_test456', 'Payment intent ID should be extracted from metadata');
152
+ assert.equal(doc.alert.stripeUrl, 'https://dashboard.stripe.com/charges/ch_test123', 'Stripe URL should be preserved');
153
+ assert.equal(doc.alert.reasonCode, 'WIP', 'Reason code should be preserved');
154
+ assert.equal(doc.alert.subprovider, 'Ethoca', 'Subprovider should be preserved');
155
+ assert.equal(doc.alert.isRefunded, false, 'isRefunded should be preserved');
156
+
139
157
  // Verify raw payload is preserved
140
158
  assert.ok(doc.raw, 'Raw payload should be preserved');
141
159
  assert.equal(doc.raw.id, alertId, 'Raw id should match');
@@ -145,6 +163,73 @@ module.exports = {
145
163
  },
146
164
  },
147
165
 
166
+ {
167
+ name: 'accepts-alert-with-alertId-field',
168
+ auth: 'none',
169
+ async run({ http, assert, firestore }) {
170
+ const alertId = '_test-dispute-alertid-field';
171
+
172
+ // Clean up any existing doc
173
+ await firestore.delete(`payments-disputes/${alertId}`);
174
+
175
+ // Chargeblast alert.created events use alertId instead of id
176
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
177
+ alertId: alertId,
178
+ card: '546616******5805',
179
+ cardBrand: 'Mastercard',
180
+ amount: 8,
181
+ transactionDate: '2026-03-19 00:00:00.000000Z',
182
+ });
183
+
184
+ assert.isSuccess(response, 'Should accept alert using alertId field');
185
+
186
+ const doc = await firestore.get(`payments-disputes/${alertId}`);
187
+ assert.ok(doc, 'Dispute doc should exist in Firestore');
188
+ assert.equal(doc.id, alertId, 'Doc ID should match alertId');
189
+ assert.equal(doc.alert.id, alertId, 'Alert id should be set from alertId');
190
+ assert.equal(doc.alert.card.last4, '5805', 'Should extract last4 from masked card');
191
+
192
+ // Clean up
193
+ await firestore.delete(`payments-disputes/${alertId}`);
194
+ },
195
+ },
196
+
197
+ {
198
+ name: 'accepts-alert-without-optional-fields',
199
+ auth: 'none',
200
+ async run({ http, assert, firestore }) {
201
+ const alertId = '_test-dispute-minimal';
202
+
203
+ // Clean up any existing doc
204
+ await firestore.delete(`payments-disputes/${alertId}`);
205
+
206
+ // Send minimal alert (alert.created shape — no externalOrder, metadata, etc.)
207
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
208
+ id: alertId,
209
+ card: '9124',
210
+ amount: 10,
211
+ transactionDate: '2026-03-21 00:01:02',
212
+ });
213
+
214
+ assert.isSuccess(response, 'Should accept minimal alert');
215
+
216
+ const doc = await firestore.get(`payments-disputes/${alertId}`);
217
+ assert.equal(doc.alert.card.last4, '9124', 'Should use card as last4');
218
+ assert.equal(doc.alert.processor, 'stripe', 'Processor should default to stripe');
219
+ assert.equal(doc.alert.chargeId, null, 'Charge ID should be null when not provided');
220
+ assert.equal(doc.alert.paymentIntentId, null, 'Payment intent should be null when not provided');
221
+ assert.equal(doc.alert.customerEmail, null, 'Customer email should be null when not provided');
222
+ assert.equal(doc.alert.alertType, null, 'Alert type should be null when not provided');
223
+ assert.equal(doc.alert.stripeUrl, null, 'Stripe URL should be null when not provided');
224
+ assert.equal(doc.alert.reasonCode, null, 'Reason code should be null when not provided');
225
+ assert.equal(doc.alert.subprovider, null, 'Subprovider should be null when not provided');
226
+ assert.equal(doc.alert.isRefunded, false, 'isRefunded should default to false');
227
+
228
+ // Clean up
229
+ await firestore.delete(`payments-disputes/${alertId}`);
230
+ },
231
+ },
232
+
148
233
  {
149
234
  name: 'accepts-alert-with-last4-only',
150
235
  auth: 'none',
@@ -242,7 +327,7 @@ module.exports = {
242
327
  },
243
328
 
244
329
  {
245
- name: 'defaults-alerts-to-chargeblast',
330
+ name: 'defaults-provider-to-chargeblast',
246
331
  auth: 'none',
247
332
  async run({ http, assert, firestore }) {
248
333
  const alertId = '_test-dispute-default-provider';
@@ -250,7 +335,7 @@ module.exports = {
250
335
  // Clean up any existing doc
251
336
  await firestore.delete(`payments-disputes/${alertId}`);
252
337
 
253
- // Send without alerts query param
338
+ // Send without provider query param
254
339
  const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
255
340
  id: alertId,
256
341
  card: '4242',
@@ -258,7 +343,7 @@ module.exports = {
258
343
  transactionDate: '2026-01-15',
259
344
  });
260
345
 
261
- assert.isSuccess(response, 'Should accept without explicit alerts param');
346
+ assert.isSuccess(response, 'Should accept without explicit provider param');
262
347
 
263
348
  const doc = await firestore.get(`payments-disputes/${alertId}`);
264
349
  assert.equal(doc.provider, 'chargeblast', 'Provider should default to chargeblast');