payment-kit 1.18.30 → 1.18.31
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/crons/metering-subscription-detection.ts +9 -0
- package/api/src/integrations/arcblock/nft.ts +1 -0
- package/api/src/integrations/blocklet/passport.ts +1 -1
- package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
- package/api/src/integrations/stripe/handlers/setup-intent.ts +29 -1
- package/api/src/integrations/stripe/handlers/subscription.ts +19 -15
- package/api/src/integrations/stripe/resource.ts +81 -1
- package/api/src/libs/audit.ts +42 -0
- package/api/src/libs/invoice.ts +54 -7
- package/api/src/libs/notification/index.ts +72 -4
- package/api/src/libs/notification/template/base.ts +2 -0
- package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -5
- package/api/src/libs/notification/template/subscription-renewed.ts +1 -5
- package/api/src/libs/notification/template/subscription-succeeded.ts +8 -18
- package/api/src/libs/notification/template/subscription-trial-start.ts +2 -10
- package/api/src/libs/notification/template/subscription-upgraded.ts +1 -5
- package/api/src/libs/payment.ts +47 -14
- package/api/src/libs/product.ts +1 -4
- package/api/src/libs/session.ts +600 -8
- package/api/src/libs/setting.ts +172 -0
- package/api/src/libs/subscription.ts +7 -69
- package/api/src/libs/ws.ts +5 -0
- package/api/src/queues/checkout-session.ts +42 -36
- package/api/src/queues/notification.ts +3 -2
- package/api/src/queues/payment.ts +33 -6
- package/api/src/queues/usage-record.ts +2 -10
- package/api/src/routes/checkout-sessions.ts +324 -187
- package/api/src/routes/connect/shared.ts +160 -38
- package/api/src/routes/connect/subscribe.ts +123 -64
- package/api/src/routes/payment-currencies.ts +3 -6
- package/api/src/routes/payment-links.ts +11 -1
- package/api/src/routes/payment-stats.ts +2 -2
- package/api/src/routes/payouts.ts +2 -1
- package/api/src/routes/settings.ts +45 -0
- package/api/src/routes/subscriptions.ts +1 -2
- package/api/src/store/migrations/20250408-subscription-grouping.ts +39 -0
- package/api/src/store/migrations/20250419-subscription-grouping.ts +69 -0
- package/api/src/store/models/checkout-session.ts +52 -0
- package/api/src/store/models/index.ts +1 -0
- package/api/src/store/models/payment-link.ts +6 -0
- package/api/src/store/models/subscription.ts +8 -6
- package/api/src/store/models/types.ts +31 -1
- package/api/tests/libs/session.spec.ts +423 -0
- package/api/tests/libs/subscription.spec.ts +0 -110
- package/blocklet.yml +3 -1
- package/package.json +20 -19
- package/scripts/sdk.js +486 -155
- package/src/locales/en.tsx +1 -1
- package/src/locales/zh.tsx +1 -1
- package/src/pages/admin/settings/vault-config/edit-form.tsx +1 -1
- package/src/pages/customer/subscription/change-payment.tsx +8 -3
|
@@ -27,6 +27,8 @@ import type {
|
|
|
27
27
|
ServiceAction,
|
|
28
28
|
SubscriptionData,
|
|
29
29
|
} from './types';
|
|
30
|
+
import { Invoice } from './invoice';
|
|
31
|
+
import { Subscription } from './subscription';
|
|
30
32
|
|
|
31
33
|
export const nextCheckoutSessionId = createIdGenerator('cs', 58);
|
|
32
34
|
|
|
@@ -54,6 +56,11 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
|
|
|
54
56
|
// The ID of the subscription for Checkout Sessions in subscription mode.
|
|
55
57
|
declare subscription_id?: string;
|
|
56
58
|
|
|
59
|
+
// New fields for subscription grouping
|
|
60
|
+
declare enable_subscription_grouping?: boolean;
|
|
61
|
+
declare subscription_groups?: Record<string, string>;
|
|
62
|
+
declare success_subscription_count?: number;
|
|
63
|
+
|
|
57
64
|
// The ID of the original expired Checkout Session that triggered the recovery flow.
|
|
58
65
|
declare recovered_from?: string;
|
|
59
66
|
|
|
@@ -418,6 +425,18 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
|
|
|
418
425
|
type: DataTypes.STRING(30),
|
|
419
426
|
allowNull: true,
|
|
420
427
|
},
|
|
428
|
+
enable_subscription_grouping: {
|
|
429
|
+
type: DataTypes.BOOLEAN,
|
|
430
|
+
defaultValue: false,
|
|
431
|
+
},
|
|
432
|
+
subscription_groups: {
|
|
433
|
+
type: DataTypes.JSON,
|
|
434
|
+
allowNull: true,
|
|
435
|
+
},
|
|
436
|
+
success_subscription_count: {
|
|
437
|
+
type: DataTypes.INTEGER,
|
|
438
|
+
defaultValue: 0,
|
|
439
|
+
},
|
|
421
440
|
},
|
|
422
441
|
{
|
|
423
442
|
sequelize,
|
|
@@ -489,6 +508,39 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
|
|
|
489
508
|
public isImmutable() {
|
|
490
509
|
return ['complete', 'expired'].includes(this.status);
|
|
491
510
|
}
|
|
511
|
+
public static async findBySubscriptionId(subscriptionId: string, options: FindOptions<CheckoutSession> = {}) {
|
|
512
|
+
if (!subscriptionId) {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
let checkoutSession = await CheckoutSession.findOne({
|
|
516
|
+
where: { subscription_id: subscriptionId },
|
|
517
|
+
...options,
|
|
518
|
+
});
|
|
519
|
+
if (!checkoutSession) {
|
|
520
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
521
|
+
if (subscription?.metadata?.checkout_session_id) {
|
|
522
|
+
checkoutSession = await CheckoutSession.findByPk(subscription.metadata.checkout_session_id);
|
|
523
|
+
return checkoutSession;
|
|
524
|
+
}
|
|
525
|
+
const invoice = await Invoice.findOne({
|
|
526
|
+
// @ts-ignore
|
|
527
|
+
where: {
|
|
528
|
+
subscription_id: subscriptionId,
|
|
529
|
+
checkout_session_id: {
|
|
530
|
+
[Op.ne]: null,
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
order: [['created_at', 'ASC']],
|
|
534
|
+
});
|
|
535
|
+
if (invoice) {
|
|
536
|
+
checkoutSession = await CheckoutSession.findOne({
|
|
537
|
+
where: { id: invoice.checkout_session_id },
|
|
538
|
+
...options,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return checkoutSession;
|
|
543
|
+
}
|
|
492
544
|
}
|
|
493
545
|
|
|
494
546
|
export type TCheckoutSession = InferAttributes<CheckoutSession>;
|
|
@@ -148,6 +148,7 @@ export type TCheckoutSessionExpanded = TCheckoutSession & {
|
|
|
148
148
|
payment_intent?: TPaymentIntent;
|
|
149
149
|
subscription?: TSubscription;
|
|
150
150
|
customer?: TCustomer;
|
|
151
|
+
subscriptions?: TSubscription[];
|
|
151
152
|
};
|
|
152
153
|
|
|
153
154
|
export type TPaymentIntentExpanded = TPaymentIntent & {
|
|
@@ -87,6 +87,8 @@ export class PaymentLink extends Model<InferAttributes<PaymentLink>, InferCreati
|
|
|
87
87
|
|
|
88
88
|
declare payment_intent_data?: PaymentIntentData;
|
|
89
89
|
|
|
90
|
+
declare enable_subscription_grouping?: boolean;
|
|
91
|
+
|
|
90
92
|
// TODO: following fields not supported
|
|
91
93
|
// application_fee_amount
|
|
92
94
|
// application_fee_percent
|
|
@@ -218,6 +220,10 @@ export class PaymentLink extends Model<InferAttributes<PaymentLink>, InferCreati
|
|
|
218
220
|
type: DataTypes.JSON,
|
|
219
221
|
allowNull: true,
|
|
220
222
|
},
|
|
223
|
+
enable_subscription_grouping: {
|
|
224
|
+
type: DataTypes.BOOLEAN,
|
|
225
|
+
defaultValue: false,
|
|
226
|
+
},
|
|
221
227
|
},
|
|
222
228
|
{
|
|
223
229
|
sequelize,
|
|
@@ -7,7 +7,13 @@ import { createCustomEvent, createEvent, createStatusEvent } from '../../libs/au
|
|
|
7
7
|
import logger from '../../libs/logger';
|
|
8
8
|
import { createIdGenerator } from '../../libs/util';
|
|
9
9
|
import { Invoice } from './invoice';
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
PaymentDetails,
|
|
12
|
+
PaymentSettings,
|
|
13
|
+
PriceRecurring,
|
|
14
|
+
ServiceAction,
|
|
15
|
+
SubscriptionBillingThresholds,
|
|
16
|
+
} from './types';
|
|
11
17
|
|
|
12
18
|
export const nextSubscriptionId = createIdGenerator('sub', 24);
|
|
13
19
|
|
|
@@ -69,11 +75,7 @@ export class Subscription extends Model<InferAttributes<Subscription>, InferCrea
|
|
|
69
75
|
};
|
|
70
76
|
|
|
71
77
|
declare billing_cycle_anchor: number;
|
|
72
|
-
declare billing_thresholds?:
|
|
73
|
-
amount_gte: number;
|
|
74
|
-
stake_gte: number;
|
|
75
|
-
reset_billing_cycle_anchor: boolean;
|
|
76
|
-
};
|
|
78
|
+
declare billing_thresholds?: SubscriptionBillingThresholds;
|
|
77
79
|
|
|
78
80
|
declare collection_method: LiteralUnion<'charge_automatically' | 'send_invoice', string>;
|
|
79
81
|
|
|
@@ -189,6 +189,20 @@ export type LineItem = {
|
|
|
189
189
|
upsell_price_id?: string;
|
|
190
190
|
cross_sell?: boolean;
|
|
191
191
|
custom_amount?: string;
|
|
192
|
+
subscription_data?: SubscriptionData & {
|
|
193
|
+
service_actions?: ServiceAction[];
|
|
194
|
+
billing_cycle_anchor?: number;
|
|
195
|
+
metadata?: Record<string, any>;
|
|
196
|
+
proration_behavior?: LiteralUnion<'create_prorations' | 'none', string>;
|
|
197
|
+
trial_end?: number;
|
|
198
|
+
trial_settings?: {
|
|
199
|
+
end_behavior: {
|
|
200
|
+
missing_payment_method: LiteralUnion<'cancel' | 'pause' | 'create_invoice', string>;
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
days_until_due?: number;
|
|
204
|
+
days_until_cancel?: number;
|
|
205
|
+
};
|
|
192
206
|
// TODO: following are not supported
|
|
193
207
|
// price_data?: any;
|
|
194
208
|
// dynamic_tax_rates?: any;
|
|
@@ -424,6 +438,16 @@ export type SubscriptionData = {
|
|
|
424
438
|
recovered_from?: string;
|
|
425
439
|
trial_end?: number;
|
|
426
440
|
trial_currency?: string;
|
|
441
|
+
no_stake?: boolean; // skip stake for this subscription
|
|
442
|
+
notification_settings?: NotificationSetting;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
export type SubscriptionBillingThresholds = {
|
|
446
|
+
amount_gte: number;
|
|
447
|
+
stake_gte: number;
|
|
448
|
+
reset_billing_cycle_anchor: boolean;
|
|
449
|
+
no_stake?: boolean;
|
|
450
|
+
references_primary_stake?: boolean;
|
|
427
451
|
};
|
|
428
452
|
|
|
429
453
|
// Very similar to PaymentLink
|
|
@@ -704,7 +728,7 @@ export type EventType = LiteralUnion<
|
|
|
704
728
|
|
|
705
729
|
export type StripeRefundReason = 'duplicate' | 'fraudulent' | 'requested_by_customer';
|
|
706
730
|
|
|
707
|
-
export type SettingType = LiteralUnion<'donate', string>;
|
|
731
|
+
export type SettingType = LiteralUnion<'donate' | 'notification', string>;
|
|
708
732
|
|
|
709
733
|
export type NotificationFrequency = 'default' | 'daily' | 'weekly' | 'monthly';
|
|
710
734
|
export type NotificationSchedule = {
|
|
@@ -721,3 +745,9 @@ export type CustomerPreferences = {
|
|
|
721
745
|
notification?: NotificationSettings;
|
|
722
746
|
// support more preferences
|
|
723
747
|
};
|
|
748
|
+
|
|
749
|
+
export type NotificationSetting = {
|
|
750
|
+
self_handle: boolean;
|
|
751
|
+
exclude_events?: EventType[];
|
|
752
|
+
include_events?: EventType[];
|
|
753
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import dayjs from '../../src/libs/dayjs';
|
|
1
2
|
import {
|
|
2
3
|
getBillingThreshold,
|
|
3
4
|
getCheckoutAmount,
|
|
@@ -5,8 +6,18 @@ import {
|
|
|
5
6
|
getPriceCurrencyOptions,
|
|
6
7
|
getPriceUintAmountByCurrency,
|
|
7
8
|
getRecurringPeriod,
|
|
9
|
+
getSubscriptionCreateSetup,
|
|
10
|
+
getCheckoutSessionSubscriptionIds,
|
|
11
|
+
getOneTimeLineItems,
|
|
12
|
+
getRecurringLineItems,
|
|
13
|
+
mergeSubscriptionDataFromLineItems,
|
|
14
|
+
getFastCheckoutAmount,
|
|
15
|
+
createPaymentBeneficiaries,
|
|
16
|
+
getSubscriptionLineItems,
|
|
8
17
|
} from '../../src/libs/session';
|
|
9
18
|
import type { TLineItemExpanded } from '../../src/store/models';
|
|
19
|
+
import type { PaymentBeneficiary } from '../../src/store/models/types';
|
|
20
|
+
import { SubscriptionItem } from '../../src/store/models';
|
|
10
21
|
|
|
11
22
|
describe('getCheckoutMode', () => {
|
|
12
23
|
it('should return "setup" when items array is empty', () => {
|
|
@@ -265,3 +276,415 @@ describe('getCheckoutAmount', () => {
|
|
|
265
276
|
expect(result.total).toBe('20');
|
|
266
277
|
});
|
|
267
278
|
});
|
|
279
|
+
|
|
280
|
+
describe('getSubscriptionCreateSetup', () => {
|
|
281
|
+
const currencies = [
|
|
282
|
+
{
|
|
283
|
+
currency_id: 'usd',
|
|
284
|
+
unit_amount: '1',
|
|
285
|
+
},
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
it('should calculate setup for recurring licensed price type', () => {
|
|
289
|
+
const items = [
|
|
290
|
+
{
|
|
291
|
+
price: { type: 'one_time', currency_options: currencies },
|
|
292
|
+
quantity: 1,
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
price: {
|
|
296
|
+
type: 'recurring',
|
|
297
|
+
currency_options: currencies,
|
|
298
|
+
recurring: { interval: 'day', interval_count: '1', usage_type: 'licensed' },
|
|
299
|
+
},
|
|
300
|
+
quantity: 2,
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
const result = getSubscriptionCreateSetup(items as any, 'usd');
|
|
304
|
+
expect(result.amount.setup).toBe('3');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('should not calculate setup for recurring metered price type', () => {
|
|
308
|
+
const items = [
|
|
309
|
+
{
|
|
310
|
+
price: { type: 'one_time', currency_options: currencies },
|
|
311
|
+
quantity: 1,
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
price: {
|
|
315
|
+
type: 'recurring',
|
|
316
|
+
currency_options: currencies,
|
|
317
|
+
recurring: { interval: 'day', interval_count: '1', usage_type: 'metered' },
|
|
318
|
+
},
|
|
319
|
+
quantity: 2,
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
const result = getSubscriptionCreateSetup(items as any, 'usd');
|
|
323
|
+
expect(result.amount.setup).toBe('1');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should calculate cycle duration for recurring price type', () => {
|
|
327
|
+
const items = [
|
|
328
|
+
{
|
|
329
|
+
price: { type: 'recurring', currency_options: currencies, recurring: { interval: 'day', interval_count: '1' } },
|
|
330
|
+
quantity: 2,
|
|
331
|
+
},
|
|
332
|
+
];
|
|
333
|
+
const result = getSubscriptionCreateSetup(items as any, 'usd');
|
|
334
|
+
expect(result.cycle.duration).toBe(24 * 60 * 60 * 1000);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should calculate trial period when only trialInDays is provided', () => {
|
|
338
|
+
const items = [
|
|
339
|
+
{
|
|
340
|
+
price: { type: 'recurring', currency_options: currencies, recurring: { interval: 'day', interval_count: '1' } },
|
|
341
|
+
quantity: 2,
|
|
342
|
+
},
|
|
343
|
+
];
|
|
344
|
+
const result = getSubscriptionCreateSetup(items as any, 'usd', 7);
|
|
345
|
+
const now = dayjs().unix();
|
|
346
|
+
expect(result.trial.start).toBe(now);
|
|
347
|
+
expect(result.trial.end).toBe(
|
|
348
|
+
dayjs()
|
|
349
|
+
.add(7 * 24 * 60 * 60 * 1000, 'millisecond')
|
|
350
|
+
.unix()
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should trialEnd overwrite trialInDays', () => {
|
|
355
|
+
const items = [
|
|
356
|
+
{
|
|
357
|
+
price: { type: 'recurring', currency_options: currencies, recurring: { interval: 'day', interval_count: '1' } },
|
|
358
|
+
quantity: 2,
|
|
359
|
+
},
|
|
360
|
+
];
|
|
361
|
+
const result = getSubscriptionCreateSetup(items as any, 'usd', 1, dayjs().add(7, 'day').unix());
|
|
362
|
+
const now = dayjs().unix();
|
|
363
|
+
expect(result.trial.start).toBe(now);
|
|
364
|
+
expect(result.trial.end).toBe(
|
|
365
|
+
dayjs()
|
|
366
|
+
.add(7 * 24 * 60 * 60 * 1000, 'millisecond')
|
|
367
|
+
.unix()
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should calculate trial period when only trialEnd is provided', () => {
|
|
372
|
+
const items = [
|
|
373
|
+
{
|
|
374
|
+
price: { type: 'recurring', currency_options: currencies, recurring: { interval: 'day', interval_count: '1' } },
|
|
375
|
+
quantity: 2,
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
const result = getSubscriptionCreateSetup(items as any, 'usd', 0, dayjs().add(7, 'day').unix());
|
|
379
|
+
const now = dayjs().unix();
|
|
380
|
+
expect(result.trial.start).toBe(now);
|
|
381
|
+
expect(result.trial.end).toBe(
|
|
382
|
+
dayjs()
|
|
383
|
+
.add(7 * 24 * 60 * 60 * 1000, 'millisecond')
|
|
384
|
+
.unix()
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe('getCheckoutSessionSubscriptionIds', () => {
|
|
390
|
+
it('should return array with subscription_id when enable_subscription_grouping is false', () => {
|
|
391
|
+
const checkoutSession = {
|
|
392
|
+
subscription_id: 'sub_123',
|
|
393
|
+
enable_subscription_grouping: false,
|
|
394
|
+
subscription_groups: { price_1: 'sub_456', price_2: 'sub_789' },
|
|
395
|
+
};
|
|
396
|
+
const result = getCheckoutSessionSubscriptionIds(checkoutSession as any);
|
|
397
|
+
expect(result).toEqual(['sub_123']);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should return array of subscription_groups values when enable_subscription_grouping is true', () => {
|
|
401
|
+
const checkoutSession = {
|
|
402
|
+
subscription_id: 'sub_123',
|
|
403
|
+
enable_subscription_grouping: true,
|
|
404
|
+
subscription_groups: { price_1: 'sub_456', price_2: 'sub_789' },
|
|
405
|
+
};
|
|
406
|
+
const result = getCheckoutSessionSubscriptionIds(checkoutSession as any);
|
|
407
|
+
expect(result).toEqual(['sub_456', 'sub_789']);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('should filter out empty subscription ids', () => {
|
|
411
|
+
const checkoutSession = {
|
|
412
|
+
enable_subscription_grouping: true,
|
|
413
|
+
subscription_groups: { price_1: 'sub_456', price_2: '', price_3: null },
|
|
414
|
+
};
|
|
415
|
+
const result = getCheckoutSessionSubscriptionIds(checkoutSession as any);
|
|
416
|
+
expect(result).toEqual(['sub_456']);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('should return empty array when no subscription ids exist', () => {
|
|
420
|
+
const checkoutSession = {
|
|
421
|
+
enable_subscription_grouping: true,
|
|
422
|
+
subscription_groups: {},
|
|
423
|
+
};
|
|
424
|
+
const result = getCheckoutSessionSubscriptionIds(checkoutSession as any);
|
|
425
|
+
expect(result).toEqual([]);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe('getOneTimeLineItems', () => {
|
|
430
|
+
it('should return only one-time line items', () => {
|
|
431
|
+
const lineItems = [
|
|
432
|
+
{ price: { type: 'one_time' } },
|
|
433
|
+
{ price: { type: 'recurring' } },
|
|
434
|
+
{ price: { type: 'one_time' } },
|
|
435
|
+
];
|
|
436
|
+
const result = getOneTimeLineItems(lineItems as TLineItemExpanded[]);
|
|
437
|
+
expect(result).toHaveLength(2);
|
|
438
|
+
// @ts-ignore
|
|
439
|
+
expect(result[0].price && result[0].price.type).toBe('one_time');
|
|
440
|
+
// @ts-ignore
|
|
441
|
+
expect(result[1].price && result[1].price.type).toBe('one_time');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('should return empty array when no one-time items exist', () => {
|
|
445
|
+
const lineItems = [{ price: { type: 'recurring' } }, { price: { type: 'recurring' } }];
|
|
446
|
+
const result = getOneTimeLineItems(lineItems as TLineItemExpanded[]);
|
|
447
|
+
expect(result).toEqual([]);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
describe('getRecurringLineItems', () => {
|
|
452
|
+
it('should return only recurring line items', () => {
|
|
453
|
+
const lineItems = [
|
|
454
|
+
{ price: { type: 'one_time' } },
|
|
455
|
+
{ price: { type: 'recurring' } },
|
|
456
|
+
{ price: { type: 'recurring' } },
|
|
457
|
+
];
|
|
458
|
+
const result = getRecurringLineItems(lineItems as TLineItemExpanded[]);
|
|
459
|
+
expect(result).toHaveLength(2);
|
|
460
|
+
// @ts-ignore
|
|
461
|
+
expect(result[0].price && result[0].price.type).toBe('recurring');
|
|
462
|
+
// @ts-ignore
|
|
463
|
+
expect(result[1].price && result[1].price.type).toBe('recurring');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should return empty array when no recurring items exist', () => {
|
|
467
|
+
const lineItems = [{ price: { type: 'one_time' } }, { price: { type: 'one_time' } }];
|
|
468
|
+
const result = getRecurringLineItems(lineItems as TLineItemExpanded[]);
|
|
469
|
+
expect(result).toEqual([]);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
describe('mergeSubscriptionDataFromLineItems', () => {
|
|
474
|
+
it('should merge subscription data from multiple line items', () => {
|
|
475
|
+
const lineItems = [
|
|
476
|
+
{
|
|
477
|
+
subscription_data: {
|
|
478
|
+
description: 'Item 1',
|
|
479
|
+
metadata: { key1: 'value1' },
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
subscription_data: {
|
|
484
|
+
days_until_due: 30,
|
|
485
|
+
metadata: { key2: 'value2' },
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
];
|
|
489
|
+
const result = mergeSubscriptionDataFromLineItems(lineItems as any);
|
|
490
|
+
expect(result).toEqual({
|
|
491
|
+
description: 'Item 1',
|
|
492
|
+
days_until_due: 30,
|
|
493
|
+
metadata: { key1: 'value1', key2: 'value2' },
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('should return empty object when line items array is empty', () => {
|
|
498
|
+
const result = mergeSubscriptionDataFromLineItems([]);
|
|
499
|
+
expect(result).toEqual({});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should handle line items without subscription_data', () => {
|
|
503
|
+
const lineItems = [
|
|
504
|
+
{ price: { type: 'recurring' } },
|
|
505
|
+
{
|
|
506
|
+
subscription_data: {
|
|
507
|
+
description: 'Item 2',
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
];
|
|
511
|
+
const result = mergeSubscriptionDataFromLineItems(lineItems as any);
|
|
512
|
+
expect(result).toEqual({
|
|
513
|
+
description: 'Item 2',
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('should not include metadata field if no metadata exists', () => {
|
|
518
|
+
const lineItems = [
|
|
519
|
+
{
|
|
520
|
+
subscription_data: {
|
|
521
|
+
description: 'Item 1',
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
];
|
|
525
|
+
const result = mergeSubscriptionDataFromLineItems(lineItems as any);
|
|
526
|
+
expect(result).toEqual({
|
|
527
|
+
description: 'Item 1',
|
|
528
|
+
});
|
|
529
|
+
expect(result.metadata).toBeUndefined();
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
describe('getFastCheckoutAmount', () => {
|
|
534
|
+
it('should return total amount for payment mode', () => {
|
|
535
|
+
const items = [{ price: { type: 'one_time', currency_id: 'usd', unit_amount: '100' }, quantity: 1 }];
|
|
536
|
+
const result = getFastCheckoutAmount(items as TLineItemExpanded[], 'payment', 'usd');
|
|
537
|
+
expect(result).toBe('100');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('should return renew amount for setup mode', () => {
|
|
541
|
+
const items = [
|
|
542
|
+
{
|
|
543
|
+
price: { type: 'recurring', recurring: { usage_type: 'licensed' }, currency_id: 'usd', unit_amount: '100' },
|
|
544
|
+
quantity: 1,
|
|
545
|
+
},
|
|
546
|
+
];
|
|
547
|
+
const result = getFastCheckoutAmount(items as any, 'setup', 'usd');
|
|
548
|
+
expect(result).toBe('100');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('should multiply renew amount by minimumCycle for setup mode', () => {
|
|
552
|
+
const items = [
|
|
553
|
+
{
|
|
554
|
+
price: { type: 'recurring', recurring: { usage_type: 'licensed' }, currency_id: 'usd', unit_amount: '100' },
|
|
555
|
+
quantity: 1,
|
|
556
|
+
},
|
|
557
|
+
];
|
|
558
|
+
const result = getFastCheckoutAmount(items as any, 'setup', 'usd', false, 3);
|
|
559
|
+
expect(result).toBe('300');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('should return total + renew*(minimumCycle-1) for subscription mode', () => {
|
|
563
|
+
const items = [
|
|
564
|
+
{
|
|
565
|
+
price: { type: 'recurring', recurring: { usage_type: 'licensed' }, currency_id: 'usd', unit_amount: '100' },
|
|
566
|
+
quantity: 1,
|
|
567
|
+
},
|
|
568
|
+
{ price: { type: 'one_time', currency_id: 'usd', unit_amount: '50' }, quantity: 1 },
|
|
569
|
+
];
|
|
570
|
+
const result = getFastCheckoutAmount(items as any, 'subscription', 'usd', false, 3);
|
|
571
|
+
expect(result).toBe('350');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('should return total + renew*minimumCycle for subscription mode when trialing', () => {
|
|
575
|
+
const items = [
|
|
576
|
+
{
|
|
577
|
+
price: { type: 'recurring', recurring: { usage_type: 'licensed' }, currency_id: 'usd', unit_amount: '100' },
|
|
578
|
+
quantity: 1,
|
|
579
|
+
},
|
|
580
|
+
{ price: { type: 'one_time', currency_id: 'usd', unit_amount: '50' }, quantity: 1 },
|
|
581
|
+
];
|
|
582
|
+
const result = getFastCheckoutAmount(items as any, 'subscription', 'usd', true, 3);
|
|
583
|
+
expect(result).toBe('350'); // 50 (total, since recurring is 0 during trial) + 100 (renew) * 3
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('should return 0 for unknown mode', () => {
|
|
587
|
+
const items = [{ price: { type: 'one_time', currency_id: 'usd', unit_amount: '100' }, quantity: 1 }];
|
|
588
|
+
const result = getFastCheckoutAmount(items as any, 'unknown', 'usd');
|
|
589
|
+
expect(result).toBe('0');
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
describe('createPaymentBeneficiaries', () => {
|
|
594
|
+
it('should return empty array when beneficiaries array is empty', () => {
|
|
595
|
+
const beneficiaries: PaymentBeneficiary[] = [];
|
|
596
|
+
const result = createPaymentBeneficiaries('100', beneficiaries);
|
|
597
|
+
expect(result).toEqual([]);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('should calculate correct amounts based on shares', () => {
|
|
601
|
+
const beneficiaries: PaymentBeneficiary[] = [
|
|
602
|
+
{ address: 'addr1', share: '30' },
|
|
603
|
+
{ address: 'addr2', share: '70' },
|
|
604
|
+
];
|
|
605
|
+
const result = createPaymentBeneficiaries('100', beneficiaries);
|
|
606
|
+
expect(result).toHaveLength(2);
|
|
607
|
+
expect(result[0]?.share).toBe('30');
|
|
608
|
+
expect(result[0]?.address).toBe('addr1');
|
|
609
|
+
expect(result[1]?.share).toBe('70');
|
|
610
|
+
expect(result[1]?.address).toBe('addr2');
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
describe('getSubscriptionLineItems', () => {
|
|
615
|
+
it('should filter line items related to subscription', async () => {
|
|
616
|
+
const subscription = { id: 'sub_123' };
|
|
617
|
+
const lineItems = [
|
|
618
|
+
{ price_id: 'price_1', price: { type: 'recurring' } },
|
|
619
|
+
{ price_id: 'price_2', price: { type: 'recurring' } },
|
|
620
|
+
{ price_id: 'price_3', price: { type: 'one_time' } },
|
|
621
|
+
];
|
|
622
|
+
|
|
623
|
+
// Mock SubscriptionItem.findAll
|
|
624
|
+
const mockSubscriptionItems = [
|
|
625
|
+
{ subscription_id: 'sub_123', price_id: 'price_1' },
|
|
626
|
+
{ subscription_id: 'sub_123', price_id: 'price_2' },
|
|
627
|
+
];
|
|
628
|
+
jest.spyOn(SubscriptionItem, 'findAll').mockResolvedValue(mockSubscriptionItems as any);
|
|
629
|
+
|
|
630
|
+
const result = await getSubscriptionLineItems(subscription as any, lineItems as any);
|
|
631
|
+
|
|
632
|
+
expect(result).toHaveLength(2);
|
|
633
|
+
// @ts-ignore
|
|
634
|
+
expect(result[0].price_id).toBe('price_1');
|
|
635
|
+
// @ts-ignore
|
|
636
|
+
expect(result[1].price_id).toBe('price_2');
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('should include one-time items for primary subscription', async () => {
|
|
640
|
+
const subscription = { id: 'sub_123' };
|
|
641
|
+
const primarySubscription = { id: 'sub_123' };
|
|
642
|
+
const lineItems = [
|
|
643
|
+
{ price_id: 'price_1', price: { type: 'recurring' } },
|
|
644
|
+
{ price_id: 'price_3', price: { type: 'one_time' } },
|
|
645
|
+
];
|
|
646
|
+
|
|
647
|
+
// Mock SubscriptionItem.findAll
|
|
648
|
+
const mockSubscriptionItems = [{ subscription_id: 'sub_123', price_id: 'price_1' }];
|
|
649
|
+
jest.spyOn(SubscriptionItem, 'findAll').mockResolvedValue(mockSubscriptionItems as any);
|
|
650
|
+
|
|
651
|
+
const result = await getSubscriptionLineItems(subscription as any, lineItems as any, primarySubscription as any);
|
|
652
|
+
|
|
653
|
+
expect(result).toHaveLength(2);
|
|
654
|
+
// @ts-ignore
|
|
655
|
+
expect(result[0].price_id).toBe('price_1');
|
|
656
|
+
// @ts-ignore
|
|
657
|
+
expect(result[1].price_id).toBe('price_3');
|
|
658
|
+
// @ts-ignore
|
|
659
|
+
expect(result[1].price.type).toBe('one_time');
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('should handle upsell price ids', async () => {
|
|
663
|
+
const subscription = { id: 'sub_456' };
|
|
664
|
+
const lineItems = [{ price_id: 'price_1', upsell_price_id: 'price_2', price: { type: 'recurring' } }];
|
|
665
|
+
|
|
666
|
+
// Mock SubscriptionItem.findAll
|
|
667
|
+
const mockSubscriptionItems = [{ subscription_id: 'sub_456', price_id: 'price_2' }];
|
|
668
|
+
jest.spyOn(SubscriptionItem, 'findAll').mockResolvedValue(mockSubscriptionItems as any);
|
|
669
|
+
|
|
670
|
+
const result = await getSubscriptionLineItems(subscription as any, lineItems as any);
|
|
671
|
+
|
|
672
|
+
expect(result).toHaveLength(1);
|
|
673
|
+
// @ts-ignore
|
|
674
|
+
expect(result[0].price_id).toBe('price_1');
|
|
675
|
+
// @ts-ignore
|
|
676
|
+
expect(result[0].upsell_price_id).toBe('price_2');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('should return empty array when no items match', async () => {
|
|
680
|
+
const subscription = { id: 'sub_789' };
|
|
681
|
+
const lineItems = [{ price_id: 'price_1', price: { type: 'recurring' } }];
|
|
682
|
+
|
|
683
|
+
// Mock SubscriptionItem.findAll with empty array
|
|
684
|
+
jest.spyOn(SubscriptionItem, 'findAll').mockResolvedValue([]);
|
|
685
|
+
|
|
686
|
+
const result = await getSubscriptionLineItems(subscription as any, lineItems as any);
|
|
687
|
+
|
|
688
|
+
expect(result).toHaveLength(0);
|
|
689
|
+
});
|
|
690
|
+
});
|