backend-manager 5.0.173 → 5.0.176
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 +11 -0
- package/_zombie-sub-guard-wip/journey-payments-zombie-sub.js +159 -0
- package/_zombie-sub-guard-wip/on-write.js +442 -0
- package/_zombie-sub-guard-wip/test-accounts.diff +21 -0
- package/package.json +1 -1
- package/src/manager/events/firestore/payments-disputes/on-write.js +14 -260
- package/src/manager/events/firestore/payments-disputes/processors/stripe.js +213 -0
- package/src/manager/events/firestore/payments-webhooks/on-write.js +4 -1
- package/src/manager/libraries/email/constants.js +2 -2
- package/src/manager/routes/payments/dispute-alert/processors/chargeblast.js +2 -2
- package/src/manager/routes/payments/intent/post.js +6 -4
- package/src/test/runner.js +17 -26
- package/src/test/test-accounts.js +69 -7
- package/src/test/utils/firestore-rules-client.js +4 -11
- package/test/helpers/email-validation.js +3 -3
- package/test/helpers/infer-contact.js +9 -17
- package/test/helpers/user.js +3 -7
- package/test/routes/admin/infer-contact.js +13 -2
- package/test/routes/payments/dispute-alert.js +90 -5
- package/test/routes/payments/intent.js +48 -0
- package/test/routes/user/user.js +14 -11
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
const uuid = require('uuid');
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the first paid subscription product from config
|
|
5
|
+
* Falls back to 'premium' if no config or no paid products found
|
|
6
|
+
*/
|
|
7
|
+
function getFirstPaidProduct(config) {
|
|
8
|
+
const products = config?.payment?.products || [];
|
|
9
|
+
const paid = products.find(p => p.type === 'subscription' && p.id !== 'basic');
|
|
10
|
+
return paid
|
|
11
|
+
? { id: paid.id, name: paid.name }
|
|
12
|
+
: { id: 'premium', name: 'Premium' };
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
/**
|
|
4
16
|
* Helper to create a future expiration date for premium subscriptions
|
|
5
17
|
* User() checks subscription.expires to determine if subscription is active
|
|
@@ -77,6 +89,24 @@ const STATIC_ACCOUNTS = {
|
|
|
77
89
|
subscription: { product: { id: 'premium' }, status: 'cancelled', expires: getPastExpires() },
|
|
78
90
|
},
|
|
79
91
|
},
|
|
92
|
+
'premium-suspended': {
|
|
93
|
+
id: 'premium-suspended',
|
|
94
|
+
uid: '_test-premium-suspended',
|
|
95
|
+
email: '_test.premium-suspended@{domain}',
|
|
96
|
+
properties: {
|
|
97
|
+
roles: {},
|
|
98
|
+
subscription: { product: { id: 'premium' }, status: 'suspended', expires: getFutureExpires() },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
'premium-cancelling': {
|
|
102
|
+
id: 'premium-cancelling',
|
|
103
|
+
uid: '_test-premium-cancelling',
|
|
104
|
+
email: '_test.premium-cancelling@{domain}',
|
|
105
|
+
properties: {
|
|
106
|
+
roles: {},
|
|
107
|
+
subscription: { product: { id: 'premium' }, status: 'active', expires: getFutureExpires(), cancellation: { pending: true } },
|
|
108
|
+
},
|
|
109
|
+
},
|
|
80
110
|
delete: {
|
|
81
111
|
id: 'delete',
|
|
82
112
|
uid: '_test-delete',
|
|
@@ -397,6 +427,25 @@ const JOURNEY_ACCOUNTS = {
|
|
|
397
427
|
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
398
428
|
},
|
|
399
429
|
},
|
|
430
|
+
// Dedicated accounts for user resolve tests — must not be reused by other tests
|
|
431
|
+
'resolve-premium-active': {
|
|
432
|
+
id: 'resolve-premium-active',
|
|
433
|
+
uid: '_test-resolve-premium-active',
|
|
434
|
+
email: '_test.resolve-premium-active@{domain}',
|
|
435
|
+
properties: {
|
|
436
|
+
roles: {},
|
|
437
|
+
subscription: { product: { id: 'premium' }, status: 'active', expires: getFutureExpires() },
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
'resolve-premium-expired': {
|
|
441
|
+
id: 'resolve-premium-expired',
|
|
442
|
+
uid: '_test-resolve-premium-expired',
|
|
443
|
+
email: '_test.resolve-premium-expired@{domain}',
|
|
444
|
+
properties: {
|
|
445
|
+
roles: {},
|
|
446
|
+
subscription: { product: { id: 'premium' }, status: 'cancelled', expires: getPastExpires() },
|
|
447
|
+
},
|
|
448
|
+
},
|
|
400
449
|
};
|
|
401
450
|
|
|
402
451
|
/**
|
|
@@ -408,19 +457,29 @@ const TEST_ACCOUNTS = {
|
|
|
408
457
|
};
|
|
409
458
|
|
|
410
459
|
/**
|
|
411
|
-
* Get all test account definitions with resolved emails
|
|
460
|
+
* Get all test account definitions with resolved emails and dynamic product IDs
|
|
412
461
|
* @param {string} domain - Domain for email addresses (e.g., 'itwcreativeworks.com')
|
|
462
|
+
* @param {object} [config] - BEM config (used to resolve first paid product)
|
|
413
463
|
* @returns {object} Account definitions with resolved emails
|
|
414
464
|
*/
|
|
415
|
-
function getAccountDefinitions(domain) {
|
|
465
|
+
function getAccountDefinitions(domain, config) {
|
|
466
|
+
const paidProduct = getFirstPaidProduct(config);
|
|
416
467
|
const accounts = {};
|
|
417
468
|
|
|
418
469
|
for (const [key, account] of Object.entries(TEST_ACCOUNTS)) {
|
|
470
|
+
const properties = JSON.parse(JSON.stringify(account.properties));
|
|
471
|
+
|
|
472
|
+
// Replace hardcoded 'premium' product with the actual first paid product from config
|
|
473
|
+
if (properties.subscription?.product?.id === 'premium') {
|
|
474
|
+
properties.subscription.product.id = paidProduct.id;
|
|
475
|
+
properties.subscription.product.name = paidProduct.name;
|
|
476
|
+
}
|
|
477
|
+
|
|
419
478
|
accounts[key] = {
|
|
420
479
|
id: account.id,
|
|
421
480
|
uid: account.uid,
|
|
422
481
|
email: account.email.replace('{domain}', domain),
|
|
423
|
-
properties
|
|
482
|
+
properties,
|
|
424
483
|
};
|
|
425
484
|
}
|
|
426
485
|
|
|
@@ -431,10 +490,11 @@ function getAccountDefinitions(domain) {
|
|
|
431
490
|
* Fetch privateKeys for test accounts from Firestore
|
|
432
491
|
* @param {object} admin - Firebase admin instance
|
|
433
492
|
* @param {string} domain - Domain for email addresses (e.g., 'itwcreativeworks.com')
|
|
493
|
+
* @param {object} [config] - BEM config (used to resolve first paid product)
|
|
434
494
|
* @returns {Promise<object>} Account credentials with privateKeys
|
|
435
495
|
*/
|
|
436
|
-
async function fetchPrivateKeys(admin, domain) {
|
|
437
|
-
const definitions = getAccountDefinitions(domain);
|
|
496
|
+
async function fetchPrivateKeys(admin, domain, config) {
|
|
497
|
+
const definitions = getAccountDefinitions(domain, config);
|
|
438
498
|
const accounts = {};
|
|
439
499
|
|
|
440
500
|
// Fetch all in parallel
|
|
@@ -617,10 +677,11 @@ async function deleteTestUsers(admin) {
|
|
|
617
677
|
* Assumes deleteTestUsers() was called first to ensure clean state
|
|
618
678
|
* @param {object} admin - Firebase admin instance
|
|
619
679
|
* @param {string} domain - Domain for email addresses (e.g., 'itwcreativeworks.com')
|
|
680
|
+
* @param {object} [config] - BEM config (used to resolve first paid product)
|
|
620
681
|
* @returns {Promise<object>} Result with created/failed counts
|
|
621
682
|
*/
|
|
622
|
-
async function createTestAccounts(admin, domain) {
|
|
623
|
-
const definitions = getAccountDefinitions(domain);
|
|
683
|
+
async function createTestAccounts(admin, domain, config) {
|
|
684
|
+
const definitions = getAccountDefinitions(domain, config);
|
|
624
685
|
const results = { created: [], failed: [] };
|
|
625
686
|
|
|
626
687
|
// Create all accounts in parallel
|
|
@@ -716,6 +777,7 @@ module.exports = {
|
|
|
716
777
|
JOURNEY_ACCOUNTS,
|
|
717
778
|
TEST_ACCOUNTS,
|
|
718
779
|
TEST_DATA,
|
|
780
|
+
getFirstPaidProduct,
|
|
719
781
|
getAccountDefinitions,
|
|
720
782
|
fetchPrivateKeys,
|
|
721
783
|
deleteTestUsers,
|
|
@@ -162,22 +162,20 @@ async function seedTestAccounts(accounts) {
|
|
|
162
162
|
throw new Error('Test environment not initialized. Call initRulesTestEnv() first.');
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// Use withSecurityRulesDisabled to write test data
|
|
165
|
+
// Use withSecurityRulesDisabled to write ONLY roles (for isAdmin() rules checks)
|
|
166
|
+
// Do NOT write subscription — that's already set by createAccount with config-resolved product IDs
|
|
169
167
|
await testEnv.withSecurityRulesDisabled(async (context) => {
|
|
170
168
|
const db = context.firestore();
|
|
169
|
+
const { TEST_ACCOUNTS } = require('../test-accounts.js');
|
|
171
170
|
|
|
172
171
|
for (const [accountType, account] of Object.entries(accounts)) {
|
|
173
172
|
if (!account.uid) {
|
|
174
173
|
continue;
|
|
175
174
|
}
|
|
176
175
|
|
|
177
|
-
// Get the static definition for this account type (has roles, subscription)
|
|
178
176
|
const staticDef = TEST_ACCOUNTS[accountType];
|
|
179
177
|
|
|
180
|
-
//
|
|
178
|
+
// Only write auth + roles for rules testing — subscription is already correct in the doc
|
|
181
179
|
const userData = {
|
|
182
180
|
auth: {
|
|
183
181
|
uid: account.uid,
|
|
@@ -186,11 +184,6 @@ async function seedTestAccounts(accounts) {
|
|
|
186
184
|
roles: staticDef?.properties?.roles || {},
|
|
187
185
|
};
|
|
188
186
|
|
|
189
|
-
// Add subscription if present in static definition
|
|
190
|
-
if (staticDef?.properties?.subscription) {
|
|
191
|
-
userData.subscription = staticDef.properties.subscription;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
187
|
await db.doc(`users/${account.uid}`).set(userData, { merge: true });
|
|
195
188
|
}
|
|
196
189
|
});
|
|
@@ -101,14 +101,14 @@ module.exports = {
|
|
|
101
101
|
},
|
|
102
102
|
|
|
103
103
|
{
|
|
104
|
-
name: 'localpart-admin-
|
|
104
|
+
name: 'localpart-admin-allowed',
|
|
105
105
|
timeout: 5000,
|
|
106
106
|
|
|
107
107
|
async run({ assert }) {
|
|
108
108
|
const result = await validate('admin@company.com');
|
|
109
109
|
|
|
110
|
-
assert.equal(result.valid,
|
|
111
|
-
assert.propertyEquals(result, 'checks.localPart.
|
|
110
|
+
assert.equal(result.valid, true, '"admin" local part should be allowed (team/role address)');
|
|
111
|
+
assert.propertyEquals(result, 'checks.localPart.valid', true, 'localPart check should pass');
|
|
112
112
|
},
|
|
113
113
|
},
|
|
114
114
|
|
|
@@ -252,24 +252,16 @@ module.exports = {
|
|
|
252
252
|
|
|
253
253
|
{
|
|
254
254
|
name: 'infer-contact-regex-fallback',
|
|
255
|
-
async run({ assert }) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
delete process.env.OPENAI_API_KEY;
|
|
259
|
-
|
|
260
|
-
try {
|
|
261
|
-
const result = await inferContact('alice.wonderland@example.com');
|
|
262
|
-
|
|
263
|
-
assert.equal(result.firstName, 'Alice', 'Regex fallback first name');
|
|
264
|
-
assert.equal(result.lastName, 'Wonderland', 'Regex fallback last name');
|
|
265
|
-
assert.equal(result.company, 'Example', 'Regex fallback company');
|
|
266
|
-
assert.equal(result.method, 'regex', 'Should use regex when no API key');
|
|
267
|
-
} finally {
|
|
268
|
-
// Restore
|
|
269
|
-
if (originalKey) {
|
|
270
|
-
process.env.OPENAI_API_KEY = originalKey;
|
|
271
|
-
}
|
|
255
|
+
async run({ assert, skip }) {
|
|
256
|
+
if (!process.env.TEST_EXTENDED_MODE || !process.env.BACKEND_MANAGER_OPENAI_API_KEY) {
|
|
257
|
+
skip('TEST_EXTENDED_MODE or BACKEND_MANAGER_OPENAI_API_KEY not set');
|
|
272
258
|
}
|
|
259
|
+
|
|
260
|
+
const result = await inferContact('alice.wonderland@example.com');
|
|
261
|
+
|
|
262
|
+
assert.equal(result.firstName, 'Alice', 'AI inferred first name');
|
|
263
|
+
assert.equal(result.lastName, 'Wonderland', 'AI inferred last name');
|
|
264
|
+
assert.ok(result.method === 'ai', 'Should use AI');
|
|
273
265
|
},
|
|
274
266
|
},
|
|
275
267
|
|
package/test/helpers/user.js
CHANGED
|
@@ -72,10 +72,7 @@ module.exports = {
|
|
|
72
72
|
assert.ok(user.api.privateKey.length > 0, 'api.privateKey should not be empty');
|
|
73
73
|
|
|
74
74
|
// Usage
|
|
75
|
-
assert.
|
|
76
|
-
assert.equal(user.usage.requests.daily, 0, 'usage.requests.daily should be 0');
|
|
77
|
-
assert.equal(user.usage.requests.total, 0, 'usage.requests.total should be 0');
|
|
78
|
-
assert.equal(user.usage.requests.last.id, null, 'usage.requests.last.id should be null');
|
|
75
|
+
assert.deepEqual(user.usage, {}, 'usage should be empty object by default');
|
|
79
76
|
|
|
80
77
|
// Personal
|
|
81
78
|
assert.equal(user.personal.name.first, null, 'personal.name.first should be null');
|
|
@@ -231,12 +228,11 @@ module.exports = {
|
|
|
231
228
|
},
|
|
232
229
|
|
|
233
230
|
{
|
|
234
|
-
name: 'usage-
|
|
231
|
+
name: 'usage-empty-when-no-keys-provided',
|
|
235
232
|
async run({ assert }) {
|
|
236
233
|
const user = createUser({});
|
|
237
234
|
|
|
238
|
-
assert.
|
|
239
|
-
assert.equal(Object.keys(user.usage).length, 1, 'usage should only have requests key');
|
|
235
|
+
assert.deepEqual(user.usage, {}, 'usage should be empty object when no keys provided');
|
|
240
236
|
},
|
|
241
237
|
},
|
|
242
238
|
|
|
@@ -44,6 +44,9 @@ module.exports = {
|
|
|
44
44
|
name: 'single-email-returns-result',
|
|
45
45
|
auth: 'admin',
|
|
46
46
|
timeout: 30000,
|
|
47
|
+
skip: !process.env.TEST_EXTENDED_MODE
|
|
48
|
+
? 'TEST_EXTENDED_MODE not set (skipping inference test)'
|
|
49
|
+
: false,
|
|
47
50
|
|
|
48
51
|
async run({ http, assert }) {
|
|
49
52
|
const response = await http.post('admin/infer-contact', {
|
|
@@ -89,12 +92,15 @@ module.exports = {
|
|
|
89
92
|
},
|
|
90
93
|
},
|
|
91
94
|
|
|
92
|
-
// ─── Name parsing (
|
|
95
|
+
// ─── Name parsing (requires AI) ───
|
|
93
96
|
|
|
94
97
|
{
|
|
95
98
|
name: 'regex-parses-dot-separated-names',
|
|
96
99
|
auth: 'admin',
|
|
97
100
|
timeout: 30000,
|
|
101
|
+
skip: !process.env.TEST_EXTENDED_MODE
|
|
102
|
+
? 'TEST_EXTENDED_MODE not set (skipping inference test)'
|
|
103
|
+
: false,
|
|
98
104
|
|
|
99
105
|
async run({ http, assert }) {
|
|
100
106
|
const response = await http.post('admin/infer-contact', {
|
|
@@ -104,7 +110,6 @@ module.exports = {
|
|
|
104
110
|
assert.isSuccess(response);
|
|
105
111
|
const result = response.data.results[0];
|
|
106
112
|
|
|
107
|
-
// AI or regex — either way should get the name right
|
|
108
113
|
assert.equal(result.firstName, 'Alice', 'Should parse first name');
|
|
109
114
|
assert.equal(result.lastName, 'Wonderland', 'Should parse last name');
|
|
110
115
|
},
|
|
@@ -114,6 +119,9 @@ module.exports = {
|
|
|
114
119
|
name: 'infers-company-from-custom-domain',
|
|
115
120
|
auth: 'admin',
|
|
116
121
|
timeout: 30000,
|
|
122
|
+
skip: !process.env.TEST_EXTENDED_MODE
|
|
123
|
+
? 'TEST_EXTENDED_MODE not set (skipping inference test)'
|
|
124
|
+
: false,
|
|
117
125
|
|
|
118
126
|
async run({ http, assert }) {
|
|
119
127
|
const response = await http.post('admin/infer-contact', {
|
|
@@ -130,6 +138,9 @@ module.exports = {
|
|
|
130
138
|
name: 'no-company-from-generic-domain',
|
|
131
139
|
auth: 'admin',
|
|
132
140
|
timeout: 30000,
|
|
141
|
+
skip: !process.env.TEST_EXTENDED_MODE
|
|
142
|
+
? 'TEST_EXTENDED_MODE not set (skipping inference test)'
|
|
143
|
+
: false,
|
|
133
144
|
|
|
134
145
|
async run({ http, assert }) {
|
|
135
146
|
const response = await http.post('admin/infer-contact', {
|
|
@@ -32,7 +32,7 @@ module.exports = {
|
|
|
32
32
|
name: 'rejects-unknown-provider',
|
|
33
33
|
auth: 'none',
|
|
34
34
|
async run({ http, assert }) {
|
|
35
|
-
const response = await http.as('none').post(`payments/dispute-alert?
|
|
35
|
+
const response = await http.as('none').post(`payments/dispute-alert?provider=unknown&key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
36
36
|
id: '_test-dispute-unknown-provider',
|
|
37
37
|
card: '4242',
|
|
38
38
|
amount: 9.99,
|
|
@@ -115,6 +115,14 @@ module.exports = {
|
|
|
115
115
|
amount: 29.99,
|
|
116
116
|
transactionDate: '2026-03-07 14:30:00',
|
|
117
117
|
processor: 'stripe',
|
|
118
|
+
alertType: 'FRAUD',
|
|
119
|
+
customerEmail: 'test@example.com',
|
|
120
|
+
externalOrder: 'ch_test123',
|
|
121
|
+
metadata: 'pi_test456',
|
|
122
|
+
externalUrl: 'https://dashboard.stripe.com/charges/ch_test123',
|
|
123
|
+
reasonCode: 'WIP',
|
|
124
|
+
subprovider: 'Ethoca',
|
|
125
|
+
isRefunded: false,
|
|
118
126
|
});
|
|
119
127
|
|
|
120
128
|
assert.isSuccess(response, 'Should accept valid Chargeblast alert');
|
|
@@ -129,13 +137,23 @@ module.exports = {
|
|
|
129
137
|
'Status should be pending or processing',
|
|
130
138
|
);
|
|
131
139
|
|
|
132
|
-
// Verify normalized alert data
|
|
140
|
+
// Verify core normalized alert data
|
|
133
141
|
assert.equal(doc.alert.card.last4, '4242', 'Should extract last4 from full card number');
|
|
134
142
|
assert.equal(doc.alert.card.brand, 'visa', 'Should lowercase card brand');
|
|
135
143
|
assert.equal(doc.alert.amount, 29.99, 'Amount should be preserved');
|
|
136
144
|
assert.equal(doc.alert.transactionDate, '2026-03-07', 'Should extract date without time');
|
|
137
145
|
assert.equal(doc.alert.processor, 'stripe', 'Processor should be stripe');
|
|
138
146
|
|
|
147
|
+
// Verify new normalized fields
|
|
148
|
+
assert.equal(doc.alert.alertType, 'FRAUD', 'Alert type should be preserved');
|
|
149
|
+
assert.equal(doc.alert.customerEmail, 'test@example.com', 'Customer email should be preserved');
|
|
150
|
+
assert.equal(doc.alert.chargeId, 'ch_test123', 'Charge ID should be extracted from externalOrder');
|
|
151
|
+
assert.equal(doc.alert.paymentIntentId, 'pi_test456', 'Payment intent ID should be extracted from metadata');
|
|
152
|
+
assert.equal(doc.alert.stripeUrl, 'https://dashboard.stripe.com/charges/ch_test123', 'Stripe URL should be preserved');
|
|
153
|
+
assert.equal(doc.alert.reasonCode, 'WIP', 'Reason code should be preserved');
|
|
154
|
+
assert.equal(doc.alert.subprovider, 'Ethoca', 'Subprovider should be preserved');
|
|
155
|
+
assert.equal(doc.alert.isRefunded, false, 'isRefunded should be preserved');
|
|
156
|
+
|
|
139
157
|
// Verify raw payload is preserved
|
|
140
158
|
assert.ok(doc.raw, 'Raw payload should be preserved');
|
|
141
159
|
assert.equal(doc.raw.id, alertId, 'Raw id should match');
|
|
@@ -145,6 +163,73 @@ module.exports = {
|
|
|
145
163
|
},
|
|
146
164
|
},
|
|
147
165
|
|
|
166
|
+
{
|
|
167
|
+
name: 'accepts-alert-with-alertId-field',
|
|
168
|
+
auth: 'none',
|
|
169
|
+
async run({ http, assert, firestore }) {
|
|
170
|
+
const alertId = '_test-dispute-alertid-field';
|
|
171
|
+
|
|
172
|
+
// Clean up any existing doc
|
|
173
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
174
|
+
|
|
175
|
+
// Chargeblast alert.created events use alertId instead of id
|
|
176
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
177
|
+
alertId: alertId,
|
|
178
|
+
card: '546616******5805',
|
|
179
|
+
cardBrand: 'Mastercard',
|
|
180
|
+
amount: 8,
|
|
181
|
+
transactionDate: '2026-03-19 00:00:00.000000Z',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
assert.isSuccess(response, 'Should accept alert using alertId field');
|
|
185
|
+
|
|
186
|
+
const doc = await firestore.get(`payments-disputes/${alertId}`);
|
|
187
|
+
assert.ok(doc, 'Dispute doc should exist in Firestore');
|
|
188
|
+
assert.equal(doc.id, alertId, 'Doc ID should match alertId');
|
|
189
|
+
assert.equal(doc.alert.id, alertId, 'Alert id should be set from alertId');
|
|
190
|
+
assert.equal(doc.alert.card.last4, '5805', 'Should extract last4 from masked card');
|
|
191
|
+
|
|
192
|
+
// Clean up
|
|
193
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
{
|
|
198
|
+
name: 'accepts-alert-without-optional-fields',
|
|
199
|
+
auth: 'none',
|
|
200
|
+
async run({ http, assert, firestore }) {
|
|
201
|
+
const alertId = '_test-dispute-minimal';
|
|
202
|
+
|
|
203
|
+
// Clean up any existing doc
|
|
204
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
205
|
+
|
|
206
|
+
// Send minimal alert (alert.created shape — no externalOrder, metadata, etc.)
|
|
207
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
208
|
+
id: alertId,
|
|
209
|
+
card: '9124',
|
|
210
|
+
amount: 10,
|
|
211
|
+
transactionDate: '2026-03-21 00:01:02',
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
assert.isSuccess(response, 'Should accept minimal alert');
|
|
215
|
+
|
|
216
|
+
const doc = await firestore.get(`payments-disputes/${alertId}`);
|
|
217
|
+
assert.equal(doc.alert.card.last4, '9124', 'Should use card as last4');
|
|
218
|
+
assert.equal(doc.alert.processor, 'stripe', 'Processor should default to stripe');
|
|
219
|
+
assert.equal(doc.alert.chargeId, null, 'Charge ID should be null when not provided');
|
|
220
|
+
assert.equal(doc.alert.paymentIntentId, null, 'Payment intent should be null when not provided');
|
|
221
|
+
assert.equal(doc.alert.customerEmail, null, 'Customer email should be null when not provided');
|
|
222
|
+
assert.equal(doc.alert.alertType, null, 'Alert type should be null when not provided');
|
|
223
|
+
assert.equal(doc.alert.stripeUrl, null, 'Stripe URL should be null when not provided');
|
|
224
|
+
assert.equal(doc.alert.reasonCode, null, 'Reason code should be null when not provided');
|
|
225
|
+
assert.equal(doc.alert.subprovider, null, 'Subprovider should be null when not provided');
|
|
226
|
+
assert.equal(doc.alert.isRefunded, false, 'isRefunded should default to false');
|
|
227
|
+
|
|
228
|
+
// Clean up
|
|
229
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
|
|
148
233
|
{
|
|
149
234
|
name: 'accepts-alert-with-last4-only',
|
|
150
235
|
auth: 'none',
|
|
@@ -242,7 +327,7 @@ module.exports = {
|
|
|
242
327
|
},
|
|
243
328
|
|
|
244
329
|
{
|
|
245
|
-
name: 'defaults-
|
|
330
|
+
name: 'defaults-provider-to-chargeblast',
|
|
246
331
|
auth: 'none',
|
|
247
332
|
async run({ http, assert, firestore }) {
|
|
248
333
|
const alertId = '_test-dispute-default-provider';
|
|
@@ -250,7 +335,7 @@ module.exports = {
|
|
|
250
335
|
// Clean up any existing doc
|
|
251
336
|
await firestore.delete(`payments-disputes/${alertId}`);
|
|
252
337
|
|
|
253
|
-
// Send without
|
|
338
|
+
// Send without provider query param
|
|
254
339
|
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
255
340
|
id: alertId,
|
|
256
341
|
card: '4242',
|
|
@@ -258,7 +343,7 @@ module.exports = {
|
|
|
258
343
|
transactionDate: '2026-01-15',
|
|
259
344
|
});
|
|
260
345
|
|
|
261
|
-
assert.isSuccess(response, 'Should accept without explicit
|
|
346
|
+
assert.isSuccess(response, 'Should accept without explicit provider param');
|
|
262
347
|
|
|
263
348
|
const doc = await firestore.get(`payments-disputes/${alertId}`);
|
|
264
349
|
assert.equal(doc.provider, 'chargeblast', 'Provider should default to chargeblast');
|
|
@@ -85,6 +85,54 @@ module.exports = {
|
|
|
85
85
|
},
|
|
86
86
|
},
|
|
87
87
|
|
|
88
|
+
{
|
|
89
|
+
name: 'rejects-suspended-user',
|
|
90
|
+
auth: 'premium-suspended',
|
|
91
|
+
async run({ http, assert, config }) {
|
|
92
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
93
|
+
|
|
94
|
+
const response = await http.as('premium-suspended').post('payments/intent', {
|
|
95
|
+
processor: 'stripe',
|
|
96
|
+
productId: paidProduct.id,
|
|
97
|
+
frequency: 'monthly',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
assert.isError(response, 400, 'Should reject user with suspended subscription');
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
name: 'rejects-cancelling-user',
|
|
106
|
+
auth: 'premium-cancelling',
|
|
107
|
+
async run({ http, assert, config }) {
|
|
108
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
109
|
+
|
|
110
|
+
const response = await http.as('premium-cancelling').post('payments/intent', {
|
|
111
|
+
processor: 'stripe',
|
|
112
|
+
productId: paidProduct.id,
|
|
113
|
+
frequency: 'monthly',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
assert.isError(response, 400, 'Should reject user with cancelling subscription');
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
name: 'allows-cancelled-user',
|
|
122
|
+
auth: 'premium-expired',
|
|
123
|
+
async run({ http, assert, config }) {
|
|
124
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
125
|
+
|
|
126
|
+
const response = await http.as('premium-expired').post('payments/intent', {
|
|
127
|
+
processor: 'test',
|
|
128
|
+
productId: paidProduct.id,
|
|
129
|
+
frequency: 'monthly',
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
assert.isSuccess(response, 'Should allow user with fully cancelled subscription');
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
|
|
88
136
|
{
|
|
89
137
|
name: 'rejects-invalid-product',
|
|
90
138
|
async run({ http, assert }) {
|
package/test/routes/user/user.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Returns user account info for authenticated users
|
|
5
5
|
* Validates that User() correctly structures user data
|
|
6
6
|
*/
|
|
7
|
+
const { getFirstPaidProduct } = require('../../../src/test/test-accounts.js');
|
|
8
|
+
|
|
7
9
|
module.exports = {
|
|
8
10
|
description: 'User resolve (account info)',
|
|
9
11
|
type: 'group',
|
|
@@ -104,10 +106,11 @@ module.exports = {
|
|
|
104
106
|
// Test 5: Premium active user - verify premium subscription is retained
|
|
105
107
|
{
|
|
106
108
|
name: 'premium-active-user-resolved-correctly',
|
|
107
|
-
auth: 'premium-active',
|
|
109
|
+
auth: 'resolve-premium-active',
|
|
108
110
|
timeout: 15000,
|
|
109
111
|
|
|
110
|
-
async run({ http, assert, accounts }) {
|
|
112
|
+
async run({ http, assert, accounts, config }) {
|
|
113
|
+
const paidProduct = getFirstPaidProduct(config);
|
|
111
114
|
const response = await http.get('user', {});
|
|
112
115
|
|
|
113
116
|
assert.isSuccess(response, 'Resolve should succeed for premium user');
|
|
@@ -115,11 +118,10 @@ module.exports = {
|
|
|
115
118
|
const user = response.data.user;
|
|
116
119
|
|
|
117
120
|
// Verify auth properties
|
|
118
|
-
assert.equal(user.auth.uid, accounts['premium-active'].uid, 'UID should match premium test account');
|
|
119
|
-
assert.equal(user.auth.email, accounts['premium-active'].email, 'Email should match premium test account');
|
|
121
|
+
assert.equal(user.auth.uid, accounts['resolve-premium-active'].uid, 'UID should match premium test account');
|
|
120
122
|
|
|
121
|
-
// Verify subscription - premium user should retain
|
|
122
|
-
assert.equal(user.subscription.product.id,
|
|
123
|
+
// Verify subscription - premium user should retain paid subscription
|
|
124
|
+
assert.equal(user.subscription.product.id, paidProduct.id, 'Subscription ID should match first paid product');
|
|
123
125
|
assert.equal(user.subscription.status, 'active', 'Subscription status should be active');
|
|
124
126
|
|
|
125
127
|
// Verify expires is in the future
|
|
@@ -132,10 +134,11 @@ module.exports = {
|
|
|
132
134
|
// Test 6: Premium expired user - verify subscription retains product but shows cancelled status
|
|
133
135
|
{
|
|
134
136
|
name: 'premium-expired-user-cancelled',
|
|
135
|
-
auth: 'premium-expired',
|
|
137
|
+
auth: 'resolve-premium-expired',
|
|
136
138
|
timeout: 15000,
|
|
137
139
|
|
|
138
|
-
async run({ http, assert, accounts }) {
|
|
140
|
+
async run({ http, assert, accounts, config }) {
|
|
141
|
+
const paidProduct = getFirstPaidProduct(config);
|
|
139
142
|
const response = await http.get('user', {});
|
|
140
143
|
|
|
141
144
|
assert.isSuccess(response, 'Resolve should succeed for expired premium user');
|
|
@@ -143,10 +146,10 @@ module.exports = {
|
|
|
143
146
|
const user = response.data.user;
|
|
144
147
|
|
|
145
148
|
// Verify auth properties
|
|
146
|
-
assert.equal(user.auth.uid, accounts['premium-expired'].uid, 'UID should match expired premium test account');
|
|
149
|
+
assert.equal(user.auth.uid, accounts['resolve-premium-expired'].uid, 'UID should match expired premium test account');
|
|
147
150
|
|
|
148
|
-
// Verify subscription - product.id stays
|
|
149
|
-
assert.equal(user.subscription.product.id,
|
|
151
|
+
// Verify subscription - product.id stays as paid product, status reflects cancellation
|
|
152
|
+
assert.equal(user.subscription.product.id, paidProduct.id, 'Product ID should remain paid product');
|
|
150
153
|
assert.equal(user.subscription.status, 'cancelled', 'Status should be cancelled');
|
|
151
154
|
},
|
|
152
155
|
},
|