Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1bc977b
Update policyOwnerAmountOwedOverdue banner text
pasyukevich Mar 19, 2025
196cf70
Add PurchaseList type and related keys to ONYXKEYS
pasyukevich Mar 19, 2025
8252fec
Update payment processing messages in English and Spanish translations
pasyukevich Mar 21, 2025
dbaebec
Update billing banner subtitle parameter from amountOwed to purchaseA…
pasyukevich Mar 21, 2025
6511817
Add purchaseList to CardSection and update billing status logic
pasyukevich Mar 21, 2025
730424f
Merge branch 'main' into task/policyOwnerAmountOwedOverdue
pasyukevich Mar 24, 2025
243fe04
Refactor getBillingStatus tests to use a single object parameter
pasyukevich Mar 24, 2025
2005b3b
Add mockPurchase object to getBillingStatus test for improved coverage
pasyukevich Mar 24, 2025
1675fdb
Refactor currency utility imports in Subscription CardSection
pasyukevich Mar 24, 2025
4d04b10
Fix prettier
pasyukevich Mar 24, 2025
406ecf1
Merge branch 'main' into task/policyOwnerAmountOwedOverdue
pasyukevich Mar 25, 2025
5f498fc
Merge branch 'main' into task/policyOwnerAmountOwedOverdue
pasyukevich Mar 26, 2025
658dca8
Merge branch 'main' into task/policyOwnerAmountOwedOverdue
pasyukevich Mar 26, 2025
09c2601
Merge branch 'main' into task/policyOwnerAmountOwedOverdue
pasyukevich Mar 28, 2025
6544c0e
Update billing status handling and refactor currency formatting in Ca…
pasyukevich Mar 28, 2025
669c151
Refactor getBillingStatus to inline purchase details extraction and r…
pasyukevich Mar 28, 2025
c7071a9
Update billing banner subtitles to handle optional date and amount pa…
pasyukevich Mar 28, 2025
e65ad3f
Update Spanish translations for billing banner to conditionally inclu…
pasyukevich Mar 28, 2025
196a5d0
Update billing date formatting to conditionally display based on bill…
pasyukevich Mar 28, 2025
2e7dc76
Remove test case for POLICY_OWNER_WITH_AMOUNT_OWED_OVERDUE variant in…
pasyukevich Mar 28, 2025
fd6043b
Refactor billablePolicies to use a detailed record structure for impr…
pasyukevich Mar 28, 2025
c07617a
Refactor billablePolicies type to use a dedicated BillablePolicy stru…
pasyukevich Mar 31, 2025
5bc1914
Update billing status handling to conditionally format subtitle based…
pasyukevich Mar 31, 2025
e28cd6d
Fix prettier
pasyukevich Mar 31, 2025
fa1e98a
Make subscription and message properties optional for improved flexib…
pasyukevich Apr 1, 2025
c34e176
Add billing type constant and refactor purchase types for improved cl…
pasyukevich Apr 4, 2025
7670805
Merge branch 'main' into task/policyOwnerAmountOwedOverdue
pasyukevich Apr 4, 2025
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
4 changes: 4 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6924,6 +6924,10 @@ const CONST = {
ILLUSTRATION_ASPECT_RATIO: 39 / 22,

OFFLINE_INDICATOR_HEIGHT: 25,

BILLING: {
TYPE_FAILED_2018: 'typeFailed2018',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

While QAing this on staging I realized this got changed at the last minute to be typeFailed2018, when the correct value is failed_2018. I'll open a PR to fix it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

},
} as const;

type Country = keyof typeof CONST.ALL_COUNTRIES;
Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
BillingBannerCardOnDisputeParams,
BillingBannerDisputePendingParams,
BillingBannerInsufficientFundsParams,
BillingBannerOwnerAmountOwedOverdueParams,
BillingBannerSubtitleWithDateParams,
CanceledRequestParams,
CardEndingParams,
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 6 additions & 2 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
BillingBannerCardOnDisputeParams,
BillingBannerDisputePendingParams,
BillingBannerInsufficientFundsParams,
BillingBannerOwnerAmountOwedOverdueParams,
BillingBannerSubtitleWithDateParams,
CanceledRequestParams,
CardEndingParams,
Expand Down Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions src/languages/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -706,6 +708,7 @@ export type {
SubscriptionSettingsRenewsOnParams,
BadgeFreeTrialParams,
BillingBannerSubtitleWithDateParams,
BillingBannerOwnerAmountOwedOverdueParams,
BillingBannerDisputePendingParams,
BillingBannerCardAuthenticationRequiredParams,
BillingBannerInsufficientFundsParams,
Expand Down
23 changes: 20 additions & 3 deletions src/pages/settings/Subscription/CardSection/CardSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -66,15 +67,31 @@ function CardSection() {
Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query}));
}, []);

const [billingStatus, setBillingStatus] = useState<BillingStatusResult | undefined>(() => CardSectionUtils.getBillingStatus(translate, defaultCard?.accountData ?? {}));
const [billingStatus, setBillingStatus] = useState<BillingStatusResult | undefined>(() =>
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],
}),
Comment on lines +80 to +84

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is there a reason we pass just the first purchase?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, accroding to #58067 (comment) we need only the first item for now

);
}, [
subscriptionRetryBillingStatusPending,
subscriptionRetryBillingStatusSuccessful,
subscriptionRetryBillingStatusFailed,
translate,
defaultCard?.accountData,
privateStripeCustomerID,
purchaseList,
]);

