diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 6f94a23acad8..8ec415442041 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -162,6 +162,15 @@ const ONYXKEYS = { /** Store the state of the subscription */ NVP_PRIVATE_SUBSCRIPTION: 'nvp_private_subscription', + /** Store the stripe id status */ + NVP_PRIVATE_STRIPE_CUSTOMER_ID: 'nvp_private_stripeCustomerID', + + /** Store the billing dispute status */ + NVP_PRIVATE_BILLING_DISPUTE_PENDING: 'nvp_private_billingDisputePending', + + /** Store the billing status */ + NVP_PRIVATE_BILLING_STATUS: 'nvp_private_billingStatus', + /** Store preferred skintone for emoji */ PREFERRED_EMOJI_SKIN_TONE: 'nvp_expensify_preferredEmojiSkinTone', @@ -187,7 +196,7 @@ const ONYXKEYS = { NVP_BILLING_FUND_ID: 'nvp_expensify_billingFundID', /** The amount owed by the workspace’s owner. */ - NVP_PRIVATE_AMOUNT_OWNED: 'nvp_private_amountOwed', + NVP_PRIVATE_AMOUNT_OWED: 'nvp_private_amountOwed', /** The end date (epoch timestamp) of the workspace owner’s grace period after the free trial ends. */ NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END: 'nvp_private_billingGracePeriodEnd', @@ -350,6 +359,12 @@ const ONYXKEYS = { /** Holds the checks used while transferring the ownership of the workspace */ POLICY_OWNERSHIP_CHANGE_CHECKS: 'policyOwnershipChangeChecks', + /** Indicates whether ClearOutstandingBalance failed */ + SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED: 'subscriptionRetryBillingStatusFailed', + + /** Indicates whether ClearOutstandingBalance was successful */ + SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL: 'subscriptionRetryBillingStatusSuccessful', + /** Stores info during review duplicates flow */ REVIEW_DUPLICATES: 'reviewDuplicates', @@ -702,6 +717,9 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS]: OnyxTypes.DismissedReferralBanners; [ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING]: boolean; [ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION]: OnyxTypes.PrivateSubscription; + [ONYXKEYS.NVP_PRIVATE_STRIPE_CUSTOMER_ID]: OnyxTypes.StripeCustomerID; + [ONYXKEYS.NVP_PRIVATE_BILLING_DISPUTE_PENDING]: number; + [ONYXKEYS.NVP_PRIVATE_BILLING_STATUS]: OnyxTypes.BillingStatus; [ONYXKEYS.USER_WALLET]: OnyxTypes.UserWallet; [ONYXKEYS.WALLET_ONFIDO]: OnyxTypes.WalletOnfido; [ONYXKEYS.WALLET_ADDITIONAL_DETAILS]: OnyxTypes.WalletAdditionalDetails; @@ -750,13 +768,15 @@ type OnyxValuesMapping = { [ONYXKEYS.CACHED_PDF_PATHS]: Record; [ONYXKEYS.POLICY_OWNERSHIP_CHANGE_CHECKS]: Record; [ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE]: OnyxTypes.QuickAction; + [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED]: boolean; + [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL]: boolean; [ONYXKEYS.NVP_TRAVEL_SETTINGS]: OnyxTypes.TravelSettings; [ONYXKEYS.REVIEW_DUPLICATES]: OnyxTypes.ReviewDuplicates; [ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: string; [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: string; [ONYXKEYS.NVP_BILLING_FUND_ID]: number; - [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWNED]: number; + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number; [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; }; diff --git a/src/components/Indicator.tsx b/src/components/Indicator.tsx index 8830681bc55f..0d269d1ca593 100644 --- a/src/components/Indicator.tsx +++ b/src/components/Indicator.tsx @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as PolicyUtils from '@libs/PolicyUtils'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as UserUtils from '@libs/UserUtils'; import * as PaymentMethods from '@userActions/PaymentMethods'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -54,13 +55,14 @@ function Indicator({reimbursementAccount, policies, bankAccountList, fundList, u () => Object.values(cleanPolicies).some(PolicyUtils.hasPolicyError), () => Object.values(cleanPolicies).some(PolicyUtils.hasCustomUnitsError), () => Object.values(cleanPolicies).some(PolicyUtils.hasEmployeeListError), + () => SubscriptionUtils.hasSubscriptionRedDotError(), () => Object.keys(reimbursementAccount?.errors ?? {}).length > 0, () => !!loginList && UserUtils.hasLoginListError(loginList), // Wallet term errors that are not caused by an IOU (we show the red brick indicator for those in the LHN instead) () => Object.keys(walletTerms?.errors ?? {}).length > 0 && !walletTerms?.chatReportID, ]; - const infoCheckingMethods: CheckingMethod[] = [() => !!loginList && UserUtils.hasLoginListInfo(loginList)]; + const infoCheckingMethods: CheckingMethod[] = [() => !!loginList && UserUtils.hasLoginListInfo(loginList), () => SubscriptionUtils.hasSubscriptionGreenDotInfo()]; const shouldShowErrorIndicator = errorCheckingMethods.some((errorCheckingMethod) => errorCheckingMethod()); const shouldShowInfoIndicator = !shouldShowErrorIndicator && infoCheckingMethods.some((infoCheckingMethod) => infoCheckingMethod()); diff --git a/src/languages/en.ts b/src/languages/en.ts index 095cc12a3896..0bc016c562e5 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3335,6 +3335,55 @@ export default { subscription: { mobileReducedFunctionalityMessage: 'You can’t make changes to your subscription in the mobile app.', billingBanner: { + policyOwnerAmountOwed: { + title: 'Your payment info is outdated', + subtitle: ({date}) => `Update your payment card by ${date} to continue using all of your favorite features.`, + }, + policyOwnerAmountOwedOverdue: { + title: 'Your payment info is outdated', + subtitle: 'Please update your payment information.', + }, + policyOwnerUnderInvoicing: { + title: 'Your payment info is outdated', + subtitle: ({date}) => `Your payment is past due. Please pay your invoice by ${date} to avoid service interruption.`, + }, + policyOwnerUnderInvoicingOverdue: { + title: 'Your payment info is outdated', + subtitle: 'Your payment is past due. Please pay your invoice.', + }, + billingDisputePending: { + title: 'Your card couldn’t be charged', + subtitle: ({amountOwed, cardEnding}) => + `You disputed the ${amountOwed} charge on the card ending in ${cardEnding}. Your account will be locked until the dispute is resolved with your bank.`, + }, + cardAuthenticationRequired: { + title: 'Your card couldn’t be charged', + subtitle: ({cardEnding}) => + `Your payment card hasn’t been fully authenticated. Please complete the authentication process to activate your payment card ending in ${cardEnding}.`, + }, + insufficientFunds: { + title: 'Your card couldn’t be charged', + subtitle: ({amountOwed}) => + `Your payment card was declined due to insufficient funds. Please retry or add a new payment card to clear your ${amountOwed} outstanding balance.`, + }, + cardExpired: { + title: 'Your card couldn’t be charged', + subtitle: ({amountOwed}) => `Your payment card expired. Please add a new payment card to clear your ${amountOwed} outstanding balance.`, + }, + cardExpireSoon: { + title: 'Your card is expiring soon', + subtitle: 'Your payment card will expire at the end of this month. Click the three-dot menu below to update it and continue using all your favorite features.', + }, + retryBillingSuccess: { + title: 'Success!', + subtitle: 'Your card has been billed successfully.', + }, + retryBillingError: { + title: 'Your card couldn’t be charged', + subtitle: 'Before retrying, please call your bank directly to authorize Expensify charges and remove any holds. Otherwise, try adding a different payment card.', + }, + cardOnDispute: ({amountOwed, cardEnding}) => + `You disputed the ${amountOwed} charge on the card ending in ${cardEnding}. Your account will be locked until the dispute is resolved with your bank.`, preTrial: { title: 'Start a free trial', subtitle: 'To get started, ', diff --git a/src/languages/es.ts b/src/languages/es.ts index e2a00222c62a..af7a64d9f135 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3835,6 +3835,57 @@ export default { subscription: { mobileReducedFunctionalityMessage: 'No puedes hacer cambios en tu suscripción en la aplicación móvil.', billingBanner: { + policyOwnerAmountOwed: { + title: 'Tu información de pago está desactualizada', + subtitle: ({date}) => `Actualiza tu tarjeta de pago antes del ${date} para continuar utilizando todas tus herramientas favoritas`, + }, + policyOwnerAmountOwedOverdue: { + title: 'Tu información de pago está desactualizada', + subtitle: 'Por favor, actualiza tu información de pago.', + }, + policyOwnerUnderInvoicing: { + title: 'Tu información de pago está desactualizada', + subtitle: ({date}) => `Tu pago está vencido. Por favor, paga tu factura antes del ${date} para evitar la interrupción del servicio.`, + }, + policyOwnerUnderInvoicingOverdue: { + title: 'Tu información de pago está desactualizada', + subtitle: 'Tu pago está vencido. Por favor, paga tu factura.', + }, + billingDisputePending: { + title: 'No se ha podido realizar el cobro a tu tarjeta', + subtitle: ({amountOwed, cardEnding}) => + `Has impugnado el cargo ${amountOwed} en la tarjeta terminada en ${cardEnding}. Tu cuenta estará bloqueada hasta que se resuelva la disputa con tu banco.`, + }, + cardAuthenticationRequired: { + title: 'No se ha podido realizar el cobro a tu tarjeta', + subtitle: ({cardEnding}) => + `Tu tarjeta de pago no ha sido autenticada completamente. Por favor, completa el proceso de autenticación para activar tu tarjeta de pago que termina en ${cardEnding}.`, + }, + insufficientFunds: { + title: 'No se ha podido realizar el cobro a tu tarjeta', + subtitle: ({amountOwed}) => + `Tu tarjeta de pago fue rechazada por falta de fondos. Vuelve a intentarlo o añade una nueva tarjeta de pago para liquidar tu saldo pendiente de ${amountOwed}.`, + }, + cardExpired: { + title: 'No se ha podido realizar el cobro a tu tarjeta', + subtitle: ({amountOwed}) => `Tu tarjeta de pago ha expirado. Por favor, añade una nueva tarjeta de pago para liquidar tu saldo pendiente de ${amountOwed}.`, + }, + cardExpireSoon: { + title: 'Tu tarjeta caducará pronto', + subtitle: + 'Tu tarjeta de pago caducará a finales de este mes. Haz clic en el menú de tres puntos que aparece a continuación para actualizarla y continuar utilizando todas tus herramientas favoritas.', + }, + retryBillingSuccess: { + title: 'Éxito!', + subtitle: 'Tu tarjeta fue facturada correctamente.', + }, + retryBillingError: { + title: 'No se ha podido realizar el cobro a tu tarjeta', + subtitle: + 'Antes de volver a intentarlo, llama directamente a tu banco para que autorice los cargos de Expensify y elimine las retenciones. De lo contrario, añade una tarjeta de pago diferente.', + }, + cardOnDispute: ({amountOwed, cardEnding}) => + `Has impugnado el cargo ${amountOwed} en la tarjeta terminada en ${cardEnding}. Tu cuenta estará bloqueada hasta que se resuelva la disputa con tu banco.`, preTrial: { title: 'Iniciar una prueba gratuita', subtitle: 'Para empezar, ', diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index 50dc4f99eec0..c8ce7a455906 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -1,9 +1,98 @@ import {differenceInSeconds, fromUnixTime, isAfter, isBefore, parse as parseDate} from 'date-fns'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {BillingGraceEndPeriod, Policy} from '@src/types/onyx'; +import type {BillingGraceEndPeriod, BillingStatus, Fund, FundList, Policy, StripeCustomerID} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +const PAYMENT_STATUS = { + POLICY_OWNER_WITH_AMOUNT_OWED: 'policy_owner_with_amount_owed', + POLICY_OWNER_WITH_AMOUNT_OWED_OVERDUE: 'policy_owner_with_amount_owed_overdue', + OWNER_OF_POLICY_UNDER_INVOICING: 'owner_of_policy_under_invoicing', + OWNER_OF_POLICY_UNDER_INVOICING_OVERDUE: 'owner_of_policy_under_invoicing_overdue', + BILLING_DISPUTE_PENDING: 'billing_dispute_pending', + CARD_AUTHENTICATION_REQUIRED: 'authentication_required', + INSUFFICIENT_FUNDS: 'insufficient_funds', + CARD_EXPIRED: 'expired_card', + CARD_EXPIRE_SOON: 'card_expire_soon', + RETRY_BILLING_SUCCESS: 'retry_billing_success', + RETRY_BILLING_ERROR: 'retry_billing_error', + GENERIC_API_ERROR: 'generic_api_error', +} as const; + +let amountOwed: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED, + callback: (value) => (amountOwed = value), +}); + +let stripeCustomerId: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_PRIVATE_STRIPE_CUSTOMER_ID, + callback: (value) => { + if (!value) { + return; + } + + stripeCustomerId = value; + }, +}); + +let billingDisputePending: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_PRIVATE_BILLING_DISPUTE_PENDING, + callback: (value) => (billingDisputePending = value), +}); + +let billingStatus: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_PRIVATE_BILLING_STATUS, + callback: (value) => (billingStatus = value), +}); + +let ownerBillingGraceEndPeriod: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END, + callback: (value) => (ownerBillingGraceEndPeriod = value), +}); + +let fundList: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.FUND_LIST, + callback: (value) => { + if (!value) { + return; + } + + fundList = value; + }, +}); + +let retryBillingSuccessful: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + callback: (value) => { + if (value === undefined) { + return; + } + + retryBillingSuccessful = value; + }, +}); + +let retryBillingFailed: OnyxEntry; +Onyx.connect({ + key: ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, + callback: (value) => { + if (value === undefined) { + return; + } + + retryBillingFailed = value; + }, + initWithStoredValues: false, +}); let firstDayFreeTrial: OnyxEntry; Onyx.connect({ @@ -30,18 +119,6 @@ Onyx.connect({ waitForCollectionCallback: true, }); -let ownerBillingGraceEndPeriod: OnyxEntry; -Onyx.connect({ - key: ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END, - callback: (value) => (ownerBillingGraceEndPeriod = value), -}); - -let amountOwed: OnyxEntry; -Onyx.connect({ - key: ONYXKEYS.NVP_PRIVATE_AMOUNT_OWNED, - callback: (value) => (amountOwed = value), -}); - let allPolicies: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, @@ -49,6 +126,224 @@ Onyx.connect({ waitForCollectionCallback: true, }); +/** + * @returns The date when the grace period ends. + */ +function getOverdueGracePeriodDate(): OnyxEntry { + return ownerBillingGraceEndPeriod; +} + +/** + * @returns Whether the workspace owner has an overdue grace period. + */ +function hasOverdueGracePeriod(): boolean { + return !!ownerBillingGraceEndPeriod ?? false; +} + +/** + * @returns Whether the workspace owner's grace period is overdue. + */ +function hasGracePeriodOverdue(): boolean { + return !!ownerBillingGraceEndPeriod && Date.now() > new Date(ownerBillingGraceEndPeriod).getTime(); +} + +/** + * @returns The amount owed by the workspace owner. + */ +function getAmountOwed(): number { + return amountOwed ?? 0; +} + +/** + * @returns Whether there is an amount owed by the workspace owner. + */ +function hasAmountOwed(): boolean { + return !!amountOwed; +} + +/** + * @returns Whether there is a card authentication error. + */ +function hasCardAuthenticatedError() { + return stripeCustomerId?.status === 'authentication_required' && amountOwed === 0; +} + +/** + * @returns Whether there is a billing dispute pending. + */ +function hasBillingDisputePending() { + return !!billingDisputePending ?? false; +} + +/** + * @returns Whether there is a card expired error. + */ +function hasCardExpiredError() { + return billingStatus?.declineReason === 'expired_card' && amountOwed !== 0; +} + +/** + * @returns Whether there is an insufficient funds error. + */ +function hasInsufficientFundsError() { + return billingStatus?.declineReason === 'insufficient_funds' && amountOwed !== 0; +} + +/** + * @returns The card to be used for subscription billing. + */ +function getCardForSubscriptionBilling(): Fund | undefined { + return Object.values(fundList ?? {}).find((card) => card?.isDefault); +} + +/** + * @returns Whether the card is due to expire soon. + */ +function hasCardExpiringSoon(): boolean { + if (!isEmptyObject(billingStatus)) { + return false; + } + + const card = getCardForSubscriptionBilling(); + + if (!card) { + return false; + } + + const cardYear = card?.accountData?.cardYear; + const cardMonth = card?.accountData?.cardMonth; + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth(); + + const isExpiringThisMonth = cardYear === currentYear && cardMonth === currentMonth; + const isExpiringNextMonth = cardYear === (currentMonth === 12 ? currentYear + 1 : currentYear) && cardMonth === (currentMonth === 12 ? 1 : currentMonth + 1); + + return isExpiringThisMonth || isExpiringNextMonth; +} + +/** + * @returns Whether there is a retry billing error. + */ +function hasRetryBillingError(): boolean { + return !!retryBillingFailed ?? false; +} + +/** + * @returns Whether the retry billing was successful. + */ +function isRetryBillingSuccessful(): boolean { + return !!retryBillingSuccessful ?? false; +} + +type SubscriptionStatus = { + status: string; + isError?: boolean; +}; + +/** + * @returns The subscription status. + */ +function getSubscriptionStatus(): SubscriptionStatus | undefined { + if (hasOverdueGracePeriod()) { + if (hasAmountOwed()) { + // 1. Policy owner with amount owed, within grace period + if (!hasGracePeriodOverdue()) { + return { + status: PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED, + isError: true, + }; + } + + // 2. Policy owner with amount owed, overdue (past grace period) + if (hasGracePeriodOverdue()) { + return { + status: PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED_OVERDUE, + }; + } + } else { + // 3. Owner of policy under invoicing, within grace period + if (!hasGracePeriodOverdue()) { + return { + status: PAYMENT_STATUS.OWNER_OF_POLICY_UNDER_INVOICING, + }; + } + + // 4. Owner of policy under invoicing, overdue (past grace period) + if (hasGracePeriodOverdue()) { + return { + status: PAYMENT_STATUS.OWNER_OF_POLICY_UNDER_INVOICING_OVERDUE, + }; + } + } + } + // 5. Billing disputed by cardholder + if (hasBillingDisputePending()) { + return { + status: PAYMENT_STATUS.BILLING_DISPUTE_PENDING, + }; + } + + // 6. Card not authenticated + if (hasCardAuthenticatedError()) { + return { + status: PAYMENT_STATUS.CARD_AUTHENTICATION_REQUIRED, + }; + } + + // 7. Insufficient funds + if (hasInsufficientFundsError()) { + return { + status: PAYMENT_STATUS.INSUFFICIENT_FUNDS, + }; + } + + // 8. Card expired + if (hasCardExpiredError()) { + return { + status: PAYMENT_STATUS.CARD_EXPIRED, + }; + } + + // 9. Card due to expire soon + if (hasCardExpiringSoon()) { + return { + status: PAYMENT_STATUS.CARD_EXPIRE_SOON, + }; + } + + // 10. Retry billing success + if (isRetryBillingSuccessful()) { + return { + status: PAYMENT_STATUS.RETRY_BILLING_SUCCESS, + isError: false, + }; + } + + // 11. Retry billing error + if (hasRetryBillingError()) { + return { + status: PAYMENT_STATUS.RETRY_BILLING_ERROR, + isError: true, + }; + } + + return undefined; +} + +/** + * @returns Whether there is a subscription red dot error. + */ +function hasSubscriptionRedDotError(): boolean { + return getSubscriptionStatus()?.isError ?? false; +} + +/** + * @returns Whether there is a subscription green dot info. + */ +function hasSubscriptionGreenDotInfo(): boolean { + return !getSubscriptionStatus()?.isError ?? false; +} + /** * Calculates the remaining number of days of the workspace owner's free trial before it ends. */ @@ -132,4 +427,18 @@ function shouldRestrictUserBillableActions(policyID: string): boolean { return false; } -export {calculateRemainingFreeTrialDays, doesUserHavePaymentCardAdded, hasUserFreeTrialEnded, isUserOnFreeTrial, shouldRestrictUserBillableActions}; +export { + calculateRemainingFreeTrialDays, + doesUserHavePaymentCardAdded, + hasUserFreeTrialEnded, + isUserOnFreeTrial, + shouldRestrictUserBillableActions, + getSubscriptionStatus, + hasSubscriptionRedDotError, + getAmountOwed, + getOverdueGracePeriodDate, + getCardForSubscriptionBilling, + hasSubscriptionGreenDotInfo, + hasRetryBillingError, + PAYMENT_STATUS, +}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 069ff5682552..5a252c991085 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -2187,8 +2187,6 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): string | u ]; successData.push(...employeeWorkspaceChat.onyxSuccessData); - successData.push(...employeeWorkspaceChat.onyxSuccessData); - const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx index 76e081c0c4c1..4587dfee2fe6 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx @@ -32,9 +32,12 @@ type BillingBannerProps = { /** Styles to apply to the subtitle. */ subtitleStyle?: StyleProp; + + /** An icon to be rendered instead of the RBR / GBR indicator. */ + rightIcon?: IconAsset; }; -function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleStyle, subtitleStyle}: BillingBannerProps) { +function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleStyle, subtitleStyle, rightIcon}: BillingBannerProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -50,12 +53,18 @@ function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleS {typeof title === 'string' ? {title} : title} {typeof subtitle === 'string' ? {subtitle} : subtitle} - - {!!brickRoadIndicator && ( + {rightIcon ? ( + ) : ( + !!brickRoadIndicator && ( + + ) )} ); @@ -64,3 +73,4 @@ function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleS BillingBanner.displayName = 'BillingBanner'; export default BillingBanner; +export type {BillingBannerProps}; diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx new file mode 100644 index 000000000000..dce215e7dbbc --- /dev/null +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/SubscriptionBillingBanner.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import * as Illustrations from '@components/Icon/Illustrations'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; +import BillingBanner from './BillingBanner'; +import type {BillingBannerProps} from './BillingBanner'; + +type SubscriptionBillingBannerProps = Omit & { + /** Indicates whether there is an error */ + isError?: boolean; + + /** An optional icon prop */ + icon?: IconAsset; +}; + +function SubscriptionBillingBanner({title, subtitle, rightIcon, icon, isError = false}: SubscriptionBillingBannerProps) { + const styles = useThemeStyles(); + + const iconAsset = icon ?? isError ? Illustrations.CreditCardEyes : Illustrations.CheckmarkCircle; + + return ( + + ); +} + +SubscriptionBillingBanner.displayName = 'SubscriptionBillingBanner'; + +export default SubscriptionBillingBanner; diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index b970bc338490..006baf3a4c0f 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -16,6 +16,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import PreTrialBillingBanner from './BillingBanner/PreTrialBillingBanner'; +import SubscriptionBillingBanner from './BillingBanner/SubscriptionBillingBanner'; import CardSectionActions from './CardSectionActions'; import CardSectionDataEmpty from './CardSectionDataEmpty'; import CardSectionUtils from './utils'; @@ -24,18 +25,34 @@ function CardSection() { const {translate, preferredLocale} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); - const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); + const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); const defaultCard = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard), [fundList]); const cardMonth = useMemo(() => DateUtils.getMonthNames(preferredLocale)[(defaultCard?.accountData?.cardMonth ?? 1) - 1], [defaultCard?.accountData?.cardMonth, preferredLocale]); + const billingStatus = CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData?.cardNumber ?? ''); + const nextPaymentDate = !isEmptyObject(privateSubscription) ? CardSectionUtils.getNextBillingDate() : undefined; const sectionSubtitle = defaultCard && !!nextPaymentDate ? translate('subscription.cardSection.cardNextPayment', {nextPaymentDate}) : translate('subscription.cardSection.subtitle'); - const BillingBanner = ; + + let BillingBanner: React.ReactNode | undefined; + if (CardSectionUtils.shouldShowPreTrialBillingBanner()) { + BillingBanner = ; + } else if (billingStatus) { + BillingBanner = ( + + ); + } return (
(phraseKey: TKey, ...phraseParameters: PhraseParameters>) => string, + cardEnding: string, +): BillingStatusResult | undefined { + const amountOwed = SubscriptionUtils.getAmountOwed(); + + const subscriptionStatus = SubscriptionUtils.getSubscriptionStatus(); + + const endDate = SubscriptionUtils.getOverdueGracePeriodDate(); + + const endDateFormatted = endDate ? DateUtils.formatWithUTCTimeZone(fromUnixTime(endDate).toUTCString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT) : null; + + switch (subscriptionStatus?.status) { + case SubscriptionUtils.PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED: + return { + title: translate('subscription.billingBanner.policyOwnerAmountOwed.title'), + subtitle: translate('subscription.billingBanner.policyOwnerAmountOwed.subtitle', {date: endDateFormatted}), + isError: true, + isRetryAvailable: true, + }; + + case SubscriptionUtils.PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED_OVERDUE: + return { + title: translate('subscription.billingBanner.policyOwnerAmountOwedOverdue.title'), + subtitle: translate('subscription.billingBanner.policyOwnerAmountOwedOverdue.subtitle'), + isError: true, + }; + + case SubscriptionUtils.PAYMENT_STATUS.OWNER_OF_POLICY_UNDER_INVOICING: + return { + title: translate('subscription.billingBanner.policyOwnerUnderInvoicing.title'), + subtitle: translate('subscription.billingBanner.policyOwnerUnderInvoicing.subtitle', {date: endDateFormatted}), + isError: true, + isAddButtonDark: true, + }; + + case SubscriptionUtils.PAYMENT_STATUS.OWNER_OF_POLICY_UNDER_INVOICING_OVERDUE: + return { + title: translate('subscription.billingBanner.policyOwnerUnderInvoicingOverdue.title'), + subtitle: translate('subscription.billingBanner.policyOwnerUnderInvoicingOverdue.subtitle'), + isError: true, + isAddButtonDark: true, + }; + + case SubscriptionUtils.PAYMENT_STATUS.BILLING_DISPUTE_PENDING: + return { + title: translate('subscription.billingBanner.billingDisputePending.title'), + subtitle: translate('subscription.billingBanner.billingDisputePending.subtitle', {amountOwed, cardEnding}), + isError: true, + isRetryAvailable: false, + }; + + case SubscriptionUtils.PAYMENT_STATUS.CARD_AUTHENTICATION_REQUIRED: + return { + title: translate('subscription.billingBanner.cardAuthenticationRequired.title'), + subtitle: translate('subscription.billingBanner.cardAuthenticationRequired.subtitle', {cardEnding}), + isError: true, + isAuthenticationRequired: true, + }; + + case SubscriptionUtils.PAYMENT_STATUS.INSUFFICIENT_FUNDS: + return { + title: translate('subscription.billingBanner.insufficientFunds.title'), + subtitle: translate('subscription.billingBanner.insufficientFunds.subtitle', {amountOwed}), + isError: true, + isRetryAvailable: true, + }; + + case SubscriptionUtils.PAYMENT_STATUS.CARD_EXPIRED: + return { + title: translate('subscription.billingBanner.cardExpired.title'), + subtitle: translate('subscription.billingBanner.cardExpired.subtitle', {amountOwed}), + isError: true, + isRetryAvailable: true, + }; + + case SubscriptionUtils.PAYMENT_STATUS.CARD_EXPIRE_SOON: + return { + title: translate('subscription.billingBanner.cardExpireSoon.title'), + subtitle: translate('subscription.billingBanner.cardExpireSoon.subtitle'), + isError: false, + icon: Illustrations.CreditCardEyes, + }; + + case SubscriptionUtils.PAYMENT_STATUS.RETRY_BILLING_SUCCESS: + return { + title: translate('subscription.billingBanner.retryBillingSuccess.title'), + subtitle: translate('subscription.billingBanner.retryBillingSuccess.subtitle'), + isError: false, + rightIcon: Expensicons.Close, + }; + + case SubscriptionUtils.PAYMENT_STATUS.RETRY_BILLING_ERROR: + return { + title: translate('subscription.billingBanner.retryBillingError.title'), + subtitle: translate('subscription.billingBanner.retryBillingError.subtitle'), + isError: true, + }; + + default: + return undefined; + } +} /** * Get the next billing date. @@ -20,6 +141,8 @@ function shouldShowPreTrialBillingBanner(): boolean { } export default { + getBillingStatus, shouldShowPreTrialBillingBanner, getNextBillingDate, }; +export type {BillingStatusResult}; diff --git a/src/types/onyx/BillingStatus.ts b/src/types/onyx/BillingStatus.ts new file mode 100644 index 000000000000..a28c5b5f770a --- /dev/null +++ b/src/types/onyx/BillingStatus.ts @@ -0,0 +1,16 @@ +/** Billing status model */ +type BillingStatus = { + /** Status action */ + action: string; + + /** Billing period month */ + periodMonth: string; + + /** Billing period year */ + periodYear: string; + + /** Decline reason */ + declineReason: 'insufficient_funds' | 'expired_card'; +}; + +export default BillingStatus; diff --git a/src/types/onyx/StripeCustomerID.ts b/src/types/onyx/StripeCustomerID.ts new file mode 100644 index 000000000000..292cb517e2a6 --- /dev/null +++ b/src/types/onyx/StripeCustomerID.ts @@ -0,0 +1,16 @@ +/** Model of Stripe customer */ +type StripeCustomerID = { + /** Payment method's ID */ + paymentMethodID: string; + + /** Intent's ID */ + intentsID: string; + + /** Payment currency */ + currency: string; + + /** Payment status */ + status: 'authentication_required' | 'intent_required' | 'succeeded'; +}; + +export default StripeCustomerID; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 82e497754d84..67cd6f372aad 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -4,6 +4,7 @@ import type {BankAccountList} from './BankAccount'; import type BankAccount from './BankAccount'; import type Beta from './Beta'; import type BillingGraceEndPeriod from './BillingGraceEndPeriod'; +import type BillingStatus from './BillingStatus'; import type BlockedFromConcierge from './BlockedFromConcierge'; import type Card from './Card'; import type {CardList, IssueNewCard} from './Card'; @@ -72,6 +73,7 @@ import type SearchResults from './SearchResults'; import type SecurityGroup from './SecurityGroup'; import type SelectedTabRequest from './SelectedTabRequest'; import type Session from './Session'; +import type StripeCustomerID from './StripeCustomerID'; import type Task from './Task'; import type Transaction from './Transaction'; import type {TransactionViolation, ViolationName} from './TransactionViolation'; @@ -195,4 +197,6 @@ export type { ReviewDuplicates, PrivateSubscription, BillingGraceEndPeriod, + StripeCustomerID, + BillingStatus, }; diff --git a/tests/unit/CardsSectionUtilsTest.ts b/tests/unit/CardsSectionUtilsTest.ts index 93d288943f4b..0291fb41daba 100644 --- a/tests/unit/CardsSectionUtilsTest.ts +++ b/tests/unit/CardsSectionUtilsTest.ts @@ -1,5 +1,30 @@ +import * as Expensicons from '@components/Icon/Expensicons'; +import * as Illustrations from '@components/Icon/Illustrations'; +import type {Phrase, PhraseParameters} from '@libs/Localize'; +import type * as SubscriptionUtils from '@libs/SubscriptionUtils'; +import {PAYMENT_STATUS} from '@libs/SubscriptionUtils'; +import type {TranslationPaths} from '@src/languages/types'; +import type {BillingStatusResult} from '@src/pages/settings/Subscription/CardSection/utils'; import CardSectionUtils from '@src/pages/settings/Subscription/CardSection/utils'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- this param is required for the mock +function translateMock(key: TKey, ...phraseParameters: PhraseParameters>): string { + return key; +} + +const CARD_ENDING = '1234'; +const AMOUNT_OWED = 100; +const GRACE_PERIOD_DATE = 1750819200; + +const mockGetSubscriptionStatus = jest.fn(); + +jest.mock('@libs/SubscriptionUtils', () => ({ + ...jest.requireActual('@libs/SubscriptionUtils'), + getAmountOwed: () => AMOUNT_OWED, + getOverdueGracePeriodDate: () => GRACE_PERIOD_DATE, + getSubscriptionStatus: () => mockGetSubscriptionStatus() as BillingStatusResult, +})); + describe('getNextBillingDate', () => { beforeAll(() => { jest.useFakeTimers(); @@ -35,3 +60,158 @@ describe('getNextBillingDate', () => { expect(CardSectionUtils.getNextBillingDate()).toEqual(expectedNextBillingDate); }); }); + +describe('CardSectionUtils', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + beforeAll(() => { + mockGetSubscriptionStatus.mockReturnValue(''); + }); + + it('should return undefined by default', () => { + expect(CardSectionUtils.getBillingStatus(translateMock, CARD_ENDING)).toBeUndefined(); + }); + + it('should return POLICY_OWNER_WITH_AMOUNT_OWED variant', () => { + mockGetSubscriptionStatus.mockReturnValue({ + status: PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED, + }); + + expect(CardSectionUtils.getBillingStatus(translateMock, CARD_ENDING)).toEqual({ + title: 'subscription.billingBanner.policyOwnerAmountOwed.title', + subtitle: 'subscription.billingBanner.policyOwnerAmountOwed.subtitle', + isError: true, + isRetryAvailable: true, + }); + }); + + it('should return POLICY_OWNER_WITH_AMOUNT_OWED_OVERDUE variant', () => { + mockGetSubscriptionStatus.mockReturnValue({ + status: PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED_OVERDUE, + }); + + expect(CardSectionUtils.getBillingStatus(translateMock, CARD_ENDING)).toEqual({ + title: 'subscription.billingBanner.policyOwnerAmountOwedOverdue.title', + subtitle: 'subscription.billingBanner.policyOwnerAmountOwedOverdue.subtitle', + isError: true, + }); + }); + + it('should return OWNER_OF_POLICY_UNDER_INVOICING variant', () => { + mockGetSubscriptionStatus.mockReturnValue({ + status: PAYMENT_STATUS.OWNER_OF_POLICY_UNDER_INVOICING, + }); + + expect(CardSectionUtils.getBillingStatus(translateMock, CARD_ENDING)).toEqual({ + title: 'subscription.billingBanner.policyOwnerUnderInvoicing.title', + subtitle: 'subscription.billingBanner.policyOwnerUnderInvoicing.subtitle', + isError: true, + isAddButtonDark: true, + }); + }); + + it('should return OWNER_OF_POLICY_UNDER_INVOICING_OVERDUE variant', () => { + mockGetSubscriptionStatus.mockReturnValue({ + status: PAYMENT_STATUS.OWNER_OF_POLICY_UNDER_INVOICING_OVERDUE, + }); + + expect(CardSectionUtils.getBillingStatus(translateMock, CARD_ENDING)).toEqual({ + title: 'subscription.billingBanner.policyOwnerUnderInvoicingOverdue.title', + subtitle: 'subscription.billingBanner.policyOwnerUnderInvoicingOverdue.subtitle', + isError: true, + isAddButtonDark: true, + }); + }); + + it('should return BILLING_DISPUTE_PENDING variant', () => { + mockGetSubscriptionStatus.mockReturnValue({ + status: PAYMENT_STATUS.BILLING_DISPUTE_PENDING, + }); + + expect(CardSectionUtils.getBillingStatus(translateMock, CARD_ENDING)).toEqual({ + title: 'subscription.billingBanner.billingDisputePending.title', + subtitle: 'subscription.billingBanner.billingDisputePending.subtitle', + isError: true, + isRetryAvailable: false, + }); + }); + + it('should return CARD_AUTHENTICATION_REQUIRED variant', () => { + mockGetSubscriptionStatus.mockReturnValue({ + status: PAYMENT_STATUS.CARD_AUTHENTICATION_REQUIRED, + }); + + expect(CardSectionUtils.getBillingStatus(translateMock, CARD_ENDING)).toEqual({ + title: 'subscription.billingBanner.cardAuthenticationRequired.title', + subtitle: 'subscription.billingBanner.cardAuthenticationRequired.subtitle', + isError: true, + isAuthenticationRequired: true, + }); + }); + + it('should return INSUFFICIENT_FUNDS variant', () => { + mockGetSubscriptionStatus.mockReturnValue({ + status: PAYMENT_STATUS.INSUFFICIENT_FUNDS, + }); + + expect(CardSectionUtils.getBillingStatus(translateMock, CARD_ENDING)).toEqual({ + title: 'subscription.billingBanner.insufficientFunds.title', + subtitle: 'subscription.billingBanner.insufficientFunds.subtitle', + isError: true, + isRetryAvailable: true, + }); + }); + + it('should return CARD_EXPIRED variant', () => { + mockGetSubscriptionStatus.mockReturnValue({ + status: PAYMENT_STATUS.CARD_EXPIRED, + }); + + expect(CardSectionUtils.getBillingStatus(translateMock, CARD_ENDING)).toEqual({ + title: 'subscription.billingBanner.cardExpired.title', + subtitle: 'subscription.billingBanner.cardExpired.subtitle', + isError: true, + isRetryAvailable: true, + }); + }); + + it('should return CARD_EXPIRE_SOON variant', () => { + mockGetSubscriptionStatus.mockReturnValue({ + status: PAYMENT_STATUS.CARD_EXPIRE_SOON, + }); + + expect(CardSectionUtils.getBillingStatus(translateMock, CARD_ENDING)).toEqual({ + title: 'subscription.billingBanner.cardExpireSoon.title', + subtitle: 'subscription.billingBanner.cardExpireSoon.subtitle', + isError: false, + icon: Illustrations.CreditCardEyes, + }); + }); + + it('should return RETRY_BILLING_SUCCESS variant', () => { + mockGetSubscriptionStatus.mockReturnValue({ + status: PAYMENT_STATUS.RETRY_BILLING_SUCCESS, + }); + + expect(CardSectionUtils.getBillingStatus(translateMock, CARD_ENDING)).toEqual({ + title: 'subscription.billingBanner.retryBillingSuccess.title', + subtitle: 'subscription.billingBanner.retryBillingSuccess.subtitle', + isError: false, + rightIcon: Expensicons.Close, + }); + }); + + it('should return RETRY_BILLING_ERROR variant', () => { + mockGetSubscriptionStatus.mockReturnValue({ + status: PAYMENT_STATUS.RETRY_BILLING_ERROR, + }); + + expect(CardSectionUtils.getBillingStatus(translateMock, CARD_ENDING)).toEqual({ + title: 'subscription.billingBanner.retryBillingError.title', + subtitle: 'subscription.billingBanner.retryBillingError.subtitle', + isError: true, + }); + }); +}); diff --git a/tests/unit/SubscriptionUtilsTest.ts b/tests/unit/SubscriptionUtilsTest.ts index 7767ae9f387b..90781840a718 100644 --- a/tests/unit/SubscriptionUtilsTest.ts +++ b/tests/unit/SubscriptionUtilsTest.ts @@ -1,9 +1,10 @@ import {addDays, addMinutes, format as formatDate, getUnixTime, subDays} from 'date-fns'; import Onyx from 'react-native-onyx'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; +import {PAYMENT_STATUS} from '@libs/SubscriptionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {BillingGraceEndPeriod} from '@src/types/onyx'; +import type {BillingGraceEndPeriod, BillingStatus, FundList, StripeCustomerID} from '@src/types/onyx'; import createRandomPolicy from '../utils/collections/policies'; const billingGraceEndPeriod: BillingGraceEndPeriod = { @@ -12,6 +13,36 @@ const billingGraceEndPeriod: BillingGraceEndPeriod = { value: 0, }; +const GRACE_PERIOD_DATE = new Date().getTime() + 1000; +const GRACE_PERIOD_DATE_OVERDUE = new Date().getTime() - 1000; + +const AMOUNT_OWED = 100; +const STRIPE_CUSTOMER_ID: StripeCustomerID = { + paymentMethodID: '1', + intentsID: '2', + currency: 'USD', + status: 'authentication_required', +}; +const BILLING_STATUS_INSUFFICIENT_FUNDS: BillingStatus = { + action: 'action', + periodMonth: 'periodMonth', + periodYear: 'periodYear', + declineReason: 'insufficient_funds', +}; +const BILLING_STATUS_EXPIRED_CARD: BillingStatus = { + ...BILLING_STATUS_INSUFFICIENT_FUNDS, + declineReason: 'expired_card', +}; +const FUND_LIST: FundList = { + defaultCard: { + isDefault: true, + accountData: { + cardYear: new Date().getFullYear(), + cardMonth: new Date().getMonth() + 1, + }, + }, +}; + Onyx.init({keys: ONYXKEYS}); describe('SubscriptionUtils', () => { @@ -150,7 +181,7 @@ describe('SubscriptionUtils', () => { await Onyx.multiSet({ [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END]: null, [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: null, - [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWNED]: null, + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: null, [ONYXKEYS.COLLECTION.POLICY]: null, }); }); @@ -228,7 +259,7 @@ describe('SubscriptionUtils', () => { await Onyx.multiSet({ [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: getUnixTime(subDays(new Date(), 3)), // past due - [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWNED]: 0, + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: 0, }); expect(SubscriptionUtils.shouldRestrictUserBillableActions(policyID)).toBeFalsy(); @@ -239,10 +270,139 @@ describe('SubscriptionUtils', () => { await Onyx.multiSet({ [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: getUnixTime(subDays(new Date(), 3)), // past due - [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWNED]: 8010, + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: 8010, }); expect(SubscriptionUtils.shouldRestrictUserBillableActions(policyID)).toBeTruthy(); }); }); + + describe('getSubscriptionStatus', () => { + it('should return undefined by default', () => { + expect(SubscriptionUtils.getSubscriptionStatus()).toBeUndefined(); + }); + + it('should return POLICY_OWNER_WITH_AMOUNT_OWED status', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: GRACE_PERIOD_DATE, + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: AMOUNT_OWED, + }); + + expect(SubscriptionUtils.getSubscriptionStatus()).toEqual({ + status: PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED, + isError: true, + }); + }); + + it('should return POLICY_OWNER_WITH_AMOUNT_OWED_OVERDUE status', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: GRACE_PERIOD_DATE_OVERDUE, + }); + + expect(SubscriptionUtils.getSubscriptionStatus()).toEqual({ + status: PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED_OVERDUE, + }); + }); + + it('should return OWNER_OF_POLICY_UNDER_INVOICING_OVERDUE status', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: 0, + }); + + expect(SubscriptionUtils.getSubscriptionStatus()).toEqual({ + status: PAYMENT_STATUS.OWNER_OF_POLICY_UNDER_INVOICING_OVERDUE, + }); + }); + + it('should return OWNER_OF_POLICY_UNDER_INVOICING status', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: GRACE_PERIOD_DATE, + }); + + expect(SubscriptionUtils.getSubscriptionStatus()).toEqual({ + status: PAYMENT_STATUS.OWNER_OF_POLICY_UNDER_INVOICING, + }); + }); + + it('should return BILLING_DISPUTE_PENDING status', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: 0, + [ONYXKEYS.NVP_PRIVATE_BILLING_DISPUTE_PENDING]: 1, + }); + + expect(SubscriptionUtils.getSubscriptionStatus()).toEqual({ + status: PAYMENT_STATUS.BILLING_DISPUTE_PENDING, + }); + }); + + it('should return CARD_AUTHENTICATION_REQUIRED status', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIVATE_BILLING_DISPUTE_PENDING]: 0, + [ONYXKEYS.NVP_PRIVATE_STRIPE_CUSTOMER_ID]: STRIPE_CUSTOMER_ID, + }); + + expect(SubscriptionUtils.getSubscriptionStatus()).toEqual({ + status: PAYMENT_STATUS.CARD_AUTHENTICATION_REQUIRED, + }); + }); + + it('should return INSUFFICIENT_FUNDS status', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: AMOUNT_OWED, + [ONYXKEYS.NVP_PRIVATE_STRIPE_CUSTOMER_ID]: {}, + [ONYXKEYS.NVP_PRIVATE_BILLING_STATUS]: BILLING_STATUS_INSUFFICIENT_FUNDS, + }); + + expect(SubscriptionUtils.getSubscriptionStatus()).toEqual({ + status: PAYMENT_STATUS.INSUFFICIENT_FUNDS, + }); + }); + + it('should return CARD_EXPIRED status', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIVATE_BILLING_STATUS]: BILLING_STATUS_EXPIRED_CARD, + }); + + expect(SubscriptionUtils.getSubscriptionStatus()).toEqual({ + status: PAYMENT_STATUS.CARD_EXPIRED, + }); + }); + + it('should return CARD_EXPIRE_SOON status', async () => { + await Onyx.multiSet({ + [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: 0, + [ONYXKEYS.NVP_PRIVATE_BILLING_STATUS]: {}, + [ONYXKEYS.FUND_LIST]: FUND_LIST, + }); + + expect(SubscriptionUtils.getSubscriptionStatus()).toEqual({ + status: PAYMENT_STATUS.CARD_EXPIRE_SOON, + }); + }); + + it('should return RETRY_BILLING_SUCCESS status', async () => { + await Onyx.multiSet({ + [ONYXKEYS.FUND_LIST]: {}, + [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL]: true, + }); + + expect(SubscriptionUtils.getSubscriptionStatus()).toEqual({ + status: PAYMENT_STATUS.RETRY_BILLING_SUCCESS, + isError: false, + }); + }); + + it('should return RETRY_BILLING_ERROR status', async () => { + await Onyx.multiSet({ + [ONYXKEYS.FUND_LIST]: {}, + [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL]: false, + [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED]: true, + }); + + expect(SubscriptionUtils.getSubscriptionStatus()).toEqual({ + status: PAYMENT_STATUS.RETRY_BILLING_ERROR, + isError: true, + }); + }); + }); });