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.
- package/CHANGELOG.md +48 -0
- package/CLAUDE.md +18 -19
- package/README.md +7 -7
- package/package.json +1 -1
- package/src/manager/cron/daily/reset-usage.js +79 -73
- 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/helpers/usage.js +44 -20
- package/src/manager/helpers/user.js +2 -1
- 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/routes/test/usage/post.js +10 -6
- 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/_legacy/usage.js +5 -5
- 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/routes/test/usage.js +134 -30
- 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`, `
|
|
650
|
-
- `
|
|
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
|
-
###
|
|
655
|
+
### Limits & Daily Caps
|
|
654
656
|
|
|
655
|
-
|
|
657
|
+
Limits are always specified as **monthly** values in product config (e.g., `limits.requests = 100` means 100/month).
|
|
656
658
|
|
|
657
|
-
|
|
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
|
-
|
|
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": "
|
|
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.*.
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
21
|
-
await
|
|
21
|
+
// Reset authenticated user counters (daily + monthly on 1st)
|
|
22
|
+
await resetAuthenticated(Manager, assistant);
|
|
22
23
|
};
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
assistant.log('[local]:
|
|
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]:
|
|
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
|
|
51
|
-
const
|
|
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
|
|
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
|
-
|
|
75
|
-
metrics.requests = true;
|
|
58
|
+
assistant.log(`[authenticated]: Resetting ${isFirstOfMonth ? 'daily + monthly' : 'daily'} for metrics`, metricNames);
|
|
76
59
|
|
|
77
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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}.
|
|
78
|
+
{ field: `usage.${metric}.daily`, operator: '>', value: 0 },
|
|
122
79
|
],
|
|
123
80
|
batchSize: 5000,
|
|
124
|
-
log:
|
|
81
|
+
log: false,
|
|
125
82
|
})
|
|
126
|
-
.
|
|
127
|
-
assistant.
|
|
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 ${
|
|
136
|
+
assistant.errorify(`Error resetting ${uid}: ${e}`, { code: 500, log: true });
|
|
131
137
|
});
|
|
132
138
|
}
|
|
133
139
|
|
|
134
|
-
assistant.log(
|
|
140
|
+
assistant.log(`[authenticated]: Completed!`);
|
|
135
141
|
}
|
|
@@ -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
|
+
}
|