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
|
@@ -114,18 +114,18 @@ Usage.prototype.validate = function (name, options) {
|
|
|
114
114
|
options._forceReject = typeof options._forceReject === 'undefined' ? false : options._forceReject;
|
|
115
115
|
|
|
116
116
|
// Check for required options
|
|
117
|
-
const
|
|
117
|
+
const monthly = self.getUsage(name);
|
|
118
118
|
const allowed = self.getLimit(name);
|
|
119
119
|
|
|
120
120
|
// Log (independent of options.log because this is important)
|
|
121
121
|
if (options.log) {
|
|
122
|
-
assistant.log(`Usage.validate(): Checking ${
|
|
122
|
+
assistant.log(`Usage.validate(): Checking ${monthly}/${allowed} for ${name} (${self.key})...`);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
// Reject function
|
|
126
126
|
function _reject() {
|
|
127
127
|
reject(
|
|
128
|
-
assistant.errorify(`You have exceeded your ${name} usage limit of ${
|
|
128
|
+
assistant.errorify(`You have exceeded your ${name} usage limit of ${monthly}/${allowed}.`, {code: 429})
|
|
129
129
|
);
|
|
130
130
|
}
|
|
131
131
|
|
|
@@ -142,22 +142,38 @@ Usage.prototype.validate = function (name, options) {
|
|
|
142
142
|
return resolve(true);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
// Check
|
|
145
|
+
// Check daily caps (for products with rateLimit: 'daily', which is the default)
|
|
146
146
|
const dailyAllowance = self.getDailyAllowance(name);
|
|
147
147
|
if (dailyAllowance !== null) {
|
|
148
|
+
// Flat daily cap: ceil(monthlyLimit / daysInMonth)
|
|
149
|
+
const now = new Date();
|
|
150
|
+
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
151
|
+
const flatDailyCap = Math.ceil(allowed / daysInMonth);
|
|
152
|
+
|
|
153
|
+
// Get today's usage from the daily counter
|
|
154
|
+
const daily = _.get(self.user, `usage.${name}.daily`, 0);
|
|
155
|
+
|
|
148
156
|
if (options.log) {
|
|
149
|
-
assistant.log(`Usage.validate(): Daily
|
|
157
|
+
assistant.log(`Usage.validate(): Daily cap check: ${daily}/${flatDailyCap} today, ${monthly}/${dailyAllowance} proportional (monthly: ${allowed}) for ${name} (${self.key})`);
|
|
150
158
|
}
|
|
151
159
|
|
|
152
|
-
|
|
160
|
+
// Check flat daily cap (can't exceed ceil(limit/daysInMonth) in a single day)
|
|
161
|
+
if (daily >= flatDailyCap) {
|
|
153
162
|
return reject(
|
|
154
|
-
assistant.errorify(`You have reached your daily usage limit for ${name} (${
|
|
163
|
+
assistant.errorify(`You have reached your daily usage limit for ${name} (${daily}/${flatDailyCap}). Your monthly limit is ${allowed}.`, {code: 429})
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check proportional monthly cap (can't accumulate too fast)
|
|
168
|
+
if (monthly >= dailyAllowance) {
|
|
169
|
+
return reject(
|
|
170
|
+
assistant.errorify(`You have reached your usage limit for ${name} (${monthly}/${dailyAllowance}). Your monthly limit is ${allowed}.`, {code: 429})
|
|
155
171
|
);
|
|
156
172
|
}
|
|
157
173
|
}
|
|
158
174
|
|
|
159
175
|
// If they are under the monthly limit, resolve
|
|
160
|
-
if (
|
|
176
|
+
if (monthly < allowed) {
|
|
161
177
|
self.log(`Usage.validate(): Valid for ${name}`);
|
|
162
178
|
|
|
163
179
|
return resolve(true);
|
|
@@ -200,8 +216,8 @@ Usage.prototype.increment = function (name, value, options) {
|
|
|
200
216
|
options = options || {};
|
|
201
217
|
options.id = options.id || null;
|
|
202
218
|
|
|
203
|
-
// Update total and
|
|
204
|
-
['total', '
|
|
219
|
+
// Update total, monthly, daily, and last
|
|
220
|
+
['total', 'monthly', 'daily', 'last'].forEach((key) => {
|
|
205
221
|
const resolved = `usage.${name}.${key}`;
|
|
206
222
|
const existing = _.get(self.user, resolved, 0);
|
|
207
223
|
|
|
@@ -237,8 +253,8 @@ Usage.prototype.set = function (name, value) {
|
|
|
237
253
|
// Set value
|
|
238
254
|
value = typeof value === 'undefined' ? 0 : value;
|
|
239
255
|
|
|
240
|
-
// Update
|
|
241
|
-
const resolved = `usage.${name}.
|
|
256
|
+
// Update monthly
|
|
257
|
+
const resolved = `usage.${name}.monthly`;
|
|
242
258
|
|
|
243
259
|
// Set the value
|
|
244
260
|
_.set(self.user, resolved, value);
|
|
@@ -256,7 +272,7 @@ Usage.prototype.getUsage = function (name) {
|
|
|
256
272
|
|
|
257
273
|
// Get usage
|
|
258
274
|
if (name) {
|
|
259
|
-
return _.get(self.user, `usage.${name}.
|
|
275
|
+
return _.get(self.user, `usage.${name}.monthly`, 0);
|
|
260
276
|
} else {
|
|
261
277
|
return self.user.usage;
|
|
262
278
|
}
|
|
@@ -290,20 +306,27 @@ Usage.prototype.getLimit = function (name) {
|
|
|
290
306
|
};
|
|
291
307
|
|
|
292
308
|
/**
|
|
293
|
-
* Get the
|
|
294
|
-
*
|
|
309
|
+
* Get the daily allowance cap for a metric
|
|
310
|
+
* Prevents users from burning their entire monthly quota in a single day
|
|
311
|
+
*
|
|
312
|
+
* Uses two checks:
|
|
313
|
+
* 1. Flat daily cap: ceil(monthlyLimit / daysInMonth) — max uses per day
|
|
314
|
+
* e.g. 100/month in March (31 days) = ceil(100/31) = 4/day
|
|
315
|
+
* e.g. 10/month in March (31 days) = ceil(10/31) = 1/day
|
|
316
|
+
* 2. Proportional monthly cap: ceil(monthlyLimit * dayOfMonth / daysInMonth) — running total
|
|
317
|
+
* Ensures users can't accumulate too much too fast even within daily limits
|
|
295
318
|
*
|
|
296
|
-
* Returns null if the product
|
|
297
|
-
* Products can set rateLimit: 'daily'
|
|
319
|
+
* Returns null if the product opts out with rateLimit: 'monthly'
|
|
320
|
+
* Products can set rateLimit: 'daily' (default) | 'monthly'
|
|
298
321
|
*/
|
|
299
322
|
Usage.prototype.getDailyAllowance = function (name) {
|
|
300
323
|
const self = this;
|
|
301
324
|
|
|
302
325
|
// Get the product config
|
|
303
326
|
const product = self.getProduct();
|
|
304
|
-
const rateLimit = product.rateLimit || '
|
|
327
|
+
const rateLimit = product.rateLimit || 'daily';
|
|
305
328
|
|
|
306
|
-
// If
|
|
329
|
+
// If explicitly set to monthly, no daily cap
|
|
307
330
|
if (rateLimit !== 'daily') {
|
|
308
331
|
return null;
|
|
309
332
|
}
|
|
@@ -314,11 +337,12 @@ Usage.prototype.getDailyAllowance = function (name) {
|
|
|
314
337
|
return null;
|
|
315
338
|
}
|
|
316
339
|
|
|
317
|
-
// Calculate
|
|
340
|
+
// Calculate caps
|
|
318
341
|
const now = new Date();
|
|
319
342
|
const dayOfMonth = now.getDate();
|
|
320
343
|
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
321
344
|
|
|
345
|
+
// Proportional monthly cap — how much should be used by this day at most
|
|
322
346
|
// ceil ensures at least 1 usage per day even with very low limits
|
|
323
347
|
return Math.ceil(monthlyLimit * (dayOfMonth / daysInMonth));
|
|
324
348
|
};
|
|
@@ -97,7 +97,8 @@ const SCHEMA = {
|
|
|
97
97
|
usage: {
|
|
98
98
|
$passthrough: true,
|
|
99
99
|
$template: {
|
|
100
|
-
|
|
100
|
+
monthly: { type: 'number', default: 0 },
|
|
101
|
+
daily: { type: 'number', default: 0 },
|
|
101
102
|
total: { type: 'number', default: 0 },
|
|
102
103
|
last: {
|
|
103
104
|
id: { type: 'string', default: null, nullable: true },
|
package/src/manager/index.js
CHANGED
|
@@ -946,11 +946,21 @@ Manager.prototype.setupFunctions = function (exporter, options) {
|
|
|
946
946
|
.firestore.document('payments-webhooks/{eventId}')
|
|
947
947
|
.onWrite((change, context) => self.EventMiddleware({ change, context }).run(`${events}/firestore/payments-webhooks/on-write.js`));
|
|
948
948
|
|
|
949
|
+
exporter.bm_paymentsDisputeOnWrite =
|
|
950
|
+
fn({memory: '256MB', timeoutSeconds: 60})
|
|
951
|
+
.firestore.document('payments-disputes/{alertId}')
|
|
952
|
+
.onWrite((change, context) => self.EventMiddleware({ change, context }).run(`${events}/firestore/payments-disputes/on-write.js`));
|
|
953
|
+
|
|
949
954
|
// Setup cron jobs
|
|
950
955
|
exporter.bm_cronDaily =
|
|
951
956
|
fn({memory: '256MB', timeoutSeconds: 60 * 5})
|
|
952
957
|
.pubsub.schedule('0 0 * * *')
|
|
953
958
|
.onRun((context) => self.EventMiddleware({ context }).run(`${cron}/daily.js`));
|
|
959
|
+
|
|
960
|
+
exporter.bm_cronFrequent =
|
|
961
|
+
fn({memory: '256MB', timeoutSeconds: 60 * 5})
|
|
962
|
+
.pubsub.schedule('*/10 * * * *')
|
|
963
|
+
.onRun((context) => self.EventMiddleware({ context }).run(`${cron}/frequent.js`));
|
|
954
964
|
};
|
|
955
965
|
|
|
956
966
|
// Setup Custom Server
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abandoned cart reminder configuration (SSOT)
|
|
3
|
+
*
|
|
4
|
+
* Used by:
|
|
5
|
+
* - cron/frequent/abandoned-carts.js (processing reminders)
|
|
6
|
+
* - Client-side checkout page (creating cart doc with first delay)
|
|
7
|
+
*/
|
|
8
|
+
module.exports = {
|
|
9
|
+
// Delays in seconds between reminders: 15m, 3h, 24h, 48h, 72h
|
|
10
|
+
REMINDER_DELAYS: [900, 10800, 86400, 172800, 259200],
|
|
11
|
+
COLLECTION: 'payments-carts',
|
|
12
|
+
};
|
|
@@ -26,12 +26,10 @@ const SEND_AT_LIMIT = 71;
|
|
|
26
26
|
// Template shortcut map — callers use readable paths instead of SendGrid IDs
|
|
27
27
|
// Paths mirror the email website structure: {category}/{subcategory}/{name}
|
|
28
28
|
const TEMPLATES = {
|
|
29
|
-
// v1 templates
|
|
30
|
-
'main/basic/card': 'd-b7f8da3c98ad49a2ad1e187f3a67b546',
|
|
31
|
-
'main/engagement/feedback': 'd-c1522214c67b47058669acc5a81ed663',
|
|
32
|
-
'main/misc/app-download-link': 'd-1d730ac8cc544b7cbccc8fa4a4b3f9ce',
|
|
33
|
-
|
|
34
29
|
// v2 templates
|
|
30
|
+
'main/basic/card': 'd-1cd2eee44b6340268c964cd7971d49b9',
|
|
31
|
+
'main/engagement/feedback': 'd-319ab5c9d5074b21926a93562d6f41f6',
|
|
32
|
+
'main/misc/app-download-link': 'd-fc8b4834d7e1472896fe7e46152029f4',
|
|
35
33
|
'main/order/confirmation': 'd-5371ac2b4e3b490bbce51bfc2922ece8',
|
|
36
34
|
'main/order/payment-failed': 'd-e56af0ac62364bfb9e50af02854e2cd3',
|
|
37
35
|
'main/order/payment-recovered': 'd-d6dbd17a260a4755b34a852ba09c2454',
|
|
@@ -39,6 +37,8 @@ const TEMPLATES = {
|
|
|
39
37
|
'main/order/cancelled': 'd-39041132e6b24e5ebf0e95bce2d94dba',
|
|
40
38
|
'main/order/plan-changed': 'd-399086311bbb48b4b77bc90b20fb9d0a',
|
|
41
39
|
'main/order/trial-ending': 'd-af8ab499cbfb4d56918b4118f44343b0',
|
|
40
|
+
'main/order/refunded': 'd-aa47fdbffa2b4ca9b73b6256e963e49f',
|
|
41
|
+
'main/order/abandoned-cart': 'd-d8b3fa67e2b44b398dc280d0576bf1b7',
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
// "default" resolves to the basic card template
|
|
@@ -7,13 +7,63 @@ const path = require('path');
|
|
|
7
7
|
const mimeTypes = require('mime-types');
|
|
8
8
|
|
|
9
9
|
// Constants
|
|
10
|
-
const DEFAULT_MODEL = 'gpt-
|
|
10
|
+
const DEFAULT_MODEL = 'gpt-5-mini';
|
|
11
11
|
const MODERATION_MODEL = 'omni-moderation-latest';
|
|
12
12
|
|
|
13
|
-
// OpenAI model pricing table
|
|
13
|
+
// OpenAI model pricing table (per 1M tokens)
|
|
14
14
|
// https://platform.openai.com/docs/pricing
|
|
15
15
|
const MODEL_TABLE = {
|
|
16
|
-
//
|
|
16
|
+
// Mar 9, 2026
|
|
17
|
+
// GPT-5 family
|
|
18
|
+
'gpt-5.4': {
|
|
19
|
+
input: 2.50,
|
|
20
|
+
output: 15.00,
|
|
21
|
+
provider: 'openai',
|
|
22
|
+
features: {
|
|
23
|
+
json: true,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
'gpt-5.2': {
|
|
27
|
+
input: 1.75,
|
|
28
|
+
output: 14.00,
|
|
29
|
+
provider: 'openai',
|
|
30
|
+
features: {
|
|
31
|
+
json: true,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
'gpt-5.1': {
|
|
35
|
+
input: 1.25,
|
|
36
|
+
output: 10.00,
|
|
37
|
+
provider: 'openai',
|
|
38
|
+
features: {
|
|
39
|
+
json: true,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
'gpt-5': {
|
|
43
|
+
input: 1.25,
|
|
44
|
+
output: 10.00,
|
|
45
|
+
provider: 'openai',
|
|
46
|
+
features: {
|
|
47
|
+
json: true,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
'gpt-5-mini': {
|
|
51
|
+
input: 0.25,
|
|
52
|
+
output: 2.00,
|
|
53
|
+
provider: 'openai',
|
|
54
|
+
features: {
|
|
55
|
+
json: true,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
'gpt-5-nano': {
|
|
59
|
+
input: 0.05,
|
|
60
|
+
output: 0.40,
|
|
61
|
+
provider: 'openai',
|
|
62
|
+
features: {
|
|
63
|
+
json: true,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
// GPT-4.5
|
|
17
67
|
'gpt-4.5-preview': {
|
|
18
68
|
input: 75.00,
|
|
19
69
|
output: 150.00,
|
|
@@ -22,6 +72,7 @@ const MODEL_TABLE = {
|
|
|
22
72
|
json: true,
|
|
23
73
|
},
|
|
24
74
|
},
|
|
75
|
+
// GPT-4.1 family
|
|
25
76
|
'gpt-4.1': {
|
|
26
77
|
input: 2.00,
|
|
27
78
|
output: 8.00,
|
|
@@ -46,6 +97,7 @@ const MODEL_TABLE = {
|
|
|
46
97
|
json: true,
|
|
47
98
|
},
|
|
48
99
|
},
|
|
100
|
+
// GPT-4o family
|
|
49
101
|
'gpt-4o': {
|
|
50
102
|
input: 2.50,
|
|
51
103
|
output: 10.00,
|
|
@@ -62,9 +114,10 @@ const MODEL_TABLE = {
|
|
|
62
114
|
json: true,
|
|
63
115
|
},
|
|
64
116
|
},
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
117
|
+
// Reasoning models
|
|
118
|
+
'o4-mini': {
|
|
119
|
+
input: 1.10,
|
|
120
|
+
output: 4.40,
|
|
68
121
|
provider: 'openai',
|
|
69
122
|
features: {
|
|
70
123
|
json: true,
|
|
@@ -86,7 +139,7 @@ const MODEL_TABLE = {
|
|
|
86
139
|
json: true,
|
|
87
140
|
},
|
|
88
141
|
},
|
|
89
|
-
'
|
|
142
|
+
'o3-mini': {
|
|
90
143
|
input: 1.10,
|
|
91
144
|
output: 4.40,
|
|
92
145
|
provider: 'openai',
|
|
@@ -94,6 +147,22 @@ const MODEL_TABLE = {
|
|
|
94
147
|
json: true,
|
|
95
148
|
},
|
|
96
149
|
},
|
|
150
|
+
'o1-pro': {
|
|
151
|
+
input: 150.00,
|
|
152
|
+
output: 600.00,
|
|
153
|
+
provider: 'openai',
|
|
154
|
+
features: {
|
|
155
|
+
json: true,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
'o1': {
|
|
159
|
+
input: 15.00,
|
|
160
|
+
output: 60.00,
|
|
161
|
+
provider: 'openai',
|
|
162
|
+
features: {
|
|
163
|
+
json: true,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
97
166
|
'o1-preview': {
|
|
98
167
|
input: 15.00,
|
|
99
168
|
output: 60.00,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discount codes — hardcoded for now, move to Firestore/config later
|
|
3
|
+
*
|
|
4
|
+
* Each code maps to a discount definition:
|
|
5
|
+
* - percent: Percentage off (1-100)
|
|
6
|
+
* - duration: 'once' (first payment only)
|
|
7
|
+
*/
|
|
8
|
+
const DISCOUNT_CODES = {
|
|
9
|
+
'FLASH20': { percent: 20, duration: 'once' },
|
|
10
|
+
'SAVE10': { percent: 10, duration: 'once' },
|
|
11
|
+
'WELCOME15': { percent: 15, duration: 'once' },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validate a discount code
|
|
16
|
+
* @param {string} code - The discount code (case-insensitive)
|
|
17
|
+
* @returns {{ valid: boolean, code: string, percent: number, duration: string } | { valid: boolean, code: string }}
|
|
18
|
+
*/
|
|
19
|
+
function validate(code) {
|
|
20
|
+
const normalized = (code || '').trim().toUpperCase();
|
|
21
|
+
|
|
22
|
+
if (!normalized) {
|
|
23
|
+
return { valid: false, code: normalized };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const entry = DISCOUNT_CODES[normalized];
|
|
27
|
+
|
|
28
|
+
if (!entry) {
|
|
29
|
+
return { valid: false, code: normalized };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
valid: true,
|
|
34
|
+
code: normalized,
|
|
35
|
+
percent: entry.percent,
|
|
36
|
+
duration: entry.duration,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { validate, DISCOUNT_CODES };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const fetch = require('wonderful-fetch');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verify a Google reCAPTCHA token
|
|
5
|
+
* @param {string} token - The reCAPTCHA response token
|
|
6
|
+
* @param {object} [options] - Options
|
|
7
|
+
* @param {number} [options.minScore=0.5] - Minimum score threshold (v3)
|
|
8
|
+
* @returns {Promise<boolean>} Whether the token is valid
|
|
9
|
+
*/
|
|
10
|
+
async function verify(token, options) {
|
|
11
|
+
const minScore = options?.minScore || 0.5;
|
|
12
|
+
|
|
13
|
+
if (!process.env.RECAPTCHA_SECRET_KEY) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!token) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const data = await fetch('https://www.google.com/recaptcha/api/siteverify', {
|
|
23
|
+
method: 'post',
|
|
24
|
+
response: 'json',
|
|
25
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
26
|
+
body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return data.success && (data.score === undefined || data.score >= minScore);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.error('reCAPTCHA verification error:', e);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { verify };
|
|
@@ -10,7 +10,7 @@ module.exports = async ({ assistant, Manager }) => {
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Build a public-safe config object from Manager.config
|
|
13
|
-
* Excludes sensitive fields: sentry,
|
|
13
|
+
* Excludes sensitive fields: sentry, googleAnalytics, ghostii, etc.
|
|
14
14
|
*/
|
|
15
15
|
function buildPublicConfig(config) {
|
|
16
16
|
return {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
const fetch = require('wonderful-fetch');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const dns = require('dns').promises;
|
|
8
|
+
const recaptcha = require('../../../libraries/recaptcha.js');
|
|
8
9
|
|
|
9
10
|
// Load disposable domains list
|
|
10
11
|
const DISPOSABLE_DOMAINS = require(path.join(__dirname, '..', '..', '..', 'libraries', 'disposable-domains.json'));
|
|
@@ -43,15 +44,17 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
43
44
|
|
|
44
45
|
// Public access protection
|
|
45
46
|
if (!isAdmin) {
|
|
46
|
-
// Verify reCAPTCHA
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
// Verify reCAPTCHA (skip during automated tests)
|
|
48
|
+
if (!assistant.isTesting()) {
|
|
49
|
+
const recaptchaToken = settings['g-recaptcha-response'];
|
|
50
|
+
if (!recaptchaToken) {
|
|
51
|
+
return assistant.respond('Request could not be verified', { code: 403 });
|
|
52
|
+
}
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
const recaptchaValid = await recaptcha.verify(recaptchaToken);
|
|
55
|
+
if (!recaptchaValid) {
|
|
56
|
+
return assistant.respond('Request could not be verified', { code: 403 });
|
|
57
|
+
}
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
// Check rate limit via Usage API
|
|
@@ -160,27 +163,6 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
|
|
|
160
163
|
return assistant.respond({ success: true });
|
|
161
164
|
};
|
|
162
165
|
|
|
163
|
-
// Helper: Verify Google reCAPTCHA token
|
|
164
|
-
async function verifyRecaptcha(token) {
|
|
165
|
-
if (!process.env.RECAPTCHA_SECRET_KEY) {
|
|
166
|
-
return true;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
try {
|
|
170
|
-
const data = await fetch('https://www.google.com/recaptcha/api/siteverify', {
|
|
171
|
-
method: 'post',
|
|
172
|
-
response: 'json',
|
|
173
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
174
|
-
body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`,
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
return data.success && (data.score === undefined || data.score >= 0.5);
|
|
178
|
-
} catch (e) {
|
|
179
|
-
console.error('reCAPTCHA verification error:', e);
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
166
|
// Helper: Validate email with ZeroBounce API
|
|
185
167
|
async function validateWithZeroBounce(email) {
|
|
186
168
|
try {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /payments/discount?code=FLASH20
|
|
3
|
+
* Validates a discount code and returns the discount details
|
|
4
|
+
*/
|
|
5
|
+
const discountCodes = require('../../../libraries/payment/discount-codes.js');
|
|
6
|
+
|
|
7
|
+
module.exports = async ({ assistant, settings }) => {
|
|
8
|
+
const result = discountCodes.validate(settings.code);
|
|
9
|
+
|
|
10
|
+
assistant.log(`Discount validation: code=${result.code}, valid=${result.valid}`);
|
|
11
|
+
|
|
12
|
+
if (!result.valid) {
|
|
13
|
+
return assistant.respond({ valid: false }, { code: 200 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return assistant.respond({
|
|
17
|
+
valid: true,
|
|
18
|
+
code: result.code,
|
|
19
|
+
percent: result.percent,
|
|
20
|
+
duration: result.duration,
|
|
21
|
+
});
|
|
22
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const powertools = require('node-powertools');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* POST /payments/dispute-alert?alerts=chargeblast&key=XXX
|
|
6
|
+
* Receives dispute alert webhooks (e.g., from Chargeblast), validates them,
|
|
7
|
+
* and saves to Firestore for async processing via onWrite trigger
|
|
8
|
+
*
|
|
9
|
+
* Query params:
|
|
10
|
+
* - alerts: alert provider name (default: 'chargeblast')
|
|
11
|
+
* - key: must match BACKEND_MANAGER_KEY
|
|
12
|
+
*/
|
|
13
|
+
module.exports = async ({ assistant, Manager, libraries }) => {
|
|
14
|
+
const { admin } = libraries;
|
|
15
|
+
const body = assistant.request.body;
|
|
16
|
+
const query = assistant.request.query;
|
|
17
|
+
|
|
18
|
+
// Validate key against BACKEND_MANAGER_KEY
|
|
19
|
+
const key = query.key;
|
|
20
|
+
if (!key || key !== process.env.BACKEND_MANAGER_KEY) {
|
|
21
|
+
return assistant.respond('Invalid key', { code: 401 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Determine alert provider (default: chargeblast)
|
|
25
|
+
const provider = query.alerts || 'chargeblast';
|
|
26
|
+
|
|
27
|
+
// Load the processor module
|
|
28
|
+
let processorModule;
|
|
29
|
+
try {
|
|
30
|
+
processorModule = require(path.resolve(__dirname, `processors/${provider}.js`));
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return assistant.respond(`Unknown alert provider: ${provider}`, { code: 400 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Normalize the payload using the processor
|
|
36
|
+
let alert;
|
|
37
|
+
try {
|
|
38
|
+
alert = processorModule.normalize(body);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return assistant.respond(`Failed to normalize alert: ${e.message}`, { code: 400 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const alertId = alert.id;
|
|
44
|
+
|
|
45
|
+
assistant.log(`Parsed dispute alert: id=${alertId}, provider=${provider}, processor=${alert.processor}, amount=${alert.amount}, card=****${alert.card.last4}`);
|
|
46
|
+
|
|
47
|
+
// Check for duplicate (skip if already processing/completed)
|
|
48
|
+
const existingDoc = await admin.firestore().doc(`payments-disputes/${alertId}`).get();
|
|
49
|
+
if (existingDoc.exists) {
|
|
50
|
+
const existingStatus = existingDoc.data()?.status;
|
|
51
|
+
if (existingStatus !== 'failed') {
|
|
52
|
+
assistant.log(`Duplicate dispute alert ${alertId}, existing status=${existingStatus}, skipping`);
|
|
53
|
+
return assistant.respond({ received: true, duplicate: true });
|
|
54
|
+
}
|
|
55
|
+
assistant.log(`Retrying previously failed dispute alert ${alertId}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Build timestamps
|
|
59
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
60
|
+
const nowUNIX = powertools.timestamp(now, { output: 'unix' });
|
|
61
|
+
|
|
62
|
+
// Save to Firestore with status=pending (trigger handles the rest)
|
|
63
|
+
await admin.firestore().doc(`payments-disputes/${alertId}`).set({
|
|
64
|
+
id: alertId,
|
|
65
|
+
provider: provider,
|
|
66
|
+
status: 'pending',
|
|
67
|
+
alert: alert,
|
|
68
|
+
match: null,
|
|
69
|
+
actions: {
|
|
70
|
+
refund: 'pending',
|
|
71
|
+
cancel: 'pending',
|
|
72
|
+
email: 'pending',
|
|
73
|
+
},
|
|
74
|
+
errors: [],
|
|
75
|
+
error: null,
|
|
76
|
+
metadata: {
|
|
77
|
+
created: {
|
|
78
|
+
timestamp: now,
|
|
79
|
+
timestampUNIX: nowUNIX,
|
|
80
|
+
},
|
|
81
|
+
completed: {
|
|
82
|
+
timestamp: null,
|
|
83
|
+
timestampUNIX: null,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
raw: body,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
assistant.log(`Saved payments-disputes/${alertId}: provider=${provider}, processor=${alert.processor}`);
|
|
90
|
+
|
|
91
|
+
// Return 200 immediately — async processing via Firestore trigger
|
|
92
|
+
return assistant.respond({ received: true });
|
|
93
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chargeblast dispute alert processor
|
|
3
|
+
* Normalizes Chargeblast webhook payloads into a standard dispute alert shape
|
|
4
|
+
*
|
|
5
|
+
* Chargeblast sends:
|
|
6
|
+
* id, card (full number or last4), cardBrand, amount (dollars),
|
|
7
|
+
* transactionDate ("YYYY-MM-DD HH:MM:SS"), app, processor, alerts
|
|
8
|
+
*/
|
|
9
|
+
module.exports = {
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a Chargeblast webhook payload
|
|
12
|
+
*
|
|
13
|
+
* @param {object} body - Raw request body from Chargeblast
|
|
14
|
+
* @returns {object} Normalized dispute alert
|
|
15
|
+
*/
|
|
16
|
+
normalize(body) {
|
|
17
|
+
if (!body.id) {
|
|
18
|
+
throw new Error('Missing required field: id');
|
|
19
|
+
}
|
|
20
|
+
if (!body.card) {
|
|
21
|
+
throw new Error('Missing required field: card');
|
|
22
|
+
}
|
|
23
|
+
if (!body.amount && body.amount !== 0) {
|
|
24
|
+
throw new Error('Missing required field: amount');
|
|
25
|
+
}
|
|
26
|
+
if (!body.transactionDate) {
|
|
27
|
+
throw new Error('Missing required field: transactionDate');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cardStr = String(body.card);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
id: String(body.id),
|
|
34
|
+
card: {
|
|
35
|
+
last4: cardStr.slice(-4),
|
|
36
|
+
brand: body.cardBrand ? String(body.cardBrand).toLowerCase() : null,
|
|
37
|
+
},
|
|
38
|
+
amount: parseFloat(body.amount),
|
|
39
|
+
transactionDate: String(body.transactionDate).split(' ')[0], // date only, no time
|
|
40
|
+
processor: body.processor ? String(body.processor).toLowerCase() : 'stripe',
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|