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
|
@@ -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
|
+
}
|
|
@@ -16,7 +16,7 @@ module.exports = {
|
|
|
16
16
|
* @param {string} options.cancelUrl - Cancel redirect URL
|
|
17
17
|
* @returns {object} { id, url, raw }
|
|
18
18
|
*/
|
|
19
|
-
async createIntent({ uid, orderId, product, productId, frequency, trial, confirmationUrl, cancelUrl, assistant }) {
|
|
19
|
+
async createIntent({ uid, orderId, product, productId, frequency, trial, discount, confirmationUrl, cancelUrl, assistant }) {
|
|
20
20
|
// Initialize Stripe SDK
|
|
21
21
|
const StripeLib = require('../../../../libraries/payment/processors/stripe.js');
|
|
22
22
|
const stripe = StripeLib.init();
|
|
@@ -30,15 +30,21 @@ module.exports = {
|
|
|
30
30
|
const email = assistant?.getUser()?.auth?.email || null;
|
|
31
31
|
const customer = await StripeLib.resolveCustomer(uid, email, assistant);
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// Resolve Stripe coupon if discount is present
|
|
34
|
+
let stripeCouponId = null;
|
|
35
|
+
if (discount) {
|
|
36
|
+
stripeCouponId = await resolveStripeCoupon(stripe, discount, assistant);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
assistant.log(`Stripe checkout: type=${productType}, priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}, discount=${discount?.code || 'none'}`);
|
|
34
40
|
|
|
35
41
|
// Build session params based on product type
|
|
36
42
|
let sessionParams;
|
|
37
43
|
|
|
38
44
|
if (productType === 'subscription') {
|
|
39
|
-
sessionParams = buildSubscriptionSession({ priceId, customer, uid, orderId, productId, frequency, trial, product, confirmationUrl, cancelUrl });
|
|
45
|
+
sessionParams = buildSubscriptionSession({ priceId, customer, uid, orderId, productId, frequency, trial, product, stripeCouponId, confirmationUrl, cancelUrl });
|
|
40
46
|
} else {
|
|
41
|
-
sessionParams = buildOneTimeSession({ priceId, customer, uid, orderId, productId, product, confirmationUrl, cancelUrl });
|
|
47
|
+
sessionParams = buildOneTimeSession({ priceId, customer, uid, orderId, productId, product, stripeCouponId, confirmationUrl, cancelUrl });
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
// Create the checkout session
|
|
@@ -57,7 +63,7 @@ module.exports = {
|
|
|
57
63
|
/**
|
|
58
64
|
* Build Stripe Checkout Session params for a subscription
|
|
59
65
|
*/
|
|
60
|
-
function buildSubscriptionSession({ priceId, customer, uid, orderId, productId, frequency, trial, product, confirmationUrl, cancelUrl }) {
|
|
66
|
+
function buildSubscriptionSession({ priceId, customer, uid, orderId, productId, frequency, trial, product, stripeCouponId, confirmationUrl, cancelUrl }) {
|
|
61
67
|
const sessionParams = {
|
|
62
68
|
mode: 'subscription',
|
|
63
69
|
customer: customer.id,
|
|
@@ -86,14 +92,19 @@ function buildSubscriptionSession({ priceId, customer, uid, orderId, productId,
|
|
|
86
92
|
sessionParams.subscription_data.trial_period_days = product.trial.days;
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
// Apply discount coupon (first payment only)
|
|
96
|
+
if (stripeCouponId) {
|
|
97
|
+
sessionParams.discounts = [{ coupon: stripeCouponId }];
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
return sessionParams;
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
/**
|
|
93
104
|
* Build Stripe Checkout Session params for a one-time payment
|
|
94
105
|
*/
|
|
95
|
-
function buildOneTimeSession({ priceId, customer, uid, orderId, productId,
|
|
96
|
-
|
|
106
|
+
function buildOneTimeSession({ priceId, customer, uid, orderId, productId, stripeCouponId, confirmationUrl, cancelUrl }) {
|
|
107
|
+
const sessionParams = {
|
|
97
108
|
mode: 'payment',
|
|
98
109
|
customer: customer.id,
|
|
99
110
|
line_items: [{
|
|
@@ -114,5 +125,42 @@ function buildOneTimeSession({ priceId, customer, uid, orderId, productId, produ
|
|
|
114
125
|
productId: productId,
|
|
115
126
|
},
|
|
116
127
|
};
|
|
128
|
+
|
|
129
|
+
// Apply discount coupon
|
|
130
|
+
if (stripeCouponId) {
|
|
131
|
+
sessionParams.discounts = [{ coupon: stripeCouponId }];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return sessionParams;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve or create a Stripe coupon for a discount code
|
|
139
|
+
* Uses a deterministic ID so the same code always maps to the same coupon
|
|
140
|
+
*/
|
|
141
|
+
async function resolveStripeCoupon(stripe, discount, assistant) {
|
|
142
|
+
const couponId = `BEM_${discount.code}_${discount.percent}OFF_ONCE`;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
// Check if coupon already exists
|
|
146
|
+
await stripe.coupons.retrieve(couponId);
|
|
147
|
+
assistant.log(`Stripe coupon exists: ${couponId}`);
|
|
148
|
+
return couponId;
|
|
149
|
+
} catch (e) {
|
|
150
|
+
if (e.code !== 'resource_missing') {
|
|
151
|
+
throw e;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Create the coupon
|
|
156
|
+
await stripe.coupons.create({
|
|
157
|
+
id: couponId,
|
|
158
|
+
percent_off: discount.percent,
|
|
159
|
+
duration: 'once',
|
|
160
|
+
name: `${discount.code} (${discount.percent}% off first payment)`,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
assistant.log(`Stripe coupon created: ${couponId}`);
|
|
164
|
+
return couponId;
|
|
117
165
|
}
|
|
118
166
|
|
|
@@ -8,8 +8,9 @@ module.exports = async ({ assistant, user, settings }) => {
|
|
|
8
8
|
const amount = settings.amount;
|
|
9
9
|
|
|
10
10
|
// Get usage before increment
|
|
11
|
-
const
|
|
11
|
+
const beforeMonthly = usage.getUsage('requests');
|
|
12
12
|
const beforeTotal = user.usage?.requests?.total || 0;
|
|
13
|
+
const beforeDaily = user.usage?.requests?.daily || 0;
|
|
13
14
|
|
|
14
15
|
// Increment usage
|
|
15
16
|
usage.increment('requests', amount);
|
|
@@ -18,15 +19,16 @@ module.exports = async ({ assistant, user, settings }) => {
|
|
|
18
19
|
await usage.update();
|
|
19
20
|
|
|
20
21
|
// Get usage after increment
|
|
21
|
-
const
|
|
22
|
+
const afterMonthly = usage.getUsage('requests');
|
|
22
23
|
const afterTotal = user.usage?.requests?.total || 0;
|
|
24
|
+
const afterDaily = user.usage?.requests?.daily || 0;
|
|
23
25
|
|
|
24
26
|
// Log
|
|
25
27
|
assistant.log(`test/usage: Incremented requests by ${amount}`, {
|
|
26
28
|
authenticated: user.authenticated,
|
|
27
29
|
key: usage.key,
|
|
28
|
-
before: {
|
|
29
|
-
after: {
|
|
30
|
+
before: { monthly: beforeMonthly, daily: beforeDaily, total: beforeTotal },
|
|
31
|
+
after: { monthly: afterMonthly, daily: afterDaily, total: afterTotal },
|
|
30
32
|
});
|
|
31
33
|
|
|
32
34
|
return assistant.respond({
|
|
@@ -35,11 +37,13 @@ module.exports = async ({ assistant, user, settings }) => {
|
|
|
35
37
|
authenticated: user.authenticated,
|
|
36
38
|
key: usage.key,
|
|
37
39
|
before: {
|
|
38
|
-
|
|
40
|
+
monthly: beforeMonthly,
|
|
41
|
+
daily: beforeDaily,
|
|
39
42
|
total: beforeTotal,
|
|
40
43
|
},
|
|
41
44
|
after: {
|
|
42
|
-
|
|
45
|
+
monthly: afterMonthly,
|
|
46
|
+
daily: afterDaily,
|
|
43
47
|
total: afterTotal,
|
|
44
48
|
},
|
|
45
49
|
user: {
|
|
@@ -19,4 +19,20 @@ module.exports = () => ({
|
|
|
19
19
|
types: ['boolean'],
|
|
20
20
|
default: false,
|
|
21
21
|
},
|
|
22
|
+
verification: {
|
|
23
|
+
types: ['object'],
|
|
24
|
+
default: {},
|
|
25
|
+
},
|
|
26
|
+
attribution: {
|
|
27
|
+
types: ['object'],
|
|
28
|
+
default: {},
|
|
29
|
+
},
|
|
30
|
+
discount: {
|
|
31
|
+
types: ['string'],
|
|
32
|
+
default: null,
|
|
33
|
+
},
|
|
34
|
+
supplemental: {
|
|
35
|
+
types: ['object'],
|
|
36
|
+
default: {},
|
|
37
|
+
},
|
|
22
38
|
});
|
package/src/test/runner.js
CHANGED
|
@@ -372,15 +372,25 @@ class TestRunner {
|
|
|
372
372
|
try {
|
|
373
373
|
const searchPaths = [
|
|
374
374
|
path.join(this.options.projectDir, 'functions'),
|
|
375
|
+
path.join(this.options.projectDir, 'functions', 'node_modules'),
|
|
375
376
|
path.resolve(__dirname, '../../'),
|
|
376
377
|
];
|
|
377
378
|
const origResolve = Module._resolveFilename.bind(Module);
|
|
378
379
|
Module._resolveFilename = function (request, parent, isMain, options) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
380
|
+
// Try normal resolution first (preserves nested node_modules traversal)
|
|
381
|
+
try {
|
|
382
|
+
return origResolve(request, parent, isMain, options);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
// Fallback: try resolving from project's search paths
|
|
385
|
+
if (!request.startsWith('.') && !path.isAbsolute(request)) {
|
|
386
|
+
const extra = (options && options.paths) ? options.paths : [];
|
|
387
|
+
return origResolve(request, parent, isMain, {
|
|
388
|
+
...options,
|
|
389
|
+
paths: [...extra, ...searchPaths],
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
throw err;
|
|
382
393
|
}
|
|
383
|
-
return origResolve(request, parent, isMain, options);
|
|
384
394
|
};
|
|
385
395
|
try {
|
|
386
396
|
testModule = require(testFile);
|
|
@@ -238,6 +238,24 @@ const JOURNEY_ACCOUNTS = {
|
|
|
238
238
|
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
239
239
|
},
|
|
240
240
|
},
|
|
241
|
+
'journey-payments-intent-discount': {
|
|
242
|
+
id: 'journey-payments-intent-discount',
|
|
243
|
+
uid: '_test-journey-payments-intent-discount',
|
|
244
|
+
email: '_test.journey-payments-intent-discount@{domain}',
|
|
245
|
+
properties: {
|
|
246
|
+
roles: {},
|
|
247
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
'journey-payments-intent-attribution': {
|
|
251
|
+
id: 'journey-payments-intent-attribution',
|
|
252
|
+
uid: '_test-journey-payments-intent-attribution',
|
|
253
|
+
email: '_test.journey-payments-intent-attribution@{domain}',
|
|
254
|
+
properties: {
|
|
255
|
+
roles: {},
|
|
256
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
257
|
+
},
|
|
258
|
+
},
|
|
241
259
|
'journey-payments-intent-trial': {
|
|
242
260
|
id: 'journey-payments-intent-trial',
|
|
243
261
|
uid: '_test-journey-payments-intent-trial',
|
|
@@ -20,10 +20,16 @@
|
|
|
20
20
|
sentry: {
|
|
21
21
|
dsn: 'https://d965557418748jd749d837asf00552f@o777489.ingest.sentry.io/8789941',
|
|
22
22
|
},
|
|
23
|
-
|
|
23
|
+
googleAnalytics: {
|
|
24
24
|
id: 'UA-123456789-1',
|
|
25
25
|
secret: 'ABCx1234567890ABCDEFGH',
|
|
26
26
|
},
|
|
27
|
+
meta: {
|
|
28
|
+
pixelId: null,
|
|
29
|
+
},
|
|
30
|
+
tiktok: {
|
|
31
|
+
pixelCode: null,
|
|
32
|
+
},
|
|
27
33
|
oauth2: {},
|
|
28
34
|
payment: {
|
|
29
35
|
processors: {
|
|
@@ -24,6 +24,14 @@ service cloud.firestore {
|
|
|
24
24
|
allow create: if true;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Protect abandoned cart data
|
|
28
|
+
match /payments-carts/{uid} {
|
|
29
|
+
allow create, update: if belongsTo(uid)
|
|
30
|
+
&& incomingData().owner == uid
|
|
31
|
+
&& incomingData().status == 'pending';
|
|
32
|
+
allow read: if belongsTo(uid);
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
// Auth functions
|
|
28
36
|
function authEmail() {
|
|
29
37
|
return request.auth.token.email;
|
|
@@ -56,7 +64,7 @@ service cloud.firestore {
|
|
|
56
64
|
return isWritingField('auth')
|
|
57
65
|
|| isWritingField('roles')
|
|
58
66
|
|| isWritingField('flags')
|
|
59
|
-
|| isWritingField('plan') // REMOVE THIS WHEN ALL PROJECTS UPDATED
|
|
67
|
+
|| isWritingField('plan') // TODO: REMOVE THIS WHEN ALL PROJECTS UPDATED
|
|
60
68
|
|| isWritingField('subscription')
|
|
61
69
|
|| isWritingField('affiliate')
|
|
62
70
|
|| isWritingField('api')
|
package/test/_legacy/usage.js
CHANGED
|
@@ -54,7 +54,7 @@ describe(`${package.name}`, () => {
|
|
|
54
54
|
return assert.deepStrictEqual({
|
|
55
55
|
requests: {
|
|
56
56
|
total: 0,
|
|
57
|
-
|
|
57
|
+
monthly: 0,
|
|
58
58
|
last: {
|
|
59
59
|
id: '',
|
|
60
60
|
timestamp: '1970-01-01T00:00:00.000Z',
|
|
@@ -72,7 +72,7 @@ describe(`${package.name}`, () => {
|
|
|
72
72
|
return assert.deepStrictEqual({
|
|
73
73
|
requests: {
|
|
74
74
|
total: 1,
|
|
75
|
-
|
|
75
|
+
monthly: 1,
|
|
76
76
|
last: {
|
|
77
77
|
id: 'increment',
|
|
78
78
|
timestamp: '2024-01-01T01:00:00.000Z',
|
|
@@ -90,7 +90,7 @@ describe(`${package.name}`, () => {
|
|
|
90
90
|
return assert.deepStrictEqual({
|
|
91
91
|
requests: {
|
|
92
92
|
total: -1,
|
|
93
|
-
|
|
93
|
+
monthly: -1,
|
|
94
94
|
last: {
|
|
95
95
|
id: 'decrement',
|
|
96
96
|
timestamp: '2024-01-01T01:00:00.000Z',
|
|
@@ -132,7 +132,7 @@ describe(`${package.name}`, () => {
|
|
|
132
132
|
return assert.deepStrictEqual({
|
|
133
133
|
requests: {
|
|
134
134
|
total: 1,
|
|
135
|
-
|
|
135
|
+
monthly: 1,
|
|
136
136
|
last: {
|
|
137
137
|
id: 'update',
|
|
138
138
|
timestamp: '2024-01-01T01:00:00.000Z',
|
|
@@ -154,7 +154,7 @@ describe(`${package.name}`, () => {
|
|
|
154
154
|
return assert.deepStrictEqual({
|
|
155
155
|
signups: {
|
|
156
156
|
total: 1,
|
|
157
|
-
|
|
157
|
+
monthly: 1,
|
|
158
158
|
last: {
|
|
159
159
|
id: 'singups',
|
|
160
160
|
timestamp: '2024-01-01T01:00:00.000Z',
|
|
@@ -338,6 +338,7 @@ module.exports = {
|
|
|
338
338
|
name: 'add-unauthenticated-requires-recaptcha',
|
|
339
339
|
auth: 'none',
|
|
340
340
|
timeout: 15000,
|
|
341
|
+
skip: !process.env.TEST_EXTENDED_MODE && 'reCAPTCHA is skipped in test mode (TEST_EXTENDED_MODE not set)',
|
|
341
342
|
|
|
342
343
|
async run({ http, assert, config }) {
|
|
343
344
|
// Public request without reCAPTCHA should fail
|
|
@@ -346,8 +347,8 @@ module.exports = {
|
|
|
346
347
|
source: 'bem-test',
|
|
347
348
|
});
|
|
348
349
|
|
|
349
|
-
// Should fail with
|
|
350
|
-
assert.isError(response,
|
|
350
|
+
// Should fail with 403 because no reCAPTCHA token
|
|
351
|
+
assert.isError(response, 403, 'Public request without reCAPTCHA should fail');
|
|
351
352
|
},
|
|
352
353
|
},
|
|
353
354
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: GET /payments/discount
|
|
3
|
+
* Tests discount code validation endpoint
|
|
4
|
+
*/
|
|
5
|
+
module.exports = {
|
|
6
|
+
description: 'Discount code validation',
|
|
7
|
+
type: 'group',
|
|
8
|
+
timeout: 15000,
|
|
9
|
+
|
|
10
|
+
tests: [
|
|
11
|
+
{
|
|
12
|
+
name: 'rejects-missing-code',
|
|
13
|
+
async run({ http, assert }) {
|
|
14
|
+
const response = await http.as('none').get('payments/discount');
|
|
15
|
+
|
|
16
|
+
assert.isError(response, 400, 'Should reject missing code');
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
name: 'returns-valid-for-known-code',
|
|
22
|
+
async run({ http, assert }) {
|
|
23
|
+
const response = await http.as('none').get('payments/discount', {
|
|
24
|
+
code: 'FLASH20',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
assert.isSuccess(response, 'Should succeed for valid code');
|
|
28
|
+
assert.equal(response.data.valid, true, 'Should be valid');
|
|
29
|
+
assert.equal(response.data.code, 'FLASH20', 'Should return normalized code');
|
|
30
|
+
assert.equal(response.data.percent, 20, 'Should return correct percent');
|
|
31
|
+
assert.equal(response.data.duration, 'once', 'Should return duration');
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
name: 'returns-valid-case-insensitive',
|
|
37
|
+
async run({ http, assert }) {
|
|
38
|
+
const response = await http.as('none').get('payments/discount', {
|
|
39
|
+
code: 'flash20',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
assert.isSuccess(response, 'Should succeed for lowercase code');
|
|
43
|
+
assert.equal(response.data.valid, true, 'Should be valid (case-insensitive)');
|
|
44
|
+
assert.equal(response.data.code, 'FLASH20', 'Should return uppercase code');
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
name: 'returns-invalid-for-unknown-code',
|
|
50
|
+
async run({ http, assert }) {
|
|
51
|
+
const response = await http.as('none').get('payments/discount', {
|
|
52
|
+
code: 'NOTAREALCODE',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
assert.isSuccess(response, 'Should return 200 even for invalid code');
|
|
56
|
+
assert.equal(response.data.valid, false, 'Should be invalid');
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
name: 'validates-all-known-codes',
|
|
62
|
+
async run({ http, assert }) {
|
|
63
|
+
const codes = [
|
|
64
|
+
{ code: 'FLASH20', percent: 20 },
|
|
65
|
+
{ code: 'SAVE10', percent: 10 },
|
|
66
|
+
{ code: 'WELCOME15', percent: 15 },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
for (const { code, percent } of codes) {
|
|
70
|
+
const response = await http.as('none').get('payments/discount', { code });
|
|
71
|
+
|
|
72
|
+
assert.isSuccess(response, `Should succeed for ${code}`);
|
|
73
|
+
assert.equal(response.data.valid, true, `${code} should be valid`);
|
|
74
|
+
assert.equal(response.data.percent, percent, `${code} should be ${percent}%`);
|
|
75
|
+
assert.equal(response.data.duration, 'once', `${code} should be once`);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|