payment-kit 1.19.11 → 1.19.13

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 (48) hide show
  1. package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +274 -0
  2. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +10 -21
  3. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +23 -3
  4. package/api/src/libs/notification/template/subscription-will-renew.ts +15 -2
  5. package/api/src/libs/session.ts +70 -1
  6. package/api/src/locales/en.ts +10 -0
  7. package/api/src/locales/zh.ts +10 -0
  8. package/api/src/queues/credit-consume.ts +4 -1
  9. package/api/src/queues/notification.ts +16 -3
  10. package/api/src/routes/checkout-sessions.ts +9 -0
  11. package/blocklet.yml +6 -6
  12. package/package.json +16 -16
  13. package/screenshots/0ffe164ebe4aa2eb43f8d87f87683f7f.png +0 -0
  14. package/screenshots/1ef9e15ac36d4af5bef34941000ba3af.png +0 -0
  15. package/screenshots/3a4cab81c52c29662db8794b05ccc7c7.png +0 -0
  16. package/screenshots/77ac49b79ae920f0f253ce8c694ffd65.png +0 -0
  17. package/screenshots/7ea8ef758865ecf6edb712d3534d2974.png +0 -0
  18. package/src/components/chart.tsx +1 -1
  19. package/src/components/conditional-section.tsx +11 -1
  20. package/src/components/customer/credit-overview.tsx +51 -48
  21. package/src/components/drawer-form.tsx +1 -1
  22. package/src/components/filter-toolbar.tsx +1 -1
  23. package/src/components/info-card.tsx +1 -1
  24. package/src/components/invoice-pdf/pdf.tsx +1 -1
  25. package/src/components/layout/admin.tsx +0 -4
  26. package/src/components/metadata/form.tsx +1 -1
  27. package/src/components/meter/actions.tsx +1 -1
  28. package/src/components/meter/events-list.tsx +6 -0
  29. package/src/components/meter/usage-guide.tsx +1 -1
  30. package/src/components/payment-link/item.tsx +1 -1
  31. package/src/components/price/form.tsx +0 -19
  32. package/src/components/pricing-table/product-item.tsx +1 -1
  33. package/src/components/product/features.tsx +1 -1
  34. package/src/components/uploader.tsx +1 -1
  35. package/src/components/webhook/attempts.tsx +1 -1
  36. package/src/locales/en.tsx +7 -4
  37. package/src/locales/zh.tsx +6 -2
  38. package/src/pages/admin/developers/webhooks/detail.tsx +1 -1
  39. package/src/pages/admin/products/pricing-tables/create.tsx +2 -2
  40. package/src/pages/checkout/pricing-table.tsx +1 -1
  41. package/src/pages/customer/index.tsx +30 -14
  42. package/src/pages/customer/invoice/detail.tsx +1 -1
  43. package/src/pages/integrations/donations/preview.tsx +4 -4
  44. package/screenshots/checkout.png +0 -0
  45. package/screenshots/customer.png +0 -0
  46. package/screenshots/payment.png +0 -0
  47. package/screenshots/setting.png +0 -0
  48. package/screenshots/subscription_detail.png +0 -0
@@ -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({