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.
- package/api/src/index.ts +4 -0
- package/api/src/integrations/arcblock/token.ts +599 -0
- package/api/src/libs/credit-grant.ts +7 -6
- package/api/src/queues/credit-consume.ts +29 -4
- package/api/src/queues/credit-grant.ts +245 -50
- package/api/src/queues/credit-reconciliation.ts +253 -0
- package/api/src/queues/refund.ts +263 -30
- package/api/src/queues/token-transfer.ts +331 -0
- package/api/src/routes/checkout-sessions.ts +1 -1
- package/api/src/routes/credit-grants.ts +27 -7
- package/api/src/routes/credit-tokens.ts +38 -0
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/meters.ts +32 -10
- package/api/src/routes/payment-currencies.ts +103 -0
- package/api/src/routes/products.ts +2 -2
- package/api/src/routes/settings.ts +4 -3
- package/api/src/store/migrations/20251120-add-token-config-to-currencies.ts +20 -0
- package/api/src/store/migrations/20251204-add-chain-fields.ts +74 -0
- package/api/src/store/models/credit-grant.ts +47 -9
- package/api/src/store/models/credit-transaction.ts +18 -1
- package/api/src/store/models/payment-currency.ts +31 -4
- package/api/src/store/models/refund.ts +12 -2
- package/api/src/store/models/types.ts +48 -0
- package/api/third.d.ts +2 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/components/customer/credit-overview.tsx +1 -1
- package/src/components/meter/form.tsx +191 -18
- package/src/components/price/form.tsx +49 -37
- package/src/locales/en.tsx +24 -0
- package/src/locales/zh.tsx +26 -0
- package/src/pages/admin/billing/meters/create.tsx +42 -13
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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;
|
package/api/src/routes/index.ts
CHANGED
|
@@ -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);
|
package/api/src/routes/meters.ts
CHANGED
|
@@ -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
|
|
91
|
-
|
|
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
|
+
};
|