payment-kit 1.20.20 → 1.20.22

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 (37) hide show
  1. package/api/src/integrations/stripe/resource.ts +1 -1
  2. package/api/src/libs/discount/coupon.ts +41 -73
  3. package/api/src/libs/invoice.ts +17 -0
  4. package/api/src/libs/notification/template/subscription-renew-failed.ts +22 -1
  5. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -0
  6. package/api/src/locales/en.ts +1 -0
  7. package/api/src/locales/zh.ts +1 -0
  8. package/api/src/queues/checkout-session.ts +2 -2
  9. package/api/src/queues/vendors/fulfillment-coordinator.ts +11 -2
  10. package/api/src/queues/vendors/status-check.ts +1 -3
  11. package/api/src/routes/checkout-sessions.ts +84 -0
  12. package/api/src/routes/connect/collect-batch.ts +2 -2
  13. package/api/src/routes/connect/pay.ts +1 -1
  14. package/api/src/routes/vendor.ts +93 -47
  15. package/api/src/store/migrations/20250926-change-customer-did-unique.ts +49 -0
  16. package/api/src/store/models/checkout-session.ts +1 -0
  17. package/api/src/store/models/customer.ts +1 -0
  18. package/api/tests/libs/coupon.spec.ts +219 -0
  19. package/api/tests/libs/discount.spec.ts +250 -0
  20. package/blocklet.yml +1 -1
  21. package/package.json +7 -7
  22. package/src/components/discount/discount-info.tsx +0 -1
  23. package/src/components/invoice/action.tsx +26 -0
  24. package/src/components/invoice/table.tsx +2 -9
  25. package/src/components/invoice-pdf/styles.ts +2 -0
  26. package/src/components/invoice-pdf/template.tsx +44 -12
  27. package/src/components/metadata/list.tsx +1 -0
  28. package/src/components/subscription/metrics.tsx +7 -3
  29. package/src/components/subscription/vendor-service-list.tsx +56 -58
  30. package/src/locales/en.tsx +9 -2
  31. package/src/locales/zh.tsx +9 -2
  32. package/src/pages/admin/billing/subscriptions/detail.tsx +43 -4
  33. package/src/pages/admin/products/coupons/applicable-products.tsx +20 -37
  34. package/src/pages/admin/products/products/detail.tsx +4 -14
  35. package/src/pages/admin/products/vendors/index.tsx +57 -48
  36. package/src/pages/customer/invoice/detail.tsx +1 -1
  37. package/src/pages/customer/subscription/detail.tsx +17 -4
