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
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;
@@ -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: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
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);
@@ -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);