backend-manager 5.0.89 → 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/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 +64 -314
- 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 +1 -0
- package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
- package/src/manager/libraries/payment-processors/stripe.js +87 -48
- package/src/manager/libraries/payment-processors/test.js +4 -4
- package/src/manager/routes/payments/intent/post.js +44 -0
- package/src/manager/routes/payments/intent/processors/stripe.js +10 -45
- package/src/manager/routes/payments/intent/processors/test.js +16 -20
- package/src/test/runner.js +11 -0
- package/src/test/test-accounts.js +18 -0
- package/templates/backend-manager-config.json +31 -12
- 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-upgrade.js +2 -2
- /package/bin/{bem → backend-manager} +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fetch = require('wonderful-fetch');
|
|
2
|
+
const resolvePriceId = require('../../../../libraries/payment-processors/resolve-price-id.js');
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Test intent processor
|
|
@@ -16,12 +17,13 @@ module.exports = {
|
|
|
16
17
|
* @param {string} options.productId - Product ID from config
|
|
17
18
|
* @param {string} options.frequency - 'monthly' or 'annually' (subscriptions only)
|
|
18
19
|
* @param {boolean} options.trial - Whether to include a trial period (subscriptions only)
|
|
19
|
-
* @param {
|
|
20
|
+
* @param {string} options.confirmationUrl - Success redirect URL
|
|
21
|
+
* @param {string} options.cancelUrl - Cancel redirect URL
|
|
20
22
|
* @param {object} options.Manager - Manager instance
|
|
21
23
|
* @param {object} options.assistant - Assistant instance
|
|
22
24
|
* @returns {object} { id, url, raw }
|
|
23
25
|
*/
|
|
24
|
-
async createIntent({ uid, orderId, product, productId, frequency, trial,
|
|
26
|
+
async createIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, Manager, assistant }) {
|
|
25
27
|
// Guard: test processor is not available in production
|
|
26
28
|
if (assistant.isProduction()) {
|
|
27
29
|
throw new Error('Test processor is not available in production');
|
|
@@ -30,10 +32,10 @@ module.exports = {
|
|
|
30
32
|
const productType = product.type || 'subscription';
|
|
31
33
|
|
|
32
34
|
if (productType === 'subscription') {
|
|
33
|
-
return createSubscriptionIntent({ uid, orderId, product,
|
|
35
|
+
return createSubscriptionIntent({ uid, orderId, product, frequency, trial, confirmationUrl, Manager, assistant });
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
return createOneTimeIntent({ uid, orderId, product, productId,
|
|
38
|
+
return createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, Manager, assistant });
|
|
37
39
|
},
|
|
38
40
|
};
|
|
39
41
|
|
|
@@ -41,12 +43,9 @@ module.exports = {
|
|
|
41
43
|
* Create a test subscription intent
|
|
42
44
|
* Generates Stripe-shaped subscription + customer.subscription.created event
|
|
43
45
|
*/
|
|
44
|
-
async function createSubscriptionIntent({ uid, orderId, product,
|
|
46
|
+
async function createSubscriptionIntent({ uid, orderId, product, frequency, trial, confirmationUrl, Manager, assistant }) {
|
|
45
47
|
// Get the price ID for the requested frequency
|
|
46
|
-
const priceId = product
|
|
47
|
-
if (!priceId) {
|
|
48
|
-
throw new Error(`No Stripe price found for ${productId}/${frequency}`);
|
|
49
|
-
}
|
|
48
|
+
const priceId = resolvePriceId(product, 'subscription', frequency);
|
|
50
49
|
|
|
51
50
|
// Generate IDs
|
|
52
51
|
const timestamp = Date.now();
|
|
@@ -94,14 +93,14 @@ async function createSubscriptionIntent({ uid, orderId, product, productId, freq
|
|
|
94
93
|
data: { object: subscription },
|
|
95
94
|
};
|
|
96
95
|
|
|
97
|
-
assistant
|
|
96
|
+
assistant.log(`Test subscription intent: sessionId=${sessionId}, subscriptionId=${subscriptionId}, eventId=${eventId}, trial=${!!subscription.trial_start}`);
|
|
98
97
|
|
|
99
98
|
// Auto-fire webhook
|
|
100
99
|
fireWebhook({ event, Manager, assistant });
|
|
101
100
|
|
|
102
101
|
return {
|
|
103
102
|
id: sessionId,
|
|
104
|
-
url:
|
|
103
|
+
url: confirmationUrl,
|
|
105
104
|
raw: { id: sessionId, object: 'checkout.session', subscription: subscriptionId },
|
|
106
105
|
};
|
|
107
106
|
}
|
|
@@ -110,12 +109,9 @@ async function createSubscriptionIntent({ uid, orderId, product, productId, freq
|
|
|
110
109
|
* Create a test one-time payment intent
|
|
111
110
|
* Generates Stripe-shaped checkout session + checkout.session.completed event
|
|
112
111
|
*/
|
|
113
|
-
async function createOneTimeIntent({ uid, orderId, product, productId,
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
if (!priceId) {
|
|
117
|
-
throw new Error(`No Stripe price found for ${productId}/once`);
|
|
118
|
-
}
|
|
112
|
+
async function createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, Manager, assistant }) {
|
|
113
|
+
// Validate that a price exists (resolvePriceId throws if not found)
|
|
114
|
+
resolvePriceId(product, 'one-time', null);
|
|
119
115
|
|
|
120
116
|
// Generate IDs
|
|
121
117
|
const timestamp = Date.now();
|
|
@@ -141,14 +137,14 @@ async function createOneTimeIntent({ uid, orderId, product, productId, config, M
|
|
|
141
137
|
data: { object: session },
|
|
142
138
|
};
|
|
143
139
|
|
|
144
|
-
assistant
|
|
140
|
+
assistant.log(`Test one-time intent: sessionId=${sessionId}, eventId=${eventId}, productId=${productId}`);
|
|
145
141
|
|
|
146
142
|
// Auto-fire webhook
|
|
147
143
|
fireWebhook({ event, Manager, assistant });
|
|
148
144
|
|
|
149
145
|
return {
|
|
150
146
|
id: sessionId,
|
|
151
|
-
url:
|
|
147
|
+
url: confirmationUrl,
|
|
152
148
|
raw: { id: sessionId, object: 'checkout.session', mode: 'payment' },
|
|
153
149
|
};
|
|
154
150
|
}
|
|
@@ -164,6 +160,6 @@ function fireWebhook({ event, Manager, assistant }) {
|
|
|
164
160
|
body: event,
|
|
165
161
|
timeout: 15000,
|
|
166
162
|
}).catch((e) => {
|
|
167
|
-
assistant
|
|
163
|
+
assistant.log(`Test processor auto-webhook failed: ${e.message}`);
|
|
168
164
|
});
|
|
169
165
|
}
|
package/src/test/runner.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const os = require('os');
|
|
1
2
|
const path = require('path');
|
|
2
3
|
const jetpack = require('fs-jetpack');
|
|
3
4
|
const chalk = require('chalk');
|
|
@@ -52,6 +53,16 @@ class TestRunner {
|
|
|
52
53
|
* Main run method
|
|
53
54
|
*/
|
|
54
55
|
async run() {
|
|
56
|
+
// Abort if BEM is running from the user's home directory (e.g., accidental ~/node_modules install)
|
|
57
|
+
const homeDir = os.homedir();
|
|
58
|
+
if (__dirname.startsWith(path.join(homeDir, 'node_modules'))) {
|
|
59
|
+
console.error(chalk.red('\n ERROR: BEM is running from ~/node_modules (home directory install).'));
|
|
60
|
+
console.error(chalk.red(' This is likely an accidental global install that shadows local project copies.'));
|
|
61
|
+
console.error(chalk.red(` Fix: rm -rf ${path.join(homeDir, 'node_modules')} ${path.join(homeDir, 'package.json')} ${path.join(homeDir, 'package-lock.json')}`));
|
|
62
|
+
console.error(chalk.red(` Running from: ${__dirname}\n`));
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
// Set testing flag to skip external API calls (emails, SendGrid)
|
|
56
67
|
process.env.BEM_TESTING = 'true';
|
|
57
68
|
|
|
@@ -175,6 +175,24 @@ const JOURNEY_ACCOUNTS = {
|
|
|
175
175
|
subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
|
|
176
176
|
},
|
|
177
177
|
},
|
|
178
|
+
'journey-payments-plan-change': {
|
|
179
|
+
id: 'journey-payments-plan-change',
|
|
180
|
+
uid: '_test-journey-payments-plan-change',
|
|
181
|
+
email: '_test.journey-payments-plan-change@{domain}',
|
|
182
|
+
properties: {
|
|
183
|
+
roles: {},
|
|
184
|
+
subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
'journey-payments-one-time': {
|
|
188
|
+
id: 'journey-payments-one-time',
|
|
189
|
+
uid: '_test-journey-payments-one-time',
|
|
190
|
+
email: '_test.journey-payments-one-time@{domain}',
|
|
191
|
+
properties: {
|
|
192
|
+
roles: {},
|
|
193
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
194
|
+
},
|
|
195
|
+
},
|
|
178
196
|
};
|
|
179
197
|
|
|
180
198
|
/**
|
|
@@ -72,18 +72,37 @@
|
|
|
72
72
|
},
|
|
73
73
|
},
|
|
74
74
|
},
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
75
|
+
{
|
|
76
|
+
id: 'ultimate',
|
|
77
|
+
name: 'Ultimate',
|
|
78
|
+
type: 'subscription',
|
|
79
|
+
limits: {
|
|
80
|
+
requests: 10000,
|
|
81
|
+
},
|
|
82
|
+
prices: {
|
|
83
|
+
monthly: {
|
|
84
|
+
amount: 19.99,
|
|
85
|
+
stripe: null,
|
|
86
|
+
paypal: null,
|
|
87
|
+
},
|
|
88
|
+
annually: {
|
|
89
|
+
amount: 199.99,
|
|
90
|
+
stripe: null,
|
|
91
|
+
paypal: null,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'credits-100',
|
|
97
|
+
name: '100 Credits',
|
|
98
|
+
type: 'one-time',
|
|
99
|
+
prices: {
|
|
100
|
+
once: {
|
|
101
|
+
amount: 9.99,
|
|
102
|
+
stripe: null,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
87
106
|
// Add more products/tiers here
|
|
88
107
|
],
|
|
89
108
|
},
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - One-Time Payment Failure
|
|
3
|
+
* Simulates: invoice.payment_failed for a non-subscription invoice → purchase-failed transition
|
|
4
|
+
*
|
|
5
|
+
* This verifies the webhook routing for one-time invoice failures:
|
|
6
|
+
* invoice.payment_failed with no subscription billing_reason → category: 'one-time'
|
|
7
|
+
*
|
|
8
|
+
* Uses the journey-payments-one-time account (one-time events don't modify subscription state)
|
|
9
|
+
*/
|
|
10
|
+
module.exports = {
|
|
11
|
+
description: 'Payment journey: one-time invoice.payment_failed → purchase-failed',
|
|
12
|
+
type: 'suite',
|
|
13
|
+
timeout: 30000,
|
|
14
|
+
|
|
15
|
+
tests: [
|
|
16
|
+
{
|
|
17
|
+
name: 'resolve-one-time-product',
|
|
18
|
+
async run({ accounts, assert, state, config }) {
|
|
19
|
+
const uid = accounts['journey-payments-one-time'].uid;
|
|
20
|
+
|
|
21
|
+
// Resolve first one-time product from config
|
|
22
|
+
const oneTimeProduct = config.payment.products.find(p => p.type === 'one-time' && p.prices?.once);
|
|
23
|
+
assert.ok(oneTimeProduct, 'Config should have at least one one-time product');
|
|
24
|
+
|
|
25
|
+
state.uid = uid;
|
|
26
|
+
state.productId = oneTimeProduct.id;
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
name: 'send-one-time-payment-failed',
|
|
32
|
+
async run({ http, assert, state, config }) {
|
|
33
|
+
state.eventId = `_test-evt-journey-onetime-fail-${Date.now()}`;
|
|
34
|
+
state.invoiceId = `_test-inv-onetime-fail-${Date.now()}`;
|
|
35
|
+
state.orderId = `0000-0000-0000`; // Fake orderId for test
|
|
36
|
+
|
|
37
|
+
// Send invoice.payment_failed with a non-subscription billing reason
|
|
38
|
+
// This routes to category: 'one-time' in the webhook parser
|
|
39
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
40
|
+
id: state.eventId,
|
|
41
|
+
type: 'invoice.payment_failed',
|
|
42
|
+
data: {
|
|
43
|
+
object: {
|
|
44
|
+
id: state.invoiceId,
|
|
45
|
+
object: 'invoice',
|
|
46
|
+
billing_reason: 'manual',
|
|
47
|
+
amount_due: 999,
|
|
48
|
+
amount_paid: 0,
|
|
49
|
+
status: 'open',
|
|
50
|
+
metadata: {
|
|
51
|
+
uid: state.uid,
|
|
52
|
+
orderId: state.orderId,
|
|
53
|
+
productId: state.productId,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
assert.isSuccess(response, 'Webhook should be accepted');
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
name: 'webhook-categorized-as-one-time',
|
|
65
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
66
|
+
await waitFor(async () => {
|
|
67
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
68
|
+
return doc?.status === 'completed' || doc?.status === 'failed';
|
|
69
|
+
}, 15000, 500);
|
|
70
|
+
|
|
71
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
72
|
+
assert.ok(webhookDoc, 'Webhook doc should exist');
|
|
73
|
+
assert.equal(webhookDoc.event?.category, 'one-time', 'Category should be one-time');
|
|
74
|
+
assert.equal(webhookDoc.event?.resourceType, 'invoice', 'Resource type should be invoice');
|
|
75
|
+
assert.equal(webhookDoc.transition, 'purchase-failed', 'Transition should be purchase-failed');
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
name: 'order-doc-created-with-failure',
|
|
81
|
+
async run({ firestore, assert, state }) {
|
|
82
|
+
const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
|
|
83
|
+
|
|
84
|
+
assert.ok(orderDoc, 'Order doc should exist');
|
|
85
|
+
assert.equal(orderDoc.type, 'one-time', 'Type should be one-time');
|
|
86
|
+
assert.equal(orderDoc.owner, state.uid, 'Owner should match');
|
|
87
|
+
assert.equal(orderDoc.processor, 'test', 'Processor should be test');
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
name: 'subscription-unchanged',
|
|
93
|
+
async run({ firestore, assert, state }) {
|
|
94
|
+
// One-time payment failures must NOT modify subscription state
|
|
95
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
96
|
+
|
|
97
|
+
assert.equal(
|
|
98
|
+
userDoc.subscription?.product?.id,
|
|
99
|
+
'basic',
|
|
100
|
+
'Subscription should remain basic after one-time failure',
|
|
101
|
+
);
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - One-Time Purchase
|
|
3
|
+
* Simulates: user → test intent (one-time product) → auto-webhook → purchase-completed
|
|
4
|
+
*
|
|
5
|
+
* Uses the test processor to exercise the full intent→webhook→trigger pipeline
|
|
6
|
+
* for one-time payments. Unlike subscriptions, one-time payments only write to
|
|
7
|
+
* payments-orders/{orderId} — they do NOT modify users/{uid}.subscription.
|
|
8
|
+
*
|
|
9
|
+
* Requires at least one product with type: 'one-time' in config.payment.products
|
|
10
|
+
*/
|
|
11
|
+
module.exports = {
|
|
12
|
+
description: 'Payment journey: one-time purchase via test intent → purchase-completed',
|
|
13
|
+
type: 'suite',
|
|
14
|
+
timeout: 30000,
|
|
15
|
+
|
|
16
|
+
tests: [
|
|
17
|
+
{
|
|
18
|
+
name: 'resolve-one-time-product',
|
|
19
|
+
async run({ accounts, firestore, assert, state, config }) {
|
|
20
|
+
const uid = accounts['journey-payments-one-time'].uid;
|
|
21
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
22
|
+
|
|
23
|
+
assert.ok(userDoc, 'User doc should exist');
|
|
24
|
+
|
|
25
|
+
// Resolve first one-time product from config
|
|
26
|
+
const oneTimeProduct = config.payment.products.find(p => p.type === 'one-time' && p.prices?.once);
|
|
27
|
+
assert.ok(oneTimeProduct, 'Config should have at least one one-time product');
|
|
28
|
+
|
|
29
|
+
state.uid = uid;
|
|
30
|
+
state.productId = oneTimeProduct.id;
|
|
31
|
+
state.productName = oneTimeProduct.name;
|
|
32
|
+
state.price = oneTimeProduct.prices.once.amount;
|
|
33
|
+
|
|
34
|
+
// Snapshot subscription before purchase — should remain unchanged after
|
|
35
|
+
state.subscriptionBefore = userDoc.subscription || null;
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
name: 'create-one-time-intent',
|
|
41
|
+
async run({ http, assert, state }) {
|
|
42
|
+
const response = await http.as('journey-payments-one-time').post('payments/intent', {
|
|
43
|
+
processor: 'test',
|
|
44
|
+
productId: state.productId,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
assert.isSuccess(response, 'Intent should succeed');
|
|
48
|
+
assert.ok(response.data.id, 'Should return intent ID');
|
|
49
|
+
assert.ok(response.data.orderId, 'Should return orderId');
|
|
50
|
+
assert.match(response.data.orderId, /^\d{4}-\d{4}-\d{4}$/, 'orderId should be XXXX-XXXX-XXXX format');
|
|
51
|
+
assert.ok(response.data.url, 'Should return URL');
|
|
52
|
+
|
|
53
|
+
state.intentId = response.data.id;
|
|
54
|
+
state.orderId = response.data.orderId;
|
|
55
|
+
|
|
56
|
+
// Derive webhook event ID from intent ID (same timestamp)
|
|
57
|
+
state.eventId = response.data.id.replace('_test-cs-', '_test-evt-');
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
name: 'webhook-transition-purchase-completed',
|
|
63
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
64
|
+
// Poll until the webhook is processed
|
|
65
|
+
await waitFor(async () => {
|
|
66
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
67
|
+
return doc?.status === 'completed';
|
|
68
|
+
}, 15000, 500);
|
|
69
|
+
|
|
70
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
71
|
+
assert.ok(webhookDoc, 'Webhook doc should exist');
|
|
72
|
+
assert.equal(webhookDoc.transition, 'purchase-completed', 'Transition should be purchase-completed');
|
|
73
|
+
assert.equal(webhookDoc.orderId, state.orderId, 'Webhook doc orderId should match intent');
|
|
74
|
+
assert.equal(webhookDoc.event?.category, 'one-time', 'Category should be one-time');
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
name: 'order-doc-created',
|
|
80
|
+
async run({ firestore, assert, state }) {
|
|
81
|
+
const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
|
|
82
|
+
|
|
83
|
+
assert.ok(orderDoc, 'Order doc should exist');
|
|
84
|
+
assert.equal(orderDoc.id, state.orderId, 'ID should match orderId');
|
|
85
|
+
assert.equal(orderDoc.type, 'one-time', 'Type should be one-time');
|
|
86
|
+
assert.equal(orderDoc.owner, state.uid, 'Owner should match');
|
|
87
|
+
assert.equal(orderDoc.processor, 'test', 'Processor should be test');
|
|
88
|
+
assert.equal(orderDoc.unified.product.id, state.productId, `Product should be ${state.productId}`);
|
|
89
|
+
assert.equal(orderDoc.unified.payment.processor, 'test', 'Unified processor should be test');
|
|
90
|
+
assert.equal(orderDoc.unified.payment.orderId, state.orderId, 'Unified orderId should match');
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
name: 'subscription-unchanged',
|
|
96
|
+
async run({ firestore, assert, state }) {
|
|
97
|
+
// One-time payments must NOT modify users/{uid}.subscription
|
|
98
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
99
|
+
const subAfter = userDoc.subscription || null;
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
subAfter?.product?.id,
|
|
103
|
+
state.subscriptionBefore?.product?.id,
|
|
104
|
+
'Subscription product should be unchanged after one-time purchase',
|
|
105
|
+
);
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
subAfter?.status,
|
|
108
|
+
state.subscriptionBefore?.status,
|
|
109
|
+
'Subscription status should be unchanged after one-time purchase',
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
name: 'intent-doc-created',
|
|
116
|
+
async run({ firestore, assert, state }) {
|
|
117
|
+
const intentDoc = await firestore.get(`payments-intents/${state.orderId}`);
|
|
118
|
+
|
|
119
|
+
assert.ok(intentDoc, 'Intent doc should exist');
|
|
120
|
+
assert.equal(intentDoc.id, state.orderId, 'ID should match orderId');
|
|
121
|
+
assert.equal(intentDoc.intentId, state.intentId, 'Intent ID should match processor session ID');
|
|
122
|
+
assert.equal(intentDoc.owner, state.uid, 'Owner should match');
|
|
123
|
+
assert.equal(intentDoc.processor, 'test', 'Processor should be test');
|
|
124
|
+
assert.equal(intentDoc.productId, state.productId, `Product should be ${state.productId}`);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -88,8 +88,8 @@ module.exports = {
|
|
|
88
88
|
assert.equal(orderDoc.owner, state.uid, 'Owner should match');
|
|
89
89
|
assert.equal(orderDoc.processor, 'test', 'Processor should be test');
|
|
90
90
|
assert.equal(orderDoc.resourceId, state.subscriptionId, 'Resource ID should match');
|
|
91
|
-
assert.equal(orderDoc.
|
|
92
|
-
assert.equal(orderDoc.
|
|
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');
|
|
93
93
|
},
|
|
94
94
|
},
|
|
95
95
|
|
|
File without changes
|