From 004bcdf627c3c5e91858bc14f28466bd02f02d83 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 21 Sep 2026 06:18:42 +0530 Subject: [PATCH 01/11] Apply category tax rules to distance expenses without a rate tax Signed-off-by: krishna2323 --- src/libs/TransactionUtils/index.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 598fc09fe125..b915a412758b 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -97,6 +97,7 @@ import type {Locale as DateFnsLocale} from 'date-fns'; import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +/* eslint-disable max-lines */ import {differenceInCalendarDays, format, isValid, parse, parseISO} from 'date-fns'; import {SafeString, Str} from 'expensify-common'; import {deepEqual} from 'fast-equals'; @@ -2809,12 +2810,17 @@ function getDistanceRateTaxUpdates( customUnitRateID: string, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], distanceUnit?: Unit, + category?: string, ): {taxAmount: number; taxCode: string; taxValue: string | undefined} { const policyCustomUnitRate = getDistanceRateCustomUnitRate(policy, customUnitRateID); const defaultTaxCode = getDefaultTaxCode(policy, transaction, undefined, customUnitRateID) ?? ''; + // A rate's own tax rate wins over a category rule, because vehicle types coded to the same category can each + // reclaim at a different rate. A rate without one falls through to the category rule, then the workspace default. + const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax) ?? []; + const categoryTaxCode = getCategoryDefaultTaxRate(taxRules, category ?? transaction?.category ?? '', defaultTaxCode); // We use || instead of ?? because taxRateExternalID may be an empty string, which should also trigger the fallback to the default tax code. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const taxCode = policyCustomUnitRate?.attributes?.taxRateExternalID || defaultTaxCode; + const taxCode = policyCustomUnitRate?.attributes?.taxRateExternalID || categoryTaxCode || defaultTaxCode; const taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, getDistanceInMeters(transaction, distanceUnit ?? transaction?.comment?.customUnit?.distanceUnit)); const taxValue = taxCode ? getTaxValue(policy, transaction, taxCode) : undefined; const mileageRates = DistanceRequestUtils.getMileageRates(policy); @@ -3351,10 +3357,20 @@ function buildMergeDuplicatesParams( function getCategoryTaxDetails(category: string, transaction: OnyxEntry, policy: OnyxEntry, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']) { const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax); - if (!taxRules || taxRules?.length === 0 || isDistanceRequest(transaction)) { + const customUnitRateID = isDistanceRequest(transaction) ? (getRateID(transaction) ?? '') : undefined; + // A rate's own tax rate wins over a category rule, so there is nothing for the rule to apply to. + const distanceRateTaxCode = customUnitRateID === undefined ? undefined : getDistanceRateCustomUnitRate(policy, customUnitRateID)?.attributes?.taxRateExternalID; + if (!taxRules || taxRules?.length === 0 || distanceRateTaxCode) { return {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; } + // Distance tax is charged on the taxable portion of the mileage rate rather than the full amount, so reuse the + // rate-aware calculation instead of taxing the expense amount. + if (customUnitRateID !== undefined) { + const {taxCode, taxAmount, taxValue} = getDistanceRateTaxUpdates(policy, transaction, customUnitRateID, getCurrencyDecimals, undefined, category); + return {categoryTaxCode: taxCode, categoryTaxAmount: taxAmount, categoryTaxValue: taxValue}; + } + const defaultTaxCode = getDefaultTaxCode(policy, transaction, getCurrency(transaction)); const categoryTaxCode = getCategoryDefaultTaxRate(taxRules, category, defaultTaxCode); const categoryTaxPercentage = getTaxValue(policy, transaction, categoryTaxCode ?? ''); From 5d9a9e8b4cb10b760aee2ea2d9c4f350d58e63a4 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 21 Sep 2026 18:33:39 +0530 Subject: [PATCH 02/11] Apply category tax rules to distance expenses without a rate tax Signed-off-by: krishna2323 --- src/libs/TransactionUtils/index.ts | 10 ++-- tests/unit/TransactionUtilsTest.ts | 76 +++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index b915a412758b..1799a49cf882 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -3358,9 +3358,13 @@ function buildMergeDuplicatesParams( function getCategoryTaxDetails(category: string, transaction: OnyxEntry, policy: OnyxEntry, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']) { const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax); const customUnitRateID = isDistanceRequest(transaction) ? (getRateID(transaction) ?? '') : undefined; - // A rate's own tax rate wins over a category rule, so there is nothing for the rule to apply to. - const distanceRateTaxCode = customUnitRateID === undefined ? undefined : getDistanceRateCustomUnitRate(policy, customUnitRateID)?.attributes?.taxRateExternalID; - if (!taxRules || taxRules?.length === 0 || distanceRateTaxCode) { + // Distance skips the category rule in two cases. Tax tracking being off for distance rates hides the Tax field, so + // a value written there would be invisible. A rate carrying its own tax rate wins over the rule, because vehicle + // types coded to the same category can each reclaim at a different rate. + const shouldSkipDistanceTax = + customUnitRateID !== undefined && + (!getDistanceRateCustomUnit(policy)?.attributes?.taxEnabled || !!getDistanceRateCustomUnitRate(policy, customUnitRateID)?.attributes?.taxRateExternalID); + if (!taxRules || taxRules?.length === 0 || shouldSkipDistanceTax) { return {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; } diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 5c41de2605f7..0b5eb04a66d2 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -379,9 +379,81 @@ describe('TransactionUtils', () => { expect(categoryTaxValue).toBe('10%'); }); + describe('distance expenses', () => { + const distanceRateID = 'DISTANCE_RATE_ID'; + + function buildDistancePolicy(category: string, taxEnabled: boolean, rateTaxRateExternalID?: string): Policy { + return { + ...createRandomPolicy(0), + taxRates: CONST.DEFAULT_TAX, + rules: {expenseRules: createCategoryTaxExpenseRules(category, 'id_TAX_RATE_1')}, + customUnits: { + DISTANCE_CUSTOM_UNIT: { + attributes: {taxEnabled, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + customUnitID: 'DISTANCE_CUSTOM_UNIT', + enabled: true, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + rates: { + [distanceRateID]: { + attributes: {taxRateExternalID: rateTaxRateExternalID}, + currency: 'USD', + customUnitRateID: distanceRateID, + enabled: true, + name: 'Test rate', + rate: 67, + subRates: [], + }, + }, + }, + }, + }; + } + + function buildDistanceTransaction(): Transaction { + const transaction = generateTransaction(); + return { + ...transaction, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: {...transaction.comment, customUnit: {customUnitRateID: distanceRateID}}, + }; + } + + it('should apply the category rule when distance tax tracking is on and the rate has no tax rate of its own', () => { + // Given a policy whose distance rates track tax, where the selected rate has no tax rate of its own + const category = 'Advertising'; + const fakePolicy = buildDistancePolicy(category, true); + + // When retrieving the tax from the associated category + const {categoryTaxCode, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(category, buildDistanceTransaction(), fakePolicy, getCurrencyDecimalsLocal); + + // Then the category rule fills the slot the rate left empty, rather than the workspace default + expect(categoryTaxCode).toBe('id_TAX_RATE_1'); + expect(categoryTaxValue).toBe('5%'); + }); + + it('should keep the rate tax rate when distance tax tracking is on and the rate has its own tax rate', () => { + // Given a policy whose distance rates track tax, where the selected rate carries its own tax rate + const category = 'Advertising'; + const fakePolicy = buildDistancePolicy(category, true, 'id_TAX_RATE_2'); + + // When retrieving the tax from the associated category + const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails( + category, + buildDistanceTransaction(), + fakePolicy, + getCurrencyDecimalsLocal, + ); + + // Then the category rule is skipped, because the rate's own tax rate takes precedence over it + expect(categoryTaxCode).toBe(undefined); + expect(categoryTaxAmount).toBe(undefined); + expect(categoryTaxValue).toBe(undefined); + }); + }); + describe('should return undefined tax', () => { - it('if the transaction type is distance', () => { - // Given a policy with tax expense rules associated with a category + it('if the transaction type is distance and distance tax tracking is disabled', () => { + // Given a policy with tax expense rules associated with a category, and no distance rates tracking tax const category = 'Advertising'; const fakePolicy: Policy = { ...createRandomPolicy(0), From f79b26b6b827dfaf7eb8206401b14bcd56531891 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 07:26:28 +0530 Subject: [PATCH 03/11] Apply the category tax rule to the confirmation page default Signed-off-by: krishna2323 --- .../MoneyRequestConfirmationList/hooks/useTaxAmount.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts b/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts index ad9b6e7fe257..59b3a82964ac 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts @@ -2,7 +2,7 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {calculateTaxAmount, getDefaultTaxCode, getTaxValue, hasTaxRateWithMatchingValue} from '@libs/TransactionUtils'; +import {calculateTaxAmount, getCategoryTaxDetails, getDefaultTaxCode, getTaxValue, hasTaxRateWithMatchingValue} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; @@ -61,7 +61,13 @@ function useTaxAmount({ const {getCurrencyDecimals} = useCurrencyListActions(); // Update the tax code when the default changes (for example, because the transaction currency changed) - const defaultTaxCode = getDefaultTaxCode(policy, transaction) ?? (isMovingTransactionFromTrackExpense ? (getDefaultTaxCode(policyForMovingExpenses, transaction) ?? '') : ''); + const workspaceDefaultTaxCode = getDefaultTaxCode(policy, transaction) ?? (isMovingTransactionFromTrackExpense ? (getDefaultTaxCode(policyForMovingExpenses, transaction) ?? '') : ''); + // A category tax rule outranks the workspace default, so resolve it here as well. Otherwise this default overwrites + // the rule's tax rate on the first render of the confirmation page. + const {categoryTaxCode} = getCategoryTaxDetails(transaction?.category ?? '', transaction, policy, getCurrencyDecimals); + // We use || instead of ?? because the category helper can return an empty string, which should also fall back to the workspace default. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const defaultTaxCode = categoryTaxCode || workspaceDefaultTaxCode; const defaultTaxValue = getTaxValue(policy, transaction, defaultTaxCode) ?? null; const previousDefaultTaxCode = getDefaultTaxCode(policy, transaction, previousTransactionCurrency); const shouldKeepCurrentTaxSelection = hasTaxRateWithMatchingValue(policy, transaction) && transaction?.taxCode !== previousDefaultTaxCode; From 1d2808e8148a6500743890d2822c54dd8e5a0c35 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 07:31:18 +0530 Subject: [PATCH 04/11] fix eslint. Signed-off-by: krishna2323 --- src/libs/TransactionUtils/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 995b04a1733e..57c151808580 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -100,7 +100,6 @@ import type {Locale as DateFnsLocale} from 'date-fns'; import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -/* eslint-disable max-lines */ import {differenceInCalendarDays, format, isValid, parse, parseISO} from 'date-fns'; import {SafeString, Str} from 'expensify-common'; import {deepEqual} from 'fast-equals'; From 5d7e592212d84d5e0fbfb2f47e74fa6fec7efcf9 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 12 Oct 2026 18:05:44 +0530 Subject: [PATCH 05/11] Restore the GitHub Action bundles to main Signed-off-by: krishna2323 --- .../isDeployChecklistLocked/index.js | 305 ++++++++++-------- .../javascript/proposalPoliceComment/index.js | 305 ++++++++++-------- 2 files changed, 330 insertions(+), 280 deletions(-) diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index 9321db05e6d7..cfe1ac778241 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -3432,6 +3432,7 @@ var require_CONST = __commonJS({ REPLACE_CARD: "replace_card", SHIP_CARD: "ship_card", REPORT_CARD_FRAUD: "report_card_fraud", + APPROVE_DIGITAL_WALLET: "approve_digital_wallet", ISSUE_CARD: "issue_card", UPDATE_CARD: "update_card", UPDATE_PERSONAL_DETAILS: "update_personal_details", @@ -22075,6 +22076,117 @@ var require_str = __commonJS({ } return parameter; } + function compare(strA, strB) { + if (strA < strB) { + return -1; + } + if (strA > strB) { + return 1; + } + return 0; + } + function caseInsensitiveCompare(strA, strB) { + const lowerCaseStrA = strA.toLocaleLowerCase(); + const lowerCaseStrB = strB.toLocaleLowerCase(); + return compare(lowerCaseStrA, lowerCaseStrB); + } + function contains(haystack, needle) { + return haystack.indexOf(needle) !== -1; + } + function cutAfter(str, substr) { + const index = str.indexOf(substr); + if (index !== -1) { + return str.substring(0, index); + } + return str; + } + function cutBefore(str, substr) { + const index = str.indexOf(substr); + if (index !== -1) { + return str.substring(index + substr.length); + } + return str; + } + function extractEmailDomain(email) { + return cutBefore(email, "@"); + } + function getExtension(url) { + var _a, _b; + if (typeof url !== "string") { + Log_1.default.warn("Str.getExtension: url is not a string", { url }); + return void 0; + } + return (_b = (_a = url.split(".").pop()) === null || _a === void 0 ? void 0 : _a.split("?")[0]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); + } + function getRawByteSize(inputChar) { + const onlyChar = String(inputChar); + const c = onlyChar.charCodeAt(0); + if (c < 1 << 7) { + return 1; + } + if (c < 1 << 11) { + return 2; + } + if (c < 1 << 16) { + return 3; + } + if (c < 1 << 21) { + return 4; + } + if (c < 1 << 26) { + return 5; + } + if (c < 1 << 31) { + return 6; + } + return Number.NaN; + } + function htmlDecode(s) { + return HtmlEntities.decode(s); + } + function htmlEncode(s) { + return HtmlEntities.encode(s); + } + function isTypeOf(obj, type) { + return Object.prototype.toString.call(obj) === `[object ${type}]`; + } + function isNumber(obj) { + return isTypeOf(obj, "Number"); + } + function isString(obj) { + return isTypeOf(obj, "String"); + } + function isUndefined(obj) { + return obj === void 0; + } + function isValidE164Phone(phone) { + return Constants.CONST.SMS.E164_REGEX.test(phone); + } + function isValidEmail(str) { + if (!str || typeof str !== "string") { + return false; + } + const unicodeVersion = punycode_1.default.toUnicode(str); + if (String(unicodeVersion).match(Constants.CONST.REG_EXP.EMOJI_RULE)) { + return false; + } + return !!String(str).match(Constants.CONST.REG_EXP.EMAIL); + } + function maskFirstNCharacters(str, num, mask) { + if (!isString(str) || !isString(mask) || str.length === 0 || !isNumber(num)) { + return str; + } + return str.substring(0, num).replaceAll(/./g, mask) + str.substring(num); + } + function removeSMSDomain(text) { + return text.replaceAll(REMOVE_SMS_DOMAIN_PATTERN, ""); + } + function removeTrailingComma(str) { + return str.trim().replaceAll(/(,$)/g, ""); + } + function startsWith(haystack, needle) { + return isString(haystack) && isString(needle) && haystack.substring(0, needle.length) === needle; + } var Str2 = { /** * Return true if the string is ending with the provided suffix @@ -22132,18 +22244,14 @@ var require_str = __commonJS({ * @param s The string to decode. * @returns The decoded string. */ - htmlDecode(s) { - return HtmlEntities.decode(s); - }, + htmlDecode, /** * HTML encodes the given string. * * @param s The string to encode. * @return string @p s HTML encoded. */ - htmlEncode(s) { - return HtmlEntities.encode(s); - }, + htmlEncode, /** * Decodes the given HTML encoded string. * @@ -22185,7 +22293,7 @@ var require_str = __commonJS({ * @returns True when first === second, ignoring HTML encoding */ htmlEncodingInsensitiveEquals(first, second) { - return first === second || this.htmlDecode(first) === second || this.htmlEncode(first) === second; + return first === second || htmlDecode(first) === second || htmlEncode(first) === second; }, /** * Creates an ID that can be used as an HTML attribute from @p str. @@ -22268,36 +22376,14 @@ var require_str = __commonJS({ * one. * @returns Byte size of the character */ - getRawByteSize(inputChar) { - const onlyChar = String(inputChar); - const c = onlyChar.charCodeAt(0); - if (c < 1 << 7) { - return 1; - } - if (c < 1 << 11) { - return 2; - } - if (c < 1 << 16) { - return 3; - } - if (c < 1 << 21) { - return 4; - } - if (c < 1 << 26) { - return 5; - } - if (c < 1 << 31) { - return 6; - } - return Number.NaN; - }, + getRawByteSize, /** * Gets the length of a string in bytes, including non-ASCII characters * @returns The number of bytes used by string */ getByteLength(input) { const stringInput = String(input); - const byteLength = Array.from(stringInput).reduce((acc, char) => acc + this.getRawByteSize(char), 0); + const byteLength = Array.from(stringInput).reduce((acc, char) => acc + getRawByteSize(char), 0); return byteLength; }, /** @@ -22309,7 +22395,7 @@ var require_str = __commonJS({ const stringInput = String(input); let totalByteLength = 0; for (let i = 0; i < stringInput.length; i++) { - const charByteSize = this.getRawByteSize(stringInput[i]); + const charByteSize = getRawByteSize(stringInput[i]); if (charByteSize + totalByteLength > maxSize) { return `${stringInput.substr(0, i - 3)}...`; } @@ -22324,9 +22410,7 @@ var require_str = __commonJS({ * @param needle The case-sensitive string to search for * @returns True if the haystack starts with the needle. */ - startsWith(haystack, needle) { - return this.isString(haystack) && this.isString(needle) && haystack.substring(0, needle.length) === needle; - }, + startsWith, /** * Gets the textual value of the given string. * @@ -22334,7 +22418,7 @@ var require_str = __commonJS({ * @returns The text from within the HTML string. */ stripHTML(str) { - if (!this.isString(str)) { + if (!isString(str)) { return ""; } return str.replaceAll(/<[^>]*>?/gm, ""); @@ -22357,13 +22441,7 @@ var require_str = __commonJS({ * @param substr The substring to search for. * @returns The cut/trimmed string. */ - cutAfter(str, substr) { - const index = str.indexOf(substr); - if (index !== -1) { - return str.substring(0, index); - } - return str; - }, + cutAfter, /** * Returns a string containing all the characters str from after the first * occurrence of substr to the end of the string. @@ -22373,13 +22451,7 @@ var require_str = __commonJS({ * @param substr The substring to search for. * @returns The cut/trimmed string. */ - cutBefore(str, substr) { - const index = str.indexOf(substr); - if (index !== -1) { - return str.substring(index + substr.length); - } - return str; - }, + cutBefore, /** * Checks that the string is a domain name (e.g. example.com) * @@ -22406,16 +22478,7 @@ var require_str = __commonJS({ * * @returns True if the string is an email */ - isValidEmail(str) { - if (!str || typeof str !== "string") { - return false; - } - const unicodeVersion = punycode_1.default.toUnicode(str); - if (String(unicodeVersion).match(Constants.CONST.REG_EXP.EMOJI_RULE)) { - return false; - } - return !!String(str).match(Constants.CONST.REG_EXP.EMAIL); - }, + isValidEmail, /** * Checks if the string is an valid email address formed during comment markdown formation. * @@ -22433,9 +22496,7 @@ var require_str = __commonJS({ * * @returns string with the trailing comma removed */ - removeTrailingComma(str) { - return str.trim().replaceAll(/(,$)/g, ""); - }, + removeTrailingComma, /** * Checks that the string is a list of coma separated email addresss. * @@ -22444,12 +22505,12 @@ var require_str = __commonJS({ * @returns True if all emails are valid or if input is empty */ areValidEmails(str) { - const string = this.removeTrailingComma(str); + const string = removeTrailingComma(str); if (string === "") { return true; } const emails = string.split(","); - const result = emails.every((email) => this.isValidEmail(email.trim())); + const result = emails.every((email) => isValidEmail(email.trim())); return result; }, /** @@ -22466,9 +22527,7 @@ var require_str = __commonJS({ * * @returns The domain name in the email address. */ - extractEmailDomain(email) { - return this.cutBefore(email, "@"); - }, + extractEmailDomain, /** * Tries to extract the company name from the given email address * (e.g. "yelp" for "joe@yelp.co.uk"). @@ -22478,7 +22537,7 @@ var require_str = __commonJS({ * @returns The company name in the email address or null. */ extractCompanyNameFromEmailDomain(email) { - const domain = this.extractEmailDomain(email); + const domain = extractEmailDomain(email); if (!domain) { return null; } @@ -22497,7 +22556,7 @@ var require_str = __commonJS({ * @returns The local part in the email address. */ extractEmailLocalPart(email) { - return this.cutAfter(email, "@"); + return cutAfter(email, "@"); }, /** * Sanitize phone number to return only numbers. Return null if non valid phone number. @@ -22555,13 +22614,13 @@ var require_str = __commonJS({ * @returns true if the length is in the range, false otherwise */ isOfLength(str, minimumLength, maximumLength) { - if (!this.isString(str)) { + if (!isString(str)) { return false; } if (str.length < minimumLength) { return false; } - if (!this.isUndefined(maximumLength) && str.length > maximumLength) { + if (!isUndefined(maximumLength) && str.length > maximumLength) { return false; } return true; @@ -22614,9 +22673,7 @@ var require_str = __commonJS({ * * @returns Returns true if the haystack contains the needle */ - contains(haystack, needle) { - return haystack.indexOf(needle) !== -1; - }, + contains, /** * Returns true if the haystack contains the needle, ignoring case * @@ -22626,7 +22683,7 @@ var require_str = __commonJS({ * @returns Returns true if the haystack contains the needle, ignoring case */ caseInsensitiveContains(haystack, needle) { - return this.contains(haystack.toLowerCase(), needle.toLowerCase()); + return contains(haystack.toLowerCase(), needle.toLowerCase()); }, /** * Case insensitive compare function @@ -22638,11 +22695,7 @@ var require_str = __commonJS({ * 1 if first string > second string * 0 if first string = second string */ - caseInsensitiveCompare(strA, strB) { - const lowerCaseStrA = strA.toLocaleLowerCase(); - const lowerCaseStrB = strB.toLocaleLowerCase(); - return this.compare(lowerCaseStrA, lowerCaseStrB); - }, + caseInsensitiveCompare, /** * Case insensitive equals * @@ -22651,7 +22704,7 @@ var require_str = __commonJS({ * @returns true when first == second except for case */ caseInsensitiveEquals(strA, strB) { - return this.caseInsensitiveCompare(strA, strB) === 0; + return caseInsensitiveCompare(strA, strB) === 0; }, /** * Compare function @@ -22663,15 +22716,7 @@ var require_str = __commonJS({ * 1 if first string > second string * 0 if first string = second string */ - compare(strA, strB) { - if (strA < strB) { - return -1; - } - if (strA > strB) { - return 1; - } - return 0; - }, + compare, /** * Check if a file extension is supported by SmartReports */ @@ -22690,45 +22735,37 @@ var require_str = __commonJS({ const accountNumber = String(num); const len = accountNumber.length; if (len < 6 || len > 20) { - return this.maskFirstNCharacters(accountNumber, len, "X"); + return maskFirstNCharacters(accountNumber, len, "X"); } if (len < 14) { - return this.maskFirstNCharacters(accountNumber, len - 4, "X"); + return maskFirstNCharacters(accountNumber, len - 4, "X"); } const first = accountNumber.substr(0, 6); const last = accountNumber.substr(7); - const masked = this.maskFirstNCharacters(last, len - 11, "X"); + const masked = maskFirstNCharacters(last, len - 11, "X"); return `${first}${masked}`; }, /** * Checks if something is a string * Stolen from underscore */ - isString(obj) { - return this.isTypeOf(obj, "String"); - }, + isString, /** * Checks if something is a number * Stolen from underscore * @param obj */ - isNumber(obj) { - return this.isTypeOf(obj, "Number"); - }, + isNumber, /** * Checks if something is a certain type * Stolen from underscore */ - isTypeOf(obj, type) { - return Object.prototype.toString.call(obj) === `[object ${type}]`; - }, + isTypeOf, /** * Checks to see if something is undefined * Stolen from underscore */ - isUndefined(obj) { - return obj === void 0; - }, + isUndefined, /** * Replace first N characters of the string with maskChar * eg: maskFirstNCharacters( '1234567890', 6, 'X' ) yields XXXXXX7890 @@ -22737,12 +22774,7 @@ var require_str = __commonJS({ * @param mask String we want replace the first N chars with * @returns Masked string */ - maskFirstNCharacters(str, num, mask) { - if (!this.isString(str) || !this.isString(mask) || str.length === 0 || !this.isNumber(num)) { - return str; - } - return str.substring(0, num).replaceAll(/./g, mask) + str.substring(num); - }, + maskFirstNCharacters, /** * Trim a string */ @@ -22754,7 +22786,7 @@ var require_str = __commonJS({ * @param percentageString The percentage as a string */ percentageStringToNumber(percentageString) { - return Number(this.cutAfter(percentageString, "%")); + return Number(cutAfter(percentageString, "%")); }, /** * Remove all the spaces from a string @@ -22798,7 +22830,7 @@ var require_str = __commonJS({ * Converts a value to boolean, case-insensitive. */ toBool(value) { - if (this.isString(value)) { + if (isString(value)) { return value.toLowerCase() === "true"; } return !!value; @@ -22848,9 +22880,7 @@ var require_str = __commonJS({ /** * Check for whether a phone number is valid according to E.164 standard. */ - isValidE164Phone(phone) { - return Constants.CONST.SMS.E164_REGEX.test(phone); - }, + isValidE164Phone, /** * Check for whether a phone number is valid in different formats/standards. For example: * significant: 4404589784 @@ -22876,14 +22906,12 @@ var require_str = __commonJS({ /** * Returns text without our SMS domain */ - removeSMSDomain(text) { - return text.replaceAll(REMOVE_SMS_DOMAIN_PATTERN, ""); - }, + removeSMSDomain, /** * Returns true if the text is a valid E.164 phone number with our SMS domain removed */ isSMSLogin(text) { - return this.isValidE164Phone(this.removeSMSDomain(text)); + return isValidE164Phone(removeSMSDomain(text)); }, /** * This method will return all matches of a single regex like preg_match_all() in PHP. This is not a common part of @@ -22936,7 +22964,7 @@ var require_str = __commonJS({ return url; } const website = match[3] ? match[2] : `${defaultScheme}://${match[2]}`; - return website.toLowerCase() + this.cutBefore(match[1], match[2]); + return website.toLowerCase() + cutBefore(match[1], match[2]); }, /** * Checks if parameter is a string or function @@ -22948,14 +22976,7 @@ var require_str = __commonJS({ * Get file extension for a given url with or * without query parameters */ - getExtension(url) { - var _a, _b; - if (typeof url !== "string") { - Log_1.default.warn("Str.getExtension: url is not a string", { url }); - return void 0; - } - return (_b = (_a = url.split(".").pop()) === null || _a === void 0 ? void 0 : _a.split("?")[0]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); - }, + getExtension, /** * Takes in a URL and checks if the file extension is PDF * @@ -22963,7 +22984,7 @@ var require_str = __commonJS({ * @returns Whether file path is PDF or not */ isPDF(url) { - return this.getExtension(url) === "pdf"; + return getExtension(url) === "pdf"; }, /** * Takes in a URL and checks if the file extension is an image @@ -22974,7 +22995,7 @@ var require_str = __commonJS({ * https://reactnative.dev/docs/image#source */ isImage(url) { - const extension = this.getExtension(url); + const extension = getExtension(url); if (!extension) { return false; } @@ -22990,7 +23011,7 @@ var require_str = __commonJS({ * https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_codecs */ isVideo(url) { - const extension = this.getExtension(url); + const extension = getExtension(url); if (!extension) { return false; } @@ -23003,7 +23024,7 @@ var require_str = __commonJS({ * @returns True if is a domain account email, otherwise false. */ isDomainEmail(email) { - return this.startsWith(email, "+@"); + return startsWith(email, "+@"); }, /** * Find the minimum indentation of any line in the string, @@ -24141,13 +24162,17 @@ ${g2} }, { name: "anchor", - regex: /<(a)[^><]*href\s*=\s*(['"])(.*?)\2(?:".*?"|'.*?'|[^'"><])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, - replacement: (_extras, _match, _g1, _g2, g3, g4) => { - const email = g3.startsWith("mailto:") ? g3.slice(7) : ""; - if (email === g4) { - return email; + regex: /(\]\()?<(a)[^><]*href\s*=\s*(['"])(.*?)\3(?:".*?"|'.*?'|[^'"><])*>([\s\S]*?)<\/\2>(?![^<]*(<\/pre>|<\/code>))/gi, + replacement: (_extras, _match, g1, _g2, _g3, g4, g5) => { + const incompleteLinkStart = g1 !== null && g1 !== void 0 ? g1 : ""; + const email = g4.startsWith("mailto:") ? g4.slice(7) : ""; + if (email === g5) { + return `${incompleteLinkStart}${email}`; + } + if (incompleteLinkStart && (g4 === g5 || g4 === `https://${g5}`)) { + return `${incompleteLinkStart}${g5}`; } - return `[${g4}](${email || g3})`; + return `${incompleteLinkStart}[${g5}](${email || g4})`; } }, { diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index beb367dd3ebd..58a12c5fb27a 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -24691,6 +24691,7 @@ var require_CONST = __commonJS({ REPLACE_CARD: "replace_card", SHIP_CARD: "ship_card", REPORT_CARD_FRAUD: "report_card_fraud", + APPROVE_DIGITAL_WALLET: "approve_digital_wallet", ISSUE_CARD: "issue_card", UPDATE_CARD: "update_card", UPDATE_PERSONAL_DETAILS: "update_personal_details", @@ -43334,6 +43335,117 @@ var require_str = __commonJS({ } return parameter; } + function compare(strA, strB) { + if (strA < strB) { + return -1; + } + if (strA > strB) { + return 1; + } + return 0; + } + function caseInsensitiveCompare(strA, strB) { + const lowerCaseStrA = strA.toLocaleLowerCase(); + const lowerCaseStrB = strB.toLocaleLowerCase(); + return compare(lowerCaseStrA, lowerCaseStrB); + } + function contains(haystack, needle) { + return haystack.indexOf(needle) !== -1; + } + function cutAfter(str2, substr) { + const index = str2.indexOf(substr); + if (index !== -1) { + return str2.substring(0, index); + } + return str2; + } + function cutBefore(str2, substr) { + const index = str2.indexOf(substr); + if (index !== -1) { + return str2.substring(index + substr.length); + } + return str2; + } + function extractEmailDomain(email) { + return cutBefore(email, "@"); + } + function getExtension(url) { + var _a3, _b; + if (typeof url !== "string") { + Log_1.default.warn("Str.getExtension: url is not a string", { url }); + return void 0; + } + return (_b = (_a3 = url.split(".").pop()) === null || _a3 === void 0 ? void 0 : _a3.split("?")[0]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); + } + function getRawByteSize(inputChar) { + const onlyChar = String(inputChar); + const c = onlyChar.charCodeAt(0); + if (c < 1 << 7) { + return 1; + } + if (c < 1 << 11) { + return 2; + } + if (c < 1 << 16) { + return 3; + } + if (c < 1 << 21) { + return 4; + } + if (c < 1 << 26) { + return 5; + } + if (c < 1 << 31) { + return 6; + } + return Number.NaN; + } + function htmlDecode(s) { + return HtmlEntities.decode(s); + } + function htmlEncode(s) { + return HtmlEntities.encode(s); + } + function isTypeOf(obj, type) { + return Object.prototype.toString.call(obj) === `[object ${type}]`; + } + function isNumber(obj) { + return isTypeOf(obj, "Number"); + } + function isString(obj) { + return isTypeOf(obj, "String"); + } + function isUndefined(obj) { + return obj === void 0; + } + function isValidE164Phone(phone) { + return Constants.CONST.SMS.E164_REGEX.test(phone); + } + function isValidEmail(str2) { + if (!str2 || typeof str2 !== "string") { + return false; + } + const unicodeVersion = punycode_1.default.toUnicode(str2); + if (String(unicodeVersion).match(Constants.CONST.REG_EXP.EMOJI_RULE)) { + return false; + } + return !!String(str2).match(Constants.CONST.REG_EXP.EMAIL); + } + function maskFirstNCharacters(str2, num, mask) { + if (!isString(str2) || !isString(mask) || str2.length === 0 || !isNumber(num)) { + return str2; + } + return str2.substring(0, num).replaceAll(/./g, mask) + str2.substring(num); + } + function removeSMSDomain(text) { + return text.replaceAll(REMOVE_SMS_DOMAIN_PATTERN, ""); + } + function removeTrailingComma(str2) { + return str2.trim().replaceAll(/(,$)/g, ""); + } + function startsWith(haystack, needle) { + return isString(haystack) && isString(needle) && haystack.substring(0, needle.length) === needle; + } var Str6 = { /** * Return true if the string is ending with the provided suffix @@ -43391,18 +43503,14 @@ var require_str = __commonJS({ * @param s The string to decode. * @returns The decoded string. */ - htmlDecode(s) { - return HtmlEntities.decode(s); - }, + htmlDecode, /** * HTML encodes the given string. * * @param s The string to encode. * @return string @p s HTML encoded. */ - htmlEncode(s) { - return HtmlEntities.encode(s); - }, + htmlEncode, /** * Decodes the given HTML encoded string. * @@ -43444,7 +43552,7 @@ var require_str = __commonJS({ * @returns True when first === second, ignoring HTML encoding */ htmlEncodingInsensitiveEquals(first, second) { - return first === second || this.htmlDecode(first) === second || this.htmlEncode(first) === second; + return first === second || htmlDecode(first) === second || htmlEncode(first) === second; }, /** * Creates an ID that can be used as an HTML attribute from @p str. @@ -43527,36 +43635,14 @@ var require_str = __commonJS({ * one. * @returns Byte size of the character */ - getRawByteSize(inputChar) { - const onlyChar = String(inputChar); - const c = onlyChar.charCodeAt(0); - if (c < 1 << 7) { - return 1; - } - if (c < 1 << 11) { - return 2; - } - if (c < 1 << 16) { - return 3; - } - if (c < 1 << 21) { - return 4; - } - if (c < 1 << 26) { - return 5; - } - if (c < 1 << 31) { - return 6; - } - return Number.NaN; - }, + getRawByteSize, /** * Gets the length of a string in bytes, including non-ASCII characters * @returns The number of bytes used by string */ getByteLength(input) { const stringInput = String(input); - const byteLength = Array.from(stringInput).reduce((acc, char) => acc + this.getRawByteSize(char), 0); + const byteLength = Array.from(stringInput).reduce((acc, char) => acc + getRawByteSize(char), 0); return byteLength; }, /** @@ -43568,7 +43654,7 @@ var require_str = __commonJS({ const stringInput = String(input); let totalByteLength = 0; for (let i = 0; i < stringInput.length; i++) { - const charByteSize = this.getRawByteSize(stringInput[i]); + const charByteSize = getRawByteSize(stringInput[i]); if (charByteSize + totalByteLength > maxSize) { return `${stringInput.substr(0, i - 3)}...`; } @@ -43583,9 +43669,7 @@ var require_str = __commonJS({ * @param needle The case-sensitive string to search for * @returns True if the haystack starts with the needle. */ - startsWith(haystack, needle) { - return this.isString(haystack) && this.isString(needle) && haystack.substring(0, needle.length) === needle; - }, + startsWith, /** * Gets the textual value of the given string. * @@ -43593,7 +43677,7 @@ var require_str = __commonJS({ * @returns The text from within the HTML string. */ stripHTML(str2) { - if (!this.isString(str2)) { + if (!isString(str2)) { return ""; } return str2.replaceAll(/<[^>]*>?/gm, ""); @@ -43616,13 +43700,7 @@ var require_str = __commonJS({ * @param substr The substring to search for. * @returns The cut/trimmed string. */ - cutAfter(str2, substr) { - const index = str2.indexOf(substr); - if (index !== -1) { - return str2.substring(0, index); - } - return str2; - }, + cutAfter, /** * Returns a string containing all the characters str from after the first * occurrence of substr to the end of the string. @@ -43632,13 +43710,7 @@ var require_str = __commonJS({ * @param substr The substring to search for. * @returns The cut/trimmed string. */ - cutBefore(str2, substr) { - const index = str2.indexOf(substr); - if (index !== -1) { - return str2.substring(index + substr.length); - } - return str2; - }, + cutBefore, /** * Checks that the string is a domain name (e.g. example.com) * @@ -43665,16 +43737,7 @@ var require_str = __commonJS({ * * @returns True if the string is an email */ - isValidEmail(str2) { - if (!str2 || typeof str2 !== "string") { - return false; - } - const unicodeVersion = punycode_1.default.toUnicode(str2); - if (String(unicodeVersion).match(Constants.CONST.REG_EXP.EMOJI_RULE)) { - return false; - } - return !!String(str2).match(Constants.CONST.REG_EXP.EMAIL); - }, + isValidEmail, /** * Checks if the string is an valid email address formed during comment markdown formation. * @@ -43692,9 +43755,7 @@ var require_str = __commonJS({ * * @returns string with the trailing comma removed */ - removeTrailingComma(str2) { - return str2.trim().replaceAll(/(,$)/g, ""); - }, + removeTrailingComma, /** * Checks that the string is a list of coma separated email addresss. * @@ -43703,12 +43764,12 @@ var require_str = __commonJS({ * @returns True if all emails are valid or if input is empty */ areValidEmails(str2) { - const string = this.removeTrailingComma(str2); + const string = removeTrailingComma(str2); if (string === "") { return true; } const emails = string.split(","); - const result = emails.every((email) => this.isValidEmail(email.trim())); + const result = emails.every((email) => isValidEmail(email.trim())); return result; }, /** @@ -43725,9 +43786,7 @@ var require_str = __commonJS({ * * @returns The domain name in the email address. */ - extractEmailDomain(email) { - return this.cutBefore(email, "@"); - }, + extractEmailDomain, /** * Tries to extract the company name from the given email address * (e.g. "yelp" for "joe@yelp.co.uk"). @@ -43737,7 +43796,7 @@ var require_str = __commonJS({ * @returns The company name in the email address or null. */ extractCompanyNameFromEmailDomain(email) { - const domain = this.extractEmailDomain(email); + const domain = extractEmailDomain(email); if (!domain) { return null; } @@ -43756,7 +43815,7 @@ var require_str = __commonJS({ * @returns The local part in the email address. */ extractEmailLocalPart(email) { - return this.cutAfter(email, "@"); + return cutAfter(email, "@"); }, /** * Sanitize phone number to return only numbers. Return null if non valid phone number. @@ -43814,13 +43873,13 @@ var require_str = __commonJS({ * @returns true if the length is in the range, false otherwise */ isOfLength(str2, minimumLength, maximumLength) { - if (!this.isString(str2)) { + if (!isString(str2)) { return false; } if (str2.length < minimumLength) { return false; } - if (!this.isUndefined(maximumLength) && str2.length > maximumLength) { + if (!isUndefined(maximumLength) && str2.length > maximumLength) { return false; } return true; @@ -43873,9 +43932,7 @@ var require_str = __commonJS({ * * @returns Returns true if the haystack contains the needle */ - contains(haystack, needle) { - return haystack.indexOf(needle) !== -1; - }, + contains, /** * Returns true if the haystack contains the needle, ignoring case * @@ -43885,7 +43942,7 @@ var require_str = __commonJS({ * @returns Returns true if the haystack contains the needle, ignoring case */ caseInsensitiveContains(haystack, needle) { - return this.contains(haystack.toLowerCase(), needle.toLowerCase()); + return contains(haystack.toLowerCase(), needle.toLowerCase()); }, /** * Case insensitive compare function @@ -43897,11 +43954,7 @@ var require_str = __commonJS({ * 1 if first string > second string * 0 if first string = second string */ - caseInsensitiveCompare(strA, strB) { - const lowerCaseStrA = strA.toLocaleLowerCase(); - const lowerCaseStrB = strB.toLocaleLowerCase(); - return this.compare(lowerCaseStrA, lowerCaseStrB); - }, + caseInsensitiveCompare, /** * Case insensitive equals * @@ -43910,7 +43963,7 @@ var require_str = __commonJS({ * @returns true when first == second except for case */ caseInsensitiveEquals(strA, strB) { - return this.caseInsensitiveCompare(strA, strB) === 0; + return caseInsensitiveCompare(strA, strB) === 0; }, /** * Compare function @@ -43922,15 +43975,7 @@ var require_str = __commonJS({ * 1 if first string > second string * 0 if first string = second string */ - compare(strA, strB) { - if (strA < strB) { - return -1; - } - if (strA > strB) { - return 1; - } - return 0; - }, + compare, /** * Check if a file extension is supported by SmartReports */ @@ -43949,45 +43994,37 @@ var require_str = __commonJS({ const accountNumber = String(num); const len = accountNumber.length; if (len < 6 || len > 20) { - return this.maskFirstNCharacters(accountNumber, len, "X"); + return maskFirstNCharacters(accountNumber, len, "X"); } if (len < 14) { - return this.maskFirstNCharacters(accountNumber, len - 4, "X"); + return maskFirstNCharacters(accountNumber, len - 4, "X"); } const first = accountNumber.substr(0, 6); const last = accountNumber.substr(7); - const masked = this.maskFirstNCharacters(last, len - 11, "X"); + const masked = maskFirstNCharacters(last, len - 11, "X"); return `${first}${masked}`; }, /** * Checks if something is a string * Stolen from underscore */ - isString(obj) { - return this.isTypeOf(obj, "String"); - }, + isString, /** * Checks if something is a number * Stolen from underscore * @param obj */ - isNumber(obj) { - return this.isTypeOf(obj, "Number"); - }, + isNumber, /** * Checks if something is a certain type * Stolen from underscore */ - isTypeOf(obj, type) { - return Object.prototype.toString.call(obj) === `[object ${type}]`; - }, + isTypeOf, /** * Checks to see if something is undefined * Stolen from underscore */ - isUndefined(obj) { - return obj === void 0; - }, + isUndefined, /** * Replace first N characters of the string with maskChar * eg: maskFirstNCharacters( '1234567890', 6, 'X' ) yields XXXXXX7890 @@ -43996,12 +44033,7 @@ var require_str = __commonJS({ * @param mask String we want replace the first N chars with * @returns Masked string */ - maskFirstNCharacters(str2, num, mask) { - if (!this.isString(str2) || !this.isString(mask) || str2.length === 0 || !this.isNumber(num)) { - return str2; - } - return str2.substring(0, num).replaceAll(/./g, mask) + str2.substring(num); - }, + maskFirstNCharacters, /** * Trim a string */ @@ -44013,7 +44045,7 @@ var require_str = __commonJS({ * @param percentageString The percentage as a string */ percentageStringToNumber(percentageString) { - return Number(this.cutAfter(percentageString, "%")); + return Number(cutAfter(percentageString, "%")); }, /** * Remove all the spaces from a string @@ -44057,7 +44089,7 @@ var require_str = __commonJS({ * Converts a value to boolean, case-insensitive. */ toBool(value) { - if (this.isString(value)) { + if (isString(value)) { return value.toLowerCase() === "true"; } return !!value; @@ -44107,9 +44139,7 @@ var require_str = __commonJS({ /** * Check for whether a phone number is valid according to E.164 standard. */ - isValidE164Phone(phone) { - return Constants.CONST.SMS.E164_REGEX.test(phone); - }, + isValidE164Phone, /** * Check for whether a phone number is valid in different formats/standards. For example: * significant: 4404589784 @@ -44135,14 +44165,12 @@ var require_str = __commonJS({ /** * Returns text without our SMS domain */ - removeSMSDomain(text) { - return text.replaceAll(REMOVE_SMS_DOMAIN_PATTERN, ""); - }, + removeSMSDomain, /** * Returns true if the text is a valid E.164 phone number with our SMS domain removed */ isSMSLogin(text) { - return this.isValidE164Phone(this.removeSMSDomain(text)); + return isValidE164Phone(removeSMSDomain(text)); }, /** * This method will return all matches of a single regex like preg_match_all() in PHP. This is not a common part of @@ -44195,7 +44223,7 @@ var require_str = __commonJS({ return url; } const website = match2[3] ? match2[2] : `${defaultScheme}://${match2[2]}`; - return website.toLowerCase() + this.cutBefore(match2[1], match2[2]); + return website.toLowerCase() + cutBefore(match2[1], match2[2]); }, /** * Checks if parameter is a string or function @@ -44207,14 +44235,7 @@ var require_str = __commonJS({ * Get file extension for a given url with or * without query parameters */ - getExtension(url) { - var _a3, _b; - if (typeof url !== "string") { - Log_1.default.warn("Str.getExtension: url is not a string", { url }); - return void 0; - } - return (_b = (_a3 = url.split(".").pop()) === null || _a3 === void 0 ? void 0 : _a3.split("?")[0]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); - }, + getExtension, /** * Takes in a URL and checks if the file extension is PDF * @@ -44222,7 +44243,7 @@ var require_str = __commonJS({ * @returns Whether file path is PDF or not */ isPDF(url) { - return this.getExtension(url) === "pdf"; + return getExtension(url) === "pdf"; }, /** * Takes in a URL and checks if the file extension is an image @@ -44233,7 +44254,7 @@ var require_str = __commonJS({ * https://reactnative.dev/docs/image#source */ isImage(url) { - const extension = this.getExtension(url); + const extension = getExtension(url); if (!extension) { return false; } @@ -44249,7 +44270,7 @@ var require_str = __commonJS({ * https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_codecs */ isVideo(url) { - const extension = this.getExtension(url); + const extension = getExtension(url); if (!extension) { return false; } @@ -44262,7 +44283,7 @@ var require_str = __commonJS({ * @returns True if is a domain account email, otherwise false. */ isDomainEmail(email) { - return this.startsWith(email, "+@"); + return startsWith(email, "+@"); }, /** * Find the minimum indentation of any line in the string, @@ -45400,13 +45421,17 @@ ${g2} }, { name: "anchor", - regex: /<(a)[^><]*href\s*=\s*(['"])(.*?)\2(?:".*?"|'.*?'|[^'"><])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi, - replacement: (_extras, _match, _g1, _g2, g3, g4) => { - const email = g3.startsWith("mailto:") ? g3.slice(7) : ""; - if (email === g4) { - return email; + regex: /(\]\()?<(a)[^><]*href\s*=\s*(['"])(.*?)\3(?:".*?"|'.*?'|[^'"><])*>([\s\S]*?)<\/\2>(?![^<]*(<\/pre>|<\/code>))/gi, + replacement: (_extras, _match, g1, _g2, _g3, g4, g5) => { + const incompleteLinkStart = g1 !== null && g1 !== void 0 ? g1 : ""; + const email = g4.startsWith("mailto:") ? g4.slice(7) : ""; + if (email === g5) { + return `${incompleteLinkStart}${email}`; + } + if (incompleteLinkStart && (g4 === g5 || g4 === `https://${g5}`)) { + return `${incompleteLinkStart}${g5}`; } - return `[${g4}](${email || g3})`; + return `${incompleteLinkStart}[${g5}](${email || g4})`; } }, { From f596695c9a4421368a5715c6eec5ad3ad65b6956 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 12 Oct 2026 18:11:57 +0530 Subject: [PATCH 06/11] Mock getCategoryTaxDetails in the useTaxAmount test Signed-off-by: krishna2323 --- tests/unit/hooks/useTaxAmount.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/hooks/useTaxAmount.test.ts b/tests/unit/hooks/useTaxAmount.test.ts index 5067e9994a28..30bc3e5d14e2 100644 --- a/tests/unit/hooks/useTaxAmount.test.ts +++ b/tests/unit/hooks/useTaxAmount.test.ts @@ -21,11 +21,14 @@ jest.mock('@libs/DistanceRequestUtils', () => ({ }, })); +let mockCategoryTaxDetails: {categoryTaxCode: string | undefined; categoryTaxAmount: number | undefined; categoryTaxValue: string | undefined}; + jest.mock('@libs/TransactionUtils', () => ({ calculateTaxAmount: (taxPercentage: string, taxableAmount: number) => { const pct = Number.parseFloat(String(taxPercentage).replace('%', '')) || 0; return (taxableAmount * pct) / 100; }, + getCategoryTaxDetails: () => mockCategoryTaxDetails, getDefaultTaxCode: () => 'tax_default', getTaxValue: () => '10%', hasTaxRateWithMatchingValue: () => false, @@ -63,6 +66,7 @@ describe('useTaxAmount', () => { mockConvertToDistanceInMeters.mockImplementation((distance: number) => distance); mockGetCommuterExclusionDisplayData.mockReturnValue(null); mockGetTaxableAmount.mockReturnValue(100); + mockCategoryTaxDetails = {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; }); it('returns the default tax code and value from policy resolution', () => { @@ -155,4 +159,15 @@ describe('useTaxAmount', () => { const {result} = renderHook(() => useTaxAmount(baseParams)); expect(result.current.shouldKeepCurrentTaxSelection).toBe(false); }); + + it('prefers the tax rate from the category rule over the workspace default', () => { + // Given a category whose tax rule resolves to a tax code of its own + mockCategoryTaxDetails = {categoryTaxCode: 'tax_category', categoryTaxAmount: 100, categoryTaxValue: '5%'}; + + // When the hook resolves the default tax code + const {result} = renderHook(() => useTaxAmount(baseParams)); + + // Then the category rule outranks the workspace default, so the confirmation page opens on the rule's rate + expect(result.current.defaultTaxCode).toBe('tax_category'); + }); }); From 54d2665dc453ffb5edb057907277d21cfaf1b3ed Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 19 Oct 2026 03:43:07 +0530 Subject: [PATCH 07/11] Apply the category tax rule consistently for manual and distance expenses Signed-off-by: krishna2323 --- .../hooks/useTaxAmount.ts | 6 ++++- src/libs/TransactionUtils/tax.ts | 17 +++++++++---- tests/unit/TransactionUtilsTest.ts | 24 +++++++++++++++++-- tests/unit/hooks/useTaxAmount.test.ts | 17 ++++++++++++- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts b/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts index 59b3a82964ac..6a412d2c6d61 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts @@ -69,7 +69,11 @@ function useTaxAmount({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const defaultTaxCode = categoryTaxCode || workspaceDefaultTaxCode; const defaultTaxValue = getTaxValue(policy, transaction, defaultTaxCode) ?? null; - const previousDefaultTaxCode = getDefaultTaxCode(policy, transaction, previousTransactionCurrency); + // Resolved the same way as defaultTaxCode, otherwise picking the workspace default while a category rule is active + // reads as "no selection" and the rule's rate is written straight back over it. + const {categoryTaxCode: previousCategoryTaxCode} = getCategoryTaxDetails(transaction?.category ?? '', transaction, policy, getCurrencyDecimals, previousTransactionCurrency); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const previousDefaultTaxCode = previousCategoryTaxCode || getDefaultTaxCode(policy, transaction, previousTransactionCurrency); const shouldKeepCurrentTaxSelection = hasTaxRateWithMatchingValue(policy, transaction) && transaction?.taxCode !== previousDefaultTaxCode; const commuterExclusionData = isDistanceRequest diff --git a/src/libs/TransactionUtils/tax.ts b/src/libs/TransactionUtils/tax.ts index 05c6ae75b96b..a1c3059379ef 100644 --- a/src/libs/TransactionUtils/tax.ts +++ b/src/libs/TransactionUtils/tax.ts @@ -217,15 +217,22 @@ function getTaxRateTitle(policy: OnyxEntry, transaction: OnyxEntry, policy: OnyxEntry, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']) { +function getCategoryTaxDetails( + category: string, + transaction: OnyxEntry, + policy: OnyxEntry, + getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + currency?: string, +) { const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax); const customUnitRateID = isDistanceRequest(transaction) ? (getRateID(transaction) ?? '') : undefined; + const rateTaxCode = customUnitRateID === undefined ? undefined : getDistanceRateCustomUnitRate(policy, customUnitRateID)?.attributes?.taxRateExternalID; // Distance skips the category rule in two cases. Tax tracking being off for distance rates hides the Tax field, so // a value written there would be invisible. A rate carrying its own tax rate wins over the rule, because vehicle - // types coded to the same category can each reclaim at a different rate. + // types coded to the same category can each reclaim at a different rate. A rate pointing at a tax the user can no + // longer pick counts as having none, which matches how getDistanceRateTaxUpdates resolves the same rate. const shouldSkipDistanceTax = - customUnitRateID !== undefined && - (!getDistanceRateCustomUnit(policy)?.attributes?.taxEnabled || !!getDistanceRateCustomUnitRate(policy, customUnitRateID)?.attributes?.taxRateExternalID); + customUnitRateID !== undefined && (!getDistanceRateCustomUnit(policy)?.attributes?.taxEnabled || (!!rateTaxCode && isSelectableTaxCode(policy, rateTaxCode))); if (!taxRules || taxRules?.length === 0 || shouldSkipDistanceTax) { return {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; } @@ -237,7 +244,7 @@ function getCategoryTaxDetails(category: string, transaction: OnyxEntry { describe('distance expenses', () => { const distanceRateID = 'DISTANCE_RATE_ID'; - function buildDistancePolicy(category: string, taxEnabled: boolean, rateTaxRateExternalID?: string): Policy { + function buildDistancePolicy(category: string, taxEnabled: boolean, rateTaxRateExternalID?: string, isRateTaxDisabled = false): Policy { return { ...createRandomPolicy(0), - taxRates: CONST.DEFAULT_TAX, + taxRates: { + ...CONST.DEFAULT_TAX, + taxes: { + ...CONST.DEFAULT_TAX.taxes, + // eslint-disable-next-line @typescript-eslint/naming-convention + id_TAX_RATE_2: {name: 'Tax Rate 2', value: '8%', isDisabled: isRateTaxDisabled}, + }, + }, rules: {expenseRules: createCategoryTaxExpenseRules(category, 'id_TAX_RATE_1')}, customUnits: { DISTANCE_CUSTOM_UNIT: { @@ -476,6 +483,19 @@ describe('TransactionUtils', () => { expect(categoryTaxAmount).toBe(undefined); expect(categoryTaxValue).toBe(undefined); }); + + it('should apply the category rule when the rate points at a tax rate that was disabled', () => { + // Given a distance rate that still points at a tax rate the admin has since disabled + const category = 'Advertising'; + const fakePolicy = buildDistancePolicy(category, true, 'id_TAX_RATE_2', true); + + // When retrieving the tax from the associated category + const {categoryTaxCode, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(category, buildDistanceTransaction(), fakePolicy, getCurrencyDecimalsLocal); + + // Then the rate counts as having no tax rate, so the category rule applies no matter which of the two the user picked first + expect(categoryTaxCode).toBe('id_TAX_RATE_1'); + expect(categoryTaxValue).toBe('5%'); + }); }); describe('should return undefined tax', () => { diff --git a/tests/unit/hooks/useTaxAmount.test.ts b/tests/unit/hooks/useTaxAmount.test.ts index 30bc3e5d14e2..af49974dbbfa 100644 --- a/tests/unit/hooks/useTaxAmount.test.ts +++ b/tests/unit/hooks/useTaxAmount.test.ts @@ -22,6 +22,7 @@ jest.mock('@libs/DistanceRequestUtils', () => ({ })); let mockCategoryTaxDetails: {categoryTaxCode: string | undefined; categoryTaxAmount: number | undefined; categoryTaxValue: string | undefined}; +let mockHasTaxRateWithMatchingValue: boolean; jest.mock('@libs/TransactionUtils', () => ({ calculateTaxAmount: (taxPercentage: string, taxableAmount: number) => { @@ -31,7 +32,7 @@ jest.mock('@libs/TransactionUtils', () => ({ getCategoryTaxDetails: () => mockCategoryTaxDetails, getDefaultTaxCode: () => 'tax_default', getTaxValue: () => '10%', - hasTaxRateWithMatchingValue: () => false, + hasTaxRateWithMatchingValue: () => mockHasTaxRateWithMatchingValue, })); type Params = Parameters[0]; @@ -67,6 +68,7 @@ describe('useTaxAmount', () => { mockGetCommuterExclusionDisplayData.mockReturnValue(null); mockGetTaxableAmount.mockReturnValue(100); mockCategoryTaxDetails = {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; + mockHasTaxRateWithMatchingValue = false; }); it('returns the default tax code and value from policy resolution', () => { @@ -170,4 +172,17 @@ describe('useTaxAmount', () => { // Then the category rule outranks the workspace default, so the confirmation page opens on the rule's rate expect(result.current.defaultTaxCode).toBe('tax_category'); }); + + it('keeps the workspace default when the user picks it over the category rule rate', () => { + // Given a category rule that resolves to its own tax code, and a user who picked the workspace default instead + mockCategoryTaxDetails = {categoryTaxCode: 'tax_category', categoryTaxAmount: 100, categoryTaxValue: '5%'}; + mockHasTaxRateWithMatchingValue = true; + const transaction = {...baseTransaction, taxCode: 'tax_default'}; + + // When the hook decides whether that selection survives + const {result} = renderHook(() => useTaxAmount({...baseParams, transaction})); + + // Then it is kept, because the selection is compared against the rule's rate rather than the workspace default + expect(result.current.shouldKeepCurrentTaxSelection).toBe(true); + }); }); From 1cc0c033af2536902c71df25891fcfed8049107e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 19 Oct 2026 23:21:02 +0530 Subject: [PATCH 08/11] Share the distance rate tax check across the tax helpers Signed-off-by: krishna2323 --- .../hooks/useTaxAmount.ts | 9 +-- src/libs/TransactionUtils/index.ts | 2 + src/libs/TransactionUtils/tax.ts | 75 +++++++++++-------- tests/unit/TransactionUtilsTest.ts | 6 +- tests/unit/hooks/useTaxAmount.test.ts | 10 +-- 5 files changed, 59 insertions(+), 43 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts b/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts index 6a412d2c6d61..823943e134b2 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts @@ -2,7 +2,7 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {calculateTaxAmount, getCategoryTaxDetails, getDefaultTaxCode, getTaxValue, hasTaxRateWithMatchingValue} from '@libs/TransactionUtils'; +import {calculateTaxAmount, getCategoryTaxCode, getDefaultTaxCode, getTaxValue, hasTaxRateWithMatchingValue} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; @@ -64,14 +64,13 @@ function useTaxAmount({ const workspaceDefaultTaxCode = getDefaultTaxCode(policy, transaction) ?? (isMovingTransactionFromTrackExpense ? (getDefaultTaxCode(policyForMovingExpenses, transaction) ?? '') : ''); // A category tax rule outranks the workspace default, so resolve it here as well. Otherwise this default overwrites // the rule's tax rate on the first render of the confirmation page. - const {categoryTaxCode} = getCategoryTaxDetails(transaction?.category ?? '', transaction, policy, getCurrencyDecimals); - // We use || instead of ?? because the category helper can return an empty string, which should also fall back to the workspace default. + // We use || instead of ?? because the category rule can resolve to an empty string. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const defaultTaxCode = categoryTaxCode || workspaceDefaultTaxCode; + const defaultTaxCode = getCategoryTaxCode(transaction?.category ?? '', transaction, policy) || workspaceDefaultTaxCode; const defaultTaxValue = getTaxValue(policy, transaction, defaultTaxCode) ?? null; // Resolved the same way as defaultTaxCode, otherwise picking the workspace default while a category rule is active // reads as "no selection" and the rule's rate is written straight back over it. - const {categoryTaxCode: previousCategoryTaxCode} = getCategoryTaxDetails(transaction?.category ?? '', transaction, policy, getCurrencyDecimals, previousTransactionCurrency); + const previousCategoryTaxCode = getCategoryTaxCode(transaction?.category ?? '', transaction, policy, previousTransactionCurrency); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const previousDefaultTaxCode = previousCategoryTaxCode || getDefaultTaxCode(policy, transaction, previousTransactionCurrency); const shouldKeepCurrentTaxSelection = hasTaxRateWithMatchingValue(policy, transaction) && transaction?.taxCode !== previousDefaultTaxCode; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 9165c2264c68..721c31c6ba5b 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -64,6 +64,7 @@ import {getClearedPendingFields, getDistanceMerchantForTransaction, getUpdatedTr import { calculateTaxAmount, getCalculatedTaxAmount, + getCategoryTaxCode, getCategoryTaxDetails, getDefaultTaxCode, getDistanceRateTaxUpdates, @@ -1953,6 +1954,7 @@ export { getAllSortedTransactions, getFormattedPostedDate, getPostedDate, + getCategoryTaxCode, getCategoryTaxDetails, isPerDiemRequest, isViolationDismissed, diff --git a/src/libs/TransactionUtils/tax.ts b/src/libs/TransactionUtils/tax.ts index a1c3059379ef..f7bf132faa8f 100644 --- a/src/libs/TransactionUtils/tax.ts +++ b/src/libs/TransactionUtils/tax.ts @@ -43,6 +43,15 @@ function getEnabledTaxRateCount(options: TaxRates) { return Object.values(options).filter((option: TaxRate) => !option.isDisabled).length; } +/** + * Whether a distance rate carries a tax rate the user can still pick. Disabling a tax rate leaves the rate pointing at + * it, so a rate the user can no longer pick is treated the same as a rate that never had one. + */ +function hasSelectableRateTaxCode(policy: OnyxEntry, customUnitRateID: string): boolean { + const rateTaxCode = getDistanceRateCustomUnitRate(policy, customUnitRateID)?.attributes?.taxRateExternalID; + return isSelectableTaxCode(policy, rateTaxCode); +} + /** * Gets the tax code based on the type of transaction and selected currency. * If it is distance request, then returns the tax code corresponding to the custom unit rate @@ -55,17 +64,13 @@ function getDefaultTaxCode(policy: OnyxEntry, transaction: OnyxEntry rule.tax) ?? []; - const categoryTaxCode = hasUsableRateTaxCode ? undefined : getCategoryDefaultTaxRate(taxRules, category ?? transaction?.category ?? '', defaultTaxCode); - // We use || instead of ?? because the category rule can resolve to an empty string, which should also fall back to the default tax code. + const categoryTaxCode = hasSelectableRateTaxCode(policy, customUnitRateID) ? undefined : getCategoryDefaultTaxRate(taxRules, category ?? transaction?.category ?? '', defaultTaxCode); + // We use || instead of ?? because the category rule can resolve to an empty string. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const taxCode = categoryTaxCode || defaultTaxCode; const taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, getDistanceInMeters(transaction, distanceUnit ?? transaction?.comment?.customUnit?.distanceUnit)); @@ -217,6 +218,29 @@ function getTaxRateTitle(policy: OnyxEntry, transaction: OnyxEntry, policy: OnyxEntry, currency?: string): string | undefined { + const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax); + if (!taxRules || taxRules.length === 0) { + return undefined; + } + + if (isDistanceRequest(transaction)) { + const customUnitRateID = getRateID(transaction) ?? ''; + // Tax tracking being off for distance rates hides the Tax field, so a value written there would be invisible, + // and a rate carrying its own tax rate outranks the rule. + if (!getDistanceRateCustomUnit(policy)?.attributes?.taxEnabled || hasSelectableRateTaxCode(policy, customUnitRateID)) { + return undefined; + } + return getCategoryDefaultTaxRate(taxRules, category, getDefaultTaxCode(policy, transaction, undefined, customUnitRateID) ?? ''); + } + + return getCategoryDefaultTaxRate(taxRules, category, getDefaultTaxCode(policy, transaction, currency ?? getCurrency(transaction))); +} + function getCategoryTaxDetails( category: string, transaction: OnyxEntry, @@ -224,29 +248,19 @@ function getCategoryTaxDetails( getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], currency?: string, ) { - const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax); - const customUnitRateID = isDistanceRequest(transaction) ? (getRateID(transaction) ?? '') : undefined; - const rateTaxCode = customUnitRateID === undefined ? undefined : getDistanceRateCustomUnitRate(policy, customUnitRateID)?.attributes?.taxRateExternalID; - // Distance skips the category rule in two cases. Tax tracking being off for distance rates hides the Tax field, so - // a value written there would be invisible. A rate carrying its own tax rate wins over the rule, because vehicle - // types coded to the same category can each reclaim at a different rate. A rate pointing at a tax the user can no - // longer pick counts as having none, which matches how getDistanceRateTaxUpdates resolves the same rate. - const shouldSkipDistanceTax = - customUnitRateID !== undefined && (!getDistanceRateCustomUnit(policy)?.attributes?.taxEnabled || (!!rateTaxCode && isSelectableTaxCode(policy, rateTaxCode))); - if (!taxRules || taxRules?.length === 0 || shouldSkipDistanceTax) { + const categoryTaxCode = getCategoryTaxCode(category, transaction, policy, currency); + if (!categoryTaxCode) { return {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; } // Distance tax is charged on the taxable portion of the mileage rate rather than the full amount, so reuse the // rate-aware calculation instead of taxing the expense amount. - if (customUnitRateID !== undefined) { - const {taxCode, taxAmount, taxValue} = getDistanceRateTaxUpdates(policy, transaction, customUnitRateID, getCurrencyDecimals, undefined, category); + if (isDistanceRequest(transaction)) { + const {taxCode, taxAmount, taxValue} = getDistanceRateTaxUpdates(policy, transaction, getRateID(transaction) ?? '', getCurrencyDecimals, undefined, category); return {categoryTaxCode: taxCode, categoryTaxAmount: taxAmount, categoryTaxValue: taxValue}; } - const defaultTaxCode = getDefaultTaxCode(policy, transaction, currency ?? getCurrency(transaction)); - const categoryTaxCode = getCategoryDefaultTaxRate(taxRules, category, defaultTaxCode); - const categoryTaxPercentage = getTaxValue(policy, transaction, categoryTaxCode ?? ''); + const categoryTaxPercentage = getTaxValue(policy, transaction, categoryTaxCode); let categoryTaxAmount; if (categoryTaxPercentage) { @@ -259,6 +273,7 @@ function getCategoryTaxDetails( export { calculateTaxAmount, getCalculatedTaxAmount, + getCategoryTaxCode, getCategoryTaxDetails, getDefaultTaxCode, getDistanceRateTaxUpdates, diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index d2bc00e30ed3..682b31aa7388 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -479,9 +479,9 @@ describe('TransactionUtils', () => { ); // Then the category rule is skipped, because the rate's own tax rate takes precedence over it - expect(categoryTaxCode).toBe(undefined); - expect(categoryTaxAmount).toBe(undefined); - expect(categoryTaxValue).toBe(undefined); + expect(categoryTaxCode).toBeUndefined(); + expect(categoryTaxAmount).toBeUndefined(); + expect(categoryTaxValue).toBeUndefined(); }); it('should apply the category rule when the rate points at a tax rate that was disabled', () => { diff --git a/tests/unit/hooks/useTaxAmount.test.ts b/tests/unit/hooks/useTaxAmount.test.ts index af49974dbbfa..a84bd43f5af9 100644 --- a/tests/unit/hooks/useTaxAmount.test.ts +++ b/tests/unit/hooks/useTaxAmount.test.ts @@ -21,7 +21,7 @@ jest.mock('@libs/DistanceRequestUtils', () => ({ }, })); -let mockCategoryTaxDetails: {categoryTaxCode: string | undefined; categoryTaxAmount: number | undefined; categoryTaxValue: string | undefined}; +let mockCategoryTaxCode: string | undefined; let mockHasTaxRateWithMatchingValue: boolean; jest.mock('@libs/TransactionUtils', () => ({ @@ -29,7 +29,7 @@ jest.mock('@libs/TransactionUtils', () => ({ const pct = Number.parseFloat(String(taxPercentage).replace('%', '')) || 0; return (taxableAmount * pct) / 100; }, - getCategoryTaxDetails: () => mockCategoryTaxDetails, + getCategoryTaxCode: () => mockCategoryTaxCode, getDefaultTaxCode: () => 'tax_default', getTaxValue: () => '10%', hasTaxRateWithMatchingValue: () => mockHasTaxRateWithMatchingValue, @@ -67,7 +67,7 @@ describe('useTaxAmount', () => { mockConvertToDistanceInMeters.mockImplementation((distance: number) => distance); mockGetCommuterExclusionDisplayData.mockReturnValue(null); mockGetTaxableAmount.mockReturnValue(100); - mockCategoryTaxDetails = {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; + mockCategoryTaxCode = undefined; mockHasTaxRateWithMatchingValue = false; }); @@ -164,7 +164,7 @@ describe('useTaxAmount', () => { it('prefers the tax rate from the category rule over the workspace default', () => { // Given a category whose tax rule resolves to a tax code of its own - mockCategoryTaxDetails = {categoryTaxCode: 'tax_category', categoryTaxAmount: 100, categoryTaxValue: '5%'}; + mockCategoryTaxCode = 'tax_category'; // When the hook resolves the default tax code const {result} = renderHook(() => useTaxAmount(baseParams)); @@ -175,7 +175,7 @@ describe('useTaxAmount', () => { it('keeps the workspace default when the user picks it over the category rule rate', () => { // Given a category rule that resolves to its own tax code, and a user who picked the workspace default instead - mockCategoryTaxDetails = {categoryTaxCode: 'tax_category', categoryTaxAmount: 100, categoryTaxValue: '5%'}; + mockCategoryTaxCode = 'tax_category'; mockHasTaxRateWithMatchingValue = true; const transaction = {...baseTransaction, taxCode: 'tax_default'}; From d10ef60d48355e4542acdff1b5807e38e94dfbeb Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 19 Oct 2026 23:39:40 +0530 Subject: [PATCH 09/11] Validate the category tax rate and exclude the commute from distance tax Signed-off-by: krishna2323 --- src/libs/TransactionUtils/tax.ts | 31 +++++++++++++----- tests/unit/TransactionUtilsTest.ts | 52 +++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/libs/TransactionUtils/tax.ts b/src/libs/TransactionUtils/tax.ts index f7bf132faa8f..072c25710e31 100644 --- a/src/libs/TransactionUtils/tax.ts +++ b/src/libs/TransactionUtils/tax.ts @@ -13,7 +13,7 @@ import {getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, isSelectableTa import CONST from '@src/CONST'; import type {Policy, TaxRate, TaxRates, Transaction} from '@src/types/onyx'; -import type {Unit} from '@src/types/onyx/Policy'; +import type {ExpenseRule, Unit} from '@src/types/onyx/Policy'; import type {OnyxEntry} from 'react-native-onyx'; @@ -52,6 +52,15 @@ function hasSelectableRateTaxCode(policy: OnyxEntry, customUnitRateID: s return isSelectableTaxCode(policy, rateTaxCode); } +/** + * The tax rate a category rule asks for, or the given default when no rule applies. Disabling a tax rate leaves the rule + * pointing at it, so a rule the user can no longer pick also falls back rather than saving a rate the expense cannot carry. + */ +function getSelectableCategoryTaxCode(policy: OnyxEntry, taxRules: ExpenseRule[], category: string, defaultTaxCode: string | undefined): string | undefined { + const categoryTaxCode = getCategoryDefaultTaxRate(taxRules, category, defaultTaxCode); + return categoryTaxCode && isSelectableTaxCode(policy, categoryTaxCode) ? categoryTaxCode : defaultTaxCode; +} + /** * Gets the tax code based on the type of transaction and selected currency. * If it is distance request, then returns the tax code corresponding to the custom unit rate @@ -122,11 +131,17 @@ function getDistanceRateTaxUpdates( // A rate's own tax rate wins over a category rule, because vehicle types coded to the same category can each // reclaim at a different rate. const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax) ?? []; - const categoryTaxCode = hasSelectableRateTaxCode(policy, customUnitRateID) ? undefined : getCategoryDefaultTaxRate(taxRules, category ?? transaction?.category ?? '', defaultTaxCode); - // We use || instead of ?? because the category rule can resolve to an empty string. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const taxCode = categoryTaxCode || defaultTaxCode; - const taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, getDistanceInMeters(transaction, distanceUnit ?? transaction?.comment?.customUnit?.distanceUnit)); + const categoryTaxCode = hasSelectableRateTaxCode(policy, customUnitRateID) + ? undefined + : getSelectableCategoryTaxCode(policy, taxRules, category ?? transaction?.category ?? '', defaultTaxCode); + const taxCode = categoryTaxCode ?? defaultTaxCode; + const unit = distanceUnit ?? transaction?.comment?.customUnit?.distanceUnit; + // A commute the workspace excludes from reimbursement is not reclaimable either, so only the rest of the trip is taxed. + const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionDisplayData(transaction?.comment?.customUnit, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + const taxableDistance = commuterExclusionData + ? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, commuterExclusionData.distanceUnit) + : getDistanceInMeters(transaction, unit); + const taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, taxableDistance); const taxValue = taxCode ? getTaxValue(policy, transaction, taxCode) : undefined; const mileageRates = DistanceRequestUtils.getMileageRates(policy); const rateCurrency = mileageRates[customUnitRateID]?.currency ?? transaction?.currency ?? CONST.CURRENCY.USD; @@ -235,10 +250,10 @@ function getCategoryTaxCode(category: string, transaction: OnyxEntry { expect(categoryTaxCode).toBe('id_TAX_RATE_1'); expect(categoryTaxValue).toBe('5%'); }); + + it('should fall back to the workspace default when the category rule points at a disabled tax rate', () => { + // Given a category rule pointing at a tax rate the admin has since disabled, on a rate with no tax rate of its own + const category = 'Advertising'; + const basePolicy = buildDistancePolicy(category, true); + const fakePolicy: Policy = { + ...basePolicy, + taxRates: { + ...basePolicy.taxRates, + taxes: { + ...basePolicy.taxRates?.taxes, + // eslint-disable-next-line @typescript-eslint/naming-convention + id_TAX_RATE_1: {name: 'Tax Rate 1', value: '5%', isDisabled: true}, + }, + }, + }; + + // When retrieving the tax from the associated category + const {categoryTaxCode, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(category, buildDistanceTransaction(), fakePolicy, getCurrencyDecimalsLocal); + + // Then the rule is skipped, because the expense cannot carry a tax rate the user is no longer offered + expect(categoryTaxCode).toBe('id_TAX_EXEMPT'); + expect(categoryTaxValue).toBe('0%'); + }); }); describe('should return undefined tax', () => { @@ -610,6 +634,32 @@ describe('TransactionUtils', () => { expect(TransactionUtils.getDefaultTaxCode(policy, distanceTransaction)).toBeUndefined(); }); + it('taxes only the reimbursable distance when a commuter exclusion applies', () => { + // Given a 30 mile trip on a taxed rate, where the workspace excludes 10 miles as a commute + const policy = buildDistancePolicy({rateTaxCode: 'id_TAX_RATE_1'}); + const buildTrip = (customUnit: TransactionCustomUnit): Transaction => ({ + ...distanceTransaction, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, customUnitRateID: RATE_ID, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, ...customUnit}, + }, + }); + + // When computing the tax for the full trip, the excluded trip, and a trip of only the reimbursable part + const fullTrip = TransactionUtils.getDistanceRateTaxUpdates(policy, buildTrip({quantity: 30}), RATE_ID, getCurrencyDecimalsLocal); + const excludedTrip = TransactionUtils.getDistanceRateTaxUpdates( + policy, + buildTrip({quantity: 30, commuterExclusion: 10, reimbursableDistance: 20}), + RATE_ID, + getCurrencyDecimalsLocal, + ); + const shortTrip = TransactionUtils.getDistanceRateTaxUpdates(policy, buildTrip({quantity: 20}), RATE_ID, getCurrencyDecimalsLocal); + + // Then the commute is left out of the tax, so the expense is taxed as if it were only the reimbursable 20 miles + expect(excludedTrip.taxAmount).toBe(shortTrip.taxAmount); + expect(excludedTrip.taxAmount).toBeLessThan(fullTrip.taxAmount); + }); + it('makes getDistanceRateTaxUpdates drop the disabled tax rate', () => { // Given a distance rate that still points at a disabled tax rate const policy = buildDistancePolicy({rateTaxCode: 'id_TAX_RATE_1', isRateTaxDisabled: true}); From 7b0df50e583f179eb966d1263f39889427d67ee0 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 19 Oct 2026 23:46:04 +0530 Subject: [PATCH 10/11] fix failing tests. Signed-off-by: krishna2323 --- tests/unit/TransactionUtilsTest.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index bad5a0e1854c..d23ea37c9a47 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -500,13 +500,12 @@ describe('TransactionUtils', () => { it('should fall back to the workspace default when the category rule points at a disabled tax rate', () => { // Given a category rule pointing at a tax rate the admin has since disabled, on a rate with no tax rate of its own const category = 'Advertising'; - const basePolicy = buildDistancePolicy(category, true); const fakePolicy: Policy = { - ...basePolicy, + ...buildDistancePolicy(category, true), taxRates: { - ...basePolicy.taxRates, + ...CONST.DEFAULT_TAX, taxes: { - ...basePolicy.taxRates?.taxes, + ...CONST.DEFAULT_TAX.taxes, // eslint-disable-next-line @typescript-eslint/naming-convention id_TAX_RATE_1: {name: 'Tax Rate 1', value: '5%', isDisabled: true}, }, From 724dd8e29458de7fdaa626db03335cc240dc68a4 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 26 Oct 2026 19:30:36 +0530 Subject: [PATCH 11/11] fix: follow the currency change when a category tax rule is active Signed-off-by: krishna2323 --- .../hooks/useTaxAmount.ts | 17 ++--- src/libs/TransactionUtils/tax.ts | 64 ++++++++++++------- tests/unit/hooks/useTaxAmount.test.ts | 15 ++++- 3 files changed, 63 insertions(+), 33 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts b/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts index 823943e134b2..8565584eaecd 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts @@ -64,15 +64,16 @@ function useTaxAmount({ const workspaceDefaultTaxCode = getDefaultTaxCode(policy, transaction) ?? (isMovingTransactionFromTrackExpense ? (getDefaultTaxCode(policyForMovingExpenses, transaction) ?? '') : ''); // A category tax rule outranks the workspace default, so resolve it here as well. Otherwise this default overwrites // the rule's tax rate on the first render of the confirmation page. - // We use || instead of ?? because the category rule can resolve to an empty string. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const defaultTaxCode = getCategoryTaxCode(transaction?.category ?? '', transaction, policy) || workspaceDefaultTaxCode; + const defaultTaxCode = getCategoryTaxCode(transaction?.category ?? '', transaction, policy) ?? workspaceDefaultTaxCode; const defaultTaxValue = getTaxValue(policy, transaction, defaultTaxCode) ?? null; - // Resolved the same way as defaultTaxCode, otherwise picking the workspace default while a category rule is active - // reads as "no selection" and the rule's rate is written straight back over it. - const previousCategoryTaxCode = getCategoryTaxCode(transaction?.category ?? '', transaction, policy, previousTransactionCurrency); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const previousDefaultTaxCode = previousCategoryTaxCode || getDefaultTaxCode(policy, transaction, previousTransactionCurrency); + const previousWorkspaceDefaultTaxCode = getDefaultTaxCode(policy, transaction, previousTransactionCurrency); + // Only the workspace default follows the currency, so after a currency change the selection is compared against that + // default. The rest of the time it is compared against the category rule's rate, otherwise picking the workspace + // default while a rule is active reads as "no selection" and the rule's rate is written straight back over it. + const previousDefaultTaxCode = + transaction?.currency === previousTransactionCurrency + ? (getCategoryTaxCode(transaction?.category ?? '', transaction, policy, previousTransactionCurrency) ?? previousWorkspaceDefaultTaxCode) + : previousWorkspaceDefaultTaxCode; const shouldKeepCurrentTaxSelection = hasTaxRateWithMatchingValue(policy, transaction) && transaction?.taxCode !== previousDefaultTaxCode; const commuterExclusionData = isDistanceRequest diff --git a/src/libs/TransactionUtils/tax.ts b/src/libs/TransactionUtils/tax.ts index 54a929ac5a87..c1ceea80bc66 100644 --- a/src/libs/TransactionUtils/tax.ts +++ b/src/libs/TransactionUtils/tax.ts @@ -61,6 +61,23 @@ function getSelectableCategoryTaxCode(policy: OnyxEntry, taxRules: Expen return categoryTaxCode && isSelectableTaxCode(policy, categoryTaxCode) ? categoryTaxCode : defaultTaxCode; } +function getTaxExpenseRules(policy: OnyxEntry): ExpenseRule[] { + return policy?.rules?.expenseRules?.filter((rule) => rule.tax) ?? []; +} + +/** + * The tax rate a category rule applies to a distance expense, or undefined when no rule can apply. + */ +function getDistanceCategoryTaxCode(policy: OnyxEntry, customUnitRateID: string, category: string, defaultTaxCode: string | undefined): string | undefined { + const taxRules = getTaxExpenseRules(policy); + // Tax tracking being off for distance rates hides the Tax field, so a value written there would be invisible, and a + // rate carrying its own tax rate outranks the rule. + if (taxRules.length === 0 || !getDistanceRateCustomUnit(policy)?.attributes?.taxEnabled || hasSelectableRateTaxCode(policy, customUnitRateID)) { + return undefined; + } + return getSelectableCategoryTaxCode(policy, taxRules, category, defaultTaxCode); +} + /** * Gets the tax code based on the type of transaction and selected currency. * If it is distance request, then returns the tax code corresponding to the custom unit rate @@ -126,14 +143,11 @@ function getDistanceRateTaxUpdates( getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], distanceUnit?: Unit, category?: string, -): {taxAmount: number; taxCode: string; taxValue: string | undefined} { +): {taxAmount: number; taxCode: string; taxValue: string | undefined; categoryTaxCode: string | undefined} { const defaultTaxCode = getDefaultTaxCode(policy, transaction, undefined, customUnitRateID) ?? ''; // A rate's own tax rate wins over a category rule, because vehicle types coded to the same category can each // reclaim at a different rate. - const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax) ?? []; - const categoryTaxCode = hasSelectableRateTaxCode(policy, customUnitRateID) - ? undefined - : getSelectableCategoryTaxCode(policy, taxRules, category ?? transaction?.category ?? '', defaultTaxCode); + const categoryTaxCode = getDistanceCategoryTaxCode(policy, customUnitRateID, category ?? transaction?.category ?? '', defaultTaxCode); const taxCode = categoryTaxCode ?? defaultTaxCode; const unit = distanceUnit ?? transaction?.comment?.customUnit?.distanceUnit; // A commute the workspace excludes from reimbursement is not reclaimable either, so only the rest of the trip is taxed. @@ -147,7 +161,7 @@ function getDistanceRateTaxUpdates( const rateCurrency = mileageRates[customUnitRateID]?.currency ?? transaction?.currency ?? CONST.CURRENCY.USD; const taxAmount = convertToBackendAmount(calculateTaxAmount(taxValue, taxableAmount, getCurrencyDecimals(rateCurrency))); - return {taxAmount, taxCode, taxValue}; + return {taxAmount, taxCode, taxValue, categoryTaxCode}; } /** @@ -237,19 +251,14 @@ function getTaxRateTitle(policy: OnyxEntry, transaction: OnyxEntry, policy: OnyxEntry, currency?: string): string | undefined { - const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax); - if (!taxRules || taxRules.length === 0) { - return undefined; + if (isDistanceRequest(transaction)) { + const customUnitRateID = getRateID(transaction); + return getDistanceCategoryTaxCode(policy, customUnitRateID, category, getDefaultTaxCode(policy, transaction, undefined, customUnitRateID)); } - if (isDistanceRequest(transaction)) { - const customUnitRateID = getRateID(transaction) ?? ''; - // Tax tracking being off for distance rates hides the Tax field, so a value written there would be invisible, - // and a rate carrying its own tax rate outranks the rule. - if (!getDistanceRateCustomUnit(policy)?.attributes?.taxEnabled || hasSelectableRateTaxCode(policy, customUnitRateID)) { - return undefined; - } - return getSelectableCategoryTaxCode(policy, taxRules, category, getDefaultTaxCode(policy, transaction, undefined, customUnitRateID) ?? ''); + const taxRules = getTaxExpenseRules(policy); + if (taxRules.length === 0) { + return undefined; } return getSelectableCategoryTaxCode(policy, taxRules, category, getDefaultTaxCode(policy, transaction, currency ?? getCurrency(transaction))); @@ -262,16 +271,23 @@ function getCategoryTaxDetails( getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], currency?: string, ) { - const categoryTaxCode = getCategoryTaxCode(category, transaction, policy, currency); - if (!categoryTaxCode) { - return {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; - } - // Distance tax is charged on the taxable portion of the mileage rate rather than the full amount, so reuse the // rate-aware calculation instead of taxing the expense amount. if (isDistanceRequest(transaction)) { - const {taxCode, taxAmount, taxValue} = getDistanceRateTaxUpdates(policy, transaction, getRateID(transaction) ?? '', getCurrencyDecimals, undefined, category); - return {categoryTaxCode: taxCode, categoryTaxAmount: taxAmount, categoryTaxValue: taxValue}; + const { + categoryTaxCode: distanceCategoryTaxCode, + taxAmount, + taxValue, + } = getDistanceRateTaxUpdates(policy, transaction, getRateID(transaction), getCurrencyDecimals, undefined, category); + if (!distanceCategoryTaxCode) { + return {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; + } + return {categoryTaxCode: distanceCategoryTaxCode, categoryTaxAmount: taxAmount, categoryTaxValue: taxValue}; + } + + const categoryTaxCode = getCategoryTaxCode(category, transaction, policy, currency); + if (!categoryTaxCode) { + return {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; } const categoryTaxPercentage = getTaxValue(policy, transaction, categoryTaxCode); diff --git a/tests/unit/hooks/useTaxAmount.test.ts b/tests/unit/hooks/useTaxAmount.test.ts index a84bd43f5af9..bc00956c831c 100644 --- a/tests/unit/hooks/useTaxAmount.test.ts +++ b/tests/unit/hooks/useTaxAmount.test.ts @@ -30,7 +30,7 @@ jest.mock('@libs/TransactionUtils', () => ({ return (taxableAmount * pct) / 100; }, getCategoryTaxCode: () => mockCategoryTaxCode, - getDefaultTaxCode: () => 'tax_default', + getDefaultTaxCode: (_policy: unknown, transaction: {currency?: string} | undefined, currency?: string) => ((currency ?? transaction?.currency) === 'EUR' ? 'tax_foreign' : 'tax_default'), getTaxValue: () => '10%', hasTaxRateWithMatchingValue: () => mockHasTaxRateWithMatchingValue, })); @@ -173,6 +173,19 @@ describe('useTaxAmount', () => { expect(result.current.defaultTaxCode).toBe('tax_category'); }); + it('resolves the tax again when the currency changes and the user had picked the old default', () => { + // Given a category rule, and a user who picked the workspace default before switching to a foreign currency + mockCategoryTaxCode = 'tax_category'; + mockHasTaxRateWithMatchingValue = true; + const transaction = {...baseTransaction, currency: 'EUR', taxCode: 'tax_default'}; + + // When the hook decides whether that selection survives the currency change + const {result} = renderHook(() => useTaxAmount({...baseParams, transaction, previousTransactionCurrency: 'USD'})); + + // Then it does not, because the workspace default follows the currency, so the rate is resolved from scratch + expect(result.current.shouldKeepCurrentTaxSelection).toBe(false); + }); + it('keeps the workspace default when the user picks it over the category rule rate', () => { // Given a category rule that resolves to its own tax code, and a user who picked the workspace default instead mockCategoryTaxCode = 'tax_category';