@@ -0,0 +1,250 @@
1
+ import {
2
+ applyDiscountsToLineItems,
3
+ applySubscriptionDiscount,
4
+ rollbackDiscountUsageForCheckoutSession,
5
+ } from '../../src/libs/discount/discount';
6
+ import { Coupon, Discount, CheckoutSession } from '../../src/store/models';
7
+
8
+ // Mock logger and event emitter to keep test output clean
9
+ jest.mock('../../src/libs/logger', () => ({
10
+ __esModule: true,
11
+ default: {
12
+ info: jest.fn(),
13
+ warn: jest.fn(),
14
+ error: jest.fn(),
15
+ debug: jest.fn(),
16
+ },
17
+ }));
18
+
19
+ jest.mock('../../src/libs/event', () => ({
20
+ emitAsync: jest.fn(),
21
+ }));
22
+
23
+ // Mock price util so we can control unit amounts per item
24
+ jest.mock('../../src/libs/price', () => ({
25
+ getPriceUintAmountByCurrency: (price: any, currencyId: string) => {
26
+ if (!price) return '0';
27
+ if (price.unit_amount_map && price.unit_amount_map[currencyId]) return String(price.unit_amount_map[currencyId]);
28
+ if (price.unit_amount) return String(price.unit_amount);
29
+ if (Array.isArray(price.currency_options)) {
30
+ const opt = price.currency_options.find((c: any) => c.currency_id === currencyId);
31
+ return String(opt?.unit_amount || '0');
32
+ }
33
+ return '0';
34
+ },
35
+ }));
36
+
37
+ describe('libs/discount/discount.ts', () => {
38
+ const currency = { id: 'usd', decimal: 2, symbol: 'USD' } as any;
39
+
40
+ beforeEach(() => {
41
+ jest.clearAllMocks();
42
+ jest.restoreAllMocks();
43
+ });
44
+
45
+ describe('applyDiscountsToLineItems - percent_off over eligible items', () => {
46
+ it('applies percentage discount only to eligible products and returns per-item breakdown', async () => {
47
+ const lineItems: any[] = [
48
+ {
49
+ id: 'li_1',
50
+ quantity: 1,
51
+ price: { product_id: 'p1', unit_amount_map: { usd: '100' } },
52
+ },
53
+ {
54
+ id: 'li_2',
55
+ quantity: 3,
56
+ price: { product_id: 'p2', unit_amount_map: { usd: '100' } }, // amount 300
57
+ },
58
+ {
59
+ id: 'li_3',
60
+ quantity: 2,
61
+ price: { product_id: 'p3', unit_amount_map: { usd: '50' } }, // ineligible
62
+ },
63
+ ];
64
+
65
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({
66
+ id: 'c1',
67
+ percent_off: 50,
68
+ amount_off: null,
69
+ applies_to: { products: ['p1', 'p2'] },
70
+ valid: true,
71
+ } as any);
72
+
73
+ // Force coupon validation to pass
74
+ const couponModule = await import('../../src/libs/discount/coupon');
75
+ jest.spyOn(couponModule, 'validCoupon').mockReturnValue({ valid: true } as any);
76
+
77
+ const result = await applyDiscountsToLineItems({
78
+ lineItems,
79
+ couponId: 'c1',
80
+ customerId: 'cust_1',
81
+ currency,
82
+ });
83
+
84
+ const { enhancedLineItems, discountSummary } = result;
85
+ // li_1 amount=100 -> 50% => 50; li_2 amount=300 -> 150; li_3 ineligible
86
+ const li1 = enhancedLineItems.find((x: any) => x.id === 'li_1');
87
+ const li2 = enhancedLineItems.find((x: any) => x.id === 'li_2');
88
+ const li3 = enhancedLineItems.find((x: any) => x.id === 'li_3');
89
+
90
+ expect(li1).toBeDefined();
91
+ expect(li2).toBeDefined();
92
+ expect(li3).toBeDefined();
93
+ expect((li1 as any).discountable).toBe(true);
94
+ expect((li1 as any).discount_amounts[0].amount).toBe('50');
95
+ expect((li2 as any).discountable).toBe(true);
96
+ expect((li2 as any).discount_amounts[0].amount).toBe('150');
97
+ expect((li3 as any).discountable).toBe(false);
98
+
99
+ expect(discountSummary.appliedCoupon).toBe('c1');
100
+ expect(discountSummary.totalDiscountAmount).toBe('200');
101
+ // base total = 100 + 300 + 100 = 500; final = 300
102
+ expect(discountSummary.finalTotal).toBe('300');
103
+ expect(result.notValidReason).toBeUndefined();
104
+ });
105
+ });
106
+
107
+ describe('applyDiscountsToLineItems - amount_off distributed proportionally', () => {
108
+ it('distributes fixed amount discount proportionally among eligible items', async () => {
109
+ const lineItems: any[] = [
110
+ { id: 'a', quantity: 1, price: { product_id: 'pa', unit_amount_map: { usd: '100' } } },
111
+ { id: 'b', quantity: 3, price: { product_id: 'pb', unit_amount_map: { usd: '100' } } }, // 300
112
+ ];
113
+
114
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({
115
+ id: 'c2',
116
+ percent_off: 0,
117
+ amount_off: '150',
118
+ currency_id: 'usd',
119
+ applies_to: { products: [] },
120
+ valid: true,
121
+ } as any);
122
+
123
+ const couponModule = await import('../../src/libs/discount/coupon');
124
+ jest.spyOn(couponModule, 'validCoupon').mockReturnValue({ valid: true } as any);
125
+
126
+ const { enhancedLineItems, discountSummary } = await applyDiscountsToLineItems({
127
+ lineItems,
128
+ couponId: 'c2',
129
+ customerId: 'cust_1',
130
+ currency,
131
+ });
132
+
133
+ // Total eligible = 400; adjustedDiscount=150
134
+ // a: 100/400 * 150 = 37; b: 300/400 * 150 = 112 (integer division)
135
+ const a = enhancedLineItems.find((x: any) => x.id === 'a');
136
+ const b = enhancedLineItems.find((x: any) => x.id === 'b');
137
+ expect(a).toBeDefined();
138
+ expect(b).toBeDefined();
139
+ expect((a as any).discount_amounts[0].amount).toBe('37');
140
+ expect((b as any).discount_amounts[0].amount).toBe('112');
141
+ expect(discountSummary.totalDiscountAmount).toBe('150');
142
+ expect(discountSummary.finalTotal).toBe('250');
143
+ });
144
+ });
145
+
146
+ describe('applyDiscountsToLineItems - invalid or missing coupon', () => {
147
+ it('returns not valid when coupon not found', async () => {
148
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue(null as any);
149
+ const { discountSummary, notValidReason } = await applyDiscountsToLineItems({
150
+ lineItems: [{ id: 'x', quantity: 1, price: { product_id: 'p1', unit_amount_map: { usd: '100' } } }] as any,
151
+ couponId: 'missing',
152
+ customerId: 'cust_1',
153
+ currency,
154
+ });
155
+ expect(discountSummary.totalDiscountAmount).toBe('0');
156
+ expect(notValidReason).toBe('Coupon not found');
157
+ });
158
+
159
+ it('returns not valid when validation fails', async () => {
160
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({ id: 'c3', valid: false } as any);
161
+ const couponModule = await import('../../src/libs/discount/coupon');
162
+ jest.spyOn(couponModule, 'validCoupon').mockReturnValue({ valid: false, reason: 'invalid' } as any);
163
+
164
+ const res = await applyDiscountsToLineItems({
165
+ lineItems: [{ id: 'x', quantity: 1, price: { product_id: 'p1', unit_amount_map: { usd: '100' } } }] as any,
166
+ couponId: 'c3',
167
+ customerId: 'cust_1',
168
+ currency,
169
+ });
170
+ expect(res.discountSummary.totalDiscountAmount).toBe('0');
171
+ expect(res.notValidReason).toBe('invalid');
172
+ });
173
+ });
174
+
175
+ describe('applySubscriptionDiscount', () => {
176
+ it('returns computed discount when coupon exists', async () => {
177
+ const items: any[] = [{ id: 'a', quantity: 2, price: { product_id: 'pa', unit_amount_map: { usd: '50' } } }];
178
+
179
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({
180
+ id: 'c4',
181
+ percent_off: 20,
182
+ applies_to: { products: [] },
183
+ valid: true,
184
+ } as any);
185
+
186
+ const result = await applySubscriptionDiscount({
187
+ lineItems: items as any,
188
+ discount: { coupon_id: 'c4', id: 'd1' } as any,
189
+ currency,
190
+ });
191
+
192
+ expect(result.discountSummary.appliedCoupon).toBe('c4');
193
+ // amount = 2*50=100; 20% => 20; final 80
194
+ expect(result.discountSummary.totalDiscountAmount).toBe('20');
195
+ expect(result.discountSummary.finalTotal).toBe('80');
196
+ });
197
+
198
+ it('returns original items when coupon missing', async () => {
199
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue(null as any);
200
+ const items: any[] = [{ id: 'a', quantity: 1, price: { unit_amount_map: { usd: '100' } } }];
201
+ const res = await applySubscriptionDiscount({
202
+ lineItems: items as any,
203
+ discount: { coupon_id: 'missing', id: 'd2' } as any,
204
+ currency,
205
+ });
206
+ expect(res.discountSummary.totalDiscountAmount).toBe('0');
207
+ expect((res.enhancedLineItems[0] as any).discountable).toBe(false);
208
+ expect(res.discountSummary.finalTotal).toBe('100');
209
+ });
210
+ });
211
+
212
+ describe('rollbackDiscountUsageForCheckoutSession', () => {
213
+ it('rolls back confirmed discounts and destroys records', async () => {
214
+ jest.spyOn(CheckoutSession, 'findByPk').mockResolvedValue({ id: 'cs_1', status: 'open' } as any);
215
+
216
+ const coupon = { id: 'c1', times_redeemed: 2, update: jest.fn().mockResolvedValue(undefined) } as any;
217
+ const pc = { id: 'pc1', times_redeemed: 3, update: jest.fn().mockResolvedValue(undefined) } as any;
218
+
219
+ const discounts = [
220
+ { id: 'd1', confirmed: true, coupon, promotionCode: pc, destroy: jest.fn().mockResolvedValue(undefined) },
221
+ { id: 'd2', confirmed: true, coupon, promotionCode: pc, destroy: jest.fn().mockResolvedValue(undefined) },
222
+ { id: 'd3', confirmed: false, coupon, promotionCode: pc, destroy: jest.fn().mockResolvedValue(undefined) },
223
+ ] as any;
224
+
225
+ jest.spyOn(Discount, 'findAll').mockResolvedValue(discounts);
226
+
227
+ await rollbackDiscountUsageForCheckoutSession('cs_1');
228
+
229
+ // For unique coupon and promotion code, update should be called once each
230
+ expect(coupon.update).toHaveBeenCalledWith({ times_redeemed: 1, valid: true });
231
+ expect(pc.update).toHaveBeenCalledWith({ times_redeemed: 2, active: true });
232
+ // All discounts should be destroyed
233
+ discounts.forEach((d: any) => expect(d.destroy).toHaveBeenCalled());
234
+ });
235
+
236
+ it('early returns when session not found or completed', async () => {
237
+ const findByPkSpy = jest.spyOn(CheckoutSession, 'findByPk');
238
+ const discountFindAllSpy = jest.spyOn(Discount, 'findAll');
239
+
240
+ findByPkSpy.mockResolvedValueOnce(null as any);
241
+ await rollbackDiscountUsageForCheckoutSession('missing');
242
+
243
+ findByPkSpy.mockResolvedValueOnce({ id: 'cs_2', status: 'complete' } as any);
244
+ await rollbackDiscountUsageForCheckoutSession('cs_2');
245
+
246
+ // Should not query discounts in either case
247
+ expect(discountFindAllSpy).not.toHaveBeenCalled();
248
+ });
249
+ });
250
+ });
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.20.20
17
+ version: 1.20.22
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.20.20",
3
+ "version": "1.20.22",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
@@ -52,13 +52,13 @@
52
52
  "@arcblock/jwt": "^1.25.3",
