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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - Trial
|
|
3
|
+
* Simulates: basic user → trial activation via webhook → trial ends → active paid
|
|
4
|
+
*/
|
|
5
|
+
const powertools = require('node-powertools');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
description: 'Payment journey: basic → trial → active paid',
|
|
9
|
+
type: 'suite',
|
|
10
|
+
timeout: 30000,
|
|
11
|
+
|
|
12
|
+
tests: [
|
|
13
|
+
{
|
|
14
|
+
name: 'verify-starts-as-basic',
|
|
15
|
+
async run({ accounts, firestore, assert, state }) {
|
|
16
|
+
const uid = accounts['journey-payment-trial'].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, 'basic', 'Should start as basic');
|
|
21
|
+
assert.equal(userDoc.subscription?.trial?.activated, false, 'Trial should not be activated');
|
|
22
|
+
|
|
23
|
+
state.uid = uid;
|
|
24
|
+
state.subscriptionId = '_test-sub-journey-trial';
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
name: 'write-trial-start-webhook',
|
|
30
|
+
async run({ firestore, state }) {
|
|
31
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
32
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
33
|
+
const trialEnd = new Date();
|
|
34
|
+
trialEnd.setDate(trialEnd.getDate() + 14);
|
|
35
|
+
|
|
36
|
+
state.eventId1 = '_test-evt-journey-trial-start';
|
|
37
|
+
|
|
38
|
+
await firestore.delete(`payments-webhooks/${state.eventId1}`);
|
|
39
|
+
await firestore.delete(`payments-subscriptions/${state.subscriptionId}`);
|
|
40
|
+
|
|
41
|
+
// Webhook: subscription created in trialing status
|
|
42
|
+
await firestore.set(`payments-webhooks/${state.eventId1}`, {
|
|
43
|
+
id: state.eventId1,
|
|
44
|
+
processor: 'stripe',
|
|
45
|
+
status: 'pending',
|
|
46
|
+
uid: state.uid,
|
|
47
|
+
event: { type: 'customer.subscription.created' },
|
|
48
|
+
raw: {
|
|
49
|
+
id: state.eventId1,
|
|
50
|
+
type: 'customer.subscription.created',
|
|
51
|
+
data: {
|
|
52
|
+
object: {
|
|
53
|
+
id: state.subscriptionId,
|
|
54
|
+
object: 'subscription',
|
|
55
|
+
status: 'trialing',
|
|
56
|
+
metadata: { uid: state.uid },
|
|
57
|
+
cancel_at_period_end: false,
|
|
58
|
+
canceled_at: null,
|
|
59
|
+
current_period_end: Math.floor(trialEnd.getTime() / 1000),
|
|
60
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
61
|
+
start_date: Math.floor(Date.now() / 1000),
|
|
62
|
+
trial_start: Math.floor(Date.now() / 1000),
|
|
63
|
+
trial_end: Math.floor(trialEnd.getTime() / 1000),
|
|
64
|
+
plan: { id: 'price_xxx', interval: 'month' },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
error: null,
|
|
69
|
+
metadata: {
|
|
70
|
+
received: { timestamp: now, timestampUNIX: nowUNIX },
|
|
71
|
+
processed: { timestamp: null, timestampUNIX: null },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
name: 'trial-activated',
|
|
79
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
80
|
+
await waitFor(async () => {
|
|
81
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId1}`);
|
|
82
|
+
return doc?.status === 'completed';
|
|
83
|
+
}, 15000, 500);
|
|
84
|
+
|
|
85
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
86
|
+
|
|
87
|
+
assert.equal(userDoc.subscription.product.id, 'premium', 'Product should be premium');
|
|
88
|
+
assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
|
|
89
|
+
assert.equal(userDoc.subscription.trial.activated, true, 'Trial should be activated');
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
name: 'write-trial-to-active-webhook',
|
|
95
|
+
async run({ firestore, state }) {
|
|
96
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
97
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
98
|
+
const futureDate = new Date();
|
|
99
|
+
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
100
|
+
|
|
101
|
+
state.eventId2 = '_test-evt-journey-trial-active';
|
|
102
|
+
|
|
103
|
+
await firestore.delete(`payments-webhooks/${state.eventId2}`);
|
|
104
|
+
|
|
105
|
+
// Webhook: subscription transitions from trialing to active
|
|
106
|
+
await firestore.set(`payments-webhooks/${state.eventId2}`, {
|
|
107
|
+
id: state.eventId2,
|
|
108
|
+
processor: 'stripe',
|
|
109
|
+
status: 'pending',
|
|
110
|
+
uid: state.uid,
|
|
111
|
+
event: { type: 'customer.subscription.updated' },
|
|
112
|
+
raw: {
|
|
113
|
+
id: state.eventId2,
|
|
114
|
+
type: 'customer.subscription.updated',
|
|
115
|
+
data: {
|
|
116
|
+
object: {
|
|
117
|
+
id: state.subscriptionId,
|
|
118
|
+
object: 'subscription',
|
|
119
|
+
status: 'active',
|
|
120
|
+
metadata: { uid: state.uid },
|
|
121
|
+
cancel_at_period_end: false,
|
|
122
|
+
canceled_at: null,
|
|
123
|
+
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
124
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
125
|
+
start_date: Math.floor(Date.now() / 1000) - 86400 * 14,
|
|
126
|
+
trial_start: Math.floor(Date.now() / 1000) - 86400 * 14,
|
|
127
|
+
trial_end: Math.floor(Date.now() / 1000),
|
|
128
|
+
plan: { id: 'price_xxx', interval: 'month' },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
error: null,
|
|
133
|
+
metadata: {
|
|
134
|
+
received: { timestamp: now, timestampUNIX: nowUNIX },
|
|
135
|
+
processed: { timestamp: null, timestampUNIX: null },
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
{
|
|
142
|
+
name: 'trial-transitioned-to-active',
|
|
143
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
144
|
+
await waitFor(async () => {
|
|
145
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId2}`);
|
|
146
|
+
return doc?.status === 'completed';
|
|
147
|
+
}, 15000, 500);
|
|
148
|
+
|
|
149
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
150
|
+
|
|
151
|
+
assert.equal(userDoc.subscription.product.id, 'premium', 'Product should be premium');
|
|
152
|
+
assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
|
|
153
|
+
assert.equal(userDoc.subscription.trial.activated, true, 'Trial should remain activated (historical)');
|
|
154
|
+
assert.equal(userDoc.subscription.payment.frequency, 'monthly', 'Frequency should be monthly');
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
name: 'cleanup',
|
|
160
|
+
async run({ firestore, state }) {
|
|
161
|
+
await firestore.delete(`payments-webhooks/${state.eventId1}`);
|
|
162
|
+
await firestore.delete(`payments-webhooks/${state.eventId2}`);
|
|
163
|
+
await firestore.delete(`payments-subscriptions/${state.subscriptionId}`);
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - Upgrade
|
|
3
|
+
* Simulates: basic user → Stripe webhook → premium active subscription
|
|
4
|
+
*
|
|
5
|
+
* Strategy: Directly write a mock webhook doc to payments-webhooks/{id}
|
|
6
|
+
* This fires the onWrite trigger in the emulator, simulating the full flow
|
|
7
|
+
*/
|
|
8
|
+
const powertools = require('node-powertools');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
description: 'Payment journey: basic → premium upgrade via webhook',
|
|
12
|
+
type: 'suite',
|
|
13
|
+
timeout: 30000,
|
|
14
|
+
|
|
15
|
+
tests: [
|
|
16
|
+
{
|
|
17
|
+
name: 'verify-starts-as-basic',
|
|
18
|
+
async run({ accounts, firestore, assert, state }) {
|
|
19
|
+
const uid = accounts['journey-payment-upgrade'].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, 'basic', 'Should start as basic');
|
|
24
|
+
assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
|
|
25
|
+
|
|
26
|
+
state.uid = uid;
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
name: 'write-upgrade-webhook',
|
|
32
|
+
async run({ firestore, state }) {
|
|
33
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
34
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
35
|
+
const futureDate = new Date();
|
|
36
|
+
futureDate.setFullYear(futureDate.getFullYear() + 1);
|
|
37
|
+
|
|
38
|
+
state.eventId = '_test-evt-journey-upgrade';
|
|
39
|
+
state.subscriptionId = '_test-sub-journey-upgrade';
|
|
40
|
+
|
|
41
|
+
// Clean up any prior test data
|
|
42
|
+
await firestore.delete(`payments-webhooks/${state.eventId}`);
|
|
43
|
+
await firestore.delete(`payments-subscriptions/${state.subscriptionId}`);
|
|
44
|
+
|
|
45
|
+
// Write mock webhook doc — this triggers the onWrite handler
|
|
46
|
+
await firestore.set(`payments-webhooks/${state.eventId}`, {
|
|
47
|
+
id: state.eventId,
|
|
48
|
+
processor: 'stripe',
|
|
49
|
+
status: 'pending',
|
|
50
|
+
uid: state.uid,
|
|
51
|
+
event: {
|
|
52
|
+
type: 'customer.subscription.created',
|
|
53
|
+
},
|
|
54
|
+
raw: {
|
|
55
|
+
id: state.eventId,
|
|
56
|
+
type: 'customer.subscription.created',
|
|
57
|
+
data: {
|
|
58
|
+
object: {
|
|
59
|
+
id: state.subscriptionId,
|
|
60
|
+
object: 'subscription',
|
|
61
|
+
status: 'active',
|
|
62
|
+
metadata: { uid: state.uid },
|
|
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),
|
|
66
|
+
cancel_at_period_end: false,
|
|
67
|
+
canceled_at: null,
|
|
68
|
+
trial_start: null,
|
|
69
|
+
trial_end: null,
|
|
70
|
+
plan: {
|
|
71
|
+
id: 'price_xxx', // Matches config products[0].prices.monthly.stripe
|
|
72
|
+
interval: 'month',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
error: null,
|
|
78
|
+
metadata: {
|
|
79
|
+
received: { timestamp: now, timestampUNIX: nowUNIX },
|
|
80
|
+
processed: { timestamp: null, timestampUNIX: null },
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
name: 'webhook-processed-successfully',
|
|
88
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
89
|
+
// Wait for the trigger to process the webhook
|
|
90
|
+
await waitFor(async () => {
|
|
91
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
92
|
+
return doc?.status === 'completed';
|
|
93
|
+
}, 15000, 500);
|
|
94
|
+
|
|
95
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
96
|
+
assert.equal(webhookDoc.status, 'completed', 'Webhook should be completed');
|
|
97
|
+
assert.ok(!webhookDoc.error, 'Webhook should not have error');
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
name: 'user-subscription-updated',
|
|
103
|
+
async run({ firestore, assert, state }) {
|
|
104
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
105
|
+
|
|
106
|
+
assert.equal(userDoc.subscription.product.id, 'premium', 'Product should be premium');
|
|
107
|
+
assert.equal(userDoc.subscription.status, 'active', 'Status should be active');
|
|
108
|
+
assert.equal(userDoc.subscription.payment.processor, 'stripe', 'Processor should be stripe');
|
|
109
|
+
assert.equal(userDoc.subscription.payment.resourceId, state.subscriptionId, 'Resource ID should match');
|
|
110
|
+
assert.equal(userDoc.subscription.payment.frequency, 'monthly', 'Frequency should be monthly');
|
|
111
|
+
assert.equal(userDoc.subscription.cancellation.pending, false, 'Should not be pending cancellation');
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
name: 'subscription-doc-created',
|
|
117
|
+
async run({ firestore, assert, state }) {
|
|
118
|
+
const subDoc = await firestore.get(`payments-subscriptions/${state.subscriptionId}`);
|
|
119
|
+
|
|
120
|
+
assert.ok(subDoc, 'Subscription doc should exist');
|
|
121
|
+
assert.equal(subDoc.uid, state.uid, 'UID should match');
|
|
122
|
+
assert.equal(subDoc.processor, 'stripe', 'Processor should be stripe');
|
|
123
|
+
assert.equal(subDoc.subscription.product.id, 'premium', 'Product should be premium');
|
|
124
|
+
assert.equal(subDoc.subscription.status, 'active', 'Status should be active');
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
name: 'cleanup',
|
|
130
|
+
async run({ firestore, state }) {
|
|
131
|
+
await firestore.delete(`payments-webhooks/${state.eventId}`);
|
|
132
|
+
await firestore.delete(`payments-subscriptions/${state.subscriptionId}`);
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: POST /payments/webhook
|
|
3
|
+
* Tests the webhook endpoint validates requests and saves to Firestore
|
|
4
|
+
*/
|
|
5
|
+
const { TEST_ACCOUNTS } = require('../../src/test/test-accounts.js');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
description: 'Payment webhook endpoint',
|
|
9
|
+
type: 'group',
|
|
10
|
+
timeout: 30000,
|
|
11
|
+
|
|
12
|
+
tests: [
|
|
13
|
+
{
|
|
14
|
+
name: 'rejects-missing-processor',
|
|
15
|
+
auth: 'none',
|
|
16
|
+
async run({ http, assert }) {
|
|
17
|
+
const response = await http.as('none').post('payments/webhook', {});
|
|
18
|
+
|
|
19
|
+
assert.isError(response, 400, 'Should reject missing processor');
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
name: 'rejects-invalid-key',
|
|
25
|
+
auth: 'none',
|
|
26
|
+
async run({ http, assert }) {
|
|
27
|
+
const response = await http.as('none').post('payments/webhook?processor=stripe&key=wrong-key', {});
|
|
28
|
+
|
|
29
|
+
assert.isError(response, 401, 'Should reject invalid key');
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
name: 'rejects-unknown-processor',
|
|
35
|
+
auth: 'none',
|
|
36
|
+
async run({ http, assert }) {
|
|
37
|
+
const response = await http.as('none').post(`payments/webhook?processor=unknown&key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
38
|
+
id: 'evt_test_unknown',
|
|
39
|
+
type: 'test.event',
|
|
40
|
+
data: { object: {} },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.isError(response, 400, 'Should reject unknown processor');
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
name: 'accepts-valid-stripe-webhook',
|
|
49
|
+
auth: 'none',
|
|
50
|
+
async run({ http, assert, firestore }) {
|
|
51
|
+
const eventId = '_test-evt-valid-webhook';
|
|
52
|
+
|
|
53
|
+
// Clean up any existing doc
|
|
54
|
+
await firestore.delete(`payments-webhooks/${eventId}`);
|
|
55
|
+
|
|
56
|
+
const response = await http.as('none').post(`payments/webhook?processor=stripe&key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
57
|
+
id: eventId,
|
|
58
|
+
type: 'customer.subscription.updated',
|
|
59
|
+
data: {
|
|
60
|
+
object: {
|
|
61
|
+
id: 'sub_test_valid',
|
|
62
|
+
metadata: { uid: TEST_ACCOUNTS.basic.uid },
|
|
63
|
+
status: 'active',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
assert.isSuccess(response, 'Should accept valid webhook');
|
|
69
|
+
assert.equal(response.data.received, true, 'Should confirm receipt');
|
|
70
|
+
|
|
71
|
+
// Verify doc was saved to Firestore
|
|
72
|
+
const doc = await firestore.get(`payments-webhooks/${eventId}`);
|
|
73
|
+
assert.ok(doc, 'Webhook doc should exist in Firestore');
|
|
74
|
+
assert.equal(doc.processor, 'stripe', 'Processor should be stripe');
|
|
75
|
+
assert.ok(
|
|
76
|
+
doc.status === 'pending' || doc.status === 'processing' || doc.status === 'completed',
|
|
77
|
+
'Status should be pending, processing, or completed',
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Clean up
|
|
81
|
+
await firestore.delete(`payments-webhooks/${eventId}`);
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
name: 'deduplicates-webhook-events',
|
|
87
|
+
auth: 'none',
|
|
88
|
+
async run({ http, assert, firestore }) {
|
|
89
|
+
const eventId = '_test-evt-duplicate';
|
|
90
|
+
|
|
91
|
+
// Clean up any existing doc
|
|
92
|
+
await firestore.delete(`payments-webhooks/${eventId}`);
|
|
93
|
+
|
|
94
|
+
// Send first webhook
|
|
95
|
+
await http.as('none').post(`payments/webhook?processor=stripe&key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
96
|
+
id: eventId,
|
|
97
|
+
type: 'customer.subscription.updated',
|
|
98
|
+
data: {
|
|
99
|
+
object: {
|
|
100
|
+
id: 'sub_test_dup',
|
|
101
|
+
metadata: { uid: TEST_ACCOUNTS.basic.uid },
|
|
102
|
+
status: 'active',
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Send duplicate
|
|
108
|
+
const response = await http.as('none').post(`payments/webhook?processor=stripe&key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
109
|
+
id: eventId,
|
|
110
|
+
type: 'customer.subscription.updated',
|
|
111
|
+
data: {
|
|
112
|
+
object: {
|
|
113
|
+
id: 'sub_test_dup',
|
|
114
|
+
metadata: { uid: TEST_ACCOUNTS.basic.uid },
|
|
115
|
+
status: 'active',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
assert.isSuccess(response, 'Duplicate should still return 200');
|
|
121
|
+
assert.equal(response.data.duplicate, true, 'Should indicate duplicate');
|
|
122
|
+
|
|
123
|
+
// Clean up
|
|
124
|
+
await firestore.delete(`payments-webhooks/${eventId}`);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
@@ -506,7 +506,7 @@ module.exports = {
|
|
|
506
506
|
const { user } = response.data;
|
|
507
507
|
|
|
508
508
|
assert.equal(user.authenticated, false, 'Should be unauthenticated');
|
|
509
|
-
assert.equal(user.
|
|
509
|
+
assert.equal(user.subscription, 'basic', 'Should default to basic subscription');
|
|
510
510
|
},
|
|
511
511
|
},
|
|
512
512
|
|
|
@@ -54,7 +54,7 @@ module.exports = {
|
|
|
54
54
|
name: 'remove-subscription-from-delete-user',
|
|
55
55
|
async run({ firestore, state }) {
|
|
56
56
|
// Remove the subscription (set to null to overwrite)
|
|
57
|
-
await firestore.set(`users/${state.deleteUid}`, {
|
|
57
|
+
await firestore.set(`users/${state.deleteUid}`, { subscription: null }, { merge: true });
|
|
58
58
|
},
|
|
59
59
|
},
|
|
60
60
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Test: GET /user/subscription
|
|
3
3
|
* Tests the user get subscription info endpoint
|
|
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.get('user/subscription', {});
|
|
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.get('user/subscription', {});
|
|
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.get('user/subscription', {});
|
|
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
|
|
package/test/routes/user/user.js
CHANGED
|
@@ -39,9 +39,9 @@ module.exports = {
|
|
|
39
39
|
assert.equal(user.auth.email, accounts.basic.email, 'Email should match test account');
|
|
40
40
|
assert.equal(user.authenticated, true, 'User should be authenticated');
|
|
41
41
|
|
|
42
|
-
// Verify
|
|
43
|
-
assert.equal(user.
|
|
44
|
-
assert.equal(user.
|
|
42
|
+
// Verify subscription properties - basic user should have basic subscription
|
|
43
|
+
assert.equal(user.subscription.product.id, 'basic', 'Subscription ID should be basic');
|
|
44
|
+
assert.equal(user.subscription.status, 'active', 'Subscription status should be active');
|
|
45
45
|
|
|
46
46
|
// Verify roles - basic user has no special roles
|
|
47
47
|
assert.equal(user.roles.admin, false, 'Basic user should not be admin');
|
|
@@ -74,8 +74,8 @@ module.exports = {
|
|
|
74
74
|
// Verify roles - admin account has roles.admin = true in Firestore
|
|
75
75
|
assert.equal(user.roles.admin, true, 'Admin account should have admin role');
|
|
76
76
|
|
|
77
|
-
// Verify
|
|
78
|
-
assert.equal(user.
|
|
77
|
+
// Verify subscription - admin account is on basic subscription
|
|
78
|
+
assert.equal(user.subscription.product.id, 'basic', 'Admin subscription ID should be basic');
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
81
|
|
|
@@ -101,7 +101,7 @@ module.exports = {
|
|
|
101
101
|
},
|
|
102
102
|
},
|
|
103
103
|
|
|
104
|
-
// Test 5: Premium active user - verify premium
|
|
104
|
+
// Test 5: Premium active user - verify premium subscription is retained
|
|
105
105
|
{
|
|
106
106
|
name: 'premium-active-user-resolved-correctly',
|
|
107
107
|
auth: 'premium-active',
|
|
@@ -118,18 +118,18 @@ module.exports = {
|
|
|
118
118
|
assert.equal(user.auth.uid, accounts['premium-active'].uid, 'UID should match premium test account');
|
|
119
119
|
assert.equal(user.auth.email, accounts['premium-active'].email, 'Email should match premium test account');
|
|
120
120
|
|
|
121
|
-
// Verify
|
|
122
|
-
assert.equal(user.
|
|
123
|
-
assert.equal(user.
|
|
121
|
+
// Verify subscription - premium user should retain premium subscription
|
|
122
|
+
assert.equal(user.subscription.product.id, 'premium', 'Subscription ID should be premium');
|
|
123
|
+
assert.equal(user.subscription.status, 'active', 'Subscription status should be active');
|
|
124
124
|
|
|
125
125
|
// Verify expires is in the future
|
|
126
|
-
const expiresTimestamp = user.
|
|
126
|
+
const expiresTimestamp = user.subscription.expires?.timestampUNIX || 0;
|
|
127
127
|
const now = Math.floor(Date.now() / 1000);
|
|
128
|
-
assert.ok(expiresTimestamp > now, 'Premium
|
|
128
|
+
assert.ok(expiresTimestamp > now, 'Premium subscription expires should be in the future');
|
|
129
129
|
},
|
|
130
130
|
},
|
|
131
131
|
|
|
132
|
-
// Test 6: Premium expired user - verify
|
|
132
|
+
// Test 6: Premium expired user - verify subscription is downgraded to basic
|
|
133
133
|
{
|
|
134
134
|
name: 'premium-expired-user-downgraded',
|
|
135
135
|
auth: 'premium-expired',
|
|
@@ -145,8 +145,8 @@ module.exports = {
|
|
|
145
145
|
// Verify auth properties
|
|
146
146
|
assert.equal(user.auth.uid, accounts['premium-expired'].uid, 'UID should match expired premium test account');
|
|
147
147
|
|
|
148
|
-
// Verify
|
|
149
|
-
assert.equal(user.
|
|
148
|
+
// Verify subscription - expired premium should be downgraded to basic by resolve-account
|
|
149
|
+
assert.equal(user.subscription.product.id, 'basic', 'Expired premium subscription should be downgraded to basic');
|
|
150
150
|
},
|
|
151
151
|
},
|
|
152
152
|
],
|
package/test/rules/user.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - Users can read their own document
|
|
7
7
|
* - Users can write to their own document (non-protected fields only)
|
|
8
8
|
* - Users cannot read/write other users' documents
|
|
9
|
-
* - Protected fields (auth, roles, flags,
|
|
9
|
+
* - Protected fields (auth, roles, flags, subscription, affiliate, api, usage) cannot be written by users
|
|
10
10
|
*
|
|
11
11
|
* @see templates/firestore.rules
|
|
12
12
|
*/
|
|
@@ -130,20 +130,20 @@ module.exports = {
|
|
|
130
130
|
},
|
|
131
131
|
},
|
|
132
132
|
|
|
133
|
-
// Test 7: User cannot write '
|
|
133
|
+
// Test 7: User cannot write 'subscription' field (protected)
|
|
134
134
|
{
|
|
135
|
-
name: 'user-cannot-write-
|
|
135
|
+
name: 'user-cannot-write-subscription-field',
|
|
136
136
|
auth: 'none',
|
|
137
137
|
|
|
138
138
|
async run({ rules, accounts }) {
|
|
139
139
|
const uid = accounts.basic.uid;
|
|
140
140
|
const db = rules.asAccount('basic');
|
|
141
141
|
|
|
142
|
-
// Should fail -
|
|
142
|
+
// Should fail - subscription is protected
|
|
143
143
|
await rules.expectFailure(
|
|
144
144
|
db.doc(`users/${uid}`).set({
|
|
145
|
-
|
|
146
|
-
id: 'premium',
|
|
145
|
+
subscription: {
|
|
146
|
+
product: { id: 'premium' },
|
|
147
147
|
status: 'active',
|
|
148
148
|
},
|
|
149
149
|
}, { merge: true })
|
|
@@ -317,7 +317,7 @@ module.exports = {
|
|
|
317
317
|
await rules.expectSuccess(
|
|
318
318
|
db.doc(`users/${basicUid}`).set({
|
|
319
319
|
roles: { premium: true },
|
|
320
|
-
|
|
320
|
+
subscription: { product: { id: 'pro' }, status: 'active' },
|
|
321
321
|
}, { merge: true })
|
|
322
322
|
);
|
|
323
323
|
},
|
|
@@ -337,7 +337,7 @@ module.exports = {
|
|
|
337
337
|
db.doc(`users/${newUid}`).set({
|
|
338
338
|
auth: { uid: newUid, email: 'new@test.com' },
|
|
339
339
|
roles: {},
|
|
340
|
-
|
|
340
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
341
341
|
})
|
|
342
342
|
);
|
|
343
343
|
},
|