payment-kit 1.18.13 → 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 (39) 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/payment.ts +77 -2
  5. package/api/src/libs/util.ts +8 -0
  6. package/api/src/queues/payment.ts +50 -1
  7. package/api/src/queues/payout.ts +297 -0
  8. package/api/src/routes/checkout-sessions.ts +2 -7
  9. package/api/src/routes/payment-currencies.ts +117 -1
  10. package/api/src/routes/payment-methods.ts +19 -9
  11. package/api/src/routes/subscriptions.ts +2 -8
  12. package/api/src/store/migrations/20250305-vault-config.ts +21 -0
  13. package/api/src/store/models/payment-currency.ts +14 -0
  14. package/api/src/store/models/payout.ts +21 -0
  15. package/api/src/store/models/types.ts +6 -0
  16. package/blocklet.yml +1 -1
  17. package/package.json +18 -18
  18. package/src/app.tsx +116 -120
  19. package/src/components/customer/overdraft-protection.tsx +1 -0
  20. package/src/components/layout/admin.tsx +6 -0
  21. package/src/components/layout/user.tsx +1 -0
  22. package/src/components/metadata/editor.tsx +7 -1
  23. package/src/components/metadata/list.tsx +3 -0
  24. package/src/components/passport/assign.tsx +3 -0
  25. package/src/components/payment-link/rename.tsx +1 -0
  26. package/src/components/pricing-table/rename.tsx +1 -0
  27. package/src/components/product/add-price.tsx +1 -0
  28. package/src/components/product/edit-price.tsx +1 -0
  29. package/src/components/product/edit.tsx +1 -0
  30. package/src/components/subscription/actions/index.tsx +1 -0
  31. package/src/components/subscription/portal/actions.tsx +1 -0
  32. package/src/locales/en.tsx +42 -0
  33. package/src/locales/zh.tsx +37 -0
  34. package/src/pages/admin/payments/payouts/detail.tsx +47 -43
  35. package/src/pages/admin/settings/index.tsx +3 -3
  36. package/src/pages/admin/settings/payment-methods/index.tsx +33 -1
  37. package/src/pages/admin/settings/vault-config/edit-form.tsx +253 -0
  38. package/src/pages/admin/settings/vault-config/index.tsx +352 -0
  39. package/src/pages/integrations/donations/edit-form.tsx +0 -1
package/api/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import { startEventQueue } from './queues/event';
25
25
  import { startInvoiceQueue } from './queues/invoice';
26
26
  import { startNotificationQueue } from './queues/notification';
27
27
  import { startPaymentQueue } from './queues/payment';
28
+ import { startPayoutQueue } from './queues/payout';
28
29
  import { startRefundQueue } from './queues/refund';
29
30
  import { startSubscriptionQueue } from './queues/subscription';
30
31
  import routes from './routes';
