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.
- 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/libs/util.ts +34 -0
- 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 +94 -29
- package/api/src/routes/credit-grants.ts +35 -9
- package/api/src/routes/credit-tokens.ts +38 -0
- package/api/src/routes/credit-transactions.ts +20 -3
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/meter-events.ts +4 -0
- package/api/src/routes/meters.ts +32 -10
- package/api/src/routes/payment-currencies.ts +103 -0
- package/api/src/routes/payment-links.ts +3 -1
- 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/migrations/20251211-optimize-slow-queries.ts +33 -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/index.ts +2 -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/src/store/sequelize.ts +1 -0
- package/api/third.d.ts +2 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/app.tsx +10 -0
- package/src/components/customer/credit-overview.tsx +19 -3
- package/src/components/meter/form.tsx +191 -18
- package/src/components/price/form.tsx +49 -37
- package/src/locales/en.tsx +25 -1
- package/src/locales/zh.tsx +27 -1
- package/src/pages/admin/billing/meters/create.tsx +42 -13
- package/src/pages/admin/billing/meters/detail.tsx +56 -5
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +13 -0
- package/src/pages/admin/customers/customers/credit-transaction/detail.tsx +324 -0
- package/src/pages/admin/customers/index.tsx +5 -0
- package/src/pages/customer/credit-grant/detail.tsx +14 -1
- package/src/pages/customer/credit-transaction/detail.tsx +289 -0
- package/src/pages/customer/invoice/detail.tsx +1 -1
- package/src/pages/customer/recharge/subscription.tsx +1 -1
- 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
|
-
|
|
1194
|
-
await doc.update(
|
|
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
|
-
|
|
1538
|
-
|
|
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
|
-
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|