payment-kit 1.18.29 → 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.
Files changed (56) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/crons/metering-subscription-detection.ts +9 -0
  3. package/api/src/integrations/arcblock/nft.ts +1 -0
  4. package/api/src/integrations/blocklet/passport.ts +1 -1
  5. package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
  6. package/api/src/integrations/stripe/handlers/setup-intent.ts +29 -1
  7. package/api/src/integrations/stripe/handlers/subscription.ts +19 -15
  8. package/api/src/integrations/stripe/resource.ts +81 -1
  9. package/api/src/libs/audit.ts +42 -0
  10. package/api/src/libs/constants.ts +2 -0
  11. package/api/src/libs/env.ts +2 -2
  12. package/api/src/libs/invoice.ts +54 -7
  13. package/api/src/libs/notification/index.ts +72 -4
  14. package/api/src/libs/notification/template/base.ts +2 -0
  15. package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -5
  16. package/api/src/libs/notification/template/subscription-renewed.ts +1 -5
  17. package/api/src/libs/notification/template/subscription-succeeded.ts +8 -18
  18. package/api/src/libs/notification/template/subscription-trial-start.ts +2 -10
  19. package/api/src/libs/notification/template/subscription-upgraded.ts +1 -5
  20. package/api/src/libs/payment.ts +48 -8
  21. package/api/src/libs/product.ts +1 -4
  22. package/api/src/libs/session.ts +600 -8
  23. package/api/src/libs/setting.ts +172 -0
  24. package/api/src/libs/subscription.ts +7 -69
  25. package/api/src/libs/ws.ts +5 -0
  26. package/api/src/queues/checkout-session.ts +42 -36
  27. package/api/src/queues/notification.ts +3 -2
  28. package/api/src/queues/payment.ts +56 -8
  29. package/api/src/queues/usage-record.ts +2 -10
  30. package/api/src/routes/checkout-sessions.ts +324 -187
  31. package/api/src/routes/connect/shared.ts +160 -38
  32. package/api/src/routes/connect/subscribe.ts +123 -64
  33. package/api/src/routes/payment-currencies.ts +11 -0
  34. package/api/src/routes/payment-links.ts +11 -1
  35. package/api/src/routes/payment-stats.ts +2 -2
  36. package/api/src/routes/payouts.ts +2 -1
  37. package/api/src/routes/settings.ts +45 -0
  38. package/api/src/routes/subscriptions.ts +1 -2
  39. package/api/src/store/migrations/20250408-subscription-grouping.ts +39 -0
  40. package/api/src/store/migrations/20250419-subscription-grouping.ts +69 -0
  41. package/api/src/store/models/checkout-session.ts +52 -0
  42. package/api/src/store/models/index.ts +1 -0
  43. package/api/src/store/models/payment-link.ts +6 -0
  44. package/api/src/store/models/subscription.ts +8 -6
  45. package/api/src/store/models/types.ts +32 -1
  46. package/api/tests/libs/session.spec.ts +423 -0
  47. package/api/tests/libs/subscription.spec.ts +0 -110
  48. package/blocklet.yml +3 -1
  49. package/package.json +25 -24
  50. package/scripts/sdk.js +486 -155
  51. package/src/locales/en.tsx +4 -0
  52. package/src/locales/zh.tsx +3 -0
  53. package/src/pages/admin/settings/vault-config/edit-form.tsx +58 -3
  54. package/src/pages/admin/settings/vault-config/index.tsx +35 -1
  55. package/src/pages/customer/subscription/change-payment.tsx +8 -3
  56. package/src/pages/integrations/overview.tsx +1 -1
@@ -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 { PaymentDetails, PaymentSettings, PriceRecurring, ServiceAction } from './types';
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;
@@ -299,6 +313,7 @@ export type VaultConfig = {
299
313
  enabled: boolean;
300
314
  deposit_threshold: string; // 存入阈值
301
315
  withdraw_threshold: string; // 提取阈值
316
+ buffer_threshold: string; // 差额阈值
302
317
  };
303
318
 
304
319
  export type PaymentSettings = {
@@ -423,6 +438,16 @@ export type SubscriptionData = {
423
438
  recovered_from?: string;
424
439
  trial_end?: number;
425
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;
426
451
  };
427
452
 
428
453
  // Very similar to PaymentLink
@@ -703,7 +728,7 @@ export type EventType = LiteralUnion<
703
728
 
704
729
  export type StripeRefundReason = 'duplicate' | 'fraudulent' | 'requested_by_customer';
705
730
 
706
- export type SettingType = LiteralUnion<'donate', string>;
731
+ export type SettingType = LiteralUnion<'donate' | 'notification', string>;
707
732
 
708
733
  export type NotificationFrequency = 'default' | 'daily' | 'weekly' | 'monthly';
709
734
  export type NotificationSchedule = {
@@ -720,3 +745,9 @@ export type CustomerPreferences = {
720
745
  notification?: NotificationSettings;
721
746
  // support more preferences
722
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
+ });