backend-manager 5.0.83 → 5.0.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/CLAUDE.md +66 -3
- package/README.md +7 -5
- package/package.json +5 -4
- package/src/cli/commands/base-command.js +89 -0
- package/src/cli/commands/emulators.js +3 -0
- package/src/cli/commands/serve.js +5 -1
- package/src/cli/commands/stripe.js +14 -0
- package/src/cli/commands/test.js +11 -6
- package/src/cli/index.js +7 -0
- package/src/manager/cron/daily/reset-usage.js +56 -34
- package/src/manager/events/firestore/payments-webhooks/on-write.js +15 -13
- package/src/manager/functions/core/actions/api/user/get-subscription-info.js +1 -1
- package/src/manager/helpers/analytics.js +2 -2
- package/src/manager/helpers/api-manager.js +1 -1
- package/src/manager/helpers/middleware.js +1 -1
- package/src/manager/helpers/usage.js +51 -3
- package/src/manager/index.js +5 -19
- package/src/manager/libraries/stripe.js +12 -8
- package/src/manager/libraries/test.js +27 -0
- package/src/manager/routes/app/get.js +11 -8
- package/src/manager/routes/payments/intent/post.js +31 -16
- package/src/manager/routes/payments/intent/{providers → processors}/stripe.js +48 -4
- package/src/manager/routes/payments/intent/processors/test.js +106 -0
- package/src/manager/routes/payments/webhook/post.js +21 -8
- package/src/manager/routes/payments/webhook/{providers → processors}/stripe.js +16 -1
- package/src/manager/routes/payments/webhook/processors/test.js +15 -0
- package/src/manager/routes/user/subscription/get.js +1 -1
- package/src/manager/schemas/payments/webhook/post.js +1 -1
- package/src/test/test-accounts.js +18 -18
- package/templates/_.env +0 -2
- package/templates/backend-manager-config.json +50 -34
- package/test/events/payments/journey-payments-cancel.js +144 -0
- package/test/events/payments/journey-payments-suspend.js +143 -0
- package/test/events/payments/journey-payments-trial.js +120 -0
- package/test/events/payments/journey-payments-upgrade.js +99 -0
- package/test/fixtures/stripe/subscription-active.json +161 -0
- package/test/fixtures/stripe/subscription-canceled.json +161 -0
- package/test/fixtures/stripe/subscription-trialing.json +161 -0
- package/test/functions/user/get-subscription-info.js +2 -2
- package/test/helpers/stripe-to-unified.js +684 -0
- package/test/routes/payments/intent.js +189 -0
- package/test/{payments → routes/payments}/webhook.js +1 -1
- package/test/routes/test/usage.js +7 -6
- package/test/routes/user/subscription.js +2 -2
- package/test/payments/intent.js +0 -104
- package/test/payments/journey-payment-cancel.js +0 -166
- package/test/payments/journey-payment-suspend.js +0 -162
- package/test/payments/journey-payment-trial.js +0 -167
- package/test/payments/journey-payment-upgrade.js +0 -136
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: POST /payments/intent
|
|
3
|
+
* Tests intent creation endpoint validation + end-to-end flow via test processor
|
|
4
|
+
*
|
|
5
|
+
* Validation tests use processor=stripe (fail at SDK step, proving validation logic)
|
|
6
|
+
* Success tests use processor=test (full intent→webhook→trigger pipeline)
|
|
7
|
+
*
|
|
8
|
+
* Product-agnostic: resolves the first paid product from config.payment.products
|
|
9
|
+
*/
|
|
10
|
+
module.exports = {
|
|
11
|
+
description: 'Payment intent creation',
|
|
12
|
+
type: 'group',
|
|
13
|
+
timeout: 30000,
|
|
14
|
+
|
|
15
|
+
tests: [
|
|
16
|
+
{
|
|
17
|
+
name: 'rejects-unauthenticated',
|
|
18
|
+
auth: 'none',
|
|
19
|
+
async run({ http, assert, config }) {
|
|
20
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
21
|
+
|
|
22
|
+
const response = await http.as('none').post('payments/intent', {
|
|
23
|
+
processor: 'stripe',
|
|
24
|
+
productId: paidProduct.id,
|
|
25
|
+
frequency: 'monthly',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
assert.isError(response, 401, 'Should reject unauthenticated request');
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
name: 'rejects-missing-processor',
|
|
34
|
+
async run({ http, assert, config }) {
|
|
35
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
36
|
+
|
|
37
|
+
const response = await http.as('basic').post('payments/intent', {
|
|
38
|
+
productId: paidProduct.id,
|
|
39
|
+
frequency: 'monthly',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
assert.isError(response, 400, 'Should reject missing processor');
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
name: 'rejects-missing-product-id',
|
|
48
|
+
async run({ http, assert }) {
|
|
49
|
+
const response = await http.as('basic').post('payments/intent', {
|
|
50
|
+
processor: 'stripe',
|
|
51
|
+
frequency: 'monthly',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
assert.isError(response, 400, 'Should reject missing productId');
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
name: 'rejects-missing-frequency',
|
|
60
|
+
async run({ http, assert, config }) {
|
|
61
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
62
|
+
|
|
63
|
+
const response = await http.as('basic').post('payments/intent', {
|
|
64
|
+
processor: 'stripe',
|
|
65
|
+
productId: paidProduct.id,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
assert.isError(response, 400, 'Should reject missing frequency');
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
{
|
|
73
|
+
name: 'rejects-active-paid-user',
|
|
74
|
+
auth: 'premium-active',
|
|
75
|
+
async run({ http, assert, config }) {
|
|
76
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
77
|
+
|
|
78
|
+
const response = await http.as('premium-active').post('payments/intent', {
|
|
79
|
+
processor: 'stripe',
|
|
80
|
+
productId: paidProduct.id,
|
|
81
|
+
frequency: 'monthly',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
assert.isError(response, 400, 'Should reject user with active subscription');
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
{
|
|
89
|
+
name: 'rejects-invalid-product',
|
|
90
|
+
async run({ http, assert }) {
|
|
91
|
+
const response = await http.as('basic').post('payments/intent', {
|
|
92
|
+
processor: 'stripe',
|
|
93
|
+
productId: 'nonexistent-product',
|
|
94
|
+
frequency: 'monthly',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
assert.isError(response, 400, 'Should reject invalid product');
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
name: 'rejects-unknown-processor',
|
|
103
|
+
async run({ http, assert, config }) {
|
|
104
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
105
|
+
|
|
106
|
+
const response = await http.as('basic').post('payments/intent', {
|
|
107
|
+
processor: 'unknown-processor',
|
|
108
|
+
productId: paidProduct.id,
|
|
109
|
+
frequency: 'monthly',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
assert.isError(response, 400, 'Should reject unknown processor');
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
name: 'succeeds-with-test-processor',
|
|
118
|
+
async run({ http, assert, config, firestore, accounts, waitFor }) {
|
|
119
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
120
|
+
|
|
121
|
+
const response = await http.as('basic').post('payments/intent', {
|
|
122
|
+
processor: 'test',
|
|
123
|
+
productId: paidProduct.id,
|
|
124
|
+
frequency: 'monthly',
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
assert.isSuccess(response, 'Should succeed with test processor');
|
|
128
|
+
assert.ok(response.data.id, 'Should return intent ID');
|
|
129
|
+
assert.ok(response.data.url, 'Should return URL');
|
|
130
|
+
|
|
131
|
+
// Verify intent doc was saved
|
|
132
|
+
const intentDoc = await firestore.get(`payments-intents/${response.data.id}`);
|
|
133
|
+
assert.ok(intentDoc, 'Intent doc should exist');
|
|
134
|
+
assert.equal(intentDoc.processor, 'test', 'Processor should be test');
|
|
135
|
+
assert.equal(intentDoc.productId, paidProduct.id, 'Product should match');
|
|
136
|
+
|
|
137
|
+
// Clean up: wait for auto-webhook to process, then restore basic user
|
|
138
|
+
await waitFor(async () => {
|
|
139
|
+
const userDoc = await firestore.get(`users/${accounts.basic.uid}`);
|
|
140
|
+
return userDoc?.subscription?.product?.id === paidProduct.id;
|
|
141
|
+
}, 15000, 500).catch(() => {});
|
|
142
|
+
|
|
143
|
+
await firestore.set(`users/${accounts.basic.uid}`, {
|
|
144
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
145
|
+
}, { merge: true });
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
{
|
|
150
|
+
name: 'downgrades-trial-for-user-with-history',
|
|
151
|
+
async run({ http, assert, config, accounts, firestore, waitFor }) {
|
|
152
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
153
|
+
const uid = accounts.basic.uid;
|
|
154
|
+
const subDocPath = `payments-subscriptions/_test-sub-history-${uid}`;
|
|
155
|
+
|
|
156
|
+
// Create fake subscription history so user is ineligible for trial
|
|
157
|
+
await firestore.set(subDocPath, { uid, processor: 'test', status: 'cancelled' });
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const response = await http.as('basic').post('payments/intent', {
|
|
161
|
+
processor: 'test',
|
|
162
|
+
productId: paidProduct.id,
|
|
163
|
+
frequency: 'monthly',
|
|
164
|
+
trial: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Should succeed (not reject with 400) — trial silently downgraded
|
|
168
|
+
assert.isSuccess(response, 'Should not reject — trial silently downgraded');
|
|
169
|
+
|
|
170
|
+
// Verify intent saved with trial=false
|
|
171
|
+
const intentDoc = await firestore.get(`payments-intents/${response.data.id}`);
|
|
172
|
+
assert.equal(intentDoc.trial, false, 'Trial should be false (downgraded)');
|
|
173
|
+
|
|
174
|
+
// Clean up: wait for auto-webhook, restore basic user
|
|
175
|
+
await waitFor(async () => {
|
|
176
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
177
|
+
return userDoc?.subscription?.product?.id === paidProduct.id;
|
|
178
|
+
}, 15000, 500).catch(() => {});
|
|
179
|
+
|
|
180
|
+
await firestore.set(`users/${uid}`, {
|
|
181
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
182
|
+
}, { merge: true });
|
|
183
|
+
} finally {
|
|
184
|
+
await firestore.delete(subDocPath);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Test: POST /payments/webhook
|
|
3
3
|
* Tests the webhook endpoint validates requests and saves to Firestore
|
|
4
4
|
*/
|
|
5
|
-
const { TEST_ACCOUNTS } = require('
|
|
5
|
+
const { TEST_ACCOUNTS } = require('../../../src/test/test-accounts.js');
|
|
6
6
|
|
|
7
7
|
module.exports = {
|
|
8
8
|
description: 'Payment webhook endpoint',
|
|
@@ -171,17 +171,18 @@ module.exports = {
|
|
|
171
171
|
{
|
|
172
172
|
name: 'unauthenticated-usage-by-ip',
|
|
173
173
|
async run({ http, assert, state }) {
|
|
174
|
-
// Unauthenticated requests use IP as key (
|
|
175
|
-
state.unauthKey = '
|
|
174
|
+
// Unauthenticated requests use IP as key (no proxy headers in emulator, so falls back to 'unknown')
|
|
175
|
+
state.unauthKey = 'unknown';
|
|
176
176
|
|
|
177
177
|
const response = await http.as('none').post('test/usage', {});
|
|
178
178
|
|
|
179
179
|
assert.isSuccess(response, 'Unauthenticated usage increment should succeed');
|
|
180
180
|
assert.equal(response.data.authenticated, false, 'Should report as unauthenticated');
|
|
181
|
-
assert.equal(response.data.key, state.unauthKey, 'Key should be
|
|
181
|
+
assert.equal(response.data.key, state.unauthKey, 'Key should be unknown');
|
|
182
182
|
|
|
183
|
-
// Verify increment happened
|
|
184
|
-
|
|
183
|
+
// Verify increment happened (relative check — prior tests may have incremented too)
|
|
184
|
+
state.unauthPeriod = response.data.after.period;
|
|
185
|
+
assert.equal(response.data.after.period, response.data.before.period + 1, 'Period should increment by 1');
|
|
185
186
|
},
|
|
186
187
|
},
|
|
187
188
|
|
|
@@ -193,7 +194,7 @@ module.exports = {
|
|
|
193
194
|
|
|
194
195
|
assert.ok(usageDoc, 'Usage doc should exist in usage collection');
|
|
195
196
|
assert.ok(usageDoc?.requests, 'Usage doc should have the requests metric');
|
|
196
|
-
assert.equal(usageDoc.requests.period,
|
|
197
|
+
assert.equal(usageDoc.requests.period, state.unauthPeriod, 'Persisted period should match');
|
|
197
198
|
},
|
|
198
199
|
},
|
|
199
200
|
|
|
@@ -44,8 +44,8 @@ module.exports = {
|
|
|
44
44
|
|
|
45
45
|
// Check trial structure
|
|
46
46
|
assert.ok(
|
|
47
|
-
typeof subscription.trial.
|
|
48
|
-
'trial.
|
|
47
|
+
typeof subscription.trial.claimed === 'boolean',
|
|
48
|
+
'trial.claimed should be boolean'
|
|
49
49
|
);
|
|
50
50
|
|
|
51
51
|
// Check payment structure
|
package/test/payments/intent.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test: POST /payments/intent
|
|
3
|
-
* Tests intent creation endpoint validation
|
|
4
|
-
*
|
|
5
|
-
* Note: These tests validate the route logic (auth, validation, subscription checks)
|
|
6
|
-
* They do NOT create real Stripe sessions since there's no Stripe key in test env
|
|
7
|
-
*/
|
|
8
|
-
module.exports = {
|
|
9
|
-
description: 'Payment intent creation',
|
|
10
|
-
type: 'group',
|
|
11
|
-
timeout: 30000,
|
|
12
|
-
|
|
13
|
-
tests: [
|
|
14
|
-
{
|
|
15
|
-
name: 'rejects-unauthenticated',
|
|
16
|
-
auth: 'none',
|
|
17
|
-
async run({ http, assert }) {
|
|
18
|
-
const response = await http.as('none').post('payments/intent', {
|
|
19
|
-
processor: 'stripe',
|
|
20
|
-
productId: 'premium',
|
|
21
|
-
frequency: 'monthly',
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
assert.isError(response, 401, 'Should reject unauthenticated request');
|
|
25
|
-
},
|
|
26
|
-
},
|
|
27
|
-
|
|
28
|
-
{
|
|
29
|
-
name: 'rejects-missing-processor',
|
|
30
|
-
async run({ http, assert }) {
|
|
31
|
-
const response = await http.as('basic').post('payments/intent', {
|
|
32
|
-
productId: 'premium',
|
|
33
|
-
frequency: 'monthly',
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
assert.isError(response, 400, 'Should reject missing processor');
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
|
|
40
|
-
{
|
|
41
|
-
name: 'rejects-missing-product-id',
|
|
42
|
-
async run({ http, assert }) {
|
|
43
|
-
const response = await http.as('basic').post('payments/intent', {
|
|
44
|
-
processor: 'stripe',
|
|
45
|
-
frequency: 'monthly',
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
assert.isError(response, 400, 'Should reject missing productId');
|
|
49
|
-
},
|
|
50
|
-
},
|
|
51
|
-
|
|
52
|
-
{
|
|
53
|
-
name: 'rejects-missing-frequency',
|
|
54
|
-
async run({ http, assert }) {
|
|
55
|
-
const response = await http.as('basic').post('payments/intent', {
|
|
56
|
-
processor: 'stripe',
|
|
57
|
-
productId: 'premium',
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
assert.isError(response, 400, 'Should reject missing frequency');
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
{
|
|
65
|
-
name: 'rejects-active-premium-user',
|
|
66
|
-
auth: 'premium-active',
|
|
67
|
-
async run({ http, assert }) {
|
|
68
|
-
const response = await http.as('premium-active').post('payments/intent', {
|
|
69
|
-
processor: 'stripe',
|
|
70
|
-
productId: 'premium',
|
|
71
|
-
frequency: 'monthly',
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
assert.isError(response, 400, 'Should reject user with active subscription');
|
|
75
|
-
},
|
|
76
|
-
},
|
|
77
|
-
|
|
78
|
-
{
|
|
79
|
-
name: 'rejects-invalid-product',
|
|
80
|
-
async run({ http, assert }) {
|
|
81
|
-
const response = await http.as('basic').post('payments/intent', {
|
|
82
|
-
processor: 'stripe',
|
|
83
|
-
productId: 'nonexistent-product',
|
|
84
|
-
frequency: 'monthly',
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
assert.isError(response, 400, 'Should reject invalid product');
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
|
|
91
|
-
{
|
|
92
|
-
name: 'rejects-unknown-processor',
|
|
93
|
-
async run({ http, assert }) {
|
|
94
|
-
const response = await http.as('basic').post('payments/intent', {
|
|
95
|
-
processor: 'unknown-processor',
|
|
96
|
-
productId: 'premium',
|
|
97
|
-
frequency: 'monthly',
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
assert.isError(response, 400, 'Should reject unknown processor');
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
],
|
|
104
|
-
};
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test: Payment Journey - Cancel
|
|
3
|
-
* Simulates: premium active → pending cancel → cancelled
|
|
4
|
-
*
|
|
5
|
-
* Step 1: Webhook with cancel_at_period_end=true → cancellation.pending=true
|
|
6
|
-
* Step 2: Webhook with status=canceled → status=cancelled, id=basic
|
|
7
|
-
*/
|
|
8
|
-
const powertools = require('node-powertools');
|
|
9
|
-
|
|
10
|
-
module.exports = {
|
|
11
|
-
description: 'Payment journey: premium → pending cancel → cancelled',
|
|
12
|
-
type: 'suite',
|
|
13
|
-
timeout: 30000,
|
|
14
|
-
|
|
15
|
-
tests: [
|
|
16
|
-
{
|
|
17
|
-
name: 'verify-starts-as-premium',
|
|
18
|
-
async run({ accounts, firestore, assert, state }) {
|
|
19
|
-
const uid = accounts['journey-payment-cancel'].uid;
|
|
20
|
-
const userDoc = await firestore.get(`users/${uid}`);
|
|
21
|
-
|
|
22
|
-
assert.ok(userDoc, 'User doc should exist');
|
|
23
|
-
assert.equal(userDoc.subscription?.product?.id, 'premium', 'Should start as premium');
|
|
24
|
-
assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
|
|
25
|
-
|
|
26
|
-
state.uid = uid;
|
|
27
|
-
state.subscriptionId = '_test-sub-journey-cancel';
|
|
28
|
-
},
|
|
29
|
-
},
|
|
30
|
-
|
|
31
|
-
{
|
|
32
|
-
name: 'write-pending-cancel-webhook',
|
|
33
|
-
async run({ firestore, state }) {
|
|
34
|
-
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
35
|
-
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
36
|
-
const futureDate = new Date();
|
|
37
|
-
futureDate.setFullYear(futureDate.getFullYear() + 1);
|
|
38
|
-
|
|
39
|
-
state.eventId1 = '_test-evt-journey-cancel-pending';
|
|
40
|
-
|
|
41
|
-
await firestore.delete(`payments-webhooks/${state.eventId1}`);
|
|
42
|
-
await firestore.delete(`payments-subscriptions/${state.subscriptionId}`);
|
|
43
|
-
|
|
44
|
-
// Webhook: active subscription with cancel_at_period_end = true
|
|
45
|
-
await firestore.set(`payments-webhooks/${state.eventId1}`, {
|
|
46
|
-
id: state.eventId1,
|
|
47
|
-
processor: 'stripe',
|
|
48
|
-
status: 'pending',
|
|
49
|
-
uid: state.uid,
|
|
50
|
-
event: { type: 'customer.subscription.updated' },
|
|
51
|
-
raw: {
|
|
52
|
-
id: state.eventId1,
|
|
53
|
-
type: 'customer.subscription.updated',
|
|
54
|
-
data: {
|
|
55
|
-
object: {
|
|
56
|
-
id: state.subscriptionId,
|
|
57
|
-
object: 'subscription',
|
|
58
|
-
status: 'active',
|
|
59
|
-
metadata: { uid: state.uid },
|
|
60
|
-
cancel_at_period_end: true,
|
|
61
|
-
cancel_at: Math.floor(futureDate.getTime() / 1000),
|
|
62
|
-
canceled_at: null,
|
|
63
|
-
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
64
|
-
current_period_start: Math.floor(Date.now() / 1000),
|
|
65
|
-
start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
66
|
-
trial_start: null,
|
|
67
|
-
trial_end: null,
|
|
68
|
-
plan: { id: 'price_xxx', interval: 'month' },
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
error: null,
|
|
73
|
-
metadata: {
|
|
74
|
-
received: { timestamp: now, timestampUNIX: nowUNIX },
|
|
75
|
-
processed: { timestamp: null, timestampUNIX: null },
|
|
76
|
-
},
|
|
77
|
-
});
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
|
|
81
|
-
{
|
|
82
|
-
name: 'pending-cancel-processed',
|
|
83
|
-
async run({ firestore, assert, state, waitFor }) {
|
|
84
|
-
await waitFor(async () => {
|
|
85
|
-
const doc = await firestore.get(`payments-webhooks/${state.eventId1}`);
|
|
86
|
-
return doc?.status === 'completed';
|
|
87
|
-
}, 15000, 500);
|
|
88
|
-
|
|
89
|
-
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
90
|
-
|
|
91
|
-
assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
|
|
92
|
-
assert.equal(userDoc.subscription.cancellation.pending, true, 'Cancellation should be pending');
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
|
-
|
|
96
|
-
{
|
|
97
|
-
name: 'write-cancelled-webhook',
|
|
98
|
-
async run({ firestore, state }) {
|
|
99
|
-
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
100
|
-
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
101
|
-
|
|
102
|
-
state.eventId2 = '_test-evt-journey-cancel-final';
|
|
103
|
-
|
|
104
|
-
await firestore.delete(`payments-webhooks/${state.eventId2}`);
|
|
105
|
-
|
|
106
|
-
// Webhook: subscription now cancelled
|
|
107
|
-
await firestore.set(`payments-webhooks/${state.eventId2}`, {
|
|
108
|
-
id: state.eventId2,
|
|
109
|
-
processor: 'stripe',
|
|
110
|
-
status: 'pending',
|
|
111
|
-
uid: state.uid,
|
|
112
|
-
event: { type: 'customer.subscription.deleted' },
|
|
113
|
-
raw: {
|
|
114
|
-
id: state.eventId2,
|
|
115
|
-
type: 'customer.subscription.deleted',
|
|
116
|
-
data: {
|
|
117
|
-
object: {
|
|
118
|
-
id: state.subscriptionId,
|
|
119
|
-
object: 'subscription',
|
|
120
|
-
status: 'canceled',
|
|
121
|
-
metadata: { uid: state.uid },
|
|
122
|
-
cancel_at_period_end: false,
|
|
123
|
-
canceled_at: Math.floor(Date.now() / 1000),
|
|
124
|
-
current_period_end: Math.floor(Date.now() / 1000),
|
|
125
|
-
current_period_start: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
126
|
-
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
127
|
-
trial_start: null,
|
|
128
|
-
trial_end: null,
|
|
129
|
-
plan: { id: 'price_xxx', interval: 'month' },
|
|
130
|
-
},
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
error: null,
|
|
134
|
-
metadata: {
|
|
135
|
-
received: { timestamp: now, timestampUNIX: nowUNIX },
|
|
136
|
-
processed: { timestamp: null, timestampUNIX: null },
|
|
137
|
-
},
|
|
138
|
-
});
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
|
|
142
|
-
{
|
|
143
|
-
name: 'subscription-cancelled',
|
|
144
|
-
async run({ firestore, assert, state, waitFor }) {
|
|
145
|
-
await waitFor(async () => {
|
|
146
|
-
const doc = await firestore.get(`payments-webhooks/${state.eventId2}`);
|
|
147
|
-
return doc?.status === 'completed';
|
|
148
|
-
}, 15000, 500);
|
|
149
|
-
|
|
150
|
-
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
151
|
-
|
|
152
|
-
assert.equal(userDoc.subscription.status, 'cancelled', 'Status should be cancelled');
|
|
153
|
-
assert.equal(userDoc.subscription.cancellation.pending, false, 'Cancellation should not be pending');
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
|
|
157
|
-
{
|
|
158
|
-
name: 'cleanup',
|
|
159
|
-
async run({ firestore, state }) {
|
|
160
|
-
await firestore.delete(`payments-webhooks/${state.eventId1}`);
|
|
161
|
-
await firestore.delete(`payments-webhooks/${state.eventId2}`);
|
|
162
|
-
await firestore.delete(`payments-subscriptions/${state.subscriptionId}`);
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
],
|
|
166
|
-
};
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test: Payment Journey - Suspend & Recover
|
|
3
|
-
* Simulates: premium active → payment fails → suspended → payment succeeds → active again
|
|
4
|
-
*/
|
|
5
|
-
const powertools = require('node-powertools');
|
|
6
|
-
|
|
7
|
-
module.exports = {
|
|
8
|
-
description: 'Payment journey: premium → suspended → recovered',
|
|
9
|
-
type: 'suite',
|
|
10
|
-
timeout: 30000,
|
|
11
|
-
|
|
12
|
-
tests: [
|
|
13
|
-
{
|
|
14
|
-
name: 'verify-starts-as-premium',
|
|
15
|
-
async run({ accounts, firestore, assert, state }) {
|
|
16
|
-
const uid = accounts['journey-payment-suspend'].uid;
|
|
17
|
-
const userDoc = await firestore.get(`users/${uid}`);
|
|
18
|
-
|
|
19
|
-
assert.ok(userDoc, 'User doc should exist');
|
|
20
|
-
assert.equal(userDoc.subscription?.product?.id, 'premium', 'Should start as premium');
|
|
21
|
-
assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
|
|
22
|
-
|
|
23
|
-
state.uid = uid;
|
|
24
|
-
state.subscriptionId = '_test-sub-journey-suspend';
|
|
25
|
-
},
|
|
26
|
-
},
|
|
27
|
-
|
|
28
|
-
{
|
|
29
|
-
name: 'write-past-due-webhook',
|
|
30
|
-
async run({ firestore, state }) {
|
|
31
|
-
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
32
|
-
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
33
|
-
|
|
34
|
-
state.eventId1 = '_test-evt-journey-suspend-fail';
|
|
35
|
-
|
|
36
|
-
await firestore.delete(`payments-webhooks/${state.eventId1}`);
|
|
37
|
-
await firestore.delete(`payments-subscriptions/${state.subscriptionId}`);
|
|
38
|
-
|
|
39
|
-
// Webhook: subscription status changed to past_due
|
|
40
|
-
await firestore.set(`payments-webhooks/${state.eventId1}`, {
|
|
41
|
-
id: state.eventId1,
|
|
42
|
-
processor: 'stripe',
|
|
43
|
-
status: 'pending',
|
|
44
|
-
uid: state.uid,
|
|
45
|
-
event: { type: 'customer.subscription.updated' },
|
|
46
|
-
raw: {
|
|
47
|
-
id: state.eventId1,
|
|
48
|
-
type: 'customer.subscription.updated',
|
|
49
|
-
data: {
|
|
50
|
-
object: {
|
|
51
|
-
id: state.subscriptionId,
|
|
52
|
-
object: 'subscription',
|
|
53
|
-
status: 'past_due',
|
|
54
|
-
metadata: { uid: state.uid },
|
|
55
|
-
cancel_at_period_end: false,
|
|
56
|
-
canceled_at: null,
|
|
57
|
-
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
58
|
-
current_period_start: Math.floor(Date.now() / 1000) - 86400 * 29,
|
|
59
|
-
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
60
|
-
trial_start: null,
|
|
61
|
-
trial_end: null,
|
|
62
|
-
plan: { id: 'price_xxx', interval: 'month' },
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
},
|
|
66
|
-
error: null,
|
|
67
|
-
metadata: {
|
|
68
|
-
received: { timestamp: now, timestampUNIX: nowUNIX },
|
|
69
|
-
processed: { timestamp: null, timestampUNIX: null },
|
|
70
|
-
},
|
|
71
|
-
});
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
|
|
75
|
-
{
|
|
76
|
-
name: 'subscription-suspended',
|
|
77
|
-
async run({ firestore, assert, state, waitFor }) {
|
|
78
|
-
await waitFor(async () => {
|
|
79
|
-
const doc = await firestore.get(`payments-webhooks/${state.eventId1}`);
|
|
80
|
-
return doc?.status === 'completed';
|
|
81
|
-
}, 15000, 500);
|
|
82
|
-
|
|
83
|
-
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
84
|
-
|
|
85
|
-
assert.equal(userDoc.subscription.status, 'suspended', 'Status should be suspended');
|
|
86
|
-
assert.equal(userDoc.subscription.product.id, 'premium', 'Product should still be premium');
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
|
|
90
|
-
{
|
|
91
|
-
name: 'write-recovery-webhook',
|
|
92
|
-
async run({ firestore, state }) {
|
|
93
|
-
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
94
|
-
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
95
|
-
const futureDate = new Date();
|
|
96
|
-
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
97
|
-
|
|
98
|
-
state.eventId2 = '_test-evt-journey-suspend-recover';
|
|
99
|
-
|
|
100
|
-
await firestore.delete(`payments-webhooks/${state.eventId2}`);
|
|
101
|
-
|
|
102
|
-
// Webhook: subscription back to active after payment succeeds
|
|
103
|
-
await firestore.set(`payments-webhooks/${state.eventId2}`, {
|
|
104
|
-
id: state.eventId2,
|
|
105
|
-
processor: 'stripe',
|
|
106
|
-
status: 'pending',
|
|
107
|
-
uid: state.uid,
|
|
108
|
-
event: { type: 'customer.subscription.updated' },
|
|
109
|
-
raw: {
|
|
110
|
-
id: state.eventId2,
|
|
111
|
-
type: 'customer.subscription.updated',
|
|
112
|
-
data: {
|
|
113
|
-
object: {
|
|
114
|
-
id: state.subscriptionId,
|
|
115
|
-
object: 'subscription',
|
|
116
|
-
status: 'active',
|
|
117
|
-
metadata: { uid: state.uid },
|
|
118
|
-
cancel_at_period_end: false,
|
|
119
|
-
canceled_at: null,
|
|
120
|
-
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
121
|
-
current_period_start: Math.floor(Date.now() / 1000),
|
|
122
|
-
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
123
|
-
trial_start: null,
|
|
124
|
-
trial_end: null,
|
|
125
|
-
plan: { id: 'price_xxx', interval: 'month' },
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
|
-
error: null,
|
|
130
|
-
metadata: {
|
|
131
|
-
received: { timestamp: now, timestampUNIX: nowUNIX },
|
|
132
|
-
processed: { timestamp: null, timestampUNIX: null },
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
},
|
|
136
|
-
},
|
|
137
|
-
|
|
138
|
-
{
|
|
139
|
-
name: 'subscription-recovered',
|
|
140
|
-
async run({ firestore, assert, state, waitFor }) {
|
|
141
|
-
await waitFor(async () => {
|
|
142
|
-
const doc = await firestore.get(`payments-webhooks/${state.eventId2}`);
|
|
143
|
-
return doc?.status === 'completed';
|
|
144
|
-
}, 15000, 500);
|
|
145
|
-
|
|
146
|
-
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
147
|
-
|
|
148
|
-
assert.equal(userDoc.subscription.status, 'active', 'Status should be active again');
|
|
149
|
-
assert.equal(userDoc.subscription.product.id, 'premium', 'Product should still be premium');
|
|
150
|
-
},
|
|
151
|
-
},
|
|
152
|
-
|
|
153
|
-
{
|
|
154
|
-
name: 'cleanup',
|
|
155
|
-
async run({ firestore, state }) {
|
|
156
|
-
await firestore.delete(`payments-webhooks/${state.eventId1}`);
|
|
157
|
-
await firestore.delete(`payments-webhooks/${state.eventId2}`);
|
|
158
|
-
await firestore.delete(`payments-subscriptions/${state.subscriptionId}`);
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
],
|
|
162
|
-
};
|