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.
- package/CHANGELOG.md +48 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/commands/deploy.js +1 -1
- 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/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/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/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/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-
|
|
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
|
+
};
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|