payment-kit 1.22.32 → 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 (33) 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/meters.ts +32 -10
  14. package/api/src/routes/payment-currencies.ts +103 -0
  15. package/api/src/routes/products.ts +2 -2
  16. package/api/src/routes/settings.ts +4 -3
  17. package/api/src/store/migrations/20251120-add-token-config-to-currencies.ts +20 -0
  18. package/api/src/store/migrations/20251204-add-chain-fields.ts +74 -0
  19. package/api/src/store/models/credit-grant.ts +47 -9
  20. package/api/src/store/models/credit-transaction.ts +18 -1
  21. package/api/src/store/models/payment-currency.ts +31 -4
  22. package/api/src/store/models/refund.ts +12 -2
  23. package/api/src/store/models/types.ts +48 -0
  24. package/api/third.d.ts +2 -0
  25. package/blocklet.yml +1 -1
  26. package/package.json +7 -6
  27. package/src/components/customer/credit-overview.tsx +1 -1
  28. package/src/components/meter/form.tsx +191 -18
  29. package/src/components/price/form.tsx +49 -37
  30. package/src/locales/en.tsx +24 -0
  31. package/src/locales/zh.tsx +26 -0
  32. package/src/pages/admin/billing/meters/create.tsx +42 -13
  33. package/src/pages/admin/billing/meters/detail.tsx +56 -5
