backend-manager 5.0.121 → 5.0.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/cli/commands/deploy.js +1 -1
  5. package/src/manager/cron/daily.js +2 -53
  6. package/src/manager/cron/frequent/abandoned-carts.js +148 -0
  7. package/src/manager/cron/frequent.js +3 -0
  8. package/src/manager/cron/runner.js +60 -0
  9. package/src/manager/events/firestore/payments-disputes/on-write.js +358 -0
  10. package/src/manager/events/firestore/payments-webhooks/analytics.js +245 -121
  11. package/src/manager/events/firestore/payments-webhooks/on-write.js +32 -2
  12. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +11 -34
  13. package/src/manager/helpers/analytics.js +2 -2
  14. package/src/manager/index.js +10 -0
  15. package/src/manager/libraries/abandoned-cart-config.js +12 -0
  16. package/src/manager/libraries/email.js +5 -5
  17. package/src/manager/libraries/openai.js +76 -7
  18. package/src/manager/libraries/payment/discount-codes.js +40 -0
  19. package/src/manager/libraries/recaptcha.js +36 -0
  20. package/src/manager/routes/app/get.js +1 -1
  21. package/src/manager/routes/marketing/contact/post.js +11 -29
  22. package/src/manager/routes/payments/discount/get.js +22 -0
  23. package/src/manager/routes/payments/dispute-alert/post.js +93 -0
  24. package/src/manager/routes/payments/dispute-alert/processors/chargeblast.js +43 -0
  25. package/src/manager/routes/payments/intent/post.js +29 -0
  26. package/src/manager/routes/payments/intent/processors/chargebee.js +59 -7
  27. package/src/manager/routes/payments/intent/processors/stripe.js +55 -7
  28. package/src/manager/schemas/payments/discount/get.js +9 -0
  29. package/src/manager/schemas/payments/dispute-alert/post.js +6 -0
  30. package/src/manager/schemas/payments/intent/post.js +16 -0
  31. package/src/test/runner.js +14 -4
  32. package/src/test/test-accounts.js +18 -0
  33. package/templates/backend-manager-config.json +7 -1
  34. package/templates/firestore.rules +9 -1
  35. package/test/routes/marketing/contact.js +3 -2
  36. package/test/routes/payments/discount.js +80 -0
  37. package/test/routes/payments/dispute-alert.js +271 -0
  38. package/test/routes/payments/intent.js +60 -0
  39. package/test/rules/payments-carts.js +371 -0
@@ -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
+ };
@@ -1,6 +1,8 @@
1
1
  const path = require('path');
2
2
  const powertools = require('node-powertools');
3
3
  const OrderId = require('../../../libraries/payment/order-id.js');
4
+ const recaptcha = require('../../../libraries/recaptcha.js');
5
+ const discountCodes = require('../../../libraries/payment/discount-codes.js');
4
6
 
