payment-kit 1.22.31 → 1.23.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 (35) hide show
  1. package/api/src/index.ts +4 -0
  2. package/api/src/integrations/arcblock/token.ts +599 -0
  3. package/api/src/libs/credit-grant.ts +7 -6
  4. package/api/src/queues/credit-consume.ts +29 -4
  5. package/api/src/queues/credit-grant.ts +245 -50
  6. package/api/src/queues/credit-reconciliation.ts +253 -0
  7. package/api/src/queues/refund.ts +263 -30
  8. package/api/src/queues/token-transfer.ts +331 -0
  9. package/api/src/routes/checkout-sessions.ts +1 -1
  10. package/api/src/routes/credit-grants.ts +27 -7
  11. package/api/src/routes/credit-tokens.ts +38 -0
  12. package/api/src/routes/index.ts +2 -0
  13. package/api/src/routes/meter-events.ts +1 -1
  14. package/api/src/routes/meters.ts +32 -10
  15. package/api/src/routes/payment-currencies.ts +103 -0
  16. package/api/src/routes/products.ts +2 -2
  17. package/api/src/routes/settings.ts +4 -3
  18. package/api/src/store/migrations/20251120-add-token-config-to-currencies.ts +20 -0
  19. package/api/src/store/migrations/20251204-add-chain-fields.ts +74 -0
  20. package/api/src/store/models/credit-grant.ts +57 -10
  21. package/api/src/store/models/credit-transaction.ts +18 -1
  22. package/api/src/store/models/meter-event.ts +48 -25
  23. package/api/src/store/models/payment-currency.ts +31 -4
  24. package/api/src/store/models/refund.ts +12 -2
  25. package/api/src/store/models/types.ts +48 -0
  26. package/api/third.d.ts +2 -0
  27. package/blocklet.yml +1 -1
  28. package/package.json +7 -6
  29. package/src/components/customer/credit-overview.tsx +1 -1
  30. package/src/components/meter/form.tsx +191 -18
  31. package/src/components/price/form.tsx +49 -37
  32. package/src/locales/en.tsx +24 -0
  33. package/src/locales/zh.tsx +26 -0
  34. package/src/pages/admin/billing/meters/create.tsx +42 -13
  35. package/src/pages/admin/billing/meters/detail.tsx +56 -5
@@ -20,6 +20,7 @@ import { getDaysUntilCancel, getDueUnit, getMeterPriceIdsFromSubscription } from
20
20
  import { events } from '../libs/event';
21
21
  import { handlePastDueSubscriptionRecovery } from './payment';
22
22
  import { checkAndTriggerAutoRecharge } from './auto-recharge';
23
+ import { addTokenTransferJob } from './token-transfer';
23
24
 
24
25
  type CreditConsumptionJob = {
25
26
  meterEventId: string;
@@ -329,11 +330,30 @@ async function processGrantConsumption(
329
330
  creditGrantId: creditGrant.id,
330
331
  consumeAmount,
331
332
  });
333
+
332
334
  const result = await creditGrant.consumeCredit(consumeAmount, {
333
335
  subscription_id: context.subscription?.id,
334
336
  meter_event_id: context.meterEvent.id,
335
337
  });
336
- await createCreditTransaction(context, creditGrant, consumeAmount, result);
338
+
339
+ const transactionId = await createCreditTransaction(context, creditGrant, consumeAmount, result);
340
+
341
+ if (CreditGrant.hasOnchainToken(creditGrant)) {
342
+ addTokenTransferJob({
343
+ creditTransactionId: transactionId,
344
+ creditGrantId: creditGrant.id,
345
+ customerDid: context.customer.did,
346
+ amount: consumeAmount,
347
+ paymentCurrencyId: context.meter.paymentCurrency.id,
348
+ meterEventId: context.meterEvent.id,
349
+ subscriptionId: context.meterEvent.getSubscriptionId(),
350
+ }).catch((error) => {
351
+ logger.error('Failed to add token transfer job', {
352
+ creditTransactionId: transactionId,
353
+ error,
354
+ });
355
+ });
356
+ }
337
357
  }
