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.
- package/CLAUDE.md +59 -0
- package/README.md +10 -0
- package/TODO-MARKETING.md +53 -0
- package/package.json +1 -1
- package/src/cli/commands/auth.js +226 -0
- package/src/cli/commands/firebase-init.js +121 -0
- package/src/cli/commands/firestore.js +261 -0
- package/src/cli/commands/index.js +2 -0
- package/src/cli/index.js +16 -0
- package/src/manager/libraries/payment-processors/stripe.js +37 -0
- package/src/manager/routes/payments/cancel/post.js +66 -0
- package/src/manager/routes/payments/cancel/processors/stripe.js +22 -0
- package/src/manager/routes/payments/cancel/processors/test.js +93 -0
- package/src/manager/routes/payments/intent/processors/stripe.js +1 -30
- package/src/manager/routes/payments/portal/post.js +54 -0
- package/src/manager/routes/payments/portal/processors/stripe.js +62 -0
- package/src/manager/routes/payments/portal/processors/test.js +18 -0
- package/src/manager/routes/user/delete.js +2 -1
- package/src/manager/schemas/payments/cancel/post.js +18 -0
- package/src/manager/schemas/payments/portal/post.js +10 -0
- package/src/test/runner.js +18 -1
- package/src/test/test-accounts.js +92 -0
- package/templates/firestore.rules +1 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +79 -0
- package/test/helpers/stripe-to-unified-one-time.js +304 -0
- package/test/routes/payments/cancel.js +124 -0
- package/test/routes/payments/intent.js +7 -14
- package/test/routes/payments/portal.js +89 -0
- package/src/manager/routes/forms/delete.js +0 -37
- package/src/manager/routes/forms/get.js +0 -46
- package/src/manager/routes/forms/post.js +0 -45
- package/src/manager/routes/forms/public/get.js +0 -37
- package/src/manager/routes/forms/put.js +0 -52
- package/src/manager/schemas/forms/delete.js +0 -6
- package/src/manager/schemas/forms/get.js +0 -6
- package/src/manager/schemas/forms/post.js +0 -9
- package/src/manager/schemas/forms/public/get.js +0 -6
- 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;
|
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(
|
|
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
|
+
};
|