5
7
  /**
6
8
  * POST /payments/intent
@@ -15,10 +17,22 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
15
17
  return assistant.respond('Authentication required', { code: 401 });
16
18
  }
17
19
 
20
+ // Verify reCAPTCHA (skip during automated tests)
21
+ if (!assistant.isTesting()) {
22
+ const recaptchaToken = settings.verification?.['g-recaptcha-response'];
23
+ const recaptchaValid = await recaptcha.verify(recaptchaToken);
24
+ if (!recaptchaValid) {
25
+ return assistant.respond('Request could not be verified', { code: 403 });
26
+ }
27
+ }
28
+
18
29
  const uid = user.auth.uid;
19
30
  const processor = settings.processor;
20
31
  const productId = settings.productId;
21
32
  const frequency = settings.frequency;
33
+ const attribution = settings.attribution;
34
+ const discount = settings.discount;
35
+ const supplemental = settings.supplemental;
22
36
  let trial = settings.trial;
23
37
 
24
38
  assistant.log(`Intent request: uid=${uid}, processor=${processor}, product=${productId}, frequency=${frequency}, trial=${trial}`);
@@ -66,6 +80,17 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
66
80
  trial = false;
67
81
  }
68
82
 
83
+ // Validate discount code (if provided)
84
+ let resolvedDiscount = null;
85
+ if (discount) {
86
+ const discountResult = discountCodes.validate(discount);
87
+ if (!discountResult.valid) {
88
+ return assistant.respond(`Invalid discount code: ${discount}`, { code: 400 });
89
+ }
90
+ resolvedDiscount = discountResult;
91
+ assistant.log(`Discount validated: code=${resolvedDiscount.code}, percent=${resolvedDiscount.percent}, duration=${resolvedDiscount.duration}`);
92
+ }
93
+
69
94
  // Generate order ID
70
95
  const orderId = OrderId.generate();
71
96
 
@@ -93,6 +118,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
93
118
  productId,
94
119
  frequency,
95
120
  trial,
121
+ discount: resolvedDiscount,
96
122
  confirmationUrl,
97
123
  cancelUrl,
98
124
  assistant,
@@ -119,6 +145,9 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
119
145
  type: productType,
120
146
  frequency: frequency,
121
147
  trial: trial,
148
+ attribution: attribution,
149
+ discount: resolvedDiscount,
150
+ supplemental: supplemental,
122
151
  raw: result.raw,
123
152
  metadata: {
124
153
  created: {
@@ -18,19 +18,25 @@ module.exports = {
18
18
  * @param {object} options.assistant - Assistant instance for logging
19
19
  * @returns {object} { id, url, raw }
20
20
  */
