payment-kit 1.23.8 → 1.23.10

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