Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion shared/modules/shield.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Subscription } from '@metamask/subscription-controller';
import {
RECURRING_INTERVALS,
Subscription,
} from '@metamask/subscription-controller';
import { getIsShieldSubscriptionActive } from '../lib/shield';

export async function getShieldGatewayConfig(
Expand Down Expand Up @@ -50,3 +53,34 @@ export async function getShieldGatewayConfig(
};
}
}

/**
* Calculate the remaining billing cycles for a subscription
*
* @param params
* @param params.currentPeriodEnd - The current period end date.
* @param params.endDate - The end date.
* @param params.interval - The interval.
* @returns The remaining billing cycles.
*/
export function calculateSubscriptionRemainingBillingCycles({
currentPeriodEnd,
endDate,
interval,
}: {
currentPeriodEnd: Date;
endDate: Date;
interval: (typeof RECURRING_INTERVALS)[keyof typeof RECURRING_INTERVALS];
}): number {
if (interval === RECURRING_INTERVALS.month) {
const yearDiff = endDate.getFullYear() - currentPeriodEnd.getFullYear();
const monthDiff = endDate.getMonth() - currentPeriodEnd.getMonth();
// Assume the period end and endDate have the same day of the month and time
// Current period is inclusive, so we need to add 1
return yearDiff * 12 + monthDiff + 1;
}
const yearDiff = endDate.getFullYear() - currentPeriodEnd.getFullYear();
// Assume the period end and endDate have the same month, day of the month and time
// Current period is inclusive, so we need to add 1
return yearDiff + 1;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Subscription Billing Cycle Calculation Error

The calculateSubscriptionRemainingBillingCycles function only correctly calculates billing cycles for monthly intervals. Other interval types are incorrectly calculated using yearly logic, leading to inaccurate results for non-monthly, non-yearly subscriptions.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Subscription Billing Cycle Calculation Error

The calculateSubscriptionRemainingBillingCycles function returns negative or incorrect zero values when endDate is before currentPeriodEnd. This affects both monthly and yearly interval calculations, leading to unexpected billing cycle counts.

Fix in Cursor Fix in Web

7 changes: 6 additions & 1 deletion ui/contexts/shield/shield-subscription.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {
getHasShieldEntryModalShownOnce,
getIsActiveShieldSubscription,
} from '../../selectors/subscription';
import { MetaMaskReduxDispatch } from '../../store/store';
import { getIsUnlocked } from '../../ducks/metamask/metamask';
import { useShieldAddFundTrigger } from './useAddFundTrigger';

export const ShieldSubscriptionContext = React.createContext<{
resetShieldEntryModalShownStatus: () => void;
Expand All @@ -45,7 +47,7 @@ export const useShieldSubscriptionContext = () => {
};

export const ShieldSubscriptionProvider: React.FC = ({ children }) => {
const dispatch = useDispatch();
const dispatch = useDispatch<MetaMaskReduxDispatch>();
const isBasicFunctionalityEnabled = Boolean(
useSelector(getUseExternalServices),
);
Expand All @@ -65,6 +67,9 @@ export const ShieldSubscriptionProvider: React.FC = ({ children }) => {
true, // use USD conversion rate instead of the current currency
);

// watch handle add fund trigger server check subscirption paused because of insufficient funds
useShieldAddFundTrigger();

/**
* Check if the user's balance criteria is met to show the shield entry modal.
* Shield entry modal will be shown if:
Expand Down
236 changes: 236 additions & 0 deletions ui/contexts/shield/useAddFundTrigger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
CRYPTO_PAYMENT_METHOD_ERRORS,
PAYMENT_TYPES,
PRODUCT_TYPES,
SUBSCRIPTION_STATUSES,
SubscriptionCryptoPaymentMethod,
SubscriptionStatus,
} from '@metamask/subscription-controller';
import log from 'loglevel';
import { useTokenBalances as pollAndUpdateEvmBalances } from '../../hooks/useTokenBalances';
import {
useUserSubscriptionByProduct,
useUserSubscriptions,
} from '../../hooks/subscription/useSubscription';
import {
getSubscriptions,
updateSubscriptionCryptoPaymentMethod,
} from '../../store/actions';
import { getSelectedAccount } from '../../selectors';
import {
useSubscriptionPaymentMethods,
useSubscriptionPricing,
useSubscriptionProductPlans,
} from '../../hooks/subscription/useSubscriptionPricing';
import { isCryptoPaymentMethod } from '../../pages/settings/transaction-shield-tab/types';
import { getTokenBalancesEvm } from '../../selectors/assets';
import { MetaMaskReduxDispatch } from '../../store/store';
import { calculateSubscriptionRemainingBillingCycles } from '../../../shared/modules/shield';
import { useThrottle } from '../../hooks/useThrottle';
import { MINUTE } from '../../../shared/constants/time';

const SHIELD_ADD_FUND_TRIGGER_INTERVAL = 5 * MINUTE;

/**
* Trigger the subscription check after user funding met criteria
*
*/
export const useShieldAddFundTrigger = () => {
const dispatch = useDispatch<MetaMaskReduxDispatch>();
const { subscriptions } = useUserSubscriptions();
const shieldSubscription = useUserSubscriptionByProduct(
PRODUCT_TYPES.SHIELD,
subscriptions,
);
// TODO: update to correct subscription status after implementation
const isSubscriptionPaused =
shieldSubscription &&
(
[
SUBSCRIPTION_STATUSES.paused,
SUBSCRIPTION_STATUSES.pastDue,
SUBSCRIPTION_STATUSES.unpaid,
] as SubscriptionStatus[]
).includes(shieldSubscription.status);

const { subscriptionPricing } = useSubscriptionPricing();
const pricingPlans = useSubscriptionProductPlans(
PRODUCT_TYPES.SHIELD,
subscriptionPricing,
);
const cryptoPaymentMethod = useSubscriptionPaymentMethods(
PAYMENT_TYPES.byCrypto,
subscriptionPricing,
);

const cryptoPaymentInfo = shieldSubscription?.paymentMethod as
| SubscriptionCryptoPaymentMethod
| undefined;
const selectedTokenPrice = cryptoPaymentInfo
? cryptoPaymentMethod?.chains
?.find(
(chain) =>
chain.chainId.toLowerCase() ===
cryptoPaymentInfo?.crypto.chainId.toLowerCase(),
)
?.tokens.find(
(token) =>
token.symbol.toLowerCase() ===
cryptoPaymentInfo?.crypto.tokenSymbol.toLowerCase(),
)
: undefined;

const selectedProductPrice = useMemo(() => {
return pricingPlans?.find(
(plan) => plan.interval === shieldSubscription?.interval,
);
}, [pricingPlans, shieldSubscription]);

const paymentChainIds = useMemo(
() => (cryptoPaymentInfo ? [cryptoPaymentInfo.crypto.chainId] : []),
[cryptoPaymentInfo],
);

const selectedAccount = useSelector(getSelectedAccount);
const evmBalances = useSelector((state) =>
getTokenBalancesEvm(state, selectedAccount?.address),
);

// Poll and update evm balances for payment chains
pollAndUpdateEvmBalances({ chainIds: paymentChainIds });
// valid token balances for checking
const validTokenBalances = useMemo(() => {
return evmBalances.filter((token) => {
const supportedTokensForChain =
cryptoPaymentInfo?.crypto.chainId === token.chainId;
const isSupportedChain = Boolean(supportedTokensForChain);
if (!isSupportedChain) {
return false;
}
const isSupportedToken =
cryptoPaymentInfo?.crypto.tokenSymbol.toLowerCase() ===
token.symbol.toLowerCase();
if (!isSupportedToken) {
return false;
}
const hasBalance = token.balance && parseFloat(token.balance) > 0;
if (!hasBalance) {
return false;
}
if (!selectedProductPrice || !shieldSubscription?.endDate) {
return false;
}

const remainingBillingCycles =
calculateSubscriptionRemainingBillingCycles({
currentPeriodEnd: new Date(shieldSubscription.currentPeriodEnd),
endDate: new Date(shieldSubscription.endDate),
interval: shieldSubscription.interval,
});
// no need to use BigInt since max unitDecimals are always 2 for price
const remainingFundBalanceNeeded =
(selectedProductPrice.unitAmount /
10 ** selectedProductPrice.unitDecimals) *
remainingBillingCycles;

return (
token.balance && parseFloat(token.balance) >= remainingFundBalanceNeeded
);
});
}, [
evmBalances,
cryptoPaymentInfo,
selectedProductPrice,
shieldSubscription,
]);

const hasAvailableSelectedToken = validTokenBalances.length > 0;

// throttle the hasAvailableSelectedToken to avoid multiple triggers
const { value: hasAvailableSelectedTokenThrottled } = useThrottle({
value: hasAvailableSelectedToken,
interval: SHIELD_ADD_FUND_TRIGGER_INTERVAL,
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Hook Argument Mismatch Causes Undefined Value

The useThrottle hook is called with an object { value, interval } instead of separate arguments, and its direct return value is incorrectly destructured for a value property. This prevents the throttling from working as intended, causing hasAvailableSelectedTokenThrottled to be undefined.

Additional Locations (1)

Fix in Cursor Fix in Web


const handleTriggerSubscriptionCheck = useCallback(async () => {
if (
!shieldSubscription ||
!selectedProductPrice ||
!hasAvailableSelectedTokenThrottled ||
!cryptoPaymentInfo
) {
return;
}

try {
// selected token is available, so we can trigger the subscription check
await dispatch(
updateSubscriptionCryptoPaymentMethod({
subscriptionId: shieldSubscription.id,
paymentType: PAYMENT_TYPES.byCrypto,
recurringInterval: shieldSubscription.interval,
chainId: cryptoPaymentInfo.crypto.chainId,
payerAddress: cryptoPaymentInfo.crypto.payerAddress,
tokenSymbol: cryptoPaymentInfo.crypto.tokenSymbol,
billingCycles:
shieldSubscription.billingCycles ??
selectedProductPrice?.minBillingCycles,
rawTransaction: undefined, // no raw transaction to trigger server to check for new funded balance
}),
);
// refetch subscription after trigger subscription check for new status
await dispatch(getSubscriptions());
} catch (error) {
log.error(
'[useShieldAddFundTrigger] error triggering subscription check',
error,
);
}
}, [
dispatch,
shieldSubscription,
selectedProductPrice,
hasAvailableSelectedTokenThrottled,
cryptoPaymentInfo,
]);

useEffect(() => {
if (
!shieldSubscription ||
!isSubscriptionPaused ||
!subscriptionPricing ||
!cryptoPaymentInfo ||
!selectedProductPrice
) {
return;
}
const isInsufficientBalanceError =
cryptoPaymentInfo.crypto.error ===
CRYPTO_PAYMENT_METHOD_ERRORS.INSUFFICIENT_BALANCE;

const isCryptoPayment = isCryptoPaymentMethod(
shieldSubscription.paymentMethod,
);
if (
!isInsufficientBalanceError ||
!isCryptoPayment ||
!selectedTokenPrice ||
!hasAvailableSelectedTokenThrottled
) {
return;
}

handleTriggerSubscriptionCheck();
}, [
isSubscriptionPaused,
subscriptionPricing,
cryptoPaymentInfo,
selectedTokenPrice,
selectedProductPrice,
hasAvailableSelectedTokenThrottled,
shieldSubscription,
handleTriggerSubscriptionCheck,
]);
};
9 changes: 9 additions & 0 deletions ui/hooks/subscription/useSubscriptionPricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ export type TokenWithApprovalAmount = (
};
};

/**
* get user available token balances for starting subscription
*
* @param params
* @param params.paymentChains - The payment chains info.
* @param params.price - The product price.
* @param params.productType - The product type.
* @returns The available token balances.
*/
export const useAvailableTokenBalances = (params: {
paymentChains?: ChainPaymentInfo[];
price?: ProductPrice;
Expand Down
Loading
Loading