backend-manager 5.0.113 → 5.0.115
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/cli/commands/logs.js +4 -4
- package/src/manager/cron/daily/data-requests.js +4 -4
- package/src/manager/cron/daily/expire-paypal-cancellations.js +104 -0
- package/src/manager/events/firestore/payments-webhooks/on-write.js +30 -11
- 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 +129 -6
- package/src/manager/libraries/payment/processors/stripe.js +31 -1
- package/src/manager/libraries/payment/processors/test.js +33 -6
- package/src/manager/routes/payments/intent/post.js +1 -1
- package/src/manager/routes/payments/webhook/post.js +2 -2
- package/src/manager/routes/payments/webhook/processors/stripe.js +27 -0
- package/src/manager/routes/user/data-request/get.js +5 -5
- 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/test/helpers/payment/paypal/to-unified-one-time.js +3 -3
- package/test/helpers/payment/stripe/to-unified-one-time.js +3 -3
package/package.json
CHANGED
package/src/cli/commands/logs.js
CHANGED
|
@@ -56,11 +56,11 @@ class LogsCommand extends BaseCommand {
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Fetch historical logs.
|
|
59
|
-
* Usage: npx bm logs:read [--fn bm_api] [--severity ERROR] [--since 1h] [--limit
|
|
59
|
+
* Usage: npx bm logs:read [--fn bm_api] [--severity ERROR] [--since 1h] [--limit 300]
|
|
60
60
|
*/
|
|
61
61
|
async read(projectId, argv) {
|
|
62
62
|
const filter = this.buildFilter(argv);
|
|
63
|
-
const limit = parseInt(argv.limit, 10) ||
|
|
63
|
+
const limit = parseInt(argv.limit, 10) || 300;
|
|
64
64
|
|
|
65
65
|
const cmd = [
|
|
66
66
|
'gcloud', 'logging', 'read',
|
|
@@ -88,8 +88,8 @@ class LogsCommand extends BaseCommand {
|
|
|
88
88
|
|
|
89
89
|
const entries = JSON.parse(output || '[]');
|
|
90
90
|
|
|
91
|
-
// Save
|
|
92
|
-
jetpack.write(logPath, JSON.stringify(
|
|
91
|
+
// Save as newline-delimited JSON (matches tail format)
|
|
92
|
+
jetpack.write(logPath, entries.map(e => JSON.stringify(e)).join('\n'));
|
|
93
93
|
|
|
94
94
|
if (entries.length === 0) {
|
|
95
95
|
this.logWarning('No log entries found.');
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Data requests cron job
|
|
3
3
|
*
|
|
4
4
|
* Processes data request status transitions:
|
|
5
|
-
* - pending →
|
|
6
|
-
* -
|
|
5
|
+
* - pending → completed: 14 days after creation
|
|
6
|
+
* - completed → expired: 30 days after becoming completed (44 days after creation)
|
|
7
7
|
*
|
|
8
8
|
* Scans the entire collection (no index required) since data-requests is small.
|
|
9
9
|
*/
|
|
@@ -33,7 +33,7 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
|
33
33
|
const age = nowUNIX - createdUNIX;
|
|
34
34
|
|
|
35
35
|
if (data.status === 'pending' && age >= FOURTEEN_DAYS) {
|
|
36
|
-
await doc.ref.update({ status: '
|
|
36
|
+
await doc.ref.update({ status: 'completed' })
|
|
37
37
|
.then(() => {
|
|
38
38
|
completed++;
|
|
39
39
|
assistant.log(`Completed request ${doc.id} (age: ${Math.round(age / 86400)}d)`);
|
|
@@ -41,7 +41,7 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
|
41
41
|
.catch((e) => {
|
|
42
42
|
assistant.error(`Failed to complete request ${doc.id}: ${e.message}`);
|
|
43
43
|
});
|
|
44
|
-
} else if (data.status === '
|
|
44
|
+
} else if (data.status === 'completed' && age >= FORTY_FOUR_DAYS) {
|
|
45
45
|
await doc.ref.update({ status: 'expired' })
|
|
46
46
|
.then(() => {
|
|
47
47
|
expired++;
|
|
@@ -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,10 +65,28 @@ 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' });
|
|
76
|
-
const webhookReceivedUNIX = dataAfter.metadata?.
|
|
89
|
+
const webhookReceivedUNIX = dataAfter.metadata?.created?.timestampUNIX || nowUNIX;
|
|
77
90
|
|
|
78
91
|
// Extract orderId from resource (processor-agnostic)
|
|
79
92
|
orderId = library.getOrderId(resource);
|
|
@@ -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({
|
|
@@ -92,7 +105,7 @@ module.exports = async ({ assistant, change, context }) => {
|
|
|
92
105
|
orderId: orderId,
|
|
93
106
|
transition: transitionName,
|
|
94
107
|
metadata: {
|
|
95
|
-
|
|
108
|
+
completed: {
|
|
96
109
|
timestamp: now,
|
|
97
110
|
timestampUNIX: nowUNIX,
|
|
98
111
|
},
|
|
@@ -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';
|
|
@@ -186,6 +199,7 @@ async function processPaymentEvent({ category, library, resource, resourceType,
|
|
|
186
199
|
id: orderId,
|
|
187
200
|
type: category,
|
|
188
201
|
owner: uid,
|
|
202
|
+
productId: unified.product.id,
|
|
189
203
|
processor: processor,
|
|
190
204
|
resourceId: resourceId,
|
|
191
205
|
unified: unified,
|
|
@@ -215,8 +229,13 @@ async function processPaymentEvent({ category, library, resource, resourceType,
|
|
|
215
229
|
assistant.log(`Transition detected: ${category}/${transitionName} (before.status=${before?.status || 'null'}, after.status=${unified.status})`);
|
|
216
230
|
|
|
217
231
|
if (shouldRunHandlers) {
|
|
232
|
+
// Extract unified refund details from the processor library (keeps handlers processor-agnostic)
|
|
233
|
+
const refundDetails = (transitionName === 'payment-refunded' && library.getRefundDetails)
|
|
234
|
+
? library.getRefundDetails(raw)
|
|
235
|
+
: null;
|
|
236
|
+
|
|
218
237
|
transitions.dispatch(transitionName, category, {
|
|
219
|
-
before, after: unified, order, uid, userDoc: userData, assistant,
|
|
238
|
+
before, after: unified, order, uid, userDoc: userData, assistant, refundDetails,
|
|
220
239
|
});
|
|
221
240
|
} else {
|
|
222
241
|
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
|
+
};
|
|
@@ -268,7 +268,7 @@ const PayPal = {
|
|
|
268
268
|
|
|
269
269
|
return {
|
|
270
270
|
product: product,
|
|
271
|
-
status: rawResource.status === 'COMPLETED' ? '
|
|
271
|
+
status: rawResource.status === 'COMPLETED' ? 'completed' : rawResource.status?.toLowerCase() || 'unknown',
|
|
272
272
|
payment: {
|
|
273
273
|
processor: 'paypal',
|
|
274
274
|
orderId: customData.orderId || null,
|
|
@@ -314,6 +314,7 @@ const PayPal = {
|
|
|
314
314
|
}
|
|
315
315
|
|
|
316
316
|
// Fetch plans with full details (Prefer header includes billing_cycles in list response)
|
|
317
|
+
// TODO: Paginate — page_size=20 only returns first page. Fine for now (each product has ~2-4 plans in 1:1 model) but will break if a product ever accumulates >20 plans.
|
|
317
318
|
const response = await this.request(`/v1/billing/plans?product_id=${paypalProductId}&page_size=20&total_required=true`, {
|
|
318
319
|
headers: { 'Prefer': 'return=representation' },
|
|
319
320
|
});
|
|
@@ -358,6 +359,35 @@ const PayPal = {
|
|
|
358
359
|
return customData.orderId || null;
|
|
359
360
|
},
|
|
360
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Extract the UID from a PayPal resource's custom_id
|
|
364
|
+
* Used to resolve UID after fetchResource() for events like PAYMENT.SALE
|
|
365
|
+
* where the initial webhook payload doesn't carry custom_id
|
|
366
|
+
*
|
|
367
|
+
* @param {object} resource - Raw PayPal resource (subscription or order)
|
|
368
|
+
* @returns {string|null}
|
|
369
|
+
*/
|
|
370
|
+
getUid(resource) {
|
|
371
|
+
const purchaseCustomId = resource.purchase_units?.[0]?.custom_id;
|
|
372
|
+
const customData = parseCustomId(purchaseCustomId || resource.custom_id);
|
|
373
|
+
return customData?.uid || null;
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Extract refund details from a PayPal PAYMENT.SALE.REFUNDED webhook payload
|
|
378
|
+
* Returns a unified shape so transition handlers stay processor-agnostic
|
|
379
|
+
*
|
|
380
|
+
* @param {object} raw - Raw PayPal webhook payload
|
|
381
|
+
* @returns {{ amount: string|null, currency: string, reason: string|null }}
|
|
382
|
+
*/
|
|
383
|
+
getRefundDetails(raw) {
|
|
384
|
+
return {
|
|
385
|
+
amount: raw?.resource?.amount?.total || raw?.resource?.total_refunded_amount?.value || null,
|
|
386
|
+
currency: raw?.resource?.amount?.currency || 'USD',
|
|
387
|
+
reason: raw?.resource?.reason_code || null,
|
|
388
|
+
};
|
|
389
|
+
},
|
|
390
|
+
|
|
361
391
|
/**
|
|
362
392
|
* Build the custom_id string for PayPal subscriptions and orders
|
|
363
393
|
* Format: uid:{uid},orderId:{orderId} or uid:{uid},orderId:{orderId},productId:{productId}
|
|
@@ -408,6 +438,44 @@ function parseCustomId(customId) {
|
|
|
408
438
|
return result;
|
|
409
439
|
}
|
|
410
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Calculate when the current billing period ends based on last payment + interval
|
|
443
|
+
* Used for cancelled subs to determine remaining access time
|
|
444
|
+
*
|
|
445
|
+
* @param {object} raw - Raw PayPal subscription (with _plan attached)
|
|
446
|
+
* @returns {Date|null} Period end date, or null if cannot be calculated
|
|
447
|
+
*/
|
|
448
|
+
function calculatePeriodEnd(raw) {
|
|
449
|
+
const lastPayment = raw.billing_info?.last_payment?.time;
|
|
450
|
+
|
|
451
|
+
if (!lastPayment) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const plan = raw._plan;
|
|
456
|
+
const regularCycle = plan?.billing_cycles?.find(c => c.tenure_type === 'REGULAR');
|
|
457
|
+
|
|
458
|
+
if (!regularCycle) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const unit = regularCycle.frequency.interval_unit;
|
|
463
|
+
const count = regularCycle.frequency.interval_count || 1;
|
|
464
|
+
const lastDate = new Date(lastPayment);
|
|
465
|
+
|
|
466
|
+
if (unit === 'YEAR') {
|
|
467
|
+
lastDate.setFullYear(lastDate.getFullYear() + count);
|
|
468
|
+
} else if (unit === 'MONTH') {
|
|
469
|
+
lastDate.setMonth(lastDate.getMonth() + count);
|
|
470
|
+
} else if (unit === 'WEEK') {
|
|
471
|
+
lastDate.setDate(lastDate.getDate() + (count * 7));
|
|
472
|
+
} else if (unit === 'DAY') {
|
|
473
|
+
lastDate.setDate(lastDate.getDate() + count);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return lastDate;
|
|
477
|
+
}
|
|
478
|
+
|
|
411
479
|
/**
|
|
412
480
|
* Map PayPal subscription status to unified status
|
|
413
481
|
*
|
|
@@ -415,7 +483,8 @@ function parseCustomId(customId) {
|
|
|
415
483
|
* |------------------|----------------|
|
|
416
484
|
* | ACTIVE | active |
|
|
417
485
|
* | SUSPENDED | suspended |
|
|
418
|
-
* | CANCELLED
|
|
486
|
+
* | CANCELLED (period remaining) | active (with cancellation.pending) |
|
|
487
|
+
* | CANCELLED (period ended) | cancelled |
|
|
419
488
|
* | EXPIRED | cancelled |
|
|
420
489
|
* | APPROVAL_PENDING | cancelled |
|
|
421
490
|
* | APPROVED | active |
|
|
@@ -431,17 +500,47 @@ function resolveStatus(raw) {
|
|
|
431
500
|
return 'suspended';
|
|
432
501
|
}
|
|
433
502
|
|
|
434
|
-
// CANCELLED
|
|
503
|
+
// CANCELLED — check if user still has paid time remaining
|
|
504
|
+
if (status === 'CANCELLED') {
|
|
505
|
+
const periodEnd = calculatePeriodEnd(raw);
|
|
506
|
+
|
|
507
|
+
if (periodEnd && periodEnd > new Date()) {
|
|
508
|
+
// User still has access until period end — treat as active with pending cancellation
|
|
509
|
+
return 'active';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return 'cancelled';
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// EXPIRED, APPROVAL_PENDING, or anything else
|
|
435
516
|
return 'cancelled';
|
|
436
517
|
}
|
|
437
518
|
|
|
438
519
|
/**
|
|
439
520
|
* Resolve cancellation state from PayPal subscription
|
|
521
|
+
*
|
|
522
|
+
* PayPal has no cancel_at_period_end like Stripe. When cancelled:
|
|
523
|
+
* - If billing period hasn't ended: pending=true, date=period end (user keeps access)
|
|
524
|
+
* - If billing period has ended: pending=false, date=cancellation time (fully cancelled)
|
|
440
525
|
*/
|
|
441
526
|
function resolveCancellation(raw) {
|
|
442
527
|
if (raw.status === 'CANCELLED') {
|
|
443
|
-
|
|
444
|
-
|
|
528
|
+
const periodEnd = calculatePeriodEnd(raw);
|
|
529
|
+
|
|
530
|
+
// Period still active — pending cancellation (user keeps access until period end)
|
|
531
|
+
if (periodEnd && periodEnd > new Date()) {
|
|
532
|
+
const periodEndStr = powertools.timestamp(periodEnd, { output: 'string' });
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
pending: true,
|
|
536
|
+
date: {
|
|
537
|
+
timestamp: periodEndStr,
|
|
538
|
+
timestampUNIX: powertools.timestamp(periodEndStr, { output: 'unix' }),
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Period has ended — fully cancelled
|
|
445
544
|
const cancelDate = raw.status_update_time
|
|
446
545
|
? powertools.timestamp(new Date(raw.status_update_time), { output: 'string' })
|
|
447
546
|
: EPOCH_ZERO;
|
|
@@ -542,6 +641,7 @@ function resolveFrequency(raw) {
|
|
|
542
641
|
/**
|
|
543
642
|
* Resolve product by matching the PayPal product ID against config products
|
|
544
643
|
* Uses: sub._plan.product_id → match config product.paypal.productId
|
|
644
|
+
* Also checks paypal.legacyProductIds[] for migrated products
|
|
545
645
|
*/
|
|
546
646
|
function resolveProduct(raw, config) {
|
|
547
647
|
// Get PayPal product ID from the plan (attached during fetchResource)
|
|
@@ -552,9 +652,15 @@ function resolveProduct(raw, config) {
|
|
|
552
652
|
}
|
|
553
653
|
|
|
554
654
|
for (const product of config.payment.products) {
|
|
655
|
+
// Check current product ID
|
|
555
656
|
if (product.paypal?.productId === paypalProductId) {
|
|
556
657
|
return { id: product.id, name: product.name || product.id };
|
|
557
658
|
}
|
|
659
|
+
|
|
660
|
+
// Check legacy product IDs (for migrated products)
|
|
661
|
+
if (product.paypal?.legacyProductIds?.includes(paypalProductId)) {
|
|
662
|
+
return { id: product.id, name: product.name || product.id };
|
|
663
|
+
}
|
|
558
664
|
}
|
|
559
665
|
|
|
560
666
|
return { id: 'basic', name: 'Basic' };
|
|
@@ -579,9 +685,26 @@ function resolveProductOneTime(productId, config) {
|
|
|
579
685
|
|
|
580
686
|
/**
|
|
581
687
|
* Resolve subscription expiration from PayPal data
|
|
688
|
+
*
|
|
689
|
+
* For cancelled subs with remaining time, uses calculated period end.
|
|
690
|
+
* For active subs, uses next_billing_time.
|
|
582
691
|
*/
|
|
583
692
|
function resolveExpires(raw) {
|
|
584
|
-
//
|
|
693
|
+
// Cancelled subs with remaining time — use calculated period end
|
|
694
|
+
if (raw.status === 'CANCELLED') {
|
|
695
|
+
const periodEnd = calculatePeriodEnd(raw);
|
|
696
|
+
|
|
697
|
+
if (periodEnd && periodEnd > new Date()) {
|
|
698
|
+
const expiresStr = powertools.timestamp(periodEnd, { output: 'string' });
|
|
699
|
+
|
|
700
|
+
return {
|
|
701
|
+
timestamp: expiresStr,
|
|
702
|
+
timestampUNIX: powertools.timestamp(expiresStr, { output: 'unix' }),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Active subs: PayPal's billing_info.next_billing_time is the closest to "period end"
|
|
585
708
|
const nextBilling = raw.billing_info?.next_billing_time;
|
|
586
709
|
|
|
587
710
|
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
|
|
@@ -274,7 +304,7 @@ const Stripe = {
|
|
|
274
304
|
|
|
275
305
|
return {
|
|
276
306
|
product: product,
|
|
277
|
-
status: rawResource.status || 'unknown',
|
|
307
|
+
status: rawResource.status === 'complete' ? 'completed' : rawResource.status || 'unknown',
|
|
278
308
|
payment: {
|
|
279
309
|
processor: 'stripe',
|
|
280
310
|
orderId: rawResource.metadata?.orderId || null,
|
|
@@ -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',
|
|
@@ -116,7 +116,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
116
116
|
owner: uid,
|
|
117
117
|
status: 'pending',
|
|
118
118
|
productId: productId,
|
|
119
|
-
|
|
119
|
+
type: productType,
|
|
120
120
|
frequency: frequency,
|
|
121
121
|
trial: trial,
|
|
122
122
|
raw: result.raw,
|
|
@@ -91,11 +91,11 @@ module.exports = async ({ assistant, Manager, libraries }) => {
|
|
|
91
91
|
},
|
|
92
92
|
error: null,
|
|
93
93
|
metadata: {
|
|
94
|
-
|
|
94
|
+
created: {
|
|
95
95
|
timestamp: now,
|
|
96
96
|
timestampUNIX: nowUNIX,
|
|
97
97
|
},
|
|
98
|
-
|
|
98
|
+
completed: {
|
|
99
99
|
timestamp: null,
|
|
100
100
|
timestampUNIX: null,
|
|
101
101
|
},
|
|
@@ -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 {
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* GET /user/data-request - Check data request status or download data
|
|
3
3
|
*
|
|
4
4
|
* action=status (default): Returns the most recent request with its stored status.
|
|
5
|
-
* action=download: Compiles user data live and returns it. Only works when status is '
|
|
5
|
+
* action=download: Compiles user data live and returns it. Only works when status is 'completed'.
|
|
6
6
|
*
|
|
7
7
|
* Statuses:
|
|
8
|
-
* pending
|
|
9
|
-
*
|
|
8
|
+
* pending — request submitted, waiting to be processed (bm_cronDaily sets to 'completed' after 14 days)
|
|
9
|
+
* completed — data is available for download (downloads counter tracks how many times downloaded)
|
|
10
10
|
*/
|
|
11
11
|
module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
12
12
|
const { admin } = libraries;
|
|
@@ -43,8 +43,8 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// Download action — only allowed when status is '
|
|
47
|
-
if (status !== '
|
|
46
|
+
// Download action — only allowed when status is 'completed'
|
|
47
|
+
if (status !== 'completed') {
|
|
48
48
|
return assistant.respond('Your data request is still being processed. Please check back later.', { code: 400 });
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -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
|
+
};
|
|
@@ -45,7 +45,7 @@ module.exports = {
|
|
|
45
45
|
name: 'status-completed',
|
|
46
46
|
async run({ assert }) {
|
|
47
47
|
const result = toUnifiedOneTime({ status: 'COMPLETED' });
|
|
48
|
-
assert.equal(result.status, '
|
|
48
|
+
assert.equal(result.status, 'completed', 'PayPal COMPLETED → unified completed');
|
|
49
49
|
},
|
|
50
50
|
},
|
|
51
51
|
|
|
@@ -250,7 +250,7 @@ module.exports = {
|
|
|
250
250
|
assert.ok(result.payment, 'Should have payment');
|
|
251
251
|
|
|
252
252
|
assert.equal(result.product.id, 'credits-100', 'Product should be credits-100');
|
|
253
|
-
assert.equal(result.status, '
|
|
253
|
+
assert.equal(result.status, 'completed', 'Status should be completed');
|
|
254
254
|
assert.equal(result.payment.processor, 'paypal', 'Processor should be paypal');
|
|
255
255
|
assert.equal(result.payment.resourceId, 'PAYID-FULL', 'Resource ID should match');
|
|
256
256
|
assert.equal(result.payment.orderId, '1234-5678-9012', 'orderId should match');
|
|
@@ -336,7 +336,7 @@ module.exports = {
|
|
|
336
336
|
async run({ assert }) {
|
|
337
337
|
const result = toUnifiedOneTime(FIXTURE_ORDER_COMPLETED);
|
|
338
338
|
|
|
339
|
-
assert.equal(result.status, '
|
|
339
|
+
assert.equal(result.status, 'completed', 'COMPLETED fixture → completed');
|
|
340
340
|
assert.equal(result.payment.processor, 'paypal', 'Processor is paypal');
|
|
341
341
|
assert.equal(result.payment.resourceId, '5UX02069M9686893E', 'Resource ID from fixture');
|
|
342
342
|
assert.equal(result.payment.orderId, 'ord-test-456', 'orderId from purchase_units custom_id');
|
|
@@ -44,7 +44,7 @@ module.exports = {
|
|
|
44
44
|
name: 'status-complete',
|
|
45
45
|
async run({ assert }) {
|
|
46
46
|
const result = toUnifiedOneTime({ status: 'complete' });
|
|
47
|
-
assert.equal(result.status, '
|
|
47
|
+
assert.equal(result.status, 'completed', 'Stripe complete → unified completed');
|
|
48
48
|
},
|
|
49
49
|
},
|
|
50
50
|
|
|
@@ -211,7 +211,7 @@ module.exports = {
|
|
|
211
211
|
assert.ok(result.payment, 'Should have payment');
|
|
212
212
|
|
|
213
213
|
assert.equal(result.product.id, 'credits-100', 'Product should be credits-100');
|
|
214
|
-
assert.equal(result.status, '
|
|
214
|
+
assert.equal(result.status, 'completed', 'Status should be completed');
|
|
215
215
|
assert.equal(result.payment.processor, 'stripe', 'Processor should be stripe');
|
|
216
216
|
assert.equal(result.payment.resourceId, 'cs_test_full', 'Resource ID should match');
|
|
217
217
|
assert.equal(result.payment.orderId, '1234-5678-9012', 'orderId should match');
|
|
@@ -253,7 +253,7 @@ module.exports = {
|
|
|
253
253
|
const result = toUnifiedOneTime(FIXTURE_SESSION);
|
|
254
254
|
|
|
255
255
|
assert.ok(result.product, 'Should have product');
|
|
256
|
-
assert.equal(result.status, '
|
|
256
|
+
assert.equal(result.status, 'completed', 'Real session fixture → completed');
|
|
257
257
|
assert.equal(result.payment.processor, 'stripe', 'Processor is stripe');
|
|
258
258
|
assert.equal(result.payment.resourceId, FIXTURE_SESSION.id, 'resourceId matches fixture ID');
|
|
259
259
|
},
|