payment-kit 1.15.17 → 1.15.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/integrations/stripe/handlers/invoice.ts +20 -0
- package/api/src/libs/audit.ts +1 -1
- package/api/src/libs/invoice.ts +81 -1
- package/api/src/libs/notification/template/billing-discrepancy.ts +223 -0
- package/api/src/libs/notification/template/subscription-canceled.ts +11 -0
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +10 -2
- package/api/src/libs/notification/template/subscription-renew-failed.ts +10 -2
- package/api/src/libs/notification/template/subscription-renewed.ts +10 -2
- package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +11 -1
- package/api/src/libs/notification/template/subscription-succeeded.ts +11 -1
- package/api/src/libs/notification/template/subscription-trial-start.ts +11 -0
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +11 -0
- package/api/src/libs/notification/template/subscription-upgraded.ts +11 -1
- package/api/src/libs/notification/template/subscription-will-canceled.ts +10 -0
- package/api/src/libs/notification/template/subscription-will-renew.ts +10 -1
- package/api/src/libs/notification/template/usage-report-empty.ts +158 -0
- package/api/src/libs/subscription.ts +67 -0
- package/api/src/libs/util.ts +30 -0
- package/api/src/locales/en.ts +13 -0
- package/api/src/locales/zh.ts +13 -0
- package/api/src/queues/invoice.ts +18 -0
- package/api/src/queues/notification.ts +43 -1
- package/api/src/queues/subscription.ts +21 -2
- package/api/src/routes/checkout-sessions.ts +26 -0
- package/api/src/routes/subscriptions.ts +5 -3
- package/api/src/store/models/checkout-session.ts +2 -0
- package/api/src/store/models/types.ts +22 -4
- package/api/src/store/models/usage-record.ts +5 -1
- package/api/tests/libs/subscription.spec.ts +58 -1
- package/api/tests/libs/util.spec.ts +135 -0
- package/blocklet.yml +1 -1
- package/package.json +4 -4
- package/scripts/sdk.js +37 -3
- package/src/components/invoice/list.tsx +0 -1
- package/src/components/invoice/table.tsx +7 -2
- package/src/components/subscription/items/index.tsx +26 -7
- package/src/components/subscription/items/usage-records.tsx +21 -10
- package/src/components/subscription/portal/actions.tsx +16 -14
- package/src/libs/util.ts +51 -0
- package/src/locales/en.tsx +2 -0
- package/src/locales/zh.tsx +2 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +1 -1
- 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 '../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:
|
|
123
|
-
end:
|
|
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().
|
|
697
|
-
color: Joi.string().allow('primary', 'secondary', 'success', 'error', 'warning').
|
|
698
|
-
variant: Joi.string().allow('text', 'contained', 'outlined').
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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:
|
|
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.15.
|
|
3
|
+
"version": "1.15.19",
|
|
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.
|
|
55
|
+
"@blocklet/payment-react": "1.15.19",
|
|
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.
|
|
121
|
+
"@blocklet/payment-types": "1.15.19",
|
|
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": "
|
|
163
|
+
"gitHead": "b0c54243e107ee3820702ba3d3cc9b15bc3d0a7f"
|
|
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: [
|
|
90
|
-
//
|
|
91
|
-
//
|
|
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={
|
|
275
|
-
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
},
|