backend-manager 5.0.102 → 5.0.103
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 +59 -0
- package/README.md +10 -0
- package/TODO-MARKETING.md +53 -0
- package/package.json +1 -1
- package/src/cli/commands/auth.js +226 -0
- package/src/cli/commands/firebase-init.js +121 -0
- package/src/cli/commands/firestore.js +261 -0
- package/src/cli/commands/index.js +2 -0
- package/src/cli/index.js +16 -0
- package/src/manager/libraries/payment-processors/stripe.js +37 -0
- package/src/manager/routes/payments/cancel/post.js +66 -0
- package/src/manager/routes/payments/cancel/processors/stripe.js +22 -0
- package/src/manager/routes/payments/cancel/processors/test.js +93 -0
- package/src/manager/routes/payments/intent/processors/stripe.js +1 -30
- package/src/manager/routes/payments/portal/post.js +54 -0
- package/src/manager/routes/payments/portal/processors/stripe.js +62 -0
- package/src/manager/routes/payments/portal/processors/test.js +18 -0
- package/src/manager/routes/user/delete.js +2 -1
- package/src/manager/schemas/payments/cancel/post.js +18 -0
- package/src/manager/schemas/payments/portal/post.js +10 -0
- package/src/test/runner.js +18 -1
- package/src/test/test-accounts.js +92 -0
- package/templates/firestore.rules +1 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +79 -0
- package/test/helpers/stripe-to-unified-one-time.js +304 -0
- package/test/routes/payments/cancel.js +124 -0
- package/test/routes/payments/intent.js +7 -14
- package/test/routes/payments/portal.js +89 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: POST /payments/cancel - Validation errors
|
|
3
|
+
* Tests rejection cases before any processor call is made.
|
|
4
|
+
* See test/events/payments/journey-payments-cancel-endpoint.js for the full end-to-end journey.
|
|
5
|
+
*/
|
|
6
|
+
module.exports = {
|
|
7
|
+
description: 'Payment cancel endpoint: validation errors',
|
|
8
|
+
type: 'group',
|
|
9
|
+
timeout: 15000,
|
|
10
|
+
|
|
11
|
+
tests: [
|
|
12
|
+
{
|
|
13
|
+
name: 'rejects-unauthenticated',
|
|
14
|
+
async run({ http, assert }) {
|
|
15
|
+
const response = await http.as('none').post('payments/cancel', {
|
|
16
|
+
confirmed: true,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.isError(response, 401, 'Should reject unauthenticated request');
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
name: 'rejects-missing-confirmed',
|
|
25
|
+
async run({ http, assert }) {
|
|
26
|
+
const response = await http.as('basic').post('payments/cancel', {
|
|
27
|
+
reason: 'Too expensive',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
assert.isError(response, 400, 'Should reject missing confirmed field');
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
name: 'rejects-basic-user',
|
|
36
|
+
async run({ http, assert }) {
|
|
37
|
+
const response = await http.as('basic').post('payments/cancel', {
|
|
38
|
+
confirmed: true,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
assert.isError(response, 400, 'Should reject basic user with no paid subscription');
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
name: 'rejects-no-processor-or-resource-id',
|
|
47
|
+
async run({ http, assert }) {
|
|
48
|
+
// cancel-no-processor starts with payment.processor=null
|
|
49
|
+
const response = await http.as('cancel-no-processor').post('payments/cancel', {
|
|
50
|
+
confirmed: true,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.isError(response, 400, 'Should reject when no processor or resourceId is set');
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
name: 'rejects-already-pending-cancellation',
|
|
59
|
+
async run({ http, assert }) {
|
|
60
|
+
// cancel-already-pending starts with cancellation.pending=true
|
|
61
|
+
const response = await http.as('cancel-already-pending').post('payments/cancel', {
|
|
62
|
+
confirmed: true,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.isError(response, 400, 'Should reject when cancellation already pending');
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
name: 'rejects-unknown-processor',
|
|
71
|
+
async run({ http, assert }) {
|
|
72
|
+
// cancel-unknown-processor starts with processor='unknown-processor'
|
|
73
|
+
const response = await http.as('cancel-unknown-processor').post('payments/cancel', {
|
|
74
|
+
confirmed: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
assert.isError(response, 400, 'Should reject unknown processor');
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
name: 'succeeds-with-test-processor',
|
|
83
|
+
async run({ http, assert, config, accounts, firestore, waitFor }) {
|
|
84
|
+
const uid = accounts['route-cancel-success'].uid;
|
|
85
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
|
|
86
|
+
|
|
87
|
+
// Step 1: Create a test subscription intent to set up a proper paid subscription
|
|
88
|
+
const intentResponse = await http.as('route-cancel-success').post('payments/intent', {
|
|
89
|
+
processor: 'test',
|
|
90
|
+
productId: paidProduct.id,
|
|
91
|
+
frequency: 'monthly',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
assert.isSuccess(intentResponse, 'Intent should succeed');
|
|
95
|
+
|
|
96
|
+
// Wait for the auto-webhook to activate the subscription
|
|
97
|
+
await waitFor(async () => {
|
|
98
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
99
|
+
return userDoc?.subscription?.payment?.processor === 'test'
|
|
100
|
+
&& userDoc?.subscription?.payment?.resourceId
|
|
101
|
+
&& userDoc?.subscription?.status === 'active';
|
|
102
|
+
}, 15000, 500);
|
|
103
|
+
|
|
104
|
+
// Step 2: Call the cancel endpoint
|
|
105
|
+
const cancelResponse = await http.as('route-cancel-success').post('payments/cancel', {
|
|
106
|
+
confirmed: true,
|
|
107
|
+
reason: 'Too expensive',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
assert.isSuccess(cancelResponse, 'Cancel should succeed');
|
|
111
|
+
assert.equal(cancelResponse.data.success, true, 'Should return success: true');
|
|
112
|
+
|
|
113
|
+
// Step 3: Verify cancellation.pending was set via the webhook pipeline
|
|
114
|
+
await waitFor(async () => {
|
|
115
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
116
|
+
return userDoc?.subscription?.cancellation?.pending === true;
|
|
117
|
+
}, 15000, 500);
|
|
118
|
+
|
|
119
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
120
|
+
assert.equal(userDoc?.subscription?.cancellation?.pending, true, 'Cancellation should be pending');
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
@@ -116,9 +116,10 @@ module.exports = {
|
|
|
116
116
|
{
|
|
117
117
|
name: 'succeeds-with-test-processor',
|
|
118
118
|
async run({ http, assert, config, firestore, accounts, waitFor }) {
|
|
119
|
+
const uid = accounts['journey-payments-intent'].uid;
|
|
119
120
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
120
121
|
|
|
121
|
-
const response = await http.as('
|
|
122
|
+
const response = await http.as('journey-payments-intent').post('payments/intent', {
|
|
122
123
|
processor: 'test',
|
|
123
124
|
productId: paidProduct.id,
|
|
124
125
|
frequency: 'monthly',
|
|
@@ -137,30 +138,26 @@ module.exports = {
|
|
|
137
138
|
assert.equal(intentDoc.processor, 'test', 'Processor should be test');
|
|
138
139
|
assert.equal(intentDoc.productId, paidProduct.id, 'Product should match');
|
|
139
140
|
|
|
140
|
-
//
|
|
141
|
+
// Wait for auto-webhook to process and activate the subscription
|
|
141
142
|
await waitFor(async () => {
|
|
142
|
-
const userDoc = await firestore.get(`users/${
|
|
143
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
143
144
|
return userDoc?.subscription?.product?.id === paidProduct.id;
|
|
144
145
|
}, 15000, 500).catch(() => {});
|
|
145
|
-
|
|
146
|
-
await firestore.set(`users/${accounts.basic.uid}`, {
|
|
147
|
-
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
148
|
-
}, { merge: true });
|
|
149
146
|
},
|
|
150
147
|
},
|
|
151
148
|
|
|
152
149
|
{
|
|
153
150
|
name: 'downgrades-trial-for-user-with-history',
|
|
154
151
|
async run({ http, assert, config, accounts, firestore, waitFor }) {
|
|
152
|
+
const uid = accounts['journey-payments-intent-trial'].uid;
|
|
155
153
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
156
|
-
const uid = accounts.basic.uid;
|
|
157
154
|
const orderDocPath = `payments-orders/_test-order-history-${uid}`;
|
|
158
155
|
|
|
159
156
|
// Create fake subscription history so user is ineligible for trial
|
|
160
157
|
await firestore.set(orderDocPath, { owner: uid, type: 'subscription', processor: 'test', status: 'cancelled' });
|
|
161
158
|
|
|
162
159
|
try {
|
|
163
|
-
const response = await http.as('
|
|
160
|
+
const response = await http.as('journey-payments-intent-trial').post('payments/intent', {
|
|
164
161
|
processor: 'test',
|
|
165
162
|
productId: paidProduct.id,
|
|
166
163
|
frequency: 'monthly',
|
|
@@ -174,15 +171,11 @@ module.exports = {
|
|
|
174
171
|
const intentDoc = await firestore.get(`payments-intents/${response.data.orderId}`);
|
|
175
172
|
assert.equal(intentDoc.trial, false, 'Trial should be false (downgraded)');
|
|
176
173
|
|
|
177
|
-
//
|
|
174
|
+
// Wait for auto-webhook to activate the subscription
|
|
178
175
|
await waitFor(async () => {
|
|
179
176
|
const userDoc = await firestore.get(`users/${uid}`);
|
|
180
177
|
return userDoc?.subscription?.product?.id === paidProduct.id;
|
|
181
178
|
}, 15000, 500).catch(() => {});
|
|
182
|
-
|
|
183
|
-
await firestore.set(`users/${uid}`, {
|
|
184
|
-
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
185
|
-
}, { merge: true });
|
|
186
179
|
} finally {
|
|
187
180
|
await firestore.delete(orderDocPath);
|
|
188
181
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: POST /payments/portal - Validation errors
|
|
3
|
+
* Tests rejection cases before any processor call is made.
|
|
4
|
+
*/
|
|
5
|
+
module.exports = {
|
|
6
|
+
description: 'Payment portal endpoint: validation errors',
|
|
7
|
+
type: 'group',
|
|
8
|
+
timeout: 15000,
|
|
9
|
+
|
|
10
|
+
tests: [
|
|
11
|
+
{
|
|
12
|
+
name: 'rejects-unauthenticated',
|
|
13
|
+
async run({ http, assert }) {
|
|
14
|
+
const response = await http.as('none').post('payments/portal', {
|
|
15
|
+
returnUrl: 'https://example.com/account',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
assert.isError(response, 401, 'Should reject unauthenticated request');
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
name: 'rejects-basic-user',
|
|
24
|
+
async run({ http, assert }) {
|
|
25
|
+
const response = await http.as('basic').post('payments/portal', {
|
|
26
|
+
returnUrl: 'https://example.com/account',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
assert.isError(response, 400, 'Should reject basic user with no paid subscription');
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
name: 'rejects-no-processor',
|
|
35
|
+
async run({ http, assert }) {
|
|
36
|
+
// portal-no-processor starts with payment.processor=null
|
|
37
|
+
const response = await http.as('portal-no-processor').post('payments/portal', {
|
|
38
|
+
returnUrl: 'https://example.com/account',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
assert.isError(response, 400, 'Should reject when no processor is set');
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
name: 'rejects-unknown-processor',
|
|
47
|
+
async run({ http, assert }) {
|
|
48
|
+
// portal-unknown-processor starts with processor='unknown-processor'
|
|
49
|
+
const response = await http.as('portal-unknown-processor').post('payments/portal', {
|
|
50
|
+
returnUrl: 'https://example.com/account',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.isError(response, 400, 'Should reject unknown processor');
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
name: 'succeeds-with-test-processor',
|
|
59
|
+
async run({ http, assert, config, accounts, firestore, waitFor }) {
|
|
60
|
+
const uid = accounts['journey-payments-portal-route'].uid;
|
|
61
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
|
|
62
|
+
|
|
63
|
+
// Set up a paid subscription with the test processor
|
|
64
|
+
const intentResponse = await http.as('journey-payments-portal-route').post('payments/intent', {
|
|
65
|
+
processor: 'test',
|
|
66
|
+
productId: paidProduct.id,
|
|
67
|
+
frequency: 'monthly',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
assert.isSuccess(intentResponse, 'Intent should succeed');
|
|
71
|
+
|
|
72
|
+
// Wait for the auto-webhook to activate the subscription
|
|
73
|
+
await waitFor(async () => {
|
|
74
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
75
|
+
return userDoc?.subscription?.payment?.processor === 'test'
|
|
76
|
+
&& userDoc?.subscription?.status === 'active';
|
|
77
|
+
}, 15000, 500);
|
|
78
|
+
|
|
79
|
+
// Call the portal endpoint
|
|
80
|
+
const portalResponse = await http.as('journey-payments-portal-route').post('payments/portal', {
|
|
81
|
+
returnUrl: 'https://example.com/account',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
assert.isSuccess(portalResponse, 'Portal should succeed');
|
|
85
|
+
assert.ok(portalResponse.data.url, 'Should return a URL');
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|