backend-manager 5.0.169 → 5.0.171

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/TODO-2.md CHANGED
@@ -10,6 +10,9 @@ payments/reactivate
10
10
  payments/upgrade
11
11
  * takes a subscription id and a new plan id and upgrades the user's subscription to the new plan. this can only be done if the user has an active subscription.
12
12
 
13
+ -------
14
+ UPSELL
15
+ * products in BEM can have an UPSELL where you link another product ID and it allows you to add it to your cart OR shows you after checkout?
13
16
 
14
17
  -------
15
18
  USER OBJECT UPDGRADE --> INSTANCE?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.169",
3
+ "version": "5.0.171",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -235,7 +235,7 @@ function uploadPost(assistant, settings, article) {
235
235
  author: settings.author,
236
236
  categories: article.categories,
237
237
  tags: article.keywords,
238
- path: 'ghostii',
238
+ postPath: 'ghostii',
239
239
  githubUser: settings.brand.github.user,
240
240
  githubRepo: settings.brand.github.repo,
241
241
  },
@@ -5,9 +5,9 @@ const powertools = require('node-powertools');
5
5
  * Firestore trigger: payments-disputes/{alertId} onWrite
6
6
  *
7
7
  * Processes pending dispute alerts:
8
- * 1. Searches Stripe invoices by date range + amount
9
- * 2. Matches card last4 digits
10
- * 3. Issues full refund on matched invoice
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
11
  * 4. Cancels subscription immediately (Stripe fires webhook → existing pipeline handles user doc)
12
12
  * 5. Sends email alert to brand contact
13
13
  * 6. Updates dispute document with results
