backend-manager 5.0.102 → 5.0.104

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 (38) hide show
  1. package/CLAUDE.md +59 -0
  2. package/README.md +10 -0
  3. package/TODO-MARKETING.md +53 -0
  4. package/package.json +1 -1
  5. package/src/cli/commands/auth.js +226 -0
  6. package/src/cli/commands/firebase-init.js +121 -0
  7. package/src/cli/commands/firestore.js +261 -0
  8. package/src/cli/commands/index.js +2 -0
  9. package/src/cli/index.js +16 -0
  10. package/src/manager/libraries/payment-processors/stripe.js +37 -0
  11. package/src/manager/routes/payments/cancel/post.js +66 -0
  12. package/src/manager/routes/payments/cancel/processors/stripe.js +22 -0
  13. package/src/manager/routes/payments/cancel/processors/test.js +93 -0
  14. package/src/manager/routes/payments/intent/processors/stripe.js +1 -30
  15. package/src/manager/routes/payments/portal/post.js +54 -0
  16. package/src/manager/routes/payments/portal/processors/stripe.js +62 -0
  17. package/src/manager/routes/payments/portal/processors/test.js +18 -0
  18. package/src/manager/routes/user/delete.js +2 -1
  19. package/src/manager/schemas/payments/cancel/post.js +18 -0
  20. package/src/manager/schemas/payments/portal/post.js +10 -0
  21. package/src/test/runner.js +18 -1
  22. package/src/test/test-accounts.js +92 -0
  23. package/templates/firestore.rules +1 -0
  24. package/test/events/payments/journey-payments-cancel-endpoint.js +79 -0
  25. package/test/helpers/stripe-to-unified-one-time.js +304 -0
  26. package/test/routes/payments/cancel.js +124 -0
  27. package/test/routes/payments/intent.js +7 -14
  28. package/test/routes/payments/portal.js +89 -0
  29. package/src/manager/routes/forms/delete.js +0 -37
  30. package/src/manager/routes/forms/get.js +0 -46
  31. package/src/manager/routes/forms/post.js +0 -45
  32. package/src/manager/routes/forms/public/get.js +0 -37
  33. package/src/manager/routes/forms/put.js +0 -52
  34. package/src/manager/schemas/forms/delete.js +0 -6
  35. package/src/manager/schemas/forms/get.js +0 -6
  36. package/src/manager/schemas/forms/post.js +0 -9
  37. package/src/manager/schemas/forms/public/get.js +0 -6
  38. package/src/manager/schemas/forms/put.js +0 -10
