payment-kit 1.15.17 → 1.15.18

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 (43) hide show
  1. package/api/src/integrations/stripe/handlers/invoice.ts +20 -0
  2. package/api/src/libs/audit.ts +1 -1
  3. package/api/src/libs/invoice.ts +81 -1
  4. package/api/src/libs/notification/template/billing-discrepancy.ts +223 -0
  5. package/api/src/libs/notification/template/subscription-canceled.ts +11 -0
  6. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +10 -2
  7. package/api/src/libs/notification/template/subscription-renew-failed.ts +10 -2
  8. package/api/src/libs/notification/template/subscription-renewed.ts +10 -2
  9. package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +11 -1
  10. package/api/src/libs/notification/template/subscription-succeeded.ts +11 -1
  11. package/api/src/libs/notification/template/subscription-trial-start.ts +11 -0
  12. package/api/src/libs/notification/template/subscription-trial-will-end.ts +11 -0
  13. package/api/src/libs/notification/template/subscription-upgraded.ts +11 -1
  14. package/api/src/libs/notification/template/subscription-will-canceled.ts +10 -0
  15. package/api/src/libs/notification/template/subscription-will-renew.ts +10 -1
  16. package/api/src/libs/notification/template/usage-report-empty.ts +158 -0
  17. package/api/src/libs/subscription.ts +67 -0
  18. package/api/src/libs/util.ts +30 -0
  19. package/api/src/locales/en.ts +13 -0
  20. package/api/src/locales/zh.ts +13 -0
  21. package/api/src/queues/invoice.ts +18 -0
  22. package/api/src/queues/notification.ts +43 -1
  23. package/api/src/queues/subscription.ts +21 -2
  24. package/api/src/routes/checkout-sessions.ts +26 -0
  25. package/api/src/routes/subscriptions.ts +5 -3
  26. package/api/src/store/models/checkout-session.ts +2 -0
  27. package/api/src/store/models/types.ts +22 -4
  28. package/api/src/store/models/usage-record.ts +5 -1
  29. package/api/tests/libs/subscription.spec.ts +58 -1
  30. package/api/tests/libs/util.spec.ts +135 -0
  31. package/blocklet.yml +1 -1
  32. package/package.json +4 -4
  33. package/scripts/sdk.js +37 -3
  34. package/src/components/invoice/list.tsx +0 -1
  35. package/src/components/invoice/table.tsx +7 -2
  36. package/src/components/subscription/items/index.tsx +26 -7
  37. package/src/components/subscription/items/usage-records.tsx +21 -10
  38. package/src/components/subscription/portal/actions.tsx +16 -14
  39. package/src/libs/util.ts +51 -0
  40. package/src/locales/en.tsx +2 -0
  41. package/src/locales/zh.tsx +2 -0
  42. package/src/pages/admin/billing/subscriptions/detail.tsx +1 -1
  43. package/src/pages/customer/subscription/embed.tsx +16 -14
@@ -1,5 +1,6 @@
1
1
  import type { LiteralUnion } from 'type-fest';
2
2
 
3
+ import { createEvent } from '@api/libs/audit';
3
4
  import { ensurePassportRevoked } from '../integrations/blocklet/passport';
4
5
  import { batchHandleStripeSubscriptions } from '../integrations/stripe/resource';
5
6
  import { wallet } from '../libs/auth';
@@ -12,6 +13,7 @@ import createQueue from '../libs/queue';
12
13
  import { getStatementDescriptor } from '../libs/session';
