payment-kit 1.26.4 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/api/src/libs/payment.ts +113 -22
  2. package/api/src/libs/queue/index.ts +20 -9
  3. package/api/src/libs/queue/store.ts +11 -7
  4. package/api/src/libs/reference-cache.ts +115 -0
  5. package/api/src/queues/auto-recharge.ts +68 -21
  6. package/api/src/queues/credit-consume.ts +835 -206
  7. package/api/src/routes/checkout-sessions.ts +78 -1
  8. package/api/src/routes/customers.ts +15 -3
  9. package/api/src/routes/donations.ts +4 -4
  10. package/api/src/routes/index.ts +37 -8
  11. package/api/src/routes/invoices.ts +14 -3
  12. package/api/src/routes/meter-events.ts +41 -15
  13. package/api/src/routes/payment-links.ts +2 -2
  14. package/api/src/routes/prices.ts +1 -1
  15. package/api/src/routes/pricing-table.ts +3 -2
  16. package/api/src/routes/products.ts +2 -2
  17. package/api/src/routes/subscription-items.ts +12 -3
  18. package/api/src/routes/subscriptions.ts +27 -9
  19. package/api/src/store/migrations/20260306-checkout-session-indexes.ts +23 -0
  20. package/api/src/store/models/checkout-session.ts +3 -2
  21. package/api/src/store/models/coupon.ts +9 -6
  22. package/api/src/store/models/credit-grant.ts +4 -1
  23. package/api/src/store/models/credit-transaction.ts +3 -2
  24. package/api/src/store/models/customer.ts +9 -6
  25. package/api/src/store/models/exchange-rate-provider.ts +9 -6
  26. package/api/src/store/models/invoice.ts +3 -2
  27. package/api/src/store/models/meter-event.ts +6 -4
  28. package/api/src/store/models/meter.ts +9 -6
  29. package/api/src/store/models/payment-intent.ts +9 -6
  30. package/api/src/store/models/payment-link.ts +9 -6
  31. package/api/src/store/models/payout.ts +3 -2
  32. package/api/src/store/models/price.ts +9 -6
  33. package/api/src/store/models/pricing-table.ts +9 -6
  34. package/api/src/store/models/product.ts +9 -6
  35. package/api/src/store/models/promotion-code.ts +9 -6
  36. package/api/src/store/models/refund.ts +9 -6
  37. package/api/src/store/models/setup-intent.ts +6 -4
  38. package/api/src/store/sequelize.ts +8 -3
  39. package/api/tests/queues/credit-consume-batch.spec.ts +438 -0
  40. package/api/tests/queues/credit-consume.spec.ts +505 -0
  41. package/api/third.d.ts +1 -1
  42. package/blocklet.yml +1 -1
  43. package/package.json +8 -7
  44. package/scripts/benchmark-seed.js +247 -0
  45. package/src/components/customer/credit-overview.tsx +31 -42
  46. package/src/components/invoice-pdf/template.tsx +5 -4
  47. package/src/components/payment-link/actions.tsx +45 -0
  48. package/src/components/payment-link/before-pay.tsx +24 -0
  49. package/src/components/subscription/payment-method-info.tsx +23 -6
  50. package/src/components/subscription/portal/actions.tsx +2 -0
  51. package/src/locales/en.tsx +11 -0
  52. package/src/locales/zh.tsx +10 -0
  53. package/src/pages/admin/products/links/detail.tsx +8 -0
  54. package/src/pages/customer/subscription/detail.tsx +21 -18
