payment-kit 1.18.12 → 1.18.14

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 (64) hide show
  1. package/api/src/index.ts +2 -0
  2. package/api/src/integrations/stripe/resource.ts +53 -11
  3. package/api/src/libs/auth.ts +14 -0
  4. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +5 -3
  5. package/api/src/libs/notification/template/subscription-canceled.ts +3 -3
  6. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +4 -3
  7. package/api/src/libs/notification/template/subscription-renew-failed.ts +5 -4
  8. package/api/src/libs/notification/template/subscription-renewed.ts +2 -1
  9. package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +3 -4
  10. package/api/src/libs/notification/template/subscription-succeeded.ts +2 -1
  11. package/api/src/libs/notification/template/subscription-upgraded.ts +6 -4
  12. package/api/src/libs/notification/template/subscription-will-canceled.ts +6 -3
  13. package/api/src/libs/notification/template/subscription-will-renew.ts +1 -1
  14. package/api/src/libs/payment.ts +77 -2
  15. package/api/src/libs/util.ts +8 -0
  16. package/api/src/queues/payment.ts +50 -1
  17. package/api/src/queues/payout.ts +297 -0
  18. package/api/src/routes/checkout-sessions.ts +2 -7
  19. package/api/src/routes/customers.ts +79 -5
  20. package/api/src/routes/payment-currencies.ts +117 -1
  21. package/api/src/routes/payment-methods.ts +19 -9
  22. package/api/src/routes/subscriptions.ts +15 -9
  23. package/api/src/store/migrations/20250305-vault-config.ts +21 -0
  24. package/api/src/store/models/invoice.ts +4 -2
  25. package/api/src/store/models/payment-currency.ts +14 -0
  26. package/api/src/store/models/payout.ts +21 -0
  27. package/api/src/store/models/types.ts +6 -0
  28. package/blocklet.yml +2 -2
  29. package/package.json +18 -18
  30. package/src/app.tsx +117 -121
  31. package/src/components/actions.tsx +32 -9
  32. package/src/components/copyable.tsx +2 -2
  33. package/src/components/customer/overdraft-protection.tsx +1 -0
  34. package/src/components/layout/admin.tsx +6 -0
  35. package/src/components/layout/user.tsx +38 -0
  36. package/src/components/metadata/editor.tsx +7 -1
  37. package/src/components/metadata/list.tsx +3 -0
  38. package/src/components/passport/assign.tsx +3 -0
  39. package/src/components/payment-link/rename.tsx +1 -0
  40. package/src/components/pricing-table/rename.tsx +1 -0
  41. package/src/components/product/add-price.tsx +1 -0
  42. package/src/components/product/edit-price.tsx +1 -0
  43. package/src/components/product/edit.tsx +1 -0
  44. package/src/components/subscription/actions/index.tsx +1 -0
  45. package/src/components/subscription/portal/actions.tsx +27 -5
  46. package/src/components/subscription/portal/list.tsx +24 -6
  47. package/src/components/subscription/status.tsx +2 -2
  48. package/src/libs/util.ts +15 -0
  49. package/src/locales/en.tsx +42 -0
  50. package/src/locales/zh.tsx +37 -0
  51. package/src/pages/admin/payments/payouts/detail.tsx +47 -38
  52. package/src/pages/admin/settings/index.tsx +3 -3
  53. package/src/pages/admin/settings/payment-methods/index.tsx +33 -1
  54. package/src/pages/admin/settings/vault-config/edit-form.tsx +253 -0
  55. package/src/pages/admin/settings/vault-config/index.tsx +352 -0
  56. package/src/pages/customer/index.tsx +247 -154
  57. package/src/pages/customer/invoice/detail.tsx +1 -1
  58. package/src/pages/customer/payout/detail.tsx +9 -2
  59. package/src/pages/customer/recharge.tsx +6 -2
  60. package/src/pages/customer/subscription/change-payment.tsx +1 -1
  61. package/src/pages/customer/subscription/change-plan.tsx +1 -1
  62. package/src/pages/customer/subscription/detail.tsx +8 -3
  63. package/src/pages/customer/subscription/embed.tsx +142 -84
  64. package/src/pages/integrations/donations/edit-form.tsx +0 -1