13
14
  import {
14
15
  checkRemainingStake,
16
+ checkUsageReportEmpty,
15
17
  getSubscriptionCycleAmount,
16
18
  getSubscriptionCycleSetup,
17
19
  getSubscriptionStakeAddress,
@@ -111,6 +113,23 @@ const doHandleSubscriptionInvoice = async ({
111
113
  { product: true }
112
114
  );
113
115
 
116
+ const usageReportStart = usageStart || start - offset;
117
+ const usageReportEnd = usageEnd || end - offset;
118
+
119
+ // check if usage report is empty
120
+ const usageReportEmpty = await checkUsageReportEmpty(subscription, usageReportStart, usageReportEnd);
121
+ if (usageReportEmpty) {
122
+ createEvent('Subscription', 'usage.report.empty', subscription, {
123
+ usageReportStart,
124
+ usageReportEnd,
125
+ }).catch(console.error);
126
+ logger.info('create usage report empty event', {
127
+ subscriptionId: subscription.id,
128
+ usageReportStart,
129
+ usageReportEnd,
130
+ });
131
+ }
132
+
114
133
  // get usage summaries for this billing cycle
115
134
  expandedItems = await Promise.all(
116
135
  expandedItems.filter(filter).map(async (x: any) => {
@@ -119,8 +138,8 @@ const doHandleSubscriptionInvoice = async ({
119
138
  if (x.price.recurring?.usage_type === 'metered') {
120
139
  const rawQuantity = await UsageRecord.getSummary({
121
140
  id: x.id,
122
- start: (usageStart || start) - offset,
123
- end: (usageEnd || end) - offset,
141
+ start: usageReportStart,
142
+ end: usageReportEnd,
124
143
  method: x.price.recurring?.aggregate_usage,
125
144
  dryRun: false,
126
145
  });
@@ -136,6 +136,23 @@ export async function validateInventory(line_items: LineItem[], includePendingQu
136
136
  await Promise.all(checks);
137
137
  }
138
138
 
139
+ const SubscriptionDataSchema = Joi.object({
140
+ service_actions: Joi.array()
141
+ .items(
142
+ Joi.object({
143
+ name: Joi.string().optional(),
144
+ color: Joi.string().allow('primary', 'secondary', 'success', 'error', 'warning').optional(),
145
+ variant: Joi.string().allow('text', 'contained', 'outlined').optional(),
146
+ text: Joi.object().required(),
147
+ link: Joi.string().uri().required(),
148
+ type: Joi.string().allow('notification', 'custom').optional(),
149
+ triggerEvents: Joi.array().items(Joi.string()).optional(),
150
+ })
151
+ )
152
+ .min(0)
153
+ .optional(),
154
+ }).unknown(true);
155
+
139
156
  export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = true) => {
140
157
  const raw: Partial<CheckoutSession> = Object.assign(
141
158
  {
@@ -161,6 +178,7 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
161
178
  billing_threshold_amount: 0,
162
179
  min_stake_amount: 0,
163
180
  trial_end: 0,
181
+ service_actions: [],
164
182
  },
165
183
  payment_intent_data: {},
166
184
  submit_type: 'pay',
@@ -193,6 +211,13 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
193
211
  raw.subscription_data.trial_period_days = Number(raw.subscription_data.trial_period_days);
194
212
  }
195
213
 
214
+ if (raw.subscription_data?.service_actions) {
215
+ const { error } = SubscriptionDataSchema.validate(raw.subscription_data);
216
+ if (error) {
217
+ throw new Error('Invalid service actions for checkout session');
218
+ }
219
+ }
220
+
196
221
  if (!raw.expires_at) {
197
222
  raw.expires_at = dayjs().unix() + CHECKOUT_SESSION_TTL;
198
223
  }
@@ -869,6 +894,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
869
894
  checkoutSession.subscription_data?.days_until_cancel ?? checkoutSession.metadata?.days_until_cancel,
870
895
  recovered_from: recoveredFrom?.id,
871
896
  metadata: omit(checkoutSession.metadata || {}, ['days_until_due', 'days_until_cancel']),
897
+ service_actions: checkoutSession.subscription_data?.service_actions || [],
872
898
  });
873
899
 
874
900
  logger.info('subscription created on checkout session submit', {
@@ -693,11 +693,13 @@ const updateSchema = Joi.object<{
693
693
  service_actions: Joi.array()
694
694
  .items(
695
695
  Joi.object({
696
- name: Joi.string().required(),
697
- color: Joi.string().allow('primary', 'secondary', 'success', 'error', 'warning').required(),
698
- variant: Joi.string().allow('text', 'contained', 'outlined').required(),
696
+ name: Joi.string().optional(),
697
+ color: Joi.string().allow('primary', 'secondary', 'success', 'error', 'warning').optional(),
698
+ variant: Joi.string().allow('text', 'contained', 'outlined').optional(),
699
699
  text: Joi.object().required(),
700
700
  link: Joi.string().uri().required(),
701
+ type: Joi.string().allow('notification', 'custom').optional(),
702
+ triggerEvents: Joi.array().items(Joi.string()).optional(),
701
703
  })
702
704
  )
703
705
  .optional(),
@@ -24,6 +24,7 @@ import type {
24
24
  NftMintSettings,
25
25
  PaymentDetails,
26
26
  PaymentIntentData,
27
+ ServiceAction,
27
28
  SubscriptionData,
28
29
  } from './types';
29
30
 
@@ -158,6 +159,7 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
158
159
 
159
160
  // When creating a subscription, the specified configuration data will be used.
160
161
  declare subscription_data?: SubscriptionData & {
162
+ service_actions?: ServiceAction[];
161
163
  billing_cycle_anchor?: number;
162
164
  metadata?: Record<string, any>;
163
165
  proration_behavior?: LiteralUnion<'create_prorations' | 'none', string>;
@@ -44,10 +44,26 @@ export type PriceCurrency = {
44
44
  custom_unit_amount: CustomUnitAmount | null;
45
45
  };
46
46
 
47
+ // 这里为triggerEvents的事件类型
48
+ type NotificationActionEvents =
49
+ | 'customer.subscription.started'
50
+ | 'customer.subscription.renewed'
51
+ | 'customer.subscription.renew_failed'
52
+ | 'refund.succeeded'
53
+ | 'subscription.stake.slash.succeeded'
54
+ | 'customer.subscription.trial_will_end'
55
+ | 'customer.subscription.trial_start'
56
+ | 'customer.subscription.upgraded'
57
+ | 'customer.subscription.will_renew'
58
+ | 'customer.subscription.will_canceled'
59
+ | 'customer.subscription.deleted';
60
+
47
61
  export type ServiceAction = {
48
- name: string;
49
- color: string;
50
- variant: string;
62
+ type?: LiteralUnion<'notification' | 'custom', string>;
63
+ triggerEvents?: NotificationActionEvents[];
64
+ name?: string;
65
+ color?: LiteralUnion<'primary' | 'secondary' | 'success' | 'error' | 'warning', string>;
66
+ variant?: LiteralUnion<'text' | 'contained' | 'outlined', string>;
51
67
  text: { [key: string]: string };
52
68
  link: string;
53
69
  };
@@ -640,7 +656,9 @@ export type EventType = LiteralUnion<
640
656
  | 'topup.succeeded'
641
657
  | 'transfer.created'
642
658
  | 'transfer.reversed'
643
- | 'transfer.updated',
659
+ | 'transfer.updated'
660
+ | 'billing.discrepancy'
661
+ | 'usage.report.empty',
644
662
  string
645
663
  >;
646
664
 
@@ -100,17 +100,21 @@ export class UsageRecord extends Model<InferAttributes<UsageRecord>, InferCreati
100
100
  end,
101
101
  method,
102
102
  dryRun,
103
+ billed = false,
104
+ searchBilled = true,
103
105
  }: {
104
106
  id: string;
105
107
  start: number;
106
108
  end: number;
107
109
  method: LiteralUnion<'sum' | 'last_during_period' | 'max' | 'last_ever', string>;
108
110
  dryRun: boolean;
111
+ billed?: boolean;
112
+ searchBilled?: boolean;
109
113
  }): Promise<number> {
110
114
  const query = {
111
115
  where: {
112
116
  subscription_item_id: id,
113
- billed: false,
117
+ ...(searchBilled ? { billed } : {}),
114
118
  timestamp: {
115
119
  [Op.gt]: start,
116
120
  [Op.lte]: end,
@@ -10,8 +10,9 @@ import {
10
10
  getSubscriptionTrialSetup,
11
11
  shouldCancelSubscription,
12
12
  getSubscriptionStakeAmountSetup,
13
+ checkUsageReportEmpty,
13
14
  } from '../../src/libs/subscription';
14
- import type { PaymentMethod, Subscription } from '../../src/store/models';
15
+ import { PaymentMethod, Subscription, SubscriptionItem, UsageRecord, Price } from '../../src/store/models';
15
16
 
16
17
  describe('getDueUnit', () => {
17
18
  it('should return 60 for recurring interval of "hour"', () => {
@@ -508,3 +509,59 @@ describe('getSubscriptionStakeAmountSetup', () => {
508
509
  });
509
510
  });
510
511
  });
512
+
513
+ // 模拟的依赖项
514
+ const mockSubscriptionItems = [
515
+ { id: 'item_1', price_id: 'price_1', quantity: 1 },
516
+ { id: 'item_2', price_id: 'price_2', quantity: 1 },
517
+ ];
518
+
519
+ const mockExpandedItems = [
520
+ { id: 'item_1', price: { recurring: { usage_type: 'metered' } } },
521
+ { id: 'item_2', price: { recurring: { usage_type: 'metered' } } },
522
+ ];
523
+
524
+ const mockUsageRecordsEmpty: any[] = [];
525
+ const mockUsageRecordsWithData = [{ id: 'usage_1' }];
526
+
527
+ describe('checkUsageReportEmpty', () => {
528
+ const subscription = { id: 'sub_123' };
529
+ const usageReportStart = 1622505600;
530
+ const usageReportEnd = 1622592000;
531
+
532
+ beforeEach(() => {
533
+ // Reset any state if necessary
534
+ });
535
+
536
+ it('should return true if there are no usage records', async () => {
537
+ // Mock the behavior of the functions directly
538
+ jest.spyOn(SubscriptionItem, 'findAll').mockResolvedValue(mockSubscriptionItems as any);
539
+ jest.spyOn(Price, 'expand').mockResolvedValue(mockExpandedItems as any);
540
+ jest.spyOn(UsageRecord, 'findAll').mockResolvedValue(mockUsageRecordsEmpty);
541
+
542
+ const result = await checkUsageReportEmpty(subscription as any, usageReportStart, usageReportEnd);
543
+ expect(result).toBe(true);
544
+ });
545
+
546
+ it('should return false if there are usage records', async () => {
547
+ jest.spyOn(SubscriptionItem, 'findAll').mockResolvedValue(mockSubscriptionItems as any);
548
+ jest.spyOn(Price, 'expand').mockResolvedValue(mockExpandedItems as any);
549
+ jest.spyOn(UsageRecord, 'findAll').mockResolvedValue(mockUsageRecordsWithData as any);
550
+
551
+ const result = await checkUsageReportEmpty(subscription as any, usageReportStart, usageReportEnd);
552
+ expect(result).toBe(false);
553
+ });
554
+
555
+ it('should handle multiple metered items', async () => {
556
+ jest.spyOn(SubscriptionItem, 'findAll').mockResolvedValue(mockSubscriptionItems as any);
557
+ jest.spyOn(Price, 'expand').mockResolvedValue(mockExpandedItems as any);
558
+
559
+ jest
560
+ .spyOn(UsageRecord, 'findAll')
561
+ .mockResolvedValueOnce(mockUsageRecordsEmpty)
562
+ .mockResolvedValueOnce(mockUsageRecordsWithData as any);
563
+
564
+ const result = await checkUsageReportEmpty(subscription as any, usageReportStart, usageReportEnd);
565
+ expect(result).toBe(false);
566
+ });
567
+ });
@@ -10,7 +10,9 @@ import {
10
10
  getDataObjectFromQuery,
11
11
  getNextRetry,
12
12
  tryWithTimeout,
13
+ getSubscriptionNotificationCustomActions,
13
14
  } from '../../src/libs/util';
15
+ import type { Subscription } from '../../src/store/models';
14
16
 
15
17
  describe('createIdGenerator', () => {
16
18
  it('should return a function that generates an ID with the specified prefix and size', () => {
@@ -240,3 +242,136 @@ describe('formatAmountPrecisionLimit', () => {
240
242
  expect(result).toBe('');
241
243
  });
242
244
  });
245
+
246
+ describe('getSubscriptionNotificationCustomActions', () => {
247
+ const mockSubscription: Partial<Subscription> = {
248
+ service_actions: [
249
+ {
250
+ type: 'notification',
251
+ triggerEvents: ['customer.subscription.started', 'customer.subscription.renewed'],
252
+ name: 'Action 1',
253
+ text: { en: 'Action 1 Text', zh: '操作1文本' },
254
+ link: 'https://example.com/action1',
255
+ color: 'blue',
256
+ variant: 'outlined',
257
+ },
258
+ {
259
+ type: 'notification',
260
+ triggerEvents: ['customer.subscription.renewed', 'customer.subscription.upgraded'],
261
+ name: 'Action 2',
262
+ text: { en: 'Action 2 Text', zh: '操作2文本' },
263
+ link: 'https://example.com/action2',
264
+ color: 'green',
265
+ variant: 'contained',
266
+ },
267
+ {
268
+ type: 'other',
269
+ triggerEvents: ['customer.subscription.started'],
270
+ name: 'Action 3',
271
+ text: { en: 'Action 3 Text', zh: '操作3文本' },
272
+ link: 'https://example.com/action3',
273
+ color: 'red',
274
+ variant: 'text',
275
+ },
276
+ ],
277
+ };
278
+
279
+ it('should return an empty array if subscription is null', () => {
280
+ const result = getSubscriptionNotificationCustomActions(null as any, 'customer.subscription.started', 'en');
281
+ expect(result).toEqual([]);
282
+ });
283
+
284
+ it('should return an empty array if service_actions is empty', () => {
285
+ const result = getSubscriptionNotificationCustomActions(
286
+ { service_actions: [] } as any,
287
+ 'customer.subscription.started',
288
+ 'en'
289
+ );
290
+ expect(result).toEqual([]);
291
+ });
292
+
293
+ it('should filter actions based on event type and return formatted actions with color and variant', () => {
294
+ const result = getSubscriptionNotificationCustomActions(
295
+ mockSubscription as any,
296
+ 'customer.subscription.renewed',
297
+ 'en'
298
+ );
299
+ expect(result).toEqual([
300
+ {
301
+ name: 'Action 1',
302
+ title: 'Action 1 Text',
303
+ link: 'https://example.com/action1',
304
+ },
305
+ {
306
+ name: 'Action 2',
307
+ title: 'Action 2 Text',
308
+ link: 'https://example.com/action2',
309
+ },
310
+ ]);
311
+ });
312
+
313
+ it('should return actions with correct locale, color, and variant', () => {
314
+ const result = getSubscriptionNotificationCustomActions(
315
+ mockSubscription as any,
316
+ 'customer.subscription.renewed',
317
+ 'zh'
318
+ );
319
+ expect(result).toEqual([
320
+ {
321
+ name: 'Action 1',
322
+ title: '操作1文本',
323
+ link: 'https://example.com/action1',
324
+ },
325
+ {
326
+ name: 'Action 2',
327
+ title: '操作2文本',
328
+ link: 'https://example.com/action2',
329
+ },
330
+ ]);
331
+ });
332
+
333
+ it('should not return actions of non-notification type', () => {
334
+ const result = getSubscriptionNotificationCustomActions(
335
+ mockSubscription as any,
336
+ 'customer.subscription.started',
337
+ 'en'
338
+ );
339
+ expect(result).toEqual([
340
+ {
341
+ name: 'Action 1',
342
+ title: 'Action 1 Text',
343
+ link: 'https://example.com/action1',
344
+ },
345
+ ]);
346
+ });
347
+
348
+ it('should return an empty array if no matching actions found', () => {
349
+ const result = getSubscriptionNotificationCustomActions(
350
+ mockSubscription as any,
351
+ 'customer.subscription.deleted',
352
+ 'en'
353
+ );
354
+ expect(result).toEqual([]);
355
+ });
356
+
357
+ it('should handle all valid NotificationActionEvents', () => {
358
+ const allEvents = [
359
+ 'customer.subscription.started',
360
+ 'customer.subscription.renewed',
361
+ 'customer.subscription.renew_failed',
362
+ 'refund.succeeded',
363
+ 'subscription.stake.slash.succeeded',
364
+ 'customer.subscription.trial_will_end',
365
+ 'customer.subscription.trial_start',
366
+ 'customer.subscription.upgraded',
367
+ 'customer.subscription.will_renew',
368
+ 'customer.subscription.will_canceled',
369
+ 'customer.subscription.deleted',
370
+ ];
371
+
372
+ allEvents.forEach((event) => {
373
+ const result = getSubscriptionNotificationCustomActions(mockSubscription as any, event, 'en');
374
+ expect(Array.isArray(result)).toBeTruthy();
375
+ });
376
+ });
377
+ });
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.15.17
17
+ version: 1.15.18
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.15.17",
3
+ "version": "1.15.18",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "eject": "vite eject",
@@ -52,7 +52,7 @@
52
52
  "@arcblock/validator": "^1.18.135",
53
53
  "@blocklet/js-sdk": "^1.16.32",
54
54
  "@blocklet/logger": "^1.16.32",
55
- "@blocklet/payment-react": "1.15.17",
55
+ "@blocklet/payment-react": "1.15.18",
56
56
  "@blocklet/sdk": "^1.16.32",
57
57
  "@blocklet/ui-react": "^2.10.45",
58
58
  "@blocklet/uploader": "^0.1.43",
@@ -118,7 +118,7 @@
118
118
  "devDependencies": {
119
119
  "@abtnode/types": "^1.16.32",
120
120
  "@arcblock/eslint-config-ts": "^0.3.2",
121
- "@blocklet/payment-types": "1.15.17",
121
+ "@blocklet/payment-types": "1.15.18",
122
122
  "@types/cookie-parser": "^1.4.7",
123
123
  "@types/cors": "^2.8.17",
124
124
  "@types/debug": "^4.1.12",
@@ -160,5 +160,5 @@
160
160
  "parser": "typescript"
161
161
  }
162
162
  },
163
- "gitHead": "746e67a4e0542f308289edd4e2d6bdd64e49e53e"
163
+ "gitHead": "f57a3e622d9701fa0421f1266efc2fd92bd84780"
164
164
  }
package/scripts/sdk.js CHANGED
@@ -86,9 +86,43 @@ const payment = require('@blocklet/payment-js').default;
86
86
  // cancel_url:
87
87
  // 'https://bbqa2t5pfyfroyobmzknmktshckzto4btkfagxyjqwy.did.abtnet.io/store/api/payment/cancel?redirect=https://bbqa2t5pfyfroyobmzknmktshckzto4btkfagxyjqwy.did.abtnet.io/maker/mint/z3CtLCSchoZj4H5gqwyATSAEdvfd1m88VnDUZ',
88
88
  // mode: 'payment',
89
- // line_items: [{ price_id: 'price_wc1WPJy7FrbX1CBPJj7zuIys', quantity: 2 }],
90
- // metadata: { factoryAddress: 'z3CtLCSchoZj4H5gqwyATSAEdvfd1m88VnDUZ', quantity: 2 },
91
- // expires_at: 1721121607,
89
+ // line_items: [
90
+ // { price_id: 'price_G7TE6QZbvqkIaqzDJVb2afws', quantity: 2 },
91
+ // { price_id: 'price_rOUdD9fQrBGqn6M3YywXdDeK', quantity: 2 },
92
+ // ],
93
+ // subscription_data: {
94
+ // service_actions: [
95
+ // {
96
+ // type: 'notification',
97
+ // text: {
98
+ // zh: '查看文档',
99
+ // en: 'View Documentation',
100
+ // },
101
+ // link: 'https://www.arcblock.io/docs/createblocklet/en/quick-start',
102
+ // triggerEvents: ['customer.subscription.started', 'customer.subscription.deleted'],
103
+ // },
104
+ // {
105
+ // type: 'notification',
106
+ // text: {
107
+ // zh: '社区提问',
108
+ // en: 'Ask in Community',
109
+ // },
110
+ // link: 'https://community.arcblock.io/?locale=en',
111
+ // triggerEvents: ['customer.subscription.started', 'customer.subscription.renewed'],
112
+ // },
113
+ // {
114
+ // type: 'custom',
115
+ // text: {
116
+ // zh: '查看',
117
+ // en: 'View',
118
+ // },
119
+ // link: 'https://www.arcblock.io/docs/createblocklet/en/quick-start',
120
+ // color: 'primary',
121
+ // variant: 'outlined',
122
+ // },
123
+ // ],
124
+ // },
125
+ // expires_at: 1729243800,
92
126
  // });
93
127
  // console.log('checkoutSession', checkoutSession);
94
128
  // const product = await payment.products.create({
@@ -229,7 +229,6 @@ export default function InvoiceList({
229
229
  columns.splice(3, 0, {
230
230
  label: t('common.customer'),
231
231
  name: 'customer_id',
232
- width: 80,
233
232
  options: {
234
233
  customBodyRenderLite: (_: string, index: number) => {
235
234
  const item = data.list[index] as TInvoiceExpanded;
@@ -11,6 +11,7 @@ import { styled } from '@mui/system';
11
11
  import { isEmpty } from 'lodash';
12
12
  import LineItemActions from '../subscription/items/actions';
13
13
  import { UsageRecordDialog } from '../subscription/items/usage-records';
14
+ import { getInvoiceUsageReportStartEnd } from '../../libs/util';
14
15
 
15
16
  type Props = {
16
17
  invoice: TInvoiceExpanded;
@@ -122,6 +123,8 @@ export default function InvoiceTable({ invoice, simple, emptyNodeText }: Props)
122
123
  subscriptionItemId: '',
123
124
  });
124
125
 
126
+ const usageReportRange = getInvoiceUsageReportStartEnd(invoice, true);
127
+
125
128
  const onOpenUsageRecords = (line: InvoiceDetailItem) => {
126
129
  if (line.rawQuantity && line.raw.subscription_id && line.raw.subscription_item_id) {
127
130
  setState({
@@ -271,8 +274,10 @@ export default function InvoiceTable({ invoice, simple, emptyNodeText }: Props)
271
274
  subscriptionId={state.subscriptionId}
272
275
  id={state.subscriptionItemId}
273
276
  onConfirm={onCloseUsageRecords}
274
- start={invoice.metadata?.usage_start || invoice.period_start}
275
- end={invoice.metadata?.usage_end || invoice.period_end}
277
+ start={usageReportRange.start}
278
+ end={usageReportRange.end}
279
+ title={t('admin.subscription.usage.title')}
280
+ disableAddUsageQuantity
276
281
  />
277
282
  )}
278
283
  </Root>
@@ -4,6 +4,7 @@ import { formatPrice, Table, TruncatedText, useMobile } from '@blocklet/payment-
4
4
  import type { TPaymentCurrency, TSubscriptionItemExpanded } from '@blocklet/payment-types';
5
5
  import { Avatar, Stack, Typography } from '@mui/material';
6
6
 
7
+ import { Link } from 'react-router-dom';
7
8
  import Copyable from '../../copyable';
8
9
  import LineItemActions from './actions';
9
10
  import UsageRecords from './usage-records';
@@ -23,6 +24,8 @@ const size = { width: 48, height: 48 };
23
24
  export default function SubscriptionItemList({ data, currency, mode }: ListProps) {
24
25
  const { t } = useLocaleContext();
25
26
  const { isMobile } = useMobile();
27
+ const isAdmin = mode === 'admin';
28
+
26
29
  const columns = [
27
30
  {
28
31
  label: t('admin.subscription.product'),
@@ -53,13 +56,29 @@ export default function SubscriptionItemList({ data, currency, mode }: ListProps
53
56
  {item.price.product.name.slice(0, 1)}
54
57
  </Avatar>
55
58
  )}
56
- <Typography color="text.primary" fontWeight={600}>
57
- <TruncatedText text={item?.price.product.name} maxLength={isMobile ? 20 : 50} useWidth />
58
- {mode === 'customer' ? '' : ` - ${item?.price_id}`}
59
- </Typography>
60
- <Typography color="text.secondary" whiteSpace="nowrap">
61
- {formatPrice(item.price, currency, item?.price.product.unit_label)}
62
- </Typography>
59
+ {isAdmin ? (
60
+ <>
61
+ <Typography color="text.primary" fontWeight={600}>
62
+ <Link to={`/admin/products/${item?.price.product_id}`}>
63
+ <TruncatedText text={item?.price.product.name} maxLength={isMobile ? 20 : 50} useWidth />
64
+ </Link>
65
+ </Typography>
66
+ <Typography color="text.secondary" whiteSpace="nowrap">
67
+ <Link to={`/admin/products/${item?.price.id}`}>
68
+ {formatPrice(item.price, currency, item?.price.product.unit_label)}
69
+ </Link>
70
+ </Typography>
71
+ </>
72
+ ) : (
73
+ <>
74
+ <Typography color="text.primary" fontWeight={600}>
75
+ <TruncatedText text={item?.price.product.name} maxLength={isMobile ? 20 : 50} useWidth />
76
+ </Typography>
77
+ <Typography color="text.secondary" whiteSpace="nowrap">
78
+ {formatPrice(item.price, currency, item?.price.product.unit_label)}
79
+ </Typography>
80
+ </>
81
+ )}
63
82
  </Stack>
64
83
  );
65
84
  },