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.
Files changed (46) 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 +1 -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
@@ -0,0 +1,247 @@
1
+ /* eslint-disable no-console */
2
+ /**
3
+ * Benchmark Seed Script
4
+ *
5
+ * Creates test data required for Payment Kit benchmarks:
6
+ * 1. Meter (credit type, with currency)
7
+ * 2. Product + metered Price
8
+ * 3. Customer (via checkout or existing)
9
+ * 4. Credit Grants (large balance for sustained testing)
10
+ *
11
+ * Usage:
12
+ * pnpm run benchmark-seed --app-id <APP_ID>
13
+ *
14
+ * Or with blocklet exec:
15
+ * blocklet exec /scripts/benchmark-seed.js --app-id <APP_ID>
16
+ *
17
+ * After running, copy the output env vars to benchmarks/.env
18
+ */
19
+ const { spawn } = require('child_process');
20
+ const payment = require('../../../packages/client/lib').default;
21
+
22
+ function getAppId() {
23
+ const args = process.argv.slice(2);
24
+ let appId = '';
25
+ for (let i = 0; i < args.length; i++) {
26
+ if (args[i] === '--app-id' && i + 1 < args.length) {
27
+ appId = args[i + 1];
28
+ break;
29
+ }
30
+ }
31
+ if (!appId) {
32
+ appId =
33
+ process.env.BLOCKLET_DEV_APP_DID?.split(':').pop() ||
34
+ process.env.BLOCKLET_APP_ID ||
35
+ 'zNKuN3XwXN7xq2NsJQjjfwujyqCxx26DhwgV';
36
+ }
37
+ return appId;
38
+ }
39
+
40
+ // Configuration
41
+ const CREDIT_GRANT_AMOUNT = 1000000; // 1M credits - enough for thousands of benchmark events
42
+ const CUSTOMER_COUNT = 3; // Multi-customer for parallelism testing
43
+ const METER_EVENT_NAME = 'benchmark_credits';
44
+ const METER_UNIT = 'Token';
45
+
46
+ async function findOrCreateMeter() {
47
+ console.log('\n── Step 1: Find or create meter ──');
48
+
49
+ // Try to find existing meter
50
+ try {
51
+ const meters = await payment.meters.list({ limit: 50 });
52
+ const items = meters?.list || meters?.data || [];
53
+ const existing = items.find((m) => m.event_name === METER_EVENT_NAME);
54
+ if (existing) {
55
+ console.log(` Found existing meter: ${existing.id} (event_name=${existing.event_name}, currency=${existing.currency_id})`);
56
+ return existing;
57
+ }
58
+ } catch (err) {
59
+ console.log(` Could not list meters: ${err.message}`);
60
+ }
61
+
62
+ // Create new meter
63
+ const meter = await payment.meters.create({
64
+ name: 'Benchmark Credits',
65
+ event_name: METER_EVENT_NAME,
66
+ aggregation_method: 'sum',
67
+ unit: METER_UNIT,
68
+ description: 'Benchmark test meter for credit consumption',
69
+ created_via: 'api',
70
+ });
71
+ console.log(` Created meter: ${meter.id} (event_name=${meter.event_name}, currency=${meter.currency_id})`);
72
+ return meter;
73
+ }
74
+
75
+ async function findOrCreateProduct(meter) {
76
+ console.log('\n── Step 2: Find or create metered product + price ──');
77
+
78
+ // Try to find existing
79
+ try {
80
+ const products = await payment.products.list({ limit: 50 });
81
+ const items = products?.list || products?.data || [];
82
+ const existing = items.find((p) => p.name === 'Benchmark Metered Service');
83
+ if (existing) {
84
+ console.log(` Found existing product: ${existing.id}`);
85
+ // Find price for this product
86
+ const prices = await payment.prices.list({ product_id: existing.id, limit: 10 });
87
+ const priceList = prices?.list || prices?.data || [];
88
+ if (priceList.length > 0) {
89
+ console.log(` Found existing price: ${priceList[0].id}`);
90
+ return { product: existing, price: priceList[0] };
91
+ }
92
+ }
93
+ } catch (err) {
94
+ console.log(` Could not list products: ${err.message}`);
95
+ }
96
+
97
+ const product = await payment.products.create({
98
+ name: 'Benchmark Metered Service',
99
+ description: 'Product for benchmark testing',
100
+ type: 'credit',
101
+ prices: [
102
+ {
103
+ type: 'recurring',
104
+ unit_amount: '0.001',
105
+ currency_id: meter.currency_id,
106
+ nickname: 'Benchmark metered price',
107
+ recurring: {
108
+ interval: 'month',
109
+ interval_count: 1,
110
+ usage_type: 'metered',
111
+ meter_id: meter.id,
112
+ aggregate_usage: 'sum',
113
+ },
114
+ metadata: {
115
+ credit_config: {
116
+ priority: 50,
117
+ valid_duration_value: 0,
118
+ valid_duration_unit: 'days',
119
+ currency_id: meter.currency_id,
120
+ credit_amount: '1',
121
+ },
122
+ meter_id: meter.id,
123
+ },
124
+ },
125
+ ],
126
+ });
127
+
128
+ const prices = product.prices || [];
129
+ const price = prices[0];
130
+ console.log(` Created product: ${product.id}`);
131
+ console.log(` Created price: ${price?.id}`);
132
+ return { product, price };
133
+ }
134
+
135
+ async function getCustomers() {
136
+ console.log('\n── Step 3: Find customers ──');
137
+
138
+ const result = await payment.customers.list({ limit: CUSTOMER_COUNT });
139
+ const items = result?.list || result?.data || [];
140
+
141
+ if (items.length === 0) {
142
+ console.log(' No customers found. Please create customers first (e.g. via checkout).');
143
+ console.log(' Tip: Log in to the blocklet UI as different users to auto-create customers.');
144
+ process.exit(1);
145
+ }
146
+
147
+ console.log(` Found ${items.length} customer(s):`);
148
+ for (const c of items) {
149
+ console.log(` ${c.id}: ${c.name || c.email || c.did || '(unnamed)'}`);
150
+ }
151
+
152
+ return items.slice(0, CUSTOMER_COUNT);
153
+ }
154
+
155
+ async function ensureCreditGrants(customers, meter) {
156
+ console.log('\n── Step 4: Ensure credit grants ──');
157
+
158
+ for (const customer of customers) {
159
+ // Check existing balance
160
+ let existingBalance = '0';
161
+ try {
162
+ const summary = await payment.creditGrants.summary({ customer_id: customer.id });
163
+ const balances = summary?.balances || summary?.data || [];
164
+ const match = balances.find((b) => b.currency_id === meter.currency_id);
165
+ existingBalance = match?.available || match?.remaining_amount || '0';
166
+ } catch {
167
+ // ignore
168
+ }
169
+
170
+ const needsGrant = Number(existingBalance) < CREDIT_GRANT_AMOUNT / 2;
171
+
172
+ if (needsGrant) {
173
+ const grant = await payment.creditGrants.create({
174
+ customer_id: customer.id,
175
+ amount: CREDIT_GRANT_AMOUNT,
176
+ currency_id: meter.currency_id,
177
+ category: 'promotional',
178
+ name: 'Benchmark credit grant',
179
+ applicability_config: {
180
+ scope: {
181
+ price_type: 'metered',
182
+ },
183
+ },
184
+ });
185
+ console.log(` Created grant for ${customer.id}: ${grant.id} (amount=${CREDIT_GRANT_AMOUNT})`);
186
+ } else {
187
+ console.log(` ${customer.id}: balance=${existingBalance}, sufficient`);
188
+ }
189
+ }
190
+ }
191
+
192
+ async function runSeed() {
193
+ payment.environments.setTestMode(true);
194
+
195
+ console.log('=== Payment Kit Benchmark Seed ===');
196
+ console.log(` Grant amount per customer: ${CREDIT_GRANT_AMOUNT}`);
197
+ console.log(` Meter event name: ${METER_EVENT_NAME}`);
198
+
199
+ const meter = await findOrCreateMeter();
200
+ const { price } = await findOrCreateProduct(meter);
201
+ const customers = await getCustomers();
202
+ await ensureCreditGrants(customers, meter);
203
+
204
+ // Output env vars for benchmarks/.env
205
+ const customerIds = customers.map((c) => c.id);
206
+ console.log('\n\n=== Copy to benchmarks/.env ===\n');
207
+ console.log(`METER_EVENT_NAME=${METER_EVENT_NAME}`);
208
+ console.log(`CUSTOMER_ID=${customerIds[0]}`);
209
+ if (customerIds.length > 1) {
210
+ console.log(`CUSTOMER_IDS=${customerIds.join(',')}`);
211
+ }
212
+ if (price) {
213
+ console.log(`# PRICE_ID=${price.id}`);
214
+ }
215
+ console.log(`# METER_ID=${meter.id}`);
216
+ console.log(`# CURRENCY_ID=${meter.currency_id}`);
217
+ console.log(`# CREDIT_GRANT_AMOUNT=${CREDIT_GRANT_AMOUNT}`);
218
+
219
+ console.log('\n=== Seed complete ===');
220
+ }
221
+
222
+ async function main() {
223
+ if (process.env.BLOCKLET_APP_ID) {
224
+ await runSeed();
225
+ process.exit(0);
226
+ }
227
+
228
+ const appId = getAppId();
229
+ const args = process.argv.slice(2).filter((arg) => arg !== '--app-id' && arg !== appId);
230
+
231
+ console.log(`Running with blocklet exec, app-id: ${appId}`);
232
+
233
+ const child = spawn('blocklet', ['exec', '/scripts/benchmark-seed.js', '--app-id', appId, ...args], {
234
+ stdio: 'inherit',
235
+ });
236
+
237
+ child.on('exit', (code) => {
238
+ process.exit(code || 0);
239
+ });
240
+ }
241
+
242
+ if (require.main === module) {
243
+ main().catch((error) => {
244
+ console.error('Seed failed:', error);
245
+ process.exit(1);
246
+ });
247
+ }
@@ -13,6 +13,7 @@ import { useSearchParams } from 'react-router-dom';
13
13
  import type { TPaymentCurrency } from '@blocklet/payment-types';
