payment-kit 1.17.12 → 1.18.1
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/integrations/arcblock/stake.ts +0 -5
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +254 -0
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +12 -11
- package/api/src/libs/payment.ts +47 -2
- package/api/src/libs/payout.ts +24 -0
- package/api/src/libs/util.ts +83 -1
- package/api/src/locales/en.ts +16 -1
- package/api/src/locales/zh.ts +28 -12
- package/api/src/queues/notification.ts +23 -1
- package/api/src/routes/invoices.ts +42 -5
- package/api/src/routes/payment-intents.ts +14 -1
- package/api/src/routes/payment-links.ts +17 -0
- package/api/src/routes/payment-methods.ts +28 -1
- package/api/src/routes/payouts.ts +103 -8
- package/api/src/store/migrations/20250206-update-donation-products.ts +56 -0
- package/api/src/store/models/payout.ts +6 -2
- package/api/src/store/models/types.ts +2 -0
- package/blocklet.yml +1 -1
- package/package.json +4 -4
- package/public/methods/default.png +0 -0
- package/src/app.tsx +10 -0
- package/src/components/customer/link.tsx +11 -2
- package/src/components/customer/overdraft-protection.tsx +2 -2
- package/src/components/info-card.tsx +6 -5
- package/src/components/invoice/table.tsx +4 -0
- package/src/components/payment-method/form.tsx +4 -4
- package/src/components/payouts/list.tsx +17 -2
- package/src/components/payouts/portal/list.tsx +192 -0
- package/src/components/subscription/items/actions.tsx +1 -2
- package/src/components/uploader.tsx +1 -1
- package/src/libs/util.ts +42 -1
- package/src/locales/en.tsx +10 -0
- package/src/locales/zh.tsx +10 -0
- package/src/pages/admin/billing/invoices/detail.tsx +21 -0
- package/src/pages/admin/payments/payouts/detail.tsx +65 -4
- package/src/pages/admin/settings/payment-methods/edit.tsx +12 -1
- package/src/pages/customer/index.tsx +12 -25
- package/src/pages/customer/invoice/detail.tsx +27 -3
- package/src/pages/customer/payout/detail.tsx +264 -0
- package/src/pages/customer/recharge.tsx +2 -2
- package/vite.config.ts +1 -0
|
@@ -4,7 +4,6 @@ import assert from 'assert';
|
|
|
4
4
|
|
|
5
5
|
import { isEthereumDid } from '@arcblock/did';
|
|
6
6
|
import { toStakeAddress } from '@arcblock/did-util';
|
|
7
|
-
import env from '@blocklet/sdk/lib/env';
|
|
8
7
|
import { BN, fromUnitToToken, toBN } from '@ocap/util';
|
|
9
8
|
|
|
10
9
|
import { Op } from 'sequelize';
|
|
@@ -26,10 +25,6 @@ export async function ensureStakedForGas() {
|
|
|
26
25
|
|
|
27
26
|
try {
|
|
28
27
|
const { state: account } = await client.getAccountState({ address: wallet.address });
|
|
29
|
-
if (!account) {
|
|
30
|
-
const hash = await client.declare({ moniker: env.appNameSlug, wallet });
|
|
31
|
-
logger.info(`declared app on chain ${host}`, { hash });
|
|
32
|
-
}
|
|
33
28
|
|
|
34
29
|
const address = toStakeAddress(wallet.address, wallet.address);
|
|
35
30
|
const { state: stake } = await client.getStakeState({ address });
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/brace-style */
|
|
2
|
+
/* eslint-disable @typescript-eslint/indent */
|
|
3
|
+
import { fromUnitToToken } from '@ocap/util';
|
|
4
|
+
import { getUrl } from '@blocklet/sdk';
|
|
5
|
+
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
6
|
+
import { translate } from '../../../locales';
|
|
7
|
+
import { CheckoutSession, Customer, PaymentLink, PaymentMethod, Payout } from '../../../store/models';
|
|
8
|
+
import { PaymentCurrency } from '../../../store/models/payment-currency';
|
|
9
|
+
import { formatTime } from '../../time';
|
|
10
|
+
import { getCustomerProfileUrl, getExplorerLink, getUserOrAppInfo } from '../../util';
|
|
11
|
+
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
12
|
+
import { getCustomerPayoutPageUrl } from '../../payout';
|
|
13
|
+
import { PaymentIntent } from '../../../store/models/payment-intent';
|
|
14
|
+
|
|
15
|
+
export interface CustomerRevenueSucceededEmailTemplateOptions {
|
|
16
|
+
payoutId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CustomerRevenueSucceededEmailTemplateContext {
|
|
20
|
+
locale: string;
|
|
21
|
+
at: string;
|
|
22
|
+
paymentIntentId: string;
|
|
23
|
+
|
|
24
|
+
chainHost: string | undefined;
|
|
25
|
+
userDid: string;
|
|
26
|
+
paymentInfo: string;
|
|
27
|
+
user: string;
|
|
28
|
+
revenueDetail: {
|
|
29
|
+
url: string;
|
|
30
|
+
title: string;
|
|
31
|
+
appDID: string;
|
|
32
|
+
logo?: string;
|
|
33
|
+
desc?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
viewPayoutLink: string;
|
|
37
|
+
viewTxHashLink: string;
|
|
38
|
+
type: 'payment' | 'donate';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @description
|
|
43
|
+
* @export
|
|
44
|
+
* @class CustomerRevenueSucceededEmailTemplate
|
|
45
|
+
* @implements {BaseEmailTemplate<CustomerRevenueSucceededEmailTemplateContext>}
|
|
46
|
+
*/
|
|
47
|
+
export class CustomerRevenueSucceededEmailTemplate
|
|
48
|
+
implements BaseEmailTemplate<CustomerRevenueSucceededEmailTemplateContext>
|
|
49
|
+
{
|
|
50
|
+
options: CustomerRevenueSucceededEmailTemplateOptions;
|
|
51
|
+
|
|
52
|
+
constructor(options: CustomerRevenueSucceededEmailTemplateOptions) {
|
|
53
|
+
this.options = options;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async getContext(): Promise<CustomerRevenueSucceededEmailTemplateContext> {
|
|
57
|
+
const payout = (await Payout.findByPk(this.options.payoutId, {
|
|
58
|
+
include: [
|
|
59
|
+
{
|
|
60
|
+
model: PaymentIntent,
|
|
61
|
+
as: 'paymentIntent',
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
})) as Payout & { paymentIntent: PaymentIntent };
|
|
65
|
+
if (!payout) {
|
|
66
|
+
throw new Error(`Payout(${this.options.payoutId}) not found`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!payout.paymentIntent) {
|
|
70
|
+
throw new Error(`PaymentIntent not found for payout: ${this.options.payoutId}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const customer = await Customer.findByPk(payout.customer_id);
|
|
74
|
+
if (!customer) {
|
|
75
|
+
throw new Error(`Customer not found: ${payout.customer_id}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const paymentCurrency = (await PaymentCurrency.findOne({
|
|
79
|
+
where: {
|
|
80
|
+
id: payout.currency_id,
|
|
81
|
+
},
|
|
82
|
+
})) as PaymentCurrency;
|
|
83
|
+
|
|
84
|
+
const userDid: string = customer.did;
|
|
85
|
+
const locale = await getUserLocale(userDid);
|
|
86
|
+
const at: string = formatTime(payout.created_at);
|
|
87
|
+
let type: 'payment' | 'donate' = 'payment';
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const checkoutSession = await CheckoutSession.findOne({
|
|
91
|
+
where: {
|
|
92
|
+
payment_intent_id: payout.payment_intent_id,
|
|
93
|
+
},
|
|
94
|
+
attributes: ['id', 'submit_type'],
|
|
95
|
+
});
|
|
96
|
+
if (checkoutSession && checkoutSession.submit_type === 'donate') {
|
|
97
|
+
type = 'donate';
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error(error);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const payer = await Customer.findByPk(payout.paymentIntent?.customer_id);
|
|
104
|
+
if (!payer) {
|
|
105
|
+
throw new Error(`Payer not found for paymentIntent: ${payout.paymentIntent?.customer_id}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const paymentInfo: string = `${fromUnitToToken(payout.amount, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
|
|
109
|
+
const userInfo = await getUserOrAppInfo(payer.did);
|
|
110
|
+
const revenueDetail = {
|
|
111
|
+
url: getCustomerProfileUrl({ userDid: payer.did, locale }),
|
|
112
|
+
title: translate('notification.customerRevenueSucceeded.sended', locale, {
|
|
113
|
+
address: payer.name || payer.did,
|
|
114
|
+
amount: paymentInfo,
|
|
115
|
+
}),
|
|
116
|
+
logo: userInfo?.avatar || getUrl('/methods/default.png'),
|
|
117
|
+
appDID: payer.did,
|
|
118
|
+
desc: '',
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(payout.payment_method_id);
|
|
122
|
+
// @ts-expect-error
|
|
123
|
+
const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
|
|
124
|
+
const viewPayoutLink = getCustomerPayoutPageUrl({
|
|
125
|
+
payoutId: this.options.payoutId,
|
|
126
|
+
userDid,
|
|
127
|
+
locale,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// @ts-expect-error
|
|
131
|
+
const txHash: string | undefined = payout?.payment_details?.[paymentMethod.type]?.tx_hash;
|
|
132
|
+
const viewTxHashLink: string =
|
|
133
|
+
(txHash &&
|
|
134
|
+
getExplorerLink({
|
|
135
|
+
type: 'tx',
|
|
136
|
+
did: txHash,
|
|
137
|
+
chainHost,
|
|
138
|
+
})) ||
|
|
139
|
+
'';
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
locale,
|
|
143
|
+
at,
|
|
144
|
+
type,
|
|
145
|
+
user: payer.name || userInfo?.name || payer.did,
|
|
146
|
+
paymentIntentId: payout.payment_intent_id,
|
|
147
|
+
userDid,
|
|
148
|
+
chainHost,
|
|
149
|
+
paymentInfo,
|
|
150
|
+
revenueDetail,
|
|
151
|
+
|
|
152
|
+
viewPayoutLink,
|
|
153
|
+
viewTxHashLink,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async getReference(paymentIntentId: string): Promise<string> {
|
|
158
|
+
try {
|
|
159
|
+
const checkoutSession = (await CheckoutSession.findOne({
|
|
160
|
+
where: {
|
|
161
|
+
payment_intent_id: paymentIntentId,
|
|
162
|
+
},
|
|
163
|
+
attributes: ['id', 'payment_link_id'],
|
|
164
|
+
})) as CheckoutSession;
|
|
165
|
+
if (!checkoutSession) {
|
|
166
|
+
throw new Error(`CheckoutSession not found for paymentIntentId: ${paymentIntentId}`);
|
|
167
|
+
}
|
|
168
|
+
if (!checkoutSession.payment_link_id) {
|
|
169
|
+
throw new Error(`Payment link cannot be found for checkoutSession: ${checkoutSession.id}`);
|
|
170
|
+
}
|
|
171
|
+
const paymentLink = await PaymentLink.findByPk(checkoutSession.payment_link_id);
|
|
172
|
+
if (!paymentLink) {
|
|
173
|
+
throw new Error(`Payment link cannot be found for payment_link_id(${checkoutSession.payment_link_id})`);
|
|
174
|
+
}
|
|
175
|
+
return paymentLink.donation_settings?.reference || '';
|
|
176
|
+
} catch (error) {
|
|
177
|
+
console.error(error);
|
|
178
|
+
return '';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async getTemplate(): Promise<BaseEmailTemplateType> {
|
|
183
|
+
const {
|
|
184
|
+
locale,
|
|
185
|
+
at,
|
|
186
|
+
user,
|
|
187
|
+
type,
|
|
188
|
+
paymentIntentId,
|
|
189
|
+
paymentInfo,
|
|
190
|
+
revenueDetail,
|
|
191
|
+
|
|
192
|
+
viewPayoutLink,
|
|
193
|
+
viewTxHashLink,
|
|
194
|
+
} = await this.getContext();
|
|
195
|
+
|
|
196
|
+
const reference = await this.getReference(paymentIntentId);
|
|
197
|
+
|
|
198
|
+
const template: BaseEmailTemplateType = {
|
|
199
|
+
title: translate(`notification.customerRevenueSucceeded.${type}.title`, locale),
|
|
200
|
+
body: translate(`notification.customerRevenueSucceeded.${type}.body`, locale, {
|
|
201
|
+
at,
|
|
202
|
+
amount: paymentInfo,
|
|
203
|
+
user,
|
|
204
|
+
}),
|
|
205
|
+
// @ts-expect-error
|
|
206
|
+
attachments: [
|
|
207
|
+
{
|
|
208
|
+
type: 'section',
|
|
209
|
+
fields: [
|
|
210
|
+
{
|
|
211
|
+
type: 'text',
|
|
212
|
+
data: {
|
|
213
|
+
type: 'plain',
|
|
214
|
+
color: '#9397A1',
|
|
215
|
+
text: translate('notification.customerRevenueSucceeded.sender', locale),
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
type: 'text',
|
|
220
|
+
data: {
|
|
221
|
+
type: 'plain',
|
|
222
|
+
text: ' ',
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
].filter(Boolean),
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
type: 'dapp',
|
|
229
|
+
data: revenueDetail,
|
|
230
|
+
},
|
|
231
|
+
].filter(Boolean),
|
|
232
|
+
// @ts-expect-error
|
|
233
|
+
actions: [
|
|
234
|
+
viewPayoutLink && {
|
|
235
|
+
name: translate('notification.customerRevenueSucceeded.viewDetail', locale),
|
|
236
|
+
title: translate('notification.customerRevenueSucceeded.viewDetail', locale),
|
|
237
|
+
link: viewPayoutLink,
|
|
238
|
+
},
|
|
239
|
+
viewTxHashLink && {
|
|
240
|
+
name: translate('notification.common.viewTxHash', locale),
|
|
241
|
+
title: translate('notification.common.viewTxHash', locale),
|
|
242
|
+
link: viewTxHashLink,
|
|
243
|
+
},
|
|
244
|
+
reference && {
|
|
245
|
+
name: translate('notification.customerRevenueSucceeded.donate.tipDetail', locale),
|
|
246
|
+
title: translate('notification.customerRevenueSucceeded.donate.tipDetail', locale),
|
|
247
|
+
link: reference,
|
|
248
|
+
},
|
|
249
|
+
].filter(Boolean),
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return template;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -5,7 +5,7 @@ import isEmpty from 'lodash/isEmpty';
|
|
|
5
5
|
import pWaitFor from 'p-wait-for';
|
|
6
6
|
import type { LiteralUnion } from 'type-fest';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { getUrl } from '@blocklet/sdk';
|
|
9
9
|
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
10
10
|
import { translate } from '../../../locales';
|
|
11
11
|
import {
|
|
@@ -22,9 +22,8 @@ import { PaymentCurrency } from '../../../store/models/payment-currency';
|
|
|
22
22
|
import { getCustomerInvoicePageUrl } from '../../invoice';
|
|
23
23
|
import logger from '../../logger';
|
|
24
24
|
import { formatTime } from '../../time';
|
|
25
|
-
import { getCustomerProfileUrl, getExplorerLink } from '../../util';
|
|
25
|
+
import { getBlockletJson, getCustomerProfileUrl, getExplorerLink, getUserOrAppInfo } from '../../util';
|
|
26
26
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
27
|
-
import { blocklet } from '../../auth';
|
|
28
27
|
|
|
29
28
|
export interface CustomerRewardSucceededEmailTemplateOptions {
|
|
30
29
|
checkoutSessionId: string;
|
|
@@ -197,8 +196,9 @@ export class CustomerRewardSucceededEmailTemplate
|
|
|
197
196
|
return null;
|
|
198
197
|
}
|
|
199
198
|
|
|
199
|
+
const blockletJson = await getBlockletJson();
|
|
200
200
|
const promises = paymentIntent.beneficiaries!.map((x: PaymentBeneficiary) => {
|
|
201
|
-
return
|
|
201
|
+
return getUserOrAppInfo(x.address, blockletJson);
|
|
202
202
|
});
|
|
203
203
|
const users = await Promise.all(promises);
|
|
204
204
|
if (!users.length) {
|
|
@@ -207,15 +207,12 @@ export class CustomerRewardSucceededEmailTemplate
|
|
|
207
207
|
}
|
|
208
208
|
const rewardDetail = paymentIntent.beneficiaries!.map((x: PaymentBeneficiary, index: number) => {
|
|
209
209
|
return {
|
|
210
|
-
url: getCustomerProfileUrl({ userDid: x.address, locale }),
|
|
210
|
+
url: users[index]?.url || getCustomerProfileUrl({ userDid: x.address, locale }),
|
|
211
211
|
title: translate('notification.customerRewardSucceeded.received', locale, {
|
|
212
|
-
address: users[index]?.
|
|
212
|
+
address: users[index]?.name || x.address,
|
|
213
213
|
amount: `${fromUnitToToken(x.share, paymentCurrency.decimal)} ${paymentCurrency.symbol}`,
|
|
214
214
|
}),
|
|
215
|
-
logo:
|
|
216
|
-
process.env.BLOCKLET_APP_URL && users[index]?.user?.avatar
|
|
217
|
-
? joinURL(process.env.BLOCKLET_APP_URL, users[index]?.user?.avatar as string)
|
|
218
|
-
: '',
|
|
215
|
+
logo: users[index]?.avatar ? users[index]?.avatar : getUrl('/methods/default.png'),
|
|
219
216
|
appDID: x.address,
|
|
220
217
|
desc: '',
|
|
221
218
|
};
|
|
@@ -246,7 +243,6 @@ export class CustomerRewardSucceededEmailTemplate
|
|
|
246
243
|
body: `${translate('notification.customerRewardSucceeded.body', locale, {
|
|
247
244
|
at,
|
|
248
245
|
amount: paymentInfo,
|
|
249
|
-
subject: `<${donationSettings.title}(link:${donationSettings.reference})>`,
|
|
250
246
|
})}`,
|
|
251
247
|
// @ts-expect-error
|
|
252
248
|
attachments: [
|
|
@@ -327,6 +323,11 @@ export class CustomerRewardSucceededEmailTemplate
|
|
|
327
323
|
title: translate('notification.common.viewTxHash', locale),
|
|
328
324
|
link: viewTxHashLink,
|
|
329
325
|
},
|
|
326
|
+
donationSettings.reference && {
|
|
327
|
+
name: translate('notification.customerRewardSucceeded.viewDetail', locale),
|
|
328
|
+
title: translate('notification.customerRewardSucceeded.viewDetail', locale),
|
|
329
|
+
link: donationSettings.reference,
|
|
330
|
+
},
|
|
330
331
|
].filter(Boolean),
|
|
331
332
|
};
|
|
332
333
|
|
package/api/src/libs/payment.ts
CHANGED
|
@@ -10,11 +10,19 @@ import cloneDeep from 'lodash/cloneDeep';
|
|
|
10
10
|
import type { LiteralUnion } from 'type-fest';
|
|
11
11
|
|
|
12
12
|
import { fetchErc20Allowance, fetchErc20Balance, fetchEtherBalance } from '../integrations/ethereum/token';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
Invoice,
|
|
15
|
+
PaymentCurrency,
|
|
16
|
+
PaymentIntent,
|
|
17
|
+
PaymentLink,
|
|
18
|
+
PaymentMethod,
|
|
19
|
+
TCustomer,
|
|
20
|
+
TLineItemExpanded,
|
|
21
|
+
} from '../store/models';
|
|
14
22
|
import type { TPaymentCurrency } from '../store/models/payment-currency';
|
|
15
23
|
import { blocklet, ethWallet, wallet } from './auth';
|
|
16
24
|
import logger from './logger';
|
|
17
|
-
import { OCAP_PAYMENT_TX_TYPE } from './util';
|
|
25
|
+
import { getBlockletJson, getUserOrAppInfo, OCAP_PAYMENT_TX_TYPE } from './util';
|
|
18
26
|
import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from './constants';
|
|
19
27
|
|
|
20
28
|
export interface SufficientForPaymentResult {
|
|
@@ -345,3 +353,40 @@ export async function isBalanceSufficientForRefund(args: {
|
|
|
345
353
|
|
|
346
354
|
throw new Error(`isBalanceSufficientForRefund: Payment method ${paymentMethod.type} not supported`);
|
|
347
355
|
}
|
|
356
|
+
|
|
357
|
+
export async function getDonationBenefits(paymentLink: PaymentLink, url?: string) {
|
|
358
|
+
const { donation_settings: donationSettings } = paymentLink;
|
|
359
|
+
if (!donationSettings) {
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
const { beneficiaries } = donationSettings;
|
|
363
|
+
const total = beneficiaries.reduce((t, x) => t + Number(x.share), 0);
|
|
364
|
+
if (total === 0) {
|
|
365
|
+
return beneficiaries;
|
|
366
|
+
}
|
|
367
|
+
const blockletJson = await getBlockletJson(url);
|
|
368
|
+
const result = await Promise.all(
|
|
369
|
+
beneficiaries.map(async (beneficiary) => {
|
|
370
|
+
const { address, share, name, avatar } = beneficiary;
|
|
371
|
+
try {
|
|
372
|
+
const info = await getUserOrAppInfo(address, blockletJson);
|
|
373
|
+
return {
|
|
374
|
+
address,
|
|
375
|
+
percent: (Number(share) * 100) / total,
|
|
376
|
+
name: name || info?.name || '',
|
|
377
|
+
avatar: avatar || info?.avatar || '',
|
|
378
|
+
url: info?.url || '',
|
|
379
|
+
type: info?.type || 'user',
|
|
380
|
+
};
|
|
381
|
+
} catch (error) {
|
|
382
|
+
return {
|
|
383
|
+
address,
|
|
384
|
+
percent: (Number(share) * 100) / total,
|
|
385
|
+
name: name || '',
|
|
386
|
+
avatar: avatar || '',
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { component } from '@blocklet/sdk';
|
|
2
|
+
import type { LiteralUnion } from 'type-fest';
|
|
3
|
+
import { withQuery } from 'ufo';
|
|
4
|
+
import { getConnectQueryParam } from './util';
|
|
5
|
+
|
|
6
|
+
export function getCustomerPayoutPageUrl({
|
|
7
|
+
payoutId,
|
|
8
|
+
userDid,
|
|
9
|
+
locale = 'en',
|
|
10
|
+
action = '',
|
|
11
|
+
}: {
|
|
12
|
+
payoutId: string;
|
|
13
|
+
userDid: string;
|
|
14
|
+
locale: LiteralUnion<'en' | 'zh', string>;
|
|
15
|
+
action?: LiteralUnion<'pay', string>;
|
|
16
|
+
}) {
|
|
17
|
+
return component.getUrl(
|
|
18
|
+
withQuery(`customer/payout/${payoutId}`, {
|
|
19
|
+
locale,
|
|
20
|
+
action,
|
|
21
|
+
...getConnectQueryParam({ userDid }),
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
}
|
package/api/src/libs/util.ts
CHANGED
|
@@ -6,8 +6,9 @@ import { getWalletDid } from '@blocklet/sdk/lib/did';
|
|
|
6
6
|
import { toStakeAddress } from '@arcblock/did-util';
|
|
7
7
|
import { customAlphabet } from 'nanoid';
|
|
8
8
|
import type { LiteralUnion } from 'type-fest';
|
|
9
|
-
import { joinURL, withQuery } from 'ufo';
|
|
9
|
+
import { joinURL, withQuery, withTrailingSlash } from 'ufo';
|
|
10
10
|
|
|
11
|
+
import axios from 'axios';
|
|
11
12
|
import dayjs from './dayjs';
|
|
12
13
|
import { blocklet, wallet } from './auth';
|
|
13
14
|
import type { Subscription } from '../store/models';
|
|
@@ -72,6 +73,10 @@ export const STRIPE_EVENTS: any[] = [
|
|
|
72
73
|
'refund.updated',
|
|
73
74
|
];
|
|
74
75
|
|
|
76
|
+
const api = axios.create({
|
|
77
|
+
timeout: 10 * 1000,
|
|
78
|
+
});
|
|
79
|
+
|
|
75
80
|
export function md5(input: string) {
|
|
76
81
|
return crypto.createHash('md5').update(input).digest('hex');
|
|
77
82
|
}
|
|
@@ -177,6 +182,83 @@ export function getDataObjectFromQuery(
|
|
|
177
182
|
return result;
|
|
178
183
|
}
|
|
179
184
|
|
|
185
|
+
export function safeJsonParse(input: any, defaultValue: any) {
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(input);
|
|
188
|
+
} catch {
|
|
189
|
+
if (defaultValue === undefined) {
|
|
190
|
+
return input;
|
|
191
|
+
}
|
|
192
|
+
return defaultValue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const cachedBlockletJsonResult = new Map<string, { data: any; expiry: number }>();
|
|
197
|
+
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
|
198
|
+
|
|
199
|
+
export async function getBlockletJson(url?: string) {
|
|
200
|
+
const blockletKey = url || process.env.BLOCKLET_APP_URL || 'default';
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
|
|
203
|
+
if (cachedBlockletJsonResult.has(blockletKey)) {
|
|
204
|
+
const cached = cachedBlockletJsonResult.get(blockletKey);
|
|
205
|
+
if (cached && now < cached.expiry) {
|
|
206
|
+
return cached.data;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const scriptUrl = new URL('__blocklet__.js?type=json', withTrailingSlash(url || process.env.BLOCKLET_APP_URL));
|
|
210
|
+
try {
|
|
211
|
+
const { data: blockletMeta } = await api.get(scriptUrl.href);
|
|
212
|
+
cachedBlockletJsonResult.set(blockletKey, { data: blockletMeta, expiry: now + CACHE_TTL });
|
|
213
|
+
return blockletMeta;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
logger.error(`getBlockletJson error for ${scriptUrl}`, err);
|
|
216
|
+
if (process.env.BLOCKLET_MOUNT_POINTS) {
|
|
217
|
+
const BLOCKLET_MOUNT_POINTS = safeJsonParse(process.env.BLOCKLET_MOUNT_POINTS, []);
|
|
218
|
+
return {
|
|
219
|
+
componentMountPoints: BLOCKLET_MOUNT_POINTS,
|
|
220
|
+
appId: process.env.BLOCKLET_APP_ID,
|
|
221
|
+
appName: process.env.BLOCKLET_APP_NAME,
|
|
222
|
+
appLogo: joinURL(process.env.BLOCKLET_APP_URL!, '.well-known/service/blocklet/logo'),
|
|
223
|
+
appUrl: process.env.BLOCKLET_APP_URL,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function getUserOrAppInfo(
|
|
231
|
+
address: string,
|
|
232
|
+
blockletJson?: any
|
|
233
|
+
): Promise<{ name: string; avatar: string; type: 'dapp' | 'user'; url: string } | null> {
|
|
234
|
+
if (blockletJson) {
|
|
235
|
+
if (blockletJson?.appId === address) {
|
|
236
|
+
return {
|
|
237
|
+
name: blockletJson?.appName,
|
|
238
|
+
avatar: blockletJson?.appLogo,
|
|
239
|
+
type: 'dapp',
|
|
240
|
+
url: blockletJson?.appUrl,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const appInfo = blockletJson?.componentMountPoints?.find((x: any) => x.appId === address);
|
|
244
|
+
if (appInfo) {
|
|
245
|
+
return {
|
|
246
|
+
name: appInfo.name,
|
|
247
|
+
avatar: joinURL(process.env.BLOCKLET_APP_URL!, `.well-known/service/blocklet/logo-bundle/${appInfo.did}`),
|
|
248
|
+
type: 'dapp',
|
|
249
|
+
url: joinURL(process.env.BLOCKLET_APP_URL!, appInfo.mountPoint),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const { user } = await blocklet.getUser(address);
|
|
254
|
+
return {
|
|
255
|
+
name: user?.fullName,
|
|
256
|
+
avatar: joinURL(process.env.BLOCKLET_APP_URL!, user?.avatar),
|
|
257
|
+
type: 'user',
|
|
258
|
+
url: getCustomerProfileUrl({ userDid: address, locale: 'en' }),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
180
262
|
// @FIXME: 这个应该封装在某个通用类库里面 @jianchao @wangshijun
|
|
181
263
|
export function getExplorerLink({
|
|
182
264
|
type,
|
package/api/src/locales/en.ts
CHANGED
|
@@ -156,10 +156,25 @@ export default flat({
|
|
|
156
156
|
|
|
157
157
|
customerRewardSucceeded: {
|
|
158
158
|
title: 'Thanks for your reward of {amount}',
|
|
159
|
-
body: 'Thanks for your reward on {at}
|
|
159
|
+
body: 'Thanks for your reward on {at}, the amount of reward is {amount}. Your support is our driving force, thanks for your generous support!',
|
|
160
160
|
received: '{address} has received {amount}',
|
|
161
|
+
viewDetail: 'View Reference',
|
|
161
162
|
},
|
|
162
163
|
|
|
164
|
+
customerRevenueSucceeded: {
|
|
165
|
+
donate: {
|
|
166
|
+
title: 'You received a tip',
|
|
167
|
+
body: 'Congratulations! On {at}, you received a tip of {amount} from {user}.',
|
|
168
|
+
tipDetail: 'View Tip Reference',
|
|
169
|
+
},
|
|
170
|
+
payment: {
|
|
171
|
+
title: 'You received a payment',
|
|
172
|
+
body: 'Congratulations! On {at}, you received a payment of {amount} from {user}.',
|
|
173
|
+
},
|
|
174
|
+
sender: 'Payer',
|
|
175
|
+
viewDetail: 'View Details',
|
|
176
|
+
sended: '{address} has sent {amount}',
|
|
177
|
+
},
|
|
163
178
|
subscriptWillCanceled: {
|
|
164
179
|
title: '{productName} subscription is about to be cancelled ',
|
|
165
180
|
pastDue:
|
package/api/src/locales/zh.ts
CHANGED
|
@@ -93,7 +93,7 @@ export default flat({
|
|
|
93
93
|
},
|
|
94
94
|
|
|
95
95
|
oneTimePaymentSucceeded: {
|
|
96
|
-
title: '
|
|
96
|
+
title: '恭喜!您已成功购买 {productName}',
|
|
97
97
|
body: '感谢您于 {at} 成功购买了 {productName}。我们将竭诚为您提供优质的服务,祝您使用愉快!',
|
|
98
98
|
},
|
|
99
99
|
|
|
@@ -122,16 +122,16 @@ export default flat({
|
|
|
122
122
|
title: '{productName} 扣费失败',
|
|
123
123
|
body: '很抱歉地通知您,您的 {productName} 于 {at} 扣费失败。如有任何疑问,请及时联系我们。谢谢!',
|
|
124
124
|
reason: {
|
|
125
|
-
noDidWallet: '您尚未绑定 DID Wallet,请绑定 DID Wallet
|
|
126
|
-
noDelegation: '您的 DID Wallet
|
|
127
|
-
noTransferPermission: '您的 DID Wallet
|
|
128
|
-
noTokenPermission: '您的 DID Wallet
|
|
129
|
-
noTransferTo: '您的 DID Wallet
|
|
130
|
-
noEnoughAllowance: '
|
|
131
|
-
noToken: '
|
|
132
|
-
noEnoughToken: '您的账户代币余额为 {balance},不足 {price}
|
|
133
|
-
noSupported: '
|
|
134
|
-
txSendFailed: '
|
|
125
|
+
noDidWallet: '您尚未绑定 DID Wallet,请绑定 DID Wallet,确保余额充足。',
|
|
126
|
+
noDelegation: '您的 DID Wallet 尚未授权,请更新授权。',
|
|
127
|
+
noTransferPermission: '您的 DID Wallet 未授予应用转账权限,请更新授权。',
|
|
128
|
+
noTokenPermission: '您的 DID Wallet 未授予应用对应通证的转账权限,请更新授权。',
|
|
129
|
+
noTransferTo: '您的 DID Wallet 未授予应用扣费权限,请更新授权。',
|
|
130
|
+
noEnoughAllowance: '扣款金额超出单笔转账限额,请更新授权。',
|
|
131
|
+
noToken: '您的账户没有任何代币,请充值代币。',
|
|
132
|
+
noEnoughToken: '您的账户代币余额为 {balance},不足 {price},请充值代币。',
|
|
133
|
+
noSupported: '不支持使用代币扣费,请检查您的套餐。',
|
|
134
|
+
txSendFailed: '扣费交易发送失败。',
|
|
135
135
|
},
|
|
136
136
|
},
|
|
137
137
|
|
|
@@ -152,8 +152,24 @@ export default flat({
|
|
|
152
152
|
|
|
153
153
|
customerRewardSucceeded: {
|
|
154
154
|
title: '感谢您打赏的 {amount}',
|
|
155
|
-
body: '感谢您于 {at}
|
|
155
|
+
body: '感谢您于 {at} 的打赏,打赏金额为 {amount}。您的支持是我们前行的动力,谢谢您的大力支持!',
|
|
156
156
|
received: '{address} 收到了 {amount}',
|
|
157
|
+
viewDetail: '查看详情',
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
customerRevenueSucceeded: {
|
|
161
|
+
donate: {
|
|
162
|
+
title: '您收到了一笔打赏',
|
|
163
|
+
body: '恭喜您于 {at} 收到了一笔来自 {user} 的打赏,打赏金额为 {amount}。',
|
|
164
|
+
tipDetail: '查看打赏原文',
|
|
165
|
+
},
|
|
166
|
+
payment: {
|
|
167
|
+
title: '您收到了一笔付款',
|
|
168
|
+
body: '恭喜您于 {at} 收到一笔来自 {user} 的 {amount}。',
|
|
169
|
+
},
|
|
170
|
+
sender: '付款方',
|
|
171
|
+
viewDetail: '查看详情',
|
|
172
|
+
sended: '{address} 付款给您 {amount}',
|
|
157
173
|
},
|
|
158
174
|
|
|
159
175
|
subscriptWillCanceled: {
|
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
SubscriptionStakeSlashSucceededEmailTemplateOptions,
|
|
57
57
|
} from '../libs/notification/template/subscription-stake-slash-succeeded';
|
|
58
58
|
import createQueue from '../libs/queue';
|
|
59
|
-
import { CheckoutSession, EventType, Invoice, PaymentLink, Refund, Subscription } from '../store/models';
|
|
59
|
+
import { CheckoutSession, EventType, Invoice, PaymentLink, Payout, Refund, Subscription } from '../store/models';
|
|
60
60
|
import {
|
|
61
61
|
UsageReportEmptyEmailTemplate,
|
|
62
62
|
UsageReportEmptyEmailTemplateOptions,
|
|
@@ -69,6 +69,10 @@ import {
|
|
|
69
69
|
OverdraftProtectionExhaustedEmailTemplate,
|
|
70
70
|
OverdraftProtectionExhaustedEmailTemplateOptions,
|
|
71
71
|
} from '../libs/notification/template/subscription-overdraft-protection-exhausted';
|
|
72
|
+
import {
|
|
73
|
+
CustomerRevenueSucceededEmailTemplate,
|
|
74
|
+
CustomerRevenueSucceededEmailTemplateOptions,
|
|
75
|
+
} from '../libs/notification/template/customer-revenue-succeeded';
|
|
72
76
|
|
|
73
77
|
export type NotificationQueueJobOptions = any;
|
|
74
78
|
|
|
@@ -141,6 +145,10 @@ function getNotificationTemplate(job: NotificationQueueJob): BaseEmailTemplate {
|
|
|
141
145
|
);
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
if (job.type === 'payout.paid') {
|
|
149
|
+
return new CustomerRevenueSucceededEmailTemplate(job.options as CustomerRevenueSucceededEmailTemplateOptions);
|
|
150
|
+
}
|
|
151
|
+
|
|
144
152
|
throw new Error(`Unknown job type: ${job.type}`);
|
|
145
153
|
}
|
|
146
154
|
|
|
@@ -339,4 +347,18 @@ export async function startNotificationQueue() {
|
|
|
339
347
|
},
|
|
340
348
|
});
|
|
341
349
|
});
|
|
350
|
+
|
|
351
|
+
events.on('payout.paid', (payout: Payout) => {
|
|
352
|
+
if (payout.customer_id) {
|
|
353
|
+
notificationQueue.push({
|
|
354
|
+
id: `payout.paid.${payout.id}`,
|
|
355
|
+
job: {
|
|
356
|
+
type: 'payout.paid',
|
|
357
|
+
options: {
|
|
358
|
+
payoutId: payout.id,
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
});
|
|
342
364
|
}
|