payment-kit 1.22.32 → 1.23.1

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 (49) 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/libs/util.ts +34 -0
  5. package/api/src/queues/credit-consume.ts +29 -4
  6. package/api/src/queues/credit-grant.ts +245 -50
  7. package/api/src/queues/credit-reconciliation.ts +253 -0
  8. package/api/src/queues/refund.ts +263 -30
  9. package/api/src/queues/token-transfer.ts +331 -0
  10. package/api/src/routes/checkout-sessions.ts +94 -29
  11. package/api/src/routes/credit-grants.ts +35 -9
  12. package/api/src/routes/credit-tokens.ts +38 -0
  13. package/api/src/routes/credit-transactions.ts +20 -3
  14. package/api/src/routes/index.ts +2 -0
  15. package/api/src/routes/meter-events.ts +4 -0
  16. package/api/src/routes/meters.ts +32 -10
  17. package/api/src/routes/payment-currencies.ts +103 -0
  18. package/api/src/routes/payment-links.ts +3 -1
  19. package/api/src/routes/products.ts +2 -2
  20. package/api/src/routes/settings.ts +4 -3
  21. package/api/src/store/migrations/20251120-add-token-config-to-currencies.ts +20 -0
  22. package/api/src/store/migrations/20251204-add-chain-fields.ts +74 -0
  23. package/api/src/store/migrations/20251211-optimize-slow-queries.ts +33 -0
  24. package/api/src/store/models/credit-grant.ts +47 -9
  25. package/api/src/store/models/credit-transaction.ts +18 -1
  26. package/api/src/store/models/index.ts +2 -1
  27. package/api/src/store/models/payment-currency.ts +31 -4
  28. package/api/src/store/models/refund.ts +12 -2
  29. package/api/src/store/models/types.ts +48 -0
  30. package/api/src/store/sequelize.ts +1 -0
  31. package/api/third.d.ts +2 -0
  32. package/blocklet.yml +1 -1
  33. package/package.json +7 -6
  34. package/src/app.tsx +10 -0
  35. package/src/components/customer/credit-overview.tsx +19 -3
  36. package/src/components/meter/form.tsx +191 -18
  37. package/src/components/price/form.tsx +49 -37
  38. package/src/locales/en.tsx +25 -1
  39. package/src/locales/zh.tsx +27 -1
  40. package/src/pages/admin/billing/meters/create.tsx +42 -13
  41. package/src/pages/admin/billing/meters/detail.tsx +56 -5
  42. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +13 -0
  43. package/src/pages/admin/customers/customers/credit-transaction/detail.tsx +324 -0
  44. package/src/pages/admin/customers/index.tsx +5 -0
  45. package/src/pages/customer/credit-grant/detail.tsx +14 -1
  46. package/src/pages/customer/credit-transaction/detail.tsx +289 -0
  47. package/src/pages/customer/invoice/detail.tsx +1 -1
  48. package/src/pages/customer/recharge/subscription.tsx +1 -1
  49. package/src/pages/customer/subscription/detail.tsx +1 -1
@@ -5,6 +5,7 @@ import autoRechargeConfigs from './auto-recharge-configs';
5
5
  import checkoutSessions from './checkout-sessions';
6
6
  import coupons from './coupons';
7
7
  import creditGrants from './credit-grants';
8
+ import creditTokens from './credit-tokens';
8
9
  import creditTransactions from './credit-transactions';
9
10
  import customers from './customers';
10
11
  import donations from './donations';
@@ -61,6 +62,7 @@ router.use('/auto-recharge-configs', autoRechargeConfigs);
61
62
  router.use('/checkout-sessions', checkoutSessions);
62
63
  router.use('/coupons', coupons);
63
64
  router.use('/credit-grants', creditGrants);
65
+ router.use('/credit-tokens', creditTokens);
64
66
  router.use('/credit-transactions', creditTransactions);
65
67
  router.use('/customers', customers);
66
68
  router.use('/donations', donations);
@@ -335,7 +335,11 @@ router.get('/pending-amount', authMine, async (req, res) => {
335
335
  }
336
336
  params.customerId = customer.id;
337
337
  }
