backend-manager 5.0.122 → 5.0.124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/CLAUDE.md +18 -19
  3. package/README.md +7 -7
  4. package/package.json +1 -1
  5. package/src/manager/cron/daily/reset-usage.js +79 -73
  6. package/src/manager/cron/daily.js +2 -53
  7. package/src/manager/cron/frequent/abandoned-carts.js +148 -0
  8. package/src/manager/cron/frequent.js +3 -0
  9. package/src/manager/cron/runner.js +60 -0
  10. package/src/manager/events/firestore/payments-disputes/on-write.js +358 -0
  11. package/src/manager/events/firestore/payments-webhooks/analytics.js +245 -121
  12. package/src/manager/events/firestore/payments-webhooks/on-write.js +32 -2
  13. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +11 -34
  14. package/src/manager/helpers/analytics.js +2 -2
  15. package/src/manager/helpers/usage.js +44 -20
  16. package/src/manager/helpers/user.js +2 -1
  17. package/src/manager/index.js +10 -0
  18. package/src/manager/libraries/abandoned-cart-config.js +12 -0
  19. package/src/manager/libraries/email.js +5 -5
  20. package/src/manager/libraries/openai.js +76 -7
  21. package/src/manager/libraries/payment/discount-codes.js +40 -0
  22. package/src/manager/libraries/recaptcha.js +36 -0
  23. package/src/manager/routes/app/get.js +1 -1
  24. package/src/manager/routes/marketing/contact/post.js +11 -29
  25. package/src/manager/routes/payments/discount/get.js +22 -0
  26. package/src/manager/routes/payments/dispute-alert/post.js +93 -0
  27. package/src/manager/routes/payments/dispute-alert/processors/chargeblast.js +43 -0
  28. package/src/manager/routes/payments/intent/post.js +29 -0
  29. package/src/manager/routes/payments/intent/processors/chargebee.js +59 -7
  30. package/src/manager/routes/payments/intent/processors/stripe.js +55 -7
  31. package/src/manager/routes/test/usage/post.js +10 -6
  32. package/src/manager/schemas/payments/discount/get.js +9 -0
  33. package/src/manager/schemas/payments/dispute-alert/post.js +6 -0
  34. package/src/manager/schemas/payments/intent/post.js +16 -0
  35. package/src/test/runner.js +14 -4
  36. package/src/test/test-accounts.js +18 -0
  37. package/templates/backend-manager-config.json +7 -1
  38. package/templates/firestore.rules +9 -1
  39. package/test/_legacy/usage.js +5 -5
  40. package/test/routes/marketing/contact.js +3 -2
  41. package/test/routes/payments/discount.js +80 -0
  42. package/test/routes/payments/dispute-alert.js +271 -0
  43. package/test/routes/payments/intent.js +60 -0
  44. package/test/routes/test/usage.js +134 -30
  45. 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/CLAUDE.md CHANGED
@@ -646,44 +646,43 @@ npx bm firestore:delete users/test123 --emulator
646
646
 
647
647
  ### Overview
648
648
 
649
- Usage is tracked per-metric (e.g., `requests`, `marketing-subscribe`) with two counters:
650
- - `period`: Current month's count, reset on the 1st of each month
649
+ Usage is tracked per-metric (e.g., `requests`, `sponsorships`) with four fields:
650
+ - `monthly`: Current month's count, reset on the 1st of each month by cron
651
+ - `daily`: Current day's count, reset every day by cron
651
652
  - `total`: All-time count, never resets
653
+ - `last`: Object with `id`, `timestamp`, `timestampUNIX` of the last usage event
652
654
 
653
- ### Product Rate Limit Modes
655
+ ### Limits & Daily Caps
654
656
 
655
- Products can set a `rateLimit` field to control how limits are enforced:
657
+ Limits are always specified as **monthly** values in product config (e.g., `limits.requests = 100` means 100/month).
656
658
 
657
- | Value | Behavior | Default |
658
- |-------|----------|---------|
659
- | `'monthly'` | Full monthly limit available at any time | Yes |
660
- | `'daily'` | Proportional daily cap: `ceil(limit * dayOfMonth / daysInMonth)` | No |
659
+ By default, limits are enforced with **daily caps** to prevent users from burning their entire monthly quota in a single day. Two checks are applied:
661
660
 