53
53
  "@arcblock/ux": "^3.1.41",
54
54
  "@arcblock/validator": "^1.25.3",
55
- "@blocklet/did-space-js": "^1.1.26",
55
+ "@blocklet/did-space-js": "^1.1.27",
56
56
  "@blocklet/error": "^0.2.5",
57
57
  "@blocklet/js-sdk": "^1.16.52-beta-20250912-112002-e3499e9c",
58
58
  "@blocklet/logger": "^1.16.52-beta-20250912-112002-e3499e9c",
59
- "@blocklet/payment-broker-client": "1.20.20",
60
- "@blocklet/payment-react": "1.20.20",
61
- "@blocklet/payment-vendor": "1.20.20",
59
+ "@blocklet/payment-broker-client": "1.20.22",
60
+ "@blocklet/payment-react": "1.20.22",
61
+ "@blocklet/payment-vendor": "1.20.22",
62
62
  "@blocklet/sdk": "^1.16.52-beta-20250912-112002-e3499e9c",
63
63
  "@blocklet/ui-react": "^3.1.41",
64
64
  "@blocklet/uploader": "^0.2.12",
@@ -128,7 +128,7 @@
128
128
  "devDependencies": {
129
129
  "@abtnode/types": "^1.16.52-beta-20250912-112002-e3499e9c",
130
130
  "@arcblock/eslint-config-ts": "^0.3.3",
131
- "@blocklet/payment-types": "1.20.20",
131
+ "@blocklet/payment-types": "1.20.22",
132
132
  "@types/cookie-parser": "^1.4.9",
133
133
  "@types/cors": "^2.8.19",
134
134
  "@types/debug": "^4.1.12",
@@ -175,5 +175,5 @@
175
175
  "parser": "typescript"
176
176
  }
