-
Notifications
You must be signed in to change notification settings - Fork 5.4k
feat: handle add fund auto check #36847
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b932036
79f8c54
23d87fc
2a3f7b9
1609ae7
2f72178
12a5825
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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( | ||
|
|
@@ -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; | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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, | ||
| }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Hook Argument Mismatch Causes Undefined ValueThe Additional Locations (1) |
||
|
|
||
| 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, | ||
| ]); | ||
| }; | ||
There was a problem hiding this comment.
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
calculateSubscriptionRemainingBillingCyclesfunction 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.