662
- Example config (not in the template add per-product as needed):
661
+ 1. **Flat daily cap**: `ceil(monthlyLimit / daysInMonth)`max uses per day
662
+ - e.g., 100/month in a 31-day month = `ceil(100/31)` = 4/day
663
+ 2. **Proportional monthly cap**: `ceil(monthlyLimit * dayOfMonth / daysInMonth)` — running total
664
+ - Prevents accumulating too much too fast even within daily limits
665
+ - e.g., Day 15 of a 30-day month with 100/month limit = max 50 used so far
666
+
667
+ Products can opt out of daily caps by setting `rateLimit: 'monthly'` (default is `'daily'`):
663
668
  ```json
664
669
  {
665
670
  "id": "basic",
666
671
  "limits": { "requests": 100 },
667
- "rateLimit": "daily"
672
+ "rateLimit": "monthly"
668
673
  }
669
674
  ```
670
675
 
671
- With `rateLimit: 'daily'` and 100 requests/month in a 30-day month:
672
- - Day 1: max 4 requests used so far
673
- - Day 15: max 50 requests used so far
674
- - Day 30: max 100 requests (full allocation)
675
-
676
- Unused days roll forward — a user who doesn't use the product for 2 weeks can use a burst later.
677
-
678
676
  ### Reset Schedule
679
677
 
680
678
  | Target | Frequency | What happens |
681
679
  |--------|-----------|-------------|
682
680
  | Local storage | Daily | Cleared entirely |
683
681
  | `usage` collection (unauthenticated) | Daily | Deleted entirely |
684
- | User doc `usage.*.period` (authenticated) | Monthly (1st) | Reset to 0 |
682
+ | User doc `usage.*.daily` (authenticated) | Daily | Reset to 0 |
683
+ | User doc `usage.*.monthly` (authenticated) | Monthly (1st) | Reset to 0 |
685
684
 
686
- The daily cron runs at midnight UTC (`0 0 * * *`). Authenticated user period resets only execute on the 1st of the month.
685
+ The daily cron (`reset-usage.js`) runs at midnight UTC. It collects all users with non-zero counters across all metrics, then performs a single write per user to reset daily (and monthly on the 1st).
687
686
 
688
687
  ## Subscription System
689
688
 
package/README.md CHANGED
@@ -139,7 +139,7 @@ Create `backend-manager-config.json` in your functions directory:
139
139
  sentry: {
140
140
  dsn: 'https://xxx@xxx.ingest.sentry.io/xxx',
141
141
  },
142
- google_analytics: {
142
+ googleAnalytics: {
143
143
  id: 'G-XXXXXXXXXX',
144
144
  secret: 'your-ga4-secret',
145
145
  },
@@ -473,7 +473,7 @@ const userProps = Manager.User(existingData, { defaults: true }).properties;
473
473
  affiliate: { code, referrals, referrer },
474
474
  activity: { lastActivity, created, geolocation, client },
475
475
  api: { clientId, privateKey },
476
- usage: { requests: { period, total, last } },
476
+ usage: { requests: { monthly, daily, total, last } },
477
477
  personal: { birthday, gender, location, name, company, telephone },
478
478
  oauth2: {}
479
479
  }
@@ -519,13 +519,13 @@ const usage = await Manager.Usage().init(assistant, {
519
519
  });
520
520
 
521
521
  // Check and validate limits
522
- const currentUsage = usage.getUsage('requests'); // Get current period usage
523
- const limit = usage.getLimit('requests'); // Get plan limit
524
- await usage.validate('requests'); // Throws if over limit
522
+ const currentUsage = usage.getUsage('requests'); // Get current monthly usage
523
+ const limit = usage.getLimit('requests'); // Get plan limit (monthly)
524
+ await usage.validate('requests'); // Throws if over daily or monthly limit
525
525
 
526
- // Increment usage
526
+ // Increment usage (increments monthly, daily, and total counters)
527
527
  usage.increment('requests', 1);
528
- usage.set('requests', 0); // Reset to specific value
528
+ usage.set('requests', 0); // Reset monthly to specific value
529
529
 
530
530
  // Save to Firestore
531
531
  await usage.update();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.122",
3
+ "version": "5.0.124",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -4,7 +4,8 @@
4
4
  * Runs daily at midnight UTC and handles different reset schedules:
5
5
  * - Local storage: cleared every day
6
6
  * - Unauthenticated usage collection: deleted every day
