payment-kit 1.18.13 → 1.18.15
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 +2 -0
- package/api/src/integrations/stripe/resource.ts +53 -11
- package/api/src/libs/auth.ts +14 -0
- package/api/src/libs/payment.ts +77 -2
- package/api/src/libs/util.ts +8 -0
- package/api/src/queues/payment.ts +50 -1
- package/api/src/queues/payout.ts +297 -0
- package/api/src/routes/checkout-sessions.ts +2 -7
- package/api/src/routes/payment-currencies.ts +120 -1
- package/api/src/routes/payment-methods.ts +19 -9
- package/api/src/routes/subscriptions.ts +2 -8
- package/api/src/store/migrations/20250305-vault-config.ts +21 -0
- package/api/src/store/models/payment-currency.ts +14 -0
- package/api/src/store/models/payout.ts +21 -0
- package/api/src/store/models/types.ts +6 -0
- package/blocklet.yml +1 -1
- package/package.json +18 -18
- package/src/app.tsx +116 -120
- package/src/components/customer/overdraft-protection.tsx +1 -0
- package/src/components/layout/admin.tsx +6 -0
- package/src/components/layout/user.tsx +1 -0
- package/src/components/metadata/editor.tsx +7 -1
- package/src/components/metadata/list.tsx +3 -0
- package/src/components/passport/assign.tsx +3 -0
- package/src/components/payment-link/rename.tsx +1 -0
- package/src/components/pricing-table/rename.tsx +1 -0
- package/src/components/product/add-price.tsx +1 -0
- package/src/components/product/edit-price.tsx +1 -0
- package/src/components/product/edit.tsx +1 -0
- package/src/components/subscription/actions/index.tsx +1 -0
- package/src/components/subscription/portal/actions.tsx +1 -0
- package/src/locales/en.tsx +42 -0
- package/src/locales/zh.tsx +37 -0
- package/src/pages/admin/payments/payouts/detail.tsx +47 -43
- package/src/pages/admin/settings/index.tsx +3 -3
- package/src/pages/admin/settings/payment-methods/index.tsx +33 -1
- package/src/pages/admin/settings/vault-config/edit-form.tsx +253 -0
- package/src/pages/admin/settings/vault-config/index.tsx +367 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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(
|
package/api/src/libs/auth.ts
CHANGED
|
@@ -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;
|
package/api/src/libs/payment.ts
CHANGED
|
@@ -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
|
+
}
|
package/api/src/libs/util.ts
CHANGED
|
@@ -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:
|
|
1051
|
+
customer_id: stripeSubscription.customer,
|
|
1057
1052
|
subscription_id: stripeSubscription.id,
|
|
1058
1053
|
setup_intent_id: stripeSubscription.pending_setup_intent?.id,
|
|
1059
1054
|
},
|