177
177
  },
178
- "gitHead": "0aa636abf410af4ba08cf03fd4a15ae3beab115d"
178
+ "gitHead": "a0a691de0cd6e47efbc0e4de01d200bb9193c435"
179
179
  }
@@ -92,7 +92,6 @@ export default function DiscountInfo({ discountStats = null }: DiscountInfoProps
92
92
  bgcolor: 'grey.50',
93
93
  borderRadius: 1.5,
94
94
  p: 2.5,
95
- my: 2,
96
95
  border: '1px solid',
97
96
  borderColor: 'divider',
98
97
  }}>
@@ -62,6 +62,16 @@ export default function InvoiceActions({ data, variant = 'compact', onChange, mo
62
62
  Toast.error(result.error);
63
63
  }
64
64
  }
65
+ if (state.action === 'retry-uncollectible') {
66
+ await api
67
+ .get('/api/invoices/retry-uncollectible', {
68
+ params: {
69
+ invoiceId: data.id,
70
+ },
71
+ })
72
+ .then((res) => res.data);
73
+ Toast.success(t('admin.invoice.retryUncollectible.success'));
74
+ }
65
75
  if (state.action === 'void') {
66
76
  await api.post(`/api/invoices/${data.id}/void`).then((res) => res.data);
67
77
  Toast.success(t('admin.invoice.void.success'));
@@ -89,6 +99,12 @@ export default function InvoiceActions({ data, variant = 'compact', onChange, mo
89
99
  disabled: data.status !== 'draft',
90
100
  divider: true,
91
101
  },
102
+ isAdmin &&
103
+ data.status === 'uncollectible' && {
104
+ label: t('admin.invoice.retryUncollectible.title'),
105
+ handler: () => setState({ action: 'retry-uncollectible' }),
106
+ color: 'primary',
107
+ },
92
108
  showReturnStake &&
93
109
  stakeResult &&
94
110
  stakeResult.value !== '0' && {
@@ -134,6 +150,16 @@ export default function InvoiceActions({ data, variant = 'compact', onChange, mo
134
150
  return (
135
151
  <ClickBoundary>
136
152
  <Actions variant={variant} actions={actions as any} onOpenCallback={fetchStakeResultAsync} />
153
+ {state.action === 'retry-uncollectible' && (
154
+ <ConfirmDialog
155
+ onConfirm={handleAction}
156
+ onCancel={() => setState({ action: '' })}
157
+ title={t('admin.invoice.retryUncollectible.title')}
158
+ message={t('admin.invoice.retryUncollectible.tip')}
159
+ loading={state.loading}
160
+ color="primary"
161
+ />
162
+ )}
137
163
  {state.action === 'return-stake' && (
138
164
  <ConfirmDialog
139
165
  onConfirm={handleAction}
@@ -210,7 +210,7 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
210
210
 
211
211
  const columns = [
212
212
  {
213
- label: t('common.description'),
213
+ label: t('admin.subscription.product'),
214
214
  name: 'product',
215
215
  options: {
216
216
  customBodyRenderLite: (_: string, index: number) => {
@@ -386,17 +386,10 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
386
386
  xs: 1,
387
387
  md: 1,
388
388
  },
389
- width: {
390
- xs: '100%',
391
- md: 'auto',
392
- },
389
+ width: '100%',
393
390
  minWidth: {
394
391
  md: '280px',
395
392
  },
396
- maxWidth: {
397
- xs: '100%',
398
- md: '400px',
399
- },
400
393
  }}>
401
394
  {summary.map((line, index) => {
402
395
  const isTotal = line.key === 'common.total';
@@ -60,6 +60,7 @@ export const pdfStyles: InvoiceStyles = {
60
60
  'w-40': { width: '40%' },
61
61
  'w-48': { width: '48%' },
62
62
  'w-38': { width: '38%' },
63
+ 'w-83': { width: '83%' },
63
64
  'w-17': { width: '17%' },
64
65
  'w-18': { width: '18%' },
65
66
  'w-15': { width: '15%' },
@@ -106,6 +107,7 @@ export const pdfStyles: InvoiceStyles = {
106
107
  },
107
108
  block: { display: 'block' },
108
109
  'ml-40': { marginLeft: '40px' },
110
+ 'text-right': { textAlign: 'right' },
109
111
  };
110
112
 
111
113
  export const styles = `
@@ -120,7 +120,7 @@ export function InvoiceTemplate({ data, t }: InvoicePDFProps) {
120
120
  </span>
121
121
  </div>
122
122
  <div style={composeStyles('w-15 p-4-8 pb-15')}>
123
- <span style={composeStyles(`${itemDiscountAmount > 0 ? 'green' : 'dark'} right`)}>
123
+ <span style={composeStyles('dark right')}>
124
124
  {itemDiscountAmount > 0
125
125
  ? `-${formatAmount(itemDiscountAmount.toString(), data.paymentCurrency.decimal)} ${
126
126
  data.paymentCurrency.symbol
@@ -138,24 +138,56 @@ export function InvoiceTemplate({ data, t }: InvoicePDFProps) {
138
138
  })}
139
139
 
140
140
  {/* Summary */}
141
- <div style={composeStyles('flex')}>
142
- <div style={composeStyles('w-60 mt-20')} />
143
- <div style={composeStyles('w-40 mt-20')}>
144
- {summary.map((line) => (
145
- <div style={composeStyles('flex')} key={line.key}>
146
- <div style={composeStyles('w-60 p-5')}>
141
+ <div
142
+ style={{
143
+ display: 'flex',
144
+ flexDirection: 'column',
145
+ alignItems: 'flex-end',
146
+ float: 'right',
147
+ paddingRight: '8px',
148
+ marginTop: '8px',
149
+ width: '100%',
150
+ minWidth: '280px',
151
+ }}>
152
+ {summary.map((line) => {
153
+ const isTotal = line.key === 'Total' || line.key === 'common.total' || line.key === 'payment.total';
154
+ const showDivider = isTotal;
155
+
156
+ return (
157
+ <div key={line.key} style={{ width: '100%' }}>
158
+ {showDivider && (
159
+ <div
160
+ style={{
161
+ borderTop: '1px solid #e3e3e3',
162
+ width: '100%',
163
+ marginBottom: '8px',
164
+ marginTop: '4px',
165
+ }}
166
+ />
167
+ )}
168
+ <div
169
+ style={{
170
+ display: 'flex',
171
+ justifyContent: 'flex-end',
172
+ width: '100%',
173
+ padding: '4px 0',
174
+ paddingRight: '8%',
175
+ gap: '20px',
176
+ }}>
147
177
  <span style={composeStyles('bold')}>
148
178
  {line.key.startsWith('common.') || line.key.startsWith('payment.') ? t(line.key) : line.key}
149
179
  </span>
150
- </div>
151
- <div style={composeStyles('w-40 p-5')}>
152
- <span style={composeStyles('right bold dark')}>
180
+ <span
181
+ style={{
182
+ ...composeStyles('bold dark text-right'),
183
+ minWidth: '80px',
184
+ }}>
153
185
  {line.value} {data.paymentCurrency.symbol}
154
186
  </span>
155
187
  </div>
156
188
  </div>
157
- ))}
158
- </div>
189
+ );
190
+ })}
159
191
  </div>
160
192
  </div>
161
193
  );
@@ -22,6 +22,7 @@ export default function MetadataList({
22
22
  variant="subtitle1"
23
23
  sx={{
24
24
  color: 'text.primary',
25
+ fontWeight: 500,
25
26
  }}>
26
27
  {t('common.metadata.empty')}
27
28
  </Typography>
@@ -13,6 +13,7 @@ import SubscriptionStatus from './status';
13
13
  type Props = {
14
14
  subscription: TSubscriptionExpanded;
15
15
  showBalance?: boolean;
16
+ mode?: 'portal' | 'admin';
16
17
  };
17
18
 
18
19
  const fetchUpcoming = (id: string): Promise<{ amount: string }> => {
@@ -35,7 +36,7 @@ const fetchCreditBalance = ({
35
36
  .then((res) => res.data);
36
37
  };
37
38
 
38
- export default function SubscriptionMetrics({ subscription, showBalance = true }: Props) {
39
+ export default function SubscriptionMetrics({ subscription, showBalance = true, mode = 'portal' }: Props) {
39
40
  const { t } = useLocaleContext();
40
41
  const isCredit = subscription.paymentCurrency?.type === 'credit';
41
42
  const { data: upcoming, loading: upcomingLoading } = useRequest(() => fetchUpcoming(subscription.id));
@@ -59,6 +60,7 @@ export default function SubscriptionMetrics({ subscription, showBalance = true }
59
60
  );
60
61
 
61
62
  const supportShowBalance = showBalance && ['arcblock', 'ethereum', 'base'].includes(subscription.paymentMethod.type);
63
+
62
64
  // let scheduleToCancelTime = 0;
63
65
  // if (['active', 'trialing', 'past_due'].includes(subscription.status) && subscription.cancel_at) {
64
66
  // scheduleToCancelTime = subscription.cancel_at * 1000;
@@ -69,7 +71,7 @@ export default function SubscriptionMetrics({ subscription, showBalance = true }
69
71
  const isInsufficientBalance = new BN(payerValue?.token || '0').lt(new BN(upcoming?.amount || '0'));
70
72
 
71
73
  const handleRecharge = () => {
72
- if (isCredit) {
74
+ if (isCredit || mode !== 'portal') {
73
75
  return;
74
76
  }
75
77
  navigate(`/customer/subscription/${subscription.id}/recharge`);
@@ -80,7 +82,9 @@ export default function SubscriptionMetrics({ subscription, showBalance = true }
80
82
  return <CircularProgress size={16} />;
81
83
  }
82
84
 
83
- if (isInsufficientBalance && !isCredit) {
85
+ const canRecharge = mode === 'portal';
86
+
87
+ if (isInsufficientBalance && !isCredit && canRecharge) {
84
88
  return (
85
89
  <Button
86
90
  component="a"