14
14
  import { useRequest } from 'ahooks';
15
15
  import { AddOutlined, AutoModeOutlined } from '@mui/icons-material';
16
+ import { BN } from '@ocap/util';
16
17
  import Toast from '@arcblock/ux/lib/Toast';
17
18
  import { formatError } from '@blocklet/error';
18
19
  import SplitButton from '@arcblock/ux/lib/SplitButton';
@@ -228,45 +229,37 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
228
229
  )}
229
230
  </Stack>
230
231
 
231
- {/* 可用额度 / 总额度 */}
232
- <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
233
- <Typography
234
- variant="body2"
235
- gutterBottom
236
- sx={{
237
- color: 'text.secondary',
238
- }}>
239
- {t('admin.customer.creditGrants.creditBalance')}
240
- </Typography>
241
- <Typography variant="h5" component="div" sx={{ fontWeight: 'normal' }}>
242
- {totalAmount === '0' && remainingAmount === '0' ? (
243
- <>0</>
244
- ) : (
245
- <>{formatCreditAmount(formatBNStr(remainingAmount, currency.decimal), currency.symbol, false)}</>
246
- )}
247
- </Typography>
248
- </Box>
232
+ {/* 可用额度(净余额 = 剩余 - 欠费) */}
233
+ {(() => {
234
+ const remaining = new BN(remainingAmount || '0');
235
+ const pending = new BN(pendingAmount || '0');
236
+ const netBalance = remaining.sub(pending);
237
+ const isNegative = netBalance.isNeg();
238
+ const absBalance = isNegative ? netBalance.abs().toString() : netBalance.toString();
239
+ const formattedBalance =
240
+ totalAmount === '0' && remainingAmount === '0' && pendingAmount === '0'
241
+ ? '0'
242
+ : formatCreditAmount(formatBNStr(absBalance, currency.decimal), currency.symbol, false);
249
243
 
250
- {/* 欠费额度 */}
251
- {pendingAmount !== '0' && (
252
- <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
253
- <Typography
254
- variant="body2"
255
- gutterBottom
256
- sx={{
257
- color: 'text.secondary',
258
- }}>
259
- {t('admin.customer.creditGrants.pendingAmount')}
260
- </Typography>
261
- <Typography
262
- variant="body1"
263
- sx={{
264
- color: 'error.main',
265
- }}>
266
- {formatCreditAmount(formatBNStr(pendingAmount, currency.decimal), currency.symbol, false)}
267
- </Typography>
268
- </Box>
269
- )}
244
+ return (
245
+ <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
246
+ <Typography
247
+ variant="body2"
248
+ gutterBottom
249
+ sx={{
250
+ color: 'text.secondary',
251
+ }}>
252
+ {t('admin.customer.creditGrants.creditBalance')}
253
+ </Typography>
254
+ <Typography
255
+ variant="h5"
256
+ component="div"
257
+ sx={{ fontWeight: 'normal', color: isNegative ? 'error.main' : 'text.primary' }}>
258
+ {isNegative ? `-${formattedBalance}` : formattedBalance}
259
+ </Typography>
260
+ </Box>
261
+ );
262
+ })()}
270
263
  </Stack>
