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
@@ -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
 
@@ -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')
@@ -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
+ };
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Test: POST /payments/dispute-alert
3
+ * Tests the dispute alert endpoint validates requests and saves to Firestore
4
+ */
5
+ module.exports = {
6
+ description: 'Dispute alert endpoint',
7
+ type: 'group',
8
+ timeout: 30000,
9
+
10
+ tests: [
11
+ {
12
+ name: 'rejects-missing-key',
13
+ auth: 'none',
14
+ async run({ http, assert }) {
15
+ const response = await http.as('none').post('payments/dispute-alert', {});
16
+
17
+ assert.isError(response, 401, 'Should reject missing key');
18
+ },
19
+ },
20
+
21
+ {
22
+ name: 'rejects-invalid-key',
23
+ auth: 'none',
24
+ async run({ http, assert }) {
25
+ const response = await http.as('none').post('payments/dispute-alert?key=wrong-key', {});
26
+
27
+ assert.isError(response, 401, 'Should reject invalid key');
28
+ },
29
+ },
30
+
31
+ {
32
+ name: 'rejects-unknown-provider',
33
+ auth: 'none',
34
+ async run({ http, assert }) {
35
+ const response = await http.as('none').post(`payments/dispute-alert?alerts=unknown&key=${process.env.BACKEND_MANAGER_KEY}`, {
36
+ id: '_test-dispute-unknown-provider',
37
+ card: '4242',
38
+ amount: 9.99,
39
+ transactionDate: '2026-01-15',
40
+ });
41
+
42
+ assert.isError(response, 400, 'Should reject unknown alert provider');
43
+ },
44
+ },
45
+
46
+ {
47
+ name: 'rejects-missing-id',
48
+ auth: 'none',
49
+ async run({ http, assert }) {
50
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
51
+ card: '4242',
52
+ amount: 9.99,
53
+ transactionDate: '2026-01-15',
54
+ });
55
+
56
+ assert.isError(response, 400, 'Should reject missing id');
57
+ },
58
+ },
59
+
60
+ {
61
+ name: 'rejects-missing-card',
62
+ auth: 'none',
63
+ async run({ http, assert }) {
64
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
65
+ id: '_test-dispute-no-card',
66
+ amount: 9.99,
67
+ transactionDate: '2026-01-15',
68
+ });
69
+
70
+ assert.isError(response, 400, 'Should reject missing card');
71
+ },
72
+ },
73
+
74
+ {
75
+ name: 'rejects-missing-amount',
76
+ auth: 'none',
77
+ async run({ http, assert }) {
78
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
79
+ id: '_test-dispute-no-amount',
80
+ card: '4242',
81
+ transactionDate: '2026-01-15',
82
+ });
83
+
84
+ assert.isError(response, 400, 'Should reject missing amount');
85
+ },
86
+ },
87
+
88
+ {
89
+ name: 'rejects-missing-transaction-date',
90
+ auth: 'none',
91
+ async run({ http, assert }) {
92
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
93
+ id: '_test-dispute-no-date',
94
+ card: '4242',
95
+ amount: 9.99,
96
+ });
97
+
98
+ assert.isError(response, 400, 'Should reject missing transactionDate');
99
+ },
100
+ },
101
+
102
+ {
103
+ name: 'accepts-valid-chargeblast-alert',
104
+ auth: 'none',
105
+ async run({ http, assert, firestore }) {
106
+ const alertId = '_test-dispute-valid';
107
+
108
+ // Clean up any existing doc
109
+ await firestore.delete(`payments-disputes/${alertId}`);
110
+
111
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
112
+ id: alertId,
113
+ card: '4242424242424242',
114
+ cardBrand: 'Visa',
115
+ amount: 29.99,
116
+ transactionDate: '2026-03-07 14:30:00',
117
+ processor: 'stripe',
118
+ });
119
+
120
+ assert.isSuccess(response, 'Should accept valid Chargeblast alert');
121
+ assert.equal(response.data.received, true, 'Should confirm receipt');
122
+
123
+ // Verify doc was saved to Firestore
124
+ const doc = await firestore.get(`payments-disputes/${alertId}`);
125
+ assert.ok(doc, 'Dispute doc should exist in Firestore');
126
+ assert.equal(doc.provider, 'chargeblast', 'Provider should be chargeblast');
127
+ assert.ok(
128
+ doc.status === 'pending' || doc.status === 'processing',
129
+ 'Status should be pending or processing',
130
+ );
131
+
132
+ // Verify normalized alert data
133
+ assert.equal(doc.alert.card.last4, '4242', 'Should extract last4 from full card number');
134
+ assert.equal(doc.alert.card.brand, 'visa', 'Should lowercase card brand');
135
+ assert.equal(doc.alert.amount, 29.99, 'Amount should be preserved');
136
+ assert.equal(doc.alert.transactionDate, '2026-03-07', 'Should extract date without time');
137
+ assert.equal(doc.alert.processor, 'stripe', 'Processor should be stripe');
138
+
139
+ // Verify raw payload is preserved
140
+ assert.ok(doc.raw, 'Raw payload should be preserved');
141
+ assert.equal(doc.raw.id, alertId, 'Raw id should match');
142
+
143
+ // Clean up
144
+ await firestore.delete(`payments-disputes/${alertId}`);
145
+ },
146
+ },
147
+
148
+ {
149
+ name: 'accepts-alert-with-last4-only',
150
+ auth: 'none',
151
+ async run({ http, assert, firestore }) {
152
+ const alertId = '_test-dispute-last4';
153
+
154
+ // Clean up any existing doc
155
+ await firestore.delete(`payments-disputes/${alertId}`);
156
+
157
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
158
+ id: alertId,
159
+ card: '1234',
160
+ amount: 9.99,
161
+ transactionDate: '2026-01-15',
162
+ });
163
+
164
+ assert.isSuccess(response, 'Should accept alert with card last4 only');
165
+
166
+ const doc = await firestore.get(`payments-disputes/${alertId}`);
167
+ assert.equal(doc.alert.card.last4, '1234', 'Should use card value as last4 when already 4 digits');
168
+ assert.equal(doc.alert.processor, 'stripe', 'Processor should default to stripe');
169
+
170
+ // Clean up
171
+ await firestore.delete(`payments-disputes/${alertId}`);
172
+ },
173
+ },
174
+
175
+ {
176
+ name: 'deduplicates-dispute-alerts',
177
+ auth: 'none',
178
+ async run({ http, assert, firestore }) {
179
+ const alertId = '_test-dispute-duplicate';
180
+
181
+ // Clean up any existing doc
182
+ await firestore.delete(`payments-disputes/${alertId}`);
183
+
184
+ // Send first alert
185
+ await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
186
+ id: alertId,
187
+ card: '4242',
188
+ amount: 29.99,
189
+ transactionDate: '2026-03-07',
190
+ });
191
+
192
+ // Send duplicate
193
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
194
+ id: alertId,
195
+ card: '4242',
196
+ amount: 29.99,
197
+ transactionDate: '2026-03-07',
198
+ });
199
+
200
+ assert.isSuccess(response, 'Duplicate should still return 200');
201
+ assert.equal(response.data.duplicate, true, 'Should indicate duplicate');
202
+
203
+ // Clean up
204
+ await firestore.delete(`payments-disputes/${alertId}`);
205
+ },
206
+ },
207
+
208
+ {
209
+ name: 'retries-failed-alerts',
210
+ auth: 'none',
211
+ async run({ http, assert, firestore }) {
212
+ const alertId = '_test-dispute-retry';
213
+
214
+ // Pre-seed a failed dispute
215
+ await firestore.set(`payments-disputes/${alertId}`, {
216
+ id: alertId,
217
+ status: 'failed',
218
+ error: 'Previous error',
219
+ });
220
+
221
+ // Send alert with same ID — should retry since previous status was 'failed'
222
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
223
+ id: alertId,
224
+ card: '4242',
225
+ amount: 29.99,
226
+ transactionDate: '2026-03-07',
227
+ });
228
+
229
+ assert.isSuccess(response, 'Should accept retry of failed alert');
230
+ assert.ok(!response.data.duplicate, 'Should not indicate duplicate for failed retry');
231
+
232
+ // Verify doc was updated
233
+ const doc = await firestore.get(`payments-disputes/${alertId}`);
234
+ assert.ok(
235
+ doc.status === 'pending' || doc.status === 'processing',
236
+ 'Status should be pending or processing after retry',
237
+ );
238
+
239
+ // Clean up
240
+ await firestore.delete(`payments-disputes/${alertId}`);
241
+ },
242
+ },
243
+
244
+ {
245
+ name: 'defaults-alerts-to-chargeblast',
246
+ auth: 'none',
247
+ async run({ http, assert, firestore }) {
248
+ const alertId = '_test-dispute-default-provider';
249
+
250
+ // Clean up any existing doc
251
+ await firestore.delete(`payments-disputes/${alertId}`);
252
+
253
+ // Send without alerts query param
254
+ const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
255
+ id: alertId,
256
+ card: '4242',
257
+ amount: 9.99,
258
+ transactionDate: '2026-01-15',
259
+ });
260
+
261
+ assert.isSuccess(response, 'Should accept without explicit alerts param');
262
+
263
+ const doc = await firestore.get(`payments-disputes/${alertId}`);
264
+ assert.equal(doc.provider, 'chargeblast', 'Provider should default to chargeblast');
265
+
266
+ // Clean up
267
+ await firestore.delete(`payments-disputes/${alertId}`);
268
+ },
269
+ },
270
+ ],
271
+ };
@@ -113,6 +113,66 @@ module.exports = {
113
113
  },
