backend-manager 5.0.89 → 5.0.92
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/CHANGELOG.md +2 -2
- package/CLAUDE.md +147 -8
- package/README.md +6 -6
- package/TODO-MARKETING.md +3 -0
- package/TODO-PAYMENT-v2.md +71 -0
- package/TODO.md +7 -0
- package/package.json +7 -5
- package/src/cli/commands/{emulators.js → emulator.js} +15 -15
- package/src/cli/commands/index.js +1 -1
- package/src/cli/commands/setup-tests/{emulators-config.js → emulator-config.js} +4 -4
- package/src/cli/commands/setup-tests/index.js +2 -2
- package/src/cli/commands/setup-tests/project-id-consistency.js +1 -1
- package/src/cli/commands/test.js +16 -16
- package/src/cli/index.js +15 -4
- package/src/manager/events/auth/on-create.js +5 -158
- package/src/manager/events/firestore/payments-webhooks/analytics.js +171 -0
- package/src/manager/events/firestore/payments-webhooks/on-write.js +95 -297
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +19 -10
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +61 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +22 -9
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +21 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +18 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +18 -7
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +26 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +24 -9
- package/src/manager/functions/core/actions/api/admin/send-email.js +0 -131
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +2 -137
- package/src/manager/functions/core/actions/api/user/sign-up.js +1 -1
- package/src/manager/helpers/user.js +1 -0
- package/src/manager/index.js +12 -0
- package/src/manager/libraries/email.js +483 -0
- package/src/manager/libraries/infer-contact.js +140 -0
- package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
- package/src/manager/libraries/payment-processors/stripe.js +87 -48
- package/src/manager/libraries/payment-processors/test.js +4 -4
- package/src/manager/libraries/prompts/infer-contact.md +43 -0
- package/src/manager/routes/admin/backup/post.js +4 -3
- package/src/manager/routes/admin/email/post.js +11 -428
- package/src/manager/routes/admin/hook/post.js +3 -2
- package/src/manager/routes/admin/notification/post.js +14 -12
- package/src/manager/routes/admin/post/post.js +5 -6
- package/src/manager/routes/admin/post/put.js +3 -2
- package/src/manager/routes/admin/stats/get.js +19 -10
- package/src/manager/routes/general/email/post.js +8 -21
- package/src/manager/routes/marketing/contact/post.js +2 -100
- package/src/manager/routes/payments/intent/post.js +44 -2
- package/src/manager/routes/payments/intent/processors/stripe.js +10 -45
- package/src/manager/routes/payments/intent/processors/test.js +20 -25
- package/src/manager/routes/user/oauth2/_helpers.js +3 -2
- package/src/manager/routes/user/oauth2/delete.js +3 -3
- package/src/manager/routes/user/oauth2/get.js +2 -2
- package/src/manager/routes/user/oauth2/post.js +9 -9
- package/src/manager/routes/user/sessions/delete.js +4 -3
- package/src/manager/routes/user/signup/post.js +254 -54
- package/src/manager/schemas/admin/email/post.js +10 -5
- package/src/test/run-tests.js +1 -1
- package/src/test/runner.js +11 -0
- package/src/test/test-accounts.js +18 -0
- package/templates/backend-manager-config.json +31 -12
- package/test/events/payments/journey-payments-one-time-failure.js +105 -0
- package/test/events/payments/journey-payments-one-time.js +128 -0
- package/test/events/payments/journey-payments-plan-change.js +126 -0
- package/test/events/payments/journey-payments-upgrade.js +2 -2
- package/test/functions/admin/send-email.js +1 -88
- package/test/helpers/email.js +381 -0
- package/test/helpers/infer-contact.js +299 -0
- package/test/routes/admin/email.js +41 -90
- package/REFACTOR-BEM-API.md +0 -76
- package/REFACTOR-MIDDLEWARE.md +0 -62
- package/REFACTOR-PAYMENT.md +0 -66
- /package/bin/{bem → backend-manager} +0 -0
|
@@ -39,7 +39,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
39
39
|
|
|
40
40
|
// Update stats if requested
|
|
41
41
|
if (settings.update) {
|
|
42
|
-
const error = await updateStats(
|
|
42
|
+
const error = await updateStats(assistant, data, settings.update);
|
|
43
43
|
|
|
44
44
|
if (error) {
|
|
45
45
|
return assistant.respond(error.message, { code: 500 });
|
|
@@ -58,7 +58,9 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
58
58
|
return assistant.respond(data);
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
-
async function updateStats(
|
|
61
|
+
async function updateStats(assistant, existingData, update) {
|
|
62
|
+
const Manager = assistant.Manager;
|
|
63
|
+
const { admin } = Manager.libraries;
|
|
62
64
|
const stats = admin.firestore().doc('meta/stats');
|
|
63
65
|
const newData = {
|
|
64
66
|
app: Manager.config?.app?.id || null,
|
|
@@ -70,7 +72,7 @@ async function updateStats(admin, assistant, Manager, existingData, update) {
|
|
|
70
72
|
|
|
71
73
|
// Update notification stats
|
|
72
74
|
if (update === true || update?.notifications) {
|
|
73
|
-
const count = await getAllNotifications(
|
|
75
|
+
const count = await getAllNotifications(assistant).catch((e) => e);
|
|
74
76
|
|
|
75
77
|
if (count instanceof Error) {
|
|
76
78
|
error = new Error(`Failed getting notifications: ${count.message}`);
|
|
@@ -81,7 +83,7 @@ async function updateStats(admin, assistant, Manager, existingData, update) {
|
|
|
81
83
|
|
|
82
84
|
// Update subscription stats
|
|
83
85
|
if (!error && (update === true || update?.subscriptions)) {
|
|
84
|
-
const subscriptions = await getAllSubscriptions(
|
|
86
|
+
const subscriptions = await getAllSubscriptions(assistant).catch((e) => e);
|
|
85
87
|
|
|
86
88
|
if (subscriptions instanceof Error) {
|
|
87
89
|
error = new Error(`Failed getting subscriptions: ${subscriptions.message}`);
|
|
@@ -92,7 +94,7 @@ async function updateStats(admin, assistant, Manager, existingData, update) {
|
|
|
92
94
|
|
|
93
95
|
// Update user stats
|
|
94
96
|
if (!error && (!existingData?.users?.total || update === true || update?.users)) {
|
|
95
|
-
const users = await getAllUsers(
|
|
97
|
+
const users = await getAllUsers(assistant).catch((e) => e);
|
|
96
98
|
|
|
97
99
|
if (users instanceof Error) {
|
|
98
100
|
error = new Error(`Failed getting users: ${users.message}`);
|
|
@@ -103,7 +105,7 @@ async function updateStats(admin, assistant, Manager, existingData, update) {
|
|
|
103
105
|
|
|
104
106
|
// Update online users
|
|
105
107
|
if (!error && (update === true || update?.online)) {
|
|
106
|
-
const online = await countOnlineUsers(
|
|
108
|
+
const online = await countOnlineUsers(assistant);
|
|
107
109
|
|
|
108
110
|
_.set(newData, 'users.online', online);
|
|
109
111
|
}
|
|
@@ -125,7 +127,9 @@ async function updateStats(admin, assistant, Manager, existingData, update) {
|
|
|
125
127
|
return error;
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
async function getAllUsers(
|
|
130
|
+
async function getAllUsers(assistant) {
|
|
131
|
+
const { admin } = assistant.Manager.libraries;
|
|
132
|
+
|
|
129
133
|
assistant.log('getAllUsers(): Starting...');
|
|
130
134
|
|
|
131
135
|
const users = [];
|
|
@@ -142,7 +146,9 @@ async function getAllUsers(admin, assistant) {
|
|
|
142
146
|
return users;
|
|
143
147
|
}
|
|
144
148
|
|
|
145
|
-
async function getAllNotifications(
|
|
149
|
+
async function getAllNotifications(assistant) {
|
|
150
|
+
const { admin } = assistant.Manager.libraries;
|
|
151
|
+
|
|
146
152
|
assistant.log('getAllNotifications(): Starting...');
|
|
147
153
|
|
|
148
154
|
const snap = await admin.firestore().collection('notifications').count().get();
|
|
@@ -153,7 +159,9 @@ async function getAllNotifications(admin, assistant) {
|
|
|
153
159
|
return count;
|
|
154
160
|
}
|
|
155
161
|
|
|
156
|
-
async function getAllSubscriptions(
|
|
162
|
+
async function getAllSubscriptions(assistant) {
|
|
163
|
+
const { admin } = assistant.Manager.libraries;
|
|
164
|
+
|
|
157
165
|
assistant.log('getAllSubscriptions(): Starting...');
|
|
158
166
|
|
|
159
167
|
const snapshot = await admin.firestore().collection('users')
|
|
@@ -195,7 +203,8 @@ async function getAllSubscriptions(admin, assistant) {
|
|
|
195
203
|
return stats;
|
|
196
204
|
}
|
|
197
205
|
|
|
198
|
-
async function countOnlineUsers(
|
|
206
|
+
async function countOnlineUsers(assistant) {
|
|
207
|
+
const { admin } = assistant.Manager.libraries;
|
|
199
208
|
let online = 0;
|
|
200
209
|
|
|
201
210
|
const paths = ['gatherings/online', 'sessions/app', 'sessions/online'];
|
|
@@ -4,10 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { merge } = require('lodash');
|
|
7
|
-
|
|
8
|
-
module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
9
|
-
const fetch = Manager.require('wonderful-fetch');
|
|
10
|
-
|
|
7
|
+
module.exports = async ({ assistant, Manager, settings }) => {
|
|
11
8
|
// Validate required parameters
|
|
12
9
|
if (!settings.id) {
|
|
13
10
|
return assistant.respond('Parameter {id} is required.', { code: 400 });
|
|
@@ -22,10 +19,7 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
22
19
|
email: 3,
|
|
23
20
|
},
|
|
24
21
|
delay: 1,
|
|
25
|
-
payload: {
|
|
26
|
-
backendManagerKey: process.env.BACKEND_MANAGER_KEY,
|
|
27
|
-
app: Manager.config.app.id,
|
|
28
|
-
},
|
|
22
|
+
payload: {},
|
|
29
23
|
};
|
|
30
24
|
|
|
31
25
|
// Load email template
|
|
@@ -75,25 +69,18 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
75
69
|
|
|
76
70
|
assistant.log('Email payload:', emailPayload);
|
|
77
71
|
|
|
78
|
-
// Send
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
response: 'json',
|
|
82
|
-
log: true,
|
|
83
|
-
headers: {
|
|
84
|
-
'Authorization': `Bearer ${process.env.BACKEND_MANAGER_KEY}`,
|
|
85
|
-
},
|
|
86
|
-
body: emailPayload.payload,
|
|
87
|
-
}).catch(e => e);
|
|
72
|
+
// Send email directly via library
|
|
73
|
+
const email = Manager.Email(assistant);
|
|
74
|
+
const result = await email.send(emailPayload.payload).catch(e => e);
|
|
88
75
|
|
|
89
76
|
if (result instanceof Error) {
|
|
90
|
-
return assistant.respond(
|
|
77
|
+
return assistant.respond(result.message, { code: result.code || 500, sentry: result.code !== 400 });
|
|
91
78
|
}
|
|
92
79
|
|
|
93
|
-
assistant.log('Response:', result);
|
|
80
|
+
assistant.log('Response:', result.status);
|
|
94
81
|
|
|
95
82
|
// Track analytics
|
|
96
|
-
analytics.event('general/email', { id: settings.id });
|
|
83
|
+
assistant.analytics.event('general/email', { id: settings.id });
|
|
97
84
|
|
|
98
85
|
return assistant.respond({ success: true });
|
|
99
86
|
};
|
|
@@ -9,9 +9,7 @@ const dns = require('dns').promises;
|
|
|
9
9
|
// Load disposable domains list
|
|
10
10
|
const DISPOSABLE_DOMAINS = require(path.join(__dirname, '..', '..', '..', 'libraries', 'disposable-domains.json'));
|
|
11
11
|
const DISPOSABLE_SET = new Set(DISPOSABLE_DOMAINS.map(d => d.toLowerCase()));
|
|
12
|
-
|
|
13
|
-
// Load OpenAI library
|
|
14
|
-
const OpenAI = require(path.join(__dirname, '..', '..', '..', 'libraries', 'openai'));
|
|
12
|
+
const { inferContact } = require(path.join(__dirname, '..', '..', '..', 'libraries', 'infer-contact.js'));
|
|
15
13
|
|
|
16
14
|
module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
17
15
|
|
|
@@ -100,7 +98,7 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
100
98
|
// Infer name if not provided
|
|
101
99
|
let nameInferred = null;
|
|
102
100
|
if (!firstName && !lastName) {
|
|
103
|
-
nameInferred = await
|
|
101
|
+
nameInferred = await inferContact(email, assistant);
|
|
104
102
|
firstName = nameInferred.firstName;
|
|
105
103
|
lastName = nameInferred.lastName;
|
|
106
104
|
}
|
|
@@ -212,102 +210,6 @@ async function validateWithZeroBounce(email) {
|
|
|
212
210
|
}
|
|
213
211
|
}
|
|
214
212
|
|
|
215
|
-
// Helper: Infer name from email
|
|
216
|
-
async function inferName(email, assistant) {
|
|
217
|
-
if (process.env.OPENAI_API_KEY) {
|
|
218
|
-
const aiResult = await inferNameWithAI(email, assistant);
|
|
219
|
-
if (aiResult && (aiResult.firstName || aiResult.lastName)) {
|
|
220
|
-
return aiResult;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return inferNameFromEmail(email);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Helper: Use AI to infer name
|
|
228
|
-
async function inferNameWithAI(email, assistant) {
|
|
229
|
-
try {
|
|
230
|
-
const ai = new OpenAI(assistant);
|
|
231
|
-
const result = await ai.request({
|
|
232
|
-
model: 'gpt-5-mini',
|
|
233
|
-
timeout: 30000,
|
|
234
|
-
maxTokens: 1024,
|
|
235
|
-
moderate: false,
|
|
236
|
-
response: 'json',
|
|
237
|
-
prompt: {
|
|
238
|
-
content: `
|
|
239
|
-
<identity>
|
|
240
|
-
You extract names and company from email addresses.
|
|
241
|
-
</identity>
|
|
242
|
-
|
|
243
|
-
<format>
|
|
244
|
-
Return ONLY valid JSON like so:
|
|
245
|
-
{
|
|
246
|
-
"firstName": "...",
|
|
247
|
-
"lastName": "...",
|
|
248
|
-
"company": "...",
|
|
249
|
-
"confidence": "..."
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
If you cannot determine a name, use empty strings.
|
|
253
|
-
</format>
|
|
254
|
-
`,
|
|
255
|
-
},
|
|
256
|
-
message: {
|
|
257
|
-
content: `Email: ${email}`,
|
|
258
|
-
},
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
if (result?.firstName !== undefined) {
|
|
262
|
-
return {
|
|
263
|
-
firstName: capitalize(result.firstName || ''),
|
|
264
|
-
lastName: capitalize(result.lastName || ''),
|
|
265
|
-
company: capitalize(result.company || ''),
|
|
266
|
-
confidence: typeof result.confidence === 'number' ? result.confidence : 0.5,
|
|
267
|
-
method: 'ai',
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
} catch (e) {
|
|
271
|
-
console.error('AI name inference error:', e);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Helper: Regex-based name inference
|
|
278
|
-
function inferNameFromEmail(email) {
|
|
279
|
-
const local = email.split('@')[0];
|
|
280
|
-
const cleaned = local.replace(/[0-9]+$/, '');
|
|
281
|
-
const parts = cleaned.split(/[._-]/);
|
|
282
|
-
|
|
283
|
-
if (parts.length >= 2) {
|
|
284
|
-
return {
|
|
285
|
-
firstName: capitalize(parts[0]),
|
|
286
|
-
lastName: capitalize(parts.slice(1).join(' ')),
|
|
287
|
-
confidence: 0.5,
|
|
288
|
-
method: 'regex',
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return {
|
|
293
|
-
firstName: capitalize(cleaned),
|
|
294
|
-
lastName: '',
|
|
295
|
-
confidence: 0.25,
|
|
296
|
-
method: 'regex',
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Helper: Capitalize string
|
|
301
|
-
function capitalize(str) {
|
|
302
|
-
if (!str) {
|
|
303
|
-
return '';
|
|
304
|
-
}
|
|
305
|
-
return str
|
|
306
|
-
.split(' ')
|
|
307
|
-
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
308
|
-
.join(' ');
|
|
309
|
-
}
|
|
310
|
-
|
|
311
213
|
// Helper: Add contact to SendGrid
|
|
312
214
|
async function addToSendGrid({ email, firstName, lastName, source, appId, brandName }) {
|
|
313
215
|
try {
|
|
@@ -71,6 +71,10 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
71
71
|
|
|
72
72
|
assistant.log(`Generated orderId=${orderId}`);
|
|
73
73
|
|
|
74
|
+
// Build redirect URLs
|
|
75
|
+
const confirmationUrl = buildConfirmationUrl(Manager.project.websiteUrl, { product, productId, productType, frequency, processor, trial, orderId });
|
|
76
|
+
const cancelUrl = buildCancelUrl(Manager.project.websiteUrl, { productId, frequency });
|
|
77
|
+
|
|
74
78
|
// Load the processor module
|
|
75
79
|
let processorModule;
|
|
76
80
|
try {
|
|
@@ -89,8 +93,8 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
89
93
|
productId,
|
|
90
94
|
frequency,
|
|
91
95
|
trial,
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
confirmationUrl,
|
|
97
|
+
cancelUrl,
|
|
94
98
|
assistant,
|
|
95
99
|
});
|
|
96
100
|
} catch (e) {
|
|
@@ -132,3 +136,41 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
132
136
|
url: result.url,
|
|
133
137
|
});
|
|
134
138
|
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build the confirmation/success redirect URL
|
|
142
|
+
*/
|
|
143
|
+
function buildConfirmationUrl(baseUrl, { product, productId, productType, frequency, processor, trial, orderId }) {
|
|
144
|
+
const amount = productType === 'subscription'
|
|
145
|
+
? (product.prices?.[frequency]?.amount || 0)
|
|
146
|
+
: (product.prices?.once?.amount || 0);
|
|
147
|
+
|
|
148
|
+
const url = new URL('/payment/confirmation', baseUrl);
|
|
149
|
+
url.searchParams.set('productId', productId);
|
|
150
|
+
url.searchParams.set('productName', product.name || productId);
|
|
151
|
+
url.searchParams.set('amount', trial && product.trial?.days ? '0' : String(amount));
|
|
152
|
+
url.searchParams.set('currency', 'USD');
|
|
153
|
+
url.searchParams.set('frequency', frequency || 'once');
|
|
154
|
+
url.searchParams.set('paymentMethod', processor);
|
|
155
|
+
url.searchParams.set('trial', String(!!trial && !!product.trial?.days));
|
|
156
|
+
url.searchParams.set('orderId', orderId);
|
|
157
|
+
url.searchParams.set('track', 'true');
|
|
158
|
+
|
|
159
|
+
return url.toString();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build the cancel/back redirect URL
|
|
164
|
+
*/
|
|
165
|
+
function buildCancelUrl(baseUrl, { productId, frequency }) {
|
|
166
|
+
const url = new URL('/payment/checkout', baseUrl);
|
|
167
|
+
url.searchParams.set('product', productId);
|
|
168
|
+
|
|
169
|
+
if (frequency) {
|
|
170
|
+
url.searchParams.set('frequency', frequency);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
url.searchParams.set('payment', 'cancelled');
|
|
174
|
+
|
|
175
|
+
return url.toString();
|
|
176
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const resolvePriceId = require('../../../../libraries/payment-processors/resolve-price-id.js');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Stripe intent processor
|
|
3
5
|
* Creates Stripe Checkout Sessions for subscription and one-time purchases
|
|
@@ -12,11 +14,11 @@ module.exports = {
|
|
|
12
14
|
* @param {string} options.productId - Product ID from config (e.g., 'premium')
|
|
13
15
|
* @param {string} options.frequency - 'monthly' or 'annually' (subscriptions only)
|
|
14
16
|
* @param {boolean} options.trial - Whether to include a trial period (subscriptions only)
|
|
15
|
-
* @param {
|
|
16
|
-
* @param {
|
|
17
|
+
* @param {string} options.confirmationUrl - Success redirect URL
|
|
18
|
+
* @param {string} options.cancelUrl - Cancel redirect URL
|
|
17
19
|
* @returns {object} { id, url, raw }
|
|
18
20
|
*/
|
|
19
|
-
async createIntent({ uid, orderId, product, productId, frequency, trial,
|
|
21
|
+
async createIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, cancelUrl, assistant }) {
|
|
20
22
|
// Initialize Stripe SDK
|
|
21
23
|
const StripeLib = require('../../../../libraries/payment-processors/stripe.js');
|
|
22
24
|
const stripe = StripeLib.init();
|
|
@@ -24,50 +26,13 @@ module.exports = {
|
|
|
24
26
|
const productType = product.type || 'subscription';
|
|
25
27
|
|
|
26
28
|
// Resolve the Stripe price ID based on product type
|
|
27
|
-
|
|
28
|
-
if (productType === 'subscription') {
|
|
29
|
-
priceId = product.prices?.[frequency]?.stripe;
|
|
30
|
-
if (!priceId) {
|
|
31
|
-
throw new Error(`No Stripe price found for ${productId}/${frequency}`);
|
|
32
|
-
}
|
|
33
|
-
} else {
|
|
34
|
-
priceId = product.prices?.once?.stripe;
|
|
35
|
-
if (!priceId) {
|
|
36
|
-
throw new Error(`No Stripe price found for ${productId}/once`);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
29
|
+
const priceId = resolvePriceId(product, productType, frequency);
|
|
39
30
|
|
|
40
31
|
// Resolve or create Stripe customer (keyed by uid in metadata)
|
|
41
32
|
const email = assistant?.getUser()?.auth?.email || null;
|
|
42
33
|
const customer = await resolveCustomer(stripe, uid, email, assistant);
|
|
43
34
|
|
|
44
|
-
assistant
|
|
45
|
-
|
|
46
|
-
// Build confirmation redirect URL
|
|
47
|
-
const baseUrl = Manager.project.websiteUrl;
|
|
48
|
-
const amount = productType === 'subscription'
|
|
49
|
-
? (product.prices?.[frequency]?.amount || 0)
|
|
50
|
-
: (product.prices?.once?.amount || 0);
|
|
51
|
-
|
|
52
|
-
let confirmationUrl = new URL('/payment/confirmation', baseUrl);
|
|
53
|
-
confirmationUrl.searchParams.set('productId', productId);
|
|
54
|
-
confirmationUrl.searchParams.set('productName', product.name || productId);
|
|
55
|
-
confirmationUrl.searchParams.set('amount', trial && product.trial?.days ? '0' : String(amount));
|
|
56
|
-
confirmationUrl.searchParams.set('currency', 'USD');
|
|
57
|
-
confirmationUrl.searchParams.set('frequency', frequency || 'once');
|
|
58
|
-
confirmationUrl.searchParams.set('paymentMethod', 'stripe');
|
|
59
|
-
confirmationUrl.searchParams.set('trial', String(!!trial && !!product.trial?.days));
|
|
60
|
-
confirmationUrl.searchParams.set('orderId', orderId);
|
|
61
|
-
confirmationUrl.searchParams.set('track', 'true');
|
|
62
|
-
confirmationUrl = confirmationUrl.toString();
|
|
63
|
-
|
|
64
|
-
let cancelUrl = new URL('/payment/checkout', baseUrl);
|
|
65
|
-
cancelUrl.searchParams.set('product', productId);
|
|
66
|
-
if (frequency) {
|
|
67
|
-
cancelUrl.searchParams.set('frequency', frequency);
|
|
68
|
-
}
|
|
69
|
-
cancelUrl.searchParams.set('payment', 'cancelled');
|
|
70
|
-
cancelUrl = cancelUrl.toString();
|
|
35
|
+
assistant.log(`Stripe checkout: type=${productType}, priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
|
|
71
36
|
|
|
72
37
|
// Build session params based on product type
|
|
73
38
|
let sessionParams;
|
|
@@ -81,7 +46,7 @@ module.exports = {
|
|
|
81
46
|
// Create the checkout session
|
|
82
47
|
const session = await stripe.checkout.sessions.create(sessionParams);
|
|
83
48
|
|
|
84
|
-
assistant
|
|
49
|
+
assistant.log(`Stripe session created: sessionId=${session.id}, mode=${sessionParams.mode}, url=${session.url}`);
|
|
85
50
|
|
|
86
51
|
return {
|
|
87
52
|
id: session.id,
|
|
@@ -165,7 +130,7 @@ async function resolveCustomer(stripe, uid, email, assistant) {
|
|
|
165
130
|
|
|
166
131
|
if (search.data.length > 0) {
|
|
167
132
|
const existing = search.data[0];
|
|
168
|
-
assistant
|
|
133
|
+
assistant.log(`Found existing Stripe customer: ${existing.id}`);
|
|
169
134
|
return existing;
|
|
170
135
|
}
|
|
171
136
|
|
|
@@ -179,6 +144,6 @@ async function resolveCustomer(stripe, uid, email, assistant) {
|
|
|
179
144
|
}
|
|
180
145
|
|
|
181
146
|
const customer = await stripe.customers.create(params);
|
|
182
|
-
assistant
|
|
147
|
+
assistant.log(`Created new Stripe customer: ${customer.id}`);
|
|
183
148
|
return customer;
|
|
184
149
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fetch = require('wonderful-fetch');
|
|
2
|
+
const resolvePriceId = require('../../../../libraries/payment-processors/resolve-price-id.js');
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Test intent processor
|
|
@@ -16,12 +17,12 @@ module.exports = {
|
|
|
16
17
|
* @param {string} options.productId - Product ID from config
|
|
17
18
|
* @param {string} options.frequency - 'monthly' or 'annually' (subscriptions only)
|
|
18
19
|
* @param {boolean} options.trial - Whether to include a trial period (subscriptions only)
|
|
19
|
-
* @param {
|
|
20
|
-
* @param {
|
|
20
|
+
* @param {string} options.confirmationUrl - Success redirect URL
|
|
21
|
+
* @param {string} options.cancelUrl - Cancel redirect URL
|
|
21
22
|
* @param {object} options.assistant - Assistant instance
|
|
22
23
|
* @returns {object} { id, url, raw }
|
|
23
24
|
*/
|
|
24
|
-
async createIntent({ uid, orderId, product, productId, frequency, trial,
|
|
25
|
+
async createIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, assistant }) {
|
|
25
26
|
// Guard: test processor is not available in production
|
|
26
27
|
if (assistant.isProduction()) {
|
|
27
28
|
throw new Error('Test processor is not available in production');
|
|
@@ -30,10 +31,10 @@ module.exports = {
|
|
|
30
31
|
const productType = product.type || 'subscription';
|
|
31
32
|
|
|
32
33
|
if (productType === 'subscription') {
|
|
33
|
-
return createSubscriptionIntent({ uid, orderId, product,
|
|
34
|
+
return createSubscriptionIntent({ uid, orderId, product, frequency, trial, confirmationUrl, assistant });
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
return createOneTimeIntent({ uid, orderId, product, productId,
|
|
37
|
+
return createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, assistant });
|
|
37
38
|
},
|
|
38
39
|
};
|
|
39
40
|
|
|
@@ -41,12 +42,9 @@ module.exports = {
|
|
|
41
42
|
* Create a test subscription intent
|
|
42
43
|
* Generates Stripe-shaped subscription + customer.subscription.created event
|
|
43
44
|
*/
|
|
44
|
-
async function createSubscriptionIntent({ uid, orderId, product,
|
|
45
|
+
async function createSubscriptionIntent({ uid, orderId, product, frequency, trial, confirmationUrl, assistant }) {
|
|
45
46
|
// Get the price ID for the requested frequency
|
|
46
|
-
const priceId = product
|
|
47
|
-
if (!priceId) {
|
|
48
|
-
throw new Error(`No Stripe price found for ${productId}/${frequency}`);
|
|
49
|
-
}
|
|
47
|
+
const priceId = resolvePriceId(product, 'subscription', frequency);
|
|
50
48
|
|
|
51
49
|
// Generate IDs
|
|
52
50
|
const timestamp = Date.now();
|
|
@@ -94,14 +92,14 @@ async function createSubscriptionIntent({ uid, orderId, product, productId, freq
|
|
|
94
92
|
data: { object: subscription },
|
|
95
93
|
};
|
|
96
94
|
|
|
97
|
-
assistant
|
|
95
|
+
assistant.log(`Test subscription intent: sessionId=${sessionId}, subscriptionId=${subscriptionId}, eventId=${eventId}, trial=${!!subscription.trial_start}`);
|
|
98
96
|
|
|
99
97
|
// Auto-fire webhook
|
|
100
|
-
fireWebhook({ event,
|
|
98
|
+
fireWebhook({ event, assistant });
|
|
101
99
|
|
|
102
100
|
return {
|
|
103
101
|
id: sessionId,
|
|
104
|
-
url:
|
|
102
|
+
url: confirmationUrl,
|
|
105
103
|
raw: { id: sessionId, object: 'checkout.session', subscription: subscriptionId },
|
|
106
104
|
};
|
|
107
105
|
}
|
|
@@ -110,12 +108,9 @@ async function createSubscriptionIntent({ uid, orderId, product, productId, freq
|
|
|
110
108
|
* Create a test one-time payment intent
|
|
111
109
|
* Generates Stripe-shaped checkout session + checkout.session.completed event
|
|
112
110
|
*/
|
|
113
|
-
async function createOneTimeIntent({ uid, orderId, product, productId,
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
if (!priceId) {
|
|
117
|
-
throw new Error(`No Stripe price found for ${productId}/once`);
|
|
118
|
-
}
|
|
111
|
+
async function createOneTimeIntent({ uid, orderId, product, productId, confirmationUrl, assistant }) {
|
|
112
|
+
// Validate that a price exists (resolvePriceId throws if not found)
|
|
113
|
+
resolvePriceId(product, 'one-time', null);
|
|
119
114
|
|
|
120
115
|
// Generate IDs
|
|
121
116
|
const timestamp = Date.now();
|
|
@@ -141,14 +136,14 @@ async function createOneTimeIntent({ uid, orderId, product, productId, config, M
|
|
|
141
136
|
data: { object: session },
|
|
142
137
|
};
|
|
143
138
|
|
|
144
|
-
assistant
|
|
139
|
+
assistant.log(`Test one-time intent: sessionId=${sessionId}, eventId=${eventId}, productId=${productId}`);
|
|
145
140
|
|
|
146
141
|
// Auto-fire webhook
|
|
147
|
-
fireWebhook({ event,
|
|
142
|
+
fireWebhook({ event, assistant });
|
|
148
143
|
|
|
149
144
|
return {
|
|
150
145
|
id: sessionId,
|
|
151
|
-
url:
|
|
146
|
+
url: confirmationUrl,
|
|
152
147
|
raw: { id: sessionId, object: 'checkout.session', mode: 'payment' },
|
|
153
148
|
};
|
|
154
149
|
}
|
|
@@ -156,14 +151,14 @@ async function createOneTimeIntent({ uid, orderId, product, productId, config, M
|
|
|
156
151
|
/**
|
|
157
152
|
* Fire-and-forget webhook to trigger the full pipeline
|
|
158
153
|
*/
|
|
159
|
-
function fireWebhook({ event,
|
|
160
|
-
const webhookUrl = `${Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_KEY}`;
|
|
154
|
+
function fireWebhook({ event, assistant }) {
|
|
155
|
+
const webhookUrl = `${assistant.Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_KEY}`;
|
|
161
156
|
fetch(webhookUrl, {
|
|
162
157
|
method: 'POST',
|
|
163
158
|
response: 'json',
|
|
164
159
|
body: event,
|
|
165
160
|
timeout: 15000,
|
|
166
161
|
}).catch((e) => {
|
|
167
|
-
assistant
|
|
162
|
+
assistant.log(`Test processor auto-webhook failed: ${e.message}`);
|
|
168
163
|
});
|
|
169
164
|
}
|
|
@@ -14,8 +14,9 @@ const STATE_KEY = process.env.BACKEND_MANAGER_KEY
|
|
|
14
14
|
* Build context object with common OAuth2 data
|
|
15
15
|
* Used by GET, POST, DELETE handlers
|
|
16
16
|
*/
|
|
17
|
-
async function buildContext({ assistant,
|
|
18
|
-
const
|
|
17
|
+
async function buildContext({ assistant, user, settings, requireProvider = true }) {
|
|
18
|
+
const Manager = assistant.Manager;
|
|
19
|
+
const { admin } = Manager.libraries;
|
|
19
20
|
|
|
20
21
|
// Require authentication
|
|
21
22
|
if (!user.authenticated) {
|
|
@@ -8,14 +8,14 @@ const {
|
|
|
8
8
|
*
|
|
9
9
|
* Revokes tokens with the provider (best effort) and removes the connection.
|
|
10
10
|
*/
|
|
11
|
-
module.exports = async ({ assistant,
|
|
12
|
-
const context = await buildContext({ assistant,
|
|
11
|
+
module.exports = async ({ assistant, user, settings }) => {
|
|
12
|
+
const context = await buildContext({ assistant, user, settings });
|
|
13
13
|
|
|
14
14
|
if (context.error) {
|
|
15
15
|
return assistant.respond(context.error.message, { code: context.error.code });
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const { admin, oauth2Provider, targetUid, targetUser, clientId, clientSecret } = context;
|
|
18
|
+
const { Manager, admin, oauth2Provider, targetUid, targetUser, clientId, clientSecret } = context;
|
|
19
19
|
|
|
20
20
|
assistant.log('OAuth2 DELETE request', { provider: settings.provider });
|
|
21
21
|
|
|
@@ -12,8 +12,8 @@ const {
|
|
|
12
12
|
* - authorize (default): Get authorization URL
|
|
13
13
|
* - status: Check connection status
|
|
14
14
|
*/
|
|
15
|
-
module.exports = async ({ assistant,
|
|
16
|
-
const context = await buildContext({ assistant,
|
|
15
|
+
module.exports = async ({ assistant, user, settings }) => {
|
|
16
|
+
const context = await buildContext({ assistant, user, settings });
|
|
17
17
|
|
|
18
18
|
if (context.error) {
|
|
19
19
|
return assistant.respond(context.error.message, { code: context.error.code });
|
|
@@ -14,18 +14,16 @@ const {
|
|
|
14
14
|
* - tokenize (default): Exchange authorization code for tokens
|
|
15
15
|
* - refresh: Refresh access token
|
|
16
16
|
*/
|
|
17
|
-
module.exports = async ({ assistant,
|
|
18
|
-
const { admin } = libraries;
|
|
19
|
-
|
|
17
|
+
module.exports = async ({ assistant, user, settings }) => {
|
|
20
18
|
assistant.log('OAuth2 POST request', { action: settings.action });
|
|
21
19
|
|
|
22
20
|
switch (settings.action) {
|
|
23
21
|
case 'refresh':
|
|
24
|
-
return processRefresh({ assistant,
|
|
22
|
+
return processRefresh({ assistant, user, settings });
|
|
25
23
|
|
|
26
24
|
case 'tokenize':
|
|
27
25
|
default:
|
|
28
|
-
return processTokenize({ assistant,
|
|
26
|
+
return processTokenize({ assistant, settings });
|
|
29
27
|
}
|
|
30
28
|
};
|
|
31
29
|
|
|
@@ -33,7 +31,9 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
33
31
|
// Handlers
|
|
34
32
|
// ============================================================================
|
|
35
33
|
|
|
36
|
-
async function processTokenize({ assistant,
|
|
34
|
+
async function processTokenize({ assistant, settings }) {
|
|
35
|
+
const Manager = assistant.Manager;
|
|
36
|
+
const { admin } = Manager.libraries;
|
|
37
37
|
assistant.log('processTokenize settings', {
|
|
38
38
|
hasCode: !!settings.code,
|
|
39
39
|
codeType: typeof settings.code,
|
|
@@ -167,14 +167,14 @@ async function processTokenize({ assistant, Manager, admin, settings }) {
|
|
|
167
167
|
return assistant.respond({ success: true });
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
async function processRefresh({ assistant,
|
|
171
|
-
const context = await buildContext({ assistant,
|
|
170
|
+
async function processRefresh({ assistant, user, settings }) {
|
|
171
|
+
const context = await buildContext({ assistant, user, settings });
|
|
172
172
|
|
|
173
173
|
if (context.error) {
|
|
174
174
|
return assistant.respond(context.error.message, { code: context.error.code });
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
const { admin, oauth2Provider, targetUid, targetUser, clientId, clientSecret } = context;
|
|
177
|
+
const { Manager, admin, oauth2Provider, targetUid, targetUser, clientId, clientSecret } = context;
|
|
178
178
|
|
|
179
179
|
const refreshToken = targetUser?.oauth2?.[settings.provider]?.token?.refresh_token;
|
|
180
180
|
|
|
@@ -26,10 +26,10 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
|
|
|
26
26
|
let count = 0;
|
|
27
27
|
|
|
28
28
|
// Sign out of main session
|
|
29
|
-
count += await signOutOfSession(
|
|
29
|
+
count += await signOutOfSession(assistant, uid, sessionPath);
|
|
30
30
|
|
|
31
31
|
// Legacy for somiibo and old electron-manager
|
|
32
|
-
count += await signOutOfSession(
|
|
32
|
+
count += await signOutOfSession(assistant, uid, 'gatherings/online');
|
|
33
33
|
|
|
34
34
|
// Revoke Firebase refresh tokens
|
|
35
35
|
try {
|
|
@@ -47,7 +47,8 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
|
|
|
47
47
|
/**
|
|
48
48
|
* Sign out of a specific session path
|
|
49
49
|
*/
|
|
50
|
-
async function signOutOfSession(
|
|
50
|
+
async function signOutOfSession(assistant, uid, sessionPath) {
|
|
51
|
+
const { admin } = assistant.Manager.libraries;
|
|
51
52
|
let count = 0;
|
|
52
53
|
|
|
53
54
|
const snapshot = await admin.database().ref(sessionPath)
|