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.
- 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/notification/template/one-time-payment-succeeded.ts +5 -3
- package/api/src/libs/notification/template/subscription-canceled.ts +3 -3
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +4 -3
- package/api/src/libs/notification/template/subscription-renew-failed.ts +5 -4
- package/api/src/libs/notification/template/subscription-renewed.ts +2 -1
- package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +3 -4
- package/api/src/libs/notification/template/subscription-succeeded.ts +2 -1
- package/api/src/libs/notification/template/subscription-upgraded.ts +6 -4
- package/api/src/libs/notification/template/subscription-will-canceled.ts +6 -3
- package/api/src/libs/notification/template/subscription-will-renew.ts +1 -1
- 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/customers.ts +79 -5
- package/api/src/routes/payment-currencies.ts +117 -1
- package/api/src/routes/payment-methods.ts +19 -9
- package/api/src/routes/subscriptions.ts +15 -9
- package/api/src/store/migrations/20250305-vault-config.ts +21 -0
- package/api/src/store/models/invoice.ts +4 -2
- 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 +2 -2
- package/package.json +18 -18
- package/src/app.tsx +117 -121
- package/src/components/actions.tsx +32 -9
- package/src/components/copyable.tsx +2 -2
- package/src/components/customer/overdraft-protection.tsx +1 -0
- package/src/components/layout/admin.tsx +6 -0
- package/src/components/layout/user.tsx +38 -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 +27 -5
- package/src/components/subscription/portal/list.tsx +24 -6
- package/src/components/subscription/status.tsx +2 -2
- package/src/libs/util.ts +15 -0
- package/src/locales/en.tsx +42 -0
- package/src/locales/zh.tsx +37 -0
- package/src/pages/admin/payments/payouts/detail.tsx +47 -38
- 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 +352 -0
- package/src/pages/customer/index.tsx +247 -154
- package/src/pages/customer/invoice/detail.tsx +1 -1
- package/src/pages/customer/payout/detail.tsx +9 -2
- package/src/pages/customer/recharge.tsx +6 -2
- package/src/pages/customer/subscription/change-payment.tsx +1 -1
- package/src/pages/customer/subscription/change-plan.tsx +1 -1
- package/src/pages/customer/subscription/detail.tsx +8 -3
- package/src/pages/customer/subscription/embed.tsx +142 -84
- 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;
|
|
@@ -87,9 +87,6 @@ export class OneTimePaymentSucceededEmailTemplate
|
|
|
87
87
|
const productName = await getMainProductNameByCheckoutSession(checkoutSession);
|
|
88
88
|
const at: string = formatTime(checkoutSession.created_at);
|
|
89
89
|
|
|
90
|
-
const paymentInfo: string = `${fromUnitToToken(checkoutSession?.amount_total, paymentCurrency.decimal)} ${
|
|
91
|
-
paymentCurrency.symbol
|
|
92
|
-
}`;
|
|
93
90
|
const hasNft: boolean = checkoutSession?.nft_mint_status === 'minted';
|
|
94
91
|
const nftMintItem: NftMintItem | undefined = hasNft
|
|
95
92
|
? // @ts-expect-error
|
|
@@ -98,6 +95,11 @@ export class OneTimePaymentSucceededEmailTemplate
|
|
|
98
95
|
|
|
99
96
|
const paymentIntent = await PaymentIntent.findByPk(checkoutSession!.payment_intent_id);
|
|
100
97
|
const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(paymentIntent!.payment_method_id);
|
|
98
|
+
|
|
99
|
+
const paymentInfo: string = `${fromUnitToToken(checkoutSession?.amount_total, paymentCurrency.decimal)} ${
|
|
100
|
+
paymentCurrency.symbol
|
|
101
|
+
}${paymentMethod ? ` (${paymentMethod.name})` : ''}`;
|
|
102
|
+
|
|
101
103
|
// @ts-expect-error
|
|
102
104
|
const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
|
|
103
105
|
const viewSubscriptionLink = '';
|
|
@@ -76,9 +76,6 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
76
76
|
const locale = await getUserLocale(userDid);
|
|
77
77
|
const productName = await getMainProductName(subscription.id);
|
|
78
78
|
const at: string = formatTime(subscription.canceled_at * 1000);
|
|
79
|
-
|
|
80
|
-
// @ts-ignore
|
|
81
|
-
const paymentInfo: string = `${fromUnitToToken(invoice.total, invoice?.paymentCurrency?.decimal)} ${invoice?.paymentCurrency?.symbol}`;
|
|
82
79
|
const currentPeriodStart: string = formatTime(invoice.period_start * 1000);
|
|
83
80
|
const currentPeriodEnd: string = formatTime(invoice.period_end * 1000);
|
|
84
81
|
const duration: string = prettyMsI18n(
|
|
@@ -89,6 +86,9 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
89
86
|
);
|
|
90
87
|
const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(invoice.default_payment_method_id);
|
|
91
88
|
|
|
89
|
+
// @ts-ignore
|
|
90
|
+
const paymentInfo: string = `${fromUnitToToken(invoice.total, invoice?.paymentCurrency?.decimal)} ${invoice?.paymentCurrency?.symbol}${paymentMethod ? ` (${paymentMethod.name})` : ''}`;
|
|
91
|
+
|
|
92
92
|
const customerCancelRequest = subscription.cancelation_details?.reason === 'cancellation_requested';
|
|
93
93
|
let cancellationReason = '';
|
|
94
94
|
const { stakeEnough, stakeReturn, stakeSlash, hasStake } = await getSubscriptionStakeCancellation(
|
|
@@ -75,9 +75,6 @@ export class SubscriptionRefundSucceededEmailTemplate
|
|
|
75
75
|
const productName = await getMainProductName(refund.subscription_id!);
|
|
76
76
|
const at: string = formatTime(refund.created_at);
|
|
77
77
|
|
|
78
|
-
const paymentInfo: string = `${fromUnitToToken(paymentIntent?.amount_received || '0', paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
|
|
79
|
-
const refundInfo: string = `${fromUnitToToken(refund.amount, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
|
|
80
|
-
|
|
81
78
|
let invoice = null;
|
|
82
79
|
let currentPeriodStart;
|
|
83
80
|
let currentPeriodEnd;
|
|
@@ -105,6 +102,10 @@ export class SubscriptionRefundSucceededEmailTemplate
|
|
|
105
102
|
const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(
|
|
106
103
|
refund.payment_method_id || invoice?.default_payment_method_id
|
|
107
104
|
);
|
|
105
|
+
|
|
106
|
+
const paymentInfo: string = `${fromUnitToToken(paymentIntent?.amount_received || '0', paymentCurrency.decimal)} ${paymentCurrency.symbol}${paymentMethod ? ` (${paymentMethod.name})` : ''}`;
|
|
107
|
+
const refundInfo: string = `${fromUnitToToken(refund.amount, paymentCurrency.decimal)} ${paymentCurrency.symbol}${paymentMethod ? ` (${paymentMethod.name})` : ''}`;
|
|
108
|
+
|
|
108
109
|
// @ts-expect-error
|
|
109
110
|
const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
|
|
110
111
|
const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
|
|
@@ -112,9 +112,6 @@ export class SubscriptionRenewFailedEmailTemplate
|
|
|
112
112
|
const nftMintItem: NftMintItem | undefined = hasNft
|
|
113
113
|
? checkoutSession?.nft_mint_details?.[checkoutSession?.nft_mint_details?.type as NFTMintChainType]
|
|
114
114
|
: undefined;
|
|
115
|
-
const paymentInfo: string = `${fromUnitToToken(invoice.amount_remaining, paymentCurrency.decimal)} ${
|
|
116
|
-
paymentCurrency.symbol
|
|
117
|
-
}`;
|
|
118
115
|
|
|
119
116
|
const currentPeriodStart: string = formatTime(invoice.period_start * 1000);
|
|
120
117
|
const currentPeriodEnd: string = formatTime(invoice.period_end * 1000);
|
|
@@ -124,8 +121,12 @@ export class SubscriptionRenewFailedEmailTemplate
|
|
|
124
121
|
locale: getPrettyMsI18nLocale(locale),
|
|
125
122
|
}
|
|
126
123
|
);
|
|
127
|
-
|
|
128
124
|
const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(invoice.default_payment_method_id);
|
|
125
|
+
|
|
126
|
+
const paymentInfo: string = `${fromUnitToToken(invoice.amount_remaining, paymentCurrency.decimal)} ${
|
|
127
|
+
paymentCurrency.symbol
|
|
128
|
+
}${paymentMethod ? ` (${paymentMethod.name})` : ''}`;
|
|
129
|
+
|
|
129
130
|
const chainHost: string | undefined =
|
|
130
131
|
paymentMethod?.settings?.[checkoutSession?.nft_mint_details?.type as NFTMintChainType]?.api_host;
|
|
131
132
|
const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
|
|
@@ -102,7 +102,6 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
|
|
|
102
102
|
? checkoutSession?.nft_mint_details?.[checkoutSession?.nft_mint_details?.type as NFTMintChainType]
|
|
103
103
|
: undefined;
|
|
104
104
|
const amountPaid = +fromUnitToToken(invoice.total, paymentCurrency.decimal);
|
|
105
|
-
const paymentInfo: string = `${fromUnitToToken(invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
|
|
106
105
|
const currentPeriodStart: string = formatTime(invoice.period_start * 1000);
|
|
107
106
|
const currentPeriodEnd: string = formatTime(invoice.period_end * 1000);
|
|
108
107
|
const duration: string = prettyMsI18n(
|
|
@@ -113,6 +112,8 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
|
|
|
113
112
|
);
|
|
114
113
|
|
|
115
114
|
const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(invoice.default_payment_method_id);
|
|
115
|
+
const paymentInfo: string = `${fromUnitToToken(invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}${paymentMethod ? ` (${paymentMethod.name})` : ''}`;
|
|
116
|
+
|
|
116
117
|
const chainHost: string | undefined =
|
|
117
118
|
paymentMethod?.settings?.[checkoutSession?.nft_mint_details?.type as NFTMintChainType]?.api_host;
|
|
118
119
|
const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
|
|
@@ -10,7 +10,6 @@ import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
|
10
10
|
import { formatTime } from '../../time';
|
|
11
11
|
import { getExplorerLink, getSubscriptionNotificationCustomActions } from '../../util';
|
|
12
12
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
13
|
-
import logger from '../../logger';
|
|
14
13
|
|
|
15
14
|
export interface SubscriptionStakeSlashSucceededEmailTemplateOptions {
|
|
16
15
|
paymentIntentId: string;
|
|
@@ -83,9 +82,10 @@ export class SubscriptionStakeSlashSucceededEmailTemplate
|
|
|
83
82
|
const productName = await getMainProductName(this.options.subscriptionId);
|
|
84
83
|
const at: string = formatTime(paymentIntent.created_at);
|
|
85
84
|
|
|
86
|
-
const slashInfo: string = `${fromUnitToToken(paymentIntent?.amount_received || '0', paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
|
|
87
|
-
|
|
88
85
|
const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(paymentIntent.payment_method_id);
|
|
86
|
+
|
|
87
|
+
const slashInfo: string = `${fromUnitToToken(paymentIntent?.amount_received || '0', paymentCurrency.decimal)} ${paymentCurrency.symbol}${paymentMethod ? ` (${paymentMethod.name})` : ''}`;
|
|
88
|
+
|
|
89
89
|
// @ts-expect-error
|
|
90
90
|
const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
|
|
91
91
|
const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
|
|
@@ -140,7 +140,6 @@ export class SubscriptionStakeSlashSucceededEmailTemplate
|
|
|
140
140
|
customActions,
|
|
141
141
|
} = await this.getContext();
|
|
142
142
|
|
|
143
|
-
logger.info('SubscriptionStakeSlashSucceededEmailTemplate getTemplate', { productName, at, userDid, slashInfo, viewSubscriptionLink, viewTxHashLink });
|
|
144
143
|
const template: BaseEmailTemplateType = {
|
|
145
144
|
title: `${translate('notification.subscriptionStakeSlashSucceeded.title', locale, {
|
|
146
145
|
productName,
|
|
@@ -120,7 +120,6 @@ export class SubscriptionSucceededEmailTemplate
|
|
|
120
120
|
const oneTimeProductInfo = await getOneTimeProductInfo(subscription.latest_invoice_id as string, paymentCurrency);
|
|
121
121
|
const paymentAmount = await getPaymentAmountForCycleSubscription(subscription, paymentCurrency);
|
|
122
122
|
|
|
123
|
-
const paymentInfo: string = `${paymentAmount} ${paymentCurrency.symbol}`;
|
|
124
123
|
const hasNft: boolean = checkoutSession?.nft_mint_status === 'minted';
|
|
125
124
|
const nftMintItem: NftMintItem | undefined = hasNft
|
|
126
125
|
? checkoutSession?.nft_mint_details?.[checkoutSession?.nft_mint_details?.type as NFTMintChainType]
|
|
@@ -135,6 +134,8 @@ export class SubscriptionSucceededEmailTemplate
|
|
|
135
134
|
);
|
|
136
135
|
|
|
137
136
|
const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(invoice.default_payment_method_id);
|
|
137
|
+
const paymentInfo: string = `${paymentAmount} ${paymentCurrency.symbol}${paymentMethod ? ` (${paymentMethod.name})` : ''}`;
|
|
138
|
+
|
|
138
139
|
// @FIXME: 获取 chainHost 困难的一批?
|
|
139
140
|
const chainHost: string | undefined =
|
|
140
141
|
paymentMethod?.settings?.[checkoutSession?.nft_mint_details?.type as NFTMintChainType]?.api_host;
|
|
@@ -88,10 +88,6 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
88
88
|
const productName = await getMainProductName(subscription.id);
|
|
89
89
|
const at: string = formatTime(subscription.created_at);
|
|
90
90
|
|
|
91
|
-
const paymentInfo: string = `${fromUnitToToken(
|
|
92
|
-
paymentIntent?.amount || invoice.amount_paid,
|
|
93
|
-
paymentCurrency.decimal
|
|
94
|
-
)} ${paymentCurrency.symbol}`;
|
|
95
91
|
const hasNft: boolean = checkoutSession?.nft_mint_status === 'minted';
|
|
96
92
|
const nftMintItem: NftMintItem | undefined = hasNft
|
|
97
93
|
? checkoutSession?.nft_mint_details?.[checkoutSession?.nft_mint_details?.type as NFTMintChainType]
|
|
@@ -110,6 +106,12 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
110
106
|
);
|
|
111
107
|
|
|
112
108
|
const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(invoice.default_payment_method_id);
|
|
109
|
+
|
|
110
|
+
const paymentInfo: string = `${fromUnitToToken(
|
|
111
|
+
paymentIntent?.amount || invoice.amount_paid,
|
|
112
|
+
paymentCurrency.decimal
|
|
113
|
+
)} ${paymentCurrency.symbol}${paymentMethod ? ` (${paymentMethod.name})` : ''}`;
|
|
114
|
+
|
|
113
115
|
// @ts-expect-error
|
|
114
116
|
const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
|
|
115
117
|
const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
|
|
@@ -6,7 +6,7 @@ import type { ManipulateType } from 'dayjs';
|
|
|
6
6
|
import dayjs from '../../dayjs';
|
|
7
7
|
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
8
8
|
import { translate } from '../../../locales';
|
|
9
|
-
import { Customer, Invoice, Subscription } from '../../../store/models';
|
|
9
|
+
import { Customer, Invoice, PaymentMethod, Subscription } from '../../../store/models';
|
|
10
10
|
import { PaymentCurrency } from '../../../store/models/payment-currency';
|
|
11
11
|
import { getCustomerInvoicePageUrl } from '../../invoice';
|
|
12
12
|
import logger from '../../logger';
|
|
@@ -82,7 +82,10 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
82
82
|
where: {
|
|
83
83
|
id: subscription.latest_invoice_id,
|
|
84
84
|
},
|
|
85
|
-
include: [
|
|
85
|
+
include: [
|
|
86
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
87
|
+
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
88
|
+
],
|
|
86
89
|
});
|
|
87
90
|
if (!invoice) {
|
|
88
91
|
throw new Error(`Invoice(${subscription.latest_invoice_id}) not found`);
|
|
@@ -94,7 +97,7 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
94
97
|
const at: string = formatTime(cancelAt * 1000);
|
|
95
98
|
const willCancelDuration: string = getSimplifyDuration((cancelAt - now) * 1000, locale);
|
|
96
99
|
// @ts-ignore
|
|
97
|
-
const paymentInfo: string = `${fromUnitToToken(+invoice.total, invoice?.paymentCurrency?.decimal)} ${invoice?.paymentCurrency?.symbol}`;
|
|
100
|
+
const paymentInfo: string = `${fromUnitToToken(+invoice.total, invoice?.paymentCurrency?.decimal)} ${invoice?.paymentCurrency?.symbol}${invoice?.paymentMethod ? `(${invoice?.paymentMethod.name})` : ''}`;
|
|
98
101
|
|
|
99
102
|
let body: string = translate('notification.subscriptWillCanceled.body', locale, {
|
|
100
103
|
productName,
|
|
@@ -117,7 +117,7 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
117
117
|
const paidType: string = isPrePaid
|
|
118
118
|
? translate('notification.common.prepaid', locale)
|
|
119
119
|
: translate('notification.common.postpaid', locale);
|
|
120
|
-
const paymentInfo: string = `${paymentDetail?.price || '0'} ${paymentCurrency.symbol}`;
|
|
120
|
+
const paymentInfo: string = `${paymentDetail?.price || '0'} ${paymentCurrency.symbol}${paymentMethod ? ` (${paymentMethod.name})` : ''}`;
|
|
121
121
|
const currentPeriodStart: string = isPrePaid
|
|
122
122
|
? formatTime(invoice.period_end * 1000)
|
|
123
123
|
: formatTime(invoice.period_start * 1000);
|
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);
|