114
114
  },
115
115
 
116
+ {
117
+ name: 'rejects-invalid-discount-code',
118
+ async run({ http, assert, config }) {
119
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
120
+
121
+ const response = await http.as('basic').post('payments/intent', {
122
+ processor: 'stripe',
123
+ productId: paidProduct.id,
124
+ frequency: 'monthly',
125
+ discount: 'FAKECODE',
126
+ });
127
+
128
+ assert.isError(response, 400, 'Should reject invalid discount code');
129
+ },
130
+ },
131
+
132
+ {
133
+ name: 'saves-discount-to-intent-doc',
134
+ async run({ http, assert, config, firestore }) {
135
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
136
+
137
+ const response = await http.as('journey-payments-intent-discount').post('payments/intent', {
138
+ processor: 'test',
139
+ productId: paidProduct.id,
140
+ frequency: 'monthly',
141
+ discount: 'FLASH20',
142
+ });
143
+
144
+ assert.isSuccess(response, 'Should succeed with valid discount');
145
+
146
+ // Verify discount was resolved and saved to intent doc
147
+ const intentDoc = await firestore.get(`payments-intents/${response.data.orderId}`);
148
+ assert.ok(intentDoc.discount, 'Discount should be saved on intent');
149
+ assert.equal(intentDoc.discount.code, 'FLASH20', 'Discount code should match');
150
+ assert.equal(intentDoc.discount.percent, 20, 'Discount percent should be 20');
151
+ assert.equal(intentDoc.discount.duration, 'once', 'Discount duration should be once');
152
+ },
153
+ },
154
+
155
+ {
156
+ name: 'saves-attribution-and-supplemental-to-intent-doc',
157
+ async run({ http, assert, config, firestore }) {
158
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
159
+
160
+ const response = await http.as('journey-payments-intent-attribution').post('payments/intent', {
161
+ processor: 'test',
162
+ productId: paidProduct.id,
163
+ frequency: 'monthly',
164
+ attribution: { utm_source: 'test', utm_medium: 'unit-test' },
165
+ supplemental: { referral: 'friend' },
166
+ });
167
+
168
+ assert.isSuccess(response, 'Should succeed with attribution and supplemental');
169
+
170
+ const intentDoc = await firestore.get(`payments-intents/${response.data.orderId}`);
171
+ assert.equal(intentDoc.attribution.utm_source, 'test', 'Attribution utm_source should be saved');
172
+ assert.equal(intentDoc.supplemental.referral, 'friend', 'Supplemental should be saved');
173
+ },
174
+ },
175
+
116
176
  {
117
177
  name: 'succeeds-with-test-processor',
118
178
  async run({ http, assert, config, firestore, accounts, waitFor }) {