payment-kit 1.19.10 → 1.19.12

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.
@@ -0,0 +1,274 @@
1
+ /* eslint-disable @typescript-eslint/brace-style */
2
+ /* eslint-disable @typescript-eslint/indent */
3
+ import { translate } from '../../../locales';
4
+ import { PaymentIntent, PaymentMethod, Refund, Customer, CheckoutSession } from '../../../store/models';
5
+ import { Invoice } from '../../../store/models/invoice';
6
+ import { PaymentCurrency } from '../../../store/models/payment-currency';
7
+ import { getUserLocale } from '../../../integrations/blocklet/notification';
8
+ import { getMainProductNameByCheckoutSession } from '../../product';
9
+ import { formatTime } from '../../time';
10
+ import { formatCurrencyInfo, getExplorerLink } from '../../util';
11
+ import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
12
+ import { getCustomerInvoicePageUrl } from '../../invoice';
13
+
14
+ export interface OneTimePaymentRefundSucceededEmailTemplateOptions {
15
+ refundId: string;
16
+ }
17
+
18
+ interface OneTimePaymentRefundSucceededEmailTemplateContext {
19
+ locale: string;
20
+ productName: string | undefined;
21
+ at: string;
22
+ chainHost: string | undefined;
23
+ userDid: string;
24
+ paymentInfo: string;
25
+ refundInfo: string;
26
+ viewInvoiceLink: string;
27
+ viewTxHashLink: string | undefined;
28
+ refund: Refund;
29
+ invoiceNumber?: string;
30
+ }
31
+
32
+ export class OneTimePaymentRefundSucceededEmailTemplate
33
+ implements BaseEmailTemplate<OneTimePaymentRefundSucceededEmailTemplateContext>
34
+ {
35
+ options: OneTimePaymentRefundSucceededEmailTemplateOptions;
36
+
37
+ constructor(options: OneTimePaymentRefundSucceededEmailTemplateOptions) {
38
+ this.options = options;
39
+ }
40
+
41
+ async getContext(): Promise<OneTimePaymentRefundSucceededEmailTemplateContext> {
42
+ const refund = await Refund.findByPk(this.options.refundId);
43
+ if (!refund) {
44
+ throw new Error(`Refund not found: ${this.options.refundId}`);
45
+ }
46
+ if (refund.status !== 'succeeded') {
47
+ throw new Error(`Refund not succeeded: ${this.options.refundId}`);
48
+ }
49
+
50
+ const customer = await Customer.findByPk(refund.customer_id);
51
+ if (!customer) {
52
+ throw new Error(`Customer not found: ${refund.customer_id}`);
53
+ }
54
+ const userDid = customer.did;
55
+ const locale = await getUserLocale(userDid);
56
+
57
+ let invoiceNumber: string | undefined;
58
+ let productName: string | undefined;
59
+ let viewInvoiceLink = '';
60
+
61
+ if (refund.invoice_id) {
62
+ const invoice = await Invoice.findByPk(refund.invoice_id);
63
+ if (invoice) {
64
+ invoiceNumber = invoice.number;
65
+ viewInvoiceLink = getCustomerInvoicePageUrl({
66
+ invoiceId: refund.invoice_id,
67
+ userDid,
68
+ locale,
69
+ });
70
+
71
+ if (invoice.checkout_session_id) {
72
+ const checkoutSession = await CheckoutSession.findByPk(invoice.checkout_session_id);
73
+ if (checkoutSession) {
74
+ productName = await getMainProductNameByCheckoutSession(checkoutSession);
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ const paymentIntent = await PaymentIntent.findByPk(refund.payment_intent_id);
81
+ const paymentCurrency = await PaymentCurrency.findByPk(refund.currency_id);
82
+ if (!paymentCurrency) {
83
+ throw new Error(`PaymentCurrency not found: ${refund.currency_id}`);
84
+ }
85
+
86
+ const paymentMethod = await PaymentMethod.findByPk(refund.payment_method_id);
87
+
88
+ const paymentInfo = formatCurrencyInfo(paymentIntent?.amount_received || '0', paymentCurrency, paymentMethod);
89
+ const refundInfo = formatCurrencyInfo(refund.amount, paymentCurrency, paymentMethod);
90
+
91
+ // @ts-expect-error
92
+ const chainHost = paymentMethod?.settings?.[paymentMethod?.type]?.api_host;
93
+ // @ts-expect-error
94
+ const txHash = refund?.payment_details?.[paymentMethod?.type]?.tx_hash;
95
+ const viewTxHashLink =
96
+ txHash &&
97
+ getExplorerLink({
98
+ type: 'tx',
99
+ did: txHash,
100
+ chainHost,
101
+ });
102
+
103
+ const at = formatTime(refund.created_at);
104
+
105
+ return {
106
+ locale,
107
+ productName,
108
+ at,
109
+ userDid,
110
+ chainHost,
111
+ paymentInfo,
112
+ refundInfo,
113
+ viewInvoiceLink,
114
+ viewTxHashLink,
115
+ refund,
116
+ invoiceNumber,
117
+ };
118
+ }
119
+
120
+ async getTemplate(): Promise<BaseEmailTemplateType> {
121
+ const {
122
+ locale,
123
+ productName,
124
+ at,
125
+ userDid,
126
+ paymentInfo,
127
+ refundInfo,
128
+ viewInvoiceLink,
129
+ viewTxHashLink,
130
+ refund,
131
+ invoiceNumber,
132
+ } = await this.getContext();
133
+
134
+ if (refund.type === 'stake_return') {
135
+ throw new Error('Stake return is not supported for one-time payment refunds');
136
+ }
137
+
138
+ const commonFields = [
139
+ {
140
+ type: 'text',
141
+ data: {
142
+ type: 'plain',
143
+ color: '#9397A1',
144
+ text: translate('notification.common.account', locale),
145
+ },
146
+ },
147
+ {
148
+ type: 'text',
149
+ data: {
150
+ type: 'plain',
151
+ text: userDid,
152
+ },
153
+ },
154
+ ];
155
+
156
+ const productFields = productName
157
+ ? [
158
+ {
159
+ type: 'text',
160
+ data: {
161
+ type: 'plain',
162
+ color: '#9397A1',
163
+ text: translate('notification.common.product', locale),
164
+ },
165
+ },
166
+ {
167
+ type: 'text',
168
+ data: {
169
+ type: 'plain',
170
+ text: productName,
171
+ },
172
+ },
173
+ ]
174
+ : [];
175
+
176
+ const amountFields = [
177
+ {
178
+ type: 'text',
179
+ data: {
180
+ type: 'plain',
181
+ color: '#9397A1',
182
+ text: translate('notification.common.paymentAmount', locale),
183
+ },
184
+ },
185
+ {
186
+ type: 'text',
187
+ data: {
188
+ type: 'plain',
189
+ text: paymentInfo,
190
+ },
191
+ },
192
+ {
193
+ type: 'text',
194
+ data: {
195
+ type: 'plain',
196
+ color: '#9397A1',
197
+ text: translate('notification.common.refundAmount', locale),
198
+ },
199
+ },
200
+ {
201
+ type: 'text',
202
+ data: {
203
+ type: 'plain',
204
+ text: refundInfo,
205
+ },
206
+ },
207
+ ];
208
+
209
+ const invoiceFields = invoiceNumber
210
+ ? [
211
+ {
212
+ type: 'text',
213
+ data: {
214
+ type: 'plain',
215
+ color: '#9397A1',
216
+ text: translate('notification.common.invoiceNumber', locale),
217
+ },
218
+ },
219
+ {
220
+ type: 'text',
221
+ data: {
222
+ type: 'plain',
223
+ text: invoiceNumber,
224
+ },
225
+ },
226
+ ]
227
+ : [];
228
+
229
+ const actions = [
230
+ viewInvoiceLink && {
231
+ name: translate('notification.common.viewInvoice', locale),
232
+ title: translate('notification.common.viewInvoice', locale),
233
+ link: viewInvoiceLink,
234
+ },
235
+ viewTxHashLink && {
236
+ name: translate('notification.common.viewTxHash', locale),
237
+ title: translate('notification.common.viewTxHash', locale),
238
+ link: viewTxHashLink,
239
+ },
240
+ ].filter(Boolean);
241
+
242
+ const titleKey = productName
243
+ ? 'notification.oneTimePaymentRefundSucceeded.title'
244
+ : 'notification.oneTimePaymentRefundSucceeded.titleNoProduct';
245
+
246
+ const bodyKey = productName
247
+ ? 'notification.oneTimePaymentRefundSucceeded.body'
248
+ : 'notification.oneTimePaymentRefundSucceeded.bodyNoProduct';
249
+
250
+ const template: BaseEmailTemplateType = {
251
+ title: translate(titleKey, locale, {
252
+ productName,
253
+ invoiceNumber: invoiceNumber || '',
254
+ }),
255
+ body: translate(bodyKey, locale, {
256
+ at,
257
+ productName,
258
+ refundInfo,
259
+ invoiceNumber: invoiceNumber || '',
260
+ }),
261
+ // @ts-expect-error
262
+ attachments: [
263
+ {
264
+ type: 'section',
265
+ fields: [...commonFields, ...productFields, ...amountFields, ...invoiceFields].filter(Boolean),
266
+ },
267
+ ].filter(Boolean),
268
+ // @ts-ignore
269
+ actions,
270
+ };
271
+
272
+ return template;
273
+ }
274
+ }
@@ -10,6 +10,7 @@ import { getMainProductNameByCheckoutSession } from '../../product';
10
10
  import { formatTime } from '../../time';
11
11
  import { formatCurrencyInfo, getExplorerLink } from '../../util';
12
12
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
13
+ import { getCustomerInvoicePageUrl } from '../../invoice';
13
14
 
14
15
  export interface OneTimePaymentSucceededEmailTemplateOptions {
15
16
  checkoutSessionId: string;
@@ -25,7 +26,6 @@ interface OneTimePaymentSucceededEmailTemplateContext {
25
26
  userDid: string;
26
27
  paymentInfo: string;
27
28
 
28
- viewSubscriptionLink: string;
29
29
  viewInvoiceLink: string;
30
30
  viewTxHashLink: string | undefined;
31
31
  }
@@ -99,8 +99,13 @@ export class OneTimePaymentSucceededEmailTemplate
99
99
 
100
100
  // @ts-expect-error
101
101
  const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
102
- const viewSubscriptionLink = '';
103
- const viewInvoiceLink = '';
102
+ const viewInvoiceLink = paymentIntent?.invoice_id
103
+ ? getCustomerInvoicePageUrl({
104
+ invoiceId: paymentIntent.invoice_id,
105
+ userDid,
106
+ locale,
107
+ })
108
+ : '';
104
109
 
105
110
  // @ts-expect-error
106
111
  const txHash: string | undefined = paymentIntent?.payment_details?.[paymentMethod.type]?.tx_hash;
@@ -122,25 +127,14 @@ export class OneTimePaymentSucceededEmailTemplate
122
127
  chainHost,
123
128
  paymentInfo,
124
129
 
125
- viewSubscriptionLink,
126
130
  viewInvoiceLink,
127
131
  viewTxHashLink,
128
132
  };
129
133
  }
130
134
 
131
135
  async getTemplate(): Promise<BaseEmailTemplateType> {
132
- const {
133
- locale,
134
- productName,
135
- at,
136
- nftMintItem,
137
- chainHost,
138
- userDid,
139
- paymentInfo,
140
- viewSubscriptionLink,
141
- viewInvoiceLink,
142
- viewTxHashLink,
143
- } = await this.getContext();
136
+ const { locale, productName, at, nftMintItem, chainHost, userDid, paymentInfo, viewInvoiceLink, viewTxHashLink } =
137
+ await this.getContext();
144
138
 
145
139
  const template: BaseEmailTemplateType = {
146
140
  title: `${translate('notification.oneTimePaymentSucceeded.title', locale, {
@@ -213,11 +207,6 @@ export class OneTimePaymentSucceededEmailTemplate
213
207
  ].filter(Boolean),
214
208
  // @ts-ignore
215
209
  actions: [
216
- viewSubscriptionLink && {
217
- name: translate('notification.common.viewSubscription', locale),
218
- title: translate('notification.common.viewSubscription', locale),
219
- link: viewSubscriptionLink,
220
- },
221
210
  viewInvoiceLink && {
222
211
  name: translate('notification.common.viewInvoice', locale),
223
212
  title: translate('notification.common.viewInvoice', locale),
@@ -4,6 +4,7 @@ import { translate } from '../../../locales';
4
4
  import { PaymentIntent, PaymentMethod, Refund, Subscription } from '../../../store/models';
5
5
  import { Invoice } from '../../../store/models/invoice';
6
6
  import { PaymentCurrency } from '../../../store/models/payment-currency';
7
+ import { getCustomerInvoicePageUrl } from '../../invoice';
7
8
  import { formatTime } from '../../time';
8
9
  import { formatCurrencyInfo, getExplorerLink } from '../../util';
9
10
  import { BaseSubscriptionEmailTemplate, BaseEmailTemplateType } from './base';
@@ -28,6 +29,7 @@ interface SubscriptionRefundSucceededEmailTemplateContext {
28
29
  unusedDuration: string;
29
30
  viewSubscriptionLink: string;
30
31
  viewTxHashLink: string | undefined;
32
+ viewInvoiceLink: string;
31
33
  refund: Refund;
32
34
  customActions: any[];
33
35
  isCreditSubscription: boolean;
@@ -49,9 +51,12 @@ export class SubscriptionRefundSucceededEmailTemplate extends BaseSubscriptionEm
49
51
  if (refund.status !== 'succeeded') {
50
52
  throw new Error(`Refund not succeeded: ${this.options.refundId}`);
51
53
  }
54
+ if (!refund.subscription_id) {
55
+ throw new Error(`Refund must have subscription_id: ${this.options.refundId}`);
56
+ }
52
57
 
53
58
  // 获取基础订阅数据
54
- const basicData = await this.getSubscriptionBasicData(refund.subscription_id!);
59
+ const basicData = await this.getSubscriptionBasicData(refund.subscription_id);
55
60
  const { userDid, locale, productName, isCreditSubscription } = basicData;
56
61
 
57
62
  // 获取支付相关信息
@@ -102,6 +107,7 @@ export class SubscriptionRefundSucceededEmailTemplate extends BaseSubscriptionEm
102
107
  // 获取链接
103
108
  let customActions: any[] = [];
104
109
  let viewSubscriptionLink = '';
110
+
105
111
  if (refund?.subscription_id) {
106
112
  const subscription = await Subscription.findByPk(refund.subscription_id);
107
113
  if (subscription) {
@@ -118,6 +124,13 @@ export class SubscriptionRefundSucceededEmailTemplate extends BaseSubscriptionEm
118
124
  }
119
125
  }
120
126
 
127
+ const viewInvoiceLink = refund.invoice_id
128
+ ? getCustomerInvoicePageUrl({
129
+ invoiceId: refund.invoice_id,
130
+ locale,
131
+ userDid,
132
+ })
133
+ : '';
121
134
  // 获取交易哈希链接
122
135
  // @ts-expect-error
123
136
  const chainHost = paymentMethod?.settings?.[paymentMethod?.type]?.api_host;
@@ -152,6 +165,7 @@ export class SubscriptionRefundSucceededEmailTemplate extends BaseSubscriptionEm
152
165
  refund,
153
166
  customActions,
154
167
  isCreditSubscription,
168
+ viewInvoiceLink,
155
169
  };
156
170
  }
157
171
 
@@ -174,6 +188,7 @@ export class SubscriptionRefundSucceededEmailTemplate extends BaseSubscriptionEm
174
188
  viewTxHashLink,
175
189
  refund,
176
190
  isCreditSubscription,
191
+ viewInvoiceLink,
177
192
  } = context;
178
193
 
179
194
  // 如果是质押返还类型,使用特殊模板
@@ -266,11 +281,16 @@ export class SubscriptionRefundSucceededEmailTemplate extends BaseSubscriptionEm
266
281
 
267
282
  // 构建操作按钮
268
283
  const actions = [
269
- {
284
+ viewSubscriptionLink && {
270
285
  name: translate('notification.common.viewSubscription', locale),
271
286
  title: translate('notification.common.viewSubscription', locale),
272
287
  link: viewSubscriptionLink,
273
288
  },
289
+ viewInvoiceLink && {
290
+ name: translate('notification.common.viewInvoice', locale),
291
+ title: translate('notification.common.viewInvoice', locale),
292
+ link: viewInvoiceLink,
293
+ },
274
294
  viewTxHashLink && {
275
295
  name: translate('notification.common.viewTxHash', locale),
276
296
  title: translate('notification.common.viewTxHash', locale),
@@ -331,7 +351,7 @@ export class SubscriptionRefundSucceededEmailTemplate extends BaseSubscriptionEm
331
351
 
332
352
  // 构建操作按钮
333
353
  const actions = [
334
- {
354
+ viewSubscriptionLink && {
335
355
  name: translate('notification.common.viewSubscription', locale),
336
356
  title: translate('notification.common.viewSubscription', locale),
337
357
  link: viewSubscriptionLink,
@@ -10,6 +10,7 @@ import { translate } from '../../../locales';
10
10
  import { Invoice, Price, SubscriptionItem } from '../../../store/models';
11
11
  import type { PaymentDetail } from '../../payment';
12
12
  import { getSubscriptionPaymentAddress, getPaymentAmountForCycleSubscription } from '../../subscription';
13
+
13
14
  import { formatTime, getSimplifyDuration } from '../../time';
14
15
  import { formatCurrencyInfo, getCustomerRechargeLink } from '../../util';
15
16
  import { BaseSubscriptionEmailTemplate, BaseEmailTemplateType } from './base';
@@ -38,6 +39,7 @@ interface SubscriptionWillRenewEmailTemplateContext {
38
39
  customActions: any[];
39
40
  isCreditSubscription: boolean;
40
41
  isStripe: boolean;
42
+ isMetered: boolean;
41
43
  }
42
44
 
43
45
  export class SubscriptionWillRenewEmailTemplate extends BaseSubscriptionEmailTemplate<SubscriptionWillRenewEmailTemplateContext> {
@@ -102,17 +104,24 @@ export class SubscriptionWillRenewEmailTemplate extends BaseSubscriptionEmailTem
102
104
 
103
105
  // 获取支付类型和周期信息
104
106
  const { isPrePaid, interval } = await this.getPaymentCategory(subscription.id);
107
+ const isMetered = !isPrePaid; // 按量计费就是后付费
108
+
105
109
  const paidType = isPrePaid
106
110
  ? translate('notification.common.prepaid', locale)
107
111
  : translate('notification.common.postpaid', locale);
108
112
 
109
- const paymentInfo = formatCurrencyInfo(
113
+ // 对于按量计费,在金额后添加预估说明
114
+ const basePaymentInfo = formatCurrencyInfo(
110
115
  paymentDetail?.price || '0',
111
116
  paymentCurrency,
112
117
  paymentInfoResult.paymentMethod,
113
118
  true
114
119
  );
115
120
 
121
+ const paymentInfo = isMetered
122
+ ? translate('notification.subscriptionWillRenew.estimatedAmountNote', locale, { amount: basePaymentInfo })
123
+ : basePaymentInfo;
124
+
116
125
  // 计算周期时间 - 使用安全的回退机制
117
126
  const periodStart = invoice?.period_start || subscription.current_period_start;
118
127
  const periodInfo = this.formatSubscriptionPeriod(
@@ -148,6 +157,7 @@ export class SubscriptionWillRenewEmailTemplate extends BaseSubscriptionEmailTem
148
157
  customActions: links.customActions,
149
158
  isCreditSubscription,
150
159
  isStripe,
160
+ isMetered: !isPrePaid,
151
161
  };
152
162
  }
153
163
 
@@ -198,6 +208,7 @@ export class SubscriptionWillRenewEmailTemplate extends BaseSubscriptionEmailTem
198
208
  customActions,
199
209
  isCreditSubscription,
200
210
  isStripe,
211
+ isMetered,
201
212
  } = context;
202
213
 
203
214
  // 如果当前时间大于预计扣费时间,那么不发送通知
@@ -206,7 +217,9 @@ export class SubscriptionWillRenewEmailTemplate extends BaseSubscriptionEmailTem
206
217
  }
207
218
 
208
219
  const canPay = paymentDetail.balance >= paymentDetail.price;
209
- if (canPay && !this.options.required) {
220
+ // 如果是按量付费的预估金额,余额充足,不发送通知
221
+ // 如果是预付费,余额充足,非必要不发送通知
222
+ if (canPay && (!this.options.required || isMetered)) {
210
223
  return null;
211
224
  }
212
225
 
@@ -1,6 +1,6 @@
1
1
  import { env } from '@blocklet/sdk/lib/config';
2
2
  import type { TransactionInput } from '@ocap/client';
3
- import { BN } from '@ocap/util';
3
+ import { BN, fromTokenToUnit } from '@ocap/util';
4
4
  import cloneDeep from 'lodash/cloneDeep';
5
5
  import isEqual from 'lodash/isEqual';
6
6
  import pAll from 'p-all';
@@ -983,3 +983,72 @@ export function isCreditMetered(price: TPrice | TPriceExpanded) {
983
983
  export function isCreditMeteredLineItems(lineItems: TLineItemExpanded[]) {
984
984
  return lineItems.every((item) => item.price && isCreditMetered(item.price));
985
985
  }
986
+
987
+ /**
988
+ * Validates payment amounts meet minimum requirements
989
+ * @param lineItems Line items to validate
990
+ * @param currencyId Currency ID for validation
991
+ * @param mode Checkout mode
992
+ * @returns Validation result with error message if any
993
+ */
994
+ export function validatePaymentAmounts(
995
+ lineItems: TLineItemExpanded[],
996
+ currency: PaymentCurrency,
997
+ checkoutSession: CheckoutSession
998
+ ): { valid: boolean; error?: string } {
999
+ const enableGrouping = checkoutSession.enable_subscription_grouping;
1000
+ const oneTimeItems = getOneTimeLineItems(lineItems);
1001
+ const recurringItems = getRecurringLineItems(lineItems);
1002
+
1003
+ const minAmountInUnits = fromTokenToUnit(0.5, currency.decimal);
1004
+
1005
+ // Case 1: All one-time items - validate total payment amount
1006
+ if (recurringItems.length === 0 && oneTimeItems.length > 0) {
1007
+ const { total } = getCheckoutAmount(lineItems, currency.id);
1008
+ if (new BN(total).lt(new BN(minAmountInUnits))) {
1009
+ return {
1010
+ valid: false,
1011
+ error: 'Total payment amount must be greater or equal to 0.5 USD',
1012
+ };
1013
+ }
1014
+ return { valid: true };
1015
+ }
1016
+
1017
+ // Case 2: Mixed or subscription only - validate subscription pricing
1018
+ if (recurringItems.length > 0) {
1019
+ if (enableGrouping) {
1020
+ // When grouping is enabled, validate each subscription product's unit price
1021
+ const priceGroups = groupLineItemsByPrice(lineItems);
1022
+
1023
+ for (const [priceId, items] of Object.entries(priceGroups)) {
1024
+ // Calculate total unit price for this subscription product
1025
+ let totalUnitPrice = new BN(0);
1026
+
1027
+ items.forEach((item) => {
1028
+ const price = item.upsell_price || item.price;
1029
+ const unitPrice = getPriceUintAmountByCurrency(price, currency.id);
1030
+ totalUnitPrice = totalUnitPrice.add(new BN(unitPrice).mul(new BN(item.quantity)));
1031
+ });
1032
+
1033
+ if (totalUnitPrice.lt(new BN(minAmountInUnits))) {
1034
+ const productName = items[0]?.price?.product?.name || `Product ${priceId}`;
1035
+ return {
1036
+ valid: false,
1037
+ error: `product "${productName}" unit price must be greater or equal to 0.5 USD`,
1038
+ };
1039
+ }
1040
+ }
1041
+ } else {
1042
+ // When grouping is disabled, validate total subscription amount
1043
+ const { renew } = getCheckoutAmount(lineItems, currency.id);
1044
+ if (new BN(renew).lt(new BN(minAmountInUnits))) {
1045
+ return {
1046
+ valid: false,
1047
+ error: 'Total subscription amount must be greater or equal to 0.5 USD',
1048
+ };
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ return { valid: true };
1054
+ }
@@ -49,6 +49,7 @@ export default flat({
49
49
  shouldPayAmount: 'Should pay amount',
50
50
  billedAmount: 'Billed amount',
51
51
  viewCreditGrant: 'View Credit Balance',
52
+ invoiceNumber: 'Invoice Number',
52
53
  },
53
54
 
54
55
  billingDiscrepancy: {
@@ -112,6 +113,7 @@ export default flat({
112
113
  unableToPayReason:
113
114
  'The estimated payment amount is {price}, but the current balance is insufficient ({balance}), please ensure that your account has enough balance to avoid payment failure.',
114
115
  renewAmount: 'Payment amount',
116
+ estimatedAmountNote: 'Estimate {amount}, billed based on final usage',
115
117
  },
116
118
 
117
119
  subscriptionRenewed: {
@@ -145,6 +147,14 @@ export default flat({
145
147
  body: 'Your subscription to {productName} has been successfully refunded on {at}, with a refund amount of {refundInfo}. If you have any questions, please feel free to contact us.',
146
148
  },
147
149
 
150
+ oneTimePaymentRefundSucceeded: {
151
+ title: '{productName} refund successful',
152
+ body: 'Your purchase of {productName} has been successfully refunded on {at}, with a refund amount of {refundInfo}. If you have any questions, please feel free to contact us.',
153
+ titleNoProduct: 'You have a refund from invoice {invoiceNumber}',
154
+ bodyNoProduct:
155
+ 'You have a refund from invoice {invoiceNumber} that has been successfully processed on {at}, with a refund amount of {refundInfo}. If you have any questions, please feel free to contact us.',
156
+ },
157
+
148
158
  subscriptionStakeReturnSucceeded: {
149
159
  title: '{productName} stake return successful',
150
160
  body: 'The stake for your subscription to {productName} has been returned on {at}, with a return amount of {refundInfo}. If you have any questions, please feel free to contact us.',
@@ -49,6 +49,7 @@ export default flat({
49
49
  shouldPayAmount: '应收金额',
50
50
  billedAmount: '实缴金额',
51
51
  viewCreditGrant: '查看额度',
52
+ invoiceNumber: '账单编号',
52
53
  },
53
54
 
54
55
  sendTo: '发送给',
@@ -111,6 +112,7 @@ export default flat({
111
112
  unableToPayReason:
112
113
  '预计扣款金额为 {price},但当前余额不足(余额为 {balance}),请确保您的账户余额充足,避免扣费失败。',
113
114
  renewAmount: '扣费金额',
115
+ estimatedAmountNote: '预估 {amount},按最终用量计费',
114
116
  },
115
117
 
116
118
  subscriptionRenewed: {
@@ -141,6 +143,14 @@ export default flat({
141
143
  body: '您订阅的 {productName} 在 {at} 已退款成功,退款金额为 {refundInfo}。如有任何疑问,请随时与我们联系。',
142
144
  },
143
145
 
146
+ oneTimePaymentRefundSucceeded: {
147
+ title: '{productName} 退款成功',
148
+ body: '您购买的 {productName} 在 {at} 已退款成功,退款金额为 {refundInfo}。如有任何疑问,请随时与我们联系。',
149
+ titleNoProduct: '您有一笔来自 {invoiceNumber} 账单的退款',
150
+ bodyNoProduct:
151
+ '您有一笔来自 {invoiceNumber} 账单的退款在 {at} 已退款成功,退款金额为 {refundInfo}。如有任何疑问,请随时与我们联系。',
152
+ },
153
+
144
154
  subscriptionStakeReturnSucceeded: {
145
155
  title: '{productName} 质押退还成功',
146
156
  body: '您订阅的 {productName} 在 {at} 已退还押金,退还金额为 {refundInfo}。如有任何疑问,请随时与我们联系。',
@@ -334,10 +334,13 @@ async function createCreditTransaction(
334
334
  meterEventId: context.meterEvent.id,
335
335
  });
336
336
 
337
- let description = `Consume ${fromUnitToToken(consumeAmount, context.meter.paymentCurrency.decimal)}${context.meter.paymentCurrency.symbol}`;
337
+ let description = `Consume ${fromUnitToToken(consumeAmount, context.meter.paymentCurrency.decimal)} ${context.meter.paymentCurrency.symbol}`;
338
338
  if (context.meterEvent.getSubscriptionId()) {
339
339
  description += 'for Subscription';
340
340
  }
341
+ if (context.meterEvent.metadata?.description) {
342
+ description = context.meterEvent.metadata.description;
343
+ }
341
344
 
342
345
  try {
343
346
  const transaction = await CreditTransaction.create({
@@ -21,6 +21,10 @@ import {
21
21
  SubscriptionRefundSucceededEmailTemplate,
22
22
  SubscriptionRefundSucceededEmailTemplateOptions,
23
23
  } from '../libs/notification/template/subscription-refund-succeeded';
24
+ import {
25
+ OneTimePaymentRefundSucceededEmailTemplate,
26
+ OneTimePaymentRefundSucceededEmailTemplateOptions,
27
+ } from '../libs/notification/template/one-time-payment-refund-succeeded';
24
28
  import {
25
29
  SubscriptionRenewFailedEmailTemplate,
26
30
  SubscriptionRenewFailedEmailTemplateOptions,
@@ -187,7 +191,7 @@ function calculateNextNotificationTime(preference: NotificationPreference): numb
187
191
  return Math.floor(nextTime.valueOf() / 1000);
188
192
  }
189
193
 
190
- function getNotificationTemplate(job: NotificationQueueJob): BaseEmailTemplate {
194
+ async function getNotificationTemplate(job: NotificationQueueJob): Promise<BaseEmailTemplate> {
191
195
  if (job.type === 'usage.report.empty') {
192
196
  return new UsageReportEmptyEmailTemplate(job.options as UsageReportEmptyEmailTemplateOptions);
193
197
  }
@@ -225,7 +229,16 @@ function getNotificationTemplate(job: NotificationQueueJob): BaseEmailTemplate {
225
229
  return new SubscriptionCanceledEmailTemplate(job.options as SubscriptionCanceledEmailTemplateOptions);
226
230
  }
227
231
  if (job.type === 'refund.succeeded') {
228
- return new SubscriptionRefundSucceededEmailTemplate(job.options as SubscriptionRefundSucceededEmailTemplateOptions);
232
+ const { refundId } = job.options as { refundId: string };
233
+ const refund = await Refund.findByPk(refundId);
234
+ if (refund?.subscription_id) {
235
+ return new SubscriptionRefundSucceededEmailTemplate(
236
+ job.options as SubscriptionRefundSucceededEmailTemplateOptions
237
+ );
238
+ }
239
+ return new OneTimePaymentRefundSucceededEmailTemplate(
240
+ job.options as OneTimePaymentRefundSucceededEmailTemplateOptions
241
+ );
229
242
  }
230
243
  if (job.type === 'customer.reward.succeeded') {
231
244
  return new CustomerRewardSucceededEmailTemplate(job.options as CustomerRewardSucceededEmailTemplateOptions);
@@ -264,7 +277,7 @@ function getNotificationTemplate(job: NotificationQueueJob): BaseEmailTemplate {
264
277
 
265
278
  async function handleNotificationJob(job: NotificationQueueJob): Promise<void> {
266
279
  try {
267
- const template = getNotificationTemplate(job);
280
+ const template = await getNotificationTemplate(job);
268
281
 
269
282
  await new Notification(template, job.type).send();
270
283
  logger.info('handleImmediateNotificationJob.success', { job });
@@ -41,6 +41,7 @@ import {
41
41
  getCheckoutSessionSubscriptionIds,
42
42
  getSubscriptionLineItems,
43
43
  isCreditMeteredLineItems,
44
+ validatePaymentAmounts,
44
45
  } from '../libs/session';
45
46
  import { getDaysUntilCancel, getDaysUntilDue, getSubscriptionTrialSetup } from '../libs/subscription';
46
47
  import {
@@ -1074,6 +1075,14 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
1074
1075
  true
1075
1076
  );
1076
1077
 
1078
+ // Validate payment amounts meet minimum requirements
1079
+ if (paymentMethod.type === 'stripe') {
1080
+ const result = validatePaymentAmounts(lineItems, paymentCurrency, checkoutSession);
1081
+ if (!result.valid) {
1082
+ return res.status(400).json({ error: result.error });
1083
+ }
1084
+ }
1085
+
1077
1086
  let customer = await Customer.findOne({ where: { did: req.user.did } });
1078
1087
  if (!customer) {
1079
1088
  const { user: userInfo } = await blocklet.getUser(req.user.did);
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.19.10
17
+ version: 1.19.12
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
@@ -72,11 +72,11 @@ capabilities:
72
72
  clusterMode: false
73
73
  component: true
74
74
  screenshots:
75
- - setting.png
76
- - payment.png
77
- - checkout.png
78
- - subscription_detail.png
79
- - customer.png
75
+ - 3a4cab81c52c29662db8794b05ccc7c7.png
76
+ - 77ac49b79ae920f0f253ce8c694ffd65.png
77
+ - 1ef9e15ac36d4af5bef34941000ba3af.png
78
+ - 7ea8ef758865ecf6edb712d3534d2974.png
79
+ - 0ffe164ebe4aa2eb43f8d87f87683f7f.png
80
80
  components:
81
81
  - name: image-bin
82
82
  source:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.19.10",
3
+ "version": "1.19.12",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
@@ -44,31 +44,31 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@abtnode/cron": "^1.16.46",
47
- "@arcblock/did": "^1.21.0",
47
+ "@arcblock/did": "^1.21.1",
48
48
  "@arcblock/did-auth-storage-nedb": "^1.7.1",
49
- "@arcblock/did-connect": "^3.0.36",
50
- "@arcblock/did-util": "^1.21.0",
51
- "@arcblock/jwt": "^1.21.0",
52
- "@arcblock/ux": "^3.0.36",
53
- "@arcblock/validator": "^1.21.0",
54
- "@blocklet/did-space-js": "^1.1.10",
49
+ "@arcblock/did-connect": "^3.0.39",
50
+ "@arcblock/did-util": "^1.21.1",
51
+ "@arcblock/jwt": "^1.21.1",
52
+ "@arcblock/ux": "^3.0.39",
53
+ "@arcblock/validator": "^1.21.1",
54
+ "@blocklet/did-space-js": "^1.1.13",
55
55
  "@blocklet/error": "^0.2.5",
56
56
  "@blocklet/js-sdk": "^1.16.46",
57
57
  "@blocklet/logger": "^1.16.46",
58
- "@blocklet/payment-react": "1.19.10",
58
+ "@blocklet/payment-react": "1.19.12",
59
59
  "@blocklet/sdk": "^1.16.46",
60
- "@blocklet/ui-react": "^3.0.36",
61
- "@blocklet/uploader": "^0.2.4",
62
- "@blocklet/xss": "^0.2.2",
60
+ "@blocklet/ui-react": "^3.0.39",
61
+ "@blocklet/uploader": "^0.2.5",
62
+ "@blocklet/xss": "^0.2.3",
63
63
  "@mui/icons-material": "^7.1.2",
64
64
  "@mui/lab": "7.0.0-beta.14",
65
65
  "@mui/material": "^7.1.2",
66
66
  "@mui/system": "^7.1.1",
67
- "@ocap/asset": "^1.21.0",
68
- "@ocap/client": "^1.21.0",
69
- "@ocap/mcrypto": "^1.21.0",
70
- "@ocap/util": "^1.21.0",
71
- "@ocap/wallet": "^1.21.0",
67
+ "@ocap/asset": "^1.21.1",
68
+ "@ocap/client": "^1.21.1",
69
+ "@ocap/mcrypto": "^1.21.1",
70
+ "@ocap/util": "^1.21.1",
71
+ "@ocap/wallet": "^1.21.1",
72
72
  "@stripe/react-stripe-js": "^2.9.0",
73
73
  "@stripe/stripe-js": "^2.4.0",
74
74
  "ahooks": "^3.8.5",
@@ -124,7 +124,7 @@
124
124
  "devDependencies": {
125
125
  "@abtnode/types": "^1.16.46",
126
126
  "@arcblock/eslint-config-ts": "^0.3.3",
127
- "@blocklet/payment-types": "1.19.10",
127
+ "@blocklet/payment-types": "1.19.12",
128
128
  "@types/cookie-parser": "^1.4.9",
129
129
  "@types/cors": "^2.8.19",
130
130
  "@types/debug": "^4.1.12",
@@ -170,5 +170,5 @@
170
170
  "parser": "typescript"
171
171
  }
172
172
  },
173
- "gitHead": "96665501b84f130e074df9a3d70f9b6f48cc3309"
173
+ "gitHead": "2496138990f95bb979125127a06c5e9d731877c9"
174
174
  }
@@ -190,8 +190,8 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
190
190
  },
191
191
  }}>
192
192
  <Tab label={t('admin.creditGrants.overview')} value={CreditTab.OVERVIEW} />
193
- <Tab label={t('admin.creditGrants.title')} value={CreditTab.GRANTS} />
194
- <Tab label={t('admin.creditTransactions.title')} value={CreditTab.TRANSACTIONS} />
193
+ <Tab label={t('admin.creditGrants.tab')} value={CreditTab.GRANTS} />
194
+ <Tab label={t('admin.creditTransactions.tab')} value={CreditTab.TRANSACTIONS} />
195
195
  </Tabs>
196
196
  {/* 概览标签页 */}
197
197
  {creditTab === CreditTab.OVERVIEW && (
@@ -66,7 +66,7 @@ export default function MeterActions({ data, variant = 'compact', onChange }: Me
66
66
  if (variant === 'compact') {
67
67
  actions.push({
68
68
  label: t('admin.meter.view'),
69
- handler: () => navigate(`/admin/billing/meters/${data.id}`),
69
+ handler: () => navigate(`/admin/billing/${data.id}`),
70
70
  color: 'primary',
71
71
  });
72
72
  }
@@ -15,7 +15,6 @@ import type {
15
15
  TMeter,
16
16
  TMeterExpanded,
17
17
  TPaymentCurrency,
18
- TPaymentCurrencyExpanded,
19
18
  TPaymentMethodExpanded,
20
19
  TPrice,
21
20
  TPriceExpanded,
@@ -102,16 +101,6 @@ const hasMoreCurrency = (methods: TPaymentMethodExpanded[] = []) => {
102
101
  return methods.every((method) => method.payment_currencies.length > 1) || methods.length > 1;
103
102
  };
104
103
 
105
- function stripeCurrencyValidate(v: number, currency: TPaymentCurrencyExpanded | null) {
106
- if (!currency) {
107
- return true;
108
- }
109
- if (currency.paymentMethod?.type === 'stripe') {
110
- return v >= 0.5;
111
- }
112
- return true;
113
- }
114
-
115
104
  const fetchMeters = (): Promise<{ list: TMeterExpanded[]; count: number }> => {
116
105
  return api.get('/api/meters?status=active&limit=100').then((res: any) => res.data);
117
106
  };
@@ -397,10 +386,6 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
397
386
  required: t('admin.price.unit_amount.required'),
398
387
  validate: (v) => {
399
388
  const currency = findCurrency(settings.paymentMethods, defaultCurrencyId);
400
- const hasStripError = !stripeCurrencyValidate(v, currency);
401
- if (hasStripError) {
402
- return t('admin.price.unit_amount.stripeTip');
403
- }
404
389
  return validateAmount(v, currency ?? {});
405
390
  },
406
391
  }}
@@ -482,10 +467,6 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
482
467
  rules={{
483
468
  required: t('admin.price.unit_amount.required'),
484
469
  validate: (v) => {
485
- const hasStripError = !stripeCurrencyValidate(v, currency);
486
- if (hasStripError) {
487
- return t('admin.price.unit_amount.stripeTip');
488
- }
489
470
  return validateAmount(v, currency ?? {});
490
471
  },
491
472
  }}
@@ -1144,11 +1144,12 @@ export default flat({
1144
1144
  appBalance: 'App Balance',
1145
1145
  },
1146
1146
  creditGrants: {
1147
- title: 'Credit Grants',
1147
+ tab: 'Grants',
1148
+ title: 'Credits',
1148
1149
  summary: 'Credit Summary',
1149
1150
  noGrants: 'No credit grants found',
1150
1151
  grantDetail: 'Credit Grant Details',
1151
- overview: 'Credit Overview',
1152
+ overview: 'Overview',
1152
1153
  overviewDescription: 'Monitor credit balances, usage, and outstanding debt across all currencies.',
1153
1154
  availableBalance: 'Available Balance',
1154
1155
  usage: 'Usage',
@@ -1180,7 +1181,8 @@ export default flat({
1180
1181
  viewDetails: 'View Details',
1181
1182
  },
1182
1183
  creditTransactions: {
1183
- title: 'Credit Transactions',
1184
+ tab: 'Transactions',
1185
+ title: 'Transactions',
1184
1186
  summary: 'Transaction Summary',
1185
1187
  noTransactions: 'No credit transactions found',
1186
1188
  totalTransactions: 'Total Transactions',
@@ -927,11 +927,12 @@ export default flat({
927
927
  postal_code: '邮政编码',
928
928
  },
929
929
  creditGrants: {
930
+ tab: '信用额度',
930
931
  title: '信用额度',
931
932
  summary: '额度汇总',
932
933
  noGrants: '未找到信用额度',
933
934
  grantDetail: '信用额度详情',
934
- overview: '额度概览',
935
+ overview: '概览',
935
936
  overviewDescription: '监控所有货币的信用余额、使用情况和未偿债务。',
936
937
  availableBalance: '可用余额',
937
938
  usage: '使用情况',
@@ -1103,7 +1104,8 @@ export default flat({
1103
1104
  appBalance: '热钱包余额',
1104
1105
  },
1105
1106
  creditGrants: {
1106
- title: '信用额度',
1107
+ tab: '信用额度',
1108
+ title: '额度',
1107
1109
  summary: '额度汇总',
1108
1110
  noGrants: '未找到信用额度',
1109
1111
  grantDetail: '信用额度详情',
@@ -1139,6 +1141,7 @@ export default flat({
1139
1141
  viewDetails: '查看详情',
1140
1142
  },
1141
1143
  creditTransactions: {
1144
+ tab: '账单',
1142
1145
  title: '额度账单',
1143
1146
  summary: '账单汇总',
1144
1147
  noTransactions: '未找到额度账单',
Binary file
Binary file
Binary file
Binary file
Binary file