diff --git a/src/CONST.ts b/src/CONST.ts index eb285d3a1bc2..7f74d081957e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -6924,6 +6924,10 @@ const CONST = { ILLUSTRATION_ASPECT_RATIO: 39 / 22, OFFLINE_INDICATOR_HEIGHT: 25, + + BILLING: { + TYPE_FAILED_2018: 'typeFailed2018', + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f4c984a99b85..2a618ee0f0c5 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -277,6 +277,9 @@ const ONYXKEYS = { /** Stores information about the user's saved statements */ WALLET_STATEMENT: 'walletStatement', + /** Stores information about the user's purchases */ + PURCHASE_LIST: 'purchaseList', + /** Stores information about the active personal bank account being set up */ PERSONAL_BANK_ACCOUNT: 'personalBankAccount', @@ -1017,6 +1020,7 @@ type OnyxValuesMapping = { [ONYXKEYS.FUND_LIST]: OnyxTypes.FundList; [ONYXKEYS.CARD_LIST]: OnyxTypes.CardList; [ONYXKEYS.WALLET_STATEMENT]: OnyxTypes.WalletStatement; + [ONYXKEYS.PURCHASE_LIST]: OnyxTypes.PurchaseList; [ONYXKEYS.PERSONAL_BANK_ACCOUNT]: OnyxTypes.PersonalBankAccount; [ONYXKEYS.REIMBURSEMENT_ACCOUNT]: OnyxTypes.ReimbursementAccount; [ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE]: string | number; diff --git a/src/languages/en.ts b/src/languages/en.ts index abc6e1cde737..03f44b97c241 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -27,6 +27,7 @@ import type { BillingBannerCardOnDisputeParams, BillingBannerDisputePendingParams, BillingBannerInsufficientFundsParams, + BillingBannerOwnerAmountOwedOverdueParams, BillingBannerSubtitleWithDateParams, CanceledRequestParams, CardEndingParams, @@ -5605,8 +5606,11 @@ const translations = { subtitle: ({date}: BillingBannerSubtitleWithDateParams) => `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.', + title: 'Your payment could not be processed', + subtitle: ({date, purchaseAmountOwed}: BillingBannerOwnerAmountOwedOverdueParams) => + date && purchaseAmountOwed + ? `Your ${date} charge of ${purchaseAmountOwed} could not be processed. Please add a payment card to clear the amount owed.` + : 'Please add a payment card to clear the amount owed.', }, policyOwnerUnderInvoicing: { title: 'Your payment info is outdated', diff --git a/src/languages/es.ts b/src/languages/es.ts index 131ede19005d..d7aa6ab92c49 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -26,6 +26,7 @@ import type { BillingBannerCardOnDisputeParams, BillingBannerDisputePendingParams, BillingBannerInsufficientFundsParams, + BillingBannerOwnerAmountOwedOverdueParams, BillingBannerSubtitleWithDateParams, CanceledRequestParams, CardEndingParams, @@ -6121,8 +6122,11 @@ const translations = { subtitle: ({date}: BillingBannerSubtitleWithDateParams) => `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.', + title: 'No se pudo procesar tu pago', + subtitle: ({date, purchaseAmountOwed}: BillingBannerOwnerAmountOwedOverdueParams) => + date && purchaseAmountOwed + ? `No se ha podido procesar tu cargo de ${purchaseAmountOwed} del día ${date}. Por favor, añade una tarjeta de pago para saldar la cantidad adeudada.` + : 'Por favor, añade una tarjeta de pago para saldar la cantidad adeudada.', }, policyOwnerUnderInvoicing: { title: 'Tu información de pago está desactualizada', diff --git a/src/languages/params.ts b/src/languages/params.ts index b452aae2e998..27df27dafe3c 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -448,6 +448,8 @@ type BadgeFreeTrialParams = {numOfDays: number}; type BillingBannerSubtitleWithDateParams = {date: string}; +type BillingBannerOwnerAmountOwedOverdueParams = {date?: string; purchaseAmountOwed?: string}; + type BillingBannerDisputePendingParams = {amountOwed: number; cardEnding: string}; type BillingBannerCardAuthenticationRequiredParams = {cardEnding: string}; @@ -706,6 +708,7 @@ export type { SubscriptionSettingsRenewsOnParams, BadgeFreeTrialParams, BillingBannerSubtitleWithDateParams, + BillingBannerOwnerAmountOwedOverdueParams, BillingBannerDisputePendingParams, BillingBannerCardAuthenticationRequiredParams, BillingBannerInsufficientFundsParams, diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index 74e7301bfa29..ca8028ff42ed 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -47,6 +47,7 @@ function CardSection() { const [authenticationLink] = useOnyx(ONYXKEYS.VERIFY_3DS_SUBSCRIPTION); const [session] = useOnyx(ONYXKEYS.SESSION); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); + const [purchaseList] = useOnyx(ONYXKEYS.PURCHASE_LIST); const subscriptionPlan = useSubscriptionPlan(); const [subscriptionRetryBillingStatusPending] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING); const [subscriptionRetryBillingStatusSuccessful] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL); @@ -66,15 +67,31 @@ function CardSection() { Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query})); }, []); - const [billingStatus, setBillingStatus] = useState(() => CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {})); + const [billingStatus, setBillingStatus] = useState(() => + CardSectionUtils.getBillingStatus({translate, accountData: defaultCard?.accountData ?? {}, purchase: purchaseList?.[0]}), + ); const nextPaymentDate = !isEmptyObject(privateSubscription) ? CardSectionUtils.getNextBillingDate() : undefined; const sectionSubtitle = defaultCard && !!nextPaymentDate ? translate('subscription.cardSection.cardNextPayment', {nextPaymentDate}) : translate('subscription.cardSection.subtitle'); useEffect(() => { - setBillingStatus(CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {})); - }, [subscriptionRetryBillingStatusPending, subscriptionRetryBillingStatusSuccessful, subscriptionRetryBillingStatusFailed, translate, defaultCard?.accountData, privateStripeCustomerID]); + setBillingStatus( + CardSectionUtils.getBillingStatus({ + translate, + accountData: defaultCard?.accountData ?? {}, + purchase: purchaseList?.[0], + }), + ); + }, [ + subscriptionRetryBillingStatusPending, + subscriptionRetryBillingStatusSuccessful, + subscriptionRetryBillingStatusFailed, + translate, + defaultCard?.accountData, + privateStripeCustomerID, + purchaseList, + ]); const handleRetryPayment = () => { clearOutstandingBalance(); diff --git a/src/pages/settings/Subscription/CardSection/utils.ts b/src/pages/settings/Subscription/CardSection/utils.ts index 3624241a7140..af7f7b635b8d 100644 --- a/src/pages/settings/Subscription/CardSection/utils.ts +++ b/src/pages/settings/Subscription/CardSection/utils.ts @@ -2,10 +2,12 @@ import {addMonths, format, fromUnixTime, startOfMonth} from 'date-fns'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {getAmountOwed, getOverdueGracePeriodDate, getSubscriptionStatus, PAYMENT_STATUS} from '@libs/SubscriptionUtils'; import CONST from '@src/CONST'; import type {AccountData} from '@src/types/onyx/Fund'; +import type {Purchase} from '@src/types/onyx/PurchaseList'; import type IconAsset from '@src/types/utils/IconAsset'; type BillingStatusResult = { @@ -19,7 +21,13 @@ type BillingStatusResult = { rightIcon?: IconAsset; }; -function getBillingStatus(translate: LocaleContextProps['translate'], accountData?: AccountData): BillingStatusResult | undefined { +type GetBillingStatusProps = { + translate: LocaleContextProps['translate']; + accountData?: AccountData; + purchase?: Purchase; +}; + +function getBillingStatus({translate, accountData, purchase}: GetBillingStatusProps): BillingStatusResult | undefined { const cardEnding = (accountData?.cardNumber ?? '')?.slice(-4); const amountOwed = getAmountOwed(); @@ -32,6 +40,13 @@ function getBillingStatus(translate: LocaleContextProps['translate'], accountDat const isCurrentCardExpired = DateUtils.isCardExpired(accountData?.cardMonth ?? 0, accountData?.cardYear ?? 0); + const purchaseAmount = purchase?.message.billableAmount; + const purchaseCurrency = purchase?.currency; + const purchaseDate = purchase?.created; + const isBillingFailed = purchase?.message.billingType === CONST.BILLING.TYPE_FAILED_2018; + const purchaseDateFormatted = purchaseDate ? DateUtils.formatWithUTCTimeZone(purchaseDate, CONST.DATE.MONTH_DAY_YEAR_FORMAT) : undefined; + const purchaseAmountWithCurrency = convertAmountToDisplayString(purchaseAmount, purchaseCurrency); + switch (subscriptionStatus?.status) { case PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED: return { @@ -44,7 +59,15 @@ function getBillingStatus(translate: LocaleContextProps['translate'], accountDat case PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED_OVERDUE: return { title: translate('subscription.billingBanner.policyOwnerAmountOwedOverdue.title'), - subtitle: translate('subscription.billingBanner.policyOwnerAmountOwedOverdue.subtitle'), + subtitle: translate( + 'subscription.billingBanner.policyOwnerAmountOwedOverdue.subtitle', + isBillingFailed + ? { + date: purchaseDateFormatted, + purchaseAmountOwed: purchaseAmountWithCurrency, + } + : {}, + ), isError: true, isRetryAvailable: true, }; diff --git a/src/types/onyx/PurchaseList.ts b/src/types/onyx/PurchaseList.ts new file mode 100644 index 000000000000..ec5b2b1fde43 --- /dev/null +++ b/src/types/onyx/PurchaseList.ts @@ -0,0 +1,171 @@ +import type CONST from '@src/CONST'; +import type PrivateSubscription from './PrivateSubscription'; + +/** Subscription type for a purchase */ +type Subscription = Omit; + +/** Type for a billable policy */ +type BillablePolicy = { + /** Comma separated list of emails for members in the policy */ + actorList?: string; + + /** Amount spent, by currency */ + approvedSpend?: Record; + + /** Whether the policy is corporate */ + corporate?: boolean; + + /** Expensify card spend by currency */ + expensifyCardSpend?: Record; + + /** Policy type */ + type?: typeof CONST.POLICY.TYPE; +}; + +/** Message type for a purchase */ +type Message = { + /** Account manager account ID */ + accountManagerAccountID?: number; + + /** List of Approved Accountant account IDs */ + approvedAccountantAccountIDs?: number[]; + + /** Approved spend amounts by currency */ + approvedSpend?: Record; + + /** Billable amount */ + billableAmount?: number; + + /** Billable amount before free trial discount */ + billableAmountBeforeFreeTrialDiscount?: number; + + /** Record of billable policies with their details */ + billablePolicies?: Record; + + /** Billing type */ + billingType?: string; + + /** Card spend surcharge percentage */ + cardSpendSurchargePercent?: number; + + /** Cash back amount */ + cashBackAmount?: number; + + /** Cash back percentage */ + cashBackPercentage?: number; + + /** Chat only actor list */ + chatOnlyActorList?: string; + + /** Actor count for Corporate policy type */ + corporateActorCount?: number; + + /** Amount charged for Corporate policy type */ + corporateRevenue?: number; + + /** Expensify Card monthly spend */ + expensifyCardMonthlySpend?: number; + + /** Expensify Card spend by currency */ + expensifyCardSpend?: Record; + + /** Free trial days */ + freeTrialDays?: number; + + /** Free trial discount amount */ + freeTrialDiscountAmount?: number; + + /** Free trial discount percentage */ + freeTrialDiscountPercentage?: number; + + /** Freebie credits used */ + freebieCreditsUsed?: number; + + /** Guide account ID */ + guideAccountID?: number; + + /** Whether the user is an Approved Accountant */ + isApprovedAccountant?: boolean; + + /** Whether the user is an Approved Accountant client */ + isApprovedAccountantClient?: boolean; + + /** Paid actor count */ + paidActorCount?: number; + + /** Partner manager account ID */ + partnerManagerAccountID?: number; + + /** Per policy total members count */ + perPolicyTotalMembersCount?: Record; + + /** Potential cash back amount */ + potentialCashBackAmount?: number; + + /** Potential cash back percentage */ + potentialCashBackPercentage?: number; + + /** Subscription details */ + subscription?: Subscription; + + /** Actor count for Team policy type */ + teamActorCount?: number; + + /** Amount charged for Team policy type */ + teamRevenue?: number; + + /** Total actor count */ + totalActorCount?: number; + + /** Total freebie credits */ + totalFreebieCredits?: number; + + /** Total platform spend */ + totalPlatformSpend?: number; + + /** Total revenue */ + totalRevenue?: number; + + /** Total unique members count */ + totalUniqueMembersCount?: number; + + /** Whether domain billing was used */ + wasDomainBillingUsed?: boolean; + + /** Yearly overage surcharge */ + yearlyOverageSurcharge?: number; + + /** Yearly subscription overage cost */ + yearlySubscriptionOverageCost?: number; + + /** Yearly subscription surcharge */ + yearlySubscriptionSurcharge?: number; + + /** Yearly subscription user count cost */ + yearlySubscriptionUserCountCost?: number; +}; + +/** Purchase type */ +type Purchase = { + /** Amount of the purchase */ + amount: number; + + /** Creation date of the purchase */ + created: string; + + /** Currency of the purchase */ + currency: string; + + /** Message containing purchase details */ + message: Message; + + /** ID of the purchase */ + purchaseID: number; +}; + +/** Array of purchases */ +type PurchaseList = Purchase[]; + +export default PurchaseList; + +export type {Purchase}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index b9b3d25ddb8a..e487ee316aba 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -67,6 +67,7 @@ import type PreferredTheme from './PreferredTheme'; import type PriorityMode from './PriorityMode'; import type PrivatePersonalDetails from './PrivatePersonalDetails'; import type PrivateSubscription from './PrivateSubscription'; +import type PurchaseList from './PurchaseList'; import type QuickAction from './QuickAction'; import type RecentlyUsedCategories from './RecentlyUsedCategories'; import type RecentlyUsedReportFields from './RecentlyUsedReportFields'; @@ -220,6 +221,7 @@ export type { WalletStatement, WalletTerms, WalletTransfer, + PurchaseList, ReportUserIsTyping, PolicyReportField, RecentlyUsedReportFields, diff --git a/tests/unit/CardsSectionUtilsTest.ts b/tests/unit/CardsSectionUtilsTest.ts index 5293d385ddbb..6a48e9b72a7f 100644 --- a/tests/unit/CardsSectionUtilsTest.ts +++ b/tests/unit/CardsSectionUtilsTest.ts @@ -6,6 +6,7 @@ import {PAYMENT_STATUS} from '@libs/SubscriptionUtils'; import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; import type {BillingStatusResult} from '@src/pages/settings/Subscription/CardSection/utils'; import CardSectionUtils from '@src/pages/settings/Subscription/CardSection/utils'; +import type {Purchase} from '@src/types/onyx/PurchaseList'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -- this param is required for the mock function translateMock(path: TPath, ...phraseParameters: TranslationParameters): string { @@ -84,7 +85,7 @@ describe('CardSectionUtils', () => { }); it('should return undefined by default', () => { - expect(CardSectionUtils.getBillingStatus(translateMock, ACCOUNT_DATA)).toBeUndefined(); + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: ACCOUNT_DATA})).toBeUndefined(); }); it('should return POLICY_OWNER_WITH_AMOUNT_OWED variant', () => { @@ -92,7 +93,7 @@ describe('CardSectionUtils', () => { status: PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED, }); - expect(CardSectionUtils.getBillingStatus(translateMock, ACCOUNT_DATA)).toEqual({ + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: ACCOUNT_DATA})).toEqual({ title: 'subscription.billingBanner.policyOwnerAmountOwed.title', subtitle: 'subscription.billingBanner.policyOwnerAmountOwed.subtitle', isError: true, @@ -105,7 +106,18 @@ describe('CardSectionUtils', () => { status: PAYMENT_STATUS.POLICY_OWNER_WITH_AMOUNT_OWED_OVERDUE, }); - expect(CardSectionUtils.getBillingStatus(translateMock, ACCOUNT_DATA)).toEqual({ + const mockPurchase = { + message: { + billingType: 'failed_2018', + billableAmount: 1000, + }, + currency: 'USD', + created: '2023-01-01', + amount: 1000, + purchaseID: 12345, + } as Purchase; + + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: ACCOUNT_DATA, purchase: mockPurchase})).toEqual({ title: 'subscription.billingBanner.policyOwnerAmountOwedOverdue.title', subtitle: 'subscription.billingBanner.policyOwnerAmountOwedOverdue.subtitle', isError: true, @@ -118,7 +130,7 @@ describe('CardSectionUtils', () => { status: PAYMENT_STATUS.OWNER_OF_POLICY_UNDER_INVOICING, }); - expect(CardSectionUtils.getBillingStatus(translateMock, ACCOUNT_DATA)).toEqual({ + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: ACCOUNT_DATA})).toEqual({ title: 'subscription.billingBanner.policyOwnerUnderInvoicing.title', subtitle: 'subscription.billingBanner.policyOwnerUnderInvoicing.subtitle', isError: true, @@ -131,7 +143,7 @@ describe('CardSectionUtils', () => { status: PAYMENT_STATUS.OWNER_OF_POLICY_UNDER_INVOICING_OVERDUE, }); - expect(CardSectionUtils.getBillingStatus(translateMock, ACCOUNT_DATA)).toEqual({ + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: ACCOUNT_DATA})).toEqual({ title: 'subscription.billingBanner.policyOwnerUnderInvoicingOverdue.title', subtitle: 'subscription.billingBanner.policyOwnerUnderInvoicingOverdue.subtitle', isError: true, @@ -144,7 +156,7 @@ describe('CardSectionUtils', () => { status: PAYMENT_STATUS.BILLING_DISPUTE_PENDING, }); - expect(CardSectionUtils.getBillingStatus(translateMock, ACCOUNT_DATA)).toEqual({ + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: ACCOUNT_DATA})).toEqual({ title: 'subscription.billingBanner.billingDisputePending.title', subtitle: 'subscription.billingBanner.billingDisputePending.subtitle', isError: true, @@ -157,7 +169,7 @@ describe('CardSectionUtils', () => { status: PAYMENT_STATUS.CARD_AUTHENTICATION_REQUIRED, }); - expect(CardSectionUtils.getBillingStatus(translateMock, ACCOUNT_DATA)).toEqual({ + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: ACCOUNT_DATA})).toEqual({ title: 'subscription.billingBanner.cardAuthenticationRequired.title', subtitle: 'subscription.billingBanner.cardAuthenticationRequired.subtitle', isError: true, @@ -170,7 +182,7 @@ describe('CardSectionUtils', () => { status: PAYMENT_STATUS.INSUFFICIENT_FUNDS, }); - expect(CardSectionUtils.getBillingStatus(translateMock, ACCOUNT_DATA)).toEqual({ + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: ACCOUNT_DATA})).toEqual({ title: 'subscription.billingBanner.insufficientFunds.title', subtitle: 'subscription.billingBanner.insufficientFunds.subtitle', isError: true, @@ -183,14 +195,14 @@ describe('CardSectionUtils', () => { status: PAYMENT_STATUS.CARD_EXPIRED, }); - expect(CardSectionUtils.getBillingStatus(translateMock, {...ACCOUNT_DATA, cardYear: 2023})).toEqual({ + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: {...ACCOUNT_DATA, cardYear: 2023}})).toEqual({ title: 'subscription.billingBanner.cardExpired.title', subtitle: 'subscription.billingBanner.cardExpired.subtitle', isError: true, isRetryAvailable: false, }); - expect(CardSectionUtils.getBillingStatus(translateMock, ACCOUNT_DATA)).toEqual({ + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: ACCOUNT_DATA})).toEqual({ title: 'subscription.billingBanner.cardExpired.title', subtitle: 'subscription.billingBanner.cardExpired.subtitle', isError: true, @@ -203,7 +215,7 @@ describe('CardSectionUtils', () => { status: PAYMENT_STATUS.CARD_EXPIRE_SOON, }); - expect(CardSectionUtils.getBillingStatus(translateMock, ACCOUNT_DATA)).toEqual({ + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: ACCOUNT_DATA})).toEqual({ title: 'subscription.billingBanner.cardExpireSoon.title', subtitle: 'subscription.billingBanner.cardExpireSoon.subtitle', isError: false, @@ -216,7 +228,7 @@ describe('CardSectionUtils', () => { status: PAYMENT_STATUS.RETRY_BILLING_SUCCESS, }); - expect(CardSectionUtils.getBillingStatus(translateMock, ACCOUNT_DATA)).toEqual({ + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: ACCOUNT_DATA})).toEqual({ title: 'subscription.billingBanner.retryBillingSuccess.title', subtitle: 'subscription.billingBanner.retryBillingSuccess.subtitle', isError: false, @@ -229,7 +241,7 @@ describe('CardSectionUtils', () => { status: PAYMENT_STATUS.RETRY_BILLING_ERROR, }); - expect(CardSectionUtils.getBillingStatus(translateMock, ACCOUNT_DATA)).toEqual({ + expect(CardSectionUtils.getBillingStatus({translate: translateMock, accountData: ACCOUNT_DATA})).toEqual({ title: 'subscription.billingBanner.retryBillingError.title', subtitle: 'subscription.billingBanner.retryBillingError.subtitle', isError: true,