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,94 @@
1
+ const path = require('path');
2
+ const powertools = require('node-powertools');
3
+
4
+ /**
5
+ * POST /payments/intent
6
+ * Creates a payment intent (e.g., Stripe Checkout Session) for subscription purchase
7
+ * Requires authentication
8
+ */
9
+ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
10
+ const { admin } = libraries;
11
+
12
+ // Require authentication
13
+ if (!user.authenticated) {
14
+ return assistant.respond('Authentication required', { code: 401 });
15
+ }
16
+
17
+ const uid = user.auth.uid;
18
+ const processor = settings.processor;
19
+ const productId = settings.productId;
20
+ const frequency = settings.frequency;
21
+ const trial = settings.trial;
22
+
23
+ // Check if user already has an active non-basic subscription
24
+ if (user.subscription?.status === 'active' && user.subscription?.product?.id !== 'basic') {
25
+ return assistant.respond('User already has an active subscription', { code: 400 });
26
+ }
27
+
28
+ // Check trial eligibility
29
+ if (trial && user.subscription?.trial?.activated) {
30
+ return assistant.respond('Trial already used', { code: 400 });
31
+ }
32
+
33
+ // Validate product exists in config
34
+ const product = (Manager.config.products || []).find(p => p.id === productId);
35
+ if (!product) {
36
+ return assistant.respond(`Product '${productId}' not found`, { code: 400 });
37
+ }
38
+
39
+ // Load the provider
40
+ let provider;
41
+ try {
42
+ provider = require(path.resolve(__dirname, `providers/${processor}.js`));
43
+ } catch (e) {
44
+ return assistant.respond(`Unknown processor: ${processor}`, { code: 400 });
45
+ }
46
+
47
+ // Initialize the processor SDK
48
+ const StripeLib = require('../../../libraries/stripe.js');
49
+ const stripe = StripeLib.init();
50
+
51
+ // Create the intent via the provider
52
+ let result;
53
+ try {
54
+ result = await provider.createIntent({
55
+ uid,
56
+ productId,
57
+ frequency,
58
+ trial,
59
+ config: Manager.config,
60
+ stripe,
61
+ });
62
+ } catch (e) {
63
+ return assistant.respond(`Failed to create intent: ${e.message}`, { code: 500, sentry: true });
64
+ }
65
+
66
+ // Build timestamps
67
+ const now = powertools.timestamp(new Date(), { output: 'string' });
68
+ const nowUNIX = powertools.timestamp(now, { output: 'unix' });
69
+
70
+ // Save to payments-intents collection
71
+ await admin.firestore().doc(`payments-intents/${result.id}`).set({
72
+ id: result.id,
73
+ processor: processor,
74
+ uid: uid,
75
+ status: 'pending',
76
+ productId: productId,
77
+ frequency: frequency,
78
+ trial: trial,
79
+ raw: result.raw,
80
+ metadata: {
81
+ created: {
82
+ timestamp: now,
83
+ timestampUNIX: nowUNIX,
84
+ },
85
+ },
86
+ });
87
+
88
+ assistant.log(`Intent ${result.id} created for user ${uid} (product=${productId}, frequency=${frequency}, trial=${trial})`);
89
+
90
+ return assistant.respond({
91
+ id: result.id,
92
+ url: result.url,
93
+ });
94
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Stripe intent provider
3
+ * Creates Stripe Checkout Sessions for subscription purchases
4
+ */
5
+ module.exports = {
6
+ /**
7
+ * Create a Stripe Checkout Session for a subscription
8
+ *
9
+ * @param {object} options
10
+ * @param {string} options.uid - User's UID
11
+ * @param {string} options.productId - Product ID from config (e.g., 'premium')
12
+ * @param {string} options.frequency - 'monthly' or 'annually'
13
+ * @param {boolean} options.trial - Whether to include a trial period
14
+ * @param {object} options.config - BEM config (must contain products array)
15
+ * @param {object} options.stripe - Initialized Stripe SDK instance
16
+ * @returns {object} { id, url, raw }
17
+ */
18
+ async createIntent({ uid, productId, frequency, trial, config, stripe }) {
19
+ // Find the product in config
20
+ const product = (config.products || []).find(p => p.id === productId);
21
+ if (!product) {
22
+ throw new Error(`Product '${productId}' not found in config`);
23
+ }
24
+
25
+ // Get the Stripe price ID for the requested frequency
26
+ const priceId = product.prices?.[frequency]?.stripe;
27
+ if (!priceId) {
28
+ throw new Error(`No Stripe price found for ${productId}/${frequency}`);
29
+ }
30
+
31
+ // Build session params
32
+ const sessionParams = {
33
+ mode: 'subscription',
34
+ line_items: [{
35
+ price: priceId,
36
+ quantity: 1,
37
+ }],
38
+ subscription_data: {
39
+ metadata: {
40
+ uid: uid,
41
+ },
42
+ },
43
+ success_url: `${config.brand?.url || 'https://example.com'}/account/?payment=success`,
44
+ cancel_url: `${config.brand?.url || 'https://example.com'}/account/?payment=cancelled`,
45
+ metadata: {
46
+ uid: uid,
47
+ productId: productId,
48
+ frequency: frequency,
49
+ },
50
+ };
51
+
52
+ // Add trial period if requested
53
+ if (trial && product.trial?.days) {
54
+ sessionParams.subscription_data.trial_period_days = product.trial.days;
55
+ }
56
+
57
+ // Create the checkout session
58
+ const session = await stripe.checkout.sessions.create(sessionParams);
59
+
60
+ return {
61
+ id: session.id,
62
+ url: session.url,
63
+ raw: session,
64
+ };
65
+ },
66
+ };
@@ -0,0 +1,87 @@
1
+ const path = require('path');
2
+ const powertools = require('node-powertools');
3
+
4
+ /**
5
+ * POST /payments/webhook?processor=stripe&key=XXX
6
+ * Receives payment provider webhooks, validates them, and saves to Firestore
7
+ * The Firestore onWrite trigger handles async processing
8
+ */
9
+ module.exports = async ({ assistant, Manager, libraries }) => {
10
+ const { admin } = libraries;
11
+ const data = assistant.request.data;
12
+ const query = assistant.request.query;
13
+
14
+ // Get processor and key from query params
15
+ const processor = query.processor;
16
+ const key = query.key;
17
+
18
+ // Validate processor
19
+ if (!processor) {
20
+ return assistant.respond('Missing processor parameter', { code: 400 });
21
+ }
22
+
23
+ // Validate key against BACKEND_MANAGER_KEY
24
+ if (!key || key !== process.env.BACKEND_MANAGER_KEY) {
25
+ return assistant.respond('Invalid key', { code: 401 });
26
+ }
27
+
28
+ // Load the provider
29
+ let provider;
30
+ try {
31
+ provider = require(path.resolve(__dirname, `providers/${processor}.js`));
32
+ } catch (e) {
33
+ return assistant.respond(`Unknown processor: ${processor}`, { code: 400 });
34
+ }
35
+
36
+ // Parse the webhook using the provider
37
+ let parsed;
38
+ try {
39
+ parsed = provider.parseWebhook(assistant.ref.req);
40
+ } catch (e) {
41
+ return assistant.respond(`Failed to parse webhook: ${e.message}`, { code: 400 });
42
+ }
43
+
44
+ const { eventId, eventType, raw, uid } = parsed;
45
+
46
+ // Check for duplicate (skip if already processing/completed)
47
+ const existingDoc = await admin.firestore().doc(`payments-webhooks/${eventId}`).get();
48
+ if (existingDoc.exists) {
49
+ const existingStatus = existingDoc.data()?.status;
50
+ if (existingStatus !== 'failed') {
51
+ assistant.log(`Webhook ${eventId} already exists with status=${existingStatus}, skipping`);
52
+ return assistant.respond({ received: true, duplicate: true });
53
+ }
54
+ }
55
+
56
+ // Build timestamps
57
+ const now = powertools.timestamp(new Date(), { output: 'string' });
58
+ const nowUNIX = powertools.timestamp(now, { output: 'unix' });
59
+
60
+ // Save to Firestore with status=pending (trigger handles the rest)
61
+ await admin.firestore().doc(`payments-webhooks/${eventId}`).set({
62
+ id: eventId,
63
+ processor: processor,
64
+ status: 'pending',
65
+ raw: raw,
66
+ uid: uid,
67
+ event: {
68
+ type: eventType,
69
+ },
70
+ error: null,
71
+ metadata: {
72
+ received: {
73
+ timestamp: now,
74
+ timestampUNIX: nowUNIX,
75
+ },
76
+ processed: {
77
+ timestamp: null,
78
+ timestampUNIX: null,
79
+ },
80
+ },
81
+ });
82
+
83
+ assistant.log(`Webhook ${eventId} saved (type=${eventType}, processor=${processor}, uid=${uid})`);
84
+
85
+ // Return 200 immediately
86
+ return assistant.respond({ received: true });
87
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Stripe webhook provider
3
+ * Extracts and validates webhook event data from Stripe
4
+ */
5
+ module.exports = {
6
+ /**
7
+ * Parse a Stripe webhook request
8
+ * Extracts the event data, event type, and resolves the UID from metadata
9
+ *
10
+ * @param {object} req - The raw HTTP request
11
+ * @returns {object} { eventId, eventType, raw, uid }
12
+ */
13
+ parseWebhook(req) {
14
+ const event = req.body;
15
+
16
+ // Validate event structure
17
+ if (!event || !event.id || !event.type) {
18
+ throw new Error('Invalid Stripe webhook payload');
19
+ }
20
+
21
+ // The subscription object is typically in event.data.object
22
+ const dataObject = event.data?.object || {};
23
+
24
+ // Resolve UID from subscription metadata
25
+ // When creating checkout sessions, we set metadata.uid on the subscription
26
+ const uid = dataObject.metadata?.uid || null;
27
+
28
+ return {
29
+ eventId: event.id,
30
+ eventType: event.type,
31
+ raw: event,
32
+ uid: uid,
33
+ };
34
+ },
35
+ };
@@ -3,10 +3,10 @@
3
3
  * Returns the resolved settings for testing purposes
4
4
  */
5
5
  module.exports = async ({ assistant, user, settings }) => {
6
- assistant.log('test/schema: User plan info', {
7
- planId: user.plan?.id,
8
- planStatus: user.plan?.status,
9
- fullPlan: user.plan,
6
+ assistant.log('test/schema: User subscription info', {
7
+ subscriptionId: user.subscription?.product?.id,
8
+ subscriptionStatus: user.subscription?.status,
9
+ fullSubscription: user.subscription,
10
10
  });
11
11
 
12
12
  return assistant.respond({
@@ -14,7 +14,7 @@ module.exports = async ({ assistant, user, settings }) => {
14
14
  user: {
15
15
  authenticated: user.authenticated,
16
16
  uid: user.auth?.uid || null,
17
- plan: user.plan?.id || 'basic',
17
+ subscription: user.subscription?.product?.id || 'basic',
18
18
  },
19
19
  });
20
20
  };
@@ -29,10 +29,12 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
29
29
 
30
30
  const userData = userDoc.data();
31
31
 
32
- // Disallow deleting users with active subscriptions
32
+ // Disallow deleting users with active or suspended paid subscriptions
33
+ const subStatus = userData?.subscription?.status;
34
+ const subId = userData?.subscription?.product?.id;
33
35
  if (
34
- (userData?.plan?.status && userData?.plan?.status !== 'cancelled')
35
- || userData?.plan?.payment?.active
36
+ (subStatus === 'active' || subStatus === 'suspended')
37
+ && subId !== 'basic'
36
38
  ) {
37
39
  return assistant.respond(
38
40
  'This account cannot be deleted because it has a paid subscription attached to it. In order to delete the account, you must first cancel the paid subscription.',
@@ -5,7 +5,7 @@ const path = require('path');
5
5
 
6
6
  /**
7
7
  * POST /user/settings/validate - Validate user settings against defaults
8
- * Merges user settings with plan-specific defaults from defaults.js
8
+ * Merges user settings with subscription-specific defaults from defaults.js
9
9
  */
10
10
  module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
11
11
  const { admin } = libraries;
@@ -23,7 +23,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
23
23
  return assistant.respond('Admin required', { code: 403 });
24
24
  }
25
25
 
26
- // Get user data for plan
26
+ // Get user data for subscription
27
27
  let userData = user;
28
28
 
29
29
  if (uid !== user.auth.uid) {
@@ -50,7 +50,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
50
50
  // Load and process defaults
51
51
  try {
52
52
  const defaults = _.get(require(resolvedPath)(), settings.defaultsPath);
53
- const combined = combineDefaults(defaults.all, defaults[userData.plan?.id] || {});
53
+ const combined = combineDefaults(defaults.all, defaults[userData.subscription?.product?.id] || {});
54
54
 
55
55
  assistant.log('Combined settings', combined);
56
56
 
@@ -2,7 +2,7 @@ const powertools = require('node-powertools');
2
2
 
3
3
  /**
4
4
  * GET /user/subscription - Get user subscription info
5
- * Returns plan, expiry, trial, and payment status
5
+ * Returns subscription, expiry, trial, and payment status
6
6
  */
7
7
  module.exports = async ({ assistant, user, settings, libraries }) => {
8
8
  const { admin } = libraries;
@@ -38,21 +38,37 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
38
38
  const oldDateUNIX = powertools.timestamp(oldDate, { output: 'unix' });
39
39
 
40
40
  const result = {
41
- plan: {
42
- id: userData?.plan?.id || 'unknown',
41
+ subscription: {
42
+ product: {
43
+ id: userData?.subscription?.product?.id || 'basic',
44
+ name: userData?.subscription?.product?.name || 'Basic',
45
+ },
46
+ status: userData?.subscription?.status || 'active',
43
47
  expires: {
44
- timestamp: userData?.plan?.expires?.timestamp || oldDate,
45
- timestampUNIX: userData?.plan?.expires?.timestampUNIX || oldDateUNIX,
48
+ timestamp: userData?.subscription?.expires?.timestamp || oldDate,
49
+ timestampUNIX: userData?.subscription?.expires?.timestampUNIX || oldDateUNIX,
46
50
  },
47
51
  trial: {
48
- activated: userData?.plan?.trial?.activated ?? false,
52
+ activated: userData?.subscription?.trial?.activated ?? false,
53
+ expires: {
54
+ timestamp: userData?.subscription?.trial?.expires?.timestamp || oldDate,
55
+ timestampUNIX: userData?.subscription?.trial?.expires?.timestampUNIX || oldDateUNIX,
56
+ },
57
+ },
58
+ cancellation: {
59
+ pending: userData?.subscription?.cancellation?.pending ?? false,
49
60
  date: {
50
- timestamp: userData?.plan?.trial?.date?.timestamp || oldDate,
51
- timestampUNIX: userData?.plan?.trial?.date?.timestampUNIX || oldDateUNIX,
61
+ timestamp: userData?.subscription?.cancellation?.date?.timestamp || oldDate,
62
+ timestampUNIX: userData?.subscription?.cancellation?.date?.timestampUNIX || oldDateUNIX,
52
63
  },
53
64
  },
54
65
  payment: {
55
- active: userData?.plan?.payment?.active ?? false,
66
+ processor: userData?.subscription?.payment?.processor || null,
67
+ frequency: userData?.subscription?.payment?.frequency || null,
68
+ startDate: {
69
+ timestamp: userData?.subscription?.payment?.startDate?.timestamp || oldDate,
70
+ timestampUNIX: userData?.subscription?.payment?.startDate?.timestampUNIX || oldDateUNIX,
71
+ },
56
72
  },
57
73
  },
58
74
  };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Schema: POST /payments/intent
3
+ * Validates intent creation parameters
4
+ */
5
+ module.exports = () => ({
6
+ processor: {
7
+ types: ['string'],
8
+ required: true,
9
+ },
10
+ productId: {
11
+ types: ['string'],
12
+ required: true,
13
+ },
14
+ frequency: {
15
+ types: ['string'],
16
+ required: true,
17
+ },
18
+ trial: {
19
+ types: ['boolean'],
20
+ default: false,
21
+ },
22
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Schema: POST /payments/webhook
3
+ * Minimal schema - webhook payloads are validated by the provider, not the schema
4
+ * The processor and key come from query params, not the body
5
+ */
6
+ module.exports = () => ({});
@@ -3,7 +3,7 @@
3
3
  * Tests: types, default (static + function), value, min, max, required, clean
4
4
  */
5
5
  module.exports = ({ user }) => {
6
- const planId = user?.plan?.id || 'basic';
6
+ const planId = user?.subscription?.product?.id || 'basic';
7
7
  const isPremium = planId !== 'basic';
8
8
 
9
9
  const schema = {
@@ -1,9 +1,9 @@
1
1
  const uuid = require('uuid');
2
2
 
3
3
  /**
4
- * Helper to create a future expiration date for premium plans
5
- * resolve-account checks plan.expires to determine if plan is active
6
- * If expires is in the past (or default 1970), plan gets downgraded to basic
4
+ * Helper to create a future expiration date for premium subscriptions
5
+ * resolve-account checks subscription.expires to determine if subscription is active
6
+ * If expires is in the past (or default 1970), subscription gets downgraded to basic
7
7
  */
8
8
  function getFutureExpires(years = 10) {
9
9
  const futureDate = new Date();
@@ -15,7 +15,7 @@ function getFutureExpires(years = 10) {
15
15
  }
16
16
 
17
17
  /**
18
- * Helper to create a past expiration date for expired plans
18
+ * Helper to create a past expiration date for expired subscriptions
19
19
  */
20
20
  function getPastExpires(years = 1) {
21
21
  const pastDate = new Date();
@@ -37,7 +37,7 @@ function getPastExpires(years = 1) {
37
37
  * - email: Email with {domain} placeholder (resolved at runtime)
38
38
  * - properties: Object to merge into user doc after auth:on-create
39
39
  *
40
- * IMPORTANT: Premium accounts MUST have plan.expires set to a future date
40
+ * IMPORTANT: Premium accounts MUST have subscription.expires set to a future date
41
41
  * or resolve-account will downgrade them to basic
42
42
  */
43
43
  const STATIC_ACCOUNTS = {
@@ -47,7 +47,7 @@ const STATIC_ACCOUNTS = {
47
47
  email: '_test.admin@{domain}',
48
48
  properties: {
49
49
  roles: { admin: true },
50
- plan: { id: 'basic', status: 'active' },
50
+ subscription: { product: { id: 'basic' }, status: 'active' },
51
51
  },
52
52
  },
53
53
  basic: {
@@ -56,7 +56,7 @@ const STATIC_ACCOUNTS = {
56
56
  email: '_test.basic@{domain}',
57
57
  properties: {
58
58
  roles: {},
59
- plan: { id: 'basic', status: 'active' },
59
+ subscription: { product: { id: 'basic' }, status: 'active' },
60
60
  },
61
61
  },
62
62
  'premium-active': {
@@ -65,7 +65,7 @@ const STATIC_ACCOUNTS = {
65
65
  email: '_test.premium-active@{domain}',
66
66
  properties: {
67
67
  roles: {},
68
- plan: { id: 'premium', status: 'active', expires: getFutureExpires() },
68
+ subscription: { product: { id: 'premium' }, status: 'active', expires: getFutureExpires() },
69
69
  },
70
70
  },
71
71
  'premium-expired': {
@@ -74,7 +74,7 @@ const STATIC_ACCOUNTS = {
74
74
  email: '_test.premium-expired@{domain}',
75
75
  properties: {
76
76
  roles: {},
77
- plan: { id: 'premium', status: 'cancelled', expires: getPastExpires() },
77
+ subscription: { product: { id: 'premium' }, status: 'cancelled', expires: getPastExpires() },
78
78
  },
79
79
  },
80
80
  delete: {
@@ -83,7 +83,7 @@ const STATIC_ACCOUNTS = {
83
83
  email: '_test.delete@{domain}',
84
84
  properties: {
85
85
  roles: {},
86
- plan: { id: 'premium', status: 'active', expires: getFutureExpires() }, // Active subscription - deletion should be blocked initially
86
+ subscription: { product: { id: 'premium' }, status: 'active', expires: getFutureExpires() }, // Active subscription - deletion should be blocked initially
87
87
  },
88
88
  },
89
89
  'delete-by-admin': {
@@ -92,7 +92,7 @@ const STATIC_ACCOUNTS = {
92
92
  email: '_test.delete-by-admin@{domain}',
93
93
  properties: {
94
94
  roles: {},
95
- // No plan - can be deleted immediately by admin
95
+ // No subscription - can be deleted immediately by admin
96
96
  },
97
97
  },
98
98
  referrer: {
@@ -101,7 +101,7 @@ const STATIC_ACCOUNTS = {
101
101
  email: '_test.referrer@{domain}',
102
102
  properties: {
103
103
  roles: {},
104
- plan: { id: 'basic', status: 'active' },
104
+ subscription: { product: { id: 'basic' }, status: 'active' },
105
105
  affiliate: { code: 'TESTREF', referrals: [] },
106
106
  },
107
107
  },
@@ -111,7 +111,7 @@ const STATIC_ACCOUNTS = {
111
111
  email: '_test.referred@{domain}',
112
112
  properties: {
113
113
  roles: {},
114
- plan: { id: 'basic', status: 'active' },
114
+ subscription: { product: { id: 'basic' }, status: 'active' },
115
115
  },
116
116
  },
117
117
  'referred-invalid': {
@@ -120,7 +120,7 @@ const STATIC_ACCOUNTS = {
120
120
  email: '_test.referred-invalid@{domain}',
121
121
  properties: {
122
122
  roles: {},
123
- plan: { id: 'basic', status: 'active' },
123
+ subscription: { product: { id: 'basic' }, status: 'active' },
124
124
  },
125
125
  },
126
126
  };
@@ -130,22 +130,40 @@ const STATIC_ACCOUNTS = {
130
130
  * These accounts transition through states via webhook tests
131
131
  */
132
132
  const JOURNEY_ACCOUNTS = {
133
- 'journey-upgrade': {
134
- id: 'journey-upgrade',
135
- uid: '_test-journey-upgrade',
136
- email: '_test.journey-upgrade@{domain}',
133
+ 'journey-payment-upgrade': {
134
+ id: 'journey-payment-upgrade',
135
+ uid: '_test-journey-payment-upgrade',
136
+ email: '_test.journey-payment-upgrade@{domain}',
137
137
  properties: {
138
138
  roles: {},
139
- plan: { id: 'basic', status: 'active' }, // Starts as basic, upgraded via Stripe webhook
139
+ subscription: { product: { id: 'basic' }, status: 'active' }, // Starts as basic, upgraded via Stripe webhook
140
140
  },
141
141
  },
142
- 'journey-cancel': {
143
- id: 'journey-cancel',
144
- uid: '_test-journey-cancel',
145
- email: '_test.journey-cancel@{domain}',
142
+ 'journey-payment-cancel': {
143
+ id: 'journey-payment-cancel',
144
+ uid: '_test-journey-payment-cancel',
145
+ email: '_test.journey-payment-cancel@{domain}',
146
146
  properties: {
147
147
  roles: {},
148
- plan: { id: 'premium', status: 'active', expires: getFutureExpires() }, // Starts as premium, cancelled via Stripe webhook
148
+ subscription: { product: { id: 'premium' }, status: 'active', expires: getFutureExpires() }, // Starts as premium, cancelled via Stripe webhook
149
+ },
150
+ },
151
+ 'journey-payment-suspend': {
152
+ id: 'journey-payment-suspend',
153
+ uid: '_test-journey-payment-suspend',
154
+ email: '_test.journey-payment-suspend@{domain}',
155
+ properties: {
156
+ roles: {},
157
+ subscription: { product: { id: 'premium' }, status: 'active', expires: getFutureExpires() }, // Starts as premium, suspended via failed payment webhook
158
+ },
159
+ },
160
+ 'journey-payment-trial': {
161
+ id: 'journey-payment-trial',
162
+ uid: '_test-journey-payment-trial',
163
+ email: '_test.journey-payment-trial@{domain}',
164
+ properties: {
165
+ roles: {},
166
+ subscription: { product: { id: 'basic' }, status: 'active' }, // Starts as basic, upgraded via trial webhook
149
167
  },
150
168
  },
151
169
  };
@@ -275,7 +293,7 @@ async function createAccount(admin, account) {
275
293
  waited += pollInterval;
276
294
  }
277
295
 
278
- // Merge test-specific properties (roles, plan, etc.)
296
+ // Merge test-specific properties (roles, subscription, etc.)
279
297
  await userRef.set(account.properties, { merge: true });
280
298
 
281
299
  return { uid: account.uid, email: account.email };
@@ -334,6 +352,26 @@ async function deleteTestUsers(admin) {
334
352
  })
335
353
  );
336
354
 
355
+ // Clean up payment-related collections for test accounts
356
+ const testUids = Object.values(TEST_ACCOUNTS).map(a => a.uid);
357
+ const paymentCollections = ['payments-subscriptions', 'payments-webhooks', 'payments-intents'];
358
+
359
+ await Promise.all(
360
+ paymentCollections.map(async (collection) => {
361
+ try {
362
+ const snapshot = await admin.firestore().collection(collection)
363
+ .where('uid', 'in', testUids)
364
+ .get();
365
+
366
+ await Promise.all(
367
+ snapshot.docs.map(doc => doc.ref.delete())
368
+ );
369
+ } catch (e) {
370
+ // Collection may not exist yet — ignore
371
+ }
372
+ })
373
+ );
374
+
337
375
  return {
338
376
  success: results.failed.length === 0,
339
377
  deleted: results.deleted.length,
@@ -162,7 +162,7 @@ async function seedTestAccounts(accounts) {
162
162
  throw new Error('Test environment not initialized. Call initRulesTestEnv() first.');
163
163
  }
164
164
 
165
- // Get static account definitions for roles/plan data
165
+ // Get static account definitions for roles/subscription data
166
166
  const { TEST_ACCOUNTS } = require('../test-accounts.js');
167
167
 
168
168
  // Use withSecurityRulesDisabled to write test data
@@ -174,7 +174,7 @@ async function seedTestAccounts(accounts) {
174
174
  continue;
175
175
  }
176
176
 
177
- // Get the static definition for this account type (has roles, plan)
177
+ // Get the static definition for this account type (has roles, subscription)
178
178
  const staticDef = TEST_ACCOUNTS[accountType];
179
179
 
180
180
  // Build user document with roles for isAdmin() check
@@ -186,9 +186,9 @@ async function seedTestAccounts(accounts) {
186
186
  roles: staticDef?.properties?.roles || {},
187
187
  };
188
188
 
189
- // Add plan if present in static definition
190
- if (staticDef?.properties?.plan) {
191
- userData.plan = staticDef.properties.plan;
189
+ // Add subscription if present in static definition
190
+ if (staticDef?.properties?.subscription) {
191
+ userData.subscription = staticDef.properties.subscription;
192
192
  }
193
193
 
194
194
  await db.doc(`users/${account.uid}`).set(userData, { merge: true });