backend-manager 5.0.121 → 5.0.123
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 +48 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/commands/deploy.js +1 -1
- package/src/manager/cron/daily.js +2 -53
- package/src/manager/cron/frequent/abandoned-carts.js +148 -0
- package/src/manager/cron/frequent.js +3 -0
- package/src/manager/cron/runner.js +60 -0
- package/src/manager/events/firestore/payments-disputes/on-write.js +358 -0
- package/src/manager/events/firestore/payments-webhooks/analytics.js +245 -121
- package/src/manager/events/firestore/payments-webhooks/on-write.js +32 -2
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +11 -34
- package/src/manager/helpers/analytics.js +2 -2
- package/src/manager/index.js +10 -0
- package/src/manager/libraries/abandoned-cart-config.js +12 -0
- package/src/manager/libraries/email.js +5 -5
- package/src/manager/libraries/openai.js +76 -7
- package/src/manager/libraries/payment/discount-codes.js +40 -0
- package/src/manager/libraries/recaptcha.js +36 -0
- package/src/manager/routes/app/get.js +1 -1
- package/src/manager/routes/marketing/contact/post.js +11 -29
- package/src/manager/routes/payments/discount/get.js +22 -0
- package/src/manager/routes/payments/dispute-alert/post.js +93 -0
- package/src/manager/routes/payments/dispute-alert/processors/chargeblast.js +43 -0
- package/src/manager/routes/payments/intent/post.js +29 -0
- package/src/manager/routes/payments/intent/processors/chargebee.js +59 -7
- package/src/manager/routes/payments/intent/processors/stripe.js +55 -7
- package/src/manager/schemas/payments/discount/get.js +9 -0
- package/src/manager/schemas/payments/dispute-alert/post.js +6 -0
- package/src/manager/schemas/payments/intent/post.js +16 -0
- package/src/test/runner.js +14 -4
- package/src/test/test-accounts.js +18 -0
- package/templates/backend-manager-config.json +7 -1
- package/templates/firestore.rules +9 -1
- package/test/routes/marketing/contact.js +3 -2
- package/test/routes/payments/discount.js +80 -0
- package/test/routes/payments/dispute-alert.js +271 -0
- package/test/routes/payments/intent.js +60 -0
- package/test/rules/payments-carts.js +371 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,54 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.0.123] - 2026-03-10
|
|
18
|
+
### Added
|
|
19
|
+
- Dispute alert system: `POST /payments/dispute-alert` endpoint with Chargeblast processor for ingesting payment dispute webhooks
|
|
20
|
+
- Firestore trigger (`payments-disputes/{alertId}`) that matches disputes to Stripe invoices by date/amount/card, auto-refunds, and cancels subscriptions
|
|
21
|
+
- Discount code system: `GET /payments/discount` validation endpoint and `discount-codes.js` library (FLASH20, SAVE10, WELCOME15)
|
|
22
|
+
- Discount code integration in payment intent flow — auto-creates/reuses Stripe and Chargebee coupons with deterministic IDs
|
|
23
|
+
- Meta Conversions API and TikTok Events API tracking alongside existing GA4 in payment analytics
|
|
24
|
+
- Subscription renewal tracking as payment events (fires on `invoice.payment_succeeded` / `PAYMENT.SALE.COMPLETED` even without a state transition)
|
|
25
|
+
- `attribution`, `discount`, and `supplemental` fields on payment intent schema for checkout context tracking
|
|
26
|
+
- Intent data (attribution, discount, supplemental) propagated to order objects during webhook on-write
|
|
27
|
+
- `meta.pixelId` and `tiktok.pixelCode` fields in config template
|
|
28
|
+
- Journey test accounts for discount and attribution flows
|
|
29
|
+
- Tests for discount validation and dispute alert endpoints
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- Renamed config key `google_analytics` → `googleAnalytics`
|
|
33
|
+
- Payment analytics rewritten with independent per-platform fire functions (`fireGA4`, `fireMeta`, `fireTikTok`)
|
|
34
|
+
- Test runner module resolution now tries normal resolution first before falling back to search paths
|
|
35
|
+
- reCAPTCHA marketing contact test skipped when `TEST_EXTENDED_MODE` is not set
|
|
36
|
+
|
|
37
|
+
# [5.0.122] - 2026-03-09
|
|
38
|
+
### Added
|
|
39
|
+
- Abandoned cart reminder system: sends escalating emails at 15min, 3h, 24h, 48h, 72h to users who visit checkout but don't complete payment
|
|
40
|
+
- `payments-carts/{uid}` Firestore collection with security rules (client-side write, server-side completion)
|
|
41
|
+
- `bm_cronFrequent` Cloud Function running every 10 minutes for sub-daily cron jobs
|
|
42
|
+
- Shared cron runner (`cron/runner.js`) consolidating daily and frequent cron orchestrators
|
|
43
|
+
- `main/order/refunded` and `main/order/abandoned-cart` email templates
|
|
44
|
+
- Firestore rules test for `payments-carts` (12 test cases)
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- Migrated v1 email templates to v2 SendGrid template IDs
|
|
48
|
+
- `cron/daily.js` and `cron/frequent.js` now delegate to shared `cron/runner.js`
|
|
49
|
+
- Payment analytics tracking now fires independently of transitions
|
|
50
|
+
|
|
51
|
+
# [5.0.120] - 2026-03-09
|
|
52
|
+
### Added
|
|
53
|
+
- reCAPTCHA verification on `POST /payments/intent` route (reads `verification.g-recaptcha-response` from request body)
|
|
54
|
+
- Shared `libraries/recaptcha.js` module for reCAPTCHA token verification (replaces duplicate helpers)
|
|
55
|
+
- `verification` field in `payments/intent` schema to accept the reCAPTCHA token object
|
|
56
|
+
|
|
57
|
+
### Security
|
|
58
|
+
- reCAPTCHA failure responses now return generic "Request could not be verified" (403) instead of revealing the verification mechanism
|
|
59
|
+
- reCAPTCHA verification runs in all environments except automated tests (`isTesting()`)
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
- Marketing contact routes (`POST /marketing/contact`, `bm_api add-marketing-contact`) now use shared `recaptcha.verify()` instead of inline helpers
|
|
63
|
+
- Marketing reCAPTCHA checks skip during automated tests (consistent with payment intent)
|
|
64
|
+
|
|
17
65
|
# [5.0.119] - 2026-03-07
|
|
18
66
|
### Added
|
|
19
67
|
- `POST /marketing/email-preferences` route for unsubscribe/resubscribe via SendGrid ASM suppression groups
|
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,54 +1,3 @@
|
|
|
1
|
-
const
|
|
1
|
+
const run = require('./runner.js');
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
* Daily cron job runner
|
|
5
|
-
*
|
|
6
|
-
* Executes all daily jobs from:
|
|
7
|
-
* 1. BEM core jobs (src/manager/cron/daily/)
|
|
8
|
-
* 2. Custom project jobs (functions/hooks/cron/daily/)
|
|
9
|
-
*/
|
|
10
|
-
module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
11
|
-
// Set log prefix
|
|
12
|
-
assistant.setLogPrefix('cron/daily()');
|
|
13
|
-
|
|
14
|
-
// Log
|
|
15
|
-
assistant.log('Starting...');
|
|
16
|
-
|
|
17
|
-
// Load BEM jobs
|
|
18
|
-
await loadAndExecuteJobs(`${__dirname}/daily`, Manager, context);
|
|
19
|
-
|
|
20
|
-
// Load custom jobs
|
|
21
|
-
await loadAndExecuteJobs(`${Manager.cwd}/hooks/cron/daily`, Manager, context);
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
async function loadAndExecuteJobs(jobsPath, Manager, context) {
|
|
25
|
-
const jobs = jetpack.list(jobsPath) || [];
|
|
26
|
-
|
|
27
|
-
// Log
|
|
28
|
-
Manager.assistant.log(`Located ${jobs.length} jobs @ ${jobsPath}...`);
|
|
29
|
-
|
|
30
|
-
for (const job of jobs) {
|
|
31
|
-
// Create new assistant for each job
|
|
32
|
-
const assistant = Manager.Assistant();
|
|
33
|
-
|
|
34
|
-
// Load job
|
|
35
|
-
const jobName = job.replace('.js', '');
|
|
36
|
-
|
|
37
|
-
// Set log prefix
|
|
38
|
-
assistant.setLogPrefix(`cron/daily/${jobName}()`);
|
|
39
|
-
|
|
40
|
-
// Log
|
|
41
|
-
assistant.log('Starting...');
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
// Load and execute job
|
|
45
|
-
const handler = require(`${jobsPath}/${job}`);
|
|
46
|
-
await handler({ Manager, assistant, context, libraries: Manager.libraries });
|
|
47
|
-
|
|
48
|
-
assistant.log('Completed!');
|
|
49
|
-
} catch (e) {
|
|
50
|
-
assistant.errorify(`Error executing: ${e}`, { code: 500, sentry: true });
|
|
51
|
-
throw e;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
3
|
+
module.exports = async (options) => run('daily', options);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const powertools = require('node-powertools');
|
|
2
|
+
const { REMINDER_DELAYS, COLLECTION } = require('../../libraries/abandoned-cart-config.js');
|
|
3
|
+
const { sendOrderEmail } = require('../../events/firestore/payments-webhooks/transitions/send-email.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Abandoned cart reminder cron job
|
|
7
|
+
*
|
|
8
|
+
* Queries payments-carts where status is pending and nextReminderAt has passed,
|
|
9
|
+
* sends escalating email reminders, and advances or completes the tracker.
|
|
10
|
+
*/
|
|
11
|
+
module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
12
|
+
const { admin } = libraries;
|
|
13
|
+
const nowUNIX = Math.floor(Date.now() / 1000);
|
|
14
|
+
|
|
15
|
+
// Query all pending carts that are due for a reminder
|
|
16
|
+
const snapshot = await admin.firestore()
|
|
17
|
+
.collection(COLLECTION)
|
|
18
|
+
.where('status', '==', 'pending')
|
|
19
|
+
.where('nextReminderAt', '<=', nowUNIX)
|
|
20
|
+
.get();
|
|
21
|
+
|
|
22
|
+
if (snapshot.empty) {
|
|
23
|
+
assistant.log('No abandoned carts due for reminders');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
assistant.log(`Processing ${snapshot.size} abandoned cart reminder(s)...`);
|
|
28
|
+
|
|
29
|
+
let sent = 0;
|
|
30
|
+
let completed = 0;
|
|
31
|
+
let skipped = 0;
|
|
32
|
+
|
|
33
|
+
for (const doc of snapshot.docs) {
|
|
34
|
+
const data = doc.data();
|
|
35
|
+
const uid = data.owner;
|
|
36
|
+
const reminderIndex = data.reminderIndex || 0;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
// Fetch user doc for email sending
|
|
40
|
+
const userSnap = await admin.firestore().doc(`users/${uid}`).get();
|
|
41
|
+
|
|
42
|
+
if (!userSnap.exists) {
|
|
43
|
+
assistant.log(`User ${uid} not found, marking cart completed`);
|
|
44
|
+
await markCompleted(doc, admin, nowUNIX);
|
|
45
|
+
skipped++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const userDoc = userSnap.data();
|
|
50
|
+
|
|
51
|
+
// Belt-and-suspenders: skip if user already has active paid subscription
|
|
52
|
+
if (userDoc.subscription?.status === 'active'
|
|
53
|
+
&& userDoc.subscription?.product?.id !== 'basic') {
|
|
54
|
+
assistant.log(`User ${uid} now has active subscription, marking cart completed`);
|
|
55
|
+
await markCompleted(doc, admin, nowUNIX);
|
|
56
|
+
skipped++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build checkout URL from cart data
|
|
61
|
+
const checkoutUrl = buildCheckoutUrl(Manager.project.websiteUrl, data);
|
|
62
|
+
|
|
63
|
+
// Send reminder email
|
|
64
|
+
assistant.log(`Sending abandoned cart reminder #${reminderIndex + 1} to uid=${uid}, product=${data.productId}`);
|
|
65
|
+
|
|
66
|
+
sendOrderEmail({
|
|
67
|
+
template: 'main/order/abandoned-cart',
|
|
68
|
+
subject: `Complete your ${data.productId} checkout`,
|
|
69
|
+
categories: ['order/abandoned-cart', `order/abandoned-cart/reminder-${reminderIndex + 1}`],
|
|
70
|
+
userDoc,
|
|
71
|
+
assistant,
|
|
72
|
+
data: {
|
|
73
|
+
abandonedCart: {
|
|
74
|
+
productId: data.productId,
|
|
75
|
+
type: data.type,
|
|
76
|
+
frequency: data.frequency,
|
|
77
|
+
reminderNumber: reminderIndex + 1,
|
|
78
|
+
totalReminders: REMINDER_DELAYS.length,
|
|
79
|
+
checkoutUrl: checkoutUrl,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
sent++;
|
|
85
|
+
|
|
86
|
+
// Advance to next reminder or mark completed if this was the last one
|
|
87
|
+
const nextIndex = reminderIndex + 1;
|
|
88
|
+
|
|
89
|
+
if (nextIndex >= REMINDER_DELAYS.length) {
|
|
90
|
+
assistant.log(`Last reminder sent for uid=${uid}, marking cart completed`);
|
|
91
|
+
await markCompleted(doc, admin, nowUNIX);
|
|
92
|
+
completed++;
|
|
93
|
+
} else {
|
|
94
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
95
|
+
const updatedNowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
96
|
+
|
|
97
|
+
await doc.ref.set({
|
|
98
|
+
reminderIndex: nextIndex,
|
|
99
|
+
nextReminderAt: updatedNowUNIX + REMINDER_DELAYS[nextIndex],
|
|
100
|
+
metadata: {
|
|
101
|
+
updated: {
|
|
102
|
+
timestamp: now,
|
|
103
|
+
timestampUNIX: updatedNowUNIX,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
}, { merge: true });
|
|
107
|
+
|
|
108
|
+
assistant.log(`Advanced uid=${uid} to reminder index ${nextIndex}, next at ${updatedNowUNIX + REMINDER_DELAYS[nextIndex]}`);
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
assistant.error(`Error processing abandoned cart for uid=${uid}: ${e.message}`, e);
|
|
112
|
+
// Continue to next document
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
assistant.log(`Completed! (${sent} sent, ${completed} completed, ${skipped} skipped)`);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Mark a cart document as completed
|
|
121
|
+
*/
|
|
122
|
+
async function markCompleted(doc, admin, nowUNIX) {
|
|
123
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
124
|
+
|
|
125
|
+
await doc.ref.set({
|
|
126
|
+
status: 'completed',
|
|
127
|
+
metadata: {
|
|
128
|
+
updated: {
|
|
129
|
+
timestamp: now,
|
|
130
|
+
timestampUNIX: nowUNIX,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
}, { merge: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build checkout URL from cart data
|
|
138
|
+
*/
|
|
139
|
+
function buildCheckoutUrl(baseUrl, data) {
|
|
140
|
+
const url = new URL('/payment/checkout', baseUrl);
|
|
141
|
+
url.searchParams.set('product', data.productId);
|
|
142
|
+
|
|
143
|
+
if (data.frequency) {
|
|
144
|
+
url.searchParams.set('frequency', data.frequency);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return url.toString();
|
|
148
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const jetpack = require('fs-jetpack');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared cron job runner
|
|
5
|
+
*
|
|
6
|
+
* Discovers and executes all .js job files from:
|
|
7
|
+
* 1. BEM core jobs directory
|
|
8
|
+
* 2. Custom project hooks directory
|
|
9
|
+
*
|
|
10
|
+
* @param {string} name - Cron schedule name (e.g., 'daily', 'frequent')
|
|
11
|
+
* @param {object} options
|
|
12
|
+
* @param {object} options.Manager - Manager instance
|
|
13
|
+
* @param {object} options.assistant - Assistant instance
|
|
14
|
+
* @param {object} options.context - Cloud Function context
|
|
15
|
+
*/
|
|
16
|
+
module.exports = async function run(name, { Manager, assistant, context }) {
|
|
17
|
+
// Set log prefix
|
|
18
|
+
assistant.setLogPrefix(`cron/${name}()`);
|
|
19
|
+
|
|
20
|
+
// Log
|
|
21
|
+
assistant.log('Starting...');
|
|
22
|
+
|
|
23
|
+
// Load BEM jobs
|
|
24
|
+
await loadAndExecuteJobs(name, `${__dirname}/${name}`, Manager, context);
|
|
25
|
+
|
|
26
|
+
// Load custom jobs
|
|
27
|
+
await loadAndExecuteJobs(name, `${Manager.cwd}/hooks/cron/${name}`, Manager, context);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function loadAndExecuteJobs(name, jobsPath, Manager, context) {
|
|
31
|
+
const jobs = jetpack.list(jobsPath) || [];
|
|
32
|
+
|
|
33
|
+
// Log
|
|
34
|
+
Manager.assistant.log(`Located ${jobs.length} jobs @ ${jobsPath}...`);
|
|
35
|
+
|
|
36
|
+
for (const job of jobs) {
|
|
37
|
+
// Create new assistant for each job
|
|
38
|
+
const assistant = Manager.Assistant();
|
|
39
|
+
|
|
40
|
+
// Load job
|
|
41
|
+
const jobName = job.replace('.js', '');
|
|
42
|
+
|
|
43
|
+
// Set log prefix
|
|
44
|
+
assistant.setLogPrefix(`cron/${name}/${jobName}()`);
|
|
45
|
+
|
|
46
|
+
// Log
|
|
47
|
+
assistant.log('Starting...');
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// Load and execute job
|
|
51
|
+
const handler = require(`${jobsPath}/${job}`);
|
|
52
|
+
await handler({ Manager, assistant, context, libraries: Manager.libraries });
|
|
53
|
+
|
|
54
|
+
assistant.log('Completed!');
|
|
55
|
+
} catch (e) {
|
|
56
|
+
assistant.errorify(`Error executing: ${e}`, { code: 500, sentry: true });
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|