7
- * - Authenticated user period counters: reset on the 1st (or 2nd as grace window) of each month
7
+ * - Authenticated user daily counters: reset every day
8
+ * - Authenticated user monthly counters: reset on the 1st of each month
8
9
  */
9
10
  module.exports = async ({ Manager, assistant, context, libraries }) => {
10
11
  const storage = Manager.storage({ name: 'usage', temporary: true, clear: false, log: false });
@@ -12,24 +13,18 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
12
13
  assistant.log('Starting...');
13
14
 
14
15
  // Clear local storage (daily)
15
- await clearLocal(assistant, storage);
16
+ clearLocal(assistant, storage);
16
17
 
17
18
  // Clear unauthenticated usage collection (daily)
18
19
  await clearUnauthenticatedUsage(assistant, libraries);
19
20
 
20
- // Reset authenticated user periods (monthly - 1st or 2nd of month)
21
- await resetAuthenticatedUsage(Manager, assistant, libraries);
21
+ // Reset authenticated user counters (daily + monthly on 1st)
22
+ await resetAuthenticated(Manager, assistant);
22
23
  };
23
24
 
24
- async function clearLocal(assistant, storage) {
25
- assistant.log('[local]: Starting...');
26
-
27
- assistant.log('[local]: storage(apps)', storage.get('apps', {}).value());
28
- assistant.log('[local]: storage(users)', storage.get('users', {}).value());
29
-
30
- // Clear storage
25
+ function clearLocal(assistant, storage) {
26
+ assistant.log('[local]: Clearing...');
31
27
  storage.setState({}).write();
32
-
33
28
  assistant.log('[local]: Completed!');
34
29
  }
35
30
 
@@ -40,96 +35,107 @@ async function clearUnauthenticatedUsage(assistant, libraries) {
40
35
 
41
36
  await admin.firestore().recursiveDelete(admin.firestore().collection('usage'))
42
37
  .then(() => {
43
- assistant.log('[unauthenticated]: Deleted usage collection');
38
+ assistant.log('[unauthenticated]: Completed!');
44
39
  })
45
40
  .catch((e) => {
46
41
  assistant.errorify(`Error deleting usage collection: ${e}`, { code: 500, log: true });
47
42
  });
48
43
  }
49
44
 
50
- async function resetAuthenticatedUsage(Manager, assistant, libraries) {
51
- const { admin } = libraries;
52
- const dayOfMonth = new Date().getDate();
53
-
54
- // Only reset on the 1st of the month
55
- if (dayOfMonth !== 1) {
56
- assistant.log('[authenticated]: Skipping period reset (not the 1st of the month)');
57
- return;
58
- }
59
-
60
- assistant.log('[authenticated]: Monthly reset starting...');
61
-
62
- // Gather all unique metric names from ALL products
45
+ async function resetAuthenticated(Manager, assistant) {
46
+ const isFirstOfMonth = new Date().getDate() === 1;
63
47
  const products = Manager.config.payment?.products || [];
64
- const metrics = {};
65
48
 
49
+ // Gather all metric names from all products
50
+ const metricSet = { requests: true };
66
51
  for (const product of products) {
67
- const limits = product.limits || {};
68
-
69
- for (const key of Object.keys(limits)) {
70
- metrics[key] = true;
52
+ for (const key of Object.keys(product.limits || {})) {
53
+ metricSet[key] = true;
71
54
  }
72
55
  }
56
+ const metricNames = Object.keys(metricSet);
73
57
 
74
- // Ensure requests is always included
75
- metrics.requests = true;
58
+ assistant.log(`[authenticated]: Resetting ${isFirstOfMonth ? 'daily + monthly' : 'daily'} for metrics`, metricNames);
76
59
 
77
- const metricNames = Object.keys(metrics);
60
+ // Collect all user IDs that need resetting (deduplicated across metrics)
61
+ // Each entry maps uid -> { ref, usage } so we only write once per user
62
+ const usersToReset = {};
78
63
 
79
- assistant.log('[authenticated]: Resetting metrics', metricNames);
80
-
81
- // Reset each metric for users who have usage > 0
82
64
  for (const metric of metricNames) {
83
- assistant.log(`[authenticated]: Resetting ${metric} for all users`);
84
-
85
- await Manager.Utilities().iterateCollection((batch, index) => {
86
- return new Promise(async (resolve, reject) => {
65
+ // Query users with daily > 0 for this metric
66
+ await Manager.Utilities().iterateCollection((batch) => {
67
+ return new Promise(async (resolve) => {
87
68
  for (const doc of batch.docs) {
88
- const data = doc.data();
89
-
90
- // Normalize the metric
91
- data.usage = data.usage || {};
92
- data.usage[metric] = data.usage[metric] || {};
93
- data.usage[metric].period = data.usage[metric].period || 0;
94
- data.usage[metric].total = data.usage[metric].total || 0;
95
- data.usage[metric].last = data.usage[metric].last || {};
96
-
97
- // Skip if already 0
98
- if (data.usage[metric].period <= 0) {
99
- continue;
69
+ if (!usersToReset[doc.id]) {
70
+ usersToReset[doc.id] = { ref: doc.ref, usage: doc.data().usage || {} };
100
71
  }
101
-
102
- // Reset the metric
103
- const original = data.usage[metric].period;
104
- data.usage[metric].period = 0;
105
-
106
- // Update the doc
107
- await doc.ref.update({ usage: data.usage })
108
- .then(r => {
109
- assistant.log(`[authenticated]: Reset ${metric} for ${doc.id} (${original} -> 0)`);
110
- })
111
- .catch(e => {
112
- assistant.errorify(`Error resetting ${metric} for ${doc.id}: ${e}`, { code: 500, log: true });
113
- });
114
72
  }
115
-
116
73
  return resolve();
117
74
  });
118
75
  }, {
119
76
  collection: 'users',
120
77
  where: [
121
- { field: `usage.${metric}.period`, operator: '>', value: 0 },
78
+ { field: `usage.${metric}.daily`, operator: '>', value: 0 },
122
79
  ],
123
80
  batchSize: 5000,
124
- log: true,
81
+ log: false,
125
82
  })
126
- .then((r) => {
127
- assistant.log(`[authenticated]: Reset ${metric} for all users complete!`);
83
+ .catch(e => {
84
+ assistant.errorify(`Error querying ${metric}.daily: ${e}`, { code: 500, log: true });
85
+ });
86
+
87
+ // On the 1st, also query users with monthly > 0
88
+ if (isFirstOfMonth) {
89
+ await Manager.Utilities().iterateCollection((batch) => {
90
+ return new Promise(async (resolve) => {
91
+ for (const doc of batch.docs) {
92
+ if (!usersToReset[doc.id]) {
93
+ usersToReset[doc.id] = { ref: doc.ref, usage: doc.data().usage || {} };
94
+ }
95
+ }
96
+ return resolve();
97
+ });
98
+ }, {
99
+ collection: 'users',
100
+ where: [
101
+ { field: `usage.${metric}.monthly`, operator: '>', value: 0 },
102
+ ],
103
+ batchSize: 5000,
104
+ log: false,
105
+ })
106
+ .catch(e => {
107
+ assistant.errorify(`Error querying ${metric}.monthly: ${e}`, { code: 500, log: true });
108
+ });
109
+ }
110
+ }
111
+
112
+ const userIds = Object.keys(usersToReset);
113
+ assistant.log(`[authenticated]: Found ${userIds.length} users to reset`);
114
+
115
+ // Single write per user: reset daily (always) + monthly (on 1st) for all metrics
116
+ for (const uid of userIds) {
117
+ const { ref, usage } = usersToReset[uid];
118
+
119
+ for (const metric of metricNames) {
120
+ if (!usage[metric]) {
121
+ continue;
122
+ }
123
+
124
+ usage[metric].daily = 0;
125
+
126
+ if (isFirstOfMonth) {
127
+ usage[metric].monthly = 0;
128
+ }
129
+ }
130
+
131
+ await ref.update({ usage })
132
+ .then(() => {
133
+ assistant.log(`[authenticated]: Reset ${uid}`);
128
134
  })
129
135
  .catch(e => {
130
- assistant.errorify(`Error resetting ${metric} for all users: ${e}`, { code: 500, log: true });
136
+ assistant.errorify(`Error resetting ${uid}: ${e}`, { code: 500, log: true });
131
137
  });
132
138
  }
133
139
 
134
- assistant.log('[authenticated]: Monthly reset completed!');
140
+ assistant.log(`[authenticated]: Completed!`);
135
141
  }
@@ -1,54 +1,3 @@
1
- const jetpack = require('fs-jetpack');
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,3 @@
1
+ const run = require('./runner.js');
2
+
3
+ module.exports = async (options) => run('frequent', options);
@@ -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
+ }