@@ -0,0 +1,297 @@
1
+ import dayjs from '../libs/dayjs';
2
+ import { events } from '../libs/event';
3
+ import logger from '../libs/logger';
4
+ import { getGasPayerExtra } from '../libs/payment';
5
+ import createQueue from '../libs/queue';
6
+ import { wallet, ethWallet } from '../libs/auth';
7
+ import { sendErc20ToUser } from '../integrations/ethereum/token';
8
+ import { PaymentMethod } from '../store/models/payment-method';
9
+ import { PaymentCurrency } from '../store/models/payment-currency';
10
+ import { Payout } from '../store/models/payout';
11
+ import { EVM_CHAIN_TYPES } from '../libs/constants';
12
+ import type { PaymentError } from '../store/models/types';
13
+ import { getNextRetry, MAX_RETRY_COUNT } from '../libs/util';
14
+
15
+ type PayoutJob = {
16
+ payoutId: string;
17
+ retryOnError?: boolean;
18
+ };
19
+
20
+ type ValidationResult =
21
+ | { valid: false }
22
+ | { valid: true; payout: Payout; paymentMethod: PaymentMethod; paymentCurrency: PaymentCurrency };
23
+
24
+ // Validate payout and fetch required data
25
+ async function validatePayoutAndFetchData(job: PayoutJob): Promise<ValidationResult> {
26
+ const payout = await Payout.findByPk(job.payoutId);
27
+ if (!payout) {
28
+ logger.warn('Payout not found', { id: job.payoutId });
29
+ return { valid: false };
30
+ }
31
+
32
+ if (payout.status !== 'pending') {
33
+ logger.warn('Payout status not expected', { id: payout.id, status: payout.status });
34
+ return { valid: false };
35
+ }
36
+
37
+ const paymentMethod = await PaymentMethod.findByPk(payout.payment_method_id);
38
+ if (!paymentMethod) {
39
+ logger.warn('PaymentMethod not found', { id: payout.payment_method_id });
40
+ return { valid: false };
41
+ }
42
+
43
+ const paymentCurrency = await PaymentCurrency.findByPk(payout.currency_id);
44
+ if (!paymentCurrency) {
45
+ logger.warn('PaymentCurrency not found', { id: payout.currency_id });
46
+ return { valid: false };
47
+ }
48
+
49
+ return {
50
+ valid: true,
51
+ payout,
52
+ paymentMethod,
53
+ paymentCurrency,
54
+ };
55
+ }
56
+
57
+ // Process Arcblock chain payout
58
+ async function processArcblockPayout(payout: Payout, paymentMethod: PaymentMethod, paymentCurrency: PaymentCurrency) {
59
+ const client = paymentMethod.getOcapClient();
60
+
61
+ const signed = await client.signTransferV2Tx({
62
+ tx: {
63
+ itx: {
64
+ to: payout.destination,
65
+ value: '0',
66
+ assets: [],
67
+ tokens: [{ address: paymentCurrency.contract, value: payout.amount }],
68
+ data: {
69
+ typeUrl: 'json',
70
+ // @ts-ignore Type issue, won't affect server runtime
71
+ value: {
72
+ appId: wallet.address,
73
+ reason: 'payout',
74
+ payoutId: payout.id,
75
+ },
76
+ },
77
+ },
78
+ },
79
+ wallet,
80
+ });
81
+ // @ts-ignore
82
+ const { buffer } = await client.encodeTransferV2Tx({ tx: signed });
83
+ // @ts-ignore
84
+ const txHash = await client.sendTransferV2Tx({ tx: signed, wallet }, getGasPayerExtra(buffer));
85
+
86
+ logger.info('Payout completed', { id: payout.id, txHash });
87
+
88
+ await payout.update({
89
+ status: 'paid',
90
+ last_attempt_error: null,
91
+ attempt_count: payout.attempt_count + 1,
92
+ attempted: true,
93
+ payment_details: {
94
+ arcblock: {
95
+ tx_hash: txHash,
96
+ payer: wallet.address,
97
+ type: 'transfer',
98
+ },
99
+ },
100
+ });
101
+ }
102
+
103
+ // Process EVM chain payout
104
+ async function processEvmPayout(payout: Payout, paymentMethod: PaymentMethod, paymentCurrency: PaymentCurrency) {
105
+ if (!paymentCurrency.contract) {
106
+ throw new Error('Payout not supported for ethereum payment currencies without contract');
107
+ }
108
+
109
+ const client = paymentMethod.getEvmClient();
110
+ const paymentType = paymentMethod.type;
111
+
112
+ // Send ERC20 tokens from system wallet to user address
113
+ const receipt = await sendErc20ToUser(client, paymentCurrency.contract, payout.destination, payout.amount);
114
+
115
+ logger.info('Payout completed', { id: payout.id, txHash: receipt.hash });
116
+
117
+ await payout.update({
118
+ status: 'paid',
119
+ last_attempt_error: null,
120
+ attempt_count: payout.attempt_count + 1,
121
+ attempted: true,
122
+ payment_details: {
123
+ [paymentType]: {
124
+ tx_hash: receipt.hash,
125
+ payer: ethWallet.address,
126
+ block_height: receipt.blockNumber.toString(),
127
+ gas_used: receipt.gasUsed.toString(),
128
+ gas_price: receipt.gasPrice.toString(),
129
+ type: 'transfer',
130
+ },
131
+ },
132
+ });
133
+ }
134
+
135
+ // Handle payout failure with retry logic
136
+ async function handlePayoutFailure(payout: Payout, paymentMethod: PaymentMethod, error: any, retryOnError: boolean) {
137
+ const paymentError: PaymentError = {
138
+ type: 'card_error',
139
+ code: error.code,
140
+ message: error.message,
141
+ payment_method_id: paymentMethod.id,
142
+ payment_method_type: paymentMethod.type,
143
+ };
144
+
145
+ if (!retryOnError) {
146
+ // Mark as failed without retry
147
+ await payout.update({
148
+ status: 'failed',
149
+ last_attempt_error: paymentError,
150
+ attempt_count: payout.attempt_count + 1,
151
+ attempted: true,
152
+ failure_message: error.message,
153
+ });
154
+ return;
155
+ }
156
+
157
+ const attemptCount = payout.attempt_count + 1;
158
+
159
+ if (attemptCount >= MAX_RETRY_COUNT) {
160
+ // Exceeded max retry count
161
+ await payout.update({
162
+ status: 'failed',
163
+ last_attempt_error: paymentError,
164
+ attempt_count: attemptCount,
165
+ attempted: true,
166
+ failure_message: error.message,
167
+ });
168
+ logger.info('Payout job deleted since max retry exceeded', { id: payout.id });
169
+ payoutQueue.delete(payout.id);
170
+ return;
171
+ }
172
+
173
+ const nextAttempt = getNextRetry(attemptCount);
174
+ await payout.update({
175
+ status: 'pending',
176
+ last_attempt_error: paymentError,
177
+ attempt_count: attemptCount,
178
+ attempted: true,
179
+ next_attempt: nextAttempt,
180
+ });
181
+
182
+ payoutQueue.push({
183
+ id: payout.id,
184
+ job: { payoutId: payout.id, retryOnError: true },
185
+ runAt: nextAttempt,
186
+ });
187
+
188
+ logger.error('Payout retry scheduled', { id: payout.id, nextAttempt, retryCount: attemptCount });
189
+ }
190
+
191
+ // Process payout transaction
192
+ export const handlePayout = async (job: PayoutJob) => {
193
+ logger.info('handle payout', job);
194
+
195
+ const result = await validatePayoutAndFetchData(job);
196
+ if (!result.valid) {
197
+ return;
198
+ }
199
+
200
+ const { payout, paymentMethod, paymentCurrency } = result;
201
+
202
+ logger.info('Payout attempt', { id: payout.id, attempt: payout.attempt_count });
203
+ try {
204
+ await payout.update({ status: 'in_transit', last_attempt_error: null });
205
+ logger.info('Payout status updated to in_transit', { payoutId: payout.id });
206
+
207
+ if (paymentMethod.type === 'arcblock') {
208
+ await processArcblockPayout(payout, paymentMethod, paymentCurrency);
209
+ } else if (EVM_CHAIN_TYPES.includes(paymentMethod.type)) {
210
+ await processEvmPayout(payout, paymentMethod, paymentCurrency);
211
+ }
212
+ } catch (err) {
213
+ logger.error('Payout failed', { error: err, id: payout.id });
214
+ await handlePayoutFailure(payout, paymentMethod, err, !!job.retryOnError);
215
+ }
216
+ };
217
+
218
+ // Create queue processor
219
+ export const payoutQueue = createQueue<PayoutJob>({
220
+ name: 'payout',
221
+ onJob: handlePayout,
222
+ options: {
223
+ concurrency: 1,
224
+ maxRetries: 0,
225
+ enableScheduledJob: true,
226
+ },
227
+ });
228
+
229
+ // Handle queue failure events
230
+ payoutQueue.on('failed', ({ id, job, error }) => {
231
+ logger.error('Payout job failed', { id, job, error });
232
+ });
233
+
234
+ // Start queue, find all payouts with "pending" status
235
+ export const startPayoutQueue = async () => {
236
+ const payouts = await Payout.findAll({
237
+ where: {
238
+ status: 'pending',
239
+ },
240
+ });
241
+
242
+ payouts.forEach(async (payout) => {
243
+ const exist = await payoutQueue.get(payout.id);
244
+ if (!exist) {
245
+ // Use next attempt time if set
246
+ if (payout.next_attempt && payout.next_attempt > dayjs().unix()) {
247
+ payoutQueue.push({
248
+ id: payout.id,
249
+ job: { payoutId: payout.id, retryOnError: true },
250
+ runAt: payout.next_attempt,
251
+ });
252
+ } else {
253
+ payoutQueue.push({
254
+ id: payout.id,
255
+ job: { payoutId: payout.id, retryOnError: true },
256
+ });
257
+ }
258
+ }
259
+ });
260
+ };
261
+
262
+ // Listen for newly created payouts
263
+ events.on('payout.created', async (payout: Payout) => {
264
+ if (payout.status === 'pending') {
265
+ const exist = await payoutQueue.get(payout.id);
266
+ if (!exist) {
267
+ payoutQueue.push({
268
+ id: payout.id,
269
+ job: { payoutId: payout.id, retryOnError: true },
270
+ });
271
+ }
272
+ }
273
+ });
274
+
275
+ // Add synchronous payout processing event
276
+ events.on('payout.queued', async (id, job, args = {}) => {
277
+ const { sync, ...extraArgs } = args;
278
+ if (sync) {
279
+ try {
280
+ await payoutQueue.pushAndWait({
281
+ id,
282
+ job,
283
+ ...extraArgs,
284
+ });
285
+ events.emit('payout.queued.done');
286
+ } catch (error) {
287
+ logger.error('Error in payout.queued', { id, job, error });
288
+ events.emit('payout.queued.error', error);
289
+ }
290
+ return;
291
+ }
292
+ payoutQueue.push({
293
+ id,
294
+ job,
295
+ ...extraArgs,
296
+ });
297
+ });
@@ -17,11 +17,7 @@ import { MetadataSchema } from '../libs/api';
17
17
  import { checkPassportForPaymentLink } from '../integrations/blocklet/passport';
