payment-kit 1.26.5 → 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 +1 -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
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { BN } from '@ocap/util';
|
|
2
|
+
|
|
3
|
+
import { handleCreditConsumption } from '../../src/queues/credit-consume';
|
|
4
|
+
|
|
5
|
+
import { MeterEvent, CreditGrant, CreditTransaction, Customer, Subscription } from '../../src/store/models';
|
|
6
|
+
import { getCachedMeterExpanded } from '../../src/libs/reference-cache';
|
|
7
|
+
import { createEvent } from '../../src/libs/audit';
|
|
8
|
+
import { checkAndTriggerAutoRecharge } from '../../src/queues/auto-recharge';
|
|
9
|
+
import { handlePastDueSubscriptionRecovery } from '../../src/queues/payment';
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Mocks
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
16
|
+
__esModule: true,
|
|
17
|
+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
jest.mock('../../src/libs/audit', () => ({
|
|
21
|
+
createEvent: jest.fn().mockResolvedValue(undefined),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
jest.mock('../../src/libs/event', () => ({
|
|
25
|
+
events: { on: jest.fn() },
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
jest.mock('../../src/libs/lock', () => {
|
|
29
|
+
const lock = { acquire: jest.fn().mockResolvedValue(true), release: jest.fn(), locked: false };
|
|
30
|
+
// @ts-ignore expose for test manipulation
|
|
31
|
+
global.__mockLock = lock;
|
|
32
|
+
return {
|
|
33
|
+
getLock: jest.fn().mockReturnValue(lock),
|
|
34
|
+
Lock: jest.fn(),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
jest.mock('../../src/libs/reference-cache', () => ({
|
|
39
|
+
getCachedMeterExpanded: jest.fn(),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
jest.mock('../../src/libs/subscription', () => ({
|
|
43
|
+
getDaysUntilCancel: jest.fn().mockReturnValue(7),
|
|
44
|
+
getDueUnit: jest.fn().mockReturnValue(86400),
|
|
45
|
+
getMeterPriceIdsFromSubscription: jest.fn().mockResolvedValue(['price_1']),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
jest.mock('../../src/libs/util', () => ({
|
|
49
|
+
MAX_RETRY_COUNT: 3,
|
|
50
|
+
getNextRetry: jest.fn().mockReturnValue(Math.floor(Date.now() / 1000) + 60),
|
|
51
|
+
formatCreditAmount: jest.fn().mockImplementation((amount, symbol) => `${symbol}${amount}`),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
jest.mock('../../src/queues/payment', () => ({
|
|
55
|
+
handlePastDueSubscriptionRecovery: jest.fn().mockResolvedValue(undefined),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
jest.mock('../../src/queues/auto-recharge', () => ({
|
|
59
|
+
checkAndTriggerAutoRecharge: jest.fn().mockResolvedValue(undefined),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
jest.mock('../../src/queues/token-transfer', () => ({
|
|
63
|
+
addTokenTransferJob: jest.fn().mockResolvedValue(undefined),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
// Queue mock — must mock before credit-consume is imported
|
|
67
|
+
jest.mock('../../src/libs/queue', () => {
|
|
68
|
+
const mockQueue = {
|
|
69
|
+
push: jest.fn(),
|
|
70
|
+
get: jest.fn().mockResolvedValue(null),
|
|
71
|
+
delete: jest.fn().mockResolvedValue(undefined),
|
|
72
|
+
on: jest.fn(),
|
|
73
|
+
};
|
|
74
|
+
return jest.fn().mockReturnValue(mockQueue);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
jest.mock('../../src/store/models', () => ({
|
|
78
|
+
MeterEvent: {
|
|
79
|
+
findByPk: jest.fn(),
|
|
80
|
+
findAll: jest.fn(),
|
|
81
|
+
getPendingAmounts: jest.fn(),
|
|
82
|
+
},
|
|
83
|
+
CreditGrant: {
|
|
84
|
+
getAvailableCreditsForCustomer: jest.fn(),
|
|
85
|
+
hasOnchainToken: jest.fn().mockReturnValue(false),
|
|
86
|
+
},
|
|
87
|
+
CreditTransaction: {
|
|
88
|
+
findAll: jest.fn(),
|
|
89
|
+
findOne: jest.fn(),
|
|
90
|
+
create: jest.fn(),
|
|
91
|
+
},
|
|
92
|
+
Customer: {
|
|
93
|
+
findByPk: jest.fn(),
|
|
94
|
+
},
|
|
95
|
+
Subscription: {
|
|
96
|
+
findByPk: jest.fn(),
|
|
97
|
+
},
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// Helpers
|
|
102
|
+
// ============================================================================
|
|
103
|
+
|
|
104
|
+
function makeMeterEvent(overrides: Record<string, any> = {}) {
|
|
105
|
+
return {
|
|
106
|
+
id: 'me_1',
|
|
107
|
+
event_name: 'ai-meter-v2',
|
|
108
|
+
status: 'pending',
|
|
109
|
+
credit_consumed: '0',
|
|
110
|
+
credit_pending: '0',
|
|
111
|
+
attempt_count: 0,
|
|
112
|
+
metadata: {},
|
|
113
|
+
created_at: new Date(),
|
|
114
|
+
getCustomerId: jest.fn().mockReturnValue('cus_1'),
|
|
115
|
+
getSubscriptionId: jest.fn().mockReturnValue(undefined),
|
|
116
|
+
getValue: jest.fn().mockReturnValue('100'),
|
|
117
|
+
markAsProcessing: jest.fn().mockResolvedValue(undefined),
|
|
118
|
+
markAsRequiresAction: jest.fn().mockResolvedValue(undefined),
|
|
119
|
+
markAsRequiresCapture: jest.fn().mockResolvedValue(undefined),
|
|
120
|
+
update: jest.fn().mockResolvedValue(undefined),
|
|
121
|
+
...overrides,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function makeMeter(overrides: Record<string, any> = {}) {
|
|
126
|
+
return {
|
|
127
|
+
id: 'meter_1',
|
|
128
|
+
name: 'AI Meter',
|
|
129
|
+
unit: 'token',
|
|
130
|
+
currency_id: 'cur_1',
|
|
131
|
+
paymentCurrency: { id: 'cur_1', symbol: '$', decimal: 2 },
|
|
132
|
+
...overrides,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function makeCustomer(overrides: Record<string, any> = {}) {
|
|
137
|
+
return {
|
|
138
|
+
id: 'cus_1',
|
|
139
|
+
did: 'did:abt:abc',
|
|
140
|
+
...overrides,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function makeGrant(id: string, amount: string, remaining: string, overrides: Record<string, any> = {}) {
|
|
145
|
+
return {
|
|
146
|
+
id,
|
|
147
|
+
customer_id: 'cus_1',
|
|
148
|
+
currency_id: 'cur_1',
|
|
149
|
+
amount,
|
|
150
|
+
remaining_amount: remaining,
|
|
151
|
+
status: 'granted',
|
|
152
|
+
metadata: {},
|
|
153
|
+
consumeCredit: jest.fn().mockImplementation(function returnConsumeCredit(this: any, consumeAmt: string) {
|
|
154
|
+
const newRemaining = new BN(this.remaining_amount).sub(new BN(consumeAmt));
|
|
155
|
+
this.remaining_amount = newRemaining.toString();
|
|
156
|
+
const depleted = newRemaining.lte(new BN(0));
|
|
157
|
+
return Promise.resolve({ consumed: consumeAmt, remaining: this.remaining_amount, depleted });
|
|
158
|
+
}),
|
|
159
|
+
save: jest.fn().mockResolvedValue(undefined),
|
|
160
|
+
...overrides,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function makeSubscription(overrides: Record<string, any> = {}) {
|
|
165
|
+
return {
|
|
166
|
+
id: 'sub_1',
|
|
167
|
+
status: 'active',
|
|
168
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
169
|
+
pending_invoice_item_interval: { interval: 'month' },
|
|
170
|
+
isActive: jest.fn().mockReturnValue(true),
|
|
171
|
+
update: jest.fn().mockResolvedValue(undefined),
|
|
172
|
+
...overrides,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function setupBasicMocks(meterEvent: any, meter: any, customer: any, grants: any[], existingTx: any[] = []) {
|
|
177
|
+
(MeterEvent.findByPk as jest.Mock).mockResolvedValue(meterEvent);
|
|
178
|
+
(getCachedMeterExpanded as jest.Mock).mockResolvedValue(meter);
|
|
179
|
+
(Customer.findByPk as jest.Mock).mockResolvedValue(customer);
|
|
180
|
+
(CreditGrant.getAvailableCreditsForCustomer as jest.Mock).mockResolvedValue(grants);
|
|
181
|
+
(CreditTransaction.findAll as jest.Mock).mockResolvedValue(existingTx);
|
|
182
|
+
(CreditTransaction.create as jest.Mock).mockImplementation((data: any) =>
|
|
183
|
+
Promise.resolve({ id: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, ...data })
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// Tests
|
|
189
|
+
// ============================================================================
|
|
190
|
+
|
|
191
|
+
describe('credit-consume: handleCreditConsumption', () => {
|
|
192
|
+
const getLock = () => (global as any).__mockLock;
|
|
193
|
+
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
jest.clearAllMocks();
|
|
196
|
+
getLock().acquire.mockResolvedValue(true);
|
|
197
|
+
getLock().release.mockReset();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ==========================================
|
|
201
|
+
// Scenario 1: Single event, sufficient balance
|
|
202
|
+
// ==========================================
|
|
203
|
+
it('scenario 1: consumes credit fully when grant has sufficient balance', async () => {
|
|
204
|
+
const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('100') });
|
|
205
|
+
const meter = makeMeter();
|
|
206
|
+
const customer = makeCustomer();
|
|
207
|
+
const grant = makeGrant('grant_1', '1000', '500');
|
|
208
|
+
|
|
209
|
+
setupBasicMocks(event, meter, customer, [grant]);
|
|
210
|
+
|
|
211
|
+
await handleCreditConsumption({ meterEventId: 'me_1' });
|
|
212
|
+
|
|
213
|
+
// Should mark as processing
|
|
214
|
+
expect(event.markAsProcessing).toHaveBeenCalledTimes(1);
|
|
215
|
+
|
|
216
|
+
// Should consume from grant
|
|
217
|
+
expect(grant.consumeCredit).toHaveBeenCalledWith('100', expect.objectContaining({ meter_event_id: 'me_1' }));
|
|
218
|
+
|
|
219
|
+
// Should mark event as completed
|
|
220
|
+
expect(event.update).toHaveBeenCalledWith(
|
|
221
|
+
expect.objectContaining({
|
|
222
|
+
status: 'completed',
|
|
223
|
+
credit_consumed: '100',
|
|
224
|
+
credit_pending: '0',
|
|
225
|
+
})
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Should trigger auto-recharge outside lock
|
|
229
|
+
expect(checkAndTriggerAutoRecharge).toHaveBeenCalled();
|
|
230
|
+
|
|
231
|
+
// Lock should be released
|
|
232
|
+
expect(getLock().release).toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ==========================================
|
|
236
|
+
// Scenario 2: Single event, insufficient balance
|
|
237
|
+
// ==========================================
|
|
238
|
+
it('scenario 2: handles insufficient balance correctly', async () => {
|
|
239
|
+
const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('500') });
|
|
240
|
+
const meter = makeMeter();
|
|
241
|
+
const customer = makeCustomer();
|
|
242
|
+
const grant = makeGrant('grant_1', '200', '200');
|
|
243
|
+
|
|
244
|
+
setupBasicMocks(event, meter, customer, [grant]);
|
|
245
|
+
|
|
246
|
+
await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow('Insufficient credit balance');
|
|
247
|
+
|
|
248
|
+
// Should create insufficient event
|
|
249
|
+
expect(createEvent).toHaveBeenCalledWith(
|
|
250
|
+
'Customer',
|
|
251
|
+
'customer.credit.insufficient',
|
|
252
|
+
customer,
|
|
253
|
+
expect.objectContaining({
|
|
254
|
+
metadata: expect.objectContaining({ meter_event_id: 'me_1' }),
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Should save partial progress
|
|
259
|
+
expect(event.update).toHaveBeenCalledWith(
|
|
260
|
+
expect.objectContaining({
|
|
261
|
+
credit_consumed: '200',
|
|
262
|
+
credit_pending: '300',
|
|
263
|
+
})
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Lock must be released even on error
|
|
267
|
+
expect(getLock().release).toHaveBeenCalled();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ==========================================
|
|
271
|
+
// Scenario 3: Exact balance → grant depleted
|
|
272
|
+
// ==========================================
|
|
273
|
+
it('scenario 3: fully consumes when balance exactly matches', async () => {
|
|
274
|
+
const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('200') });
|
|
275
|
+
const meter = makeMeter();
|
|
276
|
+
const customer = makeCustomer();
|
|
277
|
+
const grant = makeGrant('grant_1', '200', '200');
|
|
278
|
+
|
|
279
|
+
setupBasicMocks(event, meter, customer, [grant]);
|
|
280
|
+
|
|
281
|
+
await handleCreditConsumption({ meterEventId: 'me_1' });
|
|
282
|
+
|
|
283
|
+
expect(grant.consumeCredit).toHaveBeenCalledWith('200', expect.any(Object));
|
|
284
|
+
expect(event.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', credit_pending: '0' }));
|
|
285
|
+
// Grant remaining should be 0 after consumeCredit mock
|
|
286
|
+
expect(grant.remaining_amount).toBe('0');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ==========================================
|
|
290
|
+
// Scenario 4: No grants available
|
|
291
|
+
// ==========================================
|
|
292
|
+
it('scenario 4: handles no grants scenario', async () => {
|
|
293
|
+
const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('100') });
|
|
294
|
+
const meter = makeMeter();
|
|
295
|
+
const customer = makeCustomer();
|
|
296
|
+
|
|
297
|
+
setupBasicMocks(event, meter, customer, []); // no grants
|
|
298
|
+
|
|
299
|
+
await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow('Insufficient credit balance');
|
|
300
|
+
|
|
301
|
+
expect(createEvent).toHaveBeenCalledWith('Customer', 'customer.credit.insufficient', customer, expect.any(Object));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ==========================================
|
|
305
|
+
// Scenario 5: Idempotency — already completed
|
|
306
|
+
// ==========================================
|
|
307
|
+
it('scenario 5: skips already completed events', async () => {
|
|
308
|
+
const event = makeMeterEvent({ status: 'completed' });
|
|
309
|
+
(MeterEvent.findByPk as jest.Mock).mockResolvedValue(event);
|
|
310
|
+
|
|
311
|
+
await handleCreditConsumption({ meterEventId: 'me_1' });
|
|
312
|
+
|
|
313
|
+
// Should not acquire lock
|
|
314
|
+
expect(getLock().acquire).not.toHaveBeenCalled();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ==========================================
|
|
318
|
+
// Scenario 13: With subscription — priceIds filtering
|
|
319
|
+
// ==========================================
|
|
320
|
+
it('scenario 13: loads subscription inside lock and uses priceIds', async () => {
|
|
321
|
+
const event = makeMeterEvent({
|
|
322
|
+
getSubscriptionId: jest.fn().mockReturnValue('sub_1'),
|
|
323
|
+
getValue: jest.fn().mockReturnValue('100'),
|
|
324
|
+
});
|
|
325
|
+
const meter = makeMeter();
|
|
326
|
+
const customer = makeCustomer();
|
|
327
|
+
const subscription = makeSubscription();
|
|
328
|
+
const grant = makeGrant('grant_1', '1000', '500');
|
|
329
|
+
|
|
330
|
+
setupBasicMocks(event, meter, customer, [grant]);
|
|
331
|
+
(Subscription.findByPk as jest.Mock).mockResolvedValue(subscription);
|
|
332
|
+
|
|
333
|
+
await handleCreditConsumption({ meterEventId: 'me_1' });
|
|
334
|
+
|
|
335
|
+
// Subscription should be loaded inside lock
|
|
336
|
+
expect(Subscription.findByPk).toHaveBeenCalledWith('sub_1');
|
|
337
|
+
// Grant query should use priceIds
|
|
338
|
+
expect(CreditGrant.getAvailableCreditsForCustomer).toHaveBeenCalledWith('cus_1', 'cur_1', ['price_1']);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ==========================================
|
|
342
|
+
// Scenario 14: Subscription not found
|
|
343
|
+
// ==========================================
|
|
344
|
+
it('scenario 14: skips consumption when subscription not found', async () => {
|
|
345
|
+
const event = makeMeterEvent({
|
|
346
|
+
getSubscriptionId: jest.fn().mockReturnValue('sub_missing'),
|
|
347
|
+
getValue: jest.fn().mockReturnValue('100'),
|
|
348
|
+
});
|
|
349
|
+
const meter = makeMeter();
|
|
350
|
+
const customer = makeCustomer();
|
|
351
|
+
|
|
352
|
+
setupBasicMocks(event, meter, customer, []);
|
|
353
|
+
(Subscription.findByPk as jest.Mock).mockResolvedValue(null);
|
|
354
|
+
|
|
355
|
+
await handleCreditConsumption({ meterEventId: 'me_1' });
|
|
356
|
+
|
|
357
|
+
// Should skip — no grant consumption attempted
|
|
358
|
+
expect(CreditGrant.getAvailableCreditsForCustomer).not.toHaveBeenCalled();
|
|
359
|
+
expect(getLock().release).toHaveBeenCalled();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// ==========================================
|
|
363
|
+
// Scenario 17: Multi-grant cross consumption
|
|
364
|
+
// ==========================================
|
|
365
|
+
it('scenario 17: consumes across multiple grants', async () => {
|
|
366
|
+
const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('150') });
|
|
367
|
+
const meter = makeMeter();
|
|
368
|
+
const customer = makeCustomer();
|
|
369
|
+
const grant1 = makeGrant('grant_1', '100', '100');
|
|
370
|
+
const grant2 = makeGrant('grant_2', '200', '200');
|
|
371
|
+
|
|
372
|
+
setupBasicMocks(event, meter, customer, [grant1, grant2]);
|
|
373
|
+
|
|
374
|
+
await handleCreditConsumption({ meterEventId: 'me_1' });
|
|
375
|
+
|
|
376
|
+
// Should consume 100 from grant1 and 50 from grant2
|
|
377
|
+
expect(grant1.consumeCredit).toHaveBeenCalledWith('100', expect.any(Object));
|
|
378
|
+
expect(grant2.consumeCredit).toHaveBeenCalledWith('50', expect.any(Object));
|
|
379
|
+
|
|
380
|
+
// Should create 2 transactions
|
|
381
|
+
expect(CreditTransaction.create).toHaveBeenCalledTimes(2);
|
|
382
|
+
|
|
383
|
+
expect(event.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' }));
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ==========================================
|
|
387
|
+
// Scenario 22/23: Retry scheduling + max retries
|
|
388
|
+
// ==========================================
|
|
389
|
+
it('scenario 22: schedules retry on partial failure', async () => {
|
|
390
|
+
const event = makeMeterEvent({
|
|
391
|
+
getValue: jest.fn().mockReturnValue('500'),
|
|
392
|
+
attempt_count: 0,
|
|
393
|
+
});
|
|
394
|
+
const meter = makeMeter();
|
|
395
|
+
const customer = makeCustomer();
|
|
396
|
+
const grant = makeGrant('grant_1', '100', '100');
|
|
397
|
+
|
|
398
|
+
setupBasicMocks(event, meter, customer, [grant]);
|
|
399
|
+
|
|
400
|
+
await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow();
|
|
401
|
+
|
|
402
|
+
// Should schedule retry (attempt_count=0 < MAX_RETRY_COUNT=3)
|
|
403
|
+
expect(event.markAsRequiresCapture).toHaveBeenCalled();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('scenario 23: marks as requires_action after max retries', async () => {
|
|
407
|
+
const event = makeMeterEvent({
|
|
408
|
+
getValue: jest.fn().mockReturnValue('500'),
|
|
409
|
+
attempt_count: 3, // >= MAX_RETRY_COUNT
|
|
410
|
+
});
|
|
411
|
+
const meter = makeMeter();
|
|
412
|
+
const customer = makeCustomer();
|
|
413
|
+
|
|
414
|
+
setupBasicMocks(event, meter, customer, []);
|
|
415
|
+
|
|
416
|
+
await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow();
|
|
417
|
+
|
|
418
|
+
expect(event.markAsRequiresAction).toHaveBeenCalled();
|
|
419
|
+
expect(event.markAsRequiresCapture).not.toHaveBeenCalled();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// ==========================================
|
|
423
|
+
// Scenario 26: Past due subscription recovery
|
|
424
|
+
// ==========================================
|
|
425
|
+
it('scenario 26: triggers past_due recovery on successful consumption', async () => {
|
|
426
|
+
const subscription = makeSubscription({ status: 'past_due' });
|
|
427
|
+
const event = makeMeterEvent({
|
|
428
|
+
getSubscriptionId: jest.fn().mockReturnValue('sub_1'),
|
|
429
|
+
getValue: jest.fn().mockReturnValue('50'),
|
|
430
|
+
});
|
|
431
|
+
const meter = makeMeter();
|
|
432
|
+
const customer = makeCustomer();
|
|
433
|
+
const grant = makeGrant('grant_1', '1000', '500');
|
|
434
|
+
|
|
435
|
+
setupBasicMocks(event, meter, customer, [grant]);
|
|
436
|
+
(Subscription.findByPk as jest.Mock).mockResolvedValue(subscription);
|
|
437
|
+
|
|
438
|
+
await handleCreditConsumption({ meterEventId: 'me_1' });
|
|
439
|
+
|
|
440
|
+
expect(handlePastDueSubscriptionRecovery).toHaveBeenCalledWith(subscription, null);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ==========================================
|
|
444
|
+
// Scenario 30: Zero value event
|
|
445
|
+
// ==========================================
|
|
446
|
+
it('scenario 30: handles zero-value event', async () => {
|
|
447
|
+
const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('0') });
|
|
448
|
+
const meter = makeMeter();
|
|
449
|
+
const customer = makeCustomer();
|
|
450
|
+
const grant = makeGrant('grant_1', '100', '100');
|
|
451
|
+
|
|
452
|
+
setupBasicMocks(event, meter, customer, [grant]);
|
|
453
|
+
|
|
454
|
+
await handleCreditConsumption({ meterEventId: 'me_1' });
|
|
455
|
+
|
|
456
|
+
// No grants should be consumed
|
|
457
|
+
expect(grant.consumeCredit).not.toHaveBeenCalled();
|
|
458
|
+
// Event should be completed
|
|
459
|
+
expect(event.update).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', credit_pending: '0' }));
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// ==========================================
|
|
463
|
+
// Scenario 33: Low balance threshold
|
|
464
|
+
// ==========================================
|
|
465
|
+
it('scenario 33: triggers low_balance event when remaining < threshold', async () => {
|
|
466
|
+
const event = makeMeterEvent({ getValue: jest.fn().mockReturnValue('95') });
|
|
467
|
+
const meter = makeMeter();
|
|
468
|
+
const customer = makeCustomer();
|
|
469
|
+
// Grant of 100 with 100 remaining → after consuming 95, only 5 left (5% < 10% threshold)
|
|
470
|
+
const grant = makeGrant('grant_1', '100', '100');
|
|
471
|
+
|
|
472
|
+
setupBasicMocks(event, meter, customer, [grant]);
|
|
473
|
+
|
|
474
|
+
await handleCreditConsumption({ meterEventId: 'me_1' });
|
|
475
|
+
|
|
476
|
+
expect(createEvent).toHaveBeenCalledWith(
|
|
477
|
+
'Customer',
|
|
478
|
+
'customer.credit.low_balance',
|
|
479
|
+
customer,
|
|
480
|
+
expect.objectContaining({
|
|
481
|
+
metadata: expect.objectContaining({ currency_id: 'cur_1' }),
|
|
482
|
+
})
|
|
483
|
+
);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// ==========================================
|
|
487
|
+
// Lock safety: always released
|
|
488
|
+
// ==========================================
|
|
489
|
+
it('always releases lock even when unexpected error occurs', async () => {
|
|
490
|
+
const event = makeMeterEvent();
|
|
491
|
+
const meter = makeMeter();
|
|
492
|
+
const customer = makeCustomer();
|
|
493
|
+
|
|
494
|
+
(MeterEvent.findByPk as jest.Mock).mockResolvedValue(event);
|
|
495
|
+
(getCachedMeterExpanded as jest.Mock).mockResolvedValue(meter);
|
|
496
|
+
(Customer.findByPk as jest.Mock).mockResolvedValue(customer);
|
|
497
|
+
// Throw inside lock
|
|
498
|
+
(CreditGrant.getAvailableCreditsForCustomer as jest.Mock).mockRejectedValue(new Error('DB crash'));
|
|
499
|
+
(CreditTransaction.findAll as jest.Mock).mockResolvedValue([]);
|
|
500
|
+
|
|
501
|
+
await expect(handleCreditConsumption({ meterEventId: 'me_1' })).rejects.toThrow('DB crash');
|
|
502
|
+
|
|
503
|
+
expect(getLock().release).toHaveBeenCalled();
|
|
504
|
+
});
|
|
505
|
+
});
|
package/api/third.d.ts
CHANGED
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"prelint": "npm run types",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"deploy": "pnpm run bundle && blocklet deploy .blocklet/bundle",
|
|
22
22
|
"upload": "pnpm run bundle && blocklet upload .blocklet/release/blocklet.json",
|
|
23
23
|
"bump-version": "zx --quiet scripts/bump-version.mjs",
|
|
24
|
-
"sdk-test": "node scripts/sdk.js"
|
|
24
|
+
"sdk-test": "node scripts/sdk.js",
|
|
25
|
+
"benchmark-seed": "node scripts/benchmark-seed.js"
|
|
25
26
|
},
|
|
26
27
|
"lint-staged": {
|
|
27
28
|
"*.{mjs,js,jsx,ts,tsx}": [
|
|
@@ -59,9 +60,9 @@
|
|
|
59
60
|
"@blocklet/error": "^0.3.5",
|
|
60
61
|
"@blocklet/js-sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
61
62
|
"@blocklet/logger": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
62
|
-
"@blocklet/payment-broker-client": "1.
|
|
63
|
-
"@blocklet/payment-react": "1.
|
|
64
|
-
"@blocklet/payment-vendor": "1.
|
|
63
|
+
"@blocklet/payment-broker-client": "1.27.0",
|
|
64
|
+
"@blocklet/payment-react": "1.27.0",
|
|
65
|
+
"@blocklet/payment-vendor": "1.27.0",
|
|
65
66
|
"@blocklet/sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
66
67
|
"@blocklet/ui-react": "^3.5.1",
|
|
67
68
|
"@blocklet/uploader": "^0.3.19",
|
|
@@ -132,7 +133,7 @@
|
|
|
132
133
|
"devDependencies": {
|
|
133
134
|
"@abtnode/types": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
134
135
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
135
|
-
"@blocklet/payment-types": "1.
|
|
136
|
+
"@blocklet/payment-types": "1.27.0",
|
|
136
137
|
"@types/cookie-parser": "^1.4.9",
|
|
137
138
|
"@types/cors": "^2.8.19",
|
|
138
139
|
"@types/debug": "^4.1.12",
|
|
@@ -179,5 +180,5 @@
|
|
|
179
180
|
"parser": "typescript"
|
|
180
181
|
}
|
|
181
182
|
},
|
|
182
|
-
"gitHead": "
|
|
183
|
+
"gitHead": "5dbe5a31ef3bc6f37e600c73c9e5ef80a4cc2e32"
|
|
183
184
|
}
|