@@ -108,6 +109,7 @@ export const server = app.listen(port, (err?: any) => {
108
109
  startInvoiceQueue().then(() => logger.info('invoice queue started'));
109
110
  startSubscriptionQueue().then(() => logger.info('subscription queue started'));
110
111
  startEventQueue().then(() => logger.info('event queue started'));
112
+ startPayoutQueue().then(() => logger.info('payout queue started'));
111
113
  startCheckoutSessionQueue().then(() => logger.info('checkoutSession queue started'));
112
114
  startNotificationQueue().then(() => logger.info('notification queue started'));
113
115
  startRefundQueue().then(() => logger.info('refund queue started'));
@@ -24,6 +24,7 @@ import {
24
24
  } from '../../store/models';
25
25
  import { syncStripeInvoice } from './handlers/invoice';
26
26
  import { syncStripePayment } from './handlers/payment-intent';
27
+ import { getLock } from '../../libs/lock';
27
28
 
28
29
  export async function ensureStripeProduct(internal: Product, method: PaymentMethod) {
29
30
  const client = method.getStripeClient();
@@ -118,16 +119,45 @@ export async function ensureStripePrice(internal: Price, method: PaymentMethod,
118
119
 
119
120
  export async function ensureStripeCustomer(internal: Customer, method: PaymentMethod) {
120
121
  const client = method.getStripeClient();
122
+
123
+ // 1. check local metadata
124
+ if (internal.metadata?.stripe_id) {
125
+ try {
126
+ const customer = await client.customers.retrieve(internal.metadata.stripe_id);
127
+ if (customer) {
128
+ return customer;
129
+ }
130
+ } catch (error) {
131
+ logger.warn('Stored Stripe customer ID not found, will recreate', {
132
+ customerId: internal.id,
133
+ stripeId: internal.metadata.stripe_id,
134
+ });
135
+ }
136
+ }
137
+
138
+ // 2. search customer on stripe
121
139
  const result = await client.customers.search({ query: `metadata['did']:'${internal.did}'` });
122
140
  if (result.data.length > 0) {
123
- return result.data[0] as any;
141
+ const stripeCustomer = result.data[0];
142
+ if (stripeCustomer) {
143
+ // update local metadata
144
+ if (!internal.metadata?.stripe_id || internal.metadata.stripe_id !== stripeCustomer!.id) {
145
+ await internal.update({
146
+ metadata: merge(internal.metadata || {}, {
147
+ stripe_id: stripeCustomer!.id,
148
+ stripe_invoice_prefix: stripeCustomer!.invoice_prefix,
149
+ }),
150
+ });
151
+ }
152
+ return stripeCustomer;
153
+ }
124
154
  }
125
155
 
156
+ // 3. create new customer, let stripe generate invoice prefix
126
157
  const customer = await client.customers.create({
127
158
  name: internal.name,
128
159
  email: internal.email,
129
160
  phone: internal.phone,
130
- invoice_prefix: internal.invoice_prefix,
131
161
  metadata: {
132
162
  appPid: env.appPid,
133
163
  id: internal.id,
@@ -135,7 +165,14 @@ export async function ensureStripeCustomer(internal: Customer, method: PaymentMe
135
165
  },
136
166
  });
137
167
 
138
- await internal.update({ metadata: merge(internal.metadata || {}, { stripe_id: customer.id }) });
168
+ // 4. update local metadata
169
+ await internal.update({
170
+ metadata: merge(internal.metadata || {}, {
171
+ stripe_id: customer.id,
172
+ stripe_invoice_prefix: customer.invoice_prefix,
173
+ }),
174
+ });
175
+
139
176
  logger.info('customer created on stripe', { local: internal.id, remote: customer.id });
140
177
 
141
178
  return customer;
@@ -143,15 +180,20 @@ export async function ensureStripeCustomer(internal: Customer, method: PaymentMe
143
180
 
144
181
  export async function ensureStripePaymentCustomer(internal: any, method: PaymentMethod) {
145
182
  const client = method.getStripeClient();
146
- let customer = null;
147
- if (internal.payment_details?.stripe?.customer_id) {
148
- customer = await client.customers.retrieve(internal.payment_details.stripe.customer_id);
149
- } else {
150
- const local = await Customer.findByPk(internal.customer_id);
151
- customer = await ensureStripeCustomer(local as Customer, method);
183
+ const lock = getLock(`stripe-customer-${internal.customer_id}`);
184
+ await lock.acquire();
185
+ try {
186
+ let customer = null;
187
+ if (internal.payment_details?.stripe?.customer_id) {
188
+ customer = await client.customers.retrieve(internal.payment_details.stripe.customer_id);
189
+ } else {
190
+ const local = await Customer.findByPk(internal.customer_id);
191
+ customer = await ensureStripeCustomer(local as Customer, method);
192
+ }
193
+ return customer;
194
+ } finally {
195
+ lock.release();
152
196
  }
153
-
154
- return customer;
155
197
  }
156
198
 
157
199
  export async function ensureStripePaymentIntent(
@@ -10,6 +10,7 @@ import type { LiteralUnion } from 'type-fest';
10
10
  import type { WalletObject } from '@ocap/wallet';
11
11
 
12
12
  import env from './env';
13
+ import logger from './logger';
13
14
 
14
15
  export const wallet: WalletObject = getWallet();
15
16
  export const ethWallet: WalletObject = getWallet('ethereum');
@@ -25,6 +26,19 @@ export const handlers = new WalletHandler({
25
26
 
26
27
  export const blocklet = new AuthService();
27
28
 
29
+ export async function getVaultAddress() {
30
+ try {
31
+ const vault = await blocklet.getVault();
32
+ if (!vault) {
33
+ return null;
34
+ }
35
+ return vault;
36
+ } catch (error) {
37
+ logger.info('get vault wallet failed', { error });
38
+ return null;
39
+ }
40
+ }
41
+
28
42
  export type CallbackArgs = {
29
43
  request: Request & { context: Record<string, any> };
30
44
  userDid: string;
@@ -18,12 +18,14 @@ import {
18
18
  PaymentMethod,
19
19
  TCustomer,
20
20
  TLineItemExpanded,
21
+ Payout,
21
22
  } from '../store/models';
22
23
  import type { TPaymentCurrency } from '../store/models/payment-currency';
23
- import { blocklet, ethWallet, wallet } from './auth';
24
+ import { blocklet, ethWallet, wallet, getVaultAddress } from './auth';
24
25
  import logger from './logger';
25
- import { getBlockletJson, getUserOrAppInfo, OCAP_PAYMENT_TX_TYPE } from './util';
26
+ import { getBlockletJson, getUserOrAppInfo, OCAP_PAYMENT_TX_TYPE, resolveAddressChainTypes } from './util';
26
27
  import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from './constants';
28
+ import { getTokenByAddress } from '../integrations/arcblock/stake';
27
29
 
28
30
  export interface SufficientForPaymentResult {
29
31
  sufficient: boolean;
@@ -390,3 +392,76 @@ export async function getDonationBenefits(paymentLink: PaymentLink, url?: string
390
392
  );
391
393
  return result;
392
394
  }
395
+
396
+ /**
397
+ * 检查是否需要向冷钱包转账及可转账金额
398
+ * @param paymentCurrencyId
399
+ * @returns {Promise<{depositAmount: string, message?: string, vaultAddress?: string, paymentMethod?: PaymentMethod, paymentCurrency?: PaymentCurrency}>}
400
+ */
401
+ export async function checkDepositVaultAmount(paymentCurrencyId: string): Promise<{
402
+ depositAmount: string;
403
+ message?: string;
404
+ vaultAddress?: string;
405
+ paymentCurrency?: PaymentCurrency;
406
+ }> {
407
+ const paymentCurrency = await PaymentCurrency.scope('withVaultConfig').findByPk(paymentCurrencyId);
408
+ if (!paymentCurrency) {
409
+ return { depositAmount: '0', message: 'Payment currency not found' };
410
+ }
411
+
412
+ if (!paymentCurrency?.vault_config?.enabled) {
413
+ return { depositAmount: '0', message: 'Deposit vault is not enabled' };
414
+ }
415
+
416
+ const depositThreshold = paymentCurrency?.vault_config?.deposit_threshold;
417
+ if (!depositThreshold || depositThreshold === '0') {
418
+ return { depositAmount: '0', message: 'Deposit threshold is not set or zero' };
419
+ }
420
+
421
+ const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
422
+ if (!paymentMethod) {
423
+ return { depositAmount: '0', message: 'Payment method not found' };
424
+ }
425
+
426
+ const vaultAddress = await getVaultAddress();
427
+ if (!vaultAddress) {
428
+ return { depositAmount: '0', message: 'Vault address is not found' };
429
+ }
430
+
431
+ const vaultChainTypes = resolveAddressChainTypes(vaultAddress);
432
+ if (!vaultChainTypes.includes(paymentMethod.type)) {
433
+ return { depositAmount: '0', message: 'Vault chain type is not supported' };
434
+ }
435
+
436
+ const walletAddress = paymentMethod.type === 'arcblock' ? wallet.address : ethWallet.address;
437
+ const balance = await getTokenByAddress(walletAddress, paymentMethod, paymentCurrency);
438
+
439
+ const depositThresholdBN = new BN(depositThreshold);
440
+ if (new BN(balance).lte(depositThresholdBN)) {
441
+ return { depositAmount: '0', message: 'No enough balance to deposit to vault' };
442
+ }
443
+
444
+ const balanceBN = new BN(balance);
445
+ let amountToDeposit = balanceBN.sub(depositThresholdBN).toString();
446
+
447
+ const { [paymentCurrency.id]: lockedAmount } = await Payout.getPayoutLockedAmount({
448
+ currency_id: paymentCurrency.id,
449
+ });
450
+
451
+ // check if the amount to payout is already greater than the deposit threshold
452
+ if (new BN(lockedAmount).add(depositThresholdBN).gte(balanceBN)) {
453
+ return { depositAmount: '0', message: 'Amount to payout is already greater than the deposit threshold' };
454
+ }
455
+
456
+ amountToDeposit = new BN(amountToDeposit).sub(new BN(lockedAmount)).toString();
457
+
458
+ if (new BN(amountToDeposit).lte(new BN(0))) {
459
+ return { depositAmount: '0', message: 'No amount available to deposit after calculations' };
460
+ }
461
+
462
+ return {
463
+ depositAmount: amountToDeposit,
464
+ vaultAddress,
465
+ paymentCurrency,
466
+ };
467
+ }
@@ -9,6 +9,7 @@ import type { LiteralUnion } from 'type-fest';
9
9
  import { joinURL, withQuery, withTrailingSlash } from 'ufo';
10
10
 
11
11
  import axios from 'axios';
12
+ import { ethers } from 'ethers';
12
13
  import dayjs from './dayjs';
13
14
  import { blocklet, wallet } from './auth';
14
15
  import type { PaymentMethod, Subscription } from '../store/models';
@@ -508,3 +509,10 @@ export async function isUserInBlocklist(did: string, paymentMethod: PaymentMetho
508
509
  return false; // Default to allowing payment on error
509
510
  }
510
511
  }
512
+
513
+ export function resolveAddressChainTypes(address: string): LiteralUnion<'ethereum' | 'base' | 'arcblock', string>[] {
514
+ if (ethers.isAddress(address)) {
515
+ return ['ethereum', 'base', 'arcblock'];
516
+ }
517
+ return ['arcblock'];
518
+ }
@@ -9,7 +9,7 @@ import dayjs from '../libs/dayjs';
9
9
  import CustomError from '../libs/error';
10
10
  import { events } from '../libs/event';
11
11
  import logger from '../libs/logger';
12
- import { getGasPayerExtra, isDelegationSufficientForPayment } from '../libs/payment';
12
+ import { getGasPayerExtra, isDelegationSufficientForPayment, checkDepositVaultAmount } from '../libs/payment';
13
13
  import {
14
14
  checkRemainingStake,
15
15
  getDaysUntilCancel,
@@ -47,6 +47,10 @@ type PaymentJob = {
47
47
  retryOnError?: boolean;
48
48
  };
49
49
 
50
+ type DepositVaultJob = {
51
+ currencyId: string;
52
+ };
53
+
50
54
  async function updateQuantitySold(checkoutSession: CheckoutSession) {
51
55
  const updatePromises = checkoutSession.line_items.map((item) => {
52
56
  const priceId = item.upsell_price_id || item.price_id;
@@ -62,6 +66,46 @@ async function updateQuantitySold(checkoutSession: CheckoutSession) {
62
66
  await Promise.all(updatePromises);
63
67
  }
64
68
 
69
+ const handleDepositVault = async (paymentCurrencyId: string) => {
70
+ const { depositAmount, message, vaultAddress, paymentCurrency } = await checkDepositVaultAmount(paymentCurrencyId);
71
+ if (depositAmount === '0') {
72
+ logger.info(`Deposit vault skipped: ${message}`, { currencyId: paymentCurrencyId });
73
+ return;
74
+ }
75
+ const payout = await Payout.create({
76
+ livemode: paymentCurrency!.livemode,
77
+ automatic: true,
78
+ description: 'Deposit vault',
79
+ amount: depositAmount,
80
+ destination: vaultAddress!,
81
+ payment_method_id: paymentCurrency!.payment_method_id,
82
+ currency_id: paymentCurrency!.id,
83
+ customer_id: '',
84
+ payment_intent_id: '',
85
+ status: 'pending',
86
+ attempt_count: 0,
87
+ attempted: false,
88
+ next_attempt: 0,
89
+ last_attempt_error: null,
90
+ metadata: {
91
+ system: true,
92
+ },
93
+ });
94
+ logger.info('Deposit vault payout created', { payoutId: payout.id });
95
+ };
96
+
97
+ export const depositVaultQueue = createQueue<DepositVaultJob>({
98
+ name: 'deposit-vault',
99
+ onJob: async (job) => {
100
+ await handleDepositVault(job.currencyId);
101
+ },
102
+ options: {
103
+ concurrency: 1,
104
+ maxRetries: 3,
105
+ enableScheduledJob: true,
106
+ },
107
+ });
108
+
65
109
  export const handlePaymentSucceed = async (
66
110
  paymentIntent: PaymentIntent,
67
111
  triggerRenew: boolean = true,
@@ -128,6 +172,11 @@ export const handlePaymentSucceed = async (
128
172
  );
129
173
  }
130
174
 
175
+ depositVaultQueue.push({
176
+ id: `deposit-vault-${paymentIntent.currency_id}`,
177
+ job: { currencyId: paymentIntent.currency_id },
178
+ });
179
+
131
180
  let invoice;
132
181
  if (paymentIntent.invoice_id) {
133
182
  invoice = await Invoice.findByPk(paymentIntent.invoice_id);
@@ -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
  },