backend-manager 5.0.113 → 5.0.114
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 +1 -1
- package/src/manager/cron/daily/expire-paypal-cancellations.js +104 -0
- package/src/manager/events/firestore/payments-webhooks/on-write.js +27 -9
- package/src/manager/events/firestore/payments-webhooks/transitions/index.js +9 -2
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-refunded.js +34 -0
- package/src/manager/libraries/payment/processors/paypal.js +127 -5
- package/src/manager/libraries/payment/processors/stripe.js +30 -0
- package/src/manager/libraries/payment/processors/test.js +33 -6
- package/src/manager/routes/payments/webhook/processors/stripe.js +27 -0
- package/src/test/test-accounts.js +30 -0
- package/test/events/payments/journey-payments-legacy-product.js +149 -0
- package/test/events/payments/journey-payments-refund-webhook.js +177 -0
- package/test/events/payments/journey-payments-uid-resolution.js +129 -0
package/package.json
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const powertools = require('node-powertools');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Expire PayPal pending cancellations
|
|
5
|
+
*
|
|
6
|
+
* PayPal has no cancel_at_period_end like Stripe — cancellation is immediate on PayPal's side.
|
|
7
|
+
* BEM keeps users active with cancellation.pending=true until the billing period ends.
|
|
8
|
+
* Since PayPal does NOT fire a webhook at period end, this cron job transitions those
|
|
9
|
+
* users to 'cancelled' status once their paid period has expired.
|
|
10
|
+
*
|
|
11
|
+
* Flow:
|
|
12
|
+
* 1. Query users with PayPal subscriptions where cancellation.pending=true
|
|
13
|
+
* 2. Check if subscription.expires.timestampUNIX < now
|
|
14
|
+
* 3. Update status to 'cancelled' and cancellation.pending to false
|
|
15
|
+
* 4. Dispatch 'subscription-cancelled' transition (sends email)
|
|
16
|
+
*/
|
|
17
|
+
module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
18
|
+
const { admin } = libraries;
|
|
19
|
+
const transitions = require('../../events/firestore/payments-webhooks/transitions/index.js');
|
|
20
|
+
|
|
21
|
+
const now = new Date();
|
|
22
|
+
const nowStr = powertools.timestamp(now, { output: 'string' });
|
|
23
|
+
const nowUNIX = powertools.timestamp(nowStr, { output: 'unix' });
|
|
24
|
+
|
|
25
|
+
assistant.log('Checking for expired PayPal pending cancellations...');
|
|
26
|
+
|
|
27
|
+
let processed = 0;
|
|
28
|
+
let skipped = 0;
|
|
29
|
+
|
|
30
|
+
await Manager.Utilities().iterateCollection(async (batch, index) => {
|
|
31
|
+
for (const doc of batch.docs) {
|
|
32
|
+
const data = doc.data();
|
|
33
|
+
const sub = data.subscription;
|
|
34
|
+
const uid = doc.id;
|
|
35
|
+
|
|
36
|
+
// Double-check: skip if expires is in the future
|
|
37
|
+
if (!sub?.expires?.timestampUNIX || sub.expires.timestampUNIX > nowUNIX) {
|
|
38
|
+
assistant.log(`[skip] ${uid}: expires=${sub?.expires?.timestamp || 'null'} is still in the future (now=${nowStr})`);
|
|
39
|
+
skipped++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
assistant.log(`[expire] ${uid}: expires=${sub.expires.timestamp}, product=${sub.product?.id}, processor=${sub.payment?.processor}, orderId=${sub.payment?.orderId || 'null'}`);
|
|
44
|
+
|
|
45
|
+
// Snapshot the before state for transition detection
|
|
46
|
+
const before = { ...sub };
|
|
47
|
+
|
|
48
|
+
// Build the after state — transition to cancelled
|
|
49
|
+
const after = {
|
|
50
|
+
...sub,
|
|
51
|
+
status: 'cancelled',
|
|
52
|
+
cancellation: {
|
|
53
|
+
...sub.cancellation,
|
|
54
|
+
pending: false,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Write to Firestore
|
|
59
|
+
await doc.ref.set({ subscription: after }, { merge: true });
|
|
60
|
+
|
|
61
|
+
assistant.log(`[expire] ${uid}: Updated status=cancelled, cancellation.pending=false`);
|
|
62
|
+
|
|
63
|
+
// Detect and dispatch transition (should fire 'subscription-cancelled')
|
|
64
|
+
const transitionName = transitions.detectTransition('subscription', before, after, null);
|
|
65
|
+
|
|
66
|
+
if (transitionName) {
|
|
67
|
+
assistant.log(`[expire] ${uid}: Transition detected: subscription/${transitionName}`);
|
|
68
|
+
|
|
69
|
+
// Build minimal order context for the handler
|
|
70
|
+
const order = {
|
|
71
|
+
id: sub.payment?.orderId || null,
|
|
72
|
+
type: 'subscription',
|
|
73
|
+
owner: uid,
|
|
74
|
+
processor: 'paypal',
|
|
75
|
+
resourceId: sub.payment?.resourceId || null,
|
|
76
|
+
unified: after,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
transitions.dispatch(transitionName, 'subscription', {
|
|
80
|
+
before,
|
|
81
|
+
after,
|
|
82
|
+
order,
|
|
83
|
+
uid,
|
|
84
|
+
userDoc: data,
|
|
85
|
+
assistant,
|
|
86
|
+
});
|
|
87
|
+
} else {
|
|
88
|
+
assistant.log(`[expire] ${uid}: No transition detected (before.status=${before.status}, after.status=${after.status})`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
processed++;
|
|
92
|
+
}
|
|
93
|
+
}, {
|
|
94
|
+
collection: 'users',
|
|
95
|
+
where: [
|
|
96
|
+
{ field: 'subscription.payment.processor', operator: '==', value: 'paypal' },
|
|
97
|
+
{ field: 'subscription.cancellation.pending', operator: '==', value: true },
|
|
98
|
+
],
|
|
99
|
+
batchSize: 5000,
|
|
100
|
+
log: true,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
assistant.log(`Completed! Processed=${processed}, Skipped=${skipped}`);
|
|
104
|
+
};
|
|
@@ -36,7 +36,7 @@ module.exports = async ({ assistant, change, context }) => {
|
|
|
36
36
|
|
|
37
37
|
try {
|
|
38
38
|
const processor = dataAfter.processor;
|
|
39
|
-
|
|
39
|
+
let uid = dataAfter.owner;
|
|
40
40
|
const raw = dataAfter.raw;
|
|
41
41
|
const eventType = dataAfter.event?.type;
|
|
42
42
|
const category = dataAfter.event?.category;
|
|
@@ -45,11 +45,6 @@ module.exports = async ({ assistant, change, context }) => {
|
|
|
45
45
|
|
|
46
46
|
assistant.log(`Processing webhook ${eventId}: processor=${processor}, eventType=${eventType}, category=${category}, resourceType=${resourceType}, resourceId=${resourceId}, uid=${uid || 'null'}`);
|
|
47
47
|
|
|
48
|
-
// Validate UID
|
|
49
|
-
if (!uid) {
|
|
50
|
-
throw new Error('Webhook event has no UID — cannot process');
|
|
51
|
-
}
|
|
52
|
-
|
|
53
48
|
// Validate category
|
|
54
49
|
if (!category) {
|
|
55
50
|
throw new Error(`Webhook event has no category — cannot process`);
|
|
@@ -70,6 +65,24 @@ module.exports = async ({ assistant, change, context }) => {
|
|
|
70
65
|
|
|
71
66
|
assistant.log(`Fetched resource: type=${resourceType}, id=${resourceId}, status=${resource.status || 'unknown'}`);
|
|
72
67
|
|
|
68
|
+
// Resolve UID from the fetched resource if not available from webhook parse
|
|
69
|
+
// This handles events like PAYMENT.SALE where the Sale object doesn't carry custom_id
|
|
70
|
+
// but the parent subscription (fetched via fetchResource) does
|
|
71
|
+
if (!uid && library.getUid) {
|
|
72
|
+
uid = library.getUid(resource);
|
|
73
|
+
assistant.log(`UID resolved from fetched resource: uid=${uid || 'null'}, processor=${processor}, resourceType=${resourceType}`);
|
|
74
|
+
|
|
75
|
+
// Update the webhook doc with the resolved UID so it's persisted for debugging
|
|
76
|
+
if (uid) {
|
|
77
|
+
await webhookRef.set({ owner: uid }, { merge: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validate UID — must have one by now (either from webhook parse or fetched resource)
|
|
82
|
+
if (!uid) {
|
|
83
|
+
throw new Error(`Webhook event has no UID — could not extract from webhook parse or fetched ${resourceType} resource`);
|
|
84
|
+
}
|
|
85
|
+
|
|
73
86
|
// Build timestamps
|
|
74
87
|
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
75
88
|
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
@@ -83,7 +96,7 @@ module.exports = async ({ assistant, change, context }) => {
|
|
|
83
96
|
throw new Error(`Unknown event category: ${category}`);
|
|
84
97
|
}
|
|
85
98
|
|
|
86
|
-
const transitionName = await processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant });
|
|
99
|
+
const transitionName = await processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant, raw });
|
|
87
100
|
|
|
88
101
|
// Mark webhook as completed (include transition name for auditing/testing)
|
|
89
102
|
await webhookRef.set({
|
|
@@ -138,7 +151,7 @@ module.exports = async ({ assistant, change, context }) => {
|
|
|
138
151
|
* 6. Track analytics (non-blocking)
|
|
139
152
|
* 7. Write to Firestore (user doc for subscriptions + payments-orders)
|
|
140
153
|
*/
|
|
141
|
-
async function processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant }) {
|
|
154
|
+
async function processPaymentEvent({ category, library, resource, resourceType, uid, processor, eventType, eventId, resourceId, orderId, now, nowUNIX, webhookReceivedUNIX, assistant, raw }) {
|
|
142
155
|
const Manager = assistant.Manager;
|
|
143
156
|
const admin = Manager.libraries.admin;
|
|
144
157
|
const isSubscription = category === 'subscription';
|
|
@@ -215,8 +228,13 @@ async function processPaymentEvent({ category, library, resource, resourceType,
|
|
|
215
228
|
assistant.log(`Transition detected: ${category}/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
|
|
216
229
|
|
|
217
230
|
if (shouldRunHandlers) {
|
|
231
|
+
// Extract unified refund details from the processor library (keeps handlers processor-agnostic)
|
|
232
|
+
const refundDetails = (transitionName === 'payment-refunded' && library.getRefundDetails)
|
|
233
|
+
? library.getRefundDetails(raw)
|
|
234
|
+
: null;
|
|
235
|
+
|
|
218
236
|
transitions.dispatch(transitionName, category, {
|
|
219
|
-
before, after: unified, order, uid, userDoc: userData, assistant,
|
|
237
|
+
before, after: unified, order, uid, userDoc: userData, assistant, refundDetails,
|
|
220
238
|
});
|
|
221
239
|
} else {
|
|
222
240
|
assistant.log(`Transition handler skipped (testing mode): ${category}/${transitionName}`);
|
|
@@ -18,7 +18,7 @@ const path = require('path');
|
|
|
18
18
|
*/
|
|
19
19
|
function detectTransition(category, before, after, eventType) {
|
|
20
20
|
if (category === 'subscription') {
|
|
21
|
-
return detectSubscriptionTransition(before, after);
|
|
21
|
+
return detectSubscriptionTransition(before, after, eventType);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
if (category === 'one-time') {
|
|
@@ -37,11 +37,18 @@ function detectTransition(category, before, after, eventType) {
|
|
|
37
37
|
* @param {object} after - New unified subscription
|
|
38
38
|
* @returns {string|null} Transition name
|
|
39
39
|
*/
|
|
40
|
-
function detectSubscriptionTransition(before, after) {
|
|
40
|
+
function detectSubscriptionTransition(before, after, eventType) {
|
|
41
41
|
if (!after) {
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Refund events take priority — detected by webhook event type rather than state diff
|
|
46
|
+
// because the subscription state may not change meaningfully during a refund
|
|
47
|
+
const refundEvents = ['PAYMENT.SALE.REFUNDED', 'charge.refunded'];
|
|
48
|
+
if (refundEvents.includes(eventType)) {
|
|
49
|
+
return 'payment-refunded';
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
const beforeStatus = before?.status;
|
|
46
53
|
const afterStatus = after.status;
|
|
47
54
|
|
package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-refunded.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transition: payment-refunded
|
|
3
|
+
* Triggered when a payment refund webhook is received.
|
|
4
|
+
*
|
|
5
|
+
* Processor-agnostic — refund details are extracted by the processor library's
|
|
6
|
+
* getRefundDetails() method and passed as a unified { amount, currency, reason } object.
|
|
7
|
+
*
|
|
8
|
+
* This is webhook-driven so it fires regardless of how the refund originated
|
|
9
|
+
* (admin dashboard, user self-service, or direct processor action).
|
|
10
|
+
*/
|
|
11
|
+
const { sendOrderEmail, formatDate } = require('../send-email.js');
|
|
12
|
+
|
|
13
|
+
module.exports = async function ({ before, after, order, uid, userDoc, assistant, refundDetails }) {
|
|
14
|
+
assistant.log(`Transition [subscription/payment-refunded]: uid=${uid}, product=${after?.product?.id}, amount=${refundDetails?.amount} ${refundDetails?.currency}, reason=${refundDetails?.reason || 'none'}`);
|
|
15
|
+
|
|
16
|
+
sendOrderEmail({
|
|
17
|
+
template: 'main/order/refunded',
|
|
18
|
+
subject: `Your payment has been refunded #${order?.id || ''}`,
|
|
19
|
+
categories: ['order/refunded'],
|
|
20
|
+
userDoc,
|
|
21
|
+
assistant,
|
|
22
|
+
data: {
|
|
23
|
+
order: {
|
|
24
|
+
...order,
|
|
25
|
+
_computed: {
|
|
26
|
+
date: formatDate(new Date().toISOString()),
|
|
27
|
+
refundAmount: refundDetails?.amount || null,
|
|
28
|
+
refundCurrency: refundDetails?.currency || 'USD',
|
|
29
|
+
refundReason: refundDetails?.reason || null,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
};
|
|
@@ -358,6 +358,35 @@ const PayPal = {
|
|
|
358
358
|
return customData.orderId || null;
|
|
359
359
|
},
|
|
360
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Extract the UID from a PayPal resource's custom_id
|
|
363
|
+
* Used to resolve UID after fetchResource() for events like PAYMENT.SALE
|
|
364
|
+
* where the initial webhook payload doesn't carry custom_id
|
|
365
|
+
*
|
|
366
|
+
* @param {object} resource - Raw PayPal resource (subscription or order)
|
|
367
|
+
* @returns {string|null}
|
|
368
|
+
*/
|
|
369
|
+
getUid(resource) {
|
|
370
|
+
const purchaseCustomId = resource.purchase_units?.[0]?.custom_id;
|
|
371
|
+
const customData = parseCustomId(purchaseCustomId || resource.custom_id);
|
|
372
|
+
return customData?.uid || null;
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Extract refund details from a PayPal PAYMENT.SALE.REFUNDED webhook payload
|
|
377
|
+
* Returns a unified shape so transition handlers stay processor-agnostic
|
|
378
|
+
*
|
|
379
|
+
* @param {object} raw - Raw PayPal webhook payload
|
|
380
|
+
* @returns {{ amount: string|null, currency: string, reason: string|null }}
|
|
381
|
+
*/
|
|
382
|
+
getRefundDetails(raw) {
|
|
383
|
+
return {
|
|
384
|
+
amount: raw?.resource?.amount?.total || raw?.resource?.total_refunded_amount?.value || null,
|
|
385
|
+
currency: raw?.resource?.amount?.currency || 'USD',
|
|
386
|
+
reason: raw?.resource?.reason_code || null,
|
|
387
|
+
};
|
|
388
|
+
},
|
|
389
|
+
|
|
361
390
|
/**
|
|
362
391
|
* Build the custom_id string for PayPal subscriptions and orders
|
|
363
392
|
* Format: uid:{uid},orderId:{orderId} or uid:{uid},orderId:{orderId},productId:{productId}
|
|
@@ -408,6 +437,44 @@ function parseCustomId(customId) {
|
|
|
408
437
|
return result;
|
|
409
438
|
}
|
|
410
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Calculate when the current billing period ends based on last payment + interval
|
|
442
|
+
* Used for cancelled subs to determine remaining access time
|
|
443
|
+
*
|
|
444
|
+
* @param {object} raw - Raw PayPal subscription (with _plan attached)
|
|
445
|
+
* @returns {Date|null} Period end date, or null if cannot be calculated
|
|
446
|
+
*/
|
|
447
|
+
function calculatePeriodEnd(raw) {
|
|
448
|
+
const lastPayment = raw.billing_info?.last_payment?.time;
|
|
449
|
+
|
|
450
|
+
if (!lastPayment) {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const plan = raw._plan;
|
|
455
|
+
const regularCycle = plan?.billing_cycles?.find(c => c.tenure_type === 'REGULAR');
|
|
456
|
+
|
|
457
|
+
if (!regularCycle) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const unit = regularCycle.frequency.interval_unit;
|
|
462
|
+
const count = regularCycle.frequency.interval_count || 1;
|
|
463
|
+
const lastDate = new Date(lastPayment);
|
|
464
|
+
|
|
465
|
+
if (unit === 'YEAR') {
|
|
466
|
+
lastDate.setFullYear(lastDate.getFullYear() + count);
|
|
467
|
+
} else if (unit === 'MONTH') {
|
|
468
|
+
lastDate.setMonth(lastDate.getMonth() + count);
|
|
469
|
+
} else if (unit === 'WEEK') {
|
|
470
|
+
lastDate.setDate(lastDate.getDate() + (count * 7));
|
|
471
|
+
} else if (unit === 'DAY') {
|
|
472
|
+
lastDate.setDate(lastDate.getDate() + count);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return lastDate;
|
|
476
|
+
}
|
|
477
|
+
|
|
411
478
|
/**
|
|
412
479
|
* Map PayPal subscription status to unified status
|
|
413
480
|
*
|
|
@@ -415,7 +482,8 @@ function parseCustomId(customId) {
|
|
|
415
482
|
* |------------------|----------------|
|
|
416
483
|
* | ACTIVE | active |
|
|
417
484
|
* | SUSPENDED | suspended |
|
|
418
|
-
* | CANCELLED
|
|
485
|
+
* | CANCELLED (period remaining) | active (with cancellation.pending) |
|
|
486
|
+
* | CANCELLED (period ended) | cancelled |
|
|
419
487
|
* | EXPIRED | cancelled |
|
|
420
488
|
* | APPROVAL_PENDING | cancelled |
|
|
421
489
|
* | APPROVED | active |
|
|
@@ -431,17 +499,47 @@ function resolveStatus(raw) {
|
|
|
431
499
|
return 'suspended';
|
|
432
500
|
}
|
|
433
501
|
|
|
434
|
-
// CANCELLED
|
|
502
|
+
// CANCELLED — check if user still has paid time remaining
|
|
503
|
+
if (status === 'CANCELLED') {
|
|
504
|
+
const periodEnd = calculatePeriodEnd(raw);
|
|
505
|
+
|
|
506
|
+
if (periodEnd && periodEnd > new Date()) {
|
|
507
|
+
// User still has access until period end — treat as active with pending cancellation
|
|
508
|
+
return 'active';
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return 'cancelled';
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// EXPIRED, APPROVAL_PENDING, or anything else
|
|
435
515
|
return 'cancelled';
|
|
436
516
|
}
|
|
437
517
|
|
|
438
518
|
/**
|
|
439
519
|
* Resolve cancellation state from PayPal subscription
|
|
520
|
+
*
|
|
521
|
+
* PayPal has no cancel_at_period_end like Stripe. When cancelled:
|
|
522
|
+
* - If billing period hasn't ended: pending=true, date=period end (user keeps access)
|
|
523
|
+
* - If billing period has ended: pending=false, date=cancellation time (fully cancelled)
|
|
440
524
|
*/
|
|
441
525
|
function resolveCancellation(raw) {
|
|
442
526
|
if (raw.status === 'CANCELLED') {
|
|
443
|
-
|
|
444
|
-
|
|
527
|
+
const periodEnd = calculatePeriodEnd(raw);
|
|
528
|
+
|
|
529
|
+
// Period still active — pending cancellation (user keeps access until period end)
|
|
530
|
+
if (periodEnd && periodEnd > new Date()) {
|
|
531
|
+
const periodEndStr = powertools.timestamp(periodEnd, { output: 'string' });
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
pending: true,
|
|
535
|
+
date: {
|
|
536
|
+
timestamp: periodEndStr,
|
|
537
|
+
timestampUNIX: powertools.timestamp(periodEndStr, { output: 'unix' }),
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Period has ended — fully cancelled
|
|
445
543
|
const cancelDate = raw.status_update_time
|
|
446
544
|
? powertools.timestamp(new Date(raw.status_update_time), { output: 'string' })
|
|
447
545
|
: EPOCH_ZERO;
|
|
@@ -542,6 +640,7 @@ function resolveFrequency(raw) {
|
|
|
542
640
|
/**
|
|
543
641
|
* Resolve product by matching the PayPal product ID against config products
|
|
544
642
|
* Uses: sub._plan.product_id → match config product.paypal.productId
|
|
643
|
+
* Also checks paypal.legacyProductIds[] for migrated products
|
|
545
644
|
*/
|
|
546
645
|
function resolveProduct(raw, config) {
|
|
547
646
|
// Get PayPal product ID from the plan (attached during fetchResource)
|
|
@@ -552,9 +651,15 @@ function resolveProduct(raw, config) {
|
|
|
552
651
|
}
|
|
553
652
|
|
|
554
653
|
for (const product of config.payment.products) {
|
|
654
|
+
// Check current product ID
|
|
555
655
|
if (product.paypal?.productId === paypalProductId) {
|
|
556
656
|
return { id: product.id, name: product.name || product.id };
|
|
557
657
|
}
|
|
658
|
+
|
|
659
|
+
// Check legacy product IDs (for migrated products)
|
|
660
|
+
if (product.paypal?.legacyProductIds?.includes(paypalProductId)) {
|
|
661
|
+
return { id: product.id, name: product.name || product.id };
|
|
662
|
+
}
|
|
558
663
|
}
|
|
559
664
|
|
|
560
665
|
return { id: 'basic', name: 'Basic' };
|
|
@@ -579,9 +684,26 @@ function resolveProductOneTime(productId, config) {
|
|
|
579
684
|
|
|
580
685
|
/**
|
|
581
686
|
* Resolve subscription expiration from PayPal data
|
|
687
|
+
*
|
|
688
|
+
* For cancelled subs with remaining time, uses calculated period end.
|
|
689
|
+
* For active subs, uses next_billing_time.
|
|
582
690
|
*/
|
|
583
691
|
function resolveExpires(raw) {
|
|
584
|
-
//
|
|
692
|
+
// Cancelled subs with remaining time — use calculated period end
|
|
693
|
+
if (raw.status === 'CANCELLED') {
|
|
694
|
+
const periodEnd = calculatePeriodEnd(raw);
|
|
695
|
+
|
|
696
|
+
if (periodEnd && periodEnd > new Date()) {
|
|
697
|
+
const expiresStr = powertools.timestamp(periodEnd, { output: 'string' });
|
|
698
|
+
|
|
699
|
+
return {
|
|
700
|
+
timestamp: expiresStr,
|
|
701
|
+
timestampUNIX: powertools.timestamp(expiresStr, { output: 'unix' }),
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Active subs: PayPal's billing_info.next_billing_time is the closest to "period end"
|
|
585
707
|
const nextBilling = raw.billing_info?.next_billing_time;
|
|
586
708
|
|
|
587
709
|
if (!nextBilling) {
|
|
@@ -82,6 +82,36 @@ const Stripe = {
|
|
|
82
82
|
return resource.metadata?.orderId || null;
|
|
83
83
|
},
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Extract the UID from a Stripe resource's metadata
|
|
87
|
+
* Used to resolve UID after fetchResource() for parity with PayPal
|
|
88
|
+
*
|
|
89
|
+
* @param {object} resource - Raw Stripe resource (subscription, session, invoice)
|
|
90
|
+
* @returns {string|null}
|
|
91
|
+
*/
|
|
92
|
+
getUid(resource) {
|
|
93
|
+
return resource.metadata?.uid || null;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract refund details from a Stripe charge.refunded webhook payload
|
|
98
|
+
* Returns a unified shape so transition handlers stay processor-agnostic
|
|
99
|
+
*
|
|
100
|
+
* @param {object} raw - Raw Stripe webhook payload
|
|
101
|
+
* @returns {{ amount: string|null, currency: string, reason: string|null }}
|
|
102
|
+
*/
|
|
103
|
+
getRefundDetails(raw) {
|
|
104
|
+
const charge = raw?.data?.object;
|
|
105
|
+
const amountCents = charge?.amount_refunded;
|
|
106
|
+
const latestRefund = charge?.refunds?.data?.[0];
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
amount: amountCents ? (amountCents / 100).toFixed(2) : null,
|
|
110
|
+
currency: charge?.currency?.toUpperCase() || 'USD',
|
|
111
|
+
reason: latestRefund?.reason || null,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
|
|
85
115
|
/**
|
|
86
116
|
* Transform a raw Stripe subscription object into the unified subscription shape
|
|
87
117
|
* This produces the exact same object stored in users/{uid}.subscription
|
|
@@ -21,12 +21,17 @@ const Test = {
|
|
|
21
21
|
* from Firestore instead of returning mismatched data.
|
|
22
22
|
*/
|
|
23
23
|
async fetchResource(resourceType, resourceId, rawFallback, context) {
|
|
24
|
-
// If the fallback matches the requested type, return it directly
|
|
25
|
-
|
|
24
|
+
// If the fallback matches the requested type AND has a UID, return it directly
|
|
25
|
+
// When UID is missing (e.g., PAYMENT.SALE events), fall through to Firestore lookup
|
|
26
|
+
// so we can reconstruct a resource with the UID from the order's owner field
|
|
27
|
+
const fallbackHasUid = rawFallback?.metadata?.uid;
|
|
28
|
+
if (rawFallback?.object === resourceType && fallbackHasUid) {
|
|
26
29
|
return rawFallback;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
//
|
|
32
|
+
// Look up the existing resource from payments-orders in Firestore
|
|
33
|
+
// This simulates what a real API call (Stripe/PayPal) would return — a resource
|
|
34
|
+
// with the full metadata including UID
|
|
30
35
|
const admin = context?.admin;
|
|
31
36
|
if (admin && resourceId) {
|
|
32
37
|
const snapshot = await admin.firestore()
|
|
@@ -40,7 +45,15 @@ const Test = {
|
|
|
40
45
|
// payments-orders stores the unified subscription inside .unified
|
|
41
46
|
// Reconstruct a Stripe-shaped object from the unified data for toUnifiedSubscription()
|
|
42
47
|
if (resourceType === 'subscription' && data.unified) {
|
|
43
|
-
|
|
48
|
+
const reconstructed = buildStripeSubscriptionFromUnified(data.unified, resourceId, context?.eventType, context?.config, data.owner);
|
|
49
|
+
|
|
50
|
+
// If the fallback matched the type but lacked UID, overlay the webhook's new state
|
|
51
|
+
// onto the reconstructed resource, but keep the reconstructed metadata (has uid)
|
|
52
|
+
if (rawFallback?.object === resourceType) {
|
|
53
|
+
return { ...rawFallback, metadata: { ...rawFallback.metadata, ...reconstructed.metadata } };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return reconstructed;
|
|
44
57
|
}
|
|
45
58
|
}
|
|
46
59
|
}
|
|
@@ -56,6 +69,20 @@ const Test = {
|
|
|
56
69
|
return Stripe.getOrderId(resource);
|
|
57
70
|
},
|
|
58
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Extract UID — delegates to Stripe (test processor uses Stripe-shaped data)
|
|
74
|
+
*/
|
|
75
|
+
getUid(resource) {
|
|
76
|
+
return Stripe.getUid(resource);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract refund details — delegates to Stripe (test processor uses Stripe-shaped data)
|
|
81
|
+
*/
|
|
82
|
+
getRefundDetails(raw) {
|
|
83
|
+
return Stripe.getRefundDetails(raw);
|
|
84
|
+
},
|
|
85
|
+
|
|
59
86
|
/**
|
|
60
87
|
* Transform raw subscription into unified shape
|
|
61
88
|
* Delegates to Stripe's toUnifiedSubscription (same data shape), stamps processor as 'test'
|
|
@@ -87,7 +114,7 @@ module.exports = Test;
|
|
|
87
114
|
* The unified → Stripe mapping must produce data that toUnifiedSubscription() can process correctly.
|
|
88
115
|
* For payment failure events, we override the status to past_due so it maps to 'suspended'.
|
|
89
116
|
*/
|
|
90
|
-
function buildStripeSubscriptionFromUnified(unified, resourceId, eventType, config) {
|
|
117
|
+
function buildStripeSubscriptionFromUnified(unified, resourceId, eventType, config, ownerUid) {
|
|
91
118
|
// Map unified status back to a Stripe status
|
|
92
119
|
const STATUS_MAP = {
|
|
93
120
|
active: 'active',
|
|
@@ -120,7 +147,7 @@ function buildStripeSubscriptionFromUnified(unified, resourceId, eventType, conf
|
|
|
120
147
|
id: resourceId,
|
|
121
148
|
object: 'subscription',
|
|
122
149
|
status: status,
|
|
123
|
-
metadata: { orderId: unified.payment?.orderId || null },
|
|
150
|
+
metadata: { orderId: unified.payment?.orderId || null, uid: ownerUid || null },
|
|
124
151
|
plan: {
|
|
125
152
|
product: stripeProductId,
|
|
126
153
|
interval: INTERVAL_MAP[frequency] || 'month',
|
|
@@ -20,6 +20,9 @@ const SUPPORTED_EVENTS = new Set([
|
|
|
20
20
|
|
|
21
21
|
// Checkout completion (could be subscription or one-time)
|
|
22
22
|
'checkout.session.completed',
|
|
23
|
+
|
|
24
|
+
// Refunds
|
|
25
|
+
'charge.refunded',
|
|
23
26
|
]);
|
|
24
27
|
|
|
25
28
|
module.exports = {
|
|
@@ -101,6 +104,30 @@ module.exports = {
|
|
|
101
104
|
resourceId = dataObject.id;
|
|
102
105
|
uid = dataObject.metadata?.uid || null;
|
|
103
106
|
}
|
|
107
|
+
|
|
108
|
+
} else if (eventType === 'charge.refunded') {
|
|
109
|
+
// Refund event — the charge object contains an invoice ID which links to a subscription
|
|
110
|
+
const invoiceId = dataObject.invoice;
|
|
111
|
+
const subscriptionId = dataObject.subscription
|
|
112
|
+
|| dataObject.metadata?.subscriptionId
|
|
113
|
+
|| null;
|
|
114
|
+
|
|
115
|
+
if (subscriptionId) {
|
|
116
|
+
// Subscription-related refund
|
|
117
|
+
category = 'subscription';
|
|
118
|
+
resourceType = 'subscription';
|
|
119
|
+
resourceId = subscriptionId;
|
|
120
|
+
} else if (invoiceId) {
|
|
121
|
+
// Has invoice — likely subscription-related, will resolve via fetchResource
|
|
122
|
+
category = 'subscription';
|
|
123
|
+
resourceType = 'invoice';
|
|
124
|
+
resourceId = invoiceId;
|
|
125
|
+
} else {
|
|
126
|
+
// One-time payment refund — skip for now (no subscription to update)
|
|
127
|
+
category = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
uid = dataObject.metadata?.uid || null;
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
return {
|
|
@@ -340,6 +340,36 @@ const JOURNEY_ACCOUNTS = {
|
|
|
340
340
|
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
341
341
|
},
|
|
342
342
|
},
|
|
343
|
+
// Journey: refund webhook transition (charge.refunded fires payment-refunded transition)
|
|
344
|
+
'journey-payments-refund-webhook': {
|
|
345
|
+
id: 'journey-payments-refund-webhook',
|
|
346
|
+
uid: '_test-journey-payments-refund-webhook',
|
|
347
|
+
email: '_test.journey-payments-refund-webhook@{domain}',
|
|
348
|
+
properties: {
|
|
349
|
+
roles: {},
|
|
350
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
// Journey: UID resolution fallback (webhook without uid in metadata, resolved from fetched resource)
|
|
354
|
+
'journey-payments-uid-resolution': {
|
|
355
|
+
id: 'journey-payments-uid-resolution',
|
|
356
|
+
uid: '_test-journey-payments-uid-resolution',
|
|
357
|
+
email: '_test.journey-payments-uid-resolution@{domain}',
|
|
358
|
+
properties: {
|
|
359
|
+
roles: {},
|
|
360
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
// Journey: legacy product ID resolution (webhook with legacy product ID maps to correct product)
|
|
364
|
+
'journey-payments-legacy-product': {
|
|
365
|
+
id: 'journey-payments-legacy-product',
|
|
366
|
+
uid: '_test-journey-payments-legacy-product',
|
|
367
|
+
email: '_test.journey-payments-legacy-product@{domain}',
|
|
368
|
+
properties: {
|
|
369
|
+
roles: {},
|
|
370
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
371
|
+
},
|
|
372
|
+
},
|
|
343
373
|
};
|
|
344
374
|
|
|
345
375
|
/**
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - Legacy Product ID Resolution
|
|
3
|
+
* Simulates: webhook with a legacy Stripe product ID → resolves to the correct product
|
|
4
|
+
*
|
|
5
|
+
* Verifies Fix 4: when an existing subscriber's webhook carries a legacy product ID
|
|
6
|
+
* (from before product migration), resolveProduct() checks stripe.legacyProductIds[]
|
|
7
|
+
* and maps it to the correct current product.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. Find a config product that has stripe.legacyProductIds (skip if none)
|
|
11
|
+
* 2. Send a customer.subscription.created webhook with the legacy product ID in plan.product
|
|
12
|
+
* 3. Verify: subscription.product.id resolves to the correct (current) product ID
|
|
13
|
+
*
|
|
14
|
+
* This test is config-dependent — it requires at least one product with legacyProductIds.
|
|
15
|
+
* If no such product exists, the test skips gracefully.
|
|
16
|
+
*/
|
|
17
|
+
module.exports = {
|
|
18
|
+
description: 'Payment journey: webhook with legacy product ID → correct product resolution',
|
|
19
|
+
type: 'suite',
|
|
20
|
+
timeout: 30000,
|
|
21
|
+
|
|
22
|
+
tests: [
|
|
23
|
+
{
|
|
24
|
+
name: 'find-product-with-legacy-ids',
|
|
25
|
+
async run({ accounts, assert, state, config }) {
|
|
26
|
+
const uid = accounts['journey-payments-legacy-product'].uid;
|
|
27
|
+
state.uid = uid;
|
|
28
|
+
|
|
29
|
+
// Find a paid product that has legacy Stripe product IDs configured
|
|
30
|
+
const productWithLegacy = config.payment?.products?.find(
|
|
31
|
+
p => p.id !== 'basic'
|
|
32
|
+
&& p.prices?.monthly
|
|
33
|
+
&& p.stripe?.legacyProductIds?.length > 0,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (!productWithLegacy) {
|
|
37
|
+
state.skip = true;
|
|
38
|
+
console.log('No product with stripe.legacyProductIds found in config — skipping legacy product ID test');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
state.skip = false;
|
|
43
|
+
state.productId = productWithLegacy.id;
|
|
44
|
+
state.productName = productWithLegacy.name;
|
|
45
|
+
state.currentStripeProductId = productWithLegacy.stripe.productId;
|
|
46
|
+
state.legacyStripeProductId = productWithLegacy.stripe.legacyProductIds[0];
|
|
47
|
+
|
|
48
|
+
assert.ok(state.legacyStripeProductId, 'Legacy product ID should exist');
|
|
49
|
+
assert.notEqual(state.legacyStripeProductId, state.currentStripeProductId, 'Legacy ID should differ from current ID');
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
name: 'send-webhook-with-legacy-product-id',
|
|
55
|
+
async run({ http, assert, state, config }) {
|
|
56
|
+
if (state.skip) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const futureDate = new Date();
|
|
61
|
+
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
62
|
+
|
|
63
|
+
state.legacyEventId = `_test-evt-journey-legacy-prod-${Date.now()}`;
|
|
64
|
+
state.subscriptionId = `sub_test_legacy_${Date.now()}`;
|
|
65
|
+
|
|
66
|
+
// Send a subscription created webhook with the LEGACY product ID
|
|
67
|
+
// This simulates an existing subscriber whose Stripe subscription still
|
|
68
|
+
// references the old product ID from before migration
|
|
69
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
70
|
+
id: state.legacyEventId,
|
|
71
|
+
type: 'customer.subscription.created',
|
|
72
|
+
data: {
|
|
73
|
+
object: {
|
|
74
|
+
id: state.subscriptionId,
|
|
75
|
+
object: 'subscription',
|
|
76
|
+
status: 'active',
|
|
77
|
+
metadata: { uid: state.uid },
|
|
78
|
+
cancel_at_period_end: false,
|
|
79
|
+
canceled_at: null,
|
|
80
|
+
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
81
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
82
|
+
start_date: Math.floor(Date.now() / 1000),
|
|
83
|
+
trial_start: null,
|
|
84
|
+
trial_end: null,
|
|
85
|
+
// KEY: use the LEGACY product ID, not the current one
|
|
86
|
+
plan: { product: state.legacyStripeProductId, interval: 'month' },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
assert.isSuccess(response, 'Webhook with legacy product ID should be accepted');
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
name: 'legacy-product-resolved-correctly',
|
|
97
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
98
|
+
if (state.skip) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Wait for webhook to complete
|
|
103
|
+
await waitFor(async () => {
|
|
104
|
+
const doc = await firestore.get(`payments-webhooks/${state.legacyEventId}`);
|
|
105
|
+
return doc?.status === 'completed' || doc?.status === 'failed';
|
|
106
|
+
}, 15000, 500);
|
|
107
|
+
|
|
108
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.legacyEventId}`);
|
|
109
|
+
assert.equal(webhookDoc.status, 'completed', 'Webhook should complete successfully');
|
|
110
|
+
assert.equal(webhookDoc.transition, 'new-subscription', 'Transition should be new-subscription');
|
|
111
|
+
|
|
112
|
+
// Core assertion: the legacy product ID resolved to the correct current product
|
|
113
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
114
|
+
assert.equal(
|
|
115
|
+
userDoc.subscription.product.id,
|
|
116
|
+
state.productId,
|
|
117
|
+
`Legacy product ID "${state.legacyStripeProductId}" should resolve to "${state.productId}" (not "basic")`,
|
|
118
|
+
);
|
|
119
|
+
assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
|
|
120
|
+
assert.equal(userDoc.subscription.payment.processor, 'test', 'Processor should be test');
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
name: 'order-doc-has-correct-product',
|
|
126
|
+
async run({ firestore, assert, state }) {
|
|
127
|
+
if (state.skip) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Find the order doc — legacy webhook may not have an orderId from metadata,
|
|
132
|
+
// so look it up by resourceId
|
|
133
|
+
const orderQuery = await firestore.query('payments-orders', {
|
|
134
|
+
where: [{ field: 'resourceId', op: '==', value: state.subscriptionId }],
|
|
135
|
+
limit: 1,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (orderQuery && orderQuery.length > 0) {
|
|
139
|
+
const orderDoc = orderQuery[0];
|
|
140
|
+
assert.equal(
|
|
141
|
+
orderDoc.unified?.product?.id,
|
|
142
|
+
state.productId,
|
|
143
|
+
`Order unified product should be "${state.productId}"`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - Refund Webhook
|
|
3
|
+
* Simulates: basic → paid active → pending cancel → charge.refunded webhook → payment-refunded transition
|
|
4
|
+
*
|
|
5
|
+
* Verifies that refund webhook events:
|
|
6
|
+
* 1. Flow through the pipeline correctly
|
|
7
|
+
* 2. Trigger the payment-refunded transition (not subscription-cancelled)
|
|
8
|
+
* 3. Extract refundDetails via the processor library's getRefundDetails()
|
|
9
|
+
* 4. Record the transition name on the webhook doc for auditing
|
|
10
|
+
*
|
|
11
|
+
* Product-agnostic: resolves the first paid product from config.payment.products
|
|
12
|
+
*/
|
|
13
|
+
module.exports = {
|
|
14
|
+
description: 'Payment journey: paid → refund webhook → payment-refunded transition',
|
|
15
|
+
type: 'suite',
|
|
16
|
+
timeout: 30000,
|
|
17
|
+
|
|
18
|
+
tests: [
|
|
19
|
+
{
|
|
20
|
+
name: 'setup-paid-subscription',
|
|
21
|
+
async run({ accounts, firestore, assert, state, config, http, waitFor }) {
|
|
22
|
+
const uid = accounts['journey-payments-refund-webhook'].uid;
|
|
23
|
+
|
|
24
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
|
|
25
|
+
assert.ok(paidProduct, 'Config should have at least one paid product with monthly price');
|
|
26
|
+
|
|
27
|
+
state.uid = uid;
|
|
28
|
+
state.paidProductId = paidProduct.id;
|
|
29
|
+
state.paidStripeProductId = paidProduct.stripe?.productId;
|
|
30
|
+
|
|
31
|
+
// Create subscription via test intent
|
|
32
|
+
const response = await http.as('journey-payments-refund-webhook').post('payments/intent', {
|
|
33
|
+
processor: 'test',
|
|
34
|
+
productId: paidProduct.id,
|
|
35
|
+
frequency: 'monthly',
|
|
36
|
+
});
|
|
37
|
+
assert.isSuccess(response, 'Intent should succeed');
|
|
38
|
+
state.orderId = response.data.orderId;
|
|
39
|
+
|
|
40
|
+
// Wait for subscription to activate
|
|
41
|
+
await waitFor(async () => {
|
|
42
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
43
|
+
return userDoc?.subscription?.product?.id === paidProduct.id
|
|
44
|
+
&& userDoc?.subscription?.status === 'active';
|
|
45
|
+
}, 15000, 500);
|
|
46
|
+
|
|
47
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
48
|
+
assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should be ${paidProduct.id}`);
|
|
49
|
+
assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
|
|
50
|
+
|
|
51
|
+
state.subscriptionId = userDoc.subscription.payment.resourceId;
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
name: 'send-pending-cancel-webhook',
|
|
57
|
+
async run({ http, assert, state, config }) {
|
|
58
|
+
const futureDate = new Date();
|
|
59
|
+
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
60
|
+
|
|
61
|
+
state.cancelEventId = `_test-evt-journey-refund-cancel-${Date.now()}`;
|
|
62
|
+
|
|
63
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
64
|
+
id: state.cancelEventId,
|
|
65
|
+
type: 'customer.subscription.updated',
|
|
66
|
+
data: {
|
|
67
|
+
object: {
|
|
68
|
+
id: state.subscriptionId,
|
|
69
|
+
object: 'subscription',
|
|
70
|
+
status: 'active',
|
|
71
|
+
metadata: { uid: state.uid },
|
|
72
|
+
cancel_at_period_end: true,
|
|
73
|
+
cancel_at: Math.floor(futureDate.getTime() / 1000),
|
|
74
|
+
canceled_at: null,
|
|
75
|
+
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
76
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
77
|
+
start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
78
|
+
trial_start: null,
|
|
79
|
+
trial_end: null,
|
|
80
|
+
plan: { product: state.paidStripeProductId, interval: 'month' },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
assert.isSuccess(response, 'Cancel webhook should be accepted');
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
name: 'cancellation-pending-confirmed',
|
|
91
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
92
|
+
await waitFor(async () => {
|
|
93
|
+
const doc = await firestore.get(`payments-webhooks/${state.cancelEventId}`);
|
|
94
|
+
return doc?.status === 'completed';
|
|
95
|
+
}, 15000, 500);
|
|
96
|
+
|
|
97
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.cancelEventId}`);
|
|
98
|
+
assert.equal(webhookDoc.transition, 'cancellation-requested', 'Should detect cancellation-requested');
|
|
99
|
+
|
|
100
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
101
|
+
assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
|
|
102
|
+
assert.equal(userDoc.subscription.cancellation.pending, true, 'Cancellation should be pending');
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
name: 'send-charge-refunded-webhook',
|
|
108
|
+
async run({ http, assert, state, config }) {
|
|
109
|
+
// Simulate a charge.refunded event (Stripe-shaped, used by test processor)
|
|
110
|
+
// This carries refund amount data that getRefundDetails() extracts
|
|
111
|
+
state.refundEventId = `_test-evt-journey-refund-charge-${Date.now()}`;
|
|
112
|
+
state.refundAmountCents = 2800; // $28.00
|
|
113
|
+
|
|
114
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
115
|
+
id: state.refundEventId,
|
|
116
|
+
type: 'charge.refunded',
|
|
117
|
+
data: {
|
|
118
|
+
object: {
|
|
119
|
+
id: `ch_test_${Date.now()}`,
|
|
120
|
+
object: 'charge',
|
|
121
|
+
amount: state.refundAmountCents,
|
|
122
|
+
amount_refunded: state.refundAmountCents,
|
|
123
|
+
currency: 'usd',
|
|
124
|
+
subscription: state.subscriptionId,
|
|
125
|
+
metadata: { uid: state.uid },
|
|
126
|
+
refunds: {
|
|
127
|
+
data: [
|
|
128
|
+
{
|
|
129
|
+
id: `re_test_${Date.now()}`,
|
|
130
|
+
amount: state.refundAmountCents,
|
|
131
|
+
currency: 'usd',
|
|
132
|
+
reason: 'requested_by_customer',
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
assert.isSuccess(response, 'Refund webhook should be accepted');
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
name: 'payment-refunded-transition-detected',
|
|
146
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
147
|
+
// Wait for the refund webhook to be processed
|
|
148
|
+
await waitFor(async () => {
|
|
149
|
+
const doc = await firestore.get(`payments-webhooks/${state.refundEventId}`);
|
|
150
|
+
return doc?.status === 'completed';
|
|
151
|
+
}, 15000, 500);
|
|
152
|
+
|
|
153
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.refundEventId}`);
|
|
154
|
+
|
|
155
|
+
// Core assertion: refund events trigger payment-refunded, not subscription-cancelled
|
|
156
|
+
assert.equal(webhookDoc.status, 'completed', 'Webhook should complete successfully');
|
|
157
|
+
assert.equal(webhookDoc.transition, 'payment-refunded', 'Transition should be payment-refunded (not subscription-cancelled)');
|
|
158
|
+
assert.equal(webhookDoc.owner, state.uid, 'Owner should match');
|
|
159
|
+
assert.equal(webhookDoc.orderId, state.orderId, 'Order ID should match');
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
name: 'order-doc-updated',
|
|
165
|
+
async run({ firestore, assert, state }) {
|
|
166
|
+
const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
|
|
167
|
+
|
|
168
|
+
assert.ok(orderDoc, 'Order doc should exist');
|
|
169
|
+
assert.equal(orderDoc.type, 'subscription', 'Type should be subscription');
|
|
170
|
+
assert.equal(orderDoc.owner, state.uid, 'Owner should match');
|
|
171
|
+
|
|
172
|
+
// The order was last updated by the refund webhook event
|
|
173
|
+
assert.equal(orderDoc.metadata?.updatedBy?.event?.name, 'charge.refunded', 'Last event should be charge.refunded');
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - UID Resolution Fallback
|
|
3
|
+
* Simulates: paid active subscription → webhook with uid OMITTED from metadata
|
|
4
|
+
*
|
|
5
|
+
* Verifies Fix 1: when a webhook event doesn't carry uid in metadata (like PayPal's
|
|
6
|
+
* PAYMENT.SALE events), the pipeline resolves uid from the fetched resource using
|
|
7
|
+
* library.getUid() and persists it on the webhook doc.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. Set up paid subscription via test intent (establishes payments-orders doc with resourceId)
|
|
11
|
+
* 2. Send customer.subscription.updated webhook WITHOUT uid in metadata
|
|
12
|
+
* 3. Verify: webhook completes (not fails), uid resolved, subscription updated
|
|
13
|
+
*
|
|
14
|
+
* Product-agnostic: resolves the first paid product from config.payment.products
|
|
15
|
+
*/
|
|
16
|
+
module.exports = {
|
|
17
|
+
description: 'Payment journey: webhook without uid → UID resolved from fetched resource',
|
|
18
|
+
type: 'suite',
|
|
19
|
+
timeout: 30000,
|
|
20
|
+
|
|
21
|
+
tests: [
|
|
22
|
+
{
|
|
23
|
+
name: 'setup-paid-subscription',
|
|
24
|
+
async run({ accounts, firestore, assert, state, config, http, waitFor }) {
|
|
25
|
+
const uid = accounts['journey-payments-uid-resolution'].uid;
|
|
26
|
+
|
|
27
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
|
|
28
|
+
assert.ok(paidProduct, 'Config should have at least one paid product with monthly price');
|
|
29
|
+
|
|
30
|
+
state.uid = uid;
|
|
31
|
+
state.paidProductId = paidProduct.id;
|
|
32
|
+
state.paidStripeProductId = paidProduct.stripe?.productId;
|
|
33
|
+
|
|
34
|
+
// Create subscription via test intent
|
|
35
|
+
const response = await http.as('journey-payments-uid-resolution').post('payments/intent', {
|
|
36
|
+
processor: 'test',
|
|
37
|
+
productId: paidProduct.id,
|
|
38
|
+
frequency: 'monthly',
|
|
39
|
+
});
|
|
40
|
+
assert.isSuccess(response, 'Intent should succeed');
|
|
41
|
+
state.orderId = response.data.orderId;
|
|
42
|
+
|
|
43
|
+
// Wait for subscription to activate
|
|
44
|
+
await waitFor(async () => {
|
|
45
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
46
|
+
return userDoc?.subscription?.product?.id === paidProduct.id
|
|
47
|
+
&& userDoc?.subscription?.status === 'active';
|
|
48
|
+
}, 15000, 500);
|
|
49
|
+
|
|
50
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
51
|
+
state.subscriptionId = userDoc.subscription.payment.resourceId;
|
|
52
|
+
|
|
53
|
+
assert.ok(state.subscriptionId, 'Subscription resource ID should exist');
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
name: 'send-webhook-without-uid',
|
|
59
|
+
async run({ http, assert, state, config }) {
|
|
60
|
+
// Send a subscription update webhook WITHOUT uid in metadata
|
|
61
|
+
// The test processor's fetchResource() will look up payments-orders by resourceId
|
|
62
|
+
// and reconstruct a Stripe-shaped subscription that includes metadata.uid
|
|
63
|
+
// Then library.getUid() extracts uid from the fetched resource
|
|
64
|
+
const futureDate = new Date();
|
|
65
|
+
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
66
|
+
|
|
67
|
+
state.noUidEventId = `_test-evt-journey-uid-resolve-${Date.now()}`;
|
|
68
|
+
|
|
69
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
70
|
+
id: state.noUidEventId,
|
|
71
|
+
type: 'customer.subscription.updated',
|
|
72
|
+
data: {
|
|
73
|
+
object: {
|
|
74
|
+
id: state.subscriptionId,
|
|
75
|
+
object: 'subscription',
|
|
76
|
+
status: 'active',
|
|
77
|
+
// NO metadata.uid — this is the key part of the test
|
|
78
|
+
metadata: {},
|
|
79
|
+
cancel_at_period_end: true,
|
|
80
|
+
cancel_at: Math.floor(futureDate.getTime() / 1000),
|
|
81
|
+
canceled_at: null,
|
|
82
|
+
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
83
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
84
|
+
start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
85
|
+
trial_start: null,
|
|
86
|
+
trial_end: null,
|
|
87
|
+
plan: { product: state.paidStripeProductId, interval: 'month' },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
assert.isSuccess(response, 'Webhook should be accepted (even without uid)');
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
name: 'uid-resolved-and-webhook-completed',
|
|
98
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
99
|
+
// Wait for the webhook to be processed
|
|
100
|
+
await waitFor(async () => {
|
|
101
|
+
const doc = await firestore.get(`payments-webhooks/${state.noUidEventId}`);
|
|
102
|
+
return doc?.status === 'completed' || doc?.status === 'failed';
|
|
103
|
+
}, 15000, 500);
|
|
104
|
+
|
|
105
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.noUidEventId}`);
|
|
106
|
+
|
|
107
|
+
// Core assertions: UID was resolved from the fetched resource
|
|
108
|
+
assert.equal(webhookDoc.status, 'completed', 'Webhook should complete (not fail due to missing UID)');
|
|
109
|
+
assert.equal(webhookDoc.owner, state.uid, 'Owner should be resolved to the correct UID');
|
|
110
|
+
assert.equal(webhookDoc.orderId, state.orderId, 'Order ID should match');
|
|
111
|
+
|
|
112
|
+
// Transition should be cancellation-requested (we sent cancel_at_period_end: true)
|
|
113
|
+
assert.equal(webhookDoc.transition, 'cancellation-requested', 'Transition should be cancellation-requested');
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
name: 'subscription-updated-correctly',
|
|
119
|
+
async run({ firestore, assert, state }) {
|
|
120
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
121
|
+
|
|
122
|
+
// The webhook should have updated the user doc even though uid wasn't in the webhook metadata
|
|
123
|
+
assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
|
|
124
|
+
assert.equal(userDoc.subscription.cancellation.pending, true, 'Cancellation should be pending');
|
|
125
|
+
assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
};
|