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
|
@@ -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
|
|
|
@@ -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')
|
|
@@ -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
|
+
};
|
|
@@ -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 }) {
|