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.
- package/CHANGELOG.md +11 -0
- package/_zombie-sub-guard-wip/journey-payments-zombie-sub.js +159 -0
- package/_zombie-sub-guard-wip/on-write.js +442 -0
- package/_zombie-sub-guard-wip/test-accounts.diff +21 -0
- package/package.json +1 -1
- package/src/manager/events/firestore/payments-disputes/on-write.js +14 -260
- package/src/manager/events/firestore/payments-disputes/processors/stripe.js +213 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +9 -4
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +1 -0
- package/src/manager/routes/payments/dispute-alert/processors/chargeblast.js +2 -2
- package/test/routes/payments/dispute-alert.js +89 -4
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
const
|
|
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.
|
|
9
|
-
* 2.
|
|
10
|
-
* 3. Issues
|
|
11
|
-
* 4.
|
|
12
|
-
* 5.
|
|
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
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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) => {
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js
CHANGED
|
@@ -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,
|