@@ -33,14 +33,14 @@ module.exports = async ({ assistant, change, context }) => {
33
33
  const alert = dataAfter.alert;
34
34
  const processor = alert.processor || 'stripe';
35
35
 
36
- assistant.log(`Processing dispute ${alertId}: processor=${processor}, amount=${alert.amount}, card=****${alert.card.last4}, date=${alert.transactionDate}`);
36
+ 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
37
 
38
38
  // Only Stripe is supported for now
39
39
  if (processor !== 'stripe') {
40
40
  throw new Error(`Unsupported processor: ${processor}. Only 'stripe' is currently supported.`);
41
41
  }
42
42
 
43
- // Search for the matching invoice and process
43
+ // Search for the matching charge
44
44
  const match = await searchAndMatch({ alert, assistant });
45
45
 
46
46
  // Build timestamps
@@ -48,9 +48,10 @@ module.exports = async ({ assistant, change, context }) => {
48
48
  const nowUNIX = powertools.timestamp(now, { output: 'unix' });
49
49
 
50
50
  if (!match) {
51
- // No matching invoice found
51
+ // No matching charge found
52
52
  await disputeRef.set({
53
53
  status: 'no-match',
54
+ match: null,
54
55
  actions: {
55
56
  refund: 'skipped',
56
57
  cancel: 'skipped',
@@ -64,11 +65,11 @@ module.exports = async ({ assistant, change, context }) => {
64
65
  },
65
66
  }, { merge: true });
66
67
 
67
- assistant.log(`Dispute ${alertId}: no matching invoice found`);
68
+ assistant.log(`Dispute ${alertId}: no matching charge found`);
68
69
 
69
70
  // Still send email to alert brand about unmatched dispute
70
71
  if (!assistant.isTesting() || process.env.TEST_EXTENDED_MODE) {
71
- sendDisputeEmail({ alert, match: null, alertId, assistant });
72
+ sendDisputeEmail({ alert, match: null, result: null, alertId, assistant });
72
73
  await disputeRef.set({ actions: { email: 'success' } }, { merge: true });
73
74
  } else {
74
75
  assistant.log(`Dispute ${alertId}: skipping email (testing mode)`);
@@ -85,10 +86,12 @@ module.exports = async ({ assistant, change, context }) => {
85
86
  await disputeRef.set({
86
87
  status: 'resolved',
87
88
  match: {
88
- invoiceId: match.invoiceId,
89
+ method: match.method,
90
+ invoiceId: match.invoiceId || null,
89
91
  subscriptionId: match.subscriptionId || null,
90
92
  customerId: match.customerId,
91
93
  uid: match.uid || null,
94
+ email: match.email || null,
92
95
  chargeId: match.chargeId,
93
96
  refundId: result.refundId || null,
94
97
  amountRefunded: result.amountRefunded || null,
@@ -129,7 +132,12 @@ module.exports = async ({ assistant, change, context }) => {
129
132
  };
130
133
 
131
134
  /**
132
- * Search Stripe invoices by date range + amount and match card last4
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
133
141
  *
134
142
  * @param {object} options
135
143
  * @param {object} options.alert - Normalized alert data
@@ -140,6 +148,103 @@ async function searchAndMatch({ alert, assistant }) {
140
148
  const StripeLib = require('../../../libraries/payment/processors/stripe.js');
141
149
  const stripe = StripeLib.init();
142
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 }) {
143
248
  const amountCents = Math.round(alert.amount * 100);
144
249
  const alertDate = moment(alert.transactionDate);
145
250
 
@@ -150,7 +255,7 @@ async function searchAndMatch({ alert, assistant }) {
150
255
  const start = alertDate.clone().subtract(2, 'days').unix();
151
256
  const end = alertDate.clone().add(2, 'days').unix();
152
257
 
153
- assistant.log(`Searching Stripe invoices: amount=${amountCents} cents, range=${moment.unix(start).format('YYYY-MM-DD')} to ${moment.unix(end).format('YYYY-MM-DD')}`);
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')}`);
154
259
 
155
260
  // Search invoices by date range and amount
156
261
  const invoices = await stripe.invoices.search({
@@ -184,21 +289,25 @@ async function searchAndMatch({ alert, assistant }) {
184
289
 
185
290
  // Resolve UID from customer metadata
186
291
  let uid = null;
292
+ let email = null;
187
293
  const customerId = invoice.customer;
188
294
  if (customerId) {
189
295
  try {
190
296
  const customer = await stripe.customers.retrieve(customerId);
191
297
  uid = customer.metadata?.uid || null;
298
+ email = customer.email || null;
192
299
  } catch (e) {
193
300
  assistant.error(`Failed to retrieve customer ${customerId}: ${e.message}`);
194
301
  }
195
302
  }
196
303
 
197
304
  return {
305
+ method: 'invoice-search',
198
306
  invoiceId: invoice.id,
199
307
  subscriptionId: invoice.subscription || null,
200
308
  customerId: customerId,
201
309
  uid: uid,
310
+ email: email,
202
311
  chargeId: invoice.charge || null,
203
312
  };
204
313
  }
@@ -243,11 +352,11 @@ async function processDispute({ match, alert, assistant }) {
243
352
  result.currency = refund.currency;
244
353
  result.refundStatus = 'success';
245
354
 
246
- assistant.log(`Refund success: refundId=${refund.id}, amount=${amountCents}, invoice=${match.invoiceId}`);
355
+ assistant.log(`Refund success: refundId=${refund.id}, amount=${amountCents}, charge=${match.chargeId}`);
247
356
  } catch (e) {
248
357
  result.refundStatus = 'failed';
249
358
  result.errors.push(`Refund failed: ${e.message}`);
250
- assistant.error(`Refund failed for invoice ${match.invoiceId}: ${e.message}`);
359
+ assistant.error(`Refund failed for charge ${match.chargeId}: ${e.message}`);
251
360
  }
252
361
  }
253
362
 
@@ -290,47 +399,72 @@ function sendDisputeEmail({ alert, match, result, alertId, assistant }) {
290
399
  }
291
400
 
292
401
  const matched = match ? 'Matched' : 'Unmatched';
293
- const subject = `Dispute alert: ${matched} [${alertId}]`;
294
-
295
- const disputeDetails = {
296
- id: alertId,
297
- card: `****${alert.card.last4} (${alert.card.brand || 'unknown'})`,
298
- amount: `$${alert.amount}`,
299
- date: alert.transactionDate,
300
- processor: alert.processor,
301
- };
402
+ const subject = `Dispute Alert: ${matched} — $${alert.amount} on ****${alert.card.last4} [${alertId}]`;
302
403
 
303
- const matchDetails = match
304
- ? {
305
- invoiceId: match.invoiceId,
306
- subscriptionId: match.subscriptionId || 'N/A',
307
- uid: match.uid || 'unknown',
308
- refund: result?.refundStatus || 'N/A',
309
- cancel: result?.cancelStatus || 'N/A',
310
- }
311
- : null;
404
+ const messageLines = [];
312
405
 
313
- const messageLines = [
314
- `A dispute alert has been received and ${match ? 'automatically processed' : 'could not be matched to an invoice'}.`,
315
- '',
316
- '<strong>Dispute Details:</strong>',
317
- `<pre><code>${JSON.stringify(disputeDetails, null, 2)}</code></pre>`,
318
- ];
319
-
320
- if (matchDetails) {
321
- messageLines.push(
322
- '',
323
- '<strong>Match & Actions:</strong>',
324
- `<pre><code>${JSON.stringify(matchDetails, null, 2)}</code></pre>`,
325
- );
406
+ // Status banner
407
+ if (match && result) {
408
+ const hasErrors = result.errors?.length > 0;
409
+ const banner = hasErrors ? 'Partially processed (see errors below)' : 'Automatically processed';
410
+ messageLines.push(`<strong>${banner}</strong>`);
411
+ } else {
412
+ messageLines.push('<strong>Could not be matched to a charge — manual review required.</strong>');
326
413
  }
327
414
 
415
+ messageLines.push('');
416
+
417
+ // Alert details
418
+ messageLines.push('<strong>Alert Details:</strong>');
419
+ messageLines.push('<ul>');
420
+ messageLines.push(`<li><strong>Alert ID:</strong> ${alertId}</li>`);
421
+ messageLines.push(`<li><strong>Type:</strong> ${alert.alertType || 'N/A'}</li>`);
422
+ messageLines.push(`<li><strong>Card:</strong> ****${alert.card.last4} (${alert.card.brand || 'unknown'})</li>`);
423
+ messageLines.push(`<li><strong>Amount:</strong> $${alert.amount}</li>`);
424
+ messageLines.push(`<li><strong>Transaction Date:</strong> ${alert.transactionDate}</li>`);
425
+ messageLines.push(`<li><strong>Processor:</strong> ${alert.processor}</li>`);
426
+ messageLines.push(`<li><strong>Reason:</strong> ${alert.reasonCode || 'N/A'}</li>`);
427
+ messageLines.push(`<li><strong>Network:</strong> ${alert.subprovider || 'N/A'}</li>`);
428
+ messageLines.push(`<li><strong>Customer Email:</strong> ${alert.customerEmail || 'N/A'}</li>`);
429
+ messageLines.push(`<li><strong>Already Refunded:</strong> ${alert.isRefunded ? 'Yes' : 'No'}</li>`);
430
+ messageLines.push('</ul>');
431
+
432
+ // Match & action details
433
+ if (match) {
434
+ messageLines.push('<strong>Match Details:</strong>');
435
+ messageLines.push('<ul>');
436
+ messageLines.push(`<li><strong>Method:</strong> ${match.method}</li>`);
437
+ messageLines.push(`<li><strong>Charge:</strong> ${match.chargeId || 'N/A'}</li>`);
438
+ messageLines.push(`<li><strong>Invoice:</strong> ${match.invoiceId || 'N/A'}</li>`);
439
+ messageLines.push(`<li><strong>Subscription:</strong> ${match.subscriptionId || 'N/A'}</li>`);
440
+ messageLines.push(`<li><strong>Customer:</strong> ${match.customerId || 'N/A'}</li>`);
441
+ messageLines.push(`<li><strong>Customer Email:</strong> ${match.email || 'N/A'}</li>`);
442
+ messageLines.push(`<li><strong>UID:</strong> ${match.uid || 'unknown'}</li>`);
443
+ messageLines.push('</ul>');
444
+
445
+ if (result) {
446
+ messageLines.push('<strong>Actions Taken:</strong>');
447
+ messageLines.push('<ul>');
448
+ messageLines.push(`<li><strong>Refund:</strong> ${result.refundStatus}${result.refundId ? ` (${result.refundId})` : ''}${result.amountRefunded ? ` — $${(result.amountRefunded / 100).toFixed(2)} ${result.currency || ''}` : ''}</li>`);
449
+ messageLines.push(`<li><strong>Cancel Subscription:</strong> ${result.cancelStatus}</li>`);
450
+ messageLines.push('</ul>');
451
+ }
452
+ }
453
+
454
+ // Stripe link
455
+ if (alert.stripeUrl) {
456
+ messageLines.push(`<br><a href="${alert.stripeUrl}">View in Stripe Dashboard</a>`);
457
+ }
458
+
459
+ // Errors
328
460
  if (result?.errors?.length) {
329
- messageLines.push(
330
- '',
331
- '<strong>Errors:</strong>',
332
- `<pre><code>${JSON.stringify(result.errors, null, 2)}</code></pre>`,
333
- );
461
+ messageLines.push('');
462
+ messageLines.push('<strong>Errors:</strong>');
463
+ messageLines.push('<ul>');
464
+ result.errors.forEach((err) => {
465
+ messageLines.push(`<li>${err}</li>`);
466
+ });
467
+ messageLines.push('</ul>');
334
468
  }
335
469
 
336
470
  email.send({
@@ -342,11 +476,11 @@ function sendDisputeEmail({ alert, match, result, alertId, assistant }) {
342
476
  copy: true,
343
477
  data: {
344
478
  email: {
345
- preview: `Dispute alert: ${matched} — $${alert.amount} on ****${alert.card.last4}`,
479
+ preview: `Dispute Alert: ${matched} — $${alert.amount} on ****${alert.card.last4}`,
346
480
  },
347
481
  body: {
348
- title: subject,
349
- message: messageLines.join('<br>'),
482
+ title: `Dispute Alert: ${matched}`,
483
+ message: messageLines.join('\n'),
350
484
  },
351
485
  },
352
486
  })
@@ -108,7 +108,6 @@ const SCHEMA = {
108
108
  timestampUNIX: { type: 'number', default: 0 },
109
109
  },
110
110
  },
111
- requests: '$template',
112
111
  },
113
112
  personal: {
114
113
  birthday: '$timestamp',
@@ -2,12 +2,12 @@ const path = require('path');
2
2
  const powertools = require('node-powertools');
3
3
 
4
4
  /**
5
- * POST /payments/dispute-alert?alerts=chargeblast&key=XXX
5
+ * POST /payments/dispute-alert?provider=chargeblast&key=XXX
6
6
  * Receives dispute alert webhooks (e.g., from Chargeblast), validates them,
7
7
  * and saves to Firestore for async processing via onWrite trigger
8
8
  *
9
9
  * Query params:
10
- * - alerts: alert provider name (default: 'chargeblast')
10
+ * - provider: alert provider name (default: 'chargeblast')
11
11
  * - key: must match BACKEND_MANAGER_KEY
12
12
  */
13
13
  module.exports = async ({ assistant, Manager, libraries }) => {
@@ -22,7 +22,7 @@ module.exports = async ({ assistant, Manager, libraries }) => {
22
22
  }
23
23
 
24
24
  // Determine alert provider (default: chargeblast)
25
- const provider = query.alerts || 'chargeblast';
25
+ const provider = query.provider || 'chargeblast';
26
26
 
27
27
  // Load the processor module
28
28
  let processorModule;
@@ -2,9 +2,9 @@
2
2
  * Chargeblast dispute alert processor
3
3
  * Normalizes Chargeblast webhook payloads into a standard dispute alert shape
4
4
  *
5
- * Chargeblast sends:
6
- * id, card (full number or last4), cardBrand, amount (dollars),
7
- * transactionDate ("YYYY-MM-DD HH:MM:SS"), app, processor, alerts
5
+ * Chargeblast sends two event types:
6
+ * alert.created: id, card, cardBrand, amount, transactionDate, processor, etc.
7
+ * alert.updated: same + externalOrder (charge ID), metadata (payment intent), customerEmail, etc.
8
8
  */
9
9
  module.exports = {
10
10
  /**
@@ -38,6 +38,15 @@ module.exports = {
38
38
  amount: parseFloat(body.amount),
39
39
  transactionDate: String(body.transactionDate).split(' ')[0], // date only, no time
40
40
  processor: body.processor ? String(body.processor).toLowerCase() : 'stripe',
41
+ alertType: body.alertType || null,
42
+ customerEmail: body.customerEmail || null,
43
+ // Stripe-specific IDs provided by Chargeblast on alert.updated events
44
+ chargeId: body.externalOrder || null,
45
+ paymentIntentId: body.metadata || null,
46
+ stripeUrl: body.externalUrl || null,
47
+ reasonCode: body.reasonCode || null,
48
+ subprovider: body.subprovider || null,
49
+ isRefunded: body.isRefunded || false,
41
50
  };
42
51
  },
43
52
  };