338
358
 
339
359
  async function createCreditTransaction(
@@ -341,7 +361,7 @@ async function createCreditTransaction(
341
361
  creditGrant: CreditGrant,
342
362
  consumeAmount: string,
343
363
  consumeResult: any
344
- ): Promise<void> {
364
+ ): Promise<string> {
345
365
  const meterEventId = context.meterEvent.id;
346
366
  const creditGrantId = creditGrant.id;
347
367
 
@@ -360,7 +380,7 @@ async function createCreditTransaction(
360
380
  existingTransactionId: existingTransaction.id,
361
381
  });
362
382
  context.transactions.push(existingTransaction.id);
363
- return;
383
+ return existingTransaction.id;
364
384
  }
365
385
 
366
386
  logger.debug('start to create credit transaction', {
@@ -392,6 +412,8 @@ async function createCreditTransaction(
392
412
  remaining_balance: consumeResult.remaining,
393
413
  source: context.meterEvent.id,
394
414
  description,
415
+ // 如果是链上 grant,会在后续 transfer 成功之后更新为 completed
416
+ transfer_status: CreditGrant.hasOnchainToken(creditGrant) ? 'pending' : 'completed',
395
417
  metadata: {
396
418
  ...(context.meterEvent.metadata || {}),
397
419
  meter_event_id: context.meterEvent.id,
@@ -407,6 +429,8 @@ async function createCreditTransaction(
407
429
  meterEventId: context.meterEvent.id,
408
430
  });
409
431
  context.transactions.push(transaction.id);
432
+
433
+ return transaction.id;
410
434
  } catch (error: any) {
411
435
  // 处理唯一约束违反错误
412
436
  if (error.name === 'SequelizeUniqueConstraintError' || error.message.includes('UNIQUE constraint failed')) {
@@ -426,8 +450,8 @@ async function createCreditTransaction(
426
450
 
427
451
  if (duplicateTransaction) {
428
452
  context.transactions.push(duplicateTransaction.id);
453
+ return duplicateTransaction.id;
429
454
  }
430
- return;
431
455
  }
432
456
 
433
457
  // 其他错误重新抛出
@@ -486,6 +510,7 @@ export async function handleCreditConsumption(job: CreditConsumptionJob) {
486
510
 
487
511
  // Consume available credits (handles existing transactions internally)
488
512
  const consumptionResult = await consumeAvailableCredits(context, totalRequiredAmount);
513
+
489
514
  // Check for auto recharge after successful consumption
490
515
  try {
491
516
  await checkAndTriggerAutoRecharge(
@@ -1,7 +1,7 @@
1
1
  // create credit grant
2
2
 
3
3
  import { Op } from 'sequelize';
4
- import { BN, fromTokenToUnit } from '@ocap/util';
4
+ import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
5
5
  import dayjs from '../libs/dayjs';
6
6
  import createQueue from '../libs/queue';
7
7
  import {
@@ -19,6 +19,7 @@ import {
19
19
  } from '../store/models';
20
20
  import { events } from '../libs/event';
21
21
  import { calculateExpiresAt, createCreditGrant } from '../libs/credit-grant';
22
+ import { mintToken, transferTokenFromCustomer } from '../integrations/arcblock/token';
22
23
  import logger from '../libs/logger';
23
24
 
24
25
  type CreditGrantJob =
@@ -31,8 +32,217 @@ type CreditGrantJob =
31
32
  action: 'create_from_invoice';
32
33
  };
33
34
 
35
+ /**
36
+ * Activate credit grants and handle on-chain minting when required.
37
+ * Non on-chain credits finish immediately; on-chain credits wait for mint success
38
+ */
39
+ async function activateGrant(creditGrant: CreditGrant) {
40
+ const paymentCurrency = await PaymentCurrency.findByPk(creditGrant.currency_id);
41
+ const isOnChainCredit = paymentCurrency && PaymentCurrency.isOnChainCredit(paymentCurrency);
42
+
43
+ logger.info('Activating credit grant', {
44
+ creditGrantId: creditGrant.id,
45
+ customerId: creditGrant.customer_id,
46
+ currencyId: creditGrant.currency_id,
47
+ amount: creditGrant.amount,
48
+ });
49
+
50
+ if (!isOnChainCredit) {
51
+ await finalizeGrant(creditGrant);
52
+ return;
53
+ }
54
+
55
+ // Skip mint for already granted credits (legacy grants or already minted)
56
+ // New grants that need mint will have status='pending' until mint succeeds
57
+ if (creditGrant.status === 'granted' || creditGrant.chain_status === 'mint_completed') {
58
+ logger.info('Grant already in granted status, skipping mint', {
59
+ creditGrantId: creditGrant.id,
60
+ status: creditGrant.status,
61
+ chainStatus: creditGrant.chain_status,
62
+ });
63
+ await finalizeGrant(creditGrant);
64
+ return;
65
+ }
66
+
67
+ // Verify customer
68
+ const customer = await Customer.findByPk(creditGrant.customer_id);
69
+ if (!customer?.did) {
70
+ throw new Error(`Customer DID not found for credit grant ${creditGrant.id}`);
71
+ }
72
+
73
+ // Step 1: Mint token on-chain
74
+ // TODO: check if already minted
75
+ const mintAmount = fromUnitToToken(creditGrant.amount, paymentCurrency.decimal);
76
+ const hash = await mintToken({
77
+ paymentCurrency,
78
+ amount: mintAmount,
79
+ receiver: customer.did,
80
+ creditGrantId: creditGrant.id,
81
+ });
82
+
83
+ // Step 2: Finalize grant with mint result
84
+ await finalizeGrant(creditGrant, {
85
+ chain_status: 'mint_completed',
86
+ chain_detail: {
87
+ ...(creditGrant.chain_detail || {}),
88
+ mint: {
89
+ hash,
90
+ at: dayjs().unix(),
91
+ },
92
+ },
93
+ });
94
+
95
+ // Step 3: Emit mint success event for reconciliation
96
+ events.emit('customer.credit_grant.minted', creditGrant, { mintHash: hash });
97
+ }
98
+
99
+ async function finalizeGrant(
100
+ creditGrant: CreditGrant,
101
+ updates?: Partial<Pick<CreditGrant, 'chain_status' | 'chain_detail'>>
102
+ ) {
103
+ await creditGrant.update({ ...updates, status: 'granted' });
104
+ logger.info('Credit grant finalized', {
105
+ creditGrantId: creditGrant.id,
106
+ customerId: creditGrant.customer_id,
107
+ });
108
+
109
+ if (creditGrant.expires_at) {
110
+ await addCreditGrantJob(creditGrant, 'expire');
111
+ logger.info('Credit grant expire job ensured', {
112
+ creditGrantId: creditGrant.id,
113
+ expiresAt: creditGrant.expires_at,
114
+ });
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Expire credit grants and handle on-chain token transfer when required.
120
+ * Transfers remaining tokens to system wallet for grants that have been minted (chain_status === 'mint_completed').
121
+ * This is consistent with credit consumption - expired credits are treated the same as consumed credits.
122
+ */
123
+ export async function expireGrant(creditGrant: CreditGrant) {
124
+ // Only transfer for grants that have been minted on-chain
125
+ const isOnChain = creditGrant.hasOnchainToken();
126
+
127
+ logger.info('Expiring credit grant', {
128
+ creditGrantId: creditGrant.id,
129
+ isOnChain,
130
+ chainStatus: creditGrant.chain_status,
131
+ remainingAmount: creditGrant.remaining_amount,
132
+ });
133
+
134
+ // For grants without on-chain tokens, just update status
135
+ if (!isOnChain) {
136
+ await creditGrant.update({ status: 'expired' });
137
+ return;
138
+ }
139
+
140
+ const remainingAmount = new BN(creditGrant.remaining_amount || '0');
141
+ if (remainingAmount.lte(new BN(0))) {
142
+ // No remaining amount to transfer, just update status
143
+ await creditGrant.update({ status: 'expired' });
144
+ logger.info('Credit grant expired (no remaining amount to transfer)', {
145
+ creditGrantId: creditGrant.id,
146
+ customerId: creditGrant.customer_id,
147
+ });
148
+ return;
149
+ }
150
+
151
+ const paymentCurrency = await PaymentCurrency.findByPk(creditGrant.currency_id);
152
+ if (!paymentCurrency) {
153
+ logger.error('Payment currency not found for credit grant transfer', {
154
+ creditGrantId: creditGrant.id,
155
+ currencyId: creditGrant.currency_id,
156
+ customerId: creditGrant.customer_id,
157
+ });
158
+ await creditGrant.update({ status: 'expired' });
159
+ return;
160
+ }
161
+
162
+ // Get customer DID
163
+ const customer = await Customer.findByPk(creditGrant.customer_id);
164
+ if (!customer?.did) {
165
+ logger.error('Customer DID not found for credit grant transfer', {
166
+ creditGrantId: creditGrant.id,
167
+ customerId: creditGrant.customer_id,
168
+ });
169
+ await creditGrant.update({ status: 'expired' });
170
+ return;
171
+ }
172
+
173
+ // Transfer remaining tokens to system wallet (same as consumption flow)
174
+ const chainDetail = creditGrant.chain_detail || {};
175
+
176
+ try {
177
+ const hash = await transferTokenFromCustomer({
178
+ paymentCurrency,
179
+ customerDid: customer.did,
180
+ amount: remainingAmount.toString(),
181
+ data: {
182
+ reason: 'credit_expired',
183
+ creditGrantId: creditGrant.id,
184
+ },
185
+ checkBalance: false, // Don't check balance as we're transferring expired tokens
186
+ });
187
+
188
+ logger.info('Successfully transferred expired credit tokens to system wallet', {
189
+ creditGrantId: creditGrant.id,
190
+ customerId: creditGrant.customer_id,
191
+ amount: remainingAmount.toString(),
192
+ hash,
193
+ });
194
+
195
+ // Update status and chain_detail with transfer result
196
+ await creditGrant.update({
197
+ status: 'expired',
198
+ chain_status: 'transfer_completed',
199
+ chain_detail: {
200
+ ...chainDetail,
201
+ expired_transfer: {
202
+ hash,
203
+ at: dayjs().unix(),
204
+ amount: remainingAmount.toString(),
205
+ },
206
+ voided_reason: 'expired',
207
+ },
208
+ });
209
+
210
+ // Emit expired event for reconciliation
211
+ events.emit('customer.credit_grant.expired', creditGrant, {
212
+ transferHash: hash,
213
+ expiredAmount: remainingAmount.toString(),
214
+ });
215
+ } catch (error) {
216
+ logger.error('Failed to transfer expired credit tokens', {
217
+ creditGrantId: creditGrant.id,
218
+ customerId: creditGrant.customer_id,
219
+ amount: remainingAmount.toString(),
220
+ error: error.message,
221
+ stack: error.stack,
222
+ });
223
+
224
+ // Update status to expired and record error, but don't fail the expiration
225
+ await creditGrant.update({
226
+ status: 'expired',
227
+ chain_status: 'transfer_failed',
228
+ chain_detail: {
229
+ ...chainDetail,
230
+ expired_transfer: {
231
+ ...chainDetail.expired_transfer,
232
+ error: error.message,
233
+ failed_at: dayjs().unix(),
234
+ },
235
+ voided_reason: 'expired',
236
+ },
237
+ });
238
+ }
239
+ }
240
+
34
241
  const handleCreditGrantJob = async (job: CreditGrantJob) => {
35
- logger.info('Handling credit grant job', { job });
242
+ logger.info('Handling credit grant job', {
243
+ action: job.action,
244
+ ...(job.action === 'create_from_invoice' ? { invoiceId: job.invoiceId } : { creditGrantId: job.creditGrantId }),
245
+ });
36
246
  if (job.action === 'create_from_invoice') {
37
247
  await handleInvoiceCredit(job.invoiceId);
38
248
  return;
@@ -50,37 +260,17 @@ const handleCreditGrantJob = async (job: CreditGrantJob) => {
50
260
 
51
261
  if (action === 'activate') {
52
262
  if (creditGrant.status !== 'pending') {
53
- logger.warn('Credit grant not in pending status for activation', {
263
+ logger.warn('Credit grant not in pending status for activation, skipping', {
54
264
  creditGrantId,
55
265
  status: creditGrant.status,
56
266
  });
57
267
  return;
58
268
  }
59
- if (creditGrant.status === 'granted') {
60
- logger.info('Credit grant already granted', {
61
- creditGrantId,
62
- });
63
- return;
64
- }
65
269
  if (
66
270
  (!creditGrant.effective_at || creditGrant.effective_at <= now) &&
67
271
  (!creditGrant.expires_at || creditGrant.expires_at > now)
68
272
  ) {
69
- await creditGrant.update({
70
- status: 'granted',
71
- });
72
-
73
- logger.info('Credit grant activated', {
74
- creditGrantId,
75
- });
76
-
77
- if (creditGrant.expires_at) {
78
- await addCreditGrantJob(creditGrant, 'expire', creditGrant.expires_at);
79
- logger.info('Credit grant expire job scheduled', {
80
- creditGrantId,
81
- expiresAt: creditGrant.expires_at,
82
- });
83
- }
273
+ await activateGrant(creditGrant);
84
274
  }
85
275
  }
86
276
 
@@ -93,12 +283,7 @@ const handleCreditGrantJob = async (job: CreditGrantJob) => {
93
283
  return;
94
284
  }
95
285
  if (creditGrant.expires_at && creditGrant.expires_at <= now) {
96
- await creditGrant.update({
97
- status: 'expired',
98
- });
99
- logger.info('Credit grant expired', {
100
- creditGrantId,
101
- });
286
+ await expireGrant(creditGrant);
102
287
  }
103
288
  }
104
289
  };
@@ -163,6 +348,13 @@ export async function addCreditGrantJob(creditGrant: CreditGrant, action: 'activ
163
348
  export async function scheduleCreditGrantJobs(creditGrant: CreditGrant) {
164
349
  const now = dayjs().unix();
165
350
 
351
+ logger.info('Scheduling credit grant jobs', {
352
+ creditGrantId: creditGrant.id,
353
+ status: creditGrant.status,
354
+ effective_at: creditGrant.effective_at,
355
+ expires_at: creditGrant.expires_at,
356
+ });
357
+
166
358
  if (creditGrant.status === 'pending') {
167
359
  if (creditGrant.effective_at && creditGrant.effective_at > now) {
168
360
  await addCreditGrantJob(creditGrant, 'activate');
@@ -170,16 +362,9 @@ export async function scheduleCreditGrantJobs(creditGrant: CreditGrant) {
170
362
  }
171
363
  if (!creditGrant.effective_at || creditGrant.effective_at <= now) {
172
364
  if (!creditGrant.expires_at || creditGrant.expires_at > now) {
173
- await creditGrant.update({ status: 'granted' });
174
- logger.info('Credit grant immediately activated', {
175
- creditGrantId: creditGrant.id,
176
- customerId: creditGrant.customer_id,
177
- });
178
- if (creditGrant.expires_at) {
179
- await addCreditGrantJob(creditGrant, 'expire');
180
- }
365
+ await activateGrant(creditGrant);
181
366
  } else {
182
- await creditGrant.update({ status: 'expired' });
367
+ await expireGrant(creditGrant);
183
368
  logger.info('Credit grant immediately expired', {
184
369
  creditGrantId: creditGrant.id,
185
370
  customerId: creditGrant.customer_id,
@@ -187,8 +372,9 @@ export async function scheduleCreditGrantJobs(creditGrant: CreditGrant) {
187
372
  }
188
373
  }
189
374
  }
190
- if (creditGrant.status === 'granted' && creditGrant.expires_at) {
191
- await addCreditGrantJob(creditGrant, 'expire');
375
+ if (creditGrant.status === 'granted') {
376
+ // Retry mint if previously failed, and schedule expiration
377
+ await activateGrant(creditGrant);
192
378
  }
193
379
  }
194
380
 
@@ -257,8 +443,8 @@ creditGrantQueue.on('failed', ({ id, job, error }) => {
257
443
  logger.error('Credit grant job failed', { id, job, error });
258
444
  });
259
445
 
260
- creditGrantQueue.on('finished', ({ id, job, result }) => {
261
- logger.info('Credit grant job completed', { id, job, result });
446
+ creditGrantQueue.on('finished', ({ id }) => {
447
+ logger.debug('Credit grant job completed', { id });
262
448
  });
263
449
 
264
450
  async function handleInvoiceCredit(invoiceId: string) {
@@ -277,7 +463,7 @@ async function handleInvoiceCredit(invoiceId: string) {
277
463
  })) as TInvoiceExpanded | null;
278
464
 
279
465
  if (!invoice) {
280
- logger.info('Invoice not found', { invoiceId });
466
+ logger.warn('Invoice not found', { invoiceId });
281
467
  return;
282
468
  }
283
469
 
@@ -354,6 +540,7 @@ async function handleInvoiceCredit(invoiceId: string) {
354
540
  },
355
541
  };
356
542
  }
543
+
357
544
  return {
358
545
  price,
359
546
  quantity,
@@ -361,7 +548,8 @@ async function handleInvoiceCredit(invoiceId: string) {
361
548
  creditAmount,
362
549
  applicablePrices,
363
550
  expiredAt: calculateExpiresAt(
364
- parseInt(String(creditConfig?.valid_duration_value || '0'), 10),
551
+ // eslint-disable-next-line no-unsafe-optional-chaining
552
+ +creditConfig?.valid_duration_value || 0,
365
553
  creditConfig?.valid_duration_unit || 'days'
366
554
  ),
367
555
  applicabilityConfig,
@@ -459,7 +647,7 @@ async function handleInvoiceCredit(invoiceId: string) {
459
647
  } catch (error) {
460
648
  logger.error('Failed to create Credit Grant from invoice', {
461
649
  invoiceId,
462
- error: error.message,
650
+ error,
463
651
  stack: error.stack,
464
652
  });
465
653
  await Invoice.update(
@@ -502,10 +690,11 @@ export async function addInvoiceCreditJob(invoiceId: string) {
502
690
 
503
691
  const existingJob = await creditGrantQueue.get(jobId);
504
692
  if (existingJob) {
505
- logger.info('Invoice credit job already exists, skipping duplicate', {
693
+ logger.debug('Invoice credit job already exists, skipping duplicate', {
506
694
  invoiceId,
507
695
  jobId,
508
696
  });
697
+ return;
509
698
  }
510
699
 
511
700
  await creditGrantQueue.push({
@@ -534,8 +723,14 @@ events.on('invoice.paid', async (invoice: Invoice) => {
534
723
  }
535
724
  });
536
725
 
537
- events.on('customer.credit_grant.created', async (creditGrant: CreditGrant) => {
726
+ events.on('customer.credit_grant.created', async (data: { id: string }) => {
538
727
  try {
728
+ // Fetch instance since event emits dataValues (plain object)
729
+ const creditGrant = await CreditGrant.findByPk(data.id);
730
+ if (!creditGrant) {
731
+ logger.error('Credit grant not found for scheduling', { creditGrantId: data.id });
732
+ return;
733
+ }
539
734
  await scheduleCreditGrantJobs(creditGrant);
540
735
  logger.info('Credit grant jobs scheduled', {
541
736
  creditGrantId: creditGrant.id,
@@ -543,8 +738,8 @@ events.on('customer.credit_grant.created', async (creditGrant: CreditGrant) => {
543
738
  });
544
739
  } catch (error) {
545
740
  logger.error('Failed to schedule credit grant jobs', {
546
- creditGrantId: creditGrant.id,
547
- error: error.message,
741
+ creditGrantId: data.id,
742
+ error,
548
743
  });
549
744
  }
550
745
  });