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
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Stripe webhook
|
|
2
|
+
* Stripe webhook processor
|
|
3
3
|
* Extracts and validates webhook event data from Stripe
|
|
4
4
|
*/
|
|
5
|
+
|
|
6
|
+
// Stripe event types we process — add new ones here as needed
|
|
7
|
+
const SUPPORTED_EVENTS = new Set([
|
|
8
|
+
'customer.subscription.created',
|
|
9
|
+
'customer.subscription.updated',
|
|
10
|
+
'customer.subscription.deleted',
|
|
11
|
+
]);
|
|
12
|
+
|
|
5
13
|
module.exports = {
|
|
14
|
+
/**
|
|
15
|
+
* Returns true if this event type should be saved and processed
|
|
16
|
+
*/
|
|
17
|
+
isSupported(eventType) {
|
|
18
|
+
return SUPPORTED_EVENTS.has(eventType);
|
|
19
|
+
},
|
|
20
|
+
|
|
6
21
|
/**
|
|
7
22
|
* Parse a Stripe webhook request
|
|
8
23
|
* Extracts the event data, event type, and resolves the UID from metadata
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test webhook processor
|
|
3
|
+
* Delegates to Stripe's parser since test processor generates Stripe-shaped event payloads
|
|
4
|
+
*/
|
|
5
|
+
const stripeProcessor = require('./stripe.js');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
isSupported(eventType) {
|
|
9
|
+
return stripeProcessor.isSupported(eventType);
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
parseWebhook(req) {
|
|
13
|
+
return stripeProcessor.parseWebhook(req);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -49,7 +49,7 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
|
|
|
49
49
|
timestampUNIX: userData?.subscription?.expires?.timestampUNIX || oldDateUNIX,
|
|
50
50
|
},
|
|
51
51
|
trial: {
|
|
52
|
-
|
|
52
|
+
claimed: userData?.subscription?.trial?.claimed ?? false,
|
|
53
53
|
expires: {
|
|
54
54
|
timestamp: userData?.subscription?.trial?.expires?.timestamp || oldDate,
|
|
55
55
|
timestampUNIX: userData?.subscription?.trial?.expires?.timestampUNIX || oldDateUNIX,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Schema: POST /payments/webhook
|
|
3
|
-
* Minimal schema - webhook payloads are validated by the
|
|
3
|
+
* Minimal schema - webhook payloads are validated by the processor, not the schema
|
|
4
4
|
* The processor and key come from query params, not the body
|
|
5
5
|
*/
|
|
6
6
|
module.exports = () => ({});
|
|
@@ -130,37 +130,37 @@ const STATIC_ACCOUNTS = {
|
|
|
130
130
|
* These accounts transition through states via webhook tests
|
|
131
131
|
*/
|
|
132
132
|
const JOURNEY_ACCOUNTS = {
|
|
133
|
-
'journey-
|
|
134
|
-
id: 'journey-
|
|
135
|
-
uid: '_test-journey-
|
|
136
|
-
email: '_test.journey-
|
|
133
|
+
'journey-payments-upgrade': {
|
|
134
|
+
id: 'journey-payments-upgrade',
|
|
135
|
+
uid: '_test-journey-payments-upgrade',
|
|
136
|
+
email: '_test.journey-payments-upgrade@{domain}',
|
|
137
137
|
properties: {
|
|
138
138
|
roles: {},
|
|
139
139
|
subscription: { product: { id: 'basic' }, status: 'active' }, // Starts as basic, upgraded via Stripe webhook
|
|
140
140
|
},
|
|
141
141
|
},
|
|
142
|
-
'journey-
|
|
143
|
-
id: 'journey-
|
|
144
|
-
uid: '_test-journey-
|
|
145
|
-
email: '_test.journey-
|
|
142
|
+
'journey-payments-cancel': {
|
|
143
|
+
id: 'journey-payments-cancel',
|
|
144
|
+
uid: '_test-journey-payments-cancel',
|
|
145
|
+
email: '_test.journey-payments-cancel@{domain}',
|
|
146
146
|
properties: {
|
|
147
147
|
roles: {},
|
|
148
|
-
subscription: { product: { id: '
|
|
148
|
+
subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
|
|
149
149
|
},
|
|
150
150
|
},
|
|
151
|
-
'journey-
|
|
152
|
-
id: 'journey-
|
|
153
|
-
uid: '_test-journey-
|
|
154
|
-
email: '_test.journey-
|
|
151
|
+
'journey-payments-suspend': {
|
|
152
|
+
id: 'journey-payments-suspend',
|
|
153
|
+
uid: '_test-journey-payments-suspend',
|
|
154
|
+
email: '_test.journey-payments-suspend@{domain}',
|
|
155
155
|
properties: {
|
|
156
156
|
roles: {},
|
|
157
|
-
subscription: { product: { id: '
|
|
157
|
+
subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
|
|
158
158
|
},
|
|
159
159
|
},
|
|
160
|
-
'journey-
|
|
161
|
-
id: 'journey-
|
|
162
|
-
uid: '_test-journey-
|
|
163
|
-
email: '_test.journey-
|
|
160
|
+
'journey-payments-trial': {
|
|
161
|
+
id: 'journey-payments-trial',
|
|
162
|
+
uid: '_test-journey-payments-trial',
|
|
163
|
+
email: '_test.journey-payments-trial@{domain}',
|
|
164
164
|
properties: {
|
|
165
165
|
roles: {},
|
|
166
166
|
subscription: { product: { id: 'basic' }, status: 'active' }, // Starts as basic, upgraded via trial webhook
|
package/templates/_.env
CHANGED
|
@@ -25,6 +25,56 @@
|
|
|
25
25
|
secret: 'ABCx1234567890ABCDEFGH',
|
|
26
26
|
},
|
|
27
27
|
oauth2: {},
|
|
28
|
+
payment: {
|
|
29
|
+
processors: {
|
|
30
|
+
stripe: {
|
|
31
|
+
publishableKey: null,
|
|
32
|
+
},
|
|
33
|
+
paypal: {
|
|
34
|
+
clientId: null,
|
|
35
|
+
},
|
|
36
|
+
chargebee: {
|
|
37
|
+
site: null,
|
|
38
|
+
},
|
|
39
|
+
coinbase: {
|
|
40
|
+
enabled: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
products: [
|
|
44
|
+
{
|
|
45
|
+
id: 'basic',
|
|
46
|
+
name: 'Basic',
|
|
47
|
+
type: 'subscription',
|
|
48
|
+
limits: {
|
|
49
|
+
requests: 100,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'premium',
|
|
54
|
+
name: 'Premium',
|
|
55
|
+
type: 'subscription',
|
|
56
|
+
limits: {
|
|
57
|
+
requests: 1000,
|
|
58
|
+
},
|
|
59
|
+
trial: {
|
|
60
|
+
days: 14,
|
|
61
|
+
},
|
|
62
|
+
prices: {
|
|
63
|
+
monthly: {
|
|
64
|
+
amount: 4.99,
|
|
65
|
+
stripe: 'price_xxx',
|
|
66
|
+
paypal: 'P-xxx',
|
|
67
|
+
},
|
|
68
|
+
annually: {
|
|
69
|
+
amount: 49.99,
|
|
70
|
+
stripe: 'price_yyy',
|
|
71
|
+
paypal: 'P-yyy',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
// Add more products/tiers here
|
|
76
|
+
],
|
|
77
|
+
},
|
|
28
78
|
firebaseConfig: {
|
|
29
79
|
apiKey: '123-456',
|
|
30
80
|
authDomain: 'PROJECT-ID.firebaseapp.com',
|
|
@@ -51,40 +101,6 @@
|
|
|
51
101
|
// appUrl: 'https://api.otherapp.com', // Required if app is set (fetches /backend-manager/app)
|
|
52
102
|
}
|
|
53
103
|
],
|
|
54
|
-
products: [
|
|
55
|
-
{
|
|
56
|
-
id: 'basic',
|
|
57
|
-
name: 'Basic',
|
|
58
|
-
type: 'subscription',
|
|
59
|
-
limits: {
|
|
60
|
-
requests: 100,
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
id: 'premium',
|
|
65
|
-
name: 'Premium',
|
|
66
|
-
type: 'subscription',
|
|
67
|
-
limits: {
|
|
68
|
-
requests: 1000,
|
|
69
|
-
},
|
|
70
|
-
trial: {
|
|
71
|
-
days: 14,
|
|
72
|
-
},
|
|
73
|
-
prices: {
|
|
74
|
-
monthly: {
|
|
75
|
-
amount: 4.99,
|
|
76
|
-
stripe: 'price_xxx',
|
|
77
|
-
paypal: 'P-xxx',
|
|
78
|
-
},
|
|
79
|
-
annually: {
|
|
80
|
-
amount: 49.99,
|
|
81
|
-
stripe: 'price_yyy',
|
|
82
|
-
paypal: 'P-yyy',
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
// Add more products/tiers here
|
|
87
|
-
],
|
|
88
104
|
reviews: {
|
|
89
105
|
enabled: true,
|
|
90
106
|
sites: [
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - Cancel
|
|
3
|
+
* Simulates: paid active → pending cancel → cancelled
|
|
4
|
+
*
|
|
5
|
+
* Uses test intent for initial subscription, then manual webhooks for cancel flow
|
|
6
|
+
* Product-agnostic: resolves the first paid product from config.payment.products
|
|
7
|
+
*/
|
|
8
|
+
module.exports = {
|
|
9
|
+
description: 'Payment journey: paid → pending cancel → cancelled',
|
|
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-cancel'].uid;
|
|
18
|
+
|
|
19
|
+
// Resolve first paid product from config
|
|
20
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
21
|
+
assert.ok(paidProduct, 'Config should have at least one paid product');
|
|
22
|
+
|
|
23
|
+
state.uid = uid;
|
|
24
|
+
state.paidProductId = paidProduct.id;
|
|
25
|
+
state.paidProductName = paidProduct.name;
|
|
26
|
+
state.paidPriceId = paidProduct.prices.monthly.stripe;
|
|
27
|
+
|
|
28
|
+
// Create subscription via test intent
|
|
29
|
+
const response = await http.as('journey-payments-cancel').post('payments/intent', {
|
|
30
|
+
processor: 'test',
|
|
31
|
+
productId: paidProduct.id,
|
|
32
|
+
frequency: 'monthly',
|
|
33
|
+
});
|
|
34
|
+
assert.isSuccess(response, 'Intent should succeed');
|
|
35
|
+
|
|
36
|
+
// Wait for subscription to activate
|
|
37
|
+
await waitFor(async () => {
|
|
38
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
39
|
+
return userDoc?.subscription?.product?.id === paidProduct.id;
|
|
40
|
+
}, 15000, 500);
|
|
41
|
+
|
|
42
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
43
|
+
assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should start as ${paidProduct.id}`);
|
|
44
|
+
assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
|
|
45
|
+
|
|
46
|
+
state.subscriptionId = userDoc.subscription.payment.resourceId;
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
name: 'send-pending-cancel-webhook',
|
|
52
|
+
async run({ http, assert, state, config }) {
|
|
53
|
+
const futureDate = new Date();
|
|
54
|
+
futureDate.setFullYear(futureDate.getFullYear() + 1);
|
|
55
|
+
|
|
56
|
+
state.eventId1 = `_test-evt-journey-cancel-pending-${Date.now()}`;
|
|
57
|
+
|
|
58
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
59
|
+
id: state.eventId1,
|
|
60
|
+
type: 'customer.subscription.updated',
|
|
61
|
+
data: {
|
|
62
|
+
object: {
|
|
63
|
+
id: state.subscriptionId,
|
|
64
|
+
object: 'subscription',
|
|
65
|
+
status: 'active',
|
|
66
|
+
metadata: { uid: state.uid },
|
|
67
|
+
cancel_at_period_end: true,
|
|
68
|
+
cancel_at: Math.floor(futureDate.getTime() / 1000),
|
|
69
|
+
canceled_at: null,
|
|
70
|
+
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
71
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
72
|
+
start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
73
|
+
trial_start: null,
|
|
74
|
+
trial_end: null,
|
|
75
|
+
plan: { id: state.paidPriceId, interval: 'month' },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
assert.isSuccess(response, 'Webhook should be accepted');
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
name: 'pending-cancel-processed',
|
|
86
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
87
|
+
await waitFor(async () => {
|
|
88
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId1}`);
|
|
89
|
+
return doc?.status === 'completed';
|
|
90
|
+
}, 15000, 500);
|
|
91
|
+
|
|
92
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
93
|
+
|
|
94
|
+
assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
|
|
95
|
+
assert.equal(userDoc.subscription.cancellation.pending, true, 'Cancellation should be pending');
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
name: 'send-cancelled-webhook',
|
|
101
|
+
async run({ http, assert, state, config }) {
|
|
102
|
+
state.eventId2 = `_test-evt-journey-cancel-final-${Date.now()}`;
|
|
103
|
+
|
|
104
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
105
|
+
id: state.eventId2,
|
|
106
|
+
type: 'customer.subscription.deleted',
|
|
107
|
+
data: {
|
|
108
|
+
object: {
|
|
109
|
+
id: state.subscriptionId,
|
|
110
|
+
object: 'subscription',
|
|
111
|
+
status: 'canceled',
|
|
112
|
+
metadata: { uid: state.uid },
|
|
113
|
+
cancel_at_period_end: false,
|
|
114
|
+
canceled_at: Math.floor(Date.now() / 1000),
|
|
115
|
+
current_period_end: Math.floor(Date.now() / 1000),
|
|
116
|
+
current_period_start: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
117
|
+
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
118
|
+
trial_start: null,
|
|
119
|
+
trial_end: null,
|
|
120
|
+
plan: { id: state.paidPriceId, interval: 'month' },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
assert.isSuccess(response, 'Webhook should be accepted');
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
name: 'subscription-cancelled',
|
|
131
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
132
|
+
await waitFor(async () => {
|
|
133
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId2}`);
|
|
134
|
+
return doc?.status === 'completed';
|
|
135
|
+
}, 15000, 500);
|
|
136
|
+
|
|
137
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
138
|
+
|
|
139
|
+
assert.equal(userDoc.subscription.status, 'cancelled', 'Status should be cancelled');
|
|
140
|
+
assert.equal(userDoc.subscription.cancellation.pending, false, 'Cancellation should not be pending');
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - Suspend & Recover
|
|
3
|
+
* Simulates: paid active → payment fails → suspended → payment succeeds → active again
|
|
4
|
+
*
|
|
5
|
+
* Uses test intent for initial subscription, then manual webhooks for suspend/recover
|
|
6
|
+
* Product-agnostic: resolves the first paid product from config.payment.products
|
|
7
|
+
*/
|
|
8
|
+
module.exports = {
|
|
9
|
+
description: 'Payment journey: paid → suspended → recovered via test processor',
|
|
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-suspend'].uid;
|
|
18
|
+
|
|
19
|
+
// Resolve first paid product from config
|
|
20
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
21
|
+
assert.ok(paidProduct, 'Config should have at least one paid product');
|
|
22
|
+
|
|
23
|
+
state.uid = uid;
|
|
24
|
+
state.paidProductId = paidProduct.id;
|
|
25
|
+
state.paidProductName = paidProduct.name;
|
|
26
|
+
state.paidPriceId = paidProduct.prices.monthly.stripe;
|
|
27
|
+
|
|
28
|
+
// Create subscription via test intent
|
|
29
|
+
const response = await http.as('journey-payments-suspend').post('payments/intent', {
|
|
30
|
+
processor: 'test',
|
|
31
|
+
productId: paidProduct.id,
|
|
32
|
+
frequency: 'monthly',
|
|
33
|
+
});
|
|
34
|
+
assert.isSuccess(response, 'Intent should succeed');
|
|
35
|
+
|
|
36
|
+
// Wait for subscription to activate
|
|
37
|
+
await waitFor(async () => {
|
|
38
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
39
|
+
return userDoc?.subscription?.product?.id === paidProduct.id;
|
|
40
|
+
}, 15000, 500);
|
|
41
|
+
|
|
42
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
43
|
+
assert.equal(userDoc.subscription?.product?.id, paidProduct.id, `Should start as ${paidProduct.id}`);
|
|
44
|
+
assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
|
|
45
|
+
|
|
46
|
+
state.subscriptionId = userDoc.subscription.payment.resourceId;
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
name: 'send-past-due-webhook',
|
|
52
|
+
async run({ http, assert, state, config }) {
|
|
53
|
+
state.eventId1 = `_test-evt-journey-suspend-fail-${Date.now()}`;
|
|
54
|
+
|
|
55
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
56
|
+
id: state.eventId1,
|
|
57
|
+
type: 'customer.subscription.updated',
|
|
58
|
+
data: {
|
|
59
|
+
object: {
|
|
60
|
+
id: state.subscriptionId,
|
|
61
|
+
object: 'subscription',
|
|
62
|
+
status: 'past_due',
|
|
63
|
+
metadata: { uid: state.uid },
|
|
64
|
+
cancel_at_period_end: false,
|
|
65
|
+
canceled_at: null,
|
|
66
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
67
|
+
current_period_start: Math.floor(Date.now() / 1000) - 86400 * 29,
|
|
68
|
+
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
69
|
+
trial_start: null,
|
|
70
|
+
trial_end: null,
|
|
71
|
+
plan: { id: state.paidPriceId, interval: 'month' },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.isSuccess(response, 'Webhook should be accepted');
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
name: 'subscription-suspended',
|
|
82
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
83
|
+
await waitFor(async () => {
|
|
84
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId1}`);
|
|
85
|
+
return doc?.status === 'completed';
|
|
86
|
+
}, 15000, 500);
|
|
87
|
+
|
|
88
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
89
|
+
|
|
90
|
+
assert.equal(userDoc.subscription.status, 'suspended', 'Status should be suspended');
|
|
91
|
+
assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should still be ${state.paidProductId}`);
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
name: 'send-recovery-webhook',
|
|
97
|
+
async run({ http, assert, state, config }) {
|
|
98
|
+
const futureDate = new Date();
|
|
99
|
+
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
100
|
+
|
|
101
|
+
state.eventId2 = `_test-evt-journey-suspend-recover-${Date.now()}`;
|
|
102
|
+
|
|
103
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
104
|
+
id: state.eventId2,
|
|
105
|
+
type: 'customer.subscription.updated',
|
|
106
|
+
data: {
|
|
107
|
+
object: {
|
|
108
|
+
id: state.subscriptionId,
|
|
109
|
+
object: 'subscription',
|
|
110
|
+
status: 'active',
|
|
111
|
+
metadata: { uid: state.uid },
|
|
112
|
+
cancel_at_period_end: false,
|
|
113
|
+
canceled_at: null,
|
|
114
|
+
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
115
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
116
|
+
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
117
|
+
trial_start: null,
|
|
118
|
+
trial_end: null,
|
|
119
|
+
plan: { id: state.paidPriceId, interval: 'month' },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
assert.isSuccess(response, 'Webhook should be accepted');
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
name: 'subscription-recovered',
|
|
130
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
131
|
+
await waitFor(async () => {
|
|
132
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId2}`);
|
|
133
|
+
return doc?.status === 'completed';
|
|
134
|
+
}, 15000, 500);
|
|
135
|
+
|
|
136
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
137
|
+
|
|
138
|
+
assert.equal(userDoc.subscription.status, 'active', 'Status should be active again');
|
|
139
|
+
assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should still be ${state.paidProductId}`);
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - Trial
|
|
3
|
+
* Simulates: basic user → trial activation via test intent → trial ends → active paid
|
|
4
|
+
*
|
|
5
|
+
* Uses the test processor for initial trial, then manual webhook for trial-to-active
|
|
6
|
+
* Product-agnostic: resolves the first paid product from config.payment.products
|
|
7
|
+
*/
|
|
8
|
+
module.exports = {
|
|
9
|
+
description: 'Payment journey: basic → trial → active paid via test processor',
|
|
10
|
+
type: 'suite',
|
|
11
|
+
timeout: 30000,
|
|
12
|
+
|
|
13
|
+
tests: [
|
|
14
|
+
{
|
|
15
|
+
name: 'verify-starts-as-basic',
|
|
16
|
+
async run({ accounts, firestore, assert, state, config }) {
|
|
17
|
+
const uid = accounts['journey-payments-trial'].uid;
|
|
18
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
19
|
+
|
|
20
|
+
assert.ok(userDoc, 'User doc should exist');
|
|
21
|
+
assert.equal(userDoc.subscription?.product?.id, 'basic', 'Should start as basic');
|
|
22
|
+
assert.equal(userDoc.subscription?.trial?.claimed, false, 'Trial should not be claimed');
|
|
23
|
+
|
|
24
|
+
// Resolve first paid product from config
|
|
25
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
26
|
+
assert.ok(paidProduct, 'Config should have at least one paid product');
|
|
27
|
+
|
|
28
|
+
state.uid = uid;
|
|
29
|
+
state.paidProductId = paidProduct.id;
|
|
30
|
+
state.paidPriceId = paidProduct.prices.monthly.stripe;
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
name: 'create-trial-intent',
|
|
36
|
+
async run({ http, assert, state }) {
|
|
37
|
+
const response = await http.as('journey-payments-trial').post('payments/intent', {
|
|
38
|
+
processor: 'test',
|
|
39
|
+
productId: state.paidProductId,
|
|
40
|
+
frequency: 'monthly',
|
|
41
|
+
trial: true,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
assert.isSuccess(response, 'Intent should succeed');
|
|
45
|
+
assert.ok(response.data.id, 'Should return intent ID');
|
|
46
|
+
|
|
47
|
+
state.intentId = response.data.id;
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
name: 'trial-activated',
|
|
53
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
54
|
+
// Poll until trial subscription appears
|
|
55
|
+
await waitFor(async () => {
|
|
56
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
57
|
+
return userDoc?.subscription?.trial?.claimed === true;
|
|
58
|
+
}, 15000, 500);
|
|
59
|
+
|
|
60
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
61
|
+
|
|
62
|
+
assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
|
|
63
|
+
assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
|
|
64
|
+
assert.equal(userDoc.subscription.trial.claimed, true, 'Trial should be claimed');
|
|
65
|
+
|
|
66
|
+
state.subscriptionId = userDoc.subscription.payment.resourceId;
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
name: 'send-trial-to-active-webhook',
|
|
72
|
+
async run({ http, assert, state, config }) {
|
|
73
|
+
const futureDate = new Date();
|
|
74
|
+
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
75
|
+
|
|
76
|
+
state.eventId2 = `_test-evt-journey-trial-active-${Date.now()}`;
|
|
77
|
+
|
|
78
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
79
|
+
id: state.eventId2,
|
|
80
|
+
type: 'customer.subscription.updated',
|
|
81
|
+
data: {
|
|
82
|
+
object: {
|
|
83
|
+
id: state.subscriptionId,
|
|
84
|
+
object: 'subscription',
|
|
85
|
+
status: 'active',
|
|
86
|
+
metadata: { uid: state.uid },
|
|
87
|
+
cancel_at_period_end: false,
|
|
88
|
+
canceled_at: null,
|
|
89
|
+
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
90
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
91
|
+
start_date: Math.floor(Date.now() / 1000) - 86400 * 14,
|
|
92
|
+
trial_start: Math.floor(Date.now() / 1000) - 86400 * 14,
|
|
93
|
+
trial_end: Math.floor(Date.now() / 1000),
|
|
94
|
+
plan: { id: state.paidPriceId, interval: 'month' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
assert.isSuccess(response, 'Webhook should be accepted');
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
name: 'trial-transitioned-to-active',
|
|
105
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
106
|
+
await waitFor(async () => {
|
|
107
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId2}`);
|
|
108
|
+
return doc?.status === 'completed';
|
|
109
|
+
}, 15000, 500);
|
|
110
|
+
|
|
111
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
112
|
+
|
|
113
|
+
assert.equal(userDoc.subscription.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
|
|
114
|
+
assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
|
|
115
|
+
assert.equal(userDoc.subscription.trial.claimed, true, 'Trial should remain claimed (historical)');
|
|
116
|
+
assert.equal(userDoc.subscription.payment.frequency, 'monthly', 'Frequency should be monthly');
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|