18
18
  import { handleStripePaymentSucceed } from '../integrations/stripe/handlers/payment-intent';
19
19
  import { handleStripeSubscriptionSucceed } from '../integrations/stripe/handlers/subscription';
20
- import {
21
- ensureStripePaymentCustomer,
22
- ensureStripePaymentIntent,
23
- ensureStripeSubscription,
24
- } from '../integrations/stripe/resource';
20
+ import { ensureStripePaymentIntent, ensureStripeSubscription } from '../integrations/stripe/resource';
25
21
  import dayjs from '../libs/dayjs';
26
22
  import logger from '../libs/logger';
27
23
  import { isCreditSufficientForPayment, isDelegationSufficientForPayment } from '../libs/payment';
@@ -1048,12 +1044,11 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
1048
1044
  trialInDays,
1049
1045
  trialEnd
1050
1046
  );
1051
- const stripeCustomer = await ensureStripePaymentCustomer(subscription, paymentMethod);
1052
1047
  if (stripeSubscription) {
1053
1048
  await subscription.update({
1054
1049
  payment_details: {
1055
1050
  stripe: {
1056
- customer_id: stripeCustomer.id,
1051
+ customer_id: stripeSubscription.customer,
1057
1052
  subscription_id: stripeSubscription.id,
1058
1053
  setup_intent_id: stripeSubscription.pending_setup_intent?.id,
1059
1054
  },
@@ -11,6 +11,7 @@ import { formatMetadata } from '../libs/util';
11
11
  import { Customer } from '../store/models/customer';
12
12
  import { blocklet } from '../libs/auth';
13
13
  import logger from '../libs/logger';
14
+ import { Invoice } from '../store/models';
14
15
 
15
16
  const router = Router();
16
17
  const auth = authenticate<Customer>({ component: true, roles: ['owner', 'admin'] });
@@ -86,26 +87,99 @@ router.get('/me', sessionMiddleware(), async (req, res) => {
86
87
  }
87
88
 
88
89
  try {
89
- const doc = await Customer.findByPkOrDid(req.user.did as string);
90
+ let doc = await Customer.findByPkOrDid(req.user.did as string);
90
91
  const livemode = req.query.livemode ? !!req?.livemode : !!doc?.livemode;
91
92
  if (!doc) {
92
93
  if (req.query.fallback) {
93
94
  const result = await blocklet.getUser(req.user.did);
94
- res.json({ ...result.user, address: {}, livemode });
95
+ return res.json({ ...result.user, address: {}, livemode });
96
+ }
97
+ if (req.query.create) {
98
+ // create customer
99
+ const { user } = await blocklet.getUser(req.user.did);
100
+ const customer = await Customer.create({
101
+ livemode: true,
102
+ did: req.user.did,
103
+ name: user.fullName,
104
+ email: user.email,
105
+ phone: '',
106
+ address: {},
107
+ description: user.remark,
108
+ metadata: {},
109
+ balance: '0',
110
+ next_invoice_sequence: 1,
111
+ delinquent: false,
112
+ invoice_prefix: Customer.getInvoicePrefix(),
113
+ });
114
+ logger.info('customer created', {
115
+ customerId: customer.id,
116
+ did: customer.did,
117
+ });
118
+ doc = customer;
95
119
  } else {
96
- res.json({ error: 'Customer not found' });
120
+ return res.json({ error: 'Customer not found' });
97
121
  }
98
- } else {
122
+ }
123
+ try {
99
124
  const [summary, stake, token] = await Promise.all([
100
125
  doc.getSummary(livemode),
101
126
  getStakeSummaryByDid(doc.did, livemode),
102
127
  getTokenSummaryByDid(doc.did, livemode),
103
128
  ]);
104
129
  res.json({ ...doc.toJSON(), summary: { ...summary, stake, token }, livemode });
130
+ } catch (summaryErr) {
131
+ logger.error('get customer summary failed', summaryErr);
132
+ if (req.query.skipError) {
133
+ return res.json({
134
+ ...doc.toJSON(),
135
+ summary: { stake: {}, token: {} },
136
+ livemode,
137
+ summaryError: summaryErr.message,
138
+ error: `Failed to get customer: ${summaryErr.message}`,
139
+ });
140
+ }
141
+ throw summaryErr;
105
142
  }
106
143
  } catch (err) {
107
144
  logger.error('get customer failed', err);
108
- res.status(500).json({ error: `Failed to get customer: ${err.message}` });
145
+ if (req.query.skipError) {
146
+ return res.json({
147
+ error: `Failed to get customer: ${err.message}`,
148
+ did: req.user?.did,
149
+ name: req.user?.fullName,
150
+ address: {},
151
+ livemode: !!req.query.livemode,
152
+ summary: { stake: {}, token: {} },
153
+ });
154
+ }
155
+ return res.status(500).json({ error: `Failed to get customer: ${err.message}` });
156
+ }
157
+ });
158
+
159
+ // get overdue invoices
160
+ router.get('/:id/overdue/invoices', auth, async (req, res) => {
161
+ if (!req.user) {
162
+ return res.status(403).json({ error: 'Unauthorized' });
163
+ }
164
+ try {
165
+ const doc = await Customer.findByPkOrDid(req.params.id as string);
166
+ if (!doc) {
167
+ return res.status(404).json({ error: 'Customer not found' });
168
+ }
169
+ const [summary, detail, invoices] = await Invoice!.getUncollectibleAmount({
170
+ customerId: doc.id,
171
+ livemode: req.query.livemode ? !!req.query.livemode : doc.livemode,
172
+ });
173
+ const subscriptionCount = new Set(invoices.map((x) => x.subscription_id)).size;
174
+ return res.json({
175
+ summary,
176
+ invoices,
177
+ subscriptionCount,
178
+ detail,
179
+ });
180
+ } catch (err) {
181
+ logger.error(err);
182
+ return res.status(500).json({ error: `Failed to get overdue invoices: ${err.message}` });
109
183
  }
110
184
  });
111
185
 
@@ -9,11 +9,16 @@ import { authenticate } from '../libs/security';
9
9
  import { PaymentCurrency, TPaymentCurrency } from '../store/models/payment-currency';
10
10
  import { PaymentMethod } from '../store/models/payment-method';
11
11
  import { EVM_CHAIN_TYPES } from '../libs/constants';
12
+ import { ethWallet, getVaultAddress, wallet } from '../libs/auth';
13
+ import { resolveAddressChainTypes } from '../libs/util';
14
+ import { depositVaultQueue } from '../queues/payment';
15
+ import { checkDepositVaultAmount } from '../libs/payment';
16
+ import { getTokenSummaryByDid } from '../integrations/arcblock/stake';
12
17
 
13
18
  const router = Router();
14
19
 
15
20
  const auth = authenticate<PaymentCurrency>({ component: true, roles: ['owner', 'admin'] });
16
-
21
+ const authOwner = authenticate<PaymentCurrency>({ component: true, roles: ['owner'] });
17
22
  router.post('/', auth, async (req, res) => {
18
23
  const raw: Partial<TPaymentCurrency> = req.body;
19
24
 
@@ -140,6 +145,81 @@ router.get('/', auth, async (req, res) => {
140
145
  res.json(list);
141
146
  });
142
147
 
148
+ router.get('/vault-config', auth, async (req, res) => {
149
+ const vaultAddress = await getVaultAddress();
150
+ if (!vaultAddress) {
151
+ return res.json([]);
152
+ }
153
+ const chainTypes = resolveAddressChainTypes(vaultAddress);
154
+ try {
155
+ const paymentMethods = await PaymentMethod.findAll({
156
+ where: {
157
+ type: {
158
+ [Op.in]: chainTypes,
159
+ },
160
+ },
161
+ attributes: ['id'],
162
+ });
163
+
164
+ const paymentMethodIds = paymentMethods.map((method) => method.id);
165
+ const list = await PaymentCurrency.scope('withVaultConfig').findAll({
166
+ where: {
167
+ payment_method_id: {
168
+ [Op.in]: paymentMethodIds,
169
+ },
170
+ livemode: !!req.livemode,
171
+ },
172
+ include: [{ model: PaymentMethod, as: 'payment_method' }],
173
+ });
174
+ try {
175
+ const [arcblock, ethereum] = await Promise.all([
176
+ getTokenSummaryByDid(wallet.address, !!req.livemode, 'arcblock'),
177
+ getTokenSummaryByDid(ethWallet.address, !!req.livemode, EVM_CHAIN_TYPES),
178
+ ]);
179
+ return res.json({
180
+ list,
181
+ balances: {
182
+ ...arcblock,
183
+ ...ethereum,
184
+ },
185
+ });
186
+ } catch (err) {
187
+ logger.error('get token summary failed', err);
188
+ return res.status(400).json({ error: err.message, list, balances: {} });
189
+ }
190
+ } catch (err) {
191
+ logger.error('get payment currency vault config failed', err);
192
+ return res.status(400).json({ error: err.message });
193
+ }
194
+ });
195
+
196
+ router.get('/:id/deposit-vault', auth, async (req, res) => {
197
+ const { id } = req.params;
198
+ if (!id) {
199
+ return res.status(400).json({ error: 'Missing payment currency id' });
200
+ }
201
+ try {
202
+ const result = await checkDepositVaultAmount(id);
203
+ return res.json(result);
204
+ } catch (error) {
205
+ logger.error('Error checking deposit vault amount', { error, id });
206
+ return res.status(400).json({ error: 'Failed to check deposit vault amount', message: error.message });
207
+ }
208
+ });
209
+
210
+ router.put('/:id/deposit-vault', auth, async (req, res) => {
211
+ const paymentCurrency = await PaymentCurrency.findByPk(req.params.id);
212
+ if (!paymentCurrency) {
213
+ return res.status(404).json({ error: 'Payment currency not found' });
214
+ }
215
+ depositVaultQueue.push({
216
+ id: `deposit-vault-${paymentCurrency.id}`,
217
+ job: { currencyId: paymentCurrency.id },
218
+ });
219
+ logger.info('Deposit vault job pushed', { currencyId: paymentCurrency.id });
220
+ return res.json({ message: 'Deposit vault job pushed' });
221
+ });
222
+
143
223
  router.get('/:id', auth, async (req, res) => {
144
224
  const doc = await PaymentCurrency.findOne({
145
225
  where: { [Op.or]: [{ id: req.params.id }, { symbol: req.params.id }] },
@@ -152,6 +232,42 @@ router.get('/:id', auth, async (req, res) => {
152
232
  }
153
233
  });
154
234
 
235
+ const UpdateVaultConfigSchema = Joi.object({
236
+ enabled: Joi.boolean().required(),
237
+ deposit_threshold: Joi.number().greater(0).required(),
238
+ withdraw_threshold: Joi.number().min(0).required(),
239
+ });
240
+ router.put('/:id/vault-config', authOwner, async (req, res) => {
241
+ try {
242
+ const { id } = req.params;
243
+
244
+ const { error, value: vaultConfig } = UpdateVaultConfigSchema.validate(req.body);
245
+ if (error) {
246
+ return res.status(400).json({ error: error.message });
247
+ }
248
+
249
+ const paymentCurrency = await PaymentCurrency.findByPk(id);
250
+ if (!paymentCurrency) {
251
+ return res.status(404).json({ error: 'payment currency not found' });
252
+ }
253
+
254
+ const updateData: Partial<TPaymentCurrency> = {
255
+ vault_config: {
256
+ enabled: vaultConfig.enabled,
257
+ deposit_threshold: fromTokenToUnit(vaultConfig.deposit_threshold, paymentCurrency.decimal).toString(),
258
+ withdraw_threshold: fromTokenToUnit(vaultConfig.withdraw_threshold, paymentCurrency.decimal).toString(),
259
+ },
260
+ };
261
+
262
+ await paymentCurrency.update(updateData);
263
+
264
+ return res.json(paymentCurrency.toJSON());
265
+ } catch (err) {
266
+ logger.error('update payment currency vault config failed', err);
267
+ return res.status(400).json({ error: err.message });
268
+ }
269
+ });
270
+
155
271
  const updateCurrencySchema = Joi.object({
156
272
  name: Joi.string().empty('').optional(),
157
273
  description: Joi.string().empty('').optional(),
@@ -192,15 +192,25 @@ router.get('/', auth, async (req, res) => {
192
192
  include: [{ model: PaymentCurrency, as: 'payment_currencies', order: [['created_at', 'ASC']] }],
193
193
  });
194
194
  if (query.addresses === 'true') {
195
- const [arcblock, ethereum] = await Promise.all([
196
- getTokenSummaryByDid(wallet.address, !!req.livemode, 'arcblock'),
197
- getTokenSummaryByDid(ethWallet.address, !!req.livemode, EVM_CHAIN_TYPES),
198
- ]);
199
- res.json({
200
- list,
201
- addresses: { arcblock: wallet.address, ethereum: ethWallet.address },
202
- balances: { ...arcblock, ...ethereum },
203
- });
195
+ try {
196
+ const [arcblock, ethereum] = await Promise.all([
197
+ getTokenSummaryByDid(wallet.address, !!req.livemode, 'arcblock'),
198
+ getTokenSummaryByDid(ethWallet.address, !!req.livemode, EVM_CHAIN_TYPES),
199
+ ]);
200
+ res.json({
201
+ list,
202
+ addresses: { arcblock: wallet.address, ethereum: ethWallet.address },
203
+ balances: { ...arcblock, ...ethereum },
204
+ });
205
+ } catch (err) {
206
+ logger.error('get token summary failed', err.message);
207
+ res.json({
208
+ list,
209
+ addresses: { arcblock: wallet.address, ethereum: ethWallet.address },
210
+ balances: {},
211
+ error: `get token summary failed: ${err.message}`,
212
+ });
213
+ }
204
214
  } else {
205
215
  res.json(list);
206
216
  }
@@ -9,12 +9,7 @@ import uniq from 'lodash/uniq';
9
9
  import { literal, Op, OrderItem } from 'sequelize';
10
10
  import { BN } from '@ocap/util';
11
11
  import { createEvent } from '../libs/audit';
12
- import {
13
- ensureStripeCustomer,
14
- ensureStripePaymentCustomer,
15
- ensureStripePrice,
16
- ensureStripeSubscription,
17
- } from '../integrations/stripe/resource';
12
+ import { ensureStripeCustomer, ensureStripePrice, ensureStripeSubscription } from '../integrations/stripe/resource';
18
13
  import { createListParamSchema, getWhereFromKvQuery, getWhereFromQuery, MetadataSchema } from '../libs/api';
19
14
  import dayjs from '../libs/dayjs';
20
15
  import logger from '../libs/logger';
@@ -97,6 +92,7 @@ const schema = createListParamSchema<{
97
92
  activeFirst?: boolean;
98
93
  price_id?: string;
99
94
  order?: string | string[] | OrderItem | OrderItem[];
95
+ showTotalCount?: boolean;
100
96
  }>({
101
97
  status: Joi.string().empty(''),
102
98
  customer_id: Joi.string().empty(''),
@@ -110,6 +106,7 @@ const schema = createListParamSchema<{
110
106
  Joi.array().items(Joi.array().ordered(Joi.string(), Joi.string().valid('ASC', 'DESC').insensitive()))
111
107
  )
112
108
  .optional(),
109
+ showTotalCount: Joi.boolean().optional(),
113
110
  });
114
111
 
115
112
  const parseOrder = (orderStr: string): OrderItem => {
@@ -195,7 +192,17 @@ router.get('/', authMine, async (req, res) => {
195
192
  // @ts-ignore
196
193
  docs.forEach((x) => expandLineItems(x.items, products, prices));
197
194
 
198
- res.json({ count, list: docs, paging: { page, pageSize } });
195
+ if (query.showTotalCount) {
196
+ const totalCount = await Subscription.count({
197
+ where: {
198
+ customer_id: where.customer_id,
199
+ },
200
+ distinct: true,
201
+ });
202
+ res.json({ count, list: docs, paging: { page, pageSize }, totalCount });
203
+ } else {
204
+ res.json({ count, list: docs, paging: { page, pageSize } });
205
+ }
199
206
  } catch (err) {
200
207
  logger.error(err);
201
208
  res.json({ count: 0, list: [], paging: { page, pageSize } });
@@ -1528,7 +1535,6 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
1528
1535
  const settings = PaymentMethod.decryptSettings(paymentMethod.settings);
1529
1536
 
1530
1537
  // changing from crypto to stripe: create/resume stripe subscription, pause crypto subscription
1531
- const stripeCustomer = await ensureStripePaymentCustomer(subscription, paymentMethod);
1532
1538
  const stripeSubscription = await ensureStripeSubscription(
1533
1539
  subscription,
1534
1540
  paymentMethod,
@@ -1542,7 +1548,7 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
1542
1548
  payment_details: {
1543
1549
  ...subscription.payment_details,
1544
1550
  stripe: {
1545
- customer_id: stripeCustomer.id,
1551
+ customer_id: stripeSubscription.customer,
1546
1552
  subscription_id: stripeSubscription.id,
1547
1553
  setup_intent_id: stripeSubscription.pending_setup_intent?.id,
1548
1554
  },