271
264
  </CardContent>
272
265
  </Card>
@@ -277,10 +270,6 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
277
270
  return creditCurrencies.filter((currency: TPaymentCurrency) => {
278
271
  const grantData = creditSummary?.grants?.[currency.id];
279
272
  if (!grantData) return false;
280
- // Filter out credits with zero balance
281
- if (grantData.remainingAmount === '0') {
282
- return false;
283
- }
284
273
  return true;
285
274
  });
286
275
  }, [creditCurrencies, creditSummary?.grants]);
@@ -1,3 +1,4 @@
1
+ import { BN } from '@ocap/util';
1
2
  import { formatNumber, formatTime, formatAmount } from '@blocklet/payment-react';
2
3
  import { useEffect } from 'react';
3
4
  import type { InvoicePDFProps } from './types';
@@ -99,12 +100,12 @@ export function InvoiceTemplate({ data, t }: InvoicePDFProps) {
99
100
 
100
101
  {/* Table Content */}
101
102
  {detail.map((line) => {
102
- // Calculate discount amount for this line item
103
- let itemDiscountAmount = 0;
103
+ // Calculate discount amount for this line item using BN to avoid scientific notation (e.g. 1e+21)
104
+ let itemDiscountAmount = new BN(0);
104
105
  if (line.raw.discount_amounts && line.raw.discount_amounts.length > 0) {
105
106
  line.raw.discount_amounts.forEach((discount: any) => {
106
107
  if (discount.amount) {
107
- itemDiscountAmount += Number(discount.amount);
108
+ itemDiscountAmount = itemDiscountAmount.add(new BN(String(discount.amount)));
108
109
  }
109
110
  });
110
111
  }
@@ -129,7 +130,7 @@ export function InvoiceTemplate({ data, t }: InvoicePDFProps) {
129
130
  </div>
130
131
  <div style={composeStyles('w-15 p-4-8 pb-15')}>
131
132
  <span style={composeStyles('dark right')}>
132
- {itemDiscountAmount > 0
133
+ {!itemDiscountAmount.isZero()
133
134
  ? `-${formatAmount(itemDiscountAmount.toString(), data.paymentCurrency.decimal)} ${
134
135
  data.paymentCurrency.symbol
135
136
  }`