@@ -0,0 +1,261 @@
1
+ const BaseCommand = require('./base-command');
2
+ const chalk = require('chalk');
3
+ const inquirer = require('inquirer');
4
+ const { initFirebase } = require('./firebase-init');
5
+
6
+ class FirestoreCommand extends BaseCommand {
7
+ async execute() {
8
+ const argv = this.main.argv;
9
+ const args = argv._ || [];
10
+ const subcommand = args[0]; // e.g., 'firestore:get'
11
+ const action = subcommand.split(':')[1];
12
+
13
+ // Initialize Firebase
14
+ const isEmulator = argv.emulator || false;
15
+ let firebase;
16
+
17
+ try {
18
+ firebase = initFirebase({
19
+ firebaseProjectPath: this.firebaseProjectPath,
20
+ emulator: isEmulator,
21
+ });
22
+ } catch (error) {
23
+ this.logError(`Firebase init failed: ${error.message}`);
24
+ return;
25
+ }
26
+
27
+ const { admin, projectId } = firebase;
28
+ const target = isEmulator ? 'emulator' : 'production';
29
+ this.log(chalk.gray(` Target: ${projectId} (${target})\n`));
30
+
31
+ // Dispatch to subcommand handler
32
+ switch (action) {
33
+ case 'get':
34
+ return await this.get(admin, args, argv);
35
+ case 'set':
36
+ return await this.set(admin, args, argv);
37
+ case 'query':
38
+ return await this.query(admin, args, argv);
39
+ case 'delete':
40
+ return await this.del(admin, args, argv, isEmulator);
41
+ default:
42
+ this.logError(`Unknown firestore subcommand: ${action}`);
43
+ this.log(chalk.gray(' Available: firestore:get, firestore:set, firestore:query, firestore:delete'));
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Read a document by path.
49
+ * Usage: npx bm firestore:get users/abc123
50
+ */
51
+ async get(admin, args, argv) {
52
+ const docPath = args[1];
53
+
54
+ if (!docPath) {
55
+ this.logError('Missing document path. Usage: npx bm firestore:get <path>');
56
+ return;
57
+ }
58
+
59
+ // Validate path is a document (even number of segments), not a collection
60
+ const segments = docPath.split('/').filter(Boolean);
61
+ if (segments.length % 2 !== 0) {
62
+ this.logError(`Path "${docPath}" points to a collection, not a document.`);
63
+ this.log(chalk.gray(' Use firestore:query to list collection documents.'));
64
+ return;
65
+ }
66
+
67
+ try {
68
+ const doc = await admin.firestore().doc(docPath).get();
69
+
70
+ if (!doc.exists) {
71
+ this.logWarning(`Document does not exist: ${docPath}`);
72
+ return;
73
+ }
74
+
75
+ this.output({ id: doc.id, path: doc.ref.path, data: doc.data() }, argv);
76
+ } catch (error) {
77
+ this.logError(`Failed to read document: ${error.message}`);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Write/merge to a document.
83
+ * Usage: npx bm firestore:set users/abc123 '{"field": "value"}'
84
+ */
85
+ async set(admin, args, argv) {
86
+ const docPath = args[1];
87
+ const jsonString = args[2];
88
+
89
+ if (!docPath || !jsonString) {
90
+ this.logError('Usage: npx bm firestore:set <path> \'<json>\'');
91
+ return;
92
+ }
93
+
94
+ let data;
95
+ try {
96
+ data = JSON.parse(jsonString);
97
+ } catch (error) {
98
+ this.logError(`Invalid JSON: ${error.message}`);
99
+ return;
100
+ }
101
+
102
+ const merge = argv.merge !== false; // merge by default, --no-merge to overwrite
103
+
104
+ try {
105
+ await admin.firestore().doc(docPath).set(data, { merge });
106
+ this.logSuccess(`Document written: ${docPath} (merge: ${merge})`);
107
+ this.output(data, argv);
108
+ } catch (error) {
109
+ this.logError(`Failed to write document: ${error.message}`);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Query a collection.
115
+ * Usage: npx bm firestore:query users --where "plan==premium" --limit 10
116
+ */
117
+ async query(admin, args, argv) {
118
+ const collectionPath = args[1];
119
+
120
+ if (!collectionPath) {
121
+ this.logError('Usage: npx bm firestore:query <collection> [--where "field==value"] [--limit N]');
122
+ return;
123
+ }
124
+
125
+ try {
126
+ let query = admin.firestore().collection(collectionPath);
127
+
128
+ // Parse --where clauses (can be repeated for AND)
129
+ const whereClauses = this.parseWhereClauses(argv);
130
+ for (const { field, operator, value } of whereClauses) {
131
+ query = query.where(field, operator, value);
132
+ }
133
+
134
+ // Parse --orderBy
135
+ if (argv.orderBy) {
136
+ const [field, direction] = argv.orderBy.split(':');
137
+ query = query.orderBy(field, direction || 'asc');
138
+ }
139
+
140
+ // Parse --limit (default 25)
141
+ const limit = parseInt(argv.limit, 10) || 25;
142
+ query = query.limit(limit);
143
+
144
+ const snapshot = await query.get();
145
+
146
+ if (snapshot.empty) {
147
+ this.logWarning('No documents found.');
148
+ return;
149
+ }
150
+
151
+ const results = snapshot.docs.map(doc => ({
152
+ id: doc.id,
153
+ path: doc.ref.path,
154
+ data: doc.data(),
155
+ }));
156
+
157
+ this.log(chalk.gray(` Found ${results.length} document(s)\n`));
158
+ this.output(results, argv);
159
+ } catch (error) {
160
+ this.logError(`Query failed: ${error.message}`);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Delete a document.
166
+ * Usage: npx bm firestore:delete users/abc123 [--force]
167
+ */
168
+ async del(admin, args, argv, isEmulator) {
169
+ const docPath = args[1];
170
+
171
+ if (!docPath) {
172
+ this.logError('Usage: npx bm firestore:delete <path> [--force]');
173
+ return;
174
+ }
175
+
176
+ // Require confirmation for production (skip for emulator or --force)
177
+ if (!isEmulator && !argv.force) {
178
+ const { confirmed } = await inquirer.prompt([{
179
+ type: 'confirm',
180
+ name: 'confirmed',
181
+ message: `Delete document "${docPath}" from PRODUCTION?`,
182
+ default: false,
183
+ }]);
184
+
185
+ if (!confirmed) {
186
+ this.log(chalk.gray(' Aborted.'));
187
+ return;
188
+ }
189
+ }
190
+
191
+ try {
192
+ await admin.firestore().doc(docPath).delete();
193
+ this.logSuccess(`Document deleted: ${docPath}`);
194
+ } catch (error) {
195
+ this.logError(`Failed to delete document: ${error.message}`);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Parse --where flag(s) into Firestore query clauses.
201
+ * Supports: "field==value", "field>value", "field>=value", etc.
202
+ * Multiple --where flags create AND conditions.
203
+ */
204
+ parseWhereClauses(argv) {
205
+ if (!argv.where) {
206
+ return [];
207
+ }
208
+
209
+ // yargs: single --where gives string, multiple gives array
210
+ const rawClauses = Array.isArray(argv.where) ? argv.where : [argv.where];
211
+ const operators = ['>=', '<=', '!=', '==', '>', '<'];
212
+
213
+ return rawClauses.map(clause => {
214
+ for (const op of operators) {
215
+ const idx = clause.indexOf(op);
216
+
217
+ if (idx === -1) {
218
+ continue;
219
+ }
220
+
221
+ const field = clause.substring(0, idx).trim();
222
+ const rawValue = clause.substring(idx + op.length).trim();
223
+
224
+ return { field, operator: op, value: this.coerceValue(rawValue) };
225
+ }
226
+
227
+ throw new Error(`Cannot parse --where clause: "${clause}". Use format: "field==value"`);
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Coerce a string value to the appropriate JS type.
233
+ */
234
+ coerceValue(raw) {
235
+ if (raw === 'true') return true;
236
+ if (raw === 'false') return false;
237
+ if (raw === 'null') return null;
238
+ if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
239
+
240
+ // Strip surrounding quotes if present
241
+ if ((raw.startsWith('"') && raw.endsWith('"'))
242
+ || (raw.startsWith("'") && raw.endsWith("'"))) {
243
+ return raw.slice(1, -1);
244
+ }
245
+
246
+ return raw;
247
+ }
248
+
249
+ /**
250
+ * Output data as JSON.
251
+ */
252
+ output(data, argv) {
253
+ if (argv.raw) {
254
+ this.log(JSON.stringify(data));
255
+ } else {
256
+ this.log(JSON.stringify(data, null, 2));
257
+ }
258
+ }
259
+ }
260
+
261
+ module.exports = FirestoreCommand;
@@ -12,4 +12,6 @@ module.exports = {
12
12
  CleanCommand: require('./clean'),
13
13
  IndexesCommand: require('./indexes'),
14
14
  WatchCommand: require('./watch'),
15
+ FirestoreCommand: require('./firestore'),
16
+ AuthCommand: require('./auth'),
15
17
  };
package/src/cli/index.js CHANGED
@@ -26,6 +26,8 @@ const CleanCommand = require('./commands/clean');
26
26
  const IndexesCommand = require('./commands/indexes');
27
27
  const WatchCommand = require('./commands/watch');
28
28
  const StripeCommand = require('./commands/stripe');
29
+ const FirestoreCommand = require('./commands/firestore');
30
+ const AuthCommand = require('./commands/auth');
29
31
 
30
32
  function Main() {}
31
33
 
@@ -129,6 +131,20 @@ Main.prototype.process = async function (args) {
129
131
  const cmd = new StripeCommand(self);
130
132
  return await cmd.execute();
131
133
  }
134
+
135
+ // Firestore utility commands
136
+ if (self.options['firestore:get'] || self.options['firestore:set']
137
+ || self.options['firestore:query'] || self.options['firestore:delete']) {
138
+ const cmd = new FirestoreCommand(self);
139
+ return await cmd.execute();
140
+ }
141
+
142
+ // Auth utility commands
143
+ if (self.options['auth:get'] || self.options['auth:list']
144
+ || self.options['auth:delete'] || self.options['auth:set-claims']) {
145
+ const cmd = new AuthCommand(self);
146
+ return await cmd.execute();
147
+ }
132
148
  };
133
149
 
134
150
  // Test method for setup command
@@ -141,6 +141,43 @@ const Stripe = {
141
141
  };
142
142
  },
143
143
 
144
+ /**
145
+ * Find an existing Stripe customer by uid metadata, or create one
146
+ *
147
+ * @param {string} uid - User's UID
148
+ * @param {string|null} email - User's email (used when creating a new customer)
149
+ * @param {object} assistant - Assistant instance for logging
150
+ * @returns {object} Stripe customer object
151
+ */
152
+ async resolveCustomer(uid, email, assistant) {
153
+ const stripe = this.init();
154
+
155
+ // Search for existing customer with this uid
156
+ const search = await stripe.customers.search({
157
+ query: `metadata['uid']:'${uid}'`,
158
+ limit: 1,
159
+ });
160
+
161
+ if (search.data.length > 0) {
162
+ const existing = search.data[0];
163
+ assistant.log(`Found existing Stripe customer: ${existing.id}`);
164
+ return existing;
165
+ }
166
+
167
+ // Create new customer
168
+ const params = {
169
+ metadata: { uid },
170
+ };
171
+
172
+ if (email) {
173
+ params.email = email;
174
+ }
175
+
176
+ const customer = await stripe.customers.create(params);
177
+ assistant.log(`Created new Stripe customer: ${customer.id}`);
178
+ return customer;
179
+ },
180
+
144
181
  /**
145
182
  * Transform a raw Stripe one-time payment resource into a unified shape
146
183
  * Mirrors subscription structure: { product, status, payment: { ... } }
@@ -0,0 +1,66 @@
1
+ const path = require('path');
2
+
3
+ /**
4
+ * POST /payments/cancel
5
+ * Cancels the authenticated user's subscription at the end of the current billing period.
6
+ * Delegates to the processor (e.g., Stripe) to set cancel_at_period_end=true.
7
+ * The resulting webhook triggers the Firestore pipeline which updates subscription state
8
+ * and fires the cancellation-requested transition handler.
9
+ * Requires authentication.
10
+ */
11
+ module.exports = async ({ assistant, user, settings }) => {
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 confirmed = settings.confirmed;
19
+
20
+ // Require explicit confirmation
21
+ if (!confirmed) {
22
+ return assistant.respond('Cancellation must be confirmed', { code: 400 });
23
+ }
24
+
25
+ const subscription = user.subscription;
26
+
27
+ // Require an active, paid subscription
28
+ if (!subscription || subscription.status !== 'active' || subscription.product?.id === 'basic') {
29
+ assistant.log(`Cancel rejected: uid=${uid}, status=${subscription?.status}, product=${subscription?.product?.id}`);
30
+ return assistant.respond('No active paid subscription found', { code: 400 });
31
+ }
32
+
33
+ // Guard: already pending cancellation
34
+ if (subscription.cancellation?.pending === true) {
35
+ assistant.log(`Cancel rejected: uid=${uid}, cancellation already pending`);
36
+ return assistant.respond('Subscription is already pending cancellation', { code: 400 });
37
+ }
38
+
39
+ const processor = subscription.payment?.processor;
40
+ const resourceId = subscription.payment?.resourceId;
41
+
42
+ if (!processor || !resourceId) {
43
+ assistant.log(`Cancel rejected: uid=${uid}, missing processor=${processor} or resourceId=${resourceId}`);
44
+ return assistant.respond('Subscription payment details not found', { code: 400 });
45
+ }
46
+
47
+ // Load the processor module
48
+ let processorModule;
49
+ try {
50
+ processorModule = require(path.resolve(__dirname, `processors/${processor}.js`));
51
+ } catch (e) {
52
+ return assistant.respond(`Unknown processor: ${processor}`, { code: 400 });
53
+ }
54
+
55
+ // Cancel at period end via the processor
56
+ try {
57
+ await processorModule.cancelAtPeriodEnd({ resourceId, uid, subscription, assistant });
58
+ } catch (e) {
59
+ assistant.log(`Failed to cancel subscription via ${processor}: ${e.message}`);
60
+ return assistant.respond(`Failed to cancel subscription: ${e.message}`, { code: 500, sentry: true });
61
+ }
62
+
63
+ assistant.log(`Cancel at period end scheduled: uid=${uid}, processor=${processor}, sub=${resourceId}, reason=${settings.reason}`);
64
+
65
+ return assistant.respond({ success: true });
66
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Stripe cancel processor
3
+ * Sets a subscription to cancel at the end of the current billing period
4
+ */
5
+ module.exports = {
6
+ /**
7
+ * Cancel a Stripe subscription at period end
8
+ *
9
+ * @param {object} options
10
+ * @param {string} options.resourceId - Stripe subscription ID (e.g., 'sub_xxx')
11
+ * @param {string} options.uid - User's UID (for logging)
12
+ * @param {object} options.assistant - Assistant instance for logging
13
+ */
14
+ async cancelAtPeriodEnd({ resourceId, uid, assistant }) {
15
+ const StripeLib = require('../../../../libraries/payment-processors/stripe.js');
16
+ const stripe = StripeLib.init();
17
+
18
+ await stripe.subscriptions.update(resourceId, { cancel_at_period_end: true });
19
+
20
+ assistant.log(`Stripe cancel at period end: sub=${resourceId}, uid=${uid}`);
21
+ },
22
+ };
@@ -0,0 +1,93 @@
1
+ const powertools = require('node-powertools');
2
+
3
+ /**
4
+ * Test cancel processor
5
+ * Simulates the Stripe webhook that results from cancel_at_period_end=true
6
+ * by writing directly to payments-webhooks/{eventId} with status=pending.
7
+ * The on-write trigger picks it up and runs the full pipeline.
8
+ * Only available in non-production environments.
9
+ */
10
+ module.exports = {
11
+ async cancelAtPeriodEnd({ resourceId, uid, subscription, assistant }) {
12
+ if (assistant.isProduction()) {
13
+ throw new Error('Test processor is not available in production');
14
+ }
15
+
16
+ const admin = assistant.Manager.libraries.admin;
17
+
18
+ const timestamp = Date.now();
19
+ const eventId = `_test-evt-cancel-${timestamp}`;
20
+ const now = Math.floor(timestamp / 1000);
21
+ const periodEnd = now + (30 * 86400);
22
+
23
+ // Look up the price ID from the existing order so toUnifiedSubscription can resolve the product
24
+ const orderId = subscription?.payment?.orderId;
25
+ let priceId = null;
26
+
27
+ if (orderId) {
28
+ const orderDoc = await admin.firestore().doc(`payments-orders/${orderId}`).get();
29
+ if (orderDoc.exists) {
30
+ const orderData = orderDoc.data();
31
+ // Find the matching price from config using frequency
32
+ const frequency = orderData.unified?.payment?.frequency;
33
+ const productId = orderData.unified?.product?.id;
34
+ const products = assistant.Manager.config.payment?.products || [];
35
+ const product = products.find(p => p.id === productId);
36
+ priceId = product?.prices?.[frequency]?.stripe || null;
37
+ }
38
+ }
39
+
40
+ // Build a Stripe-shaped customer.subscription.updated payload
41
+ // with cancel_at_period_end=true — mirrors what Stripe sends after cancellation
42
+ const subscriptionObj = {
43
+ id: resourceId,
44
+ object: 'subscription',
45
+ status: 'active',
46
+ metadata: { uid, orderId },
47
+ cancel_at_period_end: true,
48
+ cancel_at: periodEnd,
49
+ canceled_at: null,
50
+ current_period_end: periodEnd,
51
+ current_period_start: now - (30 * 86400),
52
+ start_date: now - (30 * 86400),
53
+ trial_start: null,
54
+ trial_end: null,
55
+ plan: { id: priceId, interval: 'month' },
56
+ };
57
+
58
+ const nowTs = powertools.timestamp(new Date(), { output: 'string' });
59
+ const nowUNIX = powertools.timestamp(nowTs, { output: 'unix' });
60
+
61
+ // Write directly to payments-webhooks — on-write trigger handles the rest
62
+ await admin.firestore().doc(`payments-webhooks/${eventId}`).set({
63
+ id: eventId,
64
+ processor: 'test',
65
+ status: 'pending',
66
+ owner: uid,
67
+ raw: {
68
+ id: eventId,
69
+ type: 'customer.subscription.updated',
70
+ data: { object: subscriptionObj },
71
+ },
72
+ event: {
73
+ type: 'customer.subscription.updated',
74
+ category: 'subscription',
75
+ resourceType: 'subscription',
76
+ resourceId: resourceId,
77
+ },
78
+ error: null,
79
+ metadata: {
80
+ received: {
81
+ timestamp: nowTs,
82
+ timestampUNIX: nowUNIX,
83
+ },
84
+ processed: {
85
+ timestamp: null,
86
+ timestampUNIX: null,
87
+ },
88
+ },
89
+ });
90
+
91
+ assistant.log(`Test cancel processor: wrote payments-webhooks/${eventId} for sub=${resourceId}, uid=${uid}`);
92
+ },
93
+ };
@@ -30,7 +30,7 @@ module.exports = {
30
30
 
31
31
  // Resolve or create Stripe customer (keyed by uid in metadata)
32
32
  const email = assistant?.getUser()?.auth?.email || null;
33
- const customer = await resolveCustomer(stripe, uid, email, assistant);
33
+ const customer = await StripeLib.resolveCustomer(uid, email, assistant);
34
34
 
35
35
  assistant.log(`Stripe checkout: type=${productType}, priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
36
36
 
@@ -118,32 +118,3 @@ function buildOneTimeSession({ priceId, customer, uid, orderId, productId, produ
118
118
  };
119
119
  }
120
120
 
121
- /**
122
- * Find an existing Stripe customer by uid metadata, or create one
123
- */
124
- async function resolveCustomer(stripe, uid, email, assistant) {
125
- // Search for existing customer with this uid
126
- const search = await stripe.customers.search({
127
- query: `metadata['uid']:'${uid}'`,
128
- limit: 1,
129
- });
130
-
131
- if (search.data.length > 0) {
132
- const existing = search.data[0];
133
- assistant.log(`Found existing Stripe customer: ${existing.id}`);
134
- return existing;
135
- }
136
-
137
- // Create new customer
138
- const params = {
139
- metadata: { uid },
140
- };
141
-
142
- if (email) {
143
- params.email = email;
144
- }
145
-
146
- const customer = await stripe.customers.create(params);
147
- assistant.log(`Created new Stripe customer: ${customer.id}`);
148
- return customer;
149
- }
@@ -0,0 +1,54 @@
1
+ const path = require('path');
2
+
3
+ /**
4
+ * POST /payments/portal
5
+ * Creates a Stripe Billing Portal session for the authenticated user.
6
+ * The portal allows managing payment methods and viewing invoices,
7
+ * but does NOT allow cancellation (users must use POST /payments/cancel).
8
+ * Requires authentication.
9
+ */
10
+ module.exports = async ({ assistant, user, settings }) => {
11
+ // Require authentication
12
+ if (!user.authenticated) {
13
+ return assistant.respond('Authentication required', { code: 401 });
14
+ }
15
+
16
+ const uid = user.auth.uid;
17
+ const returnUrl = settings.returnUrl;
18
+ const subscription = user.subscription;
19
+
20
+ // Require a paid subscription (any status — suspended users can still manage billing)
21
+ if (!subscription || subscription.product?.id === 'basic') {
22
+ assistant.log(`Portal rejected: uid=${uid}, product=${subscription?.product?.id}`);
23
+ return assistant.respond('No paid subscription found', { code: 400 });
24
+ }
25
+
26
+ const processor = subscription.payment?.processor;
27
+
28
+ if (!processor) {
29
+ assistant.log(`Portal rejected: uid=${uid}, no processor set`);
30
+ return assistant.respond('Subscription payment processor not found', { code: 400 });
31
+ }
32
+
33
+ // Load the processor module
34
+ let processorModule;
35
+ try {
36
+ processorModule = require(path.resolve(__dirname, `processors/${processor}.js`));
37
+ } catch (e) {
38
+ return assistant.respond(`Unknown processor: ${processor}`, { code: 400 });
39
+ }
40
+
41
+ // Create the portal session via the processor
42
+ const email = user.auth?.email || null;
43
+ let result;
44
+ try {
45
+ result = await processorModule.createPortalSession({ uid, email, returnUrl, assistant });
46
+ } catch (e) {
47
+ assistant.log(`Failed to create ${processor} portal session: ${e.message}`);
48
+ return assistant.respond(`Failed to create portal session: ${e.message}`, { code: 500, sentry: true });
49
+ }
50
+
51
+ assistant.log(`Portal session created: uid=${uid}, processor=${processor}`);
52
+
53
+ return assistant.respond({ url: result.url });
54
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Stripe portal processor
3
+ * Creates a Stripe Billing Portal session with cancellation disabled.
4
+ * The portal config is lazily created and cached per cold start.
5
+ */
6
+
7
+ // Cached portal configuration ID (no cancellation allowed)
8
+ let portalConfigId = null;
9
+
10
+ module.exports = {
11
+ /**
12
+ * Create a Stripe Billing Portal session
13
+ *
14
+ * @param {object} options
15
+ * @param {string} options.uid - User's UID
16
+ * @param {string} options.email - User's email (for customer resolution)
17
+ * @param {string|null} options.returnUrl - URL to return to after portal session
18
+ * @param {object} options.assistant - Assistant instance for logging
19
+ * @returns {object} { url }
20
+ */
21
+ async createPortalSession({ uid, email, returnUrl, assistant }) {
22
+ const StripeLib = require('../../../../libraries/payment-processors/stripe.js');
23
+ const stripe = StripeLib.init();
24
+
25
+ // Resolve the Stripe customer for this user
26
+ const customer = await StripeLib.resolveCustomer(uid, email, assistant);
27
+
28
+ // Lazily create and cache a portal configuration with cancellation disabled
29
+ if (!portalConfigId) {
30
+ const config = await stripe.billingPortal.configurations.create({
31
+ business_profile: {
32
+ headline: 'Manage your subscription',
33
+ },
34
+ features: {
35
+ subscription_cancel: { enabled: false },
36
+ subscription_update: { enabled: false },
37
+ payment_method_update: { enabled: true },
38
+ invoice_history: { enabled: true },
39
+ },
40
+ });
41
+
42
+ portalConfigId = config.id;
43
+ assistant.log(`Created Stripe portal config: ${portalConfigId}`);
44
+ }
45
+
46
+ // Build session params
47
+ const sessionParams = {
48
+ customer: customer.id,
49
+ configuration: portalConfigId,
50
+ };
51
+
52
+ if (returnUrl) {
53
+ sessionParams.return_url = returnUrl;
54
+ }
55
+
56
+ const session = await stripe.billingPortal.sessions.create(sessionParams);
57
+
58
+ assistant.log(`Stripe portal session created: uid=${uid}, customerId=${customer.id}, url=${session.url}`);
59
+
60
+ return { url: session.url };
61
+ },
62
+ };