backend-manager 5.0.84 → 5.0.86
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/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/processors/stripe.js +130 -0
- 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/src/manager/routes/payments/intent/providers/stripe.js +0 -66
- 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
|
@@ -3,8 +3,12 @@ const powertools = require('node-powertools');
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* POST /payments/webhook?processor=stripe&key=XXX
|
|
6
|
-
* Receives payment
|
|
6
|
+
* Receives payment processor webhooks, validates them, and saves to Firestore
|
|
7
7
|
* The Firestore onWrite trigger handles async processing
|
|
8
|
+
*
|
|
9
|
+
* This handler is processor-agnostic. Each processor module defines:
|
|
10
|
+
* - parseWebhook(req) — extracts { eventId, eventType, raw, uid }
|
|
11
|
+
* - isSupported(eventType) — returns true for events we should process
|
|
8
12
|
*/
|
|
9
13
|
module.exports = async ({ assistant, Manager, libraries }) => {
|
|
10
14
|
const { admin } = libraries;
|
|
@@ -25,32 +29,41 @@ module.exports = async ({ assistant, Manager, libraries }) => {
|
|
|
25
29
|
return assistant.respond('Invalid key', { code: 401 });
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
// Load the
|
|
29
|
-
let
|
|
32
|
+
// Load the processor module
|
|
33
|
+
let processorModule;
|
|
30
34
|
try {
|
|
31
|
-
|
|
35
|
+
processorModule = require(path.resolve(__dirname, `processors/${processor}.js`));
|
|
32
36
|
} catch (e) {
|
|
33
37
|
return assistant.respond(`Unknown processor: ${processor}`, { code: 400 });
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
// Parse the webhook using the
|
|
40
|
+
// Parse the webhook using the processor
|
|
37
41
|
let parsed;
|
|
38
42
|
try {
|
|
39
|
-
parsed =
|
|
43
|
+
parsed = processorModule.parseWebhook(assistant.ref.req);
|
|
40
44
|
} catch (e) {
|
|
41
45
|
return assistant.respond(`Failed to parse webhook: ${e.message}`, { code: 400 });
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
const { eventId, eventType, raw, uid } = parsed;
|
|
45
49
|
|
|
50
|
+
assistant.log(`Parsed webhook: eventId=${eventId}, eventType=${eventType}, uid=${uid || 'null'}`);
|
|
51
|
+
|
|
52
|
+
// Let the processor decide if this event type is relevant
|
|
53
|
+
if (processorModule.isSupported && !processorModule.isSupported(eventType)) {
|
|
54
|
+
assistant.log(`Ignoring event type: ${eventType}`);
|
|
55
|
+
return assistant.respond({ received: true, ignored: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
// Check for duplicate (skip if already processing/completed)
|
|
47
59
|
const existingDoc = await admin.firestore().doc(`payments-webhooks/${eventId}`).get();
|
|
48
60
|
if (existingDoc.exists) {
|
|
49
61
|
const existingStatus = existingDoc.data()?.status;
|
|
50
62
|
if (existingStatus !== 'failed') {
|
|
51
|
-
assistant.log(`
|
|
63
|
+
assistant.log(`Duplicate webhook ${eventId}, existing status=${existingStatus}, skipping`);
|
|
52
64
|
return assistant.respond({ received: true, duplicate: true });
|
|
53
65
|
}
|
|
66
|
+
assistant.log(`Retrying previously failed webhook ${eventId}`);
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
// Build timestamps
|
|
@@ -80,7 +93,7 @@ module.exports = async ({ assistant, Manager, libraries }) => {
|
|
|
80
93
|
},
|
|
81
94
|
});
|
|
82
95
|
|
|
83
|
-
assistant.log(`
|
|
96
|
+
assistant.log(`Saved payments-webhooks/${eventId}: eventType=${eventType}, processor=${processor}, uid=${uid}`);
|
|
84
97
|
|
|
85
98
|
// Return 200 immediately
|
|
86
99
|
return assistant.respond({ received: true });
|
|
@@ -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
|
+
};
|