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
@@ -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
+ }
@@ -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
- assistant.log(`Stripe checkout: type=${productType}, priceId=${priceId}, uid=${uid}, customerId=${customer.id}, trial=${trial}, trialDays=${product.trial?.days || 'none'}`);
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, product, confirmationUrl, cancelUrl }) {
96
- return {
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 beforePeriod = usage.getUsage('requests');
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 afterPeriod = usage.getUsage('requests');
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: { period: beforePeriod, total: beforeTotal },
29
- after: { period: afterPeriod, total: afterTotal },
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
- period: beforePeriod,
40
+ monthly: beforeMonthly,
41
+ daily: beforeDaily,
39
42
  total: beforeTotal,
40
43
  },
41
44
  after: {
42
- period: afterPeriod,
45
+ monthly: afterMonthly,
46
+ daily: afterDaily,
43
47
  total: afterTotal,
44
48
  },
45
49
  user: {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Schema: GET /payments/discount
3
+ */
4
+ module.exports = () => ({
5
+ code: {
6
+ types: ['string'],
7
+ required: true,
8
+ },
9
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Schema: POST /payments/dispute-alert
3
+ * Minimal schema - dispute alert payloads are validated by the processor, not the schema
4
+ * The key comes from query params, not the body
5
+ */
6
+ module.exports = () => ({});
@@ -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
  });
@@ -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
- if (!request.startsWith('.') && !path.isAbsolute(request)) {
380
- const extra = (options && options.paths) ? options.paths : [];
381
- options = { ...options, paths: [...extra, ...searchPaths] };
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
- google_analytics: {
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')
@@ -54,7 +54,7 @@ describe(`${package.name}`, () => {
54
54
  return assert.deepStrictEqual({
55
55
  requests: {
56
56
  total: 0,
57
- period: 0,
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
- period: 1,
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
- period: -1,
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
- period: 1,
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
- period: 1,
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 400 because no reCAPTCHA token
350
- assert.isError(response, 400, 'Public request without reCAPTCHA should fail');
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
+ };