backend-manager 5.0.175 → 5.0.177

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.
@@ -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
+ }
@@ -15,22 +15,27 @@ const moment = require('moment');
15
15
  * @param {object} options.userDoc - User document data (passed as `to` — email.js extracts email/name and user template data)
16
16
  * @param {object} options.assistant - Assistant instance
17
17
  */
18
- function sendOrderEmail({ template, subject, categories, data, userDoc, assistant, copy, sender = 'orders' }) {
18
+ function sendOrderEmail({ template, subject, categories, data, userDoc, assistant, copy, internalOnly, sender = 'orders' }) {
19
19
  const email = assistant.Manager.Email(assistant);
20
20
  const uid = userDoc?.auth?.uid;
21
21
 
22
- if (!userDoc?.auth?.email) {
22
+ if (!internalOnly && !userDoc?.auth?.email) {
23
23
  assistant.error(`sendOrderEmail(): No email found for uid=${uid}, skipping`);
24
24
  return;
25
25
  }
26
26
 
27
+ const brandContact = assistant.Manager.config?.brand?.contact;
28
+ const to = internalOnly
29
+ ? { email: brandContact?.email, name: brandContact?.name || assistant.Manager.config?.brand?.name }
30
+ : userDoc;
31
+
27
32
  email.send({
28
33
  sender,
29
- to: userDoc,
34
+ to,
30
35
  subject,
31
36
  template,
32
37
  categories,
33
- copy: copy !== false,
38
+ copy: internalOnly ? false : copy !== false,
34
39
  data,
35
40
  })
36
41
  .then((result) => {
@@ -11,6 +11,7 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
11
11
  template: 'main/order/payment-recovered',
12
12
  subject: `Payment received for order #${order?.id || ''}`,
13
13
  categories: ['order/payment-recovered'],
14
+ internalOnly: true,
14
15
  userDoc,
15
16
  assistant,
16
17
  data: {
@@ -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,