backend-manager 5.0.88 → 5.0.91
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/CLAUDE.md +133 -2
- package/README.md +1 -1
- package/package.json +5 -3
- package/src/cli/index.js +11 -0
- package/src/manager/events/firestore/payments-webhooks/analytics.js +170 -0
- package/src/manager/events/firestore/payments-webhooks/on-write.js +75 -315
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +20 -10
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +67 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +23 -9
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +22 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +19 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +19 -7
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +27 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +25 -9
- package/src/manager/helpers/user.js +2 -0
- package/src/manager/libraries/payment-processors/order-id.js +18 -0
- package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
- package/src/manager/libraries/payment-processors/stripe.js +88 -47
- package/src/manager/libraries/payment-processors/test.js +14 -11
- package/src/manager/routes/payments/intent/post.js +61 -7
- package/src/manager/routes/payments/intent/processors/stripe.js +18 -50
- package/src/manager/routes/payments/intent/processors/test.js +18 -22
- package/src/manager/routes/payments/webhook/post.js +1 -1
- package/src/test/runner.js +11 -0
- package/src/test/test-accounts.js +20 -2
- package/templates/backend-manager-config.json +31 -12
- package/test/events/payments/journey-payments-cancel.js +2 -0
- package/test/events/payments/journey-payments-failure.js +2 -0
- package/test/events/payments/journey-payments-one-time-failure.js +105 -0
- package/test/events/payments/journey-payments-one-time.js +128 -0
- package/test/events/payments/journey-payments-plan-change.js +126 -0
- package/test/events/payments/journey-payments-suspend.js +2 -0
- package/test/events/payments/journey-payments-trial.js +4 -0
- package/test/events/payments/journey-payments-upgrade.js +20 -10
- package/test/helpers/stripe-to-unified.js +17 -0
- package/test/helpers/user.js +1 -0
- package/test/routes/payments/intent.js +10 -7
- /package/bin/{bem → backend-manager} +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - Plan Change
|
|
3
|
+
* Simulates: basic → paid product A → plan-changed webhook → paid product B
|
|
4
|
+
*
|
|
5
|
+
* Uses test intent for initial subscription, then manual webhook to change plans.
|
|
6
|
+
* Requires at least two paid subscription products in config.
|
|
7
|
+
*/
|
|
8
|
+
module.exports = {
|
|
9
|
+
description: 'Payment journey: paid product A → plan-changed → paid product B',
|
|
10
|
+
type: 'suite',
|
|
11
|
+
timeout: 30000,
|
|
12
|
+
|
|
13
|
+
tests: [
|
|
14
|
+
{
|
|
15
|
+
name: 'setup-paid-subscription',
|
|
16
|
+
async run({ accounts, firestore, assert, state, config, http, waitFor }) {
|
|
17
|
+
const uid = accounts['journey-payments-plan-change'].uid;
|
|
18
|
+
|
|
19
|
+
// Resolve two distinct paid subscription products from config
|
|
20
|
+
const paidProducts = config.payment.products.filter(p => p.id !== 'basic' && p.type === 'subscription' && p.prices);
|
|
21
|
+
assert.ok(paidProducts.length >= 2, 'Config should have at least two paid subscription products');
|
|
22
|
+
|
|
23
|
+
const productA = paidProducts[0];
|
|
24
|
+
const productB = paidProducts[1];
|
|
25
|
+
|
|
26
|
+
state.uid = uid;
|
|
27
|
+
state.productA = { id: productA.id, name: productA.name, priceId: productA.prices.monthly.stripe };
|
|
28
|
+
state.productB = { id: productB.id, name: productB.name, priceId: productB.prices.monthly.stripe };
|
|
29
|
+
|
|
30
|
+
// Create subscription via test intent (product A)
|
|
31
|
+
const response = await http.as('journey-payments-plan-change').post('payments/intent', {
|
|
32
|
+
processor: 'test',
|
|
33
|
+
productId: productA.id,
|
|
34
|
+
frequency: 'monthly',
|
|
35
|
+
});
|
|
36
|
+
assert.isSuccess(response, 'Intent should succeed');
|
|
37
|
+
state.orderId = response.data.orderId;
|
|
38
|
+
|
|
39
|
+
// Wait for subscription to activate
|
|
40
|
+
await waitFor(async () => {
|
|
41
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
42
|
+
return userDoc?.subscription?.product?.id === productA.id;
|
|
43
|
+
}, 15000, 500);
|
|
44
|
+
|
|
45
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
46
|
+
assert.equal(userDoc.subscription?.product?.id, productA.id, `Should start as ${productA.id}`);
|
|
47
|
+
assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
|
|
48
|
+
|
|
49
|
+
state.subscriptionId = userDoc.subscription.payment.resourceId;
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
name: 'send-plan-change-webhook',
|
|
55
|
+
async run({ http, assert, state, config }) {
|
|
56
|
+
const futureDate = new Date();
|
|
57
|
+
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
58
|
+
|
|
59
|
+
state.eventId = `_test-evt-journey-plan-change-${Date.now()}`;
|
|
60
|
+
|
|
61
|
+
// Send subscription.updated with a different product's price ID
|
|
62
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
63
|
+
id: state.eventId,
|
|
64
|
+
type: 'customer.subscription.updated',
|
|
65
|
+
data: {
|
|
66
|
+
object: {
|
|
67
|
+
id: state.subscriptionId,
|
|
68
|
+
object: 'subscription',
|
|
69
|
+
status: 'active',
|
|
70
|
+
metadata: { uid: state.uid, orderId: state.orderId },
|
|
71
|
+
cancel_at_period_end: false,
|
|
72
|
+
canceled_at: null,
|
|
73
|
+
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
74
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
75
|
+
start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
76
|
+
trial_start: null,
|
|
77
|
+
trial_end: null,
|
|
78
|
+
plan: { id: state.productB.priceId, interval: 'month' },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
assert.isSuccess(response, 'Webhook should be accepted');
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
name: 'plan-changed-transition-detected',
|
|
89
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
90
|
+
await waitFor(async () => {
|
|
91
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
92
|
+
return doc?.status === 'completed';
|
|
93
|
+
}, 15000, 500);
|
|
94
|
+
|
|
95
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
96
|
+
assert.ok(webhookDoc, 'Webhook doc should exist');
|
|
97
|
+
assert.equal(webhookDoc.transition, 'plan-changed', 'Transition should be plan-changed');
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
name: 'subscription-updated-to-product-b',
|
|
103
|
+
async run({ firestore, assert, state }) {
|
|
104
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
105
|
+
|
|
106
|
+
assert.equal(userDoc.subscription.product.id, state.productB.id, `Product should be ${state.productB.id}`);
|
|
107
|
+
assert.equal(userDoc.subscription.product.name, state.productB.name, `Product name should be ${state.productB.name}`);
|
|
108
|
+
assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
|
|
109
|
+
assert.equal(userDoc.subscription.payment.processor, 'test', 'Processor should be test');
|
|
110
|
+
assert.equal(userDoc.subscription.payment.frequency, 'monthly', 'Frequency should be monthly');
|
|
111
|
+
assert.equal(userDoc.subscription.payment.resourceId, state.subscriptionId, 'Resource ID should be the same subscription');
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
name: 'order-doc-updated',
|
|
117
|
+
async run({ firestore, assert, state }) {
|
|
118
|
+
const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
|
|
119
|
+
|
|
120
|
+
assert.ok(orderDoc, 'Order doc should exist');
|
|
121
|
+
assert.equal(orderDoc.unified.product.id, state.productB.id, `Order product should be ${state.productB.id}`);
|
|
122
|
+
assert.equal(orderDoc.unified.status, 'active', 'Order status should be active');
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
@@ -32,6 +32,7 @@ module.exports = {
|
|
|
32
32
|
frequency: 'monthly',
|
|
33
33
|
});
|
|
34
34
|
assert.isSuccess(response, 'Intent should succeed');
|
|
35
|
+
state.orderId = response.data.orderId;
|
|
35
36
|
|
|
36
37
|
// Wait for subscription to activate
|
|
37
38
|
await waitFor(async () => {
|
|
@@ -42,6 +43,7 @@ module.exports = {
|
|
|
42
43
|
const userDoc = await firestore.get(`users/${uid}`);
|
|
43
44
|
assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should start as ${paidProduct.id}`);
|
|
44
45
|
assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
|
|
46
|
+
assert.equal(userDoc.subscription?.payment?.orderId, state.orderId, 'Order ID should match intent');
|
|
45
47
|
|
|
46
48
|
state.subscriptionId = userDoc.subscription.payment.resourceId;
|
|
47
49
|
},
|
|
@@ -43,8 +43,10 @@ module.exports = {
|
|
|
43
43
|
|
|
44
44
|
assert.isSuccess(response, 'Intent should succeed');
|
|
45
45
|
assert.ok(response.data.id, 'Should return intent ID');
|
|
46
|
+
assert.ok(response.data.orderId, 'Should return orderId');
|
|
46
47
|
|
|
47
48
|
state.intentId = response.data.id;
|
|
49
|
+
state.orderId = response.data.orderId;
|
|
48
50
|
|
|
49
51
|
// Derive webhook event ID from intent ID (same timestamp)
|
|
50
52
|
state.eventId = response.data.id.replace('_test-cs-', '_test-evt-');
|
|
@@ -65,6 +67,7 @@ module.exports = {
|
|
|
65
67
|
assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
|
|
66
68
|
assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
|
|
67
69
|
assert.equal(userDoc.subscription.trial.claimed, true, 'Trial should be claimed');
|
|
70
|
+
assert.equal(userDoc.subscription.payment.orderId, state.orderId, 'Order ID should match intent');
|
|
68
71
|
|
|
69
72
|
state.subscriptionId = userDoc.subscription.payment.resourceId;
|
|
70
73
|
|
|
@@ -72,6 +75,7 @@ module.exports = {
|
|
|
72
75
|
const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
73
76
|
assert.ok(webhookDoc, 'Webhook doc should exist');
|
|
74
77
|
assert.equal(webhookDoc.transition, 'new-subscription', 'Transition should be new-subscription (trial detected inside handler)');
|
|
78
|
+
assert.equal(webhookDoc.orderId, state.orderId, 'Webhook doc orderId should match intent');
|
|
75
79
|
},
|
|
76
80
|
},
|
|
77
81
|
|
|
@@ -42,9 +42,12 @@ module.exports = {
|
|
|
42
42
|
|
|
43
43
|
assert.isSuccess(response, 'Intent should succeed');
|
|
44
44
|
assert.ok(response.data.id, 'Should return intent ID');
|
|
45
|
+
assert.ok(response.data.orderId, 'Should return orderId');
|
|
46
|
+
assert.match(response.data.orderId, /^\d{4}-\d{4}-\d{4}$/, 'orderId should be XXXX-XXXX-XXXX format');
|
|
45
47
|
assert.ok(response.data.url, 'Should return URL');
|
|
46
48
|
|
|
47
49
|
state.intentId = response.data.id;
|
|
50
|
+
state.orderId = response.data.orderId;
|
|
48
51
|
|
|
49
52
|
// Derive webhook event ID from intent ID (same timestamp)
|
|
50
53
|
state.eventId = response.data.id.replace('_test-cs-', '_test-evt-');
|
|
@@ -65,6 +68,7 @@ module.exports = {
|
|
|
65
68
|
assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
|
|
66
69
|
assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
|
|
67
70
|
assert.equal(userDoc.subscription.payment.processor, 'test', 'Processor should be test');
|
|
71
|
+
assert.equal(userDoc.subscription.payment.orderId, state.orderId, 'Order ID should match intent');
|
|
68
72
|
assert.ok(userDoc.subscription.payment.resourceId, 'Resource ID should be set');
|
|
69
73
|
assert.equal(userDoc.subscription.payment.frequency, 'monthly', 'Frequency should be monthly');
|
|
70
74
|
assert.equal(userDoc.subscription.cancellation.pending, false, 'Should not be pending cancellation');
|
|
@@ -74,15 +78,18 @@ module.exports = {
|
|
|
74
78
|
},
|
|
75
79
|
|
|
76
80
|
{
|
|
77
|
-
name: '
|
|
81
|
+
name: 'order-doc-created',
|
|
78
82
|
async run({ firestore, assert, state }) {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
assert.ok(
|
|
82
|
-
assert.equal(
|
|
83
|
-
assert.equal(
|
|
84
|
-
assert.equal(
|
|
85
|
-
assert.equal(
|
|
83
|
+
const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
|
|
84
|
+
|
|
85
|
+
assert.ok(orderDoc, 'Order doc should exist');
|
|
86
|
+
assert.equal(orderDoc.id, state.orderId, 'ID should match orderId');
|
|
87
|
+
assert.equal(orderDoc.type, 'subscription', 'Type should be subscription');
|
|
88
|
+
assert.equal(orderDoc.owner, state.uid, 'Owner should match');
|
|
89
|
+
assert.equal(orderDoc.processor, 'test', 'Processor should be test');
|
|
90
|
+
assert.equal(orderDoc.resourceId, state.subscriptionId, 'Resource ID should match');
|
|
91
|
+
assert.equal(orderDoc.unified.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
|
|
92
|
+
assert.equal(orderDoc.unified.status, 'active', 'Status should be active');
|
|
86
93
|
},
|
|
87
94
|
},
|
|
88
95
|
|
|
@@ -97,16 +104,19 @@ module.exports = {
|
|
|
97
104
|
const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
98
105
|
assert.ok(webhookDoc, 'Webhook doc should exist');
|
|
99
106
|
assert.equal(webhookDoc.transition, 'new-subscription', 'Transition should be new-subscription');
|
|
107
|
+
assert.equal(webhookDoc.orderId, state.orderId, 'Webhook doc orderId should match intent');
|
|
100
108
|
},
|
|
101
109
|
},
|
|
102
110
|
|
|
103
111
|
{
|
|
104
112
|
name: 'intent-doc-created',
|
|
105
113
|
async run({ firestore, assert, state }) {
|
|
106
|
-
const intentDoc = await firestore.get(`payments-intents/${state.
|
|
114
|
+
const intentDoc = await firestore.get(`payments-intents/${state.orderId}`);
|
|
107
115
|
|
|
108
116
|
assert.ok(intentDoc, 'Intent doc should exist');
|
|
109
|
-
assert.equal(intentDoc.
|
|
117
|
+
assert.equal(intentDoc.id, state.orderId, 'ID should match orderId');
|
|
118
|
+
assert.equal(intentDoc.intentId, state.intentId, 'Intent ID should match processor session ID');
|
|
119
|
+
assert.equal(intentDoc.owner, state.uid, 'Owner should match');
|
|
110
120
|
assert.equal(intentDoc.processor, 'test', 'Processor should be test');
|
|
111
121
|
assert.equal(intentDoc.status, 'pending', 'Intent status should be pending');
|
|
112
122
|
assert.equal(intentDoc.productId, state.paidProductId, `Product should be ${state.paidProductId}`);
|
|
@@ -365,6 +365,22 @@ module.exports = {
|
|
|
365
365
|
},
|
|
366
366
|
},
|
|
367
367
|
|
|
368
|
+
{
|
|
369
|
+
name: 'payment-order-id-from-metadata',
|
|
370
|
+
async run({ assert }) {
|
|
371
|
+
const result = toUnifiedSubscription({ metadata: { orderId: '1234-5678-9012' } });
|
|
372
|
+
assert.equal(result.payment.orderId, '1234-5678-9012', 'orderId should come from metadata');
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
{
|
|
377
|
+
name: 'payment-order-id-null-when-missing',
|
|
378
|
+
async run({ assert }) {
|
|
379
|
+
const result = toUnifiedSubscription({});
|
|
380
|
+
assert.equal(result.payment.orderId, null, 'Missing metadata → null orderId');
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
|
|
368
384
|
{
|
|
369
385
|
name: 'payment-event-metadata-passed-through',
|
|
370
386
|
async run({ assert }) {
|
|
@@ -432,6 +448,7 @@ module.exports = {
|
|
|
432
448
|
assert.equal(result.trial.claimed, false, 'Empty → trial not claimed');
|
|
433
449
|
assert.equal(result.cancellation.pending, false, 'Empty → not pending');
|
|
434
450
|
assert.equal(result.payment.processor, 'stripe', 'Empty → still stripe');
|
|
451
|
+
assert.equal(result.payment.orderId, null, 'Empty → null orderId');
|
|
435
452
|
assert.equal(result.payment.resourceId, null, 'Empty → null resourceId');
|
|
436
453
|
assert.equal(result.payment.frequency, null, 'Empty → null frequency');
|
|
437
454
|
},
|
package/test/helpers/user.js
CHANGED
|
@@ -393,6 +393,7 @@ module.exports = {
|
|
|
393
393
|
});
|
|
394
394
|
|
|
395
395
|
assert.equal(user.subscription.payment.processor, 'stripe', 'processor preserved');
|
|
396
|
+
assert.equal(user.subscription.payment.orderId, null, 'missing orderId defaults to null');
|
|
396
397
|
assert.equal(user.subscription.payment.resourceId, 'sub_123', 'resourceId preserved');
|
|
397
398
|
assert.equal(user.subscription.payment.frequency, null, 'missing frequency defaults to null');
|
|
398
399
|
assert.ok(user.subscription.payment.startDate.timestamp, 'missing startDate gets default');
|
|
@@ -126,11 +126,14 @@ module.exports = {
|
|
|
126
126
|
|
|
127
127
|
assert.isSuccess(response, 'Should succeed with test processor');
|
|
128
128
|
assert.ok(response.data.id, 'Should return intent ID');
|
|
129
|
+
assert.ok(response.data.orderId, 'Should return orderId');
|
|
130
|
+
assert.match(response.data.orderId, /^\d{4}-\d{4}-\d{4}$/, 'orderId should be XXXX-XXXX-XXXX format');
|
|
129
131
|
assert.ok(response.data.url, 'Should return URL');
|
|
130
132
|
|
|
131
|
-
// Verify intent doc was saved
|
|
132
|
-
const intentDoc = await firestore.get(`payments-intents/${response.data.
|
|
133
|
+
// Verify intent doc was saved (keyed by orderId)
|
|
134
|
+
const intentDoc = await firestore.get(`payments-intents/${response.data.orderId}`);
|
|
133
135
|
assert.ok(intentDoc, 'Intent doc should exist');
|
|
136
|
+
assert.equal(intentDoc.intentId, response.data.id, 'Intent ID should match response');
|
|
134
137
|
assert.equal(intentDoc.processor, 'test', 'Processor should be test');
|
|
135
138
|
assert.equal(intentDoc.productId, paidProduct.id, 'Product should match');
|
|
136
139
|
|
|
@@ -151,10 +154,10 @@ module.exports = {
|
|
|
151
154
|
async run({ http, assert, config, accounts, firestore, waitFor }) {
|
|
152
155
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
153
156
|
const uid = accounts.basic.uid;
|
|
154
|
-
const
|
|
157
|
+
const orderDocPath = `payments-orders/_test-order-history-${uid}`;
|
|
155
158
|
|
|
156
159
|
// Create fake subscription history so user is ineligible for trial
|
|
157
|
-
await firestore.set(
|
|
160
|
+
await firestore.set(orderDocPath, { owner: uid, type: 'subscription', processor: 'test', status: 'cancelled' });
|
|
158
161
|
|
|
159
162
|
try {
|
|
160
163
|
const response = await http.as('basic').post('payments/intent', {
|
|
@@ -167,8 +170,8 @@ module.exports = {
|
|
|
167
170
|
// Should succeed (not reject with 400) — trial silently downgraded
|
|
168
171
|
assert.isSuccess(response, 'Should not reject — trial silently downgraded');
|
|
169
172
|
|
|
170
|
-
// Verify intent saved with trial=false
|
|
171
|
-
const intentDoc = await firestore.get(`payments-intents/${response.data.
|
|
173
|
+
// Verify intent saved with trial=false (keyed by orderId)
|
|
174
|
+
const intentDoc = await firestore.get(`payments-intents/${response.data.orderId}`);
|
|
172
175
|
assert.equal(intentDoc.trial, false, 'Trial should be false (downgraded)');
|
|
173
176
|
|
|
174
177
|
// Clean up: wait for auto-webhook, restore basic user
|
|
@@ -181,7 +184,7 @@ module.exports = {
|
|
|
181
184
|
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
182
185
|
}, { merge: true });
|
|
183
186
|
} finally {
|
|
184
|
-
await firestore.delete(
|
|
187
|
+
await firestore.delete(orderDocPath);
|
|
185
188
|
}
|
|
186
189
|
},
|
|
187
190
|
},
|
|
File without changes
|