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.
Files changed (50) hide show
  1. package/CLAUDE.md +70 -0
  2. package/README.md +81 -7
  3. package/package.json +1 -1
  4. package/src/manager/cron/daily/reset-usage.js +5 -32
  5. package/src/manager/events/firestore/payments-webhooks/on-write.js +126 -0
  6. package/src/manager/functions/core/actions/api/admin/get-stats.js +3 -3
  7. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +1 -1
  8. package/src/manager/functions/core/actions/api/user/delete.js +5 -3
  9. package/src/manager/functions/core/actions/api/user/get-subscription-info.js +25 -9
  10. package/src/manager/functions/core/actions/api/user/validate-settings.js +1 -1
  11. package/src/manager/helpers/analytics.js +4 -4
  12. package/src/manager/helpers/api-manager.js +25 -42
  13. package/src/manager/helpers/middleware.js +1 -1
  14. package/src/manager/helpers/usage.js +24 -93
  15. package/src/manager/helpers/user.js +29 -38
  16. package/src/manager/index.js +22 -10
  17. package/src/manager/libraries/stripe.js +293 -0
  18. package/src/manager/routes/admin/stats/get.js +3 -3
  19. package/src/manager/routes/marketing/contact/post.js +1 -1
  20. package/src/manager/routes/payments/intent/post.js +94 -0
  21. package/src/manager/routes/payments/intent/providers/stripe.js +66 -0
  22. package/src/manager/routes/payments/webhook/post.js +87 -0
  23. package/src/manager/routes/payments/webhook/providers/stripe.js +35 -0
  24. package/src/manager/routes/test/schema/post.js +5 -5
  25. package/src/manager/routes/user/delete.js +5 -3
  26. package/src/manager/routes/user/settings/validate/post.js +3 -3
  27. package/src/manager/routes/user/subscription/get.js +25 -9
  28. package/src/manager/schemas/payments/intent/post.js +22 -0
  29. package/src/manager/schemas/payments/webhook/post.js +6 -0
  30. package/src/manager/schemas/test/schema/post.js +1 -1
  31. package/src/test/test-accounts.js +63 -25
  32. package/src/test/utils/firestore-rules-client.js +5 -5
  33. package/templates/backend-manager-config.json +32 -0
  34. package/templates/firestore.rules +1 -1
  35. package/test/_init/accounts-validation.js +3 -3
  36. package/test/functions/user/delete.js +1 -1
  37. package/test/functions/user/get-subscription-info.js +18 -24
  38. package/test/payments/intent.js +104 -0
  39. package/test/payments/journey-payment-cancel.js +166 -0
  40. package/test/payments/journey-payment-suspend.js +162 -0
  41. package/test/payments/journey-payment-trial.js +167 -0
  42. package/test/payments/journey-payment-upgrade.js +136 -0
  43. package/test/payments/webhook.js +128 -0
  44. package/test/routes/test/schema.js +1 -1
  45. package/test/routes/user/delete.js +1 -1
  46. package/test/routes/user/subscription.js +18 -24
  47. package/test/routes/user/user.js +14 -14
  48. package/test/rules/user.js +8 -8
  49. package/src/manager/helpers/subscription-resolver-new.js +0 -827
  50. 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.plan, 'basic', 'Should default to basic plan');
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}`, { plan: null }, { merge: true });
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 plan details for authenticated users
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.plan', 'Response should contain plan object');
21
- assert.hasProperty(response, 'data.plan.id', 'Plan should have id');
22
- assert.hasProperty(response, 'data.plan.expires', 'Plan should have expires');
23
- assert.hasProperty(response, 'data.plan.trial', 'Plan should have trial info');
24
- assert.hasProperty(response, 'data.plan.payment', 'Plan should have payment info');
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: Plan has correct structure
28
+ // Test 2: Subscription has correct structure
29
29
  {
30
- name: 'plan-structure-valid',
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 plan = response.data.plan;
39
+ const subscription = response.data.subscription;
40
40
 
41
41
  // Check expires structure
42
- assert.hasProperty(response, 'data.plan.expires.timestamp', 'expires should have timestamp');
43
- assert.hasProperty(response, 'data.plan.expires.timestampUNIX', 'expires should have timestampUNIX');
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 plan.trial.activated === 'boolean',
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.ok(
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-plan',
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
- // The API returns plan.id from the user doc (test account has plan.id = 'premium')
72
- assert.hasProperty(response, 'data.plan.id', 'Premium user should have plan id');
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.plan.id', 'Should still have plan id');
88
- assert.hasProperty(response, 'data.plan.expires.timestampUNIX', 'Should have expires timestamp');
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
 
@@ -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 plan properties - basic user should have basic plan
43
- assert.equal(user.plan.id, 'basic', 'Plan ID should be basic');
44
- assert.equal(user.plan.status, 'active', 'Plan status should be active');
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 plan - admin account is on basic plan
78
- assert.equal(user.plan.id, 'basic', 'Admin plan ID should be basic');
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 plan is retained
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 plan - premium user should retain premium plan
122
- assert.equal(user.plan.id, 'premium', 'Plan ID should be premium');
123
- assert.equal(user.plan.status, 'active', 'Plan status should be active');
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.plan.expires?.timestampUNIX || 0;
126
+ const expiresTimestamp = user.subscription.expires?.timestampUNIX || 0;
127
127
  const now = Math.floor(Date.now() / 1000);
128
- assert.ok(expiresTimestamp > now, 'Premium plan expires should be in the future');
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 plan is downgraded to basic
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 plan - expired premium should be downgraded to basic by resolve-account
149
- assert.equal(user.plan.id, 'basic', 'Expired premium plan should be downgraded to basic');
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
  ],
@@ -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, plan, affiliate, api, usage) cannot be written by users
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 'plan' field (protected)
133
+ // Test 7: User cannot write 'subscription' field (protected)
134
134
  {
135
- name: 'user-cannot-write-plan-field',
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 - plan is protected
142
+ // Should fail - subscription is protected
143
143
  await rules.expectFailure(
144
144
  db.doc(`users/${uid}`).set({
145
- plan: {
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
- plan: { id: 'pro', status: 'active' },
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
- plan: { id: 'basic', status: 'active' },
340
+ subscription: { product: { id: 'basic' }, status: 'active' },
341
341
  })
342
342
  );
343
343
  },