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.
- package/api/src/libs/payment.ts +113 -22
- package/api/src/libs/queue/index.ts +20 -9
- package/api/src/libs/queue/store.ts +11 -7
- package/api/src/libs/reference-cache.ts +115 -0
- package/api/src/queues/auto-recharge.ts +68 -21
- package/api/src/queues/credit-consume.ts +835 -206
- package/api/src/routes/checkout-sessions.ts +78 -1
- package/api/src/routes/customers.ts +15 -3
- package/api/src/routes/donations.ts +4 -4
- package/api/src/routes/index.ts +37 -8
- package/api/src/routes/invoices.ts +14 -3
- package/api/src/routes/meter-events.ts +41 -15
- package/api/src/routes/payment-links.ts +2 -2
- package/api/src/routes/prices.ts +1 -1
- package/api/src/routes/pricing-table.ts +3 -2
- package/api/src/routes/products.ts +2 -2
- package/api/src/routes/subscription-items.ts +12 -3
- package/api/src/routes/subscriptions.ts +27 -9
- package/api/src/store/migrations/20260306-checkout-session-indexes.ts +23 -0
- package/api/src/store/models/checkout-session.ts +3 -2
- package/api/src/store/models/coupon.ts +9 -6
- package/api/src/store/models/credit-grant.ts +4 -1
- package/api/src/store/models/credit-transaction.ts +3 -2
- package/api/src/store/models/customer.ts +9 -6
- package/api/src/store/models/exchange-rate-provider.ts +9 -6
- package/api/src/store/models/invoice.ts +3 -2
- package/api/src/store/models/meter-event.ts +6 -4
- package/api/src/store/models/meter.ts +9 -6
- package/api/src/store/models/payment-intent.ts +9 -6
- package/api/src/store/models/payment-link.ts +9 -6
- package/api/src/store/models/payout.ts +3 -2
- package/api/src/store/models/price.ts +9 -6
- package/api/src/store/models/pricing-table.ts +9 -6
- package/api/src/store/models/product.ts +9 -6
- package/api/src/store/models/promotion-code.ts +9 -6
- package/api/src/store/models/refund.ts +9 -6
- package/api/src/store/models/setup-intent.ts +6 -4
- package/api/src/store/sequelize.ts +8 -3
- package/api/tests/queues/credit-consume-batch.spec.ts +438 -0
- package/api/tests/queues/credit-consume.spec.ts +505 -0
- package/api/third.d.ts +1 -1
- package/blocklet.yml +1 -1
- package/package.json +8 -7
- package/scripts/benchmark-seed.js +247 -0
- package/src/components/customer/credit-overview.tsx +31 -42
- package/src/components/invoice-pdf/template.tsx +5 -4
- package/src/components/payment-link/actions.tsx +45 -0
- package/src/components/payment-link/before-pay.tsx +24 -0
- package/src/components/subscription/payment-method-info.tsx +23 -6
- package/src/components/subscription/portal/actions.tsx +2 -0
- package/src/locales/en.tsx +11 -0
- package/src/locales/zh.tsx +10 -0
- package/src/pages/admin/products/links/detail.tsx +8 -0
- 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
|
+
});
|