payment-kit 1.14.34 → 1.14.37

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.
@@ -204,7 +204,7 @@ export async function ensureStripeSubscription(
204
204
  currency: PaymentCurrency,
205
205
  items: TLineItemExpanded[],
206
206
  trialInDays: number = 0,
207
- trialEnds: number = 0
207
+ trialEnd: number = 0
208
208
  ) {
209
209
  const client = method.getStripeClient();
210
210
 
@@ -255,8 +255,8 @@ export async function ensureStripeSubscription(
255
255
 
256
256
  if (trialInDays) {
257
257
  props.trial_period_days = trialInDays;
258
- } else if (trialEnds) {
259
- props.trial_end = trialEnds;
258
+ } else if (trialEnd) {
259
+ props.trial_end = trialEnd;
260
260
  }
261
261
 
262
262
  stripeSubscription = await client.subscriptions.create(props);
@@ -2,6 +2,7 @@
2
2
  import component from '@blocklet/sdk/lib/component';
3
3
  import { BN } from '@ocap/util';
4
4
  import isEmpty from 'lodash/isEmpty';
5
+ import trim from 'lodash/trim';
5
6
  import pick from 'lodash/pick';
6
7
  import type { LiteralUnion } from 'type-fest';
7
8
  import { withQuery } from 'ufo';
@@ -18,6 +19,7 @@ import {
18
19
  PriceRecurring,
19
20
  Refund,
20
21
  Subscription,
22
+ SubscriptionData,
21
23
  SubscriptionItem,
22
24
  SubscriptionUpdateItem,
23
25
  TLineItemExpanded,
@@ -110,29 +112,35 @@ export const getMinRetryMail = (interval: string) => {
110
112
  return 15; // 18 hours
111
113
  };
112
114
 
115
+ const ZERO = new BN(0);
113
116
  export function getSubscriptionStakeSetup(items: TLineItemExpanded[], currencyId: string, billingThreshold = '0') {
114
117
  const staking = {
115
118
  licensed: new BN(0),
116
119
  metered: new BN(0),
117
120
  };
118
121
 
119
- items.forEach((x) => {
120
- const price = getSubscriptionItemPrice(x);
121
- const unit = getPriceUintAmountByCurrency(price, currencyId);
122
- const amount = new BN(unit).mul(new BN(x.quantity));
123
- if (price.type === 'recurring' && price.recurring) {
124
- if (price.recurring.usage_type === 'licensed') {
125
- staking.licensed = staking.licensed.add(amount);
126
- }
127
- if (price.recurring.usage_type === 'metered') {
128
- if (+billingThreshold) {
129
- staking.metered = new BN(billingThreshold);
130
- } else {
131
- staking.metered = staking.metered.add(amount);
122
+ const recurringItems = items
123
+ .map((x) => x.upsell_price || x.price)
124
+ .filter((x) => x.type === 'recurring' && x.recurring);
125
+ if (recurringItems.length > 0) {
126
+ if (new BN(billingThreshold).gt(ZERO)) {
127
+ staking.licensed = new BN(billingThreshold);
128
+ } else {
129
+ items.forEach((x) => {
130
+ const price = getSubscriptionItemPrice(x);
131
+ const unit = getPriceUintAmountByCurrency(price, currencyId);
132
+ const amount = new BN(unit).mul(new BN(x.quantity));
133
+ if (price.type === 'recurring' && price.recurring) {
134
+ if (price.recurring.usage_type === 'licensed') {
135
+ staking.licensed = staking.licensed.add(amount);
136
+ }
137
+ if (price.recurring.usage_type === 'metered') {
138
+ staking.metered = staking.metered.add(amount);
139
+ }
132
140
  }
133
- }
141
+ });
134
142
  }
135
- });
143
+ }
136
144
 
137
145
  return staking;
138
146
  }
@@ -141,7 +149,7 @@ export function getSubscriptionCreateSetup(
141
149
  items: TLineItemExpanded[],
142
150
  currencyId: string,
143
151
  trialInDays = 0,
144
- trialEnds = 0
152
+ trialEnd = 0
145
153
  ) {
146
154
  let setup = new BN(0);
147
155
 
@@ -164,18 +172,18 @@ export function getSubscriptionCreateSetup(
164
172
  const recurring = (item?.upsell_price || item?.price)?.recurring as PriceRecurring;
165
173
  const cycle = getRecurringPeriod(recurring);
166
174
 
167
- let trialStart = 0;
168
- let trialEnd = 0;
169
- if (+trialEnds && trialEnds > now) {
170
- trialStart = now;
171
- trialEnd = trialEnds;
175
+ let trialStartAt = 0;
176
+ let trialEndAt = 0;
177
+ if (+trialEnd && trialEnd > now) {
178
+ trialStartAt = now;
179
+ trialEndAt = trialEnd;
172
180
  } else if (trialInDays) {
173
- trialStart = now;
174
- trialEnd = dayjs().add(trialInDays, 'day').unix();
181
+ trialStartAt = now;
182
+ trialEndAt = dayjs().add(trialInDays, 'day').unix();
175
183
  }
176
184
 
177
- const periodStart = trialStart || now;
178
- const periodEnd = trialEnd || dayjs().add(cycle, 'millisecond').unix();
185
+ const periodStart = trialStartAt || now;
186
+ const periodEnd = trialEndAt || dayjs().add(cycle, 'millisecond').unix();
179
187
 
180
188
  return {
181
189
  recurring,
@@ -184,8 +192,8 @@ export function getSubscriptionCreateSetup(
184
192
  anchor: periodEnd,
185
193
  },
186
194
  trial: {
187
- start: trialStart,
188
- end: trialEnd,
195
+ start: trialStartAt,
196
+ end: trialEndAt,
189
197
  },
190
198
  period: {
191
199
  start: periodStart,
@@ -660,10 +668,37 @@ export async function getRemainingStakes(subscriptionIds: string[], subscription
660
668
  return total.toString();
661
669
  }
662
670
 
671
+ export async function getSubscriptionStakeSlashSetup(
672
+ subscription: Subscription,
673
+ address: string,
674
+ paymentMethod: PaymentMethod
675
+ ) {
676
+ const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
677
+ const result = await getSubscriptionRemainingStakeSetup(subscription, address, paymentMethod, 'slash');
678
+ return {
679
+ ...result,
680
+ lastInvoice,
681
+ };
682
+ }
683
+
663
684
  export async function getSubscriptionStakeReturnSetup(
664
685
  subscription: Subscription,
665
686
  address: string,
666
687
  paymentMethod: PaymentMethod
688
+ ) {
689
+ const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
690
+ const result = await getSubscriptionRemainingStakeSetup(subscription, address, paymentMethod, 'return');
691
+ return {
692
+ ...result,
693
+ lastInvoice,
694
+ };
695
+ }
696
+
697
+ export async function getSubscriptionRemainingStakeSetup(
698
+ subscription: Subscription,
699
+ address: string,
700
+ paymentMethod: PaymentMethod,
701
+ action: 'return' | 'slash' = 'return'
667
702
  ) {
668
703
  const client = paymentMethod.getOcapClient();
669
704
  const { state } = await client.getStakeState({ address });
@@ -675,7 +710,12 @@ export async function getSubscriptionStakeReturnSetup(
675
710
  sender: '',
676
711
  };
677
712
  }
678
- const total = new BN(state.tokens.find((x: any) => x.address === currency.contract)?.value || '0');
713
+ let total = new BN(state.tokens.find((x: any) => x.address === currency.contract)?.value || '0');
714
+ if (action === 'slash') {
715
+ // add revoked tokens to total
716
+ const revoked = state.revokedTokens?.find((x: any) => x.address === currency.contract);
717
+ total = total.add(new BN(revoked?.value || '0'));
718
+ }
679
719
  const [summary] = await Invoice.getUncollectibleAmount({
680
720
  subscriptionId: subscription.id,
681
721
  currencyId: subscription.currency_id,
@@ -684,14 +724,12 @@ export async function getSubscriptionStakeReturnSetup(
684
724
  const subscriptionInitStakes = JSON.parse(state.data?.value || '{}');
685
725
  const initStake = subscriptionInitStakes[subscription.id];
686
726
  const uncollectibleAmountBN = new BN(summary?.[subscription.currency_id] || '0');
687
- const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
688
727
  if (state.nonce) {
689
728
  const returnStake = total.sub(uncollectibleAmountBN);
690
729
  return {
691
730
  total: total.toString(),
692
731
  return_amount: returnStake.lt(new BN(0)) ? '0' : returnStake.toString(),
693
732
  sender: state.sender,
694
- lastInvoice,
695
733
  };
696
734
  }
697
735
  const getReturnState = async () => {
@@ -709,7 +747,6 @@ export async function getSubscriptionStakeReturnSetup(
709
747
  total: initStake,
710
748
  return_amount: returnStake.lt(new BN(0)) ? '0' : returnStake.toString(),
711
749
  sender: state.sender,
712
- lastInvoice,
713
750
  };
714
751
  }
715
752
 
@@ -729,14 +766,22 @@ export async function checkRemainingStake(
729
766
  const client = paymentMethod.getOcapClient();
730
767
  const { state } = await client.getStakeState({ address });
731
768
 
732
- const staked = state.tokens.find((x: any) => x.address === paymentCurrency.contract);
733
- const revoked = state.revokedTokens.find((x: any) => x.address === paymentCurrency.contract);
769
+ if (!state) {
770
+ logger.warn('getStakeState failed in checkRemainingStake', { address, paymentMethod, paymentCurrency });
771
+ return {
772
+ enough: false,
773
+ staked: '0',
774
+ revoked: '0',
775
+ };
776
+ }
777
+ const staked = state.tokens?.find((x: any) => x.address === paymentCurrency.contract);
778
+ const revoked = state.revokedTokens?.find((x: any) => x.address === paymentCurrency.contract);
734
779
  let total = new BN(0);
735
780
  if (staked) {
736
- total = total.add(new BN(staked.value));
781
+ total = total.add(new BN(staked?.value || '0'));
737
782
  }
738
783
  if (revoked) {
739
- total = total.add(new BN(revoked.value));
784
+ total = total.add(new BN(revoked?.value || '0'));
740
785
  }
741
786
  return {
742
787
  enough: total.gte(new BN(amount)),
@@ -744,3 +789,19 @@ export async function checkRemainingStake(
744
789
  revoked,
745
790
  };
746
791
  }
792
+
793
+ // trialing can be customized with currency_id list
794
+ export function getSubscriptionTrialSetup(data: Partial<SubscriptionData>, currencyId: string) {
795
+ let trialInDays = Number(data?.trial_period_days || 0);
796
+ let trialEnd = Number(data?.trial_end || 0);
797
+ const trialCurrencyIds = (data?.trial_currency || '').split(',').map(trim).filter(Boolean);
798
+ if (trialCurrencyIds.length > 0 && trialCurrencyIds.includes(currencyId) === false) {
799
+ trialEnd = 0;
800
+ trialInDays = 0;
801
+ }
802
+
803
+ return {
804
+ trialInDays,
805
+ trialEnd,
806
+ };
807
+ }
@@ -165,6 +165,11 @@ export const handleInvoice = async (job: InvoiceJob) => {
165
165
  return;
166
166
  }
167
167
 
168
+ const existJob = await paymentQueue.get(paymentIntent.id);
169
+ if (existJob) {
170
+ logger.warn('Payment job already scheduled', { invoice: invoice.id, paymentIntent: paymentIntent.id });
171
+ return;
172
+ }
168
173
  logger.info('Payment job scheduled', { invoice: invoice.id, paymentIntent: paymentIntent.id });
169
174
  if (job.waitForPayment) {
170
175
  await paymentQueue.pushAndWait({
@@ -16,6 +16,7 @@ import {
16
16
  getSubscriptionCycleAmount,
17
17
  getSubscriptionCycleSetup,
18
18
  getSubscriptionStakeReturnSetup,
19
+ getSubscriptionStakeSlashSetup,
19
20
  shouldCancelSubscription,
20
21
  } from '../libs/subscription';
21
22
  import { ensureInvoiceAndItems } from '../routes/connect/shared';
@@ -538,7 +539,7 @@ const slashStakeOnCancel = async (subscription: Subscription) => {
538
539
  });
539
540
  return;
540
541
  }
541
- const result = await getSubscriptionStakeReturnSetup(subscription, address, paymentMethod);
542
+ const result = await getSubscriptionStakeSlashSetup(subscription, address, paymentMethod);
542
543
  const stakeEnough = await checkRemainingStake(paymentMethod, currency, address, result.return_amount);
543
544
  if (!stakeEnough.enough) {
544
545
  logger.warn('Stake slashing aborted because no enough staking', {
@@ -42,11 +42,12 @@ import {
42
42
  getDaysUntilCancel,
43
43
  getDaysUntilDue,
44
44
  getSubscriptionCreateSetup,
45
+ getSubscriptionTrialSetup,
45
46
  } from '../libs/subscription';
46
47
  import { CHECKOUT_SESSION_TTL, formatAmountPrecisionLimit, formatMetadata, getDataObjectFromQuery } from '../libs/util';
47
48
  import { invoiceQueue } from '../queues/invoice';
48
49
  import { paymentQueue } from '../queues/payment';
49
- import type { LineItem, TPriceExpanded, TProductExpanded } from '../store/models';
50
+ import type { LineItem, SubscriptionData, TPriceExpanded, TProductExpanded } from '../store/models';
50
51
  import { CheckoutSession } from '../store/models/checkout-session';
51
52
  import { Customer } from '../store/models/customer';
52
53
  import { PaymentCurrency } from '../store/models/payment-currency';
@@ -249,8 +250,8 @@ export async function getCheckoutSessionAmounts(checkoutSession: CheckoutSession
249
250
  const now = dayjs().unix();
250
251
  const items = await Price.expand(checkoutSession.line_items);
251
252
  const trialInDays = Number(checkoutSession.subscription_data?.trial_period_days || 0);
252
- const trialEnds = Number(checkoutSession.subscription_data?.trial_end || 0);
253
- const amount = getCheckoutAmount(items, checkoutSession.currency_id, trialInDays > 0 || trialEnds > now);
253
+ const trialEnd = Number(checkoutSession.subscription_data?.trial_end || 0);
254
+ const amount = getCheckoutAmount(items, checkoutSession.currency_id, trialInDays > 0 || trialEnd > now);
254
255
  return {
255
256
  amount_subtotal: amount.subtotal,
256
257
  amount_total: amount.total,
@@ -378,10 +379,20 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
378
379
  raw.submit_type = link.submit_type;
379
380
  raw.currency_id = link.currency_id || req.currency.id;
380
381
  raw.payment_link_id = link.id;
381
- raw.subscription_data = merge(link.subscription_data, getDataObjectFromQuery(req.query, 'subscription_data'), {
382
- billing_threshold_amount: getBillingThreshold(link.subscription_data),
383
- min_stake_amount: getMinStakeAmount(link.subscription_data),
384
- });
382
+
383
+ // Settings priority: PaymentLink.subscription_data > req.query > environments
384
+ const protectedSettings: Partial<SubscriptionData> = {};
385
+ if (link.subscription_data?.min_stake_amount) {
386
+ protectedSettings.min_stake_amount = getMinStakeAmount(link.subscription_data);
387
+ }
388
+ if (link.subscription_data?.billing_threshold_amount) {
389
+ protectedSettings.billing_threshold_amount = getBillingThreshold(link.subscription_data);
390
+ }
391
+ raw.subscription_data = merge(
392
+ link.subscription_data,
393
+ getDataObjectFromQuery(req.query, 'subscription_data'),
394
+ protectedSettings
395
+ );
385
396
 
386
397
  if (link.after_completion?.hosted_confirmation?.custom_message) {
387
398
  raw.payment_intent_data = {
@@ -564,11 +575,16 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
564
575
  // always update payment amount in case currency has changed
565
576
  const now = dayjs().unix();
566
577
  const lineItems = await Price.expand(checkoutSession.line_items, { product: true, upsell: true });
567
- const trialInDays = Number(checkoutSession.subscription_data?.trial_period_days || 0);
568
- const trialEnds = Number(checkoutSession.subscription_data?.trial_end || 0);
578
+
579
+ // trialing can be customized with currency_id list
580
+ const { trialEnd, trialInDays } = getSubscriptionTrialSetup(
581
+ checkoutSession.subscription_data as any,
582
+ paymentCurrency.id
583
+ );
584
+
569
585
  const billingThreshold = Number(checkoutSession.subscription_data?.billing_threshold_amount || 0);
570
586
  const minStakeAmount = Number(checkoutSession.subscription_data?.min_stake_amount || 0);
571
- const amount = getCheckoutAmount(lineItems, paymentCurrency.id, trialInDays > 0 || trialEnds > now);
587
+ const amount = getCheckoutAmount(lineItems, paymentCurrency.id, trialInDays > 0 || trialEnd > now);
572
588
  await checkoutSession.update({
573
589
  amount_subtotal: amount.subtotal,
574
590
  amount_total: amount.total,
@@ -760,7 +776,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
760
776
  .status(403)
761
777
  .json({ code: 'SUBSCRIPTION_INVALID', error: 'Checkout session subscription status unexpected' });
762
778
  }
763
- const setup = getSubscriptionCreateSetup(lineItems, paymentCurrency.id, trialInDays, trialEnds);
779
+ const setup = getSubscriptionCreateSetup(lineItems, paymentCurrency.id, trialInDays, trialEnd);
764
780
  subscription = await subscription.update({
765
781
  currency_id: paymentCurrency.id,
766
782
  customer_id: customer.id,
@@ -803,7 +819,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
803
819
  const recoveredFrom = recoveredFromId ? await Subscription.findByPk(recoveredFromId) : null;
804
820
 
805
821
  // FIXME: @wangshijun respect all checkoutSession.subscription_data fields
806
- const setup = getSubscriptionCreateSetup(lineItems, paymentCurrency.id, trialInDays, trialEnds);
822
+ const setup = getSubscriptionCreateSetup(lineItems, paymentCurrency.id, trialInDays, trialEnd);
807
823
  subscription = await Subscription.create({
808
824
  livemode: !!checkoutSession.livemode,
809
825
  currency_id: paymentCurrency.id,
@@ -877,7 +893,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
877
893
  lineItems,
878
894
  checkoutSession.mode,
879
895
  paymentCurrency.id,
880
- trialInDays > 0 || trialEnds > now
896
+ trialInDays > 0 || trialEnd > now
881
897
  );
882
898
  const paymentSettings = {
883
899
  payment_method_types: checkoutSession.payment_method_types,
@@ -954,7 +970,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
954
970
  paymentCurrency,
955
971
  lineItems,
956
972
  trialInDays,
957
- trialEnds
973
+ trialEnd
958
974
  );
959
975
  if (stripeSubscription && subscription?.payment_details?.stripe?.subscription_id === stripeSubscription.id) {
960
976
  if (['active', 'trialing'].includes(stripeSubscription.status) && subscription.status === 'incomplete') {
@@ -3,6 +3,7 @@ import type { CallbackArgs } from '../../libs/auth';
3
3
  import dayjs from '../../libs/dayjs';
4
4
  import logger from '../../libs/logger';
5
5
  import { isDelegationSufficientForPayment } from '../../libs/payment';
6
+ import { getSubscriptionTrialSetup } from '../../libs/subscription';
6
7
  import { getFastCheckoutAmount } from '../../libs/session';
7
8
  import { getTxMetadata } from '../../libs/util';
8
9
  import { invoiceQueue } from '../../queues/invoice';
@@ -39,9 +40,13 @@ export default {
39
40
  const claims: { [type: string]: [string, object] } = {};
40
41
  const now = dayjs().unix();
41
42
  const items = checkoutSession.line_items as TLineItemExpanded[];
42
- const trialInDays = Number(checkoutSession.subscription_data?.trial_period_days || 0);
43
- const trialEnds = Number(checkoutSession.subscription_data?.trial_end || 0);
44
- const trialing = trialInDays > 0 || trialEnds > now;
43
+
44
+ const { trialEnd, trialInDays } = getSubscriptionTrialSetup(
45
+ checkoutSession.subscription_data as any,
46
+ paymentCurrency.id
47
+ );
48
+
49
+ const trialing = trialInDays > 0 || trialEnd > now;
45
50
  const billingThreshold = Number(checkoutSession.subscription_data?.billing_threshold_amount || 0);
46
51
  const minStakeAmount = Number(checkoutSession.subscription_data?.min_stake_amount || 0);
47
52
  const fastCheckoutAmount = getFastCheckoutAmount(items, checkoutSession.mode, paymentCurrency.id, trialing);
@@ -740,6 +740,7 @@ export async function getStakeTxClaim({
740
740
  const threshold = fromTokenToUnit(Math.max(billingThreshold, minStakeAmount), paymentCurrency.decimal);
741
741
  const staking = getSubscriptionStakeSetup(items, paymentCurrency.id, threshold.toString());
742
742
  const amount = staking.licensed.add(staking.metered).toString();
743
+ logger.info('getStakeTxClaim', { subscriptionId: subscription.id, billingThreshold, minStakeAmount, threshold: threshold.toString(), staking, amount: amount.toString() });
743
744
 
744
745
  if (paymentMethod.type === 'arcblock') {
745
746
  // create staking data
@@ -3,6 +3,7 @@ import type { CallbackArgs } from '../../libs/auth';
3
3
  import dayjs from '../../libs/dayjs';
4
4
  import logger from '../../libs/logger';
5
5
  import { isDelegationSufficientForPayment } from '../../libs/payment';
6
+ import { getSubscriptionTrialSetup } from '../../libs/subscription';
6
7
  import { getFastCheckoutAmount } from '../../libs/session';
7
8
  import { getTxMetadata } from '../../libs/util';
8
9
  import { invoiceQueue } from '../../queues/invoice';
@@ -40,9 +41,11 @@ export default {
40
41
  const claims: { [type: string]: [string, object] } = {};
41
42
  const now = dayjs().unix();
42
43
  const items = checkoutSession.line_items as TLineItemExpanded[];
43
- const trialInDays = Number(checkoutSession.subscription_data?.trial_period_days || 0);
44
- const trialEnds = Number(checkoutSession.subscription_data?.trial_end || 0);
45
- const trialing = trialInDays > 0 || trialEnds > now;
44
+ const { trialEnd, trialInDays } = getSubscriptionTrialSetup(
45
+ checkoutSession.subscription_data as any,
46
+ paymentCurrency.id
47
+ );
48
+ const trialing = trialInDays > 0 || trialEnd > now;
46
49
  const billingThreshold = Number(checkoutSession.subscription_data?.billing_threshold_amount || 0);
47
50
  const minStakeAmount = Number(checkoutSession.subscription_data?.min_stake_amount || 0);
48
51
  const fastCheckoutAmount = getFastCheckoutAmount(items, checkoutSession.mode, paymentCurrency.id, trialing);
@@ -147,7 +147,7 @@ router.get('/', authMine, async (req, res) => {
147
147
 
148
148
  let stakeAmount = data[subscription.id];
149
149
  if (state.nonce) {
150
- stakeAmount = state.tokens.find((x: any) => x.address === currency?.contract)?.value;
150
+ stakeAmount = state.tokens?.find((x: any) => x.address === currency?.contract)?.value;
151
151
  // stakeAmount should not be zero if nonce exist
152
152
  if (!Number(stakeAmount)) {
153
153
  if (subscription.cancelation_details?.return_stake) {
@@ -21,11 +21,12 @@ import {
21
21
  getSubscriptionCreateSetup,
22
22
  getSubscriptionRefundSetup,
23
23
  getSubscriptionStakeReturnSetup,
24
+ getSubscriptionStakeSlashSetup,
24
25
  getUpcomingInvoiceAmount,
25
26
  } from '../libs/subscription';
26
27
  import { MAX_SUBSCRIPTION_ITEM_COUNT, formatMetadata } from '../libs/util';
27
28
  import { invoiceQueue } from '../queues/invoice';
28
- import { addSubscriptionJob, subscriptionQueue } from '../queues/subscription';
29
+ import { addSubscriptionJob, slashStakeQueue, subscriptionQueue } from '../queues/subscription';
29
30
  import type { TLineItemExpanded } from '../store/models';
30
31
  import { Customer } from '../store/models/customer';
31
32
  import { Invoice } from '../store/models/invoice';
@@ -1235,9 +1236,6 @@ router.get('/:id/staking', authPortal, async (req, res) => {
1235
1236
  if (!subscription) {
1236
1237
  return res.status(404).json({ error: 'Subscription not found' });
1237
1238
  }
1238
- if (subscription.isActive() === false) {
1239
- return res.status(400).json({ error: 'Subscription is not active' });
1240
- }
1241
1239
 
1242
1240
  const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
1243
1241
  if (paymentMethod?.type !== 'arcblock') {
@@ -1249,12 +1247,15 @@ router.get('/:id/staking', authPortal, async (req, res) => {
1249
1247
  if (!address) {
1250
1248
  return res.status(400).json({ error: 'Staking not found on subscription payment detail' });
1251
1249
  }
1252
- const result = await getSubscriptionStakeReturnSetup(subscription, address, paymentMethod);
1250
+ const returnResult = await getSubscriptionStakeReturnSetup(subscription, address, paymentMethod);
1251
+ const slashResult = await getSubscriptionStakeSlashSetup(subscription, address, paymentMethod);
1253
1252
  return res.json({
1254
- return_amount: result.return_amount,
1255
- total: result.total,
1253
+ return_amount: returnResult.return_amount,
1254
+ total: returnResult.total,
1255
+ slash_amount: slashResult.return_amount,
1256
1256
  });
1257
1257
  } catch (err) {
1258
+ logger.error('subscription staking simulation failed', { error: err });
1258
1259
  console.error(err);
1259
1260
  return res.status(400).json({ error: err.message });
1260
1261
  }
@@ -1608,4 +1609,43 @@ router.get('/:id/upcoming', authPortal, async (req, res) => {
1608
1609
  }
1609
1610
  });
1610
1611
 
1612
+ // slash stake
1613
+ router.put('/:id/slash-stake', auth, async (req, res) => {
1614
+ const subscription = await Subscription.findByPk(req.params.id);
1615
+ if (!subscription) {
1616
+ return res.status(404).json({ error: 'Subscription not found' });
1617
+ }
1618
+
1619
+ if (subscription.status !== 'canceled') {
1620
+ return res.status(400).json({ error: `Subscription for ${subscription.id} not canceled` });
1621
+ }
1622
+
1623
+ const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
1624
+ if (paymentMethod?.type !== 'arcblock') {
1625
+ return res
1626
+ .status(400)
1627
+ .json({ error: `Stake slash not supported for subscription with payment method ${paymentMethod?.type}` });
1628
+ }
1629
+ const address = subscription?.payment_details?.arcblock?.staking?.address ?? undefined;
1630
+ if (!address) {
1631
+ return res.status(400).json({ error: 'Staking not found on subscription payment detail' });
1632
+ }
1633
+ try {
1634
+ await subscription.update({
1635
+ // @ts-ignore
1636
+ cancelation_details: {
1637
+ ...subscription.cancelation_details,
1638
+ slash_stake: true,
1639
+ },
1640
+ });
1641
+ const result = await slashStakeQueue.pushAndWait({
1642
+ id: `slash-stake-${subscription.id}`,
1643
+ job: { subscriptionId: subscription.id },
1644
+ });
1645
+ return res.json(result);
1646
+ } catch (err) {
1647
+ logger.error('subscription slash stake failed', { subscription: subscription.id, error: err });
1648
+ return res.status(400).json({ error: err.message });
1649
+ }
1650
+ });
1611
1651
  export default router;
@@ -367,6 +367,7 @@ export type SubscriptionData = {
367
367
  metadata?: Record<string, any>;
368
368
  recovered_from?: string;
369
369
  trial_end?: number;
370
+ trial_currency?: string;
370
371
  };
371
372
 
372
373
  // Very similar to PaymentLink
@@ -7,6 +7,7 @@ import {
7
7
  getMinRetryMail,
8
8
  getSubscriptionCreateSetup,
9
9
  getSubscriptionStakeSetup,
10
+ getSubscriptionTrialSetup,
10
11
  shouldCancelSubscription,
11
12
  } from '../../src/libs/subscription';
12
13
 
@@ -183,7 +184,7 @@ describe('getSubscriptionCreateSetup', () => {
183
184
  );
184
185
  });
185
186
 
186
- it('should trialEnds overwrite trialInDays', () => {
187
+ it('should trialEnd overwrite trialInDays', () => {
187
188
  const items = [
188
189
  {
189
190
  price: { type: 'recurring', currency_options: currencies, recurring: { interval: 'day', interval_count: '1' } },
@@ -200,7 +201,7 @@ describe('getSubscriptionCreateSetup', () => {
200
201
  );
201
202
  });
202
203
 
203
- it('should calculate trial period when only trialEnds is provided', () => {
204
+ it('should calculate trial period when only trialEnd is provided', () => {
204
205
  const items = [
205
206
  {
206
207
  price: { type: 'recurring', currency_options: currencies, recurring: { interval: 'day', interval_count: '1' } },
@@ -371,8 +372,8 @@ describe('getSubscriptionStakeSetup', () => {
371
372
 
372
373
  it('should calculate staking for recurring metered price type when billingThreshold is 0 #1', () => {
373
374
  const result = getSubscriptionStakeSetup(items.slice(2, 3), 'usd', '10');
374
- expect(result.licensed.toString()).toBe('0');
375
- expect(result.metered.toString()).toBe('10');
375
+ expect(result.licensed.toString()).toBe('10');
376
+ expect(result.metered.toString()).toBe('0');
376
377
  });
377
378
 
378
379
  it('should calculate staking for recurring metered price type when billingThreshold is 0 #2', () => {
@@ -383,7 +384,30 @@ describe('getSubscriptionStakeSetup', () => {
383
384
 
384
385
  it('should calculate staking for recurring metered price type when billingThreshold is greater than 0', () => {
385
386
  const result = getSubscriptionStakeSetup(items, 'usd', '10');
386
- expect(result.licensed.toString()).toBe('2');
387
- expect(result.metered.toString()).toBe('10');
387
+ expect(result.licensed.toString()).toBe('10');
388
+ expect(result.metered.toString()).toBe('0');
389
+ });
390
+ });
391
+
392
+ describe('getSubscriptionTrialSetup', () => {
393
+ it('should return trialInDays and trialEnd when data is provided', () => {
394
+ const data: any = { trial_period_days: '10', trial_end: '20', trial_currency: 'USD,EUR' };
395
+ const currencyId = 'USD';
396
+ const result = getSubscriptionTrialSetup(data, currencyId);
397
+ expect(result).toEqual({ trialInDays: 10, trialEnd: 20 });
398
+ });
399
+
400
+ it('should set trialInDays and trialEnd to 0 if currencyId is not in the trialCurrencyIds list', () => {
401
+ const data: any = { trial_period_days: '10', trial_end: '20', trial_currency: 'USD,EUR' };
402
+ const currencyId = 'JPY'; // currency code not included in the trialCurrencyIds list
403
+ const result = getSubscriptionTrialSetup(data, currencyId);
404
+ expect(result).toEqual({ trialInDays: 0, trialEnd: 0 });
405
+ });
406
+
407
+ it('should set trialInDays and trialEnd to 0 when values are not provided', () => {
408
+ const data: any = {};
409
+ const currencyId = 'USD';
410
+ const result = getSubscriptionTrialSetup(data, currencyId);
411
+ expect(result).toEqual({ trialInDays: 0, trialEnd: 0 });
388
412
  });
389
413
  });
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.14.34
17
+ version: 1.14.37
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
@@ -39,7 +39,6 @@ interfaces:
39
39
  profileFields:
40
40
  - fullName
41
41
  - email
42
- - phone
43
42
  - avatar
44
43
  allowSwitchProfile: true
45
44
  ignoreUrls:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.14.34",
3
+ "version": "1.14.37",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "eject": "vite eject",
@@ -52,7 +52,7 @@
52
52
  "@arcblock/validator": "^1.18.132",
53
53
  "@blocklet/js-sdk": "1.16.30",
54
54
  "@blocklet/logger": "1.16.30",
55
- "@blocklet/payment-react": "1.14.34",
55
+ "@blocklet/payment-react": "1.14.37",
56
56
  "@blocklet/sdk": "1.16.30",
57
57
  "@blocklet/ui-react": "^2.10.23",
58
58
  "@blocklet/uploader": "^0.1.27",
@@ -119,7 +119,7 @@
119
119
  "devDependencies": {
120
120
  "@abtnode/types": "1.16.30",
121
121
  "@arcblock/eslint-config-ts": "^0.3.2",
122
- "@blocklet/payment-types": "1.14.34",
122
+ "@blocklet/payment-types": "1.14.37",
123
123
  "@types/cookie-parser": "^1.4.7",
124
124
  "@types/cors": "^2.8.17",
125
125
  "@types/debug": "^4.1.12",
@@ -161,5 +161,5 @@
161
161
  "parser": "typescript"
162
162
  }
163
163
  },
164
- "gitHead": "7e2c9a3dd52ccd6880ebbd08a953d93aec715dff"
164
+ "gitHead": "645ae0bdbab463eec878af0521eb982b51499639"
165
165
  }
@@ -1,8 +1,8 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import { stopEvent } from '@blocklet/payment-react';
3
3
  import { ExpandMoreOutlined, MoreHorizOutlined } from '@mui/icons-material';
4
- import { Button, IconButton, ListItemText, Menu, MenuItem } from '@mui/material';
5
- import React, { useState } from 'react';
4
+ import { Button, IconButton, ListItemText, Menu, MenuItem, Skeleton } from '@mui/material';
5
+ import React, { useRef, useState } from 'react';
6
6
  import type { LiteralUnion } from 'type-fest';
7
7
 
8
8
  type ActionItem = {
@@ -18,20 +18,34 @@ export type ActionsProps = {
18
18
  actions: ActionItem[];
19
19
  variant?: LiteralUnion<'compact' | 'normal', string>;
20
20
  sx?: any;
21
+ onOpenCallback?: Function;
21
22
  };
22
23
 
23
24
  Actions.defaultProps = {
24
25
  variant: 'compact',
25
26
  sx: {},
27
+ onOpenCallback: null,
26
28
  };
27
29
 
28
30
  export default function Actions(props: ActionsProps) {
29
31
  const { t } = useLocaleContext();
30
32
  const [anchorEl, setAnchorEl] = useState(null);
31
33
  const open = Boolean(anchorEl);
34
+ const anchorRef = useRef(null);
35
+ const [openLoading, setOpenLoading] = useState(false);
32
36
 
33
37
  const onOpen = (e: React.SyntheticEvent<any>) => {
34
38
  stopEvent(e);
39
+ anchorRef.current = e.currentTarget;
40
+ if (props.onOpenCallback && typeof props.onOpenCallback === 'function') {
41
+ const result = props.onOpenCallback();
42
+ if (result instanceof Promise) {
43
+ setOpenLoading(true);
44
+ result.finally(() => {
45
+ setOpenLoading(false);
46
+ });
47
+ }
48
+ }
35
49
  setAnchorEl(e.currentTarget);
36
50
  };
37
51
 
@@ -62,16 +76,22 @@ export default function Actions(props: ActionsProps) {
62
76
  onClose={onClose}
63
77
  anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
64
78
  transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
65
- {props.actions.map((action) => (
66
- <MenuItem
67
- key={action.label}
68
- divider={!!action.divider}
69
- dense={!!action.dense}
70
- disabled={!!action.disabled}
71
- onClick={(e) => onClose(e, action.handler)}>
72
- <ListItemText primary={action.label} primaryTypographyProps={{ color: action.color }} />
73
- </MenuItem>
74
- ))}
79
+ {props.actions.map((action) =>
80
+ openLoading ? (
81
+ <MenuItem key={action.label} dense disabled>
82
+ <ListItemText primary={<Skeleton />} primaryTypographyProps={{ width: '56px' }} />
83
+ </MenuItem>
84
+ ) : (
85
+ <MenuItem
86
+ key={action.label}
87
+ divider={!!action.divider}
88
+ dense={!!action.dense}
89
+ disabled={!!action.disabled}
90
+ onClick={(e) => onClose(e, action.handler)}>
91
+ <ListItemText primary={action.label} primaryTypographyProps={{ color: action.color }} />
92
+ </MenuItem>
93
+ )
94
+ )}
75
95
  </Menu>
76
96
  </>
77
97
  );
@@ -10,7 +10,7 @@ const fetchData = (id: string, time: string): Promise<{ total: string; unused: s
10
10
  return api.get(`/api/subscriptions/${id}/proration?time=${encodeURIComponent(time)}`).then((res: any) => res.data);
11
11
  };
12
12
 
13
- const fetchStakingData = (id: string, time: string): Promise<{ return_amount: string }> => {
13
+ const fetchStakingData = (id: string, time: string): Promise<{ return_amount: string; slash_amount: string }> => {
14
14
  return api.get(`/api/subscriptions/${id}/staking?time=${encodeURIComponent(time)}`).then((res: any) => res.data);
15
15
  };
16
16
 
@@ -153,7 +153,7 @@ export default function SubscriptionCancelForm({ data }: { data: TSubscriptionEx
153
153
  onClick={() => !(loading || !staking) && setValue('cancel.staking', 'slash')}
154
154
  control={<Radio checked={stakingType === 'slash'} />}
155
155
  label={t('admin.subscription.cancel.staking.slash', {
156
- unused: formatAmount(staking?.return_amount || '0', decimal),
156
+ unused: formatAmount(staking?.slash_amount || '0', decimal),
157
157
  symbol,
158
158
  })}
159
159
  />
@@ -1,12 +1,13 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import Toast from '@arcblock/ux/lib/Toast';
3
- import { ConfirmDialog, api, formatError } from '@blocklet/payment-react';
3
+ import { ConfirmDialog, api, formatBNStr, formatError } from '@blocklet/payment-react';
4
4
  import type { TSubscriptionExpanded } from '@blocklet/payment-types';
5
- import { useSetState } from 'ahooks';
5
+ import { useRequest, useSetState } from 'ahooks';
6
6
  import { FormProvider, useForm, useFormContext } from 'react-hook-form';
7
7
  import { useNavigate } from 'react-router-dom';
8
8
  import type { LiteralUnion } from 'type-fest';
9
9
 
10
+ import { useMemo } from 'react';
10
11
  import Actions from '../../actions';
11
12
  import ClickBoundary from '../../click-boundary';
12
13
  import SubscriptionCancelForm from './cancel';
@@ -22,6 +23,10 @@ SubscriptionActionsInner.defaultProps = {
22
23
  variant: 'compact',
23
24
  };
24
25
 
26
+ const fetchStakingData = (id: string, time: string): Promise<{ return_amount: string; slash_amount: string }> => {
27
+ return api.get(`/api/subscriptions/${id}/staking?time=${encodeURIComponent(time)}`).then((res: any) => res.data);
28
+ };
29
+
25
30
  function SubscriptionActionsInner({ data, variant, onChange }: Props) {
26
31
  const { t } = useLocaleContext();
27
32
  const navigate = useNavigate();
@@ -31,6 +36,21 @@ function SubscriptionActionsInner({ data, variant, onChange }: Props) {
31
36
  loading: false,
32
37
  });
33
38
 
39
+ const {
40
+ data: stakeResult = {
41
+ return_amount: '0',
42
+ total: '0',
43
+ slash_amount: '0',
44
+ },
45
+ runAsync: fetchStakeResultAsync,
46
+ } = useRequest(() => fetchStakingData(data.id, ''), {
47
+ manual: true,
48
+ });
49
+
50
+ const stakeValue = useMemo(() => {
51
+ return formatBNStr(stakeResult?.slash_amount, data?.paymentCurrency?.decimal);
52
+ }, [stakeResult]);
53
+
34
54
  const createHandler = (action: string) => {
35
55
  return async () => {
36
56
  const values = getValues();
@@ -79,6 +99,8 @@ function SubscriptionActionsInner({ data, variant, onChange }: Props) {
79
99
  color: 'primary',
80
100
  };
81
101
 
102
+ const showSlashStake = data.status === 'canceled' && Number(stakeValue) > 0;
103
+
82
104
  const actions = [
83
105
  {
84
106
  label: t('admin.subscription.update'),
@@ -92,8 +114,20 @@ function SubscriptionActionsInner({ data, variant, onChange }: Props) {
92
114
  handler: () => setState({ action: 'cancel' }),
93
115
  disabled: data.status === 'canceled',
94
116
  color: 'error',
95
- divider: true,
117
+ divider: !showSlashStake,
96
118
  },
119
+ ...(showSlashStake
120
+ ? [
121
+ {
122
+ label: t('admin.subscription.cancel.staking.slashTitle'),
123
+ handler: () => {
124
+ setState({ action: 'slashStake' });
125
+ },
126
+ color: 'error',
127
+ divider: true,
128
+ },
129
+ ]
130
+ : []),
97
131
  {
98
132
  label: t('admin.customer.view'),
99
133
  handler: () => navigate(`/admin/customers/${data.customer_id}`),
@@ -111,7 +145,7 @@ function SubscriptionActionsInner({ data, variant, onChange }: Props) {
111
145
 
112
146
  return (
113
147
  <ClickBoundary>
114
- <Actions variant={variant} actions={actions} />
148
+ <Actions variant={variant} actions={actions} onOpenCallback={fetchStakeResultAsync} />
115
149
  {state.action === 'cancel' && (
116
150
  <ConfirmDialog
117
151
  onConfirm={createHandler('cancel')}
@@ -139,6 +173,18 @@ function SubscriptionActionsInner({ data, variant, onChange }: Props) {
139
173
  loading={state.loading}
140
174
  />
141
175
  )}
176
+ {state.action === 'slashStake' && (
177
+ <ConfirmDialog
178
+ onConfirm={createHandler('slash-stake')}
179
+ onCancel={handleCancel}
180
+ title={t('admin.subscription.cancel.staking.slashTitle')}
181
+ message={t('admin.subscription.cancel.staking.slashTip', {
182
+ unused: stakeValue,
183
+ symbol: data.paymentCurrency?.symbol,
184
+ })}
185
+ loading={state.loading}
186
+ />
187
+ )}
142
188
  </ClickBoundary>
143
189
  );
144
190
  }
@@ -465,6 +465,9 @@ export default flat({
465
465
  none: 'No return or slash',
466
466
  proration: 'Return Remaining Stake {unused}{symbol}',
467
467
  slash: 'Slash Remaining Stake {unused}{symbol}',
468
+ slashTip:
469
+ 'The remaining stake of this subscription {unused}{symbol} will be slashed, please confirm to continue?',
470
+ slashTitle: 'Slash stake',
468
471
  },
469
472
  },
470
473
  pause: {
@@ -456,6 +456,8 @@ export default flat({
456
456
  none: '不退还 / 罚没质押',
457
457
  proration: '退还剩余部分 {unused}{symbol}',
458
458
  slash: '罚没剩余部分 {unused}{symbol}',
459
+ slashTip: '该订阅剩余的质押部分 {unused}{symbol} 将被罚没, 请确认是否继续?',
460
+ slashTitle: '罚没质押',
459
461
  },
460
462
  },
461
463
  pause: {
@@ -64,7 +64,7 @@ export default function CustomersList() {
64
64
  variant="square"
65
65
  sx={{ borderRadius: 'var(--radius-m, 8px)' }}
66
66
  />
67
- <Typography>{item.name}</Typography>
67
+ <Typography sx={{ wordBreak: 'break-all' }}>{item.name}</Typography>
68
68
  </Stack>
69
69
  </Link>
70
70
  );