backend-manager 5.0.73 → 5.0.74
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 +70 -0
- package/README.md +81 -7
- package/package.json +1 -1
- package/src/manager/cron/daily/reset-usage.js +5 -32
- package/src/manager/events/firestore/payments-webhooks/on-write.js +126 -0
- package/src/manager/functions/core/actions/api/admin/get-stats.js +3 -3
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +1 -1
- package/src/manager/functions/core/actions/api/user/delete.js +5 -3
- package/src/manager/functions/core/actions/api/user/get-subscription-info.js +25 -9
- package/src/manager/functions/core/actions/api/user/validate-settings.js +1 -1
- package/src/manager/helpers/analytics.js +4 -4
- package/src/manager/helpers/api-manager.js +25 -42
- package/src/manager/helpers/middleware.js +1 -1
- package/src/manager/helpers/usage.js +24 -93
- package/src/manager/helpers/user.js +29 -38
- package/src/manager/index.js +22 -10
- package/src/manager/libraries/stripe.js +293 -0
- package/src/manager/routes/admin/stats/get.js +3 -3
- package/src/manager/routes/marketing/contact/post.js +1 -1
- package/src/manager/routes/payments/intent/post.js +94 -0
- package/src/manager/routes/payments/intent/providers/stripe.js +66 -0
- package/src/manager/routes/payments/webhook/post.js +87 -0
- package/src/manager/routes/payments/webhook/providers/stripe.js +35 -0
- package/src/manager/routes/test/schema/post.js +5 -5
- package/src/manager/routes/user/delete.js +5 -3
- package/src/manager/routes/user/settings/validate/post.js +3 -3
- package/src/manager/routes/user/subscription/get.js +25 -9
- package/src/manager/schemas/payments/intent/post.js +22 -0
- package/src/manager/schemas/payments/webhook/post.js +6 -0
- package/src/manager/schemas/test/schema/post.js +1 -1
- package/src/test/test-accounts.js +63 -25
- package/src/test/utils/firestore-rules-client.js +5 -5
- package/templates/backend-manager-config.json +32 -0
- package/templates/firestore.rules +1 -1
- package/test/_init/accounts-validation.js +3 -3
- package/test/functions/user/delete.js +1 -1
- package/test/functions/user/get-subscription-info.js +18 -24
- package/test/payments/intent.js +104 -0
- package/test/payments/journey-payment-cancel.js +166 -0
- package/test/payments/journey-payment-suspend.js +162 -0
- package/test/payments/journey-payment-trial.js +167 -0
- package/test/payments/journey-payment-upgrade.js +136 -0
- package/test/payments/webhook.js +128 -0
- package/test/routes/test/schema.js +1 -1
- package/test/routes/user/delete.js +1 -1
- package/test/routes/user/subscription.js +18 -24
- package/test/routes/user/user.js +14 -14
- package/test/rules/user.js +8 -8
- package/src/manager/helpers/subscription-resolver-new.js +0 -827
- package/src/manager/helpers/subscription-resolver.js +0 -841
|
@@ -47,4 +47,36 @@
|
|
|
47
47
|
author: 'alex-raeburn',
|
|
48
48
|
}
|
|
49
49
|
],
|
|
50
|
+
products: [
|
|
51
|
+
{
|
|
52
|
+
id: 'basic',
|
|
53
|
+
name: 'Basic',
|
|
54
|
+
type: 'free',
|
|
55
|
+
limits: {
|
|
56
|
+
requests: 100,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'premium',
|
|
61
|
+
name: 'Premium',
|
|
62
|
+
type: 'subscription',
|
|
63
|
+
limits: {
|
|
64
|
+
requests: 1000,
|
|
65
|
+
},
|
|
66
|
+
trial: {
|
|
67
|
+
days: 14,
|
|
68
|
+
},
|
|
69
|
+
prices: {
|
|
70
|
+
monthly: {
|
|
71
|
+
stripe: 'price_xxx',
|
|
72
|
+
paypal: 'P-xxx',
|
|
73
|
+
},
|
|
74
|
+
annually: {
|
|
75
|
+
stripe: 'price_yyy',
|
|
76
|
+
paypal: 'P-yyy',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
// Add more products/tiers here
|
|
81
|
+
],
|
|
50
82
|
}
|
|
@@ -58,7 +58,7 @@ service cloud.firestore {
|
|
|
58
58
|
return isWritingField('auth')
|
|
59
59
|
|| isWritingField('roles')
|
|
60
60
|
|| isWritingField('flags')
|
|
61
|
-
|| isWritingField('
|
|
61
|
+
|| isWritingField('subscription')
|
|
62
62
|
|| isWritingField('affiliate')
|
|
63
63
|
|| isWritingField('api')
|
|
64
64
|
|| isWritingField('usage');
|
|
@@ -48,9 +48,9 @@ module.exports = {
|
|
|
48
48
|
assert.hasProperty(userDoc, 'activity.created.timestamp', `Account '${accountId}' should have activity.created.timestamp`);
|
|
49
49
|
assert.hasProperty(userDoc, 'activity.created.timestampUNIX', `Account '${accountId}' should have activity.created.timestampUNIX`);
|
|
50
50
|
|
|
51
|
-
// Validate
|
|
52
|
-
assert.hasProperty(userDoc, '
|
|
53
|
-
assert.hasProperty(userDoc, '
|
|
51
|
+
// Validate subscription fields (merged from test account properties)
|
|
52
|
+
assert.hasProperty(userDoc, 'subscription.product.id', `Account '${accountId}' should have subscription.product.id`);
|
|
53
|
+
assert.hasProperty(userDoc, 'subscription.status', `Account '${accountId}' should have subscription.status`);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
return { success: true };
|
|
@@ -53,7 +53,7 @@ module.exports = {
|
|
|
53
53
|
name: 'remove-subscription-from-delete-user',
|
|
54
54
|
async run({ firestore, state }) {
|
|
55
55
|
// Remove the subscription (set to null to overwrite)
|
|
56
|
-
await firestore.set(`users/${state.deleteUid}`, {
|
|
56
|
+
await firestore.set(`users/${state.deleteUid}`, { subscription: null }, { merge: true });
|
|
57
57
|
},
|
|
58
58
|
},
|
|
59
59
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Test: user:get-subscription-info
|
|
3
3
|
* Tests the user get subscription info command
|
|
4
|
-
* Returns
|
|
4
|
+
* Returns subscription details for authenticated users
|
|
5
5
|
*/
|
|
6
6
|
module.exports = {
|
|
7
7
|
description: 'User get subscription info',
|
|
@@ -17,17 +17,17 @@ module.exports = {
|
|
|
17
17
|
const response = await http.command('user:get-subscription-info', {});
|
|
18
18
|
|
|
19
19
|
assert.isSuccess(response, 'Get subscription info should succeed for authenticated user');
|
|
20
|
-
assert.hasProperty(response, 'data.
|
|
21
|
-
assert.hasProperty(response, 'data.
|
|
22
|
-
assert.hasProperty(response, 'data.
|
|
23
|
-
assert.hasProperty(response, 'data.
|
|
24
|
-
assert.hasProperty(response, 'data.
|
|
20
|
+
assert.hasProperty(response, 'data.subscription', 'Response should contain subscription object');
|
|
21
|
+
assert.hasProperty(response, 'data.subscription.product.id', 'Subscription should have id');
|
|
22
|
+
assert.hasProperty(response, 'data.subscription.expires', 'Subscription should have expires');
|
|
23
|
+
assert.hasProperty(response, 'data.subscription.trial', 'Subscription should have trial info');
|
|
24
|
+
assert.hasProperty(response, 'data.subscription.payment', 'Subscription should have payment info');
|
|
25
25
|
},
|
|
26
26
|
},
|
|
27
27
|
|
|
28
|
-
// Test 2:
|
|
28
|
+
// Test 2: Subscription has correct structure
|
|
29
29
|
{
|
|
30
|
-
name: '
|
|
30
|
+
name: 'subscription-structure-valid',
|
|
31
31
|
auth: 'basic',
|
|
32
32
|
timeout: 15000,
|
|
33
33
|
|
|
@@ -36,31 +36,26 @@ module.exports = {
|
|
|
36
36
|
|
|
37
37
|
assert.isSuccess(response, 'Get subscription info should succeed');
|
|
38
38
|
|
|
39
|
-
const
|
|
39
|
+
const subscription = response.data.subscription;
|
|
40
40
|
|
|
41
41
|
// Check expires structure
|
|
42
|
-
assert.hasProperty(response, 'data.
|
|
43
|
-
assert.hasProperty(response, 'data.
|
|
42
|
+
assert.hasProperty(response, 'data.subscription.expires.timestamp', 'expires should have timestamp');
|
|
43
|
+
assert.hasProperty(response, 'data.subscription.expires.timestampUNIX', 'expires should have timestampUNIX');
|
|
44
44
|
|
|
45
45
|
// Check trial structure
|
|
46
46
|
assert.ok(
|
|
47
|
-
typeof
|
|
47
|
+
typeof subscription.trial.activated === 'boolean',
|
|
48
48
|
'trial.activated should be boolean'
|
|
49
49
|
);
|
|
50
|
-
assert.hasProperty(response, 'data.plan.trial.date', 'trial should have date');
|
|
51
|
-
assert.hasProperty(response, 'data.plan.trial.date.timestamp', 'trial.date should have timestamp');
|
|
52
50
|
|
|
53
51
|
// Check payment structure
|
|
54
|
-
assert.
|
|
55
|
-
typeof plan.payment.active === 'boolean',
|
|
56
|
-
'payment.active should be boolean'
|
|
57
|
-
);
|
|
52
|
+
assert.hasProperty(response, 'data.subscription.payment', 'subscription should have payment');
|
|
58
53
|
},
|
|
59
54
|
},
|
|
60
55
|
|
|
61
56
|
// Test 3: Premium user has active subscription
|
|
62
57
|
{
|
|
63
|
-
name: 'premium-user-has-active-
|
|
58
|
+
name: 'premium-user-has-active-subscription',
|
|
64
59
|
auth: 'premium-active',
|
|
65
60
|
timeout: 15000,
|
|
66
61
|
|
|
@@ -68,9 +63,8 @@ module.exports = {
|
|
|
68
63
|
const response = await http.command('user:get-subscription-info', {});
|
|
69
64
|
|
|
70
65
|
assert.isSuccess(response, 'Get subscription info should succeed for premium user');
|
|
71
|
-
|
|
72
|
-
assert.hasProperty(response, 'data.
|
|
73
|
-
assert.hasProperty(response, 'data.plan.payment', 'Premium user should have payment info');
|
|
66
|
+
assert.hasProperty(response, 'data.subscription.product.id', 'Premium user should have subscription id');
|
|
67
|
+
assert.hasProperty(response, 'data.subscription.payment', 'Premium user should have payment info');
|
|
74
68
|
},
|
|
75
69
|
},
|
|
76
70
|
|
|
@@ -84,8 +78,8 @@ module.exports = {
|
|
|
84
78
|
const response = await http.command('user:get-subscription-info', {});
|
|
85
79
|
|
|
86
80
|
assert.isSuccess(response, 'Get subscription info should succeed for expired premium');
|
|
87
|
-
assert.hasProperty(response, 'data.
|
|
88
|
-
assert.hasProperty(response, 'data.
|
|
81
|
+
assert.hasProperty(response, 'data.subscription.product.id', 'Should still have subscription id');
|
|
82
|
+
assert.hasProperty(response, 'data.subscription.expires.timestampUNIX', 'Should have expires timestamp');
|
|
89
83
|
},
|
|
90
84
|
},
|
|
91
85
|
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
};
|