payment-kit 1.23.8 → 1.23.9
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.
|
@@ -0,0 +1,1260 @@
|
|
|
1
|
+
import { Request, Response } from 'express';
|
|
2
|
+
import { AutoRechargeConfig, Customer, MeterEvent, PaymentCurrency } from '../../src/store/models';
|
|
3
|
+
import { checkTokenBalance } from '../../src/libs/payment';
|
|
4
|
+
import { getPriceUintAmountByCurrency } from '../../src/libs/price';
|
|
5
|
+
import logger from '../../src/libs/logger';
|
|
6
|
+
import creditGrantsRoute from '../../src/routes/credit-grants';
|
|
7
|
+
|
|
8
|
+
// Mock dependencies
|
|
9
|
+
jest.mock('../../src/libs/payment');
|
|
10
|
+
jest.mock('../../src/libs/price');
|
|
11
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
12
|
+
error: jest.fn(),
|
|
13
|
+
info: jest.fn(),
|
|
14
|
+
warn: jest.fn(),
|
|
15
|
+
debug: jest.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Note: Models are not fully mocked to allow spying on their methods
|
|
19
|
+
|
|
20
|
+
describe('GET /api/credit-grants/verify-availability', () => {
|
|
21
|
+
let mockReq: Partial<Request>;
|
|
22
|
+
let mockRes: Partial<Response>;
|
|
23
|
+
let routeHandler: any;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
// Setup mocks
|
|
27
|
+
jest.clearAllMocks();
|
|
28
|
+
|
|
29
|
+
mockReq = {
|
|
30
|
+
query: {
|
|
31
|
+
customer_id: 'customer_123',
|
|
32
|
+
currency_id: 'currency_123',
|
|
33
|
+
},
|
|
34
|
+
livemode: true,
|
|
35
|
+
} as any;
|
|
36
|
+
|
|
37
|
+
mockRes = {
|
|
38
|
+
status: jest.fn().mockReturnThis(),
|
|
39
|
+
json: jest.fn().mockReturnThis(),
|
|
40
|
+
} as any;
|
|
41
|
+
|
|
42
|
+
// Extract route handler from router
|
|
43
|
+
// The route structure is: router.get('/verify-availability', authMine, handler)
|
|
44
|
+
// We'll get the handler by finding the route in the router stack
|
|
45
|
+
try {
|
|
46
|
+
const router = creditGrantsRoute as any;
|
|
47
|
+
const routeLayer = router.stack.find((layer: any) => {
|
|
48
|
+
return layer.route && layer.route.path === '/verify-availability';
|
|
49
|
+
});
|
|
50
|
+
if (routeLayer && routeLayer.route) {
|
|
51
|
+
// The handler is the last middleware in the stack (after authMine)
|
|
52
|
+
const { stack } = routeLayer.route;
|
|
53
|
+
routeHandler = stack[stack.length - 1]?.handle;
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// If we can't extract the handler, we'll skip these tests
|
|
57
|
+
console.warn('Could not extract route handler:', error);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('Input validation', () => {
|
|
62
|
+
it('should return 400 if customer_id is missing', async () => {
|
|
63
|
+
if (!routeHandler) return;
|
|
64
|
+
mockReq.query = { currency_id: 'currency_123' } as any;
|
|
65
|
+
|
|
66
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
67
|
+
|
|
68
|
+
expect(mockRes.status).toHaveBeenCalledWith(400);
|
|
69
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
70
|
+
expect.objectContaining({
|
|
71
|
+
error: expect.stringContaining('customer_id'),
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return 400 if currency_id is missing', async () => {
|
|
77
|
+
if (!routeHandler) return;
|
|
78
|
+
mockReq.query = { customer_id: 'customer_123' } as any;
|
|
79
|
+
|
|
80
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
81
|
+
|
|
82
|
+
expect(mockRes.status).toHaveBeenCalledWith(400);
|
|
83
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
error: expect.stringContaining('currency_id'),
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('Customer and currency checks', () => {
|
|
92
|
+
it('should return 404 if customer not found', async () => {
|
|
93
|
+
if (!routeHandler) return;
|
|
94
|
+
jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue(null as any);
|
|
95
|
+
|
|
96
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
97
|
+
|
|
98
|
+
expect(mockRes.status).toHaveBeenCalledWith(404);
|
|
99
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
100
|
+
error: 'Customer customer_123 not found',
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should return 404 if currency not found', async () => {
|
|
105
|
+
if (!routeHandler) return;
|
|
106
|
+
jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue({ id: 'customer_123' } as any);
|
|
107
|
+
jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue(null as any);
|
|
108
|
+
|
|
109
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
110
|
+
|
|
111
|
+
expect(mockRes.status).toHaveBeenCalledWith(404);
|
|
112
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
113
|
+
error: 'PaymentCurrency currency_123 not found',
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('Auto recharge config checks', () => {
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue({ id: 'customer_123' } as any);
|
|
121
|
+
jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue({
|
|
122
|
+
id: 'currency_123',
|
|
123
|
+
decimal: 2,
|
|
124
|
+
} as any);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should return can_continue: false if auto recharge config not found', async () => {
|
|
128
|
+
if (!routeHandler) return;
|
|
129
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(null as any);
|
|
130
|
+
|
|
131
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
132
|
+
|
|
133
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
134
|
+
can_continue: false,
|
|
135
|
+
has_auto_recharge: false,
|
|
136
|
+
reason: 'auto_recharge_config_not_found',
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should return can_continue: false if recharge currency not found', async () => {
|
|
141
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue({
|
|
142
|
+
rechargeCurrency: null,
|
|
143
|
+
price: { id: 'price_123' },
|
|
144
|
+
paymentMethod: { id: 'pm_123' },
|
|
145
|
+
} as any);
|
|
146
|
+
|
|
147
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
148
|
+
|
|
149
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
150
|
+
can_continue: false,
|
|
151
|
+
reason: 'recharge_currency_not_found',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should return can_continue: false if price not found', async () => {
|
|
156
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue({
|
|
157
|
+
rechargeCurrency: { id: 'recharge_currency_123' },
|
|
158
|
+
price: null,
|
|
159
|
+
paymentMethod: { id: 'pm_123' },
|
|
160
|
+
} as any);
|
|
161
|
+
|
|
162
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
163
|
+
|
|
164
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
165
|
+
can_continue: false,
|
|
166
|
+
reason: 'price_not_found',
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should return can_continue: false if payment method not found', async () => {
|
|
171
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue({
|
|
172
|
+
rechargeCurrency: { id: 'recharge_currency_123' },
|
|
173
|
+
price: { id: 'price_123' },
|
|
174
|
+
paymentMethod: null,
|
|
175
|
+
} as any);
|
|
176
|
+
|
|
177
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
178
|
+
|
|
179
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
180
|
+
can_continue: false,
|
|
181
|
+
reason: 'payment_method_not_found',
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should return can_continue: false if payment method is stripe', async () => {
|
|
186
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue({
|
|
187
|
+
rechargeCurrency: { id: 'recharge_currency_123' },
|
|
188
|
+
price: { id: 'price_123' },
|
|
189
|
+
paymentMethod: { type: 'stripe' },
|
|
190
|
+
} as any);
|
|
191
|
+
|
|
192
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
193
|
+
|
|
194
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
195
|
+
can_continue: false,
|
|
196
|
+
reason: 'balance_check_not_supported',
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should return can_continue: false if price amount is invalid', async () => {
|
|
201
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue({
|
|
202
|
+
rechargeCurrency: { id: 'recharge_currency_123' },
|
|
203
|
+
price: { id: 'price_123' },
|
|
204
|
+
paymentMethod: { type: 'arcblock' },
|
|
205
|
+
quantity: 1,
|
|
206
|
+
} as any);
|
|
207
|
+
(getPriceUintAmountByCurrency as jest.Mock).mockResolvedValue(null);
|
|
208
|
+
|
|
209
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
210
|
+
|
|
211
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
212
|
+
can_continue: false,
|
|
213
|
+
reason: 'invalid_price_amount',
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('No pending amount scenarios', () => {
|
|
219
|
+
let mockConfig: any;
|
|
220
|
+
|
|
221
|
+
beforeEach(() => {
|
|
222
|
+
jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue({ id: 'customer_123', did: 'did:customer:123' } as any);
|
|
223
|
+
jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue({
|
|
224
|
+
id: 'currency_123',
|
|
225
|
+
decimal: 2,
|
|
226
|
+
} as any);
|
|
227
|
+
|
|
228
|
+
mockConfig = {
|
|
229
|
+
rechargeCurrency: { id: 'recharge_currency_123' },
|
|
230
|
+
price: {
|
|
231
|
+
id: 'price_123',
|
|
232
|
+
metadata: {
|
|
233
|
+
credit_config: {
|
|
234
|
+
credit_amount: '1',
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
paymentMethod: {
|
|
239
|
+
type: 'arcblock',
|
|
240
|
+
getOcapClient: jest.fn(),
|
|
241
|
+
},
|
|
242
|
+
quantity: 1,
|
|
243
|
+
payment_settings: {
|
|
244
|
+
payment_method_options: {
|
|
245
|
+
arcblock: {
|
|
246
|
+
payer: 'did:test:123',
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(mockConfig as any);
|
|
253
|
+
(getPriceUintAmountByCurrency as jest.Mock).mockResolvedValue('1000000'); // 1 credit with 6 decimals for ABT
|
|
254
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '0' }, [], []] as any);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should return can_continue: true if balance is sufficient for one recharge', async () => {
|
|
258
|
+
(checkTokenBalance as jest.Mock).mockResolvedValue({
|
|
259
|
+
sufficient: true,
|
|
260
|
+
token: { balance: '6000000' }, // 60 with 2 decimals
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
264
|
+
|
|
265
|
+
expect(checkTokenBalance).toHaveBeenCalledWith(
|
|
266
|
+
expect.objectContaining({
|
|
267
|
+
amount: '1000000', // totalAmount for one recharge (1 credit)
|
|
268
|
+
})
|
|
269
|
+
);
|
|
270
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
271
|
+
expect.objectContaining({
|
|
272
|
+
can_continue: true,
|
|
273
|
+
payment_account_sufficient: true,
|
|
274
|
+
})
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should return can_continue: true if balance exactly equals one recharge amount (boundary)', async () => {
|
|
279
|
+
// Balance: exactly 0.1 ABT (one recharge cost for 1 credit)
|
|
280
|
+
(checkTokenBalance as jest.Mock)
|
|
281
|
+
.mockResolvedValueOnce({
|
|
282
|
+
sufficient: true,
|
|
283
|
+
token: { balance: '1000000' }, // Exactly one recharge
|
|
284
|
+
})
|
|
285
|
+
.mockResolvedValueOnce({
|
|
286
|
+
sufficient: true,
|
|
287
|
+
token: { balance: '1000000' },
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
291
|
+
|
|
292
|
+
expect(checkTokenBalance).toHaveBeenCalledWith(
|
|
293
|
+
expect.objectContaining({
|
|
294
|
+
amount: '1000000',
|
|
295
|
+
})
|
|
296
|
+
);
|
|
297
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
298
|
+
expect.objectContaining({
|
|
299
|
+
can_continue: true,
|
|
300
|
+
payment_account_sufficient: true,
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should return can_continue: false if balance is insufficient for one recharge', async () => {
|
|
306
|
+
(checkTokenBalance as jest.Mock).mockReset();
|
|
307
|
+
(checkTokenBalance as jest.Mock).mockImplementation((params: any) => {
|
|
308
|
+
if (params.amount === '1000000') {
|
|
309
|
+
return {
|
|
310
|
+
sufficient: false,
|
|
311
|
+
token: { balance: '500000' }, // 0.05 ABT, insufficient for 0.1 ABT
|
|
312
|
+
reason: 'NO_ENOUGH_TOKEN',
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
sufficient: true,
|
|
317
|
+
token: { balance: '500000' },
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
322
|
+
|
|
323
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
324
|
+
can_continue: false,
|
|
325
|
+
reason: 'insufficient_balance',
|
|
326
|
+
payment_account_balance: '500000',
|
|
327
|
+
pending_amount: '0',
|
|
328
|
+
required_amount: '1000000',
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should return can_continue: false if balance is exactly one unit less than required (boundary)', async () => {
|
|
333
|
+
// Balance: 0.0999999 ABT (one unit less than 0.1 ABT)
|
|
334
|
+
(checkTokenBalance as jest.Mock).mockImplementation((params: any) => {
|
|
335
|
+
if (params.amount === '1000000') {
|
|
336
|
+
return {
|
|
337
|
+
sufficient: false,
|
|
338
|
+
token: { balance: '999999' }, // 1 unit less than required
|
|
339
|
+
reason: 'NO_ENOUGH_TOKEN',
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
sufficient: true,
|
|
344
|
+
token: { balance: '999999' },
|
|
345
|
+
};
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
349
|
+
|
|
350
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
351
|
+
can_continue: false,
|
|
352
|
+
reason: 'insufficient_balance',
|
|
353
|
+
payment_account_balance: '999999',
|
|
354
|
+
pending_amount: '0',
|
|
355
|
+
required_amount: '1000000',
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should return can_continue: false if balance is zero', async () => {
|
|
360
|
+
// Balance: 0 ABT
|
|
361
|
+
(checkTokenBalance as jest.Mock).mockImplementation((params: any) => {
|
|
362
|
+
if (params.amount === '1000000') {
|
|
363
|
+
return {
|
|
364
|
+
sufficient: false,
|
|
365
|
+
token: { balance: '0' },
|
|
366
|
+
reason: 'NO_ENOUGH_TOKEN',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
sufficient: true,
|
|
371
|
+
token: { balance: '0' },
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
376
|
+
|
|
377
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
378
|
+
can_continue: false,
|
|
379
|
+
reason: 'insufficient_balance',
|
|
380
|
+
payment_account_balance: '0',
|
|
381
|
+
pending_amount: '0',
|
|
382
|
+
required_amount: '1000000',
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe('Pending amount scenarios', () => {
|
|
388
|
+
let mockConfig: any;
|
|
389
|
+
|
|
390
|
+
beforeEach(() => {
|
|
391
|
+
jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue({ id: 'customer_123', did: 'did:customer:123' } as any);
|
|
392
|
+
jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue({
|
|
393
|
+
id: 'currency_123',
|
|
394
|
+
decimal: 2,
|
|
395
|
+
} as any);
|
|
396
|
+
|
|
397
|
+
mockConfig = {
|
|
398
|
+
rechargeCurrency: { id: 'recharge_currency_123' },
|
|
399
|
+
price: {
|
|
400
|
+
id: 'price_123',
|
|
401
|
+
metadata: {
|
|
402
|
+
credit_config: {
|
|
403
|
+
credit_amount: '1', // 1 credit per recharge
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
paymentMethod: {
|
|
408
|
+
type: 'arcblock',
|
|
409
|
+
getOcapClient: jest.fn(),
|
|
410
|
+
},
|
|
411
|
+
quantity: 1,
|
|
412
|
+
payment_settings: {
|
|
413
|
+
payment_method_options: {
|
|
414
|
+
arcblock: {
|
|
415
|
+
payer: 'did:test:123',
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
last_recharge_date: '2024-01-01',
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(mockConfig as any);
|
|
423
|
+
(getPriceUintAmountByCurrency as jest.Mock).mockResolvedValue('1000000'); // 1 credit with 2 decimals = 0.1 ABT (1 * 10^6 for ABT decimal=6)
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('Recharge times limit (3 times)', () => {
|
|
427
|
+
it('should return can_continue: false if required recharge times > 3', async () => {
|
|
428
|
+
// Pending: 4 credits, each recharge: 1 credit, need 4 times
|
|
429
|
+
// 4 credits with decimal=2: 4 * 10^2 = 400 (unit format, within system limit of 500)
|
|
430
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '400' }, [], []] as any);
|
|
431
|
+
|
|
432
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
433
|
+
|
|
434
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
435
|
+
can_continue: false,
|
|
436
|
+
reason: 'too_many_recharges_required',
|
|
437
|
+
pending_amount: '400',
|
|
438
|
+
required_recharge_times: '4',
|
|
439
|
+
max_allowed_times: 3,
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('should return can_continue: false if required recharge times exactly equals 4 (boundary)', async () => {
|
|
444
|
+
// Pending: 4 credits, each recharge: 1 credit, need ceil(4/1) = 4 times
|
|
445
|
+
// 4 credits with decimal=2: 4 * 10^2 = 400 (unit format)
|
|
446
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '400' }, [], []] as any);
|
|
447
|
+
|
|
448
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
449
|
+
|
|
450
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
451
|
+
can_continue: false,
|
|
452
|
+
reason: 'too_many_recharges_required',
|
|
453
|
+
pending_amount: '400',
|
|
454
|
+
required_recharge_times: '4',
|
|
455
|
+
max_allowed_times: 3,
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('should continue if required recharge times exactly equals 3 (boundary)', async () => {
|
|
460
|
+
// Pending: 3 credits, each recharge: 1 credit, need exactly 3 times
|
|
461
|
+
// 3 credits with decimal=2: 3 * 10^2 = 300 (unit format, within system limit of 500)
|
|
462
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '300' }, [], []] as any);
|
|
463
|
+
(checkTokenBalance as jest.Mock)
|
|
464
|
+
.mockResolvedValueOnce({
|
|
465
|
+
sufficient: true,
|
|
466
|
+
token: { balance: '10000000' },
|
|
467
|
+
})
|
|
468
|
+
.mockResolvedValueOnce({
|
|
469
|
+
sufficient: true,
|
|
470
|
+
token: { balance: '10000000' },
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
474
|
+
|
|
475
|
+
expect(mockRes.json).not.toHaveBeenCalledWith(
|
|
476
|
+
expect.objectContaining({
|
|
477
|
+
reason: 'too_many_recharges_required',
|
|
478
|
+
})
|
|
479
|
+
);
|
|
480
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
481
|
+
expect.objectContaining({
|
|
482
|
+
can_continue: true,
|
|
483
|
+
})
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('should continue if required recharge times is 1 (minimal pending)', async () => {
|
|
488
|
+
// Pending: 1 credit, each recharge: 1 credit, need 1 time
|
|
489
|
+
// 1 credit with decimal=2: 1 * 10^2 = 100 (unit format)
|
|
490
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '100' }, [], []] as any);
|
|
491
|
+
(checkTokenBalance as jest.Mock)
|
|
492
|
+
.mockResolvedValueOnce({
|
|
493
|
+
sufficient: true,
|
|
494
|
+
token: { balance: '10000000' },
|
|
495
|
+
})
|
|
496
|
+
.mockResolvedValueOnce({
|
|
497
|
+
sufficient: true,
|
|
498
|
+
token: { balance: '10000000' },
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
502
|
+
|
|
503
|
+
expect(mockRes.json).not.toHaveBeenCalledWith(
|
|
504
|
+
expect.objectContaining({
|
|
505
|
+
reason: 'too_many_recharges_required',
|
|
506
|
+
})
|
|
507
|
+
);
|
|
508
|
+
expect(checkTokenBalance).toHaveBeenCalled();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('should continue if required recharge times <= 3', async () => {
|
|
512
|
+
// Pending: 2 credits, each recharge: 1 credit, need 2 times
|
|
513
|
+
// 2 credits with decimal=2: 2 * 10^2 = 200 (unit format, within system limit of 500)
|
|
514
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '200' }, [], []] as any);
|
|
515
|
+
(checkTokenBalance as jest.Mock).mockResolvedValue({
|
|
516
|
+
sufficient: true,
|
|
517
|
+
token: { balance: '20000000' },
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
521
|
+
|
|
522
|
+
// Should not return too_many_recharges_required error
|
|
523
|
+
expect(mockRes.json).not.toHaveBeenCalledWith(
|
|
524
|
+
expect.objectContaining({
|
|
525
|
+
reason: 'too_many_recharges_required',
|
|
526
|
+
})
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe('Custom max_recharge_times parameter', () => {
|
|
532
|
+
it('should use custom max_recharge_times when provided', async () => {
|
|
533
|
+
mockReq.query = {
|
|
534
|
+
customer_id: 'customer_123',
|
|
535
|
+
currency_id: 'currency_123',
|
|
536
|
+
max_recharge_times: '5', // Custom: allow up to 5 recharges
|
|
537
|
+
} as any;
|
|
538
|
+
|
|
539
|
+
// Pending: 4 credits, each recharge: 1 credit, need 4 times
|
|
540
|
+
// 4 credits with decimal=2: 4 * 10^2 = 400 (unit format, within system limit of 500)
|
|
541
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '400' }, [], []] as any);
|
|
542
|
+
(checkTokenBalance as jest.Mock).mockResolvedValue({
|
|
543
|
+
sufficient: true,
|
|
544
|
+
token: { balance: '30000000' },
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
548
|
+
|
|
549
|
+
// Should pass because 4 < 5
|
|
550
|
+
expect(mockRes.json).not.toHaveBeenCalledWith(
|
|
551
|
+
expect.objectContaining({
|
|
552
|
+
reason: 'too_many_recharges_required',
|
|
553
|
+
})
|
|
554
|
+
);
|
|
555
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
556
|
+
expect.objectContaining({
|
|
557
|
+
can_continue: true,
|
|
558
|
+
})
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('should reject if required recharge times exceeds custom max_recharge_times', async () => {
|
|
563
|
+
mockReq.query = {
|
|
564
|
+
customer_id: 'customer_123',
|
|
565
|
+
currency_id: 'currency_123',
|
|
566
|
+
max_recharge_times: '2', // Custom: only allow 2 recharges
|
|
567
|
+
} as any;
|
|
568
|
+
|
|
569
|
+
// Pending: 3 credits, each recharge: 1 credit, need 3 times
|
|
570
|
+
// 3 credits with decimal=2: 3 * 10^2 = 300 (unit format, within system limit of 500)
|
|
571
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '300' }, [], []] as any);
|
|
572
|
+
|
|
573
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
574
|
+
|
|
575
|
+
// Should fail because 3 > 2
|
|
576
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
577
|
+
can_continue: false,
|
|
578
|
+
reason: 'too_many_recharges_required',
|
|
579
|
+
pending_amount: '300',
|
|
580
|
+
required_recharge_times: '3',
|
|
581
|
+
max_allowed_times: 2,
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('should use default max_recharge_times (3) when not provided', async () => {
|
|
586
|
+
mockReq.query = {
|
|
587
|
+
customer_id: 'customer_123',
|
|
588
|
+
currency_id: 'currency_123',
|
|
589
|
+
// max_recharge_times not provided, should default to 3
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
// Pending: 4 credits, each recharge: 1 credit, need 4 times
|
|
593
|
+
// 4 credits with decimal=2: 4 * 10^2 = 400 (unit format, within system limit of 500)
|
|
594
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '400' }, [], []] as any);
|
|
595
|
+
|
|
596
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
597
|
+
|
|
598
|
+
// Should fail because 4 > 3 (default)
|
|
599
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
600
|
+
can_continue: false,
|
|
601
|
+
reason: 'too_many_recharges_required',
|
|
602
|
+
pending_amount: '400',
|
|
603
|
+
required_recharge_times: '4',
|
|
604
|
+
max_allowed_times: 3, // Default value
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
describe('Max pending amount limit', () => {
|
|
610
|
+
it('should reject if pending amount exceeds max_pending_amount', async () => {
|
|
611
|
+
mockReq.query = {
|
|
612
|
+
customer_id: 'customer_123',
|
|
613
|
+
currency_id: 'currency_123',
|
|
614
|
+
max_pending_amount: '3', // Token format: limit to 3 credits
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// Pending: 4 credits (in unit format with decimal=2: 4 * 10^2 = 400, within system limit of 500)
|
|
618
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '400' }, [], []] as any);
|
|
619
|
+
|
|
620
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
621
|
+
|
|
622
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
623
|
+
can_continue: false,
|
|
624
|
+
reason: 'pending_amount_exceeds_limit',
|
|
625
|
+
pending_amount: '400',
|
|
626
|
+
max_pending_amount: '300', // Converted to unit format: 3 * 10^2 = 300
|
|
627
|
+
detail: 'Current pending amount exceeds the maximum allowed limit',
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('should allow if pending amount equals max_pending_amount (boundary)', async () => {
|
|
632
|
+
mockReq.query = {
|
|
633
|
+
customer_id: 'customer_123',
|
|
634
|
+
currency_id: 'currency_123',
|
|
635
|
+
max_pending_amount: '4', // Token format: limit to 4 credits
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
// Pending: exactly 4 credits (in unit format: 4 * 10^2 = 400, within system limit of 500)
|
|
639
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '400' }, [], []] as any);
|
|
640
|
+
(checkTokenBalance as jest.Mock).mockResolvedValue({
|
|
641
|
+
sufficient: true,
|
|
642
|
+
token: { balance: '20000000' },
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
646
|
+
|
|
647
|
+
// Should not be rejected by max_pending_amount check
|
|
648
|
+
expect(mockRes.json).not.toHaveBeenCalledWith(
|
|
649
|
+
expect.objectContaining({
|
|
650
|
+
reason: 'pending_amount_exceeds_limit',
|
|
651
|
+
})
|
|
652
|
+
);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('should allow if pending amount is below max_pending_amount', async () => {
|
|
656
|
+
mockReq.query = {
|
|
657
|
+
customer_id: 'customer_123',
|
|
658
|
+
currency_id: 'currency_123',
|
|
659
|
+
max_pending_amount: '4', // Token format: limit to 4 credits
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// Pending: 2 credits (in unit format: 2 * 10^2 = 200, within system limit of 500)
|
|
663
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '200' }, [], []] as any);
|
|
664
|
+
(checkTokenBalance as jest.Mock).mockResolvedValue({
|
|
665
|
+
sufficient: true,
|
|
666
|
+
token: { balance: '20000000' },
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
670
|
+
|
|
671
|
+
expect(mockRes.json).not.toHaveBeenCalledWith(
|
|
672
|
+
expect.objectContaining({
|
|
673
|
+
reason: 'pending_amount_exceeds_limit',
|
|
674
|
+
})
|
|
675
|
+
);
|
|
676
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
677
|
+
expect.objectContaining({
|
|
678
|
+
can_continue: true,
|
|
679
|
+
})
|
|
680
|
+
);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('should handle decimal max_pending_amount values', async () => {
|
|
684
|
+
mockReq.query = {
|
|
685
|
+
customer_id: 'customer_123',
|
|
686
|
+
currency_id: 'currency_123',
|
|
687
|
+
max_pending_amount: '3.5', // Token format with decimal: 3.5 credits
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
// Pending: 4 credits (in unit format: 4 * 10^2 = 400, within system limit of 500)
|
|
691
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '400' }, [], []] as any);
|
|
692
|
+
|
|
693
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
694
|
+
|
|
695
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
696
|
+
can_continue: false,
|
|
697
|
+
reason: 'pending_amount_exceeds_limit',
|
|
698
|
+
pending_amount: '400',
|
|
699
|
+
max_pending_amount: '350', // Converted: 3.5 * 10^2 = 350
|
|
700
|
+
detail: 'Current pending amount exceeds the maximum allowed limit',
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it('should not check max_pending_amount if pending is zero', async () => {
|
|
705
|
+
mockReq.query = {
|
|
706
|
+
customer_id: 'customer_123',
|
|
707
|
+
currency_id: 'currency_123',
|
|
708
|
+
max_pending_amount: '10', // Very low limit
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
// No pending amount
|
|
712
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '0' }, [], []] as any);
|
|
713
|
+
(checkTokenBalance as jest.Mock).mockResolvedValue({
|
|
714
|
+
sufficient: true,
|
|
715
|
+
token: { balance: '20000000' },
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
719
|
+
|
|
720
|
+
// Should not be rejected even though limit is low, because there's no pending
|
|
721
|
+
expect(mockRes.json).not.toHaveBeenCalledWith(
|
|
722
|
+
expect.objectContaining({
|
|
723
|
+
reason: 'pending_amount_exceeds_limit',
|
|
724
|
+
})
|
|
725
|
+
);
|
|
726
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
727
|
+
expect.objectContaining({
|
|
728
|
+
can_continue: true,
|
|
729
|
+
})
|
|
730
|
+
);
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
describe('Balance checks with pending amount', () => {
|
|
735
|
+
beforeEach(() => {
|
|
736
|
+
// Pending: 2 credits, each recharge: 1 credit, need 2 times
|
|
737
|
+
// 2 credits with decimal=2: 2 * 10^2 = 200 (unit format, within system limit of 500)
|
|
738
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '200' }, [], []] as any);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('should return can_continue: false if balance is insufficient (less than required)', async () => {
|
|
742
|
+
// Pending: 2 credits, each recharge: 1 credit
|
|
743
|
+
// Required: 2 * 0.1 ABT = 0.2 ABT (pay off pending) + 0.1 ABT (one more recharge) = 0.3 ABT
|
|
744
|
+
// Balance: 0.25 ABT (insufficient)
|
|
745
|
+
(checkTokenBalance as jest.Mock).mockReset();
|
|
746
|
+
(checkTokenBalance as jest.Mock).mockImplementation((params: any) => {
|
|
747
|
+
// Required amount: 200 (pending) + 100 (one more recharge) = 300 (unit format for credits)
|
|
748
|
+
// Convert to ABT: need to check actual amount calculation
|
|
749
|
+
const requiredAmount = '3000000'; // 0.3 ABT with 6 decimals
|
|
750
|
+
if (params.amount === requiredAmount) {
|
|
751
|
+
return {
|
|
752
|
+
sufficient: false,
|
|
753
|
+
token: { balance: '2500000' }, // 0.25 ABT
|
|
754
|
+
reason: 'NO_ENOUGH_TOKEN',
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
sufficient: true,
|
|
759
|
+
token: { balance: '2500000' },
|
|
760
|
+
};
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
764
|
+
|
|
765
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
766
|
+
expect.objectContaining({
|
|
767
|
+
can_continue: false,
|
|
768
|
+
reason: 'insufficient_balance',
|
|
769
|
+
pending_amount: '200',
|
|
770
|
+
})
|
|
771
|
+
);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it('should return can_continue: false if balance exactly equals required amount minus 1 (boundary)', async () => {
|
|
775
|
+
// Required: 0.3 ABT, Balance: 0.299999 ABT (insufficient by 1 unit)
|
|
776
|
+
(checkTokenBalance as jest.Mock).mockImplementation((params: any) => {
|
|
777
|
+
const requiredAmount = '3000000';
|
|
778
|
+
if (params.amount === requiredAmount) {
|
|
779
|
+
return {
|
|
780
|
+
sufficient: false,
|
|
781
|
+
token: { balance: '2999999' }, // 1 unit less than required
|
|
782
|
+
reason: 'NO_ENOUGH_TOKEN',
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
return {
|
|
786
|
+
sufficient: true,
|
|
787
|
+
token: { balance: '2999999' },
|
|
788
|
+
};
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
792
|
+
|
|
793
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
794
|
+
expect.objectContaining({
|
|
795
|
+
can_continue: false,
|
|
796
|
+
reason: 'insufficient_balance',
|
|
797
|
+
pending_amount: '200',
|
|
798
|
+
})
|
|
799
|
+
);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('should return can_continue: true if balance exactly equals required amount (boundary)', async () => {
|
|
803
|
+
// Required: 0.3 ABT, Balance: exactly 0.3 ABT (sufficient)
|
|
804
|
+
(checkTokenBalance as jest.Mock).mockImplementation((params: any) => {
|
|
805
|
+
const requiredAmount = '3000000';
|
|
806
|
+
if (params.amount === requiredAmount) {
|
|
807
|
+
return {
|
|
808
|
+
sufficient: true,
|
|
809
|
+
token: { balance: '3000000' }, // Exactly required amount
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
sufficient: true,
|
|
814
|
+
token: { balance: '3000000' },
|
|
815
|
+
};
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
819
|
+
|
|
820
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
821
|
+
expect.objectContaining({
|
|
822
|
+
can_continue: true,
|
|
823
|
+
payment_account_sufficient: true,
|
|
824
|
+
})
|
|
825
|
+
);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('should return can_continue: false if balance can only pay off pending but not cover one more recharge', async () => {
|
|
829
|
+
// Pending: 2 credits (need 2 recharges = 0.2 ABT)
|
|
830
|
+
// Balance: 0.2 ABT (can pay off pending, but cannot cover one more recharge = 0.1 ABT)
|
|
831
|
+
// Required: 0.2 + 0.1 = 0.3 ABT
|
|
832
|
+
(checkTokenBalance as jest.Mock).mockImplementation((params: any) => {
|
|
833
|
+
const requiredAmount = '3000000';
|
|
834
|
+
if (params.amount === requiredAmount) {
|
|
835
|
+
return {
|
|
836
|
+
sufficient: false,
|
|
837
|
+
token: { balance: '2000000' }, // Exactly enough to pay off pending, but not enough for one more
|
|
838
|
+
reason: 'NO_ENOUGH_TOKEN',
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
return {
|
|
842
|
+
sufficient: true,
|
|
843
|
+
token: { balance: '2000000' },
|
|
844
|
+
};
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
848
|
+
|
|
849
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
850
|
+
expect.objectContaining({
|
|
851
|
+
can_continue: false,
|
|
852
|
+
reason: 'insufficient_balance',
|
|
853
|
+
pending_amount: '200',
|
|
854
|
+
})
|
|
855
|
+
);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('should return can_continue: true if balance is sufficient (greater than required)', async () => {
|
|
859
|
+
// Required: 0.3 ABT, Balance: 0.5 ABT (sufficient)
|
|
860
|
+
(checkTokenBalance as jest.Mock)
|
|
861
|
+
.mockResolvedValueOnce({
|
|
862
|
+
sufficient: true,
|
|
863
|
+
token: { balance: '5000000' },
|
|
864
|
+
})
|
|
865
|
+
.mockResolvedValueOnce({
|
|
866
|
+
sufficient: true,
|
|
867
|
+
token: { balance: '5000000' },
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
871
|
+
|
|
872
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
873
|
+
expect.objectContaining({
|
|
874
|
+
can_continue: true,
|
|
875
|
+
payment_account_sufficient: true,
|
|
876
|
+
})
|
|
877
|
+
);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it('should return can_continue: false if balance is zero', async () => {
|
|
881
|
+
// Balance: 0 ABT
|
|
882
|
+
(checkTokenBalance as jest.Mock).mockReset();
|
|
883
|
+
(checkTokenBalance as jest.Mock).mockImplementation((params: any) => {
|
|
884
|
+
const requiredAmount = '3000000';
|
|
885
|
+
if (params.amount === requiredAmount) {
|
|
886
|
+
return {
|
|
887
|
+
sufficient: false,
|
|
888
|
+
token: { balance: '0' },
|
|
889
|
+
reason: 'NO_ENOUGH_TOKEN',
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
return {
|
|
893
|
+
sufficient: true,
|
|
894
|
+
token: { balance: '0' },
|
|
895
|
+
};
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
899
|
+
|
|
900
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
901
|
+
expect.objectContaining({
|
|
902
|
+
can_continue: false,
|
|
903
|
+
reason: 'insufficient_balance',
|
|
904
|
+
pending_amount: '200',
|
|
905
|
+
})
|
|
906
|
+
);
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
describe('Daily limit checks', () => {
|
|
911
|
+
beforeEach(() => {
|
|
912
|
+
// Pending: 2 credits, each recharge: 1 credit, need 2 times
|
|
913
|
+
// 2 credits with decimal=2: 2 * 10^2 = 200 (unit format, within system limit of 500)
|
|
914
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '200' }, [], []] as any);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it('should skip daily limit check if it is a new day', async () => {
|
|
918
|
+
mockConfig.last_recharge_date = '2024-01-01';
|
|
919
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(mockConfig as any);
|
|
920
|
+
// Mock current date to be different
|
|
921
|
+
jest.useFakeTimers();
|
|
922
|
+
jest.setSystemTime(new Date('2024-01-02'));
|
|
923
|
+
|
|
924
|
+
(checkTokenBalance as jest.Mock).mockResolvedValue({
|
|
925
|
+
sufficient: true,
|
|
926
|
+
token: { balance: '20000000' },
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
930
|
+
|
|
931
|
+
// Should not check daily limits
|
|
932
|
+
expect(mockRes.json).not.toHaveBeenCalledWith(
|
|
933
|
+
expect.objectContaining({
|
|
934
|
+
reason: 'daily_limit_reached',
|
|
935
|
+
})
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
jest.useRealTimers();
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it('should return can_continue: false if attempt limit exceeded', async () => {
|
|
942
|
+
// eslint-disable-next-line prefer-destructuring
|
|
943
|
+
mockConfig.last_recharge_date = new Date().toISOString().split('T')[0];
|
|
944
|
+
mockConfig.daily_stats = {
|
|
945
|
+
attempt_count: 2,
|
|
946
|
+
total_amount: '0',
|
|
947
|
+
};
|
|
948
|
+
mockConfig.daily_limits = {
|
|
949
|
+
max_attempts: 3,
|
|
950
|
+
max_amount: '0',
|
|
951
|
+
};
|
|
952
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(mockConfig as any);
|
|
953
|
+
|
|
954
|
+
// Need 2 recharges, but only 1 attempt remaining (3 - 2 = 1)
|
|
955
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
956
|
+
|
|
957
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
958
|
+
can_continue: false,
|
|
959
|
+
reason: 'daily_limit_reached',
|
|
960
|
+
detail: 'attempt_limit_exceeded',
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it('should return can_continue: false if amount limit exceeded', async () => {
|
|
965
|
+
// eslint-disable-next-line prefer-destructuring
|
|
966
|
+
mockConfig.last_recharge_date = new Date().toISOString().split('T')[0];
|
|
967
|
+
mockConfig.daily_stats = {
|
|
968
|
+
attempt_count: 0,
|
|
969
|
+
total_amount: '1800000', // Already spent 1.8 ABT today
|
|
970
|
+
};
|
|
971
|
+
mockConfig.daily_limits = {
|
|
972
|
+
max_attempts: 0,
|
|
973
|
+
max_amount: '2000000', // Max 2 ABT per day
|
|
974
|
+
};
|
|
975
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(mockConfig as any);
|
|
976
|
+
|
|
977
|
+
// Need 2 recharges = 0.2 ABT, but only 0.2 ABT remaining (2 - 1.8 = 0.2)
|
|
978
|
+
// However, we need to check if the remaining amount is sufficient
|
|
979
|
+
// Actually, if we need 2 recharges of 0.1 ABT each = 0.2 ABT, and we have 0.2 ABT remaining, it should pass
|
|
980
|
+
// Let's set it so that we need more than what's remaining
|
|
981
|
+
// Pending: 2 credits, need 2 recharges = 0.2 ABT, but only 0.15 ABT remaining
|
|
982
|
+
mockConfig.daily_stats.total_amount = '1850000'; // Already spent 1.85 ABT, only 0.15 ABT remaining
|
|
983
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(mockConfig as any);
|
|
984
|
+
|
|
985
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
986
|
+
|
|
987
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
988
|
+
can_continue: false,
|
|
989
|
+
reason: 'daily_limit_reached',
|
|
990
|
+
detail: 'amount_limit_exceeded',
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it('should pass daily limit check if limits are sufficient', async () => {
|
|
995
|
+
// eslint-disable-next-line prefer-destructuring
|
|
996
|
+
mockConfig.last_recharge_date = new Date().toISOString().split('T')[0];
|
|
997
|
+
mockConfig.daily_stats = {
|
|
998
|
+
attempt_count: 0,
|
|
999
|
+
total_amount: '0',
|
|
1000
|
+
};
|
|
1001
|
+
mockConfig.daily_limits = {
|
|
1002
|
+
max_attempts: 5,
|
|
1003
|
+
max_amount: '50000000', // Max 50 ABT per day
|
|
1004
|
+
};
|
|
1005
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(mockConfig as any);
|
|
1006
|
+
|
|
1007
|
+
(checkTokenBalance as jest.Mock)
|
|
1008
|
+
.mockResolvedValueOnce({
|
|
1009
|
+
sufficient: true,
|
|
1010
|
+
token: { balance: '20000000' },
|
|
1011
|
+
})
|
|
1012
|
+
.mockResolvedValueOnce({
|
|
1013
|
+
sufficient: true,
|
|
1014
|
+
token: { balance: '20000000' },
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
1018
|
+
|
|
1019
|
+
expect(mockRes.json).not.toHaveBeenCalledWith(
|
|
1020
|
+
expect.objectContaining({
|
|
1021
|
+
reason: 'daily_limit_reached',
|
|
1022
|
+
})
|
|
1023
|
+
);
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
describe('Credit config checks', () => {
|
|
1028
|
+
it('should return can_continue: false if credit_config not found', async () => {
|
|
1029
|
+
mockConfig.price.metadata = {};
|
|
1030
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(mockConfig as any);
|
|
1031
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '200' }, [], []] as any);
|
|
1032
|
+
|
|
1033
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
1034
|
+
|
|
1035
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
1036
|
+
can_continue: false,
|
|
1037
|
+
reason: 'credit_config_not_found',
|
|
1038
|
+
});
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it('should return can_continue: false if credit_amount not found', async () => {
|
|
1042
|
+
mockConfig.price.metadata = {
|
|
1043
|
+
credit_config: {},
|
|
1044
|
+
};
|
|
1045
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(mockConfig as any);
|
|
1046
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '200' }, [], []] as any);
|
|
1047
|
+
|
|
1048
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
1049
|
+
|
|
1050
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
1051
|
+
can_continue: false,
|
|
1052
|
+
reason: 'credit_config_not_found',
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
describe('Payer checks', () => {
|
|
1058
|
+
it('should return can_continue: false if payer not found', async () => {
|
|
1059
|
+
jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue({ id: 'customer_123', did: null } as any);
|
|
1060
|
+
mockConfig.payment_settings = {
|
|
1061
|
+
payment_method_options: {},
|
|
1062
|
+
};
|
|
1063
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(mockConfig as any);
|
|
1064
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '200' }, [], []] as any);
|
|
1065
|
+
|
|
1066
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
1067
|
+
|
|
1068
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
1069
|
+
can_continue: false,
|
|
1070
|
+
reason: 'payer_not_found',
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it('should use customer.did as payer if not specified in payment_settings', async () => {
|
|
1075
|
+
jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue({
|
|
1076
|
+
id: 'customer_123',
|
|
1077
|
+
did: 'did:customer:123',
|
|
1078
|
+
} as any);
|
|
1079
|
+
mockConfig.payment_settings = {
|
|
1080
|
+
payment_method_options: {},
|
|
1081
|
+
};
|
|
1082
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(mockConfig as any);
|
|
1083
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '0' }, [], []] as any);
|
|
1084
|
+
(checkTokenBalance as jest.Mock).mockResolvedValue({
|
|
1085
|
+
sufficient: true,
|
|
1086
|
+
token: { balance: '6000000' },
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
1090
|
+
|
|
1091
|
+
expect(checkTokenBalance).toHaveBeenCalledWith(
|
|
1092
|
+
expect.objectContaining({
|
|
1093
|
+
userDid: 'did:customer:123',
|
|
1094
|
+
})
|
|
1095
|
+
);
|
|
1096
|
+
});
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
describe('Pending amount edge cases', () => {
|
|
1100
|
+
it('should handle pending amount exactly equals one recharge credit amount', async () => {
|
|
1101
|
+
// Pending: exactly 1 credit (one recharge), need 1 time
|
|
1102
|
+
// 1 credit with decimal=2: 1 * 10^2 = 100 (unit format, within system limit of 500)
|
|
1103
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '100' }, [], []] as any);
|
|
1104
|
+
(checkTokenBalance as jest.Mock)
|
|
1105
|
+
.mockResolvedValueOnce({
|
|
1106
|
+
sufficient: true,
|
|
1107
|
+
token: { balance: '10000000' },
|
|
1108
|
+
})
|
|
1109
|
+
.mockResolvedValueOnce({
|
|
1110
|
+
sufficient: true,
|
|
1111
|
+
token: { balance: '10000000' },
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
1115
|
+
|
|
1116
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
1117
|
+
expect.objectContaining({
|
|
1118
|
+
can_continue: true,
|
|
1119
|
+
})
|
|
1120
|
+
);
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it('should handle pending amount exactly equals three recharge credit amount (boundary)', async () => {
|
|
1124
|
+
// Pending: exactly 3 credits (three recharges), need exactly 3 times
|
|
1125
|
+
// 3 credits with decimal=2: 3 * 10^2 = 300 (unit format, within system limit of 500)
|
|
1126
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '300' }, [], []] as any);
|
|
1127
|
+
(checkTokenBalance as jest.Mock)
|
|
1128
|
+
.mockResolvedValueOnce({
|
|
1129
|
+
sufficient: true,
|
|
1130
|
+
token: { balance: '10000000' },
|
|
1131
|
+
})
|
|
1132
|
+
.mockResolvedValueOnce({
|
|
1133
|
+
sufficient: true,
|
|
1134
|
+
token: { balance: '10000000' },
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
1138
|
+
|
|
1139
|
+
expect(mockRes.json).not.toHaveBeenCalledWith(
|
|
1140
|
+
expect.objectContaining({
|
|
1141
|
+
reason: 'too_many_recharges_required',
|
|
1142
|
+
})
|
|
1143
|
+
);
|
|
1144
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
1145
|
+
expect.objectContaining({
|
|
1146
|
+
can_continue: true,
|
|
1147
|
+
})
|
|
1148
|
+
);
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it('should handle pending amount one unit more than three recharge credit amount', async () => {
|
|
1152
|
+
// Pending: 4 credits (one unit more than 3), need 4 times
|
|
1153
|
+
// 4 credits with decimal=2: 4 * 10^2 = 400 (unit format, within system limit of 500)
|
|
1154
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '400' }, [], []] as any);
|
|
1155
|
+
|
|
1156
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
1157
|
+
|
|
1158
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
1159
|
+
can_continue: false,
|
|
1160
|
+
reason: 'too_many_recharges_required',
|
|
1161
|
+
pending_amount: '400',
|
|
1162
|
+
required_recharge_times: '4',
|
|
1163
|
+
max_allowed_times: 3,
|
|
1164
|
+
});
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
it('should handle very small pending amount (1 credit)', async () => {
|
|
1168
|
+
// Pending: 1 credit, need 1 time
|
|
1169
|
+
// 1 credit with decimal=2: 1 * 10^2 = 100 (unit format)
|
|
1170
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '100' }, [], []] as any);
|
|
1171
|
+
(checkTokenBalance as jest.Mock)
|
|
1172
|
+
.mockResolvedValueOnce({
|
|
1173
|
+
sufficient: true,
|
|
1174
|
+
token: { balance: '10000000' }, // 10 ABT (1 * 5 + 5)
|
|
1175
|
+
})
|
|
1176
|
+
.mockResolvedValueOnce({
|
|
1177
|
+
sufficient: true,
|
|
1178
|
+
token: { balance: '10000000' },
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
1182
|
+
|
|
1183
|
+
expect(checkTokenBalance).toHaveBeenCalledWith(
|
|
1184
|
+
expect.objectContaining({
|
|
1185
|
+
amount: '2000000', // 0.1 ABT (pay off 1 credit) + 0.1 ABT (one more recharge)
|
|
1186
|
+
})
|
|
1187
|
+
);
|
|
1188
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
1189
|
+
expect.objectContaining({
|
|
1190
|
+
can_continue: true,
|
|
1191
|
+
})
|
|
1192
|
+
);
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
describe('Pending amount from query parameter', () => {
|
|
1197
|
+
it('should use pending_amount from query if provided', async () => {
|
|
1198
|
+
mockReq.query = {
|
|
1199
|
+
customer_id: 'customer_123',
|
|
1200
|
+
currency_id: 'currency_123',
|
|
1201
|
+
pending_amount: '300', // 3 credits with decimal=2: 3 * 10^2 = 300 (unit format, within system limit of 500)
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '200' }, [], []] as any);
|
|
1205
|
+
(checkTokenBalance as jest.Mock).mockResolvedValue({
|
|
1206
|
+
sufficient: true,
|
|
1207
|
+
token: { balance: '20000000' },
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
1211
|
+
|
|
1212
|
+
// Should use 300 from query, not 200 from getPendingAmounts
|
|
1213
|
+
// 3 credits / 1 credit per recharge = 3 times
|
|
1214
|
+
expect(checkTokenBalance).toHaveBeenCalled();
|
|
1215
|
+
expect(mockRes.json).toHaveBeenCalledWith(
|
|
1216
|
+
expect.objectContaining({
|
|
1217
|
+
can_continue: true,
|
|
1218
|
+
})
|
|
1219
|
+
);
|
|
1220
|
+
});
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
describe('Quantity handling', () => {
|
|
1224
|
+
it('should handle quantity > 1 correctly', async () => {
|
|
1225
|
+
mockConfig.quantity = 2; // Buy 2 units per recharge
|
|
1226
|
+
jest.spyOn(AutoRechargeConfig, 'findOne').mockResolvedValue(mockConfig as any);
|
|
1227
|
+
(getPriceUintAmountByCurrency as jest.Mock).mockResolvedValue('1000000'); // 1 credit (0.1 ABT) per unit
|
|
1228
|
+
jest.spyOn(MeterEvent, 'getPendingAmounts').mockResolvedValue([{ currency_123: '0' }, [], []] as any);
|
|
1229
|
+
|
|
1230
|
+
(checkTokenBalance as jest.Mock).mockResolvedValue({
|
|
1231
|
+
sufficient: true,
|
|
1232
|
+
token: { balance: '20000000' },
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
1236
|
+
|
|
1237
|
+
// totalAmount should be 0.1 ABT * 2 = 0.2 ABT
|
|
1238
|
+
expect(checkTokenBalance).toHaveBeenCalledWith(
|
|
1239
|
+
expect.objectContaining({
|
|
1240
|
+
amount: '2000000', // 0.2 ABT (1 credit * 2 quantity)
|
|
1241
|
+
})
|
|
1242
|
+
);
|
|
1243
|
+
});
|
|
1244
|
+
});
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
describe('Error handling', () => {
|
|
1248
|
+
it('should handle errors and return 400', async () => {
|
|
1249
|
+
jest.spyOn(Customer, 'findByPkOrDid').mockRejectedValue(new Error('Database error'));
|
|
1250
|
+
|
|
1251
|
+
await routeHandler(mockReq as Request, mockRes as Response);
|
|
1252
|
+
|
|
1253
|
+
expect(mockRes.status).toHaveBeenCalledWith(400);
|
|
1254
|
+
expect(mockRes.json).toHaveBeenCalledWith({
|
|
1255
|
+
error: 'Database error',
|
|
1256
|
+
});
|
|
1257
|
+
expect(logger.error).toHaveBeenCalled();
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
});
|