338
+ // eslint-disable-next-line no-console
339
+ console.time('pending-amount: getPendingAmounts');
338
340
  const [summary] = await MeterEvent.getPendingAmounts(params);
341
+ // eslint-disable-next-line no-console
342
+ console.timeEnd('pending-amount: getPendingAmounts');
339
343
  return res.json(summary);
340
344
  } catch (err) {
341
345
  logger.error('Error getting meter event pending amount', err);
@@ -18,9 +18,13 @@ const meterSchema = Joi.object({
18
18
  aggregation_method: Joi.string().valid('sum', 'count', 'last').default('sum'),
19
19
  unit: Joi.string().max(32).required(),
20
20
  currency_id: Joi.string().max(40).optional(),
21
+ decimal: Joi.number().integer().min(2).max(18).default(10),
21
22
  description: Joi.string().max(255).allow('').optional(),
22
23
  metadata: MetadataSchema,
23
24
  component_did: Joi.string().max(40).optional(),
25
+ token: Joi.object({
26
+ tokenFactoryAddress: Joi.string().required(),
27
+ }).optional(),
24
28
  }).unknown(true);
25
29
 
26
30
  const updateMeterSchema = Joi.object({
@@ -78,6 +82,32 @@ router.post('/', auth, async (req, res) => {
78
82
  return res.status(400).json({ error: 'Aggregation method is not supported' });
79
83
  }
80
84
 
85
+ const needArcblockMethod = req.body.token?.tokenFactoryAddress || !req.body.currency_id;
86
+ const arcblockMethod = needArcblockMethod
87
+ ? await PaymentMethod.findOne({ where: { livemode: !!req.livemode, type: 'arcblock' } })
88
+ : null;
89
+ if (needArcblockMethod && !arcblockMethod) {
90
+ throw new Error('ArcBlock payment method not found');
91
+ }
92
+
93
+ let tokenConfig: Record<string, any> | undefined;
94
+ if (req.body.token?.tokenFactoryAddress) {
95
+ const client = arcblockMethod!.getOcapClient();
96
+ const { state: tokenFactoryState } = await client.getTokenFactoryState({
97
+ address: req.body.token.tokenFactoryAddress,
98
+ });
99
+ if (!tokenFactoryState) {
100
+ return res.status(400).json({ error: 'Token factory not found on chain' });
101
+ }
102
+ tokenConfig = {
103
+ address: tokenFactoryState.token.address,
104
+ symbol: tokenFactoryState.token.symbol,
105
+ name: tokenFactoryState.token.name,
106
+ decimal: tokenFactoryState.token.decimal,
107
+ token_factory_address: tokenFactoryState.address,
108
+ };
109
+ }
110
+
81
111
  const meterData = {
82
112
  ...pick(req.body, ['name', 'event_name', 'aggregation_method', 'unit', 'currency_id', 'description', 'metadata']),
83
113
  livemode: !!req.livemode,
@@ -87,17 +117,9 @@ router.post('/', auth, async (req, res) => {
87
117
  };
88
118
 
89
119
  if (!meterData.currency_id) {
90
- const paymentMethod = await PaymentMethod.findOne({
91
- where: {
92
- livemode: !!req.livemode,
93
- type: 'arcblock',
94
- },
120
+ const paymentCurrency = await PaymentCurrency.createForMeter(meterData, arcblockMethod!.id, tokenConfig, {
121
+ decimal: req.body.decimal,
95
122
  });
96
- if (!paymentMethod) {
97
- return res.status(400).json({ error: 'Payment method not found' });
98
- }
99
-
100
- const paymentCurrency = await PaymentCurrency.createForMeter(meterData, paymentMethod.id);
101
123
  meterData.currency_id = paymentCurrency.id;
102
124
  }
103
125
 
@@ -351,6 +351,109 @@ router.put('/:id', auth, async (req, res) => {
351
351
  return res.json(updatedCurrency);
352
352
  });
353
353
 
354
+ const tokenConfigSchema = Joi.object({
355
+ token_factory_address: Joi.string().required(),
356
+ });
357
+
358
+ router.put('/:id/token-config', auth, async (req, res) => {
359
+ try {
360
+ const { id } = req.params;
361
+
362
+ const { error, value } = tokenConfigSchema.validate(req.body);
363
+ if (error) {
364
+ return res.status(400).json({ error: error.message });
365
+ }
366
+
367
+ const currency = await PaymentCurrency.findByPk(id);
368
+ if (!currency) {
369
+ return res.status(404).json({ error: 'Payment currency not found' });
370
+ }
371
+
372
+ if (currency.type !== 'credit') {
373
+ return res.status(400).json({ error: 'Only credit currencies can have token_config' });
374
+ }
375
+
376
+ if (currency.token_config) {
377
+ return res.status(400).json({ error: 'Token config already exists. Cannot be updated once set.' });
378
+ }
379
+
380
+ const paymentMethod = await PaymentMethod.findOne({
381
+ where: {
382
+ livemode: currency.livemode,
383
+ type: 'arcblock',
384
+ },
385
+ });
386
+
387
+ if (!paymentMethod) {
388
+ return res.status(400).json({ error: 'ArcBlock payment method not found' });
389
+ }
390
+
391
+ const client = paymentMethod.getOcapClient();
392
+ const { state: tokenFactoryState } = await client.getTokenFactoryState({
393
+ address: value.token_factory_address,
394
+ });
395
+
396
+ if (!tokenFactoryState) {
397
+ return res.status(400).json({ error: 'Token factory not found on chain' });
398
+ }
399
+
400
+ const tokenConfig = {
401
+ address: tokenFactoryState.token.address,
402
+ symbol: tokenFactoryState.token.symbol,
403
+ name: tokenFactoryState.token.name,
404
+ decimal: tokenFactoryState.token.decimal,
405
+ token_factory_address: tokenFactoryState.address,
406
+ };
407
+
408
+ // Only update token_config, keep the original decimal to avoid breaking existing credit grants
409
+ await currency.update({
410
+ token_config: tokenConfig,
411
+ });
412
+
413
+ logger.info('Payment currency token_config updated', {
414
+ currencyId: id,
415
+ tokenConfig,
416
+ });
417
+
418
+ return res.json(currency.toJSON());
419
+ } catch (err) {
420
+ logger.error('update payment currency token_config failed', { error: err?.message, id: req.params.id });
421
+ return res.status(400).json({ error: err?.message });
422
+ }
423
+ });
424
+
425
+ router.delete('/:id/token-config', auth, async (req, res) => {
426
+ try {
427
+ const { id } = req.params;
428
+
429
+ const currency = await PaymentCurrency.findByPk(id);
430
+ if (!currency) {
431
+ return res.status(404).json({ error: 'Payment currency not found' });
432
+ }
433
+
434
+ if (currency.type !== 'credit') {
435
+ return res.status(400).json({ error: 'Only credit currencies can have token_config' });
436
+ }
437
+
438
+ if (!currency.token_config) {
439
+ return res.status(400).json({ error: 'Token config does not exist' });
440
+ }
441
+
442
+ await currency.update({
443
+ token_config: null,
444
+ });
445
+
446
+ logger.info('Payment currency token_config removed', {
447
+ currencyId: id,
448
+ });
449
+
450
+ return res.json(currency.toJSON());
451
+ } catch (err) {
452
+ logger.error('delete payment currency token_config failed', { error: err?.message, id: req.params.id });
453
+ return res.status(400).json({ error: err?.message });
454
+ }
455
+ });
456
+
354
457
  router.delete('/:id', auth, async (req, res) => {
355
458
  const { id } = req.params;
356
459
 
@@ -260,10 +260,12 @@ router.get('/', auth, async (req, res) => {
260
260
  const priceIds: string[] = list.reduce((acc: string[], x) => acc.concat(x.line_items.map((i) => i.price_id)), []);
261
261
  const prices = await Price.findAll({ where: { id: priceIds }, include: [{ model: Product, as: 'product' }] });
262
262
 
263
+ const priceMap = new Map(prices.map((p) => [p.id, p]));
264
+
263
265
  list.forEach((x) => {
264
266
  x.line_items.forEach((i) => {
265
267
  // @ts-ignore
266
- i.price = prices.find((p) => p.id === i.price_id);
268
+ i.price = priceMap.get(i.price_id);
267
269
  });
268
270
  });
269
271
 
@@ -37,7 +37,7 @@ const ProductAndPriceSchema = Joi.object({
37
37
  description: Joi.string().max(250).empty('').optional(),
38
38
  images: Joi.any().optional(),
39
39
  metadata: MetadataSchema,
40
- tax_code: Joi.string().max(30).empty('').optional(),
40
+ tax_code: Joi.string().max(30).allow(null).empty('').optional(),
41
41
  statement_descriptor: Joi.string()
42
42
  .max(22)
43
43
  .pattern(/^(?=.*[A-Za-z])[^\u4e00-\u9fa5<>"’\\]*$/)
@@ -48,7 +48,7 @@ const ProductAndPriceSchema = Joi.object({
48
48
  .allow(null, '')
49
49
  .empty('')
50
50
  .optional(),
51
- unit_label: Joi.string().max(12).empty('').optional(),
51
+ unit_label: Joi.string().max(12).allow(null).empty('').optional(),
52
52
  nft_factory: Joi.string().max(40).allow(null).empty('').optional(),
53
53
  features: Joi.array()
54
54
  .items(Joi.object({ name: Joi.string().max(64).empty('').optional() }).unknown(true))
@@ -31,9 +31,10 @@ router.get('/', async (req, res) => {
31
31
  });
32
32
 
33
33
  res.json({
34
- paymentMethods: methods.map((x) =>
35
- pick(x, ['id', 'name', 'type', 'logo', 'payment_currencies', 'default_currency_id', 'maximum_precision'])
36
- ),
34
+ paymentMethods: methods.map((x) => ({
35
+ ...pick(x, ['id', 'name', 'type', 'logo', 'payment_currencies', 'default_currency_id', 'maximum_precision']),
36
+ api_host: x.settings?.arcblock?.api_host,
37
+ })),
37
38
  baseCurrency: await PaymentCurrency.findOne({
38
39
  where: { is_base_currency: true, livemode: req.livemode },
39
40
  attributes,
@@ -0,0 +1,20 @@
1
+ import { DataTypes } from 'sequelize';
2
+ import { safeApplyColumnChanges, type Migration } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ await safeApplyColumnChanges(context, {
6
+ payment_currencies: [
7
+ {
8
+ name: 'token_config',
9
+ field: {
10
+ type: DataTypes.JSON,
11
+ allowNull: true,
12
+ },
13
+ },
14
+ ],
15
+ });
16
+ };
17
+
18
+ export const down: Migration = async ({ context }) => {
19
+ await context.removeColumn('payment_currencies', 'token_config');
20
+ };
@@ -0,0 +1,74 @@
1
+ import { DataTypes } from 'sequelize';
2
+ import { createIndexIfNotExists, safeApplyColumnChanges, type Migration } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ // Add new columns to credit_transactions table
6
+ await safeApplyColumnChanges(context, {
7
+ credit_transactions: [
8
+ {
9
+ name: 'transfer_status',
10
+ field: {
11
+ type: DataTypes.ENUM('pending', 'completed', 'failed'),
12
+ allowNull: true,
13
+ },
14
+ },
15
+ {
16
+ name: 'transfer_hash',
17
+ field: {
18
+ type: DataTypes.STRING(255),
19
+ allowNull: true,
20
+ },
21
+ },
22
+ ],
23
+ });
24
+
25
+ // Add new columns to credit_grants table for on-chain operations
26
+ await safeApplyColumnChanges(context, {
27
+ credit_grants: [
28
+ {
29
+ name: 'chain_status',
30
+ field: {
31
+ type: DataTypes.ENUM(
32
+ 'mint_pending',
33
+ 'mint_completed',
34
+ 'mint_failed',
35
+ 'burn_pending',
36
+ 'burn_completed',
37
+ 'burn_failed'
38
+ ),
39
+ allowNull: true,
40
+ },
41
+ },
42
+ {
43
+ name: 'chain_detail',
44
+ field: {
45
+ type: DataTypes.JSON,
46
+ allowNull: true,
47
+ },
48
+ },
49
+ ],
50
+ });
51
+
52
+ // Add indexes for efficient querying
53
+ await createIndexIfNotExists(
54
+ context,
55
+ 'credit_transactions',
56
+ ['transfer_status'],
57
+ 'idx_credit_transactions_transfer_status'
58
+ );
59
+ await createIndexIfNotExists(context, 'credit_grants', ['chain_status'], 'idx_credit_grants_chain_status');
60
+ };
61
+
62
+ export const down: Migration = async ({ context }) => {
63
+ // Remove indexes
64
+ await context.removeIndex('credit_transactions', 'idx_credit_transactions_transfer_status');
65
+ await context.removeIndex('credit_grants', 'idx_credit_grants_chain_status');
66
+
67
+ // Remove columns from credit_transactions
68
+ await context.removeColumn('credit_transactions', 'transfer_hash');
69
+ await context.removeColumn('credit_transactions', 'transfer_status');
70
+
71
+ // Remove columns from credit_grants
72
+ await context.removeColumn('credit_grants', 'chain_detail');
73
+ await context.removeColumn('credit_grants', 'chain_status');
74
+ };
@@ -0,0 +1,33 @@
1
+ /* eslint-disable no-console */
2
+ import { createIndexIfNotExists, type Migration } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ await createIndexIfNotExists(
6
+ context,
7
+ 'meter_events',
8
+ ['livemode', 'event_name', 'timestamp'],
9
+ 'idx_meter_events_livemode_event_timestamp'
10
+ );
11
+
12
+ await createIndexIfNotExists(
13
+ context,
14
+ 'payment_currencies',
15
+ ['is_base_currency', 'livemode'],
16
+ 'idx_payment_currencies_base_livemode'
17
+ );
18
+
19
+ await createIndexIfNotExists(context, 'jobs', ['queue', 'id'], 'idx_jobs_queue_id');
20
+ await createIndexIfNotExists(
21
+ context,
22
+ 'jobs',
23
+ ['queue', 'cancelled', 'will_run_at', 'delay'],
24
+ 'idx_jobs_queue_cancelled_run_at_delay'
25
+ );
26
+ };
27
+
28
+ export const down: Migration = async ({ context }) => {
29
+ await context.removeIndex('meter_events', 'idx_meter_events_livemode_event_timestamp');
30
+ await context.removeIndex('payment_currencies', 'idx_payment_currencies_base_livemode');
31
+ await context.removeIndex('jobs', 'idx_jobs_queue_id');
32
+ await context.removeIndex('jobs', 'idx_jobs_queue_cancelled_run_at_delay');
33
+ };
@@ -6,9 +6,10 @@ import type { LiteralUnion } from 'type-fest';
6
6
  import { createEvent } from '../../libs/audit';
7
7
  import { createIdGenerator } from '../../libs/util';
8
8
  import dayjs from '../../libs/dayjs';
9
- import { CreditGrantApplicabilityConfig } from './types';
9
+ import { CreditGrantApplicabilityConfig, CreditGrantChainDetail, CreditGrantChainStatus } from './types';
10
10
  import logger from '../../libs/logger';
11
11
  import { PaymentCurrency, TPaymentCurrency } from './payment-currency';
12
+ import { Meter, TMeter } from './meter';
12
13
 
13
14
  const CREDIT_GRANT_STATUS_EVENTS = {
14
15
  depleted: 'depleted',
@@ -18,6 +19,7 @@ const CREDIT_GRANT_STATUS_EVENTS = {
18
19
 
19
20
  type CreditGrantSummary = {
20
21
  paymentCurrency: TPaymentCurrency;
22
+ meter?: TMeter;
21
23
  totalAmount: string;
22
24
  remainingAmount: string;
23
25
  grantCount: number;
@@ -62,6 +64,10 @@ export class CreditGrant extends Model<InferAttributes<CreditGrant>, InferCreati
62
64
  declare status: LiteralUnion<'pending' | 'granted' | 'depleted' | 'expired' | 'voided', string>;
63
65
  declare remaining_amount: string; // 剩余金额
64
66
 
67
+ // On-chain credit token operation fields
68
+ declare chain_status?: CreditGrantChainStatus;
69
+ declare chain_detail?: CreditGrantChainDetail;
70
+
65
71
  // 审计字段
66
72
  declare created_by?: string;
67
73
  declare updated_by?: string;
@@ -148,6 +154,21 @@ export class CreditGrant extends Model<InferAttributes<CreditGrant>, InferCreati
148
154
  type: DataTypes.STRING(32),
149
155
  allowNull: false,
150
156
  },
157
+ chain_status: {
158
+ type: DataTypes.ENUM(
159
+ 'mint_pending',
160
+ 'mint_completed',
161
+ 'mint_failed',
162
+ 'burn_pending',
163
+ 'burn_completed',
164
+ 'burn_failed'
165
+ ),
166
+ allowNull: true,
167
+ },
168
+ chain_detail: {
169
+ type: DataTypes.JSON,
170
+ allowNull: true,
171
+ },
151
172
  created_by: {
152
173
  type: DataTypes.STRING(40),
153
174
  allowNull: true,
@@ -267,6 +288,16 @@ export class CreditGrant extends Model<InferAttributes<CreditGrant>, InferCreati
267
288
  return true;
268
289
  }
269
290
 
291
+ // check if Credit Grant has on-chain token (mint completed)
292
+ public hasOnchainToken(): boolean {
293
+ return CreditGrant.hasOnchainToken(this);
294
+ }
295
+
296
+ // Static version that works with plain objects
297
+ public static hasOnchainToken(grant: { chain_status?: string | null }): boolean {
298
+ return grant.chain_status === 'mint_completed';
299
+ }
300
+
270
301
  public static initialize(sequelize: any) {
271
302
  this.init(this.GENESIS_ATTRIBUTES, {
272
303
  sequelize,
@@ -282,9 +313,6 @@ export class CreditGrant extends Model<InferAttributes<CreditGrant>, InferCreati
282
313
  hooks: {
283
314
  afterCreate: (model: CreditGrant, options) => {
284
315
  createEvent('CreditGrant', 'customer.credit_grant.created', model, options).catch(console.error);
285
- if (!model.effective_at || model.effective_at <= Math.floor(Date.now() / 1000)) {
286
- createEvent('CreditGrant', 'customer.credit_grant.granted', model, options).catch(console.error);
287
- }
288
316
  },
289
317
  afterUpdate: (model: CreditGrant, options) => {
290
318
  createEvent('CreditGrant', 'customer.credit_grant.updated', model, options).catch(console.error);
@@ -404,10 +432,7 @@ export class CreditGrant extends Model<InferAttributes<CreditGrant>, InferCreati
404
432
  currencyId?: string[] | string;
405
433
  priceIds?: string[];
406
434
  }): Promise<Record<string, CreditGrantSummary>> {
407
- const summary: Record<
408
- string,
409
- { paymentCurrency: TPaymentCurrency; totalAmount: string; remainingAmount: string; grantCount: number }
410
- > = {};
435
+ const summary: Record<string, CreditGrantSummary> = {};
411
436
 
412
437
  let targetCurrencyIds: string[] = [];
413
438
  if (searchCurrencyId) {
@@ -436,6 +461,16 @@ export class CreditGrant extends Model<InferAttributes<CreditGrant>, InferCreati
436
461
  });
437
462
  const currencyMap = new Map(currencies.map((c) => [c.id, c]));
438
463
 
464
+ // Query meters for credit currencies
465
+ const meters = await Meter.findAll({
466
+ where: {
467
+ currency_id: {
468
+ [Op.in]: Array.from(new Set(targetCurrencyIds)),
469
+ },
470
+ },
471
+ });
472
+ const meterMap = new Map(meters.map((m) => [m.currency_id, m]));
473
+
439
474
  await Promise.all(
440
475
  targetCurrencyIds.map(async (currencyId: string) => {
441
476
  const paymentCurrency = currencyMap.get(currencyId);
@@ -446,6 +481,7 @@ export class CreditGrant extends Model<InferAttributes<CreditGrant>, InferCreati
446
481
  return null;
447
482
  }
448
483
 
484
+ const meter = meterMap.get(currencyId);
449
485
  const availableGrants = await this.getAvailableCreditsForCustomer(customerId, currencyId, priceIds);
450
486
 
451
487
  if (availableGrants.length > 0) {
@@ -459,8 +495,9 @@ export class CreditGrant extends Model<InferAttributes<CreditGrant>, InferCreati
459
495
  grantCount += 1;
460
496
  });
461
497
 
462
- const result = {
498
+ const result: CreditGrantSummary = {
463
499
  paymentCurrency,
500
+ meter,
464
501
  totalAmount,
465
502
  remainingAmount,
466
503
  grantCount,
@@ -471,6 +508,7 @@ export class CreditGrant extends Model<InferAttributes<CreditGrant>, InferCreati
471
508
  }
472
509
  summary[currencyId] = {
473
510
  paymentCurrency,
511
+ meter,
474
512
  totalAmount: '0',
475
513
  remainingAmount: '0',
476
514
  grantCount: 0,
@@ -29,6 +29,10 @@ export class CreditTransaction extends Model<
29
29
  declare description?: string;
30
30
  declare metadata?: Record<string, any>;
31
31
 
32
+ // Token transfer fields
33
+ declare transfer_status?: 'pending' | 'completed' | 'failed' | null;
34
+ declare transfer_hash?: string;
35
+
32
36
  declare created_at: CreationOptional<Date>;
33
37
  declare updated_at: CreationOptional<Date>;
34
38
 
@@ -88,6 +92,14 @@ export class CreditTransaction extends Model<
88
92
  type: DataTypes.JSON,
89
93
  allowNull: true,
90
94
  },
95
+ transfer_status: {
96
+ type: DataTypes.ENUM('pending', 'completed', 'failed'),
97
+ allowNull: true,
98
+ },
99
+ transfer_hash: {
100
+ type: DataTypes.STRING(255),
101
+ allowNull: true,
102
+ },
91
103
  created_at: {
92
104
  type: DataTypes.DATE,
93
105
  defaultValue: DataTypes.NOW,
@@ -107,7 +119,12 @@ export class CreditTransaction extends Model<
107
119
  tableName: 'credit_transactions',
108
120
  createdAt: 'created_at',
109
121
  updatedAt: 'updated_at',
110
- indexes: [{ fields: ['customer_id'] }, { fields: ['credit_grant_id'] }, { fields: ['source'] }],
122
+ indexes: [
123
+ { fields: ['customer_id'] },
124
+ { fields: ['credit_grant_id'] },
125
+ { fields: ['source'] },
126
+ { fields: ['transfer_status'] },
127
+ ],
111
128
  hooks: {
112
129
  afterCreate: (model: CreditTransaction, options) =>
113
130
  createEvent('CreditTransaction', 'customer.credit_transaction.created', model, options).catch(console.error),
@@ -305,9 +305,10 @@ export type TCreditTransactionExpanded = TCreditTransaction & {
305
305
  customer: TCustomer;
306
306
  paymentCurrency: TPaymentCurrency;
307
307
  paymentMethod?: TPaymentMethod;
308
- creditGrant: TCreditGrant;
308
+ creditGrant: TCreditGrant & { paymentCurrency?: TPaymentCurrency };
309
309
  meter: TMeter;
310
310
  subscription: TSubscription;
311
+ meterEvent?: TMeterEvent;
311
312
  };
312
313
 
313
314
  export type TMeterEventExpanded = TMeterEvent & {
@@ -46,6 +46,7 @@ export class PaymentCurrency extends Model<InferAttributes<PaymentCurrency>, Inf
46
46
  declare vault_config?: VaultConfig;
47
47
  declare type: LiteralUnion<'standard' | 'credit', string>;
48
48
  declare recharge_config?: RechargeConfig;
49
+ declare token_config?: Record<string, any> | null;
49
50
 
50
51
  public static readonly GENESIS_ATTRIBUTES = {
51
52
  id: {
@@ -144,6 +145,10 @@ export class PaymentCurrency extends Model<InferAttributes<PaymentCurrency>, Inf
144
145
  type: DataTypes.JSON,
145
146
  allowNull: true,
146
147
  },
148
+ token_config: {
149
+ type: DataTypes.JSON,
150
+ allowNull: true,
151
+ },
147
152
  },
148
153
  {
149
154
  sequelize,
@@ -210,7 +215,12 @@ export class PaymentCurrency extends Model<InferAttributes<PaymentCurrency>, Inf
210
215
  );
211
216
  return count > 0;
212
217
  }
213
- public static async createForMeter(meter: any, paymentMethodId: string) {
218
+ public static async createForMeter(
219
+ meter: any,
220
+ paymentMethodId: string,
221
+ tokenConfig?: Record<string, any>,
222
+ options?: { decimal?: number }
223
+ ) {
214
224
  const existingCurrency = await this.findOne({
215
225
  where: {
216
226
  type: 'credit',
@@ -222,6 +232,8 @@ export class PaymentCurrency extends Model<InferAttributes<PaymentCurrency>, Inf
222
232
  if (existingCurrency) {
223
233
  return existingCurrency;
224
234
  }
235
+
236
+ const decimal = options?.decimal ?? 10;
225
237
  const currency = await this.create({
226
238
  type: 'credit',
227
239
  payment_method_id: paymentMethodId,
@@ -229,8 +241,8 @@ export class PaymentCurrency extends Model<InferAttributes<PaymentCurrency>, Inf
229
241
  description: `Credit for ${meter.unit}`,
230
242
  symbol: meter.unit,
231
243
  logo: getUrl('/methods/arcblock.png'), // 默认credit图标
232
- decimal: 2,
233
- maximum_precision: 2,
244
+ decimal,
245
+ maximum_precision: decimal,
234
246
  minimum_payment_amount: '1',
235
247
  maximum_payment_amount: '100000000000',
236
248
  active: true,
@@ -242,13 +254,28 @@ export class PaymentCurrency extends Model<InferAttributes<PaymentCurrency>, Inf
242
254
  meter_event_name: meter.event_name,
243
255
  created_by_meter: true,
244
256
  },
257
+ token_config: tokenConfig,
245
258
  });
246
259
 
247
260
  return currency;
248
261
  }
249
262
 
250
263
  public isCredit(): boolean {
251
- return this.type === 'credit';
264
+ return PaymentCurrency.isCredit(this);
265
+ }
266
+
267
+ public isOnChainCredit(): boolean {
268
+ return PaymentCurrency.isOnChainCredit(this);
269
+ }
270
+
271
+ // Static version that works with plain objects
272
+ public static isCredit(currency: { type?: string | null }): boolean {
273
+ return currency.type === 'credit';
274
+ }
275
+
276
+ // Static version that works with plain objects
277
+ public static isOnChainCredit(currency: { type?: string | null; token_config?: any }): boolean {
278
+ return PaymentCurrency.isCredit(currency) && !!currency.token_config;
252
279
  }
253
280
  }
254
281
 
@@ -32,7 +32,11 @@ export class Refund extends Model<InferAttributes<Refund>, InferCreationAttribut
32
32
 
33
33
  declare metadata?: Record<string, any>;
34
34
 
35
- declare status: LiteralUnion<'pending' | 'requires_action' | 'failed' | 'canceled' | 'succeeded', string>;
35
+ // processing: refund transfer succeeded, waiting for credit burn (for onchain credit only)
36
+ declare status: LiteralUnion<
37
+ 'pending' | 'requires_action' | 'processing' | 'failed' | 'canceled' | 'succeeded',
38
+ string
39
+ >;
36
40
  declare reason?: LiteralUnion<
37
41
  'duplicate' | 'requested_by_customer' | 'requested_by_admin' | 'fraudulent' | 'expired_uncaptured_charge',
38
42
  string
@@ -199,7 +203,13 @@ export class Refund extends Model<InferAttributes<Refund>, InferCreationAttribut
199
203
  afterCreate: (model: Refund, options) =>
200
204
  createEvent('Refund', 'refund.created', model, options).catch(console.error),
201
205
  afterUpdate: (model: Refund, options) =>
202
- createStatusEvent('Refund', 'refund', { canceled: 'canceled', succeeded: 'succeeded' }, model, options),
206
+ createStatusEvent(
207
+ 'Refund',
208
+ 'refund',
209
+ { canceled: 'canceled', processing: 'processing', succeeded: 'succeeded' },
210
+ model,
211
+ options
212
+ ),
203
213
  afterDestroy: (model: Refund, options) =>
204
214
  createEvent('Refund', 'refund.deleted', model, options).catch(console.error),
205
215
  },