backend-manager 5.0.122 → 5.0.124
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 +48 -0
- package/CLAUDE.md +18 -19
- package/README.md +7 -7
- package/package.json +1 -1
- package/src/manager/cron/daily/reset-usage.js +79 -73
- package/src/manager/cron/daily.js +2 -53
- package/src/manager/cron/frequent/abandoned-carts.js +148 -0
- package/src/manager/cron/frequent.js +3 -0
- package/src/manager/cron/runner.js +60 -0
- package/src/manager/events/firestore/payments-disputes/on-write.js +358 -0
- package/src/manager/events/firestore/payments-webhooks/analytics.js +245 -121
- package/src/manager/events/firestore/payments-webhooks/on-write.js +32 -2
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +11 -34
- package/src/manager/helpers/analytics.js +2 -2
- package/src/manager/helpers/usage.js +44 -20
- package/src/manager/helpers/user.js +2 -1
- package/src/manager/index.js +10 -0
- package/src/manager/libraries/abandoned-cart-config.js +12 -0
- package/src/manager/libraries/email.js +5 -5
- package/src/manager/libraries/openai.js +76 -7
- package/src/manager/libraries/payment/discount-codes.js +40 -0
- package/src/manager/libraries/recaptcha.js +36 -0
- package/src/manager/routes/app/get.js +1 -1
- package/src/manager/routes/marketing/contact/post.js +11 -29
- package/src/manager/routes/payments/discount/get.js +22 -0
- package/src/manager/routes/payments/dispute-alert/post.js +93 -0
- package/src/manager/routes/payments/dispute-alert/processors/chargeblast.js +43 -0
- package/src/manager/routes/payments/intent/post.js +29 -0
- package/src/manager/routes/payments/intent/processors/chargebee.js +59 -7
- package/src/manager/routes/payments/intent/processors/stripe.js +55 -7
- package/src/manager/routes/test/usage/post.js +10 -6
- package/src/manager/schemas/payments/discount/get.js +9 -0
- package/src/manager/schemas/payments/dispute-alert/post.js +6 -0
- package/src/manager/schemas/payments/intent/post.js +16 -0
- package/src/test/runner.js +14 -4
- package/src/test/test-accounts.js +18 -0
- package/templates/backend-manager-config.json +7 -1
- package/templates/firestore.rules +9 -1
- package/test/_legacy/usage.js +5 -5
- package/test/routes/marketing/contact.js +3 -2
- package/test/routes/payments/discount.js +80 -0
- package/test/routes/payments/dispute-alert.js +271 -0
- package/test/routes/payments/intent.js +60 -0
- package/test/routes/test/usage.js +134 -30
- package/test/rules/payments-carts.js +371 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
const moment = require('moment');
|
|
2
|
+
const powertools = require('node-powertools');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Firestore trigger: payments-disputes/{alertId} onWrite
|
|
6
|
+
*
|
|
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
|
|
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
|
|
14
|
+
*/
|
|
15
|
+
module.exports = async ({ assistant, change, context }) => {
|
|
16
|
+
const Manager = assistant.Manager;
|
|
17
|
+
const admin = Manager.libraries.admin;
|
|
18
|
+
|
|
19
|
+
const dataAfter = change.after.data();
|
|
20
|
+
|
|
21
|
+
// Short-circuit: deleted doc or non-pending status
|
|
22
|
+
if (!dataAfter || dataAfter.status !== 'pending') {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const alertId = context.params.alertId;
|
|
27
|
+
const disputeRef = admin.firestore().doc(`payments-disputes/${alertId}`);
|
|
28
|
+
|
|
29
|
+
// Set status to processing
|
|
30
|
+
await disputeRef.set({ status: 'processing' }, { merge: true });
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const alert = dataAfter.alert;
|
|
34
|
+
const processor = alert.processor || 'stripe';
|
|
35
|
+
|
|
36
|
+
assistant.log(`Processing dispute ${alertId}: processor=${processor}, amount=${alert.amount}, card=****${alert.card.last4}, date=${alert.transactionDate}`);
|
|
37
|
+
|
|
38
|
+
// Only Stripe is supported for now
|
|
39
|
+
if (processor !== 'stripe') {
|
|
40
|
+
throw new Error(`Unsupported processor: ${processor}. Only 'stripe' is currently supported.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Search for the matching invoice and process
|
|
44
|
+
const match = await searchAndMatch({ alert, assistant });
|
|
45
|
+
|
|
46
|
+
// Build timestamps
|
|
47
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
48
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
49
|
+
|
|
50
|
+
if (!match) {
|
|
51
|
+
// No matching invoice found
|
|
52
|
+
await disputeRef.set({
|
|
53
|
+
status: 'no-match',
|
|
54
|
+
actions: {
|
|
55
|
+
refund: 'skipped',
|
|
56
|
+
cancel: 'skipped',
|
|
57
|
+
email: 'pending',
|
|
58
|
+
},
|
|
59
|
+
metadata: {
|
|
60
|
+
completed: {
|
|
61
|
+
timestamp: now,
|
|
62
|
+
timestampUNIX: nowUNIX,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}, { merge: true });
|
|
66
|
+
|
|
67
|
+
assistant.log(`Dispute ${alertId}: no matching invoice found`);
|
|
68
|
+
|
|
69
|
+
// Still send email to alert brand about unmatched dispute
|
|
70
|
+
if (!assistant.isTesting() || process.env.TEST_EXTENDED_MODE) {
|
|
71
|
+
sendDisputeEmail({ alert, match: null, alertId, assistant });
|
|
72
|
+
await disputeRef.set({ actions: { email: 'success' } }, { merge: true });
|
|
73
|
+
} else {
|
|
74
|
+
assistant.log(`Dispute ${alertId}: skipping email (testing mode)`);
|
|
75
|
+
await disputeRef.set({ actions: { email: 'skipped-testing' } }, { merge: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Process refund and cancel
|
|
82
|
+
const result = await processDispute({ match, alert, assistant });
|
|
83
|
+
|
|
84
|
+
// Update dispute document with results
|
|
85
|
+
await disputeRef.set({
|
|
86
|
+
status: 'resolved',
|
|
87
|
+
match: {
|
|
88
|
+
invoiceId: match.invoiceId,
|
|
89
|
+
subscriptionId: match.subscriptionId || null,
|
|
90
|
+
customerId: match.customerId,
|
|
91
|
+
uid: match.uid || null,
|
|
92
|
+
chargeId: match.chargeId,
|
|
93
|
+
refundId: result.refundId || null,
|
|
94
|
+
amountRefunded: result.amountRefunded || null,
|
|
95
|
+
currency: result.currency || null,
|
|
96
|
+
},
|
|
97
|
+
actions: {
|
|
98
|
+
refund: result.refundStatus,
|
|
99
|
+
cancel: result.cancelStatus,
|
|
100
|
+
email: 'pending',
|
|
101
|
+
},
|
|
102
|
+
errors: result.errors,
|
|
103
|
+
metadata: {
|
|
104
|
+
completed: {
|
|
105
|
+
timestamp: now,
|
|
106
|
+
timestampUNIX: nowUNIX,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}, { merge: true });
|
|
110
|
+
|
|
111
|
+
// Send email alert (fire-and-forget)
|
|
112
|
+
if (!assistant.isTesting() || process.env.TEST_EXTENDED_MODE) {
|
|
113
|
+
sendDisputeEmail({ alert, match, result, alertId, assistant });
|
|
114
|
+
await disputeRef.set({ actions: { email: 'success' } }, { merge: true });
|
|
115
|
+
} else {
|
|
116
|
+
assistant.log(`Dispute ${alertId}: skipping email (testing mode)`);
|
|
117
|
+
await disputeRef.set({ actions: { email: 'skipped-testing' } }, { merge: true });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
assistant.log(`Dispute ${alertId} resolved: refund=${result.refundStatus}, cancel=${result.cancelStatus}`);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
assistant.error(`Dispute ${alertId} failed: ${e.message}`, e);
|
|
123
|
+
|
|
124
|
+
await disputeRef.set({
|
|
125
|
+
status: 'failed',
|
|
126
|
+
error: e.message || String(e),
|
|
127
|
+
}, { merge: true });
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Search Stripe invoices by date range + amount and match card last4
|
|
133
|
+
*
|
|
134
|
+
* @param {object} options
|
|
135
|
+
* @param {object} options.alert - Normalized alert data
|
|
136
|
+
* @param {object} options.assistant - Assistant instance
|
|
137
|
+
* @returns {object|null} Match details or null
|
|
138
|
+
*/
|
|
139
|
+
async function searchAndMatch({ alert, assistant }) {
|
|
140
|
+
const StripeLib = require('../../../libraries/payment/processors/stripe.js');
|
|
141
|
+
const stripe = StripeLib.init();
|
|
142
|
+
|
|
143
|
+
const amountCents = Math.round(alert.amount * 100);
|
|
144
|
+
const alertDate = moment(alert.transactionDate);
|
|
145
|
+
|
|
146
|
+
if (!alertDate.isValid()) {
|
|
147
|
+
throw new Error(`Invalid transactionDate: ${alert.transactionDate}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const start = alertDate.clone().subtract(2, 'days').unix();
|
|
151
|
+
const end = alertDate.clone().add(2, 'days').unix();
|
|
152
|
+
|
|
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')}`);
|
|
154
|
+
|
|
155
|
+
// Search invoices by date range and amount
|
|
156
|
+
const invoices = await stripe.invoices.search({
|
|
157
|
+
limit: 100,
|
|
158
|
+
query: `created>${start} AND created<${end} AND total:${amountCents}`,
|
|
159
|
+
expand: ['data.payment_intent.payment_method'],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (!invoices.data.length) {
|
|
163
|
+
assistant.log(`No invoices found for amount=${amountCents} in date range`);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (invoices.data.length >= 100) {
|
|
168
|
+
assistant.log(`Warning: 100+ invoices found, results may be truncated`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
assistant.log(`Found ${invoices.data.length} invoice(s), matching card last4=${alert.card.last4}`);
|
|
172
|
+
|
|
173
|
+
// Loop through invoices and match card last4
|
|
174
|
+
for (const invoice of invoices.data) {
|
|
175
|
+
const invoiceLast4 = invoice?.payment_intent?.payment_method?.card?.last4;
|
|
176
|
+
|
|
177
|
+
assistant.log(`Checking invoice ${invoice.id}: card last4=${invoiceLast4 || 'unknown'}`);
|
|
178
|
+
|
|
179
|
+
if (!invoiceLast4 || invoiceLast4 !== alert.card.last4) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
assistant.log(`Matched invoice ${invoice.id}: card last4=${invoiceLast4}`);
|
|
184
|
+
|
|
185
|
+
// Resolve UID from customer metadata
|
|
186
|
+
let uid = null;
|
|
187
|
+
const customerId = invoice.customer;
|
|
188
|
+
if (customerId) {
|
|
189
|
+
try {
|
|
190
|
+
const customer = await stripe.customers.retrieve(customerId);
|
|
191
|
+
uid = customer.metadata?.uid || null;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
assistant.error(`Failed to retrieve customer ${customerId}: ${e.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
invoiceId: invoice.id,
|
|
199
|
+
subscriptionId: invoice.subscription || null,
|
|
200
|
+
customerId: customerId,
|
|
201
|
+
uid: uid,
|
|
202
|
+
chargeId: invoice.charge || null,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
assistant.log(`No invoice matched card last4=${alert.card.last4}`);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Process refund + cancellation for a matched dispute
|
|
212
|
+
*
|
|
213
|
+
* @param {object} options
|
|
214
|
+
* @param {object} options.match - Match details from searchAndMatch
|
|
215
|
+
* @param {object} options.alert - Normalized alert data
|
|
216
|
+
* @param {object} options.assistant - Assistant instance
|
|
217
|
+
* @returns {object} Result with statuses
|
|
218
|
+
*/
|
|
219
|
+
async function processDispute({ match, alert, assistant }) {
|
|
220
|
+
const StripeLib = require('../../../libraries/payment/processors/stripe.js');
|
|
221
|
+
const stripe = StripeLib.init();
|
|
222
|
+
|
|
223
|
+
const amountCents = Math.round(alert.amount * 100);
|
|
224
|
+
const result = {
|
|
225
|
+
refundId: null,
|
|
226
|
+
amountRefunded: null,
|
|
227
|
+
currency: null,
|
|
228
|
+
refundStatus: 'skipped',
|
|
229
|
+
cancelStatus: 'skipped',
|
|
230
|
+
errors: [],
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Issue full refund
|
|
234
|
+
if (match.chargeId) {
|
|
235
|
+
try {
|
|
236
|
+
const refund = await stripe.refunds.create({
|
|
237
|
+
charge: match.chargeId,
|
|
238
|
+
amount: amountCents,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
result.refundId = refund.id;
|
|
242
|
+
result.amountRefunded = amountCents;
|
|
243
|
+
result.currency = refund.currency;
|
|
244
|
+
result.refundStatus = 'success';
|
|
245
|
+
|
|
246
|
+
assistant.log(`Refund success: refundId=${refund.id}, amount=${amountCents}, invoice=${match.invoiceId}`);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
result.refundStatus = 'failed';
|
|
249
|
+
result.errors.push(`Refund failed: ${e.message}`);
|
|
250
|
+
assistant.error(`Refund failed for invoice ${match.invoiceId}: ${e.message}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Cancel subscription immediately
|
|
255
|
+
// Stripe fires customer.subscription.deleted webhook → existing pipeline handles user doc update
|
|
256
|
+
if (match.subscriptionId) {
|
|
257
|
+
try {
|
|
258
|
+
await stripe.subscriptions.cancel(match.subscriptionId);
|
|
259
|
+
result.cancelStatus = 'success';
|
|
260
|
+
|
|
261
|
+
assistant.log(`Subscription cancelled: sub=${match.subscriptionId}`);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
result.cancelStatus = 'failed';
|
|
264
|
+
result.errors.push(`Cancel failed: ${e.message}`);
|
|
265
|
+
assistant.error(`Cancel failed for sub ${match.subscriptionId}: ${e.message}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Send dispute alert email to brand contact (fire-and-forget)
|
|
274
|
+
*
|
|
275
|
+
* @param {object} options
|
|
276
|
+
* @param {object} options.alert - Normalized alert data
|
|
277
|
+
* @param {object|null} options.match - Match details (null if no match)
|
|
278
|
+
* @param {object} [options.result] - Processing result (refund/cancel statuses)
|
|
279
|
+
* @param {string} options.alertId - Dispute alert ID
|
|
280
|
+
* @param {object} options.assistant - Assistant instance
|
|
281
|
+
*/
|
|
282
|
+
function sendDisputeEmail({ alert, match, result, alertId, assistant }) {
|
|
283
|
+
const Manager = assistant.Manager;
|
|
284
|
+
const email = Manager.Email(assistant);
|
|
285
|
+
const brandEmail = Manager.config.brand?.contact?.email;
|
|
286
|
+
|
|
287
|
+
if (!brandEmail) {
|
|
288
|
+
assistant.error(`sendDisputeEmail(): No brand.contact.email configured, skipping`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
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
|
+
};
|
|
302
|
+
|
|
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;
|
|
312
|
+
|
|
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
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (result?.errors?.length) {
|
|
329
|
+
messageLines.push(
|
|
330
|
+
'',
|
|
331
|
+
'<strong>Errors:</strong>',
|
|
332
|
+
`<pre><code>${JSON.stringify(result.errors, null, 2)}</code></pre>`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
email.send({
|
|
337
|
+
to: { email: brandEmail },
|
|
338
|
+
subject: subject,
|
|
339
|
+
template: 'main/basic/card',
|
|
340
|
+
categories: ['order/dispute-alert'],
|
|
341
|
+
copy: true,
|
|
342
|
+
data: {
|
|
343
|
+
email: {
|
|
344
|
+
preview: `Dispute alert: ${matched} — $${alert.amount} on ****${alert.card.last4}`,
|
|
345
|
+
},
|
|
346
|
+
body: {
|
|
347
|
+
title: subject,
|
|
348
|
+
message: messageLines.join('<br>'),
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
})
|
|
352
|
+
.then((r) => {
|
|
353
|
+
assistant.log(`sendDisputeEmail(): Success alertId=${alertId}`);
|
|
354
|
+
})
|
|
355
|
+
.catch((e) => {
|
|
356
|
+
assistant.error(`sendDisputeEmail(): Failed alertId=${alertId}: ${e.message}`);
|
|
357
|
+
});
|
|
358
|
+
}
|