@@ -0,0 +1,331 @@
1
+ import { BN } from '@ocap/util';
2
+ import logger from '../libs/logger';
3
+ import createQueue from '../libs/queue';
4
+ import { transferTokenFromCustomer, getCustomerTokenBalance } from '../integrations/arcblock/token';
5
+ import { CreditTransaction, CreditGrant, Customer, MeterEvent, PaymentCurrency, Subscription } from '../store/models';
6
+
7
+ type TokenTransferJob = {
8
+ creditTransactionId: string;
9
+ creditGrantId: string;
10
+ customerDid: string;
11
+ amount: string;
12
+ paymentCurrencyId: string;
13
+ meterEventId: string;
14
+ subscriptionId?: string;
15
+ };
16
+
17
+ type ValidationResult =
18
+ | { valid: false }
19
+ | {
20
+ valid: true;
21
+ creditTransaction: CreditTransaction;
22
+ creditGrant: CreditGrant;
23
+ customer: Customer;
24
+ paymentCurrency: PaymentCurrency;
25
+ meterEvent: MeterEvent;
26
+ subscription?: Subscription;
27
+ };
28
+
29
+ /**
30
+ * Validate transfer job and fetch required data
31
+ */
32
+ async function validateAndFetchData(job: TokenTransferJob): Promise<ValidationResult> {
33
+ const creditTransaction = await CreditTransaction.findByPk(job.creditTransactionId);
34
+ if (!creditTransaction) {
35
+ logger.warn('CreditTransaction not found', { creditTransactionId: job.creditTransactionId });
36
+ return { valid: false };
37
+ }
38
+
39
+ // Check if already transferred
40
+ if (creditTransaction.transfer_status === 'completed') {
41
+ logger.info('Token transfer already completed', { creditTransactionId: job.creditTransactionId });
42
+ return { valid: false };
43
+ }
44
+
45
+ const creditGrant = await CreditGrant.findByPk(job.creditGrantId);
46
+ if (!creditGrant) {
47
+ logger.warn('CreditGrant not found', { creditGrantId: job.creditGrantId });
48
+ return { valid: false };
49
+ }
50
+
51
+ const customer = await Customer.findByPkOrDid(job.customerDid);
52
+ if (!customer) {
53
+ logger.warn('Customer not found', { customerDid: job.customerDid });
54
+ return { valid: false };
55
+ }
56
+
57
+ const paymentCurrency = await PaymentCurrency.findByPk(job.paymentCurrencyId);
58
+ if (!paymentCurrency) {
59
+ logger.warn('PaymentCurrency not found', { paymentCurrencyId: job.paymentCurrencyId });
60
+ return { valid: false };
61
+ }
62
+
63
+ const meterEvent = await MeterEvent.findByPk(job.meterEventId);
64
+ if (!meterEvent) {
65
+ logger.warn('MeterEvent not found', { meterEventId: job.meterEventId });
66
+ return { valid: false };
67
+ }
68
+
69
+ let subscription: Subscription | undefined;
70
+ if (job.subscriptionId) {
71
+ subscription = (await Subscription.findByPk(job.subscriptionId)) || undefined;
72
+ }
73
+
74
+ return {
75
+ valid: true,
76
+ creditTransaction,
77
+ creditGrant,
78
+ customer,
79
+ paymentCurrency,
80
+ meterEvent,
81
+ subscription,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Handle token transfer job
87
+ */
88
+ export async function handleTokenTransfer(job: TokenTransferJob): Promise<void> {
89
+ logger.info('Starting token transfer job', {
90
+ creditTransactionId: job.creditTransactionId,
91
+ creditGrantId: job.creditGrantId,
92
+ customerDid: job.customerDid,
93
+ amount: job.amount,
94
+ });
95
+
96
+ const validation = await validateAndFetchData(job);
97
+ if (!validation.valid) {
98
+ logger.warn('Token transfer validation failed, skipping', { job });
99
+ return;
100
+ }
101
+
102
+ const { creditTransaction, creditGrant, customer, paymentCurrency, meterEvent } = validation;
103
+
104
+ let txHash: string | null = null;
105
+ // Record partial transfer details when chain balance is insufficient
106
+ let transferResult: { expected: string; actual: string } | null = null;
107
+
108
+ try {
109
+ // Attempt token transfer
110
+ txHash = await transferTokenFromCustomer({
111
+ paymentCurrency,
112
+ customerDid: customer.did,
113
+ amount: job.amount,
114
+ data: {
115
+ reason: 'credit_consumption',
116
+ creditGrantId: creditGrant.id,
117
+ meterEventId: meterEvent.id,
118
+ },
119
+ });
120
+
121
+ logger.info('Token transfer completed successfully', {
122
+ creditTransactionId: creditTransaction.id,
123
+ txHash,
124
+ from: customer.did,
125
+ amount: job.amount,
126
+ currency: paymentCurrency.symbol,
127
+ });
128
+ } catch (error: any) {
129
+ const isInsufficientBalanceError =
130
+ error?.message?.includes('does not have enough token') || error?.message?.includes('INSUFFICIENT_TOKEN_BALANCE');
131
+
132
+ if (!isInsufficientBalanceError) {
133
+ logger.error('Token transfer failed', {
134
+ error,
135
+ creditTransactionId: creditTransaction.id,
136
+ customerDid: customer.did,
137
+ amount: job.amount,
138
+ });
139
+ throw error;
140
+ }
141
+
142
+ // Handle insufficient balance: transfer whatever we can and mark as completed
143
+ logger.warn('Insufficient chain balance detected', {
144
+ creditTransactionId: creditTransaction.id,
145
+ requestedAmount: job.amount,
146
+ error,
147
+ });
148
+
149
+ // Get actual chain balance
150
+ const chainBalance = await getCustomerTokenBalance(customer.did, paymentCurrency);
151
+ const chainBalanceBN = new BN(chainBalance);
152
+ const expectBalanceBN = new BN(job.amount);
153
+ const chainDebt = expectBalanceBN.sub(chainBalanceBN);
154
+ const transferAmount = BN.min(chainBalanceBN, expectBalanceBN);
155
+
156
+ // Record transfer result for metadata (only when partial)
157
+ if (chainDebt.gt(new BN(0))) {
158
+ transferResult = {
159
+ expected: job.amount,
160
+ actual: transferAmount.toString(),
161
+ };
162
+ }
163
+
164
+ if (chainBalanceBN.lt(expectBalanceBN)) {
165
+ logger.error('CRITICAL: Chain balance is less than requested amount', {
166
+ creditTransactionId: creditTransaction.id,
167
+ chainBalance: chainBalance.toString(),
168
+ requestedAmount: job.amount,
169
+ chainDebt: chainDebt.toString(),
170
+ });
171
+ }
172
+
173
+ // Transfer tokens if there is any balance
174
+ if (chainBalanceBN.gt(new BN(0))) {
175
+ txHash = await transferTokenFromCustomer({
176
+ paymentCurrency,
177
+ customerDid: customer.did,
178
+ amount: transferAmount.toString(),
179
+ data: {
180
+ reason: 'credit_consumption',
181
+ creditGrantId: creditGrant.id,
182
+ meterEventId: meterEvent.id,
183
+ },
184
+ });
185
+
186
+ logger.warn('Partial token transfer completed - chain balance insufficient', {
187
+ creditTransactionId: creditTransaction.id,
188
+ customerDid: customer.did,
189
+ currencyId: paymentCurrency.id,
190
+ actualBalance: chainBalanceBN.toString(),
191
+ expectedAmount: expectBalanceBN.toString(),
192
+ transferredAmount: transferAmount.toString(),
193
+ chainDebt: chainDebt.toString(),
194
+ });
195
+ } else {
196
+ logger.error('Zero chain balance - no tokens transferred', {
197
+ creditTransactionId: creditTransaction.id,
198
+ customerDid: customer.did,
199
+ currencyId: paymentCurrency.id,
200
+ expectedAmount: expectBalanceBN.toString(),
201
+ });
202
+ }
203
+ }
204
+
205
+ // Update transaction with result
206
+ await creditTransaction.update({
207
+ transfer_status: 'completed',
208
+ transfer_hash: txHash ?? undefined,
209
+ ...(transferResult && {
210
+ metadata: {
211
+ ...(creditTransaction.metadata || {}),
212
+ transfer_result: transferResult,
213
+ },
214
+ }),
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Token transfer queue
220
+ */
221
+ export const tokenTransferQueue = createQueue<TokenTransferJob>({
222
+ name: 'token-transfer',
223
+ onJob: handleTokenTransfer,
224
+ options: {
225
+ concurrency: 5,
226
+ maxRetries: 3,
227
+ retryDelay: 5000,
228
+ },
229
+ });
230
+
231
+ tokenTransferQueue.on('finished', ({ id, job }) => {
232
+ logger.debug('Token transfer job finished', { id, creditTransactionId: job.creditTransactionId });
233
+ });
234
+
235
+ tokenTransferQueue.on('failed', async ({ id, job, error }) => {
236
+ logger.error('Token transfer job failed after all retries', { id, job, error: error.message });
237
+
238
+ const creditTransaction = await CreditTransaction.findByPk(job.creditTransactionId);
239
+
240
+ if (creditTransaction && creditTransaction.transfer_status !== 'completed') {
241
+ await creditTransaction.update({
242
+ transfer_status: 'failed',
243
+ });
244
+ }
245
+ });
246
+
247
+ tokenTransferQueue.on('retry', ({ id, job }) => {
248
+ logger.info('Token transfer job retry scheduled', {
249
+ id,
250
+ creditTransactionId: job.creditTransactionId,
251
+ });
252
+ });
253
+
254
+ /**
255
+ * Add token transfer job to queue
256
+ */
257
+ export async function addTokenTransferJob(job: TokenTransferJob): Promise<void> {
258
+ const jobId = `token-transfer-${job.creditTransactionId}`;
259
+
260
+ try {
261
+ // Check if job already exists
262
+ const existingJob = await tokenTransferQueue.get(jobId);
263
+ if (existingJob) {
264
+ logger.debug('Token transfer job already exists', { jobId, creditTransactionId: job.creditTransactionId });
265
+ return;
266
+ }
267
+
268
+ // Add job to queue
269
+ await tokenTransferQueue.push({ id: jobId, job });
270
+
271
+ logger.info('Token transfer job added to queue', {
272
+ jobId,
273
+ creditTransactionId: job.creditTransactionId,
274
+ customerDid: job.customerDid,
275
+ amount: job.amount,
276
+ });
277
+ } catch (error: any) {
278
+ logger.error('Failed to add token transfer job', {
279
+ jobId,
280
+ creditTransactionId: job.creditTransactionId,
281
+ error,
282
+ });
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Start token transfer queue
288
+ */
289
+ export async function startTokenTransferQueue(): Promise<void> {
290
+ try {
291
+ logger.info('Token transfer queue started');
292
+
293
+ // Process any pending transfers on startup
294
+ const pendingTransactions = await CreditTransaction.findAll({
295
+ where: {
296
+ transfer_status: 'pending',
297
+ },
298
+ limit: 100,
299
+ order: [['created_at', 'DESC']],
300
+ });
301
+
302
+ logger.info('Found pending token transfers to process', { count: pendingTransactions.length });
303
+
304
+ // Process in batches to add jobs
305
+ await Promise.all(
306
+ pendingTransactions.map(async (transaction) => {
307
+ try {
308
+ const customer = (await Customer.findByPk(transaction.customer_id))!;
309
+ const creditGrant = (await CreditGrant.findByPk(transaction.credit_grant_id))!;
310
+
311
+ await addTokenTransferJob({
312
+ creditTransactionId: transaction.id,
313
+ creditGrantId: transaction.credit_grant_id,
314
+ customerDid: customer.did,
315
+ amount: transaction.credit_amount,
316
+ paymentCurrencyId: creditGrant.currency_id,
317
+ meterEventId: transaction.source!,
318
+ subscriptionId: transaction.subscription_id || undefined,
319
+ });
320
+ } catch (error: any) {
321
+ logger.error('Failed to add pending transfer job', {
322
+ transactionId: transaction.id,
323
+ error: error.message,
324
+ });
325
+ }
326
+ })
327
+ );
328
+ } catch (error: any) {
329
+ logger.error('Failed to start token transfer queue', { error: error.message });
330
+ }
331
+ }
@@ -2036,7 +2036,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
2036
2036
  } catch (err) {
2037
2037
  logger.error('Error submitting checkout session', {
2038
2038
  sessionId: req.params.id,
2039
- error: err.message,
2039
+ error: err,
2040
2040
  stack: err.stack,
2041
2041
  });
2042
2042
  res.status(500).json({ code: err.code, error: err.message });
@@ -19,6 +19,7 @@ import {
19
19
  Subscription,
20
20
  } from '../store/models';
21
21
  import { createCreditGrant } from '../libs/credit-grant';
22
+ import { expireGrant } from '../queues/credit-grant';
22
23
  import { getMeterPriceIdsFromSubscription } from '../libs/subscription';
23
24
  import { blocklet } from '../libs/auth';
24
25
  import { formatMetadata } from '../libs/util';
@@ -455,8 +456,9 @@ router.post('/', auth, async (req, res) => {
455
456
  }
456
457
  });
457
458
 
458
- const updateMetadataSchema = Joi.object({
459
+ const updateSchema = Joi.object({
459
460
  metadata: MetadataSchema,
461
+ expired: Joi.boolean().optional(),
460
462
  });
461
463
 
462
464
  router.put('/:id', auth, async (req, res) => {
@@ -464,17 +466,35 @@ router.put('/:id', auth, async (req, res) => {
464
466
  if (!creditGrant) {
465
467
  return res.status(404).json({ error: `Credit grant ${req.params.id} not found` });
466
468
  }
467
- const { error } = updateMetadataSchema.validate(pick(req.body, 'metadata'));
469
+
470
+ const { error, value } = updateSchema.validate(pick(req.body, ['metadata', 'expired']), { stripUnknown: true });
468
471
  if (error) {
469
472
  return res.status(400).json({ error: `Credit grant update request invalid: ${error.message}` });
470
473
  }
471
- if (req.body.metadata) {
472
- const { error: metadataError } = MetadataSchema.validate(req.body.metadata);
473
- if (metadataError) {
474
- return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
474
+
475
+ // Handle metadata update
476
+ if (value.metadata !== undefined) {
477
+ await creditGrant.update({ metadata: formatMetadata(value.metadata) });
478
+ }
479
+
480
+ // Handle expire operation first (before metadata update)
481
+ if (value.expired === true) {
482
+ // Only pending or granted grants can be expired
483
+ if (!['pending', 'granted'].includes(creditGrant.status)) {
484
+ return res.status(400).json({
485
+ error: `Cannot expire credit grant with status '${creditGrant.status}'. Only 'pending' or 'granted' grants can be expired.`,
486
+ });
475
487
  }
488
+
489
+ await expireGrant(creditGrant);
490
+
491
+ logger.info('Credit grant manually expired', {
492
+ creditGrantId: req.params.id,
493
+ previousStatus: creditGrant.status,
494
+ requestedBy: req.user?.did,
495
+ });
476
496
  }
477
- await creditGrant.update({ metadata: formatMetadata(req.body.metadata) });
497
+
478
498
  return res.json({ success: true });
479
499
  });
480
500
 
@@ -0,0 +1,38 @@
1
+ import { Router } from 'express';
2
+ import Joi from 'joi';
3
+
4
+ import logger from '../libs/logger';
5
+ import { authenticate } from '../libs/security';
6
+ import { createToken } from '../integrations/arcblock/token';
7
+
8
+ const router = Router();
9
+ const auth = authenticate({ component: true, roles: ['owner', 'admin'] });
10
+
11
+ const createTokenSchema = Joi.object({
12
+ name: Joi.string().max(64).required(),
13
+ symbol: Joi.string().max(16).required(),
14
+ decimal: Joi.number().integer().min(2).max(18).default(10),
15
+ }).unknown(true);
16
+
17
+ router.post('/', auth, async (req, res) => {
18
+ try {
19
+ const { error } = createTokenSchema.validate(req.body);
20
+ if (error) {
21
+ return res.status(400).json({ error: `Token create request invalid: ${error.message}` });
22
+ }
23
+
24
+ const tokenFactoryState = await createToken({
25
+ name: req.body.name,
26
+ symbol: req.body.symbol,
27
+ decimal: req.body.decimal,
28
+ livemode: !!req.livemode,
29
+ });
30
+
31
+ return res.json(tokenFactoryState);
32
+ } catch (err) {
33
+ logger.error('create credit token failed', { error: err?.message, request: req.body });
34
+ return res.status(400).json({ error: err?.message });
35
+ }
36
+ });
37
+
38
+ export default router;
@@ -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);
@@ -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
 
@@ -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
+ };