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
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
const fetch = require('wonderful-fetch');
|
|
2
|
+
const moment = require('moment');
|
|
3
|
+
const { inferContact } = require('../../../libraries/infer-contact.js');
|
|
4
|
+
|
|
1
5
|
const MAX_POLL_TIME_MS = 30000;
|
|
2
6
|
const POLL_INTERVAL_MS = 500;
|
|
3
7
|
const MAX_ACCOUNT_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
|
@@ -7,11 +11,13 @@ const MAX_ACCOUNT_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
7
11
|
*
|
|
8
12
|
* Called by client after account creation to:
|
|
9
13
|
* 1. Poll for user doc to exist (waits for onCreate to complete)
|
|
10
|
-
* 2.
|
|
11
|
-
* 3.
|
|
12
|
-
* 4.
|
|
14
|
+
* 2. Validate (already processed, account age)
|
|
15
|
+
* 3. Gather all data (client details, inferred contact)
|
|
16
|
+
* 4. Write everything to user doc in one merge
|
|
17
|
+
* 5. Process affiliate referral (writes to referrer's doc)
|
|
18
|
+
* 6. Send welcome emails + add to marketing lists (non-blocking)
|
|
13
19
|
*/
|
|
14
|
-
module.exports = async ({ assistant,
|
|
20
|
+
module.exports = async ({ assistant, user, settings, libraries }) => {
|
|
15
21
|
const { admin } = libraries;
|
|
16
22
|
|
|
17
23
|
// Require authentication
|
|
@@ -30,7 +36,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
30
36
|
assistant.log(`signup(): Starting for ${uid}`, settings);
|
|
31
37
|
|
|
32
38
|
// 1. Poll for user doc to exist (wait for onCreate to complete)
|
|
33
|
-
const userDoc = await pollForUserDoc(
|
|
39
|
+
const userDoc = await pollForUserDoc(assistant, uid);
|
|
34
40
|
|
|
35
41
|
if (!userDoc) {
|
|
36
42
|
return assistant.respond('User document not found after waiting. Please try again.', { code: 500 });
|
|
@@ -56,52 +62,24 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
56
62
|
return assistant.respond('Account is too old to process signup', { code: 400 });
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
// 4.
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (settings.affiliateCode && !attribution.affiliate?.code) {
|
|
64
|
-
attribution.affiliate = { code: settings.affiliateCode };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const affiliateCode = attribution.affiliate?.code || null;
|
|
68
|
-
|
|
69
|
-
// 5. Build update record with client details
|
|
70
|
-
const userRecord = {
|
|
71
|
-
flags: {
|
|
72
|
-
signupProcessed: true,
|
|
73
|
-
},
|
|
74
|
-
activity: {
|
|
75
|
-
...settings.context,
|
|
76
|
-
geolocation: {
|
|
77
|
-
...(settings.context?.geolocation || {}),
|
|
78
|
-
...assistant.request.geolocation,
|
|
79
|
-
},
|
|
80
|
-
client: {
|
|
81
|
-
...(settings.context?.client || {}),
|
|
82
|
-
...assistant.request.client,
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
attribution: attribution || {},
|
|
86
|
-
metadata: Manager.Metadata().set({ tag: 'user/signup' }),
|
|
87
|
-
};
|
|
65
|
+
// 4. Gather all data, then write once
|
|
66
|
+
const email = user.auth.email;
|
|
67
|
+
const inferred = await inferUserContact(assistant, email);
|
|
68
|
+
const userRecord = buildUserRecord(assistant, settings, inferred);
|
|
88
69
|
|
|
89
|
-
assistant.log(`signup():
|
|
70
|
+
assistant.log(`signup(): Writing user record for ${uid}`, userRecord);
|
|
90
71
|
|
|
91
|
-
// Update user doc with client details
|
|
92
72
|
await admin.firestore().doc(`users/${uid}`)
|
|
93
73
|
.set(userRecord, { merge: true })
|
|
94
74
|
.catch((e) => {
|
|
95
75
|
assistant.error(`signup(): Failed to update user record:`, e);
|
|
96
76
|
});
|
|
97
77
|
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
});
|
|
104
|
-
}
|
|
78
|
+
// 5. Process affiliate referral (writes to referrer's doc, not this user's)
|
|
79
|
+
await processAffiliate(assistant, uid, settings);
|
|
80
|
+
|
|
81
|
+
// 6. Send emails + marketing (non-blocking, fire-and-forget)
|
|
82
|
+
sendEmailsAndMarketing(assistant, uid, email, inferred);
|
|
105
83
|
|
|
106
84
|
return assistant.respond({ signedUp: true });
|
|
107
85
|
};
|
|
@@ -109,7 +87,8 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
109
87
|
/**
|
|
110
88
|
* Poll for user doc to exist (wait for onCreate to complete)
|
|
111
89
|
*/
|
|
112
|
-
async function pollForUserDoc(
|
|
90
|
+
async function pollForUserDoc(assistant, uid) {
|
|
91
|
+
const { admin } = assistant.Manager.libraries;
|
|
113
92
|
const startTime = Date.now();
|
|
114
93
|
|
|
115
94
|
while (Date.now() - startTime < MAX_POLL_TIME_MS) {
|
|
@@ -133,25 +112,99 @@ async function pollForUserDoc(admin, assistant, uid) {
|
|
|
133
112
|
}
|
|
134
113
|
|
|
135
114
|
/**
|
|
136
|
-
*
|
|
115
|
+
* Build the full user record: client details, attribution, and inferred contact
|
|
116
|
+
*/
|
|
117
|
+
function buildUserRecord(assistant, settings, inferred) {
|
|
118
|
+
const Manager = assistant.Manager;
|
|
119
|
+
const attribution = settings.attribution;
|
|
120
|
+
|
|
121
|
+
// Legacy support: if affiliateCode exists, normalize to new format
|
|
122
|
+
if (settings.affiliateCode && !attribution.affiliate?.code) {
|
|
123
|
+
attribution.affiliate = { code: settings.affiliateCode };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const record = {
|
|
127
|
+
flags: {
|
|
128
|
+
signupProcessed: true,
|
|
129
|
+
},
|
|
130
|
+
activity: {
|
|
131
|
+
...settings.context,
|
|
132
|
+
geolocation: {
|
|
133
|
+
...(settings.context?.geolocation || {}),
|
|
134
|
+
...assistant.request.geolocation,
|
|
135
|
+
},
|
|
136
|
+
client: {
|
|
137
|
+
...(settings.context?.client || {}),
|
|
138
|
+
...assistant.request.client,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
attribution: attribution || {},
|
|
142
|
+
metadata: Manager.Metadata().set({ tag: 'user/signup' }),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Add inferred name/company if available
|
|
146
|
+
if (inferred) {
|
|
147
|
+
record.personal = {
|
|
148
|
+
...(inferred.firstName || inferred.lastName ? {
|
|
149
|
+
name: {
|
|
150
|
+
...(inferred.firstName ? { first: inferred.firstName } : {}),
|
|
151
|
+
...(inferred.lastName ? { last: inferred.lastName } : {}),
|
|
152
|
+
},
|
|
153
|
+
} : {}),
|
|
154
|
+
...(inferred.company ? { company: { name: inferred.company } } : {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return record;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Infer name/company from email using AI (or regex fallback)
|
|
163
|
+
* Returns the inferred contact info, or null on failure
|
|
164
|
+
*/
|
|
165
|
+
async function inferUserContact(assistant, email) {
|
|
166
|
+
try {
|
|
167
|
+
const inferred = await inferContact(email, assistant);
|
|
168
|
+
|
|
169
|
+
if (!inferred?.firstName && !inferred?.lastName && !inferred?.company) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
assistant.log(`signup(): Inferred contact: ${inferred.firstName || ''} ${inferred.lastName || ''}, company=${inferred.company || ''} (method=${inferred.method})`);
|
|
174
|
+
|
|
175
|
+
return inferred;
|
|
176
|
+
} catch (e) {
|
|
177
|
+
assistant.error('signup(): Name inference failed:', e);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Process affiliate referral if affiliate code provided
|
|
184
|
+
* Writes to the referrer's doc (not the current user's)
|
|
137
185
|
*/
|
|
138
|
-
async function
|
|
186
|
+
async function processAffiliate(assistant, uid, settings) {
|
|
187
|
+
const { admin } = assistant.Manager.libraries;
|
|
188
|
+
const affiliateCode = settings.attribution?.affiliate?.code
|
|
189
|
+
|| settings.affiliateCode
|
|
190
|
+
|| null;
|
|
191
|
+
|
|
139
192
|
if (!affiliateCode) {
|
|
140
193
|
return;
|
|
141
194
|
}
|
|
142
195
|
|
|
143
|
-
assistant.log(`
|
|
196
|
+
assistant.log(`processAffiliate(): Looking for referrer with code ${affiliateCode}`);
|
|
144
197
|
|
|
145
198
|
const snapshot = await admin.firestore().collection('users')
|
|
146
199
|
.where('affiliate.code', '==', affiliateCode)
|
|
147
200
|
.get()
|
|
148
201
|
.catch((e) => {
|
|
149
|
-
assistant.error(`
|
|
202
|
+
assistant.error(`processAffiliate(): Failed to find referrer:`, e);
|
|
150
203
|
throw e;
|
|
151
204
|
});
|
|
152
205
|
|
|
153
206
|
if (snapshot.empty) {
|
|
154
|
-
assistant.log(`
|
|
207
|
+
assistant.log(`processAffiliate(): No referrer found with code ${affiliateCode}`);
|
|
155
208
|
return;
|
|
156
209
|
}
|
|
157
210
|
|
|
@@ -162,13 +215,12 @@ async function updateReferral(admin, assistant, newUserUid, affiliateCode) {
|
|
|
162
215
|
let referrals = referrerData?.affiliate?.referrals || [];
|
|
163
216
|
referrals = Array.isArray(referrals) ? referrals : [];
|
|
164
217
|
|
|
165
|
-
// Add new referral
|
|
166
218
|
referrals.push({
|
|
167
|
-
uid:
|
|
219
|
+
uid: uid,
|
|
168
220
|
timestamp: assistant.meta.startTime.timestamp,
|
|
169
221
|
});
|
|
170
222
|
|
|
171
|
-
assistant.log(`
|
|
223
|
+
assistant.log(`processAffiliate(): Appending referral to ${referrerDoc.id}`, referrals);
|
|
172
224
|
|
|
173
225
|
await admin.firestore().doc(`users/${referrerDoc.id}`)
|
|
174
226
|
.set({
|
|
@@ -177,9 +229,157 @@ async function updateReferral(admin, assistant, newUserUid, affiliateCode) {
|
|
|
177
229
|
},
|
|
178
230
|
}, { merge: true })
|
|
179
231
|
.then(() => {
|
|
180
|
-
assistant.log(`
|
|
232
|
+
assistant.log(`processAffiliate(): Success`);
|
|
181
233
|
})
|
|
182
234
|
.catch((e) => {
|
|
183
|
-
assistant.error(`
|
|
235
|
+
assistant.error(`processAffiliate(): Failed to update referrer:`, e);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Send welcome emails and add to marketing lists (non-blocking, fire-and-forget)
|
|
241
|
+
*/
|
|
242
|
+
function sendEmailsAndMarketing(assistant, uid, email, inferred) {
|
|
243
|
+
const Manager = assistant.Manager;
|
|
244
|
+
const shouldSend = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
245
|
+
|
|
246
|
+
if (!shouldSend) {
|
|
247
|
+
assistant.log(`signup(): Skipping emails/marketing (BEM_TESTING=true, TEST_EXTENDED_MODE not set)`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
assistant.log(`signup(): Sending emails/adding to marketing for ${uid}`);
|
|
252
|
+
|
|
253
|
+
// Add to marketing lists (SendGrid + Beehiiv) via centralized endpoint
|
|
254
|
+
fetch(`${Manager.project.apiUrl}/backend-manager/marketing/contact`, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
response: 'json',
|
|
257
|
+
body: {
|
|
258
|
+
backendManagerKey: process.env.BACKEND_MANAGER_KEY,
|
|
259
|
+
email: email,
|
|
260
|
+
firstName: inferred?.firstName || '',
|
|
261
|
+
lastName: inferred?.lastName || '',
|
|
262
|
+
source: 'user:signup',
|
|
263
|
+
},
|
|
264
|
+
}).catch(e => assistant.error('signup(): marketing-contact failed:', e));
|
|
265
|
+
|
|
266
|
+
// Send welcome emails (non-blocking, don't fail on error)
|
|
267
|
+
sendWelcomeEmail(assistant, email).catch(e => assistant.error('signup(): sendWelcomeEmail failed:', e));
|
|
268
|
+
sendCheckupEmail(assistant, email).catch(e => assistant.error('signup(): sendCheckupEmail failed:', e));
|
|
269
|
+
sendFeedbackEmail(assistant, email).catch(e => assistant.error('signup(): sendFeedbackEmail failed:', e));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Send welcome email (immediate)
|
|
274
|
+
*/
|
|
275
|
+
function sendWelcomeEmail(assistant, email) {
|
|
276
|
+
const Manager = assistant.Manager;
|
|
277
|
+
const mailer = Manager.Email(assistant);
|
|
278
|
+
|
|
279
|
+
return mailer.send({
|
|
280
|
+
to: email,
|
|
281
|
+
categories: ['account/welcome'],
|
|
282
|
+
subject: `Welcome to ${Manager.config.brand.name}!`,
|
|
283
|
+
template: 'd-b7f8da3c98ad49a2ad1e187f3a67b546',
|
|
284
|
+
group: 25928,
|
|
285
|
+
copy: false,
|
|
286
|
+
data: {
|
|
287
|
+
email: {
|
|
288
|
+
preview: `Welcome aboard! I'm Ian, the CEO and founder of ${Manager.config.brand.name}. I'm here to ensure your journey with us gets off to a great start.`,
|
|
289
|
+
},
|
|
290
|
+
body: {
|
|
291
|
+
title: `Welcome to ${Manager.config.brand.name}!`,
|
|
292
|
+
message: `
|
|
293
|
+
Welcome aboard!
|
|
294
|
+
<br><br>
|
|
295
|
+
I'm Ian, the founder and CEO of <strong>${Manager.config.brand.name}</strong>, and I'm thrilled to have you with us.
|
|
296
|
+
Your journey begins today, and we are committed to supporting you every step of the way.
|
|
297
|
+
<br><br>
|
|
298
|
+
We are dedicated to ensuring your experience is exceptional.
|
|
299
|
+
Feel free to reply directly to this email with any questions you may have.
|
|
300
|
+
<br><br>
|
|
301
|
+
Thank you for choosing <strong>${Manager.config.brand.name}</strong>. Here's to new beginnings!
|
|
302
|
+
`,
|
|
303
|
+
},
|
|
304
|
+
signoff: {
|
|
305
|
+
type: 'personal',
|
|
306
|
+
name: 'Ian Wiedenman, CEO',
|
|
307
|
+
url: `https://ianwiedenman.com?utm_source=welcome-email&utm_medium=email&utm_campaign=${Manager.config.app.id}`,
|
|
308
|
+
urlText: '@ianwieds',
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
})
|
|
312
|
+
.then((result) => {
|
|
313
|
+
assistant.log('sendWelcomeEmail(): Success', result.status);
|
|
314
|
+
return result;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Send checkup email (7 days after signup)
|
|
320
|
+
*/
|
|
321
|
+
function sendCheckupEmail(assistant, email) {
|
|
322
|
+
const Manager = assistant.Manager;
|
|
323
|
+
const mailer = Manager.Email(assistant);
|
|
324
|
+
|
|
325
|
+
return mailer.send({
|
|
326
|
+
to: email,
|
|
327
|
+
categories: ['account/checkup'],
|
|
328
|
+
subject: `How's your experience with ${Manager.config.brand.name}?`,
|
|
329
|
+
template: 'd-b7f8da3c98ad49a2ad1e187f3a67b546',
|
|
330
|
+
group: 25928,
|
|
331
|
+
copy: false,
|
|
332
|
+
sendAt: moment().add(7, 'days').unix(),
|
|
333
|
+
data: {
|
|
334
|
+
email: {
|
|
335
|
+
preview: `Checking in from ${Manager.config.brand.name} to see how things are going. Let us know if you have any questions or feedback!`,
|
|
336
|
+
},
|
|
337
|
+
body: {
|
|
338
|
+
title: `How's everything going?`,
|
|
339
|
+
message: `
|
|
340
|
+
Hi there,
|
|
341
|
+
<br><br>
|
|
342
|
+
It's Ian again from <strong>${Manager.config.brand.name}</strong>. Just checking in to see how things are going for you.
|
|
343
|
+
<br><br>
|
|
344
|
+
Have you had a chance to explore all our features? Any questions or feedback for us?
|
|
345
|
+
<br><br>
|
|
346
|
+
We're always here to help, so don't hesitate to reach out. Just reply to this email and we'll get back to you as soon as possible.
|
|
347
|
+
<br><br>
|
|
348
|
+
Thank you for choosing <strong>${Manager.config.brand.name}</strong>. Here's to new beginnings!
|
|
349
|
+
`,
|
|
350
|
+
},
|
|
351
|
+
signoff: {
|
|
352
|
+
type: 'personal',
|
|
353
|
+
name: 'Ian Wiedenman, CEO',
|
|
354
|
+
url: `https://ianwiedenman.com?utm_source=checkup-email&utm_medium=email&utm_campaign=${Manager.config.app.id}`,
|
|
355
|
+
urlText: '@ianwieds',
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
})
|
|
359
|
+
.then((result) => {
|
|
360
|
+
assistant.log('sendCheckupEmail(): Success', result.status);
|
|
361
|
+
return result;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Send feedback email (14 days after signup)
|
|
367
|
+
*/
|
|
368
|
+
function sendFeedbackEmail(assistant, email) {
|
|
369
|
+
const Manager = assistant.Manager;
|
|
370
|
+
const mailer = Manager.Email(assistant);
|
|
371
|
+
|
|
372
|
+
return mailer.send({
|
|
373
|
+
to: email,
|
|
374
|
+
categories: ['engagement/feedback'],
|
|
375
|
+
subject: `Want to share your feedback about ${Manager.config.brand.name}?`,
|
|
376
|
+
template: 'd-c1522214c67b47058669acc5a81ed663',
|
|
377
|
+
group: 25928,
|
|
378
|
+
copy: false,
|
|
379
|
+
sendAt: moment().add(14, 'days').unix(),
|
|
380
|
+
})
|
|
381
|
+
.then((result) => {
|
|
382
|
+
assistant.log('sendFeedbackEmail(): Success', result.status);
|
|
383
|
+
return result;
|
|
184
384
|
});
|
|
185
385
|
}
|
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Schema for POST /admin/email
|
|
3
|
+
*
|
|
4
|
+
* Recipients (to, cc, bcc) accept flexible formats:
|
|
5
|
+
* - String email: "user@example.com"
|
|
6
|
+
* - UID string: "uid:abc123" (auto-resolves from Firestore)
|
|
7
|
+
* - Object: { email: "user@example.com", name: "John" }
|
|
8
|
+
* - Array of any of the above
|
|
3
9
|
*/
|
|
4
10
|
module.exports = () => ({
|
|
5
|
-
to: { types: ['array'], default: [] },
|
|
6
|
-
cc: { types: ['array'], default: [] },
|
|
7
|
-
bcc: { types: ['array'], default: [] },
|
|
11
|
+
to: { types: ['array', 'string', 'object'], default: [] },
|
|
12
|
+
cc: { types: ['array', 'string', 'object'], default: [] },
|
|
13
|
+
bcc: { types: ['array', 'string', 'object'], default: [] },
|
|
8
14
|
from: { types: ['object'], default: undefined },
|
|
9
15
|
replyTo: { types: ['string'], default: undefined },
|
|
10
16
|
subject: { types: ['string'], default: undefined },
|
|
11
17
|
template: { types: ['string'], default: 'd-b7f8da3c98ad49a2ad1e187f3a67b546' },
|
|
12
18
|
group: { types: ['number'], default: 24077 },
|
|
13
|
-
sendAt: { types: ['number'], default: undefined },
|
|
19
|
+
sendAt: { types: ['number', 'string'], default: undefined },
|
|
14
20
|
user: { types: ['object'], default: {} },
|
|
15
21
|
data: { types: ['object'], default: {} },
|
|
16
22
|
categories: { types: ['array'], default: [] },
|
|
17
23
|
copy: { types: ['boolean'], default: true },
|
|
18
|
-
ensureUnique: { types: ['boolean'], default: true },
|
|
19
24
|
html: { types: ['string'], default: undefined },
|
|
20
25
|
});
|
package/src/test/run-tests.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* BEM Test Runner Entry Point
|
|
5
|
-
* This script is executed by the CLI test command inside
|
|
5
|
+
* This script is executed by the CLI test command inside the Firebase emulator
|
|
6
6
|
* It reads configuration from BEM_TEST_CONFIG environment variable and runs the test suite
|
|
7
7
|
*/
|
|
8
8
|
|
package/src/test/runner.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const os = require('os');
|
|
1
2
|
const path = require('path');
|
|
2
3
|
const jetpack = require('fs-jetpack');
|
|
3
4
|
const chalk = require('chalk');
|
|
@@ -52,6 +53,16 @@ class TestRunner {
|
|
|
52
53
|
* Main run method
|
|
53
54
|
*/
|
|
54
55
|
async run() {
|
|
56
|
+
// Abort if BEM is running from the user's home directory (e.g., accidental ~/node_modules install)
|
|
57
|
+
const homeDir = os.homedir();
|
|
58
|
+
if (__dirname.startsWith(path.join(homeDir, 'node_modules'))) {
|
|
59
|
+
console.error(chalk.red('\n ERROR: BEM is running from ~/node_modules (home directory install).'));
|
|
60
|
+
console.error(chalk.red(' This is likely an accidental global install that shadows local project copies.'));
|
|
61
|
+
console.error(chalk.red(` Fix: rm -rf ${path.join(homeDir, 'node_modules')} ${path.join(homeDir, 'package.json')} ${path.join(homeDir, 'package-lock.json')}`));
|
|
62
|
+
console.error(chalk.red(` Running from: ${__dirname}\n`));
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
// Set testing flag to skip external API calls (emails, SendGrid)
|
|
56
67
|
process.env.BEM_TESTING = 'true';
|
|
57
68
|
|
|
@@ -175,6 +175,24 @@ const JOURNEY_ACCOUNTS = {
|
|
|
175
175
|
subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
|
|
176
176
|
},
|
|
177
177
|
},
|
|
178
|
+
'journey-payments-plan-change': {
|
|
179
|
+
id: 'journey-payments-plan-change',
|
|
180
|
+
uid: '_test-journey-payments-plan-change',
|
|
181
|
+
email: '_test.journey-payments-plan-change@{domain}',
|
|
182
|
+
properties: {
|
|
183
|
+
roles: {},
|
|
184
|
+
subscription: { product: { id: 'basic' }, status: 'active' }, // Test's first step overwrites with correct paid product from config
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
'journey-payments-one-time': {
|
|
188
|
+
id: 'journey-payments-one-time',
|
|
189
|
+
uid: '_test-journey-payments-one-time',
|
|
190
|
+
email: '_test.journey-payments-one-time@{domain}',
|
|
191
|
+
properties: {
|
|
192
|
+
roles: {},
|
|
193
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
194
|
+
},
|
|
195
|
+
},
|
|
178
196
|
};
|
|
179
197
|
|
|
180
198
|
/**
|
|
@@ -72,18 +72,37 @@
|
|
|
72
72
|
},
|
|
73
73
|
},
|
|
74
74
|
},
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
75
|
+
{
|
|
76
|
+
id: 'ultimate',
|
|
77
|
+
name: 'Ultimate',
|
|
78
|
+
type: 'subscription',
|
|
79
|
+
limits: {
|
|
80
|
+
requests: 10000,
|
|
81
|
+
},
|
|
82
|
+
prices: {
|
|
83
|
+
monthly: {
|
|
84
|
+
amount: 19.99,
|
|
85
|
+
stripe: null,
|
|
86
|
+
paypal: null,
|
|
87
|
+
},
|
|
88
|
+
annually: {
|
|
89
|
+
amount: 199.99,
|
|
90
|
+
stripe: null,
|
|
91
|
+
paypal: null,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'credits-100',
|
|
97
|
+
name: '100 Credits',
|
|
98
|
+
type: 'one-time',
|
|
99
|
+
prices: {
|
|
100
|
+
once: {
|
|
101
|
+
amount: 9.99,
|
|
102
|
+
stripe: null,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
87
106
|
// Add more products/tiers here
|
|
88
107
|
],
|
|
89
108
|
},
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - One-Time Payment Failure
|
|
3
|
+
* Simulates: invoice.payment_failed for a non-subscription invoice → purchase-failed transition
|
|
4
|
+
*
|
|
5
|
+
* This verifies the webhook routing for one-time invoice failures:
|
|
6
|
+
* invoice.payment_failed with no subscription billing_reason → category: 'one-time'
|
|
7
|
+
*
|
|
8
|
+
* Uses the journey-payments-one-time account (one-time events don't modify subscription state)
|
|
9
|
+
*/
|
|
10
|
+
module.exports = {
|
|
11
|
+
description: 'Payment journey: one-time invoice.payment_failed → purchase-failed',
|
|
12
|
+
type: 'suite',
|
|
13
|
+
timeout: 30000,
|
|
14
|
+
|
|
15
|
+
tests: [
|
|
16
|
+
{
|
|
17
|
+
name: 'resolve-one-time-product',
|
|
18
|
+
async run({ accounts, assert, state, config }) {
|
|
19
|
+
const uid = accounts['journey-payments-one-time'].uid;
|
|
20
|
+
|
|
21
|
+
// Resolve first one-time product from config
|
|
22
|
+
const oneTimeProduct = config.payment.products.find(p => p.type === 'one-time' && p.prices?.once);
|
|
23
|
+
assert.ok(oneTimeProduct, 'Config should have at least one one-time product');
|
|
24
|
+
|
|
25
|
+
state.uid = uid;
|
|
26
|
+
state.productId = oneTimeProduct.id;
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
name: 'send-one-time-payment-failed',
|
|
32
|
+
async run({ http, assert, state, config }) {
|
|
33
|
+
state.eventId = `_test-evt-journey-onetime-fail-${Date.now()}`;
|
|
34
|
+
state.invoiceId = `_test-inv-onetime-fail-${Date.now()}`;
|
|
35
|
+
state.orderId = `0000-0000-0000`; // Fake orderId for test
|
|
36
|
+
|
|
37
|
+
// Send invoice.payment_failed with a non-subscription billing reason
|
|
38
|
+
// This routes to category: 'one-time' in the webhook parser
|
|
39
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
40
|
+
id: state.eventId,
|
|
41
|
+
type: 'invoice.payment_failed',
|
|
42
|
+
data: {
|
|
43
|
+
object: {
|
|
44
|
+
id: state.invoiceId,
|
|
45
|
+
object: 'invoice',
|
|
46
|
+
billing_reason: 'manual',
|
|
47
|
+
amount_due: 999,
|
|
48
|
+
amount_paid: 0,
|
|
49
|
+
status: 'open',
|
|
50
|
+
metadata: {
|
|
51
|
+
uid: state.uid,
|
|
52
|
+
orderId: state.orderId,
|
|
53
|
+
productId: state.productId,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
assert.isSuccess(response, 'Webhook should be accepted');
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
name: 'webhook-categorized-as-one-time',
|
|
65
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
66
|
+
await waitFor(async () => {
|
|
67
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
68
|
+
return doc?.status === 'completed' || doc?.status === 'failed';
|
|
69
|
+
}, 15000, 500);
|
|
70
|
+
|
|
71
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
72
|
+
assert.ok(webhookDoc, 'Webhook doc should exist');
|
|
73
|
+
assert.equal(webhookDoc.event?.category, 'one-time', 'Category should be one-time');
|
|
74
|
+
assert.equal(webhookDoc.event?.resourceType, 'invoice', 'Resource type should be invoice');
|
|
75
|
+
assert.equal(webhookDoc.transition, 'purchase-failed', 'Transition should be purchase-failed');
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
name: 'order-doc-created-with-failure',
|
|
81
|
+
async run({ firestore, assert, state }) {
|
|
82
|
+
const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
|
|
83
|
+
|
|
84
|
+
assert.ok(orderDoc, 'Order doc should exist');
|
|
85
|
+
assert.equal(orderDoc.type, 'one-time', 'Type should be one-time');
|
|
86
|
+
assert.equal(orderDoc.owner, state.uid, 'Owner should match');
|
|
87
|
+
assert.equal(orderDoc.processor, 'test', 'Processor should be test');
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
name: 'subscription-unchanged',
|
|
93
|
+
async run({ firestore, assert, state }) {
|
|
94
|
+
// One-time payment failures must NOT modify subscription state
|
|
95
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
96
|
+
|
|
97
|
+
assert.equal(
|
|
98
|
+
userDoc.subscription?.product?.id,
|
|
99
|
+
'basic',
|
|
100
|
+
'Subscription should remain basic after one-time failure',
|
|
101
|
+
);
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|