payment-kit 1.15.16 → 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 (51) hide show
  1. package/api/src/integrations/stripe/handlers/invoice.ts +20 -0
  2. package/api/src/integrations/stripe/resource.ts +2 -2
  3. package/api/src/libs/audit.ts +1 -1
  4. package/api/src/libs/invoice.ts +81 -1
  5. package/api/src/libs/notification/template/billing-discrepancy.ts +223 -0
  6. package/api/src/libs/notification/template/subscription-canceled.ts +11 -0
  7. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +10 -2
  8. package/api/src/libs/notification/template/subscription-renew-failed.ts +10 -2
  9. package/api/src/libs/notification/template/subscription-renewed.ts +11 -3
  10. package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +11 -1
  11. package/api/src/libs/notification/template/subscription-succeeded.ts +11 -1
  12. package/api/src/libs/notification/template/subscription-trial-start.ts +11 -0
  13. package/api/src/libs/notification/template/subscription-trial-will-end.ts +17 -0
  14. package/api/src/libs/notification/template/subscription-upgraded.ts +51 -26
  15. package/api/src/libs/notification/template/subscription-will-canceled.ts +16 -0
  16. package/api/src/libs/notification/template/subscription-will-renew.ts +15 -3
  17. package/api/src/libs/notification/template/usage-report-empty.ts +158 -0
  18. package/api/src/libs/queue/index.ts +69 -19
  19. package/api/src/libs/queue/store.ts +28 -5
  20. package/api/src/libs/subscription.ts +129 -19
  21. package/api/src/libs/util.ts +30 -0
  22. package/api/src/locales/en.ts +13 -0
  23. package/api/src/locales/zh.ts +13 -0
  24. package/api/src/queues/invoice.ts +58 -20
  25. package/api/src/queues/notification.ts +43 -1
  26. package/api/src/queues/payment.ts +5 -1
  27. package/api/src/queues/subscription.ts +64 -15
  28. package/api/src/routes/checkout-sessions.ts +26 -0
  29. package/api/src/routes/invoices.ts +11 -31
  30. package/api/src/routes/subscriptions.ts +43 -7
  31. package/api/src/store/models/checkout-session.ts +2 -0
  32. package/api/src/store/models/job.ts +4 -0
  33. package/api/src/store/models/types.ts +22 -4
  34. package/api/src/store/models/usage-record.ts +5 -1
  35. package/api/tests/libs/subscription.spec.ts +154 -0
  36. package/api/tests/libs/util.spec.ts +135 -0
  37. package/blocklet.yml +1 -1
  38. package/package.json +10 -10
  39. package/scripts/sdk.js +37 -3
  40. package/src/components/invoice/list.tsx +0 -1
  41. package/src/components/invoice/table.tsx +7 -2
  42. package/src/components/subscription/items/index.tsx +26 -7
  43. package/src/components/subscription/items/usage-records.tsx +21 -10
  44. package/src/components/subscription/portal/actions.tsx +16 -14
  45. package/src/libs/util.ts +51 -0
  46. package/src/locales/en.tsx +2 -0
  47. package/src/locales/zh.tsx +2 -0
  48. package/src/pages/admin/billing/subscriptions/detail.tsx +1 -1
  49. package/src/pages/customer/subscription/change-plan.tsx +1 -1
  50. package/src/pages/customer/subscription/embed.tsx +16 -14
  51. package/vite-server.config.ts +8 -0
@@ -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
  });
@@ -177,6 +196,8 @@ const doHandleSubscriptionInvoice = async ({
177
196
  } as Invoice,
178
197
  });
179
198
 
199
+ logger.info('Invoice created for subscription', { invoice: invoice.id, subscription: subscription.id });
200
+
180
201
  return invoice;
181
202
  };
182
203
 
@@ -401,6 +422,7 @@ const handleStakeSlashAfterCancel = async (subscription: Subscription) => {
401
422
  amount: invoice.amount_remaining,
402
423
  address,
403
424
  txHash,
425
+ invoice: invoice.id,
404
426
  });
405
427
 