21
- async createIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, cancelUrl, assistant }) {
21
+ async createIntent({ uid, orderId, product, productId, frequency, trial, discount, confirmationUrl, cancelUrl, assistant }) {
22
22
  const ChargebeeLib = require('../../../../libraries/payment/processors/chargebee.js');
23
23
  ChargebeeLib.init();
24
24
 
25
25
  const productType = product.type || 'subscription';
26
26
  const metaData = ChargebeeLib.buildMetaData(uid, orderId, productType === 'one-time' ? productId : undefined);
27
27
 
28
+ // Resolve Chargebee coupon if discount is present
29
+ let chargebeeCouponId = null;
30
+ if (discount) {
31
+ chargebeeCouponId = await resolveChargebeeCoupon(ChargebeeLib, discount, assistant);
32
+ }
33
+
28
34
  let hostedPage;
29
35
 
30
36
  if (productType === 'subscription') {
31
- hostedPage = await createSubscriptionCheckout({ ChargebeeLib, uid, orderId, product, productId, frequency, trial, metaData, confirmationUrl, cancelUrl, assistant });
37
+ hostedPage = await createSubscriptionCheckout({ ChargebeeLib, uid, orderId, product, productId, frequency, trial, metaData, chargebeeCouponId, confirmationUrl, cancelUrl, assistant });
32
38
  } else {
33
- hostedPage = await createOneTimeCheckout({ ChargebeeLib, uid, orderId, product, productId, metaData, confirmationUrl, cancelUrl, assistant });
39
+ hostedPage = await createOneTimeCheckout({ ChargebeeLib, uid, orderId, product, productId, metaData, chargebeeCouponId, confirmationUrl, cancelUrl, assistant });
34
40
  }
35
41
 
36
42
  assistant.log(`Chargebee hosted page created: id=${hostedPage.id}, type=${productType}, url=${hostedPage.url}`);
@@ -46,7 +52,7 @@ module.exports = {
46
52
  /**
47
53
  * Create a Chargebee Hosted Page for a new subscription
48
54
  */
49
- async function createSubscriptionCheckout({ ChargebeeLib, uid, orderId, product, productId, frequency, trial, metaData, confirmationUrl, cancelUrl, assistant }) {
55
+ async function createSubscriptionCheckout({ ChargebeeLib, uid, orderId, product, productId, frequency, trial, metaData, chargebeeCouponId, confirmationUrl, cancelUrl, assistant }) {
50
56
  const chargebeeItemId = product.chargebee?.itemId;
51
57
 
52
58
  if (!chargebeeItemId) {
@@ -76,7 +82,12 @@ async function createSubscriptionCheckout({ ChargebeeLib, uid, orderId, product,
76
82
  params.subscription.trial_end = 0;
77
83
  }
78
84
 
79
- assistant.log(`Chargebee subscription checkout: itemPriceId=${itemPriceId}, uid=${uid}, trial=${trial}`);
85
+ // Apply discount coupon (first payment only)
86
+ if (chargebeeCouponId) {
87
+ params.coupon_ids = [chargebeeCouponId];
88
+ }
89
+
90
+ assistant.log(`Chargebee subscription checkout: itemPriceId=${itemPriceId}, uid=${uid}, trial=${trial}, coupon=${chargebeeCouponId || 'none'}`);
80
91
 
81
92
  const result = await ChargebeeLib.request('/hosted_pages/checkout_new_for_items', {
82
93
  method: 'POST',
@@ -89,7 +100,7 @@ async function createSubscriptionCheckout({ ChargebeeLib, uid, orderId, product,
89
100
  /**
90
101
  * Create a Chargebee Hosted Page for a one-time charge
91
102
  */
92
- async function createOneTimeCheckout({ ChargebeeLib, uid, orderId, product, productId, metaData, confirmationUrl, cancelUrl, assistant }) {
103
+ async function createOneTimeCheckout({ ChargebeeLib, uid, orderId, product, productId, metaData, chargebeeCouponId, confirmationUrl, cancelUrl, assistant }) {
93
104
  const price = product.prices?.once;
94
105
 
95
106
  if (!price) {
@@ -109,7 +120,12 @@ async function createOneTimeCheckout({ ChargebeeLib, uid, orderId, product, prod
109
120
  pass_thru_content: metaData,
110
121
  };
111
122
 
112
- assistant.log(`Chargebee one-time checkout: amount=${amountCents}, productId=${productId}, uid=${uid}`);
123
+ // Apply discount coupon
124
+ if (chargebeeCouponId) {
125
+ params.coupon_ids = [chargebeeCouponId];
126
+ }
127
+
128
+ assistant.log(`Chargebee one-time checkout: amount=${amountCents}, productId=${productId}, uid=${uid}, coupon=${chargebeeCouponId || 'none'}`);
113
129
 
114
130
  const result = await ChargebeeLib.request('/hosted_pages/checkout_one_time_for_items', {
115
131
  method: 'POST',
@@ -118,3 +134,39 @@ async function createOneTimeCheckout({ ChargebeeLib, uid, orderId, product, prod
118
134
 
119
135
  return result.hosted_page;
120
136
  }
137
+
138
+ /**
139
+ * Resolve or create a Chargebee coupon for a discount code
140
+ * Uses a deterministic ID so the same code always maps to the same coupon
141
+ */
142
+ async function resolveChargebeeCoupon(ChargebeeLib, discount, assistant) {
143
+ const couponId = `BEM_${discount.code}_${discount.percent}OFF_ONCE`;
144
+
145
+ try {
146
+ // Check if coupon already exists
147
+ await ChargebeeLib.request(`/coupons/${couponId}`, { method: 'GET' });
148
+ assistant.log(`Chargebee coupon exists: ${couponId}`);
149
+ return couponId;
150
+ } catch (e) {
151
+ // Chargebee returns 404 for missing resources
152
+ if (e.status !== 404 && e.statusCode !== 404) {
153
+ throw e;
154
+ }
155
+ }
156
+
157
+ // Create the coupon
158
+ await ChargebeeLib.request('/coupons', {
159
+ method: 'POST',
160
+ body: {
161
+ id: couponId,
162
+ name: `${discount.code} (${discount.percent}% off first payment)`,
163
+ discount_type: 'percentage',
164
+ discount_percentage: discount.percent,
165
+ duration_type: 'one_time',
166
+ apply_on: 'invoice_amount',
167
+ },
168
+ });
169
+
170
+ assistant.log(`Chargebee coupon created: ${couponId}`);
171
+ return couponId;
172
+ }