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
@@ -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 period = self.getUsage(name);
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 ${period}/${allowed} for ${name} (${self.key})...`);
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 ${period}/${allowed}.`, {code: 429})
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 proportional daily allowance (for products with rateLimit: 'daily')
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 allowance check: ${period}/${dailyAllowance} (monthly: ${allowed}) for ${name} (${self.key})`);
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
- if (period >= dailyAllowance) {
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} (${period}/${dailyAllowance}). Your monthly limit is ${allowed}.`, {code: 429})
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 (period < allowed) {
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 period
204
- ['total', 'period', 'last'].forEach((key) => {
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 total and period
241
- const resolved = `usage.${name}.period`;
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}.period`, 0);
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 proportional daily allowance for a metric
294
- * Based on how far into the month we are: ceil(monthlyLimit * dayOfMonth / daysInMonth)
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 uses monthly rate limiting (no daily cap)
297
- * Products can set rateLimit: 'daily' | 'monthly' (default: 'monthly')
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 || 'monthly';
327
+ const rateLimit = product.rateLimit || 'daily';
305
328
 
306
- // If monthly rate limiting, no daily cap
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 proportional allowance based on day of month
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
- period: { type: 'number', default: 0 },
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 },
@@ -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-4o';
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
- // Jul 11, 2025
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
- 'o1-pro': {
66
- input: 150.00,
67
- output: 600.00,
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
- 'o4-mini': {
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, google_analytics, ghostii, etc.
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
- const recaptchaToken = settings['g-recaptcha-response'];
48
- if (!recaptchaToken) {
49
- return assistant.respond('reCAPTCHA token required', { code: 400 });
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
- const recaptchaValid = await verifyRecaptcha(recaptchaToken);
53
- if (!recaptchaValid) {
54
- return assistant.respond('reCAPTCHA verification failed', { code: 400 });
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
+ };