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
@@ -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
+ }
@@ -54,6 +54,7 @@ import {
54
54
  getConnectQueryParam,
55
55
  getDataObjectFromQuery,
56
56
  getUserOrAppInfo,
57
+ hasObjectChanged,
57
58
  isUserInBlocklist,
58
59
  } from '../libs/util';
59
60
  import {
@@ -387,7 +388,13 @@ export async function calculateAndUpdateAmount(
387
388
 
388
389
  logger.info('Amount calculated', {
389
390
  checkoutSessionId: checkoutSession.id,
390
- amount,
391
+ amount: {
392
+ subtotal: amount.subtotal,
393
+ total: amount.total,
394
+ discount: amount.discount,
395
+ shipping: amount.shipping,
396
+ tax: amount.tax,
397
+ },
391
398
  });
392
399
 
393
400
  if (checkoutSession.mode === 'payment' && new BN(amount.total || '0').lt(new BN('0'))) {
@@ -1190,8 +1197,11 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
1190
1197
  item.upsell_price_id = item.price.upsell.upsells_to_id;
1191
1198
  }
1192
1199
  });
1193
- await doc.update({ line_items: updatedItems });
1194
- await doc.update(await getCheckoutSessionAmounts(doc));
1200
+ const amounts = await getCheckoutSessionAmounts(doc);
1201
+ await doc.update({
1202
+ line_items: updatedItems,
1203
+ ...amounts,
1204
+ });
1195
1205
  doc.line_items = await Price.expand(updatedItems, { upsell: true });
1196
1206
  }
1197
1207
 
@@ -1534,29 +1544,43 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
1534
1544
  updates.invoice_prefix = Customer.getInvoicePrefix();
1535
1545
  }
1536
1546
 