406
428
  await paymentIntent.update({
@@ -645,7 +667,7 @@ export const handleSubscription = async (job: SubscriptionJob) => {
645
667
  previousStatus,
646
668
  });
647
669
 
648
- if (previousStatus === 'past_due') {
670
+ if (previousStatus === 'past_due' && job.action === 'cancel') {
649
671
  await handleStakeSlashAfterCancel(subscription);
650
672
  }
651
673
  return;
@@ -689,21 +711,48 @@ export const subscriptionQueue = createQueue<SubscriptionJob>({
689
711
  });
690
712
 
691
713
  export const startSubscriptionQueue = async () => {
692
- const subscriptions = await Subscription.findAll({
693
- where: {
694
- status: EXPECTED_SUBSCRIPTION_STATUS,
695
- },
696
- });
714
+ const lock = getLock('startSubscriptionQueue');
715
+ if (lock.locked) {
716
+ return;
717
+ }
718
+ logger.info('startSubscriptionQueue');
719
+ try {
720
+ await lock.acquire();
721
+ const subscriptions = await Subscription.findAll({
722
+ where: {
723
+ status: EXPECTED_SUBSCRIPTION_STATUS,
724
+ },
725
+ });
697
726
 
698
- subscriptions.forEach(async (x) => {
699
- const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
700
- if (supportAutoCharge === false) {
701
- return;
727
+ const results = await Promise.allSettled(
728
+ subscriptions.map(async (x) => {
729
+ const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
730
+ if (supportAutoCharge === false) {
731
+ return;
732
+ }
733
+ if (['past_due', 'paused'].includes(x.status)) {
734
+ logger.info(`skip add cycle subscription job because status is ${x.status}`, {
735
+ subscription: x.id,
736
+ action: 'cycle',
737
+ });
738
+ return;
739
+ }
740
+ logger.info('add subscription job', { subscription: x.id, action: 'cycle' });
741
+ await addSubscriptionJob(x, 'cycle', process.env.PAYMENT_RELOAD_SUBSCRIPTION_JOBS === '1');
742
+ })
743
+ );
744
+
745
+ const failed = results.filter((r) => r.status === 'rejected').length;
746
+ if (failed > 0) {
747
+ logger.warn(`Failed to process ${failed} subscriptions in startSubscriptionQueue`);
702
748
  }
703
- await addSubscriptionJob(x, 'cycle', process.env.PAYMENT_RELOAD_SUBSCRIPTION_JOBS === '1');
704
- });
705
749
 
706
- await batchHandleStripeSubscriptions();
750
+ await batchHandleStripeSubscriptions();
751
+ } catch (error) {
752
+ logger.error('Error in startSubscriptionQueue:', error);
753
+ } finally {
754
+ lock.release();
755
+ }
707
756
  };
708
757
 
709
758
  export const slashStakeQueue = createQueue({
@@ -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', {
@@ -20,6 +20,7 @@ import { PaymentMethod } from '../store/models/payment-method';
20
20
  import { Price } from '../store/models/price';
21
21
  import { Product } from '../store/models/product';
22
22
  import { Subscription } from '../store/models/subscription';
23
+ import { getSubscriptionStakeAmountSetup } from '../libs/subscription';
23
24
 
24
25
  const router = Router();
25
26
  const authAdmin = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
@@ -131,44 +132,23 @@ router.get('/', authMine, async (req, res) => {
131
132
  if (subscription?.payment_details?.arcblock?.staking?.tx_hash) {
132
133
  const method = await PaymentMethod.findOne({ where: { type: 'arcblock', livemode: subscription.livemode } });
133
134
  if (method) {
134
- const client = method.getOcapClient();
135
135
  const { address } = subscription.payment_details.arcblock.staking;
136
- const { state } = await client.getStakeState({ address });
137
136
  const firstInvoice = await Invoice.findOne({
138
137
  where: { subscription_id: subscription.id },
139
138
  order: [['created_at', 'ASC']],
139
+ include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
140
140
  });
141
141
  const last = query.o === 'asc' ? list?.[list.length - 1] : list?.[0];
142
- if (state && firstInvoice) {
143
- const data = JSON.parse(state.data?.value || '{}');
142
+ if (subscription.payment_details.arcblock.staking.tx_hash && firstInvoice) {
144
143
  const customer = await Customer.findByPk(firstInvoice.customer_id);
145
- const currency = await PaymentCurrency.findOne({
146
- where: { payment_method_id: method.id, is_base_currency: true },
147
- });
148
-
149
- let stakeAmount = data[subscription.id];
150
- if (state.nonce) {
151
- stakeAmount = state.tokens?.find((x: any) => x.address === currency?.contract)?.value;
152
- // stakeAmount should not be zero if nonce exist
153
- if (!Number(stakeAmount)) {
154
- if (subscription.cancelation_details?.return_stake) {
155
- const refund = await Refund.findOne({
156
- where: { subscription_id: subscription.id, status: 'succeeded', type: 'stake_return' },
157
- });
158
- if (refund) {
159
- stakeAmount = refund.amount;
160
- }
161
- }
162
- if (subscription.cancelation_details?.slash_stake) {
163
- const invoice = await Invoice.findOne({
164
- where: { subscription_id: subscription.id, status: 'paid', billing_reason: 'slash_stake' },
165
- });
166
- if (invoice) {
167
- stakeAmount = invoice.total;
168
- }
169
- }
170
- }
171
- }
144
+ const currency =
145
+ // @ts-ignore
146
+ firstInvoice?.paymentCurrency ||
147
+ (await PaymentCurrency.findOne({
148
+ where: { payment_method_id: method.id, is_base_currency: true },
149
+ }));
150
+ const stakeAmountResult = await getSubscriptionStakeAmountSetup(subscription, method);
151
+ const stakeAmount = stakeAmountResult?.[currency?.contract] || '0';
172
152
 
173
153
  list.push({
174
154
  id: address as string,
@@ -7,6 +7,7 @@ import pick from 'lodash/pick';
7
7
  import uniq from 'lodash/uniq';
8
8
 
9
9
  import { literal, OrderItem } from 'sequelize';
10
+ import { createEvent } from '../libs/audit';
10
11
  import { ensureStripeCustomer, ensureStripePrice, ensureStripeSubscription } from '../integrations/stripe/resource';
11
12
  import { createListParamSchema, getWhereFromKvQuery, getWhereFromQuery, MetadataSchema } from '../libs/api';
12
13
  import dayjs from '../libs/dayjs';
@@ -692,11 +693,13 @@ const updateSchema = Joi.object<{
692
693
  service_actions: Joi.array()
693
694
  .items(
694
695
  Joi.object({
695
- name: Joi.string().required(),
696
- color: Joi.string().allow('primary', 'secondary', 'success', 'error', 'warning').required(),
697
- 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(),
698
699
  text: Joi.object().required(),
699
700
  link: Joi.string().uri().required(),
701
+ type: Joi.string().allow('notification', 'custom').optional(),
702
+ triggerEvents: Joi.array().items(Joi.string()).optional(),
700
703
  })
701
704
  )
702
705
  .optional(),
@@ -851,10 +854,15 @@ router.put('/:id', authPortal, async (req, res) => {
851
854
  // update subscription period settings
852
855
  // HINT: if we are adding new items, we need to reset the anchor to now
853
856
  const setup = getSubscriptionCreateSetup(newItems, paymentCurrency.id, 0);
857
+ // Check if the subscription is currently in trial
858
+ const isInTrial =
859
+ subscription.status === 'trialing' && subscription.trial_end && subscription.trial_end > dayjs().unix();
854
860
  if (newItems.some((x) => x.price.type === 'recurring' && addedItems.find((y) => y.price_id === x.price_id))) {
855
861
  updates.pending_invoice_item_interval = setup.recurring;
856
- updates.current_period_start = setup.period.start;
857
- updates.current_period_end = setup.period.end;
862
+ if (!isInTrial) {
863
+ updates.current_period_start = setup.period.start;
864
+ updates.current_period_end = setup.period.end;
865
+ }
858
866
  updates.billing_cycle_anchor = setup.cycle.anchor;
859
867
  logger.info('subscription updates on reset anchor', { subscription: req.params.id, updates });
860
868
  }
@@ -869,12 +877,30 @@ router.put('/:id', authPortal, async (req, res) => {
869
877
  }
870
878
 
871
879
  // 1. create proration
872
- const { lastInvoice, due, newCredit, appliedCredit, prorations } = await createProration(
880
+ const { lastInvoice, due, newCredit, appliedCredit, prorations, total } = await createProration(
873
881
  subscription,
874
882
  setup,
875
883
  dayjs().unix()
876
884
  );
877
885
 
886
+ if ((total === '0' && isInTrial) || newCredit !== '0') {
887
+ // 0 amount or new credit means no need to create invoice
888
+ await subscription.update(updates);
889
+ await finalizeSubscriptionUpdate({
890
+ subscription,
891
+ customer,
892
+ invoice: null,
893
+ paymentCurrency,
894
+ appliedCredit,
895
+ newCredit,
896
+ addedItems,
897
+ deletedItems,
898
+ updatedItems,
899
+ updates,
900
+ });
901
+ await createEvent('Subscription', 'customer.subscription.upgraded', subscription).catch(console.error);
902
+ return res.json({ ...subscription.toJSON(), connectAction });
903
+ }
878
904
  // 2. create new invoice: amount according to new subscription items
879
905
  // 3. create new invoice items: amount according to new subscription items
880
906
  const result = await ensureInvoiceAndItems({
@@ -894,7 +920,7 @@ router.put('/:id', authPortal, async (req, res) => {
894
920
  period_end: setup.period.end,
895
921
  auto_advance: true,
896
922
  billing_reason: 'subscription_update',
897
- total: setup.amount.setup,
923
+ total,
898
924
  currency_id: paymentCurrency.id,
899
925
  default_payment_method_id: subscription.default_payment_method_id,
900
926
  custom_fields: lastInvoice.custom_fields || [],
@@ -977,6 +1003,16 @@ router.put('/:id', authPortal, async (req, res) => {
977
1003
  invoice: invoice.id,
978
1004
  });
979
1005
  } else {
1006
+ await subscription.update({
1007
+ pending_update: {
1008
+ updates,
1009
+ appliedCredit,
1010
+ newCredit,
1011
+ addedItems,
1012
+ deletedItems,
1013
+ updatedItems,
1014
+ },
1015
+ });
980
1016
  await invoiceQueue.pushAndWait({
981
1017
  id: invoice.id,
982
1018
  job: { invoiceId: invoice.id, retryOnError: false, waitForPayment: true },
@@ -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>;
@@ -70,6 +70,10 @@ export class Job extends Model<InferAttributes<Job>, InferCreationAttributes<Job
70
70
  public static associate() {
71
71
  // Do nothing
72
72
  }
73
+
74
+ public static isInitialized(): boolean {
75
+ return this.sequelize !== undefined;
76
+ }
73
77
  }
74
78
 
75
79
  export type TJob = InferAttributes<Job>;
@@ -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,
@@ -9,7 +9,10 @@ import {
9
9
  getSubscriptionStakeSetup,
10
10
  getSubscriptionTrialSetup,
11
11
  shouldCancelSubscription,
12
+ getSubscriptionStakeAmountSetup,
13
+ checkUsageReportEmpty,
12
14
  } from '../../src/libs/subscription';
15
+ import { PaymentMethod, Subscription, SubscriptionItem, UsageRecord, Price } from '../../src/store/models';
13
16
 
14
17
  describe('getDueUnit', () => {
15
18
  it('should return 60 for recurring interval of "hour"', () => {
@@ -411,3 +414,154 @@ describe('getSubscriptionTrialSetup', () => {
411
414
  expect(result).toEqual({ trialInDays: 0, trialEnd: 0 });
412
415
  });
413
416
  });
417
+
418
+ describe('getSubscriptionStakeAmountSetup', () => {
419
+ let mockSubscription: Subscription;
420
+ let mockPaymentMethod: PaymentMethod;
421
+ let mockGetOcapClient: jest.Mock;
422
+ let mockGetTx: jest.Mock;
423
+
424
+ beforeEach(() => {
425
+ mockSubscription = {
426
+ payment_details: {
427
+ arcblock: {
428
+ staking: {
429
+ tx_hash: 'mock_tx_hash',
430
+ },
431
+ },
432
+ },
433
+ } as Subscription;
434
+
435
+ mockGetTx = jest.fn();
436
+ mockGetOcapClient = jest.fn().mockReturnValue({
437
+ getTx: mockGetTx,
438
+ });
439
+
440
+ // @ts-ignore
441
+ mockPaymentMethod = {
442
+ type: 'arcblock',
443
+ getOcapClient: mockGetOcapClient,
444
+ };
445
+ });
446
+
447
+ it('should return null if payment method is not arcblock', async () => {
448
+ mockPaymentMethod.type = 'other';
449
+ const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
450
+ expect(result).toBeNull();
451
+ });
452
+
453
+ it('should return null if tx_hash is missing', async () => {
454
+ // @ts-ignore
455
+ mockSubscription.payment_details.arcblock.staking.tx_hash = undefined;
456
+ const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
457
+ expect(result).toBeNull();
458
+ });
459
+
460
+ it('should return null if getTx info is null', async () => {
461
+ mockGetTx.mockResolvedValue({ info: null });
462
+ const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
463
+ expect(result).toBeNull();
464
+ });
465
+
466
+ it('should return null if inputs are empty', async () => {
467
+ mockGetTx.mockResolvedValue({
468
+ info: {
469
+ tx: {
470
+ itxJson: {
471
+ inputs: [],
472
+ },
473
+ },
474
+ },
475
+ });
476
+ const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
477
+ expect(result).toBeNull();
478
+ });
479
+
480
+ it('should calculate stake amount correctly', async () => {
481
+ mockGetTx.mockResolvedValue({
482
+ info: {
483
+ tx: {
484
+ itxJson: {
485
+ inputs: [
486
+ {
487
+ tokens: [
488
+ { address: 'addr1', value: '100' },
489
+ { address: 'addr2', value: '200' },
490
+ ],
491
+ },
492
+ {
493
+ tokens: [
494
+ { address: 'addr1', value: '300' },
495
+ { address: 'addr3', value: '400' },
496
+ ],
497
+ },
498
+ ],
499
+ },
500
+ },
501
+ },
502
+ });
503
+
504
+ const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
505
+ expect(result).toEqual({
506
+ addr1: '400',
507
+ addr2: '200',
508
+ addr3: '400',
509
+ });
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
+ });