Skip to content

[$175] UK fuel-band rates in the App's types, optimistic copy and lightning bolt check #104135

Open

Description

@neil-marcellini

(Neil's AI agent)

Part of the UK Advisory Fuel Rates tracking issue

Depends on: nothing
Design: Workspace distance rates, UK Government Mileage rates

Problem

buildOnyxDataForGovernmentRateAutoUpdate copies every reference rate that matches the currency, and drops taxClaimablePercentage. It would add AMAP plus all 8 bands, without VAT.

isGovernmentRateUnmodified ignores the reclaimable amount, so an edited fuel-band rate keeps its lightning bolt.

Solution

  • Add taxClaimablePercentage to the GovernmentMileageRate and GovernmentRateSnapshot types.
  • In the builder, match Expensify/Expensify#692142:
    • Pick fuel-band or AMAP rates by tax tracking and distance tax.
    • Copy taxClaimablePercentage to the rate and the snapshot.
    • Copy enabled from the previous rate in the series (ordered by the snapshot's startDate), for every series, not only fuel bands, so an admin who disables a government rate opts out of it going forward.
      • Only when that previous rate is an unmodified government rate: amount, reclaimable amount (if the snapshot has taxClaimablePercentage), start and end dates all match its snapshot. enabled itself isn't part of this check.
      • If the latest previous rate was edited, or there is none, use true. Don't look further back.
    • Leave taxRateExternalID to the server response.
  • isGovernmentRateUnmodified also compares the reclaimable amount within tolerance.
  • Tests: extend DistanceRateTest and PolicyDistanceRatesUtilsTest.
Issue OwnerCurrent Issue Owner: @thelullabyy
Upwork Automation - Do Not Edit

Activity

  1. added
    InternalRequires API changes or must be handled by Expensify staff
    on Oct 15, 2026
  2. self-assigned this
    on Oct 16, 2026
  3. thelullabyy commented on Oct 16, 2026

    @thelullabyy
    Contributor

    @MelvinBot could you please post a proposal?

  4. MelvinBot commented on Oct 16, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    The App assumes each country publishes one rate series with no VAT data.

    What changes do you think we should make in order to solve the problem?

    1. Types. Add optional taxClaimablePercentage to GovernmentMileageRate and GovernmentRateSnapshot.
    2. Series helper. In PolicyDistanceRatesUtils, add getGovernmentRateSeries(sourceRateID). It returns the sourceRateID without the trailing _<startDate>, for example GB for AMAP or GB_<band> for a fuel band. A rate is a fuel-band rate when its series differs from its country prefix.
    3. Pick the right rates. Add an isTaxTrackingEnabled argument to buildOnyxDataForGovernmentRateAutoUpdate. The workspace is eligible when that flag and customUnit.attributes.taxEnabled are both true. In the loop, skip fuel-band rates when not eligible and AMAP rates when eligible. Other countries have only one series, so they behave as before.
    4. Copy the new fields onto each optimistic rate:
      • taxClaimablePercentage on attributes and on the governmentRate snapshot, when present.
      • enabled from the workspace rate with the same series and the latest earlier startDate. Fall back to governmentMileageRate.enabled ?? true.
      • Leave taxRateExternalID unset; the server response fills it in.
    5. Pass eligibility from callers. Pass !!policy?.tax?.trackingEnabled from setWorkspaceDistanceAutoUpdate and updateGeneralSettings. Its three page callers (settings, country, upgrade) already have policy.
    6. Lightning bolt check. In isGovernmentRateUnmodified, also require |rate.rate × rate pct − governmentRateAmount × snapshot pct| < GOVERNMENT_RATE_MATCH_TOLERANCE. Treat a missing percentage as 0. governmentRateAmount is already unit-converted, and the percentage is a ratio, so the unit conversion still holds. Rates without VAT data compare 0 to 0 and behave as before.
    7. Tests (Given/When/Then):
      • PolicyDistanceRatesUtilsTest: an edited reclaimable amount is modified; a matching one is unmodified; an amount within tolerance is unmodified; no percentage on either side is unmodified.
      • DistanceRateTest: eligible GBP workspace gets only bands, with taxClaimablePercentage on the rate and the snapshot. Non-eligible gets only AMAP. A disabled previous band stays disabled. US and EUR are unchanged.

    What alternative solutions did you explore? (Optional)

    Detecting fuel bands by whether taxClaimablePercentage is set. That is simpler, but the series ID matches how the server finds successors, and it still works if a band's percentage is ever 0.

    Open question

    The fuel rate list starts empty until launch. If an eligible GBP workspace turns on auto-update before then, this proposal copies nothing optimistically, matching the rule "bands when eligible, otherwise AMAP." If the server falls back to AMAP when no bands exist, step 3 should do the same. Please confirm the intended server behavior.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/104135) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.

  5. thelullabyy commented on Oct 18, 2026

    @thelullabyy
    Contributor

    @MelvinBot thanks, the root cause and overall approach look good. Please update the proposal with the following:

    1. Lightning bolt tolerance. PolicyDistanceRateTaxReclaimableEditPage displays the reclaimable amount with toFixed(CONST.MAX_TAX_RATE_DECIMAL_PLACES) (4 dp) and saves pct = value * 100 / rate, so restoring the displayed value can be off by up to 0.005 cents, more than GOVERNMENT_RATE_MATCH_TOLERANCE (0.001). Example: a 14p band with 1/6 VAT gives 2.3333p, displayed as 0.0233, so re-entering it gives 2.33p and the bolt never comes back. Please compare at display precision (or use a suitable separate tolerance) and add a test for this restore case.
    2. Missing percentage treated as 0. This changes behavior for non-UK government rates: a US/EUR rate where the admin set a reclaimable amount would lose its bolt even though the snapshot has no percentage. Please either compare only when the snapshot has a percentage, or explain why this matches how the server decides a rate is unmodified.
    3. Reuse the existing eligibility helper. isTaxTrackingEnabled in src/libs/PolicyUtils/tax.ts already combines policy.tax.trackingEnabled with the distance custom unit's taxEnabled. Please reuse it instead of re-deriving the rule, and avoid naming the new parameter isTaxTrackingEnabled (e.g. shouldUseFuelBandRates).
    4. Function signature. buildOnyxDataForGovernmentRateAutoUpdate / setWorkspaceDistanceAutoUpdate already take 8 positional params. Rather than appending a 9th boolean, consider passing policy (all callers have it) or an options object.
    5. Series ID and enabled inheritance. Please confirm the band sourceRateID format against Expensify/Expensify#692142, and strip the date with lastIndexOf('_') so a band name containing _ still works. Also clarify how enabled should be inherited when one request copies several rates of the same series (e.g. two years of a band on first enable): from the workspace only, or also from an earlier rate in the same batch?
    6. Tests. Also cover a band rate whose snapshot has no percentage, and the updateGeneralSettings path (changing currency to GBP on an eligible workspace picks bands only).
  6. MelvinBot commented on Oct 18, 2026

    @MelvinBot
    Contributor

    Proposal (updated)

    Updated for thelullabyy's review. Changes by point:

    1. Tolerance: the lightning bolt now compares the reclaimable amount at the edit page's display precision (4 dp), so restoring the shown value brings the bolt back.
    2. Missing percentage: the reclaimable amount is compared only when the snapshot has a percentage. US/EUR rates behave exactly as today.
    3. Eligibility: uses isTaxTrackingEnabled(true, policy, true). The local is named shouldUseFuelBandRates.
    4. Signature: both functions take policy instead of policyID + customUnit. That's 7 params, down from 8.
    5. Series and enabled: band IDs are confirmed as GB_<fuelBand>_<startDate>. The series strips the date with lastIndexOf('_'). enabled chains through earlier rates in the same batch.
    6. Tests: added the cases you asked for. A GBP currency change doesn't run the optimistic copy today; details are in step 7.

    What is the root cause of that problem?

    The App assumes each country publishes one rate series with no VAT data.

    What changes do you think we should make in order to solve the problem?

    1. Types. Add optional taxClaimablePercentage to GovernmentMileageRate and GovernmentRateSnapshot.
    2. Series helper. Add getGovernmentRateSeries(sourceRateID) to PolicyDistanceRatesUtils. It returns sourceRateID.slice(0, sourceRateID.lastIndexOf('_')), or the whole ID if there is no _.
      • Band IDs are GB_<fuelBand>_<startDate>, for example GB_PETROL_UP_TO_1400_2026-12-01 → GB_PETROL_UP_TO_1400. The band name itself contains _, so only the last _ marks the date.
      • AMAP stays GB_<startDate> → GB.
      • A rate is a fuel-band rate when its series differs from its country (split('_').at(0), which still returns GB).
    3. Signature. In buildOnyxDataForGovernmentRateAutoUpdate and setWorkspaceDistanceAutoUpdate, replace policyID and customUnit with policy. Derive policy.id and getDistanceRateCustomUnit(policy) inside. Every caller already gets its unit that way (settings, country, upgrade), and updateGeneralSettings has policy.
    4. Pick the right rates. In the builder, set const shouldUseFuelBandRates = isTaxTrackingEnabled(true, policy, true) (tax.ts). Skip band rates when it is false and AMAP rates when it is true. Other countries have one series, so nothing changes for them. While the production fuel list is empty, an eligible GBP workspace gets no optimistic rates. This matches the server rule (bands when eligible, otherwise AMAP).
    5. Copy the new fields onto each optimistic rate:
      • taxClaimablePercentage goes on attributes and on the governmentRate snapshot, only when the reference rate has it.
      • enabled: sort the candidates by startDate. Seed a per-series map with the enabled of the workspace's latest rate in each series. Each copied rate takes the map value, falling back to governmentMileageRate.enabled ?? true, then writes its own value back. If one request copies two years of a band, the second year inherits from the first. A batch rate always copies its predecessor, so this gives the same result as a workspace-only lookup. It just follows "previous rate in the series" directly.
      • taxRateExternalID stays unset; the server response fills it in.
    6. Lightning bolt. Add getTaxClaimableDisplayAmount(taxClaimablePercentage, rate), which returns ((taxClaimablePercentage * rate) / 100).toFixed(CONST.MAX_TAX_RATE_DECIMAL_PLACES). Use it on the edit page as well. In isGovernmentRateUnmodified, only when governmentRate.taxClaimablePercentage is defined, also require that the display amount of the rate's own percentage (missing = 0) matches the display amount of governmentRateAmount × the snapshot percentage.
      • The edit page saves value * 100 / rate. Re-entering the shown value therefore rounds back to the same string, and the bolt returns.
      • A snapshot with no percentage skips the check. A US/EUR rate where an admin added a reclaimable amount keeps its bolt, as today.
    7. Currency change to GBP. This path never runs the optimistic copy. WorkspaceOverviewCurrencyPage passes no country for single-country currencies. updateGeneralSettings builds rates only when governmentRateCountry is set, which only the EUR country picker does. For GBP, the server copies the bands and the response brings them in. Making GBP optimistic would be a separate behavior change, so this proposal leaves it as is.
    8. Tests (Given/When/Then):
      • PolicyDistanceRatesUtilsTest:
        • getGovernmentRateSeries handles GB_2026-04-06, GB_PETROL_UP_TO_1400_2026-12-01, and an ID with no _.
        • isGovernmentRateUnmodified treats an edited reclaimable amount as modified and a matching one as unmodified.
        • Restore case: a 14p rate with a 1/6 snapshot shows 0.0233. Saving 0.0233 is unmodified.
        • A band snapshot with no percentage is unmodified, even when the rate has one.
        • A US snapshot with no percentage plus an admin-set reclaimable amount is unmodified.
      • DistanceRateTest (setWorkspaceDistanceAutoUpdate):
        • An eligible GBP workspace gets only bands, with taxClaimablePercentage on the rate and the snapshot.
        • Tax tracking on but distance tax off gives only AMAP.
        • A band reference rate with no percentage is copied without the field.
        • A disabled previous band stays disabled. Two years of one band in one request both inherit it.
        • US and EUR are unchanged.
      • PolicyTest (updateGeneralSettings):
        • On an eligible workspace, changing the currency to GBP adds no optimistic rates and sends no optimisticRateIDs.
        • A call with governmentRateCountry: 'GB' on an eligible workspace copies bands only. This covers passing policy through that caller.

    What alternative solutions did you explore? (Optional)

    • Detect fuel bands by whether taxClaimablePercentage is set. That is simpler, but the series ID matches how the server finds successors, and it still works if a band's percentage is ever 0.
    • Use a separate tolerance of half a display unit (0.005 cents). That works too, but display-precision comparison means "unmodified" whenever the edit page shows the government value. It also needs no new constant.
    Notes for the reviewer
    • Server parity. The server's planned unmodified check compares the reclaimable amount within GOVERNMENT_RATE_MATCH_TOLERANCE. With step 6, a restored value can show the bolt while the server still treats the rate as edited and stops back-filling it. The server check should round the same way. That needs an internal decision outside this issue.
    • The 1/6 VAT example. Under the server design, a band's rate is the AMAP pence, and taxClaimablePercentage is the band's fuel pence ÷ the AMAP pence. The shown reclaimable amount is therefore the whole-pence fuel rate (for example, 14p shows 0.1400), and VAT comes from the linked tax rate. Reference values should restore exactly. Display-precision comparison still matters for any value an admin types.

    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/104135) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.


    view run

  7. thelullabyy commented on Oct 18, 2026

    @thelullabyy
    Contributor

    @MelvinBot Implement

  8. MelvinBot commented on Oct 18, 2026

    @MelvinBot
    Contributor

    🤖 Draft PR created: Copy UK fuel-band rates with their reclaimable fraction and check it for the lightning bolt
    Please mark it as "Ready for review" or ask me to do that for you when it is ready for review.

    It implements the updated proposal. Two details differ from the proposal:

    • The bands-or-standard-rate filter applies only to GB rates, using a new CONST.CUSTOM_UNITS.GOVERNMENT_RATE_FUEL_BAND_COUNTRY. Without that limit, a US workspace that tracks distance tax would get no rates.
    • To keep React Compiler working after the edit page started using the shared getTaxClaimableDisplayAmount helper, I removed its useCallback.

    Typecheck, lint, spelling, the React Compiler check, and 798 related Jest tests pass. The full Jest suite and the Storybook smoke test weren't run locally.


    view run

  9. changed the title [-]UK fuel-band rates in the App's types, optimistic copy and lightning bolt check[/-] [+][$175] UK fuel-band rates in the App's types, optimistic copy and lightning bolt check[/+] on Oct 28, 2026
  10. melvin-bot commented on Oct 28, 2026

    @melvin-bot
  11. kula-star commented on Oct 30, 2026

    @kula-star

    Proposal

    What is the root cause of that problem?

    The App currently assumes each country publishes a single, monolithic rate series with no VAT data:

    1. Filtering & Layout: The optimistic copy loop inside buildOnyxDataForGovernmentRateAutoUpdate filters strictly by currency, country, and sourceRateID. For a GBP-based workspace, it copies both the AMAP standard rate and all 8 fuel band variants simultaneously, completely disregarding tax tracking eligibility.
    2. Type Attrition: The GovernmentMileageRate and GovernmentRateSnapshot types entirely lack a taxClaimablePercentage property, causing this data to be stripped out on the client side.
    3. Modification Checks: isGovernmentRateUnmodified checks only baseline amount thresholds and structural dates. Altering a specific band's reclaimable amount fails to clear the lightning bolt indicator because the calculation doesn't inspect the reclaimable subset variations.

    What changes do you think we should make in order to solve the problem?

    1. Types Adaptation
      Add an optional taxClaimablePercentage?: number property to GovernmentMileageRate and GovernmentRateSnapshot schemas within src/types/onyx/Policy.ts.

    2. Series Isolation Helper
      Implement a getGovernmentRateSeries(sourceRateID: string) helper inside src/libs/PolicyDistanceRatesUtils.ts:

      return sourceRateID.slice(0, sourceRateID.lastIndexOf('_'));

      This accurately strips the date suffix (e.g., GB_PETROL_UP_TO_1400_2026-12-01 → GB_PETROL_UP_TO_1400), safely preserving inner underscores for complex series IDs.

    3. Signature Refactoring & Eligibility Checks

      • Update the signatures of buildOnyxDataForGovernmentRateAutoUpdate and setWorkspaceDistanceAutoUpdate to accept the full policy object instead of separate positional IDs, cleaning up parameters.
      • Determine eligibility by deriving const shouldUseFuelBandRates = isTaxTrackingEnabled(true, policy, true).
      • In the copy loop, skip fuel band rates when shouldUseFuelBandRates is false, and skip standard AMAP rates when it is true (enforcing this rule exclusively for CONST.CUSTOM_UNITS.GOVERNMENT_RATE_FUEL_BAND_COUNTRY i.e. 'GB').
    4. Fields Inheritance Mapping

      • Map taxClaimablePercentage onto attributes and snapshots if present.
      • Inherit the enabled configuration sequentially: sort candidate rates by startDate and seed a tracking map with the workspace's last active rate configuration per series. Ensure chained rates in a multi-year batch pass along the explicit user-toggled status.
    5. Precision Strings & Lightning Bolt Integrity

      • Introduce a shared helper getTaxClaimableDisplayAmount(taxClaimablePercentage, rate) returning calculations locked to .toFixed(CONST.MAX_TAX_RATE_DECIMAL_PLACES) (4 decimal places) to bypass floating-point drift.
      • Update src/pages/workspace/distance/PolicyDistanceRateTaxReclaimableEditPage.tsx to handle display transformations via this shared precision manager.
      • In isGovernmentRateUnmodified, check if governmentRate.taxClaimablePercentage is defined. If so, validate that the local rate's display precision string aligns exactly with the target snapshot calculation.
    6. Automated Unit Testing

      • PolicyDistanceRatesUtilsTest: Validate string slicing limits inside getGovernmentRateSeries, exact matching string checks, and confirm US/EUR configurations default safely to original logic patterns.
      • DistanceRateTest: Test conditional branching to verify an eligible GBP workspace maps out only corresponding fuel bands with proper tax snapshot bindings.

    What alternative solutions did you explore?

    Evaluating fuel bands based purely on the existential presence of a taxClaimablePercentage field was considered. However, tracking properties via explicit structural Series IDs is significantly more resilient since it perfectly mimics how the server determines chronological successors and functions seamlessly even if a fuel band's percentage metric scales to 0.

  12. melvin-bot commented on Oct 30, 2026

    @melvin-bot

    📣 @kula-star! 📣
    Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
    Please follow these steps:

    1. Make sure you've read and understood the contributing guidelines.
    2. Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
    3. Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
    4. Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
      Screen Shot 2022-11-16 at 4 42 54 PM
      Format:
    Contributor details
    Your Expensify account email: <REPLACE EMAIL HERE>
    Upwork Profile Link: <REPLACE LINK HERE>
    
  13. kula-star commented on Oct 30, 2026

    @kula-star

    Contributor details
    Your Expensify account email: greendream60606@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~01887481b4bbe5e8fa

  14. melvin-bot commented on Oct 30, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

DailyKSv2InternalRequires API changes or must be handled by Expensify staffReviewingHas a PR in review

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions