backend-manager 5.0.170 → 5.0.173
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/package.json
CHANGED
|
@@ -58,6 +58,17 @@ module.exports = [
|
|
|
58
58
|
],
|
|
59
59
|
},
|
|
60
60
|
|
|
61
|
+
// Admin dashboard — active paid subscriber count
|
|
62
|
+
// Serves: .where('subscription.status', '==', 'active').where('subscription.product.id', '!=', 'basic')
|
|
63
|
+
{
|
|
64
|
+
collectionGroup: 'users',
|
|
65
|
+
queryScope: 'COLLECTION',
|
|
66
|
+
fields: [
|
|
67
|
+
{ fieldPath: 'subscription.status', order: 'ASCENDING' },
|
|
68
|
+
{ fieldPath: 'subscription.product.id', order: 'ASCENDING' },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
|
|
61
72
|
// GET /marketing/campaign — list by type + sendAt range
|
|
62
73
|
// Query: .where('type', '==', type).where('sendAt', '>=', start).where('sendAt', '<=', end)
|
|
63
74
|
{
|
|
@@ -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.
|
|
9
|
-
* 2.
|
|
10
|
-
* 3. Issues full refund on matched
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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(`
|
|
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},
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
'
|
|
317
|
-
`<
|
|
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
|
-
|
|
332
|
-
|
|
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
|
|
479
|
+
preview: `Dispute Alert: ${matched} — $${alert.amount} on ****${alert.card.last4}`,
|
|
346
480
|
},
|
|
347
481
|
body: {
|
|
348
|
-
title:
|
|
349
|
-
message: messageLines.join('
|
|
482
|
+
title: `Dispute Alert: ${matched}`,
|
|
483
|
+
message: messageLines.join('\n'),
|
|
350
484
|
},
|
|
351
485
|
},
|
|
352
486
|
})
|
|
@@ -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
|
|
7
|
-
*
|
|
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
|
};
|