const handleRetryPayment = () => {
clearOutstandingBalance();
Expand Down
27 changes: 25 additions & 2 deletions src/pages/settings/Subscription/CardSection/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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();
Expand All @@ -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 {
Expand All @@ -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,
};
Expand Down
171 changes: 171 additions & 0 deletions src/types/onyx/PurchaseList.ts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Many fields are optional, here is sample

{
    "amount": 0,
    "created": "2025-03-01 03:32:13",
    "currency": "USD",
    "message": {
        "accountManagerAccountID": 0,
        "approvedAccountantAccountIDs": [],
        "approvedSpend": {
            "USD": 0
        },
        "billableAmount": 2700,
        "billablePolicies": {
            "0680EF3C70411F57": {
                "actorList": "admin+testc@abdelhafidh.com",
                "approvedSpend": {
                    "USD": 0
                },
                "corporate": true,
                "expensifyCardSpend": {
                    "USD": 0
                },
                "type": "corporate"
            },
            "8970B100A18A2CD6": {
                "actorList": "admin+testc@abdelhafidh.com",
                "approvedSpend": {
                    "USD": 0
                },
                "corporate": true,
                "expensifyCardSpend": {
                    "USD": 0
                },
                "type": "corporate"
            },
            "8ECD8C2EBB39933F": {
                "actorList": "admin+testc@abdelhafidh.com",
                "approvedSpend": {
                    "USD": 0
                },
                "corporate": true,
                "expensifyCardSpend": {
                    "USD": 0
                },
                "type": "corporate"
            },
            "978C2546B3751E81": {
                "actorList": "admin+testc@abdelhafidh.com",
                "approvedSpend": {
                    "USD": 0
                },
                "corporate": false,
                "expensifyCardSpend": {
                    "USD": 0
                },
                "type": "team"
            },
            "CDD878B939EB631E": {
                "actorList": "admin+testc@abdelhafidh.com",
                "approvedSpend": {
                    "USD": 0
                },
                "corporate": true,
                "expensifyCardSpend": {
                    "USD": 0
                },
                "type": "corporate"
            },
            "E666A63199B43042": {
                "actorList": "admin+testc@abdelhafidh.com",
                "approvedSpend": {
                    "USD": 0
                },
                "corporate": true,
                "expensifyCardSpend": {
                    "USD": 0
                },
                "type": "corporate"
            }
        },
        "billingType": "failed_2018",
        "cardSpendSurchargePercent": 1,
        "cashBackAmount": 0,
        "cashBackPercentage": 0,
        "chatOnlyActorList": "",
        "corporateActorCount": 1,
        "corporateRevenue": 3600,
        "expensifyCardMonthlySpend": 0,
        "expensifyCardSpend": {
            "USD": 0
        },
        "freeToTeamMigrationDiscountAmount": 900,
        "freeToTeamMigrationDiscountPercent": 25,
        "freebieCreditsUsed": 0,
        "guideAccountID": 12003194,
        "isApprovedAccountant": false,
        "isApprovedAccountantClient": false,
        "monthlyCorpSubscriptionCost": 1800,
        "monthlyTeamSubscriptionCost": 0,
        "paidActorCount": 1,
        "partnerManagerAccountID": 0,
        "perPolicyTotalMembersCount": {
            "05E4A0124E314E2D": 1,
            "23B249B9FA2A78A3": 2,
            "2499B1B92C375F8B": 1,
            "4656350AB2539E7A": 1,
            "5F5EB0E523187199": 1,
            "6AEBBB88BBAF7048": 1,
            "762A250743827663": 1,
            "978C2546B3751E81": 3,
            "C22CACBB604EED41": 1,
            "CFD83323ED0FC6A9": 1,
            "D15117903706B9B0": 4,
            "E5405AABB0C65377": 2,
            "FED1806CF392498F": 1
        },
        "potentialCashBackAmount": 1755,
        "potentialCashBackPercentage": 0.01,
        "subscription": {
            "type": "monthly2018"
        },
        "teamActorCount": 0,
        "teamRevenue": 0,
        "totalActorCount": 1,
        "totalFreebieCredits": 0,
        "totalPlatformSpend": 175500,
        "totalRevenue": 2700,
        "totalUniqueMembersCount": 6,
        "wasDomainBillingUsed": false
    },
    "purchaseID": 71976932
}

cc @amyevans to confirm which fields are required or we can assume all are optional to avoid accessing a non existing field which may lead to a crash

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The top level fields are required (amount, created, currency, message, purchaseID). Anything within message we should assume is optional and code defensively to avoid crashes - message is just a JSON string in our database, so the existence of fields is not enforced/guaranteed.

Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type CONST from '@src/CONST';
import type PrivateSubscription from './PrivateSubscription';

/** Subscription type for a purchase */
type Subscription = Omit<PrivateSubscription, 'errors' | 'errorFields'>;

/** 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<string, number>;

/** Whether the policy is corporate */
corporate?: boolean;

/** Expensify card spend by currency */
expensifyCardSpend?: Record<string, number>;

/** 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<string, number>;

/** Billable amount */
billableAmount?: number;

/** Billable amount before free trial discount */
billableAmountBeforeFreeTrialDiscount?: number;

/** Record of billable policies with their details */
billablePolicies?: Record<string, BillablePolicy>;

/** 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<string, number>;

/** 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<string, number>;

/** 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};
2 changes: 2 additions & 0 deletions src/types/onyx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -220,6 +221,7 @@ export type {
WalletStatement,
WalletTerms,
WalletTransfer,
PurchaseList,
ReportUserIsTyping,
PolicyReportField,
RecentlyUsedReportFields,
Expand Down
Loading