@@ -0,0 +1,438 @@
1
+ import { BN } from '@ocap/util';
2
+
3
+ import { handleBatchCreditConsumption } from '../../src/queues/credit-consume';
4
+
5
+ import { MeterEvent, CreditGrant, CreditTransaction, Customer } from '../../src/store/models';
6
+ import { getCachedMeterExpanded } from '../../src/libs/reference-cache';
7
+ import { checkAndTriggerAutoRecharge } from '../../src/queues/auto-recharge';
8
+
9
+ // ============================================================================
10
+ // Mocks (same as credit-consume.spec.ts)
11
+ // ============================================================================
12
+
13
+ jest.mock('../../src/libs/logger', () => ({
14
+ __esModule: true,
15
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
16
+ }));
17
+
18
+ jest.mock('../../src/libs/audit', () => ({
19
+ createEvent: jest.fn().mockResolvedValue(undefined),
20
+ }));
21
+
22
+ jest.mock('../../src/libs/event', () => ({
23
+ events: { on: jest.fn() },
24
+ }));
25
+
26
+ jest.mock('../../src/libs/lock', () => {
27
+ const lock = { acquire: jest.fn().mockResolvedValue(true), release: jest.fn(), locked: false };
28
+ // @ts-ignore expose for test manipulation
29
+ global.__mockLock = lock;
30
+ return {
31
+ getLock: jest.fn().mockReturnValue(lock),
32
+ Lock: jest.fn(),
33
+ };
34
+ });
35
+
36
+ jest.mock('../../src/libs/reference-cache', () => ({
37
+ getCachedMeterExpanded: jest.fn(),
38
+ }));
39
+
40
+ jest.mock('../../src/libs/subscription', () => ({
41
+ getDaysUntilCancel: jest.fn().mockReturnValue(7),
42
+ getDueUnit: jest.fn().mockReturnValue(86400),
43
+ getMeterPriceIdsFromSubscription: jest.fn().mockResolvedValue(['price_1']),
44
+ }));
45
+
46
+ jest.mock('../../src/libs/util', () => ({
47
+ MAX_RETRY_COUNT: 3,
48
+ getNextRetry: jest.fn().mockReturnValue(Math.floor(Date.now() / 1000) + 60),
49
+ formatCreditAmount: jest.fn().mockImplementation((amount, symbol) => `${symbol}${amount}`),
50
+ }));
51
+
52
+ jest.mock('../../src/queues/payment', () => ({
53
+ handlePastDueSubscriptionRecovery: jest.fn().mockResolvedValue(undefined),
54
+ }));
55
+
56
+ jest.mock('../../src/queues/auto-recharge', () => ({
57
+ checkAndTriggerAutoRecharge: jest.fn().mockResolvedValue(undefined),
58
+ }));
59
+
60
+ jest.mock('../../src/queues/token-transfer', () => ({
61
+ addTokenTransferJob: jest.fn().mockResolvedValue(undefined),
62
+ }));
63
+
64
+ jest.mock('../../src/libs/queue', () => {
65
+ const mockQueue = {
66
+ push: jest.fn(),
67
+ get: jest.fn().mockResolvedValue(null),
68
+ delete: jest.fn().mockResolvedValue(undefined),
69
+ on: jest.fn(),
70
+ };
71
+ return jest.fn().mockReturnValue(mockQueue);
72
+ });
73
+
74
+ jest.mock('../../src/store/models', () => ({
75
+ MeterEvent: {
76
+ findByPk: jest.fn(),
77
+ findAll: jest.fn(),
78
+ update: jest.fn().mockResolvedValue([0]),
79
+ getPendingAmounts: jest.fn(),
80
+ },
81
+ CreditGrant: {
82
+ getAvailableCreditsForCustomer: jest.fn(),
83
+ hasOnchainToken: jest.fn().mockReturnValue(false),
84
+ },
85
+ CreditTransaction: {
86
+ findAll: jest.fn(),
87
+ findOne: jest.fn(),
88
+ create: jest.fn(),
89
+ },
90
+ Customer: {
91
+ findByPk: jest.fn(),
92
+ },
93
+ Subscription: {
94
+ findByPk: jest.fn(),
95
+ },
96
+ }));
97
+
98
+ // ============================================================================
99
+ // Helpers
100
+ // ============================================================================
101
+
102
+ function makeMeterEvent(overrides: Record<string, any> = {}) {
103
+ return {
104
+ id: overrides.id || 'me_1',
105
+ event_name: 'ai-meter-v2',
106
+ status: 'pending',
107
+ credit_consumed: '0',
108
+ credit_pending: '0',
109
+ attempt_count: 0,
110
+ metadata: {},
111
+ created_at: overrides.created_at || new Date(),
112
+ getCustomerId: jest.fn().mockReturnValue('cus_1'),
113
+ getSubscriptionId: jest.fn().mockReturnValue(undefined),
114
+ getValue: jest.fn().mockReturnValue('100'),
115
+ markAsProcessing: jest.fn().mockResolvedValue(undefined),
116
+ markAsRequiresAction: jest.fn().mockResolvedValue(undefined),
117
+ markAsRequiresCapture: jest.fn().mockResolvedValue(undefined),
118
+ update: jest.fn().mockResolvedValue(undefined),
119
+ ...overrides,
120
+ };
121
+ }
122
+
123
+ function makeMeter(overrides: Record<string, any> = {}) {
124
+ return {
125
+ id: 'meter_1',
126
+ name: 'AI Meter',
127
+ unit: 'token',
128
+ currency_id: 'cur_1',
129
+ paymentCurrency: { id: 'cur_1', symbol: '$', decimal: 2 },
130
+ ...overrides,
131
+ };
132
+ }
133
+
134
+ function makeCustomer(overrides: Record<string, any> = {}) {
135
+ return { id: 'cus_1', did: 'did:abt:abc', ...overrides };
136
+ }
137
+
138
+ function makeGrant(id: string, amount: string, remaining: string, overrides: Record<string, any> = {}) {
139
+ return {
140
+ id,
141
+ customer_id: 'cus_1',
142
+ currency_id: 'cur_1',
143
+ amount,
144
+ remaining_amount: remaining,
145
+ status: 'granted',
146
+ metadata: {},
147
+ consumeCredit: jest.fn().mockImplementation(function returnConsumeCredit(this: any, consumeAmt: string) {
148
+ const newRemaining = new BN(this.remaining_amount).sub(new BN(consumeAmt));
149
+ this.remaining_amount = newRemaining.toString();
150
+ const depleted = newRemaining.lte(new BN(0));
151
+ return Promise.resolve({ consumed: consumeAmt, remaining: this.remaining_amount, depleted });
152
+ }),
153
+ save: jest.fn().mockResolvedValue(undefined),
154
+ ...overrides,
155
+ };
156
+ }
157
+
158
+ function setupBatchMocks(events: any[], meter: any, customer: any, grants: any[], existingTx: any[] = []) {
159
+ // findAll is called twice: pre-check and fresh read inside lock
160
+ (MeterEvent.findAll as jest.Mock).mockResolvedValue(events);
161
+ (getCachedMeterExpanded as jest.Mock).mockResolvedValue(meter);
162
+ (Customer.findByPk as jest.Mock).mockResolvedValue(customer);
163
+ (CreditGrant.getAvailableCreditsForCustomer as jest.Mock).mockResolvedValue(grants);
164
+ (CreditTransaction.findAll as jest.Mock).mockResolvedValue(existingTx);
165
+ (CreditTransaction.create as jest.Mock).mockImplementation((data: any) =>
166
+ Promise.resolve({ id: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, ...data })
167
+ );
168
+ }
169
+
170
+ // ============================================================================
171
+ // Tests
172
+ // ============================================================================
173
+
174
+ describe('credit-consume: handleBatchCreditConsumption', () => {
175
+ const getLock = () => (global as any).__mockLock;
176
+
177
+ beforeEach(() => {
178
+ jest.clearAllMocks();
179
+ getLock().acquire.mockResolvedValue(true);
180
+ getLock().release.mockReset();
181
+ });
182
+
183
+ // ==========================================
184
+ // Batch 1: Multiple events, sufficient balance
185
+ // ==========================================
186
+ it('consumes credits for multiple events when balance is sufficient', async () => {
187
+ const event1 = makeMeterEvent({
188
+ id: 'me_1',
189
+ getValue: jest.fn().mockReturnValue('100'),
190
+ created_at: new Date(1000),
191
+ });
192
+ const event2 = makeMeterEvent({
193
+ id: 'me_2',
194
+ getValue: jest.fn().mockReturnValue('50'),
195
+ created_at: new Date(2000),
196
+ });
197
+ const event3 = makeMeterEvent({
198
+ id: 'me_3',
199
+ getValue: jest.fn().mockReturnValue('30'),
200
+ created_at: new Date(3000),
201
+ });
202
+ const meter = makeMeter();
203
+ const customer = makeCustomer();
204
+ const grant = makeGrant('grant_1', '1000', '500');
205
+
206
+ setupBatchMocks([event1, event2, event3], meter, customer, [grant]);
207
+
208
+ await handleBatchCreditConsumption({ meterEventIds: ['me_1', 'me_2', 'me_3'] });
209
+
210
+ // All events should be completed
211
+ expect(event1.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', credit_pending: '0' }));
212
+ expect(event2.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', credit_pending: '0' }));
213
+ expect(event3.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', credit_pending: '0' }));
214
+
215
+ // Grant should be consumed: 100 + 50 + 30 = 180
216
+ expect(grant.remaining_amount).toBe('320');
217
+
218
+ // Lock released
219
+ expect(getLock().release).toHaveBeenCalled();
220
+
221
+ // Auto-recharge triggered
222
+ expect(checkAndTriggerAutoRecharge).toHaveBeenCalled();
223
+ });
224
+
225
+ // ==========================================
226
+ // Batch 2: Partial insufficient balance
227
+ // ==========================================
228
+ it('handles partial insufficient balance — some events succeed, later ones fail', async () => {
229
+ const event1 = makeMeterEvent({
230
+ id: 'me_1',
231
+ getValue: jest.fn().mockReturnValue('80'),
232
+ created_at: new Date(1000),
233
+ });
234
+ const event2 = makeMeterEvent({
235
+ id: 'me_2',
236
+ getValue: jest.fn().mockReturnValue('80'),
237
+ created_at: new Date(2000),
238
+ });
239
+ const meter = makeMeter();
240
+ const customer = makeCustomer();
241
+ const grant = makeGrant('grant_1', '100', '100');
242
+
243
+ setupBatchMocks([event1, event2], meter, customer, [grant]);
244
+
245
+ await handleBatchCreditConsumption({ meterEventIds: ['me_1', 'me_2'] });
246
+
247
+ // Event 1 should succeed (80 consumed, 20 remaining)
248
+ expect(event1.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }));
249
+
250
+ // Event 2 should fail — only 20 remaining but needs 80
251
+ // It should have partial consumption saved and be marked for retry
252
+ expect(event2.update).toHaveBeenCalledWith(
253
+ expect.objectContaining({
254
+ credit_pending: expect.any(String),
255
+ })
256
+ );
257
+ expect(event2.markAsRequiresCapture).toHaveBeenCalled();
258
+
259
+ expect(getLock().release).toHaveBeenCalled();
260
+ });
261
+
262
+ // ==========================================
263
+ // Batch 3: FIFO ordering
264
+ // ==========================================
265
+ it('processes events in FIFO order by created_at', async () => {
266
+ const callOrder: string[] = [];
267
+ const event1 = makeMeterEvent({
268
+ id: 'me_late',
269
+ created_at: new Date(3000),
270
+ getValue: jest.fn().mockImplementation(() => {
271
+ callOrder.push('me_late');
272
+ return '10';
273
+ }),
274
+ });
275
+ const event2 = makeMeterEvent({
276
+ id: 'me_early',
277
+ created_at: new Date(1000),
278
+ getValue: jest.fn().mockImplementation(() => {
279
+ callOrder.push('me_early');
280
+ return '10';
281
+ }),
282
+ });
283
+ const meter = makeMeter();
284
+ const customer = makeCustomer();
285
+ const grant = makeGrant('grant_1', '100', '100');
286
+
287
+ setupBatchMocks([event1, event2], meter, customer, [grant]);
288
+
289
+ await handleBatchCreditConsumption({ meterEventIds: ['me_late', 'me_early'] });
290
+
291
+ // Early event should be processed first
292
+ expect(callOrder[0]).toBe('me_early');
293
+ expect(callOrder[1]).toBe('me_late');
294
+ });
295
+
296
+ // ==========================================
297
+ // Batch 4: Fallback to single on batch-level error
298
+ // ==========================================
299
+ it('resets processing events to requires_capture and falls back to single on batch error', async () => {
300
+ const event1 = makeMeterEvent({ id: 'me_1' });
301
+ const event2 = makeMeterEvent({ id: 'me_2' });
302
+ const meter = makeMeter();
303
+ const customer = makeCustomer();
304
+
305
+ // Pre-check findAll returns events, but grant query throws
306
+ (MeterEvent.findAll as jest.Mock).mockResolvedValue([event1, event2]);
307
+ (getCachedMeterExpanded as jest.Mock).mockResolvedValue(meter);
308
+ (Customer.findByPk as jest.Mock).mockResolvedValue(customer);
309
+ (CreditGrant.getAvailableCreditsForCustomer as jest.Mock).mockRejectedValue(new Error('DB crash'));
310
+ (CreditTransaction.findAll as jest.Mock).mockResolvedValue([]);
311
+
312
+ await handleBatchCreditConsumption({ meterEventIds: ['me_1', 'me_2'] });
313
+
314
+ // Should reset only 'processing' events to 'requires_capture' (second update call)
315
+ const updateCalls = (MeterEvent.update as jest.Mock).mock.calls;
316
+ const resetCall = updateCalls.find((c: any) => c[0]?.status === 'requires_capture');
317
+ expect(resetCall).toBeTruthy();
318
+ expect(resetCall[1].where.status).toBe('processing');
319
+
320
+ // Lock must be released
321
+ expect(getLock().release).toHaveBeenCalled();
322
+ });
323
+
324
+ // ==========================================
325
+ // Batch 5: Single event delegates to handleCreditConsumption
326
+ // ==========================================
327
+ it('delegates to single handler when batch contains only one event', async () => {
328
+ const event = makeMeterEvent({ id: 'me_1', getValue: jest.fn().mockReturnValue('100') });
329
+ const meter = makeMeter();
330
+ const customer = makeCustomer();
331
+ const grant = makeGrant('grant_1', '1000', '500');
332
+
333
+ // Single event path uses findByPk, not findAll
334
+ (MeterEvent.findByPk as jest.Mock).mockResolvedValue(event);
335
+ (getCachedMeterExpanded as jest.Mock).mockResolvedValue(meter);
336
+ (Customer.findByPk as jest.Mock).mockResolvedValue(customer);
337
+ (CreditGrant.getAvailableCreditsForCustomer as jest.Mock).mockResolvedValue([grant]);
338
+ (CreditTransaction.findAll as jest.Mock).mockResolvedValue([]);
339
+ (CreditTransaction.create as jest.Mock).mockResolvedValue({ id: 'tx_1' });
340
+
341
+ await handleBatchCreditConsumption({ meterEventIds: ['me_1'] });
342
+
343
+ // Should use single-event path (findByPk, markAsProcessing)
344
+ expect(MeterEvent.findByPk).toHaveBeenCalledWith('me_1');
345
+ expect(event.markAsProcessing).toHaveBeenCalled();
346
+ });
347
+
348
+ // ==========================================
349
+ // Batch 6: Already completed events are skipped
350
+ // ==========================================
351
+ it('skips already completed events in batch', async () => {
352
+ const event1 = makeMeterEvent({ id: 'me_1', status: 'completed' });
353
+ const event2 = makeMeterEvent({ id: 'me_2', getValue: jest.fn().mockReturnValue('50') });
354
+ const meter = makeMeter();
355
+ const customer = makeCustomer();
356
+ const grant = makeGrant('grant_1', '1000', '500');
357
+
358
+ // Pre-check returns both, but event1 is completed
359
+ (MeterEvent.findAll as jest.Mock)
360
+ .mockResolvedValueOnce([event1, event2]) // pre-check
361
+ .mockResolvedValueOnce([event2]); // fresh read (only pending)
362
+ (getCachedMeterExpanded as jest.Mock).mockResolvedValue(meter);
363
+ (Customer.findByPk as jest.Mock).mockResolvedValue(customer);
364
+ (CreditGrant.getAvailableCreditsForCustomer as jest.Mock).mockResolvedValue([grant]);
365
+ (CreditTransaction.findAll as jest.Mock).mockResolvedValue([]);
366
+ (CreditTransaction.create as jest.Mock).mockResolvedValue({ id: 'tx_1' });
367
+
368
+ await handleBatchCreditConsumption({ meterEventIds: ['me_1', 'me_2'] });
369
+
370
+ // Only event2 should be processed
371
+ expect(event1.update).not.toHaveBeenCalled();
372
+ expect(event2.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }));
373
+ });
374
+
375
+ // ==========================================
376
+ // Batch 7: Empty batch is no-op
377
+ // ==========================================
378
+ it('handles empty batch gracefully', async () => {
379
+ await handleBatchCreditConsumption({ meterEventIds: [] });
380
+
381
+ // Should not acquire lock or query anything
382
+ expect(getLock().acquire).not.toHaveBeenCalled();
383
+ expect(MeterEvent.findAll).not.toHaveBeenCalled();
384
+ });
385
+
386
+ // ==========================================
387
+ // Batch 8: batchKey triggers onBatchComplete
388
+ // ==========================================
389
+ it('calls onBatchComplete when batchKey is provided', async () => {
390
+ const event1 = makeMeterEvent({ id: 'me_1', getValue: jest.fn().mockReturnValue('10') });
391
+ const meter = makeMeter();
392
+ const customer = makeCustomer();
393
+ const grant = makeGrant('grant_1', '1000', '500');
394
+
395
+ setupBatchMocks([event1], meter, customer, [grant]);
396
+
397
+ // With batchKey — should not throw even though onBatchComplete is internal
398
+ await handleBatchCreditConsumption({
399
+ meterEventIds: ['me_1'],
400
+ batchKey: 'cus_1::ai-meter-v2::',
401
+ });
402
+
403
+ // Single event delegates to handleCreditConsumption, batchKey cleanup still happens
404
+ expect(MeterEvent.findByPk).toHaveBeenCalled();
405
+ });
406
+
407
+ // ==========================================
408
+ // Batch 9: Multi-grant cross consumption in batch
409
+ // ==========================================
410
+ it('consumes across multiple grants for batch events', async () => {
411
+ const event1 = makeMeterEvent({
412
+ id: 'me_1',
413
+ getValue: jest.fn().mockReturnValue('80'),
414
+ created_at: new Date(1000),
415
+ });
416
+ const event2 = makeMeterEvent({
417
+ id: 'me_2',
418
+ getValue: jest.fn().mockReturnValue('50'),
419
+ created_at: new Date(2000),
420
+ });
421
+ const meter = makeMeter();
422
+ const customer = makeCustomer();
423
+ const grant1 = makeGrant('grant_1', '100', '100');
424
+ const grant2 = makeGrant('grant_2', '200', '200');
425
+
426
+ setupBatchMocks([event1, event2], meter, customer, [grant1, grant2]);
427
+
428
+ await handleBatchCreditConsumption({ meterEventIds: ['me_1', 'me_2'] });
429
+
430
+ // Event1 consumes 80 from grant1 (remaining: 20)
431
+ // Event2 consumes 20 from grant1 + 30 from grant2
432
+ expect(grant1.remaining_amount).toBe('0');
433
+ expect(grant2.remaining_amount).toBe('170');
434
+
435
+ expect(event1.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }));
436
+ expect(event2.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }));
437
+ });
438
+ });