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.
Files changed (45) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/CLAUDE.md +18 -19
  3. package/README.md +7 -7
  4. package/package.json +1 -1
  5. package/src/manager/cron/daily/reset-usage.js +79 -73
  6. package/src/manager/cron/daily.js +2 -53
  7. package/src/manager/cron/frequent/abandoned-carts.js +148 -0
  8. package/src/manager/cron/frequent.js +3 -0
  9. package/src/manager/cron/runner.js +60 -0
  10. package/src/manager/events/firestore/payments-disputes/on-write.js +358 -0
  11. package/src/manager/events/firestore/payments-webhooks/analytics.js +245 -121
  12. package/src/manager/events/firestore/payments-webhooks/on-write.js +32 -2
  13. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +11 -34
  14. package/src/manager/helpers/analytics.js +2 -2
  15. package/src/manager/helpers/usage.js +44 -20
  16. package/src/manager/helpers/user.js +2 -1
  17. package/src/manager/index.js +10 -0
  18. package/src/manager/libraries/abandoned-cart-config.js +12 -0
  19. package/src/manager/libraries/email.js +5 -5
  20. package/src/manager/libraries/openai.js +76 -7
  21. package/src/manager/libraries/payment/discount-codes.js +40 -0
  22. package/src/manager/libraries/recaptcha.js +36 -0
  23. package/src/manager/routes/app/get.js +1 -1
  24. package/src/manager/routes/marketing/contact/post.js +11 -29
  25. package/src/manager/routes/payments/discount/get.js +22 -0
  26. package/src/manager/routes/payments/dispute-alert/post.js +93 -0
  27. package/src/manager/routes/payments/dispute-alert/processors/chargeblast.js +43 -0
  28. package/src/manager/routes/payments/intent/post.js +29 -0
  29. package/src/manager/routes/payments/intent/processors/chargebee.js +59 -7
  30. package/src/manager/routes/payments/intent/processors/stripe.js +55 -7
  31. package/src/manager/routes/test/usage/post.js +10 -6
  32. package/src/manager/schemas/payments/discount/get.js +9 -0
  33. package/src/manager/schemas/payments/dispute-alert/post.js +6 -0
  34. package/src/manager/schemas/payments/intent/post.js +16 -0
  35. package/src/test/runner.js +14 -4
  36. package/src/test/test-accounts.js +18 -0
  37. package/templates/backend-manager-config.json +7 -1
  38. package/templates/firestore.rules +9 -1
  39. package/test/_legacy/usage.js +5 -5
  40. package/test/routes/marketing/contact.js +3 -2
  41. package/test/routes/payments/discount.js +80 -0
  42. package/test/routes/payments/dispute-alert.js +271 -0
  43. package/test/routes/payments/intent.js +60 -0
  44. package/test/routes/test/usage.js +134 -30
  45. 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
+ }