1537
- await customer.update(updates);
1538
- try {
1539
- await blocklet.updateUserAddress(
1540
- {
1541
- did: customer.did,
1542
- address: Customer.formatAddressFromCustomer(customer),
1543
- // @ts-ignore
1544
- phone: customer.phone,
1545
- },
1546
- {
1547
- headers: {
1548
- cookie: req.headers.cookie || '',
1549
- },
1550
- }
1551
- );
1552
- logger.info('updateUserAddress success', {
1547
+ if (!hasObjectChanged(updates, customer, { deepCompare: ['address'] })) {
1548
+ logger.info('customer update skipped (no changes)', {
1553
1549
  did: customer.did,
1554
1550
  });
1555
- } catch (err) {
1556
- logger.error('updateUserAddress failed', {
1557
- error: err,
1558
- customerId: customer.id,
1551
+ } else {
1552
+ await customer.update(updates);
1553
+ logger.info('customer updated', {
1554
+ did: customer.did,
1559
1555
  });
1556
+
1557
+ try {
1558
+ // eslint-disable-next-line no-console
1559
+ console.time('updateUserAddress');
1560
+ await blocklet.updateUserAddress(
1561
+ {
1562
+ did: customer.did,
1563
+ address: Customer.formatAddressFromCustomer(customer),
1564
+ // @ts-ignore
1565
+ phone: customer.phone,
1566
+ },
1567
+ {
1568
+ headers: {
1569
+ cookie: req.headers.cookie || '',
1570
+ },
1571
+ }
1572
+ );
1573
+ // eslint-disable-next-line no-console
1574
+ console.timeEnd('updateUserAddress');
1575
+ logger.info('updateUserAddress success', {
1576
+ did: customer.did,
1577
+ });
1578
+ } catch (err) {
1579
+ logger.error('updateUserAddress failed', {
1580
+ error: err,
1581
+ customerId: customer.id,
1582
+ });
1583
+ }
1560
1584
  }
1561
1585
  }
1562
1586
 
@@ -2036,7 +2060,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
2036
2060
  } catch (err) {
2037
2061
  logger.error('Error submitting checkout session', {
2038
2062
  sessionId: req.params.id,
2039
- error: err.message,
2063
+ error: err,
2040
2064
  stack: err.stack,
2041
2065
  });
2042
2066
  res.status(500).json({ code: err.code, error: err.message });
@@ -3027,10 +3051,27 @@ router.delete('/:id/remove-promotion', user, ensureCheckoutSessionOpen, async (r
3027
3051
  }
3028
3052
  });
3029
3053
 
3054
+ const amountSchema = Joi.object({
3055
+ amount: Joi.string()
3056
+ .pattern(/^\d+(\.\d+)?$/)
3057
+ .required()
3058
+ .messages({
3059
+ 'string.pattern.base': 'Amount must be a valid number',
3060
+ 'any.required': 'Amount is required',
3061
+ }),
3062
+ priceId: Joi.string().required(),
3063
+ });
3030
3064
  // change payment amount
3031
3065
  router.put('/:id/amount', ensureCheckoutSessionOpen, async (req, res) => {
3032
3066
  try {
3033
- const { amount, priceId } = req.body;
3067
+ const { error, value } = amountSchema.validate(req.body, {
3068
+ stripUnknown: true,
3069
+ });
3070
+ if (error) {
3071
+ return res.status(400).json({ error: error.message });
3072
+ }
3073
+
3074
+ const { amount, priceId } = value;
3034
3075
  const checkoutSession = req.doc as CheckoutSession;
3035
3076
  const items = await Price.expand(checkoutSession.line_items);
3036
3077
  const item = items.find((x) => x.price_id === priceId);
@@ -3090,7 +3131,7 @@ router.put('/:id/amount', ensureCheckoutSessionOpen, async (req, res) => {
3090
3131
  newItem.custom_amount = amount;
3091
3132
  }
3092
3133
  await checkoutSession.update({ line_items: newItems.map((x) => omit(x, ['price'])) as LineItem[] });
3093
- logger.info('CheckoutSession updated on amount', { id: req.params.id, ...req.body, newItem });
3134
+ logger.info('CheckoutSession updated on amount', { id: req.params.id, amount, priceId });
3094
3135
 
3095
3136
  // recalculate amount
3096
3137
  await checkoutSession.update(await getCheckoutSessionAmounts(checkoutSession));
@@ -3185,11 +3226,35 @@ router.get('/', auth, async (req, res) => {
3185
3226
  include: [],
3186
3227
  });
3187
3228
 
3188
- const condition = { where: { livemode: !!req.livemode } };
3189
- const products = (await Product.findAll(condition)).map((x) => x.toJSON());
3190
- const prices = (await Price.findAll(condition)).map((x) => x.toJSON());
3191
3229
  const docs = list.map((x) => x.toJSON());
3192
3230
 
3231
+ const productIds = new Set<string>();
3232
+ const priceIds = new Set<string>();
3233
+ docs.forEach((x) => {
3234
+ x.line_items?.forEach((item: any) => {
3235
+ if (item.price_id) {
3236
+ priceIds.add(item.price_id);
3237
+ }
3238
+ if (item.product_id) {
3239
+ productIds.add(item.product_id);
3240
+ }
3241
+ });
3242
+ });
3243
+
3244
+ const condition = { where: { livemode: !!req.livemode } };
3245
+ const products =
3246
+ productIds.size > 0
3247
+ ? (await Product.findAll({ ...condition, where: { ...condition.where, id: Array.from(productIds) } })).map(
3248
+ (x) => x.toJSON()
3249
+ )
3250
+ : [];
3251
+ const prices =
3252
+ priceIds.size > 0
3253
+ ? (await Price.findAll({ ...condition, where: { ...condition.where, id: Array.from(priceIds) } })).map((x) =>
3254
+ x.toJSON()
3255
+ )
3256
+ : [];
3257
+
3193
3258
  docs.forEach((x) => {
3194
3259
  // @ts-ignore
3195
3260
  expandLineItems(x.line_items, products, prices);
@@ -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';
@@ -354,19 +355,25 @@ router.get('/verify-availability', authMine, async (req, res) => {
354
355
  });
355
356
 
356
357
  router.get('/:id', authPortal, async (req, res) => {
357
- const creditGrant = await CreditGrant.findByPk(req.params.id, {
358
+ const creditGrant = (await CreditGrant.findByPk(req.params.id, {
358
359
  include: [
359
360
  { model: Customer, as: 'customer' },
360
361
  { model: PaymentCurrency, as: 'paymentCurrency' },
361
362
  ],
362
- });
363
+ })) as CreditGrant & { paymentCurrency?: PaymentCurrency };
363
364
  if (!creditGrant) {
364
365
  return res.status(404).json({ error: `Credit grant ${req.params.id} not found` });
365
366
  }
367
+
368
+ let paymentMethod = null;
369
+ if (creditGrant.paymentCurrency) {
370
+ paymentMethod = await PaymentMethod.findByPk(creditGrant.paymentCurrency.payment_method_id);
371
+ }
366
372
  const expandedPrices = await expandScopePrices(creditGrant);
367
373
  return res.json({
368
374
  ...creditGrant.toJSON(),
369
375
  items: expandedPrices,
376
+ paymentMethod,
370
377
  });
371
378
  });
372
379
 
@@ -455,8 +462,9 @@ router.post('/', auth, async (req, res) => {
455
462
  }
456
463
  });
457
464
 
458
- const updateMetadataSchema = Joi.object({
465
+ const updateSchema = Joi.object({
459
466
  metadata: MetadataSchema,
467
+ expired: Joi.boolean().optional(),
460
468
  });
461
469
 
462
470
  router.put('/:id', auth, async (req, res) => {
@@ -464,17 +472,35 @@ router.put('/:id', auth, async (req, res) => {
464
472
  if (!creditGrant) {
465
473
  return res.status(404).json({ error: `Credit grant ${req.params.id} not found` });
466
474
  }
467
- const { error } = updateMetadataSchema.validate(pick(req.body, 'metadata'));
475
+
476
+ const { error, value } = updateSchema.validate(pick(req.body, ['metadata', 'expired']), { stripUnknown: true });
468
477
  if (error) {
469
478
  return res.status(400).json({ error: `Credit grant update request invalid: ${error.message}` });
470
479
  }
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}` });
480
+
481
+ // Handle metadata update
482
+ if (value.metadata !== undefined) {
483
+ await creditGrant.update({ metadata: formatMetadata(value.metadata) });
484
+ }
485
+
486
+ // Handle expire operation first (before metadata update)
487
+ if (value.expired === true) {
488
+ // Only pending or granted grants can be expired
489
+ if (!['pending', 'granted'].includes(creditGrant.status)) {
490
+ return res.status(400).json({
491
+ error: `Cannot expire credit grant with status '${creditGrant.status}'. Only 'pending' or 'granted' grants can be expired.`,
492
+ });
475
493
  }
494
+
495
+ await expireGrant(creditGrant);
496
+
497
+ logger.info('Credit grant manually expired', {
498
+ creditGrantId: req.params.id,
499
+ previousStatus: creditGrant.status,
500
+ requestedBy: req.user?.did,
501
+ });
476
502
  }
477
- await creditGrant.update({ metadata: formatMetadata(req.body.metadata) });
503
+
478
504
  return res.json({ success: true });
479
505
  });
480
506
 
@@ -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;
@@ -14,6 +14,8 @@ import {
14
14
  MeterEvent,
15
15
  Subscription,
16
16
  PaymentCurrency,
17
+ PaymentMethod,
18
+ TCreditTransactionExpanded,
17
19
  } from '../store/models';
18
20
 
19
21
  const router = Router();
@@ -303,7 +305,7 @@ router.get('/summary', authMine, async (req, res) => {
303
305
 
304
306
  router.get('/:id', authPortal, async (req, res) => {
305
307
  try {
306
- const transaction = await CreditTransaction.findByPk(req.params.id, {
308
+ const transaction = (await CreditTransaction.findByPk(req.params.id, {
307
309
  include: [
308
310
  {
309
311
  model: Customer,
@@ -339,14 +341,29 @@ router.get('/:id', authPortal, async (req, res) => {
339
341
  ],
340
342
  required: false,
341
343
  },
344
+ {
345
+ model: MeterEvent,
346
+ as: 'meterEvent',
347
+ attributes: ['id', 'source_data'],
348
+ required: false,
349
+ },
342
350
  ],
343
- });
351
+ })) as CreditTransaction &
352
+ TCreditTransactionExpanded & { creditGrant?: CreditGrant & { paymentCurrency?: PaymentCurrency } };
344
353
 
345
354
  if (!transaction) {
346
355
  return res.status(404).json({ error: 'Credit transaction not found' });
347
356
  }
348
357
 
349
- return res.json(transaction);
358
+ let paymentMethod = null;
359
+ if (transaction.creditGrant?.paymentCurrency?.payment_method_id) {
360
+ paymentMethod = await PaymentMethod.findByPk(transaction.creditGrant.paymentCurrency.payment_method_id);
361
+ }
362
+
363
+ return res.json({
364
+ ...transaction.toJSON(),
365
+ paymentMethod,
366
+ });
350
367
  } catch (err) {
351
368
  logger.error('get credit transaction failed', err);
352
369
  return res.status(400).json({ error: err.message });