Skip to content

[Payment due @Krishna2323] Add support for SmartScan hotel nightly rate category violations - #97596

Merged
MonilBhavsar merged 17 commits into
Expensify:mainfrom
akinwale:task-app-632262-ee
Sep 11, 2026
Merged

MonilBhavsar merged 17 commits into
Expensify:mainfrom
akinwale:task-app-632262-ee

Conversation

@akinwale

@akinwale akinwale commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

When a workspace category's Flag amounts over limit (limit type: Individual expense) is applied to an expense whose SmartScanned receipt has hotel reservation dates, the limit is now compared against the average nightly rate (total ÷ nights) rather than the full expense amount.

Expenses without reservation dates are unaffected, and the receipt-required and itemized-receipt-required thresholds continue to use the full amount. The overLimit and overCategoryLimit violations show new "Nightly rate over…" copy when the limit was applied per night, distinguished by the nights field the backend now returns.

Fixed Issues

$ https://github.com/Expensify/Expensify/issues/632262
PROPOSAL: https://github.com/Expensify/Expensify/issues/632262#issuecomment-5110547879

Tests

Prerequisites

  • Advanced Duplicate Detection enabled
  • Workspace Rules enabled
    • Max expense amount set (eg. $200)
  • Category limit set (eg. Travel category; Flag amounts over $300, Type: Individual expense).

Test: Nightly rate under Category limit

  1. Launch Expensify.
  2. Navigate the workspace policy expense chat.
  3. Create a new expense and upload a hotel receipt with a $250 nightly rate and a total up to $500 or higher (the receipt must have reservation start and end dates).
  4. Set the category to Travel.
  5. Click the Create expense button.
  6. After the scan is complete, verify that the expense limit violation is not triggered and no error message is displayed.
  • Verify that no errors appear in the JS console

Test: Nightly rate over Category limit

  1. Launch Expensify.
  2. Navigate the workspace policy expense chat.
  3. Create a new expense and upload a hotel receipt with a $350 nightly rate and a total up to $500 or higher (the receipt must have reservation start and end dates).
  4. Set the category to Travel.
  5. Click the Create expense button.
  6. After the scan is complete, verify that the expense limit violation is triggered and the nightly rate error message is displayed.
  • Verify that no errors appear in the JS console

Offline tests

QA Steps

Same as test steps.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari Screenshot 2026-08-06 at 13 10 40

@melvin-bot

melvin-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

Hey, I noticed you changed src/languages/en.ts in a PR from a fork. For security reasons, translations are not generated automatically for PRs from forks.

If you want to automatically generate translations for other locales, an Expensify employee will have to:

  1. Look at the code and make sure there are no malicious changes.
  2. Run the Generate static translations GitHub workflow. If you have write access and the K2 extension, you can simply click: [this button]

Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running:

npx ts-node ./scripts/generateTranslations.ts --help

Typically, you'd want to translate only what you changed by running npx ts-node ./scripts/generateTranslations.ts --compare-ref main

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/libs/DebugUtils.ts 69.09% <ø> (ø)
src/libs/Violations/ViolationsUtils.ts 81.70% <100.00%> (+1.01%) ⬆️
...categories/DynamicExpenseLimitTypeSelectorPage.tsx 0.00% <ø> (ø)
...ForReviewRules/FlagForReviewRuleAmountPageBase.tsx 0.00% <ø> (ø)
src/libs/TransactionUtils/index.ts 93.00% <66.66%> (+0.61%) ⬆️
... and 521 files with indirect coverage changes

@akinwale akinwale changed the title [HOLD Auth 23387] Add support for SmartScan hotel nightly rate violations Add support for SmartScan hotel nightly rate violations Aug 4, 2026
@akinwale
akinwale marked this pull request as ready for review August 6, 2026 19:11
@akinwale
akinwale requested review from a team as code owners August 6, 2026 19:11
@melvin-bot
melvin-bot Bot requested a review from Krishna2323 August 6, 2026 19:11
@melvin-bot

melvin-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

@Krishna2323 Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot
melvin-bot Bot requested review from garrettmknight and removed request for a team August 6, 2026 19:11
@Krishna2323

Copy link
Copy Markdown
Contributor

@akinwale I can review if required.

@akinwale

akinwale commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@akinwale I can review if required.

@Krishna2323 Please go ahead. Thanks.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8b55cd376b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/libs/Violations/ViolationsUtils.ts Outdated
Comment thread src/libs/TransactionUtils/index.ts Outdated
garrettmknight
garrettmknight previously approved these changes Aug 6, 2026

@garrettmknight garrettmknight left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Steps look good to me.


// A SmartScanned multi-day reservation is measured against its average nightly rate rather than its total
const reservationNights = TransactionUtils.getReservationNights(updatedTransaction);
const amountForLimitCheck = reservationNights > 0 ? expenseAmount / reservationNights : expenseAmount;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does the backend floor/round/ceil the per-night average, and at what precision?

@akinwale akinwale Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There is no rounding in the backend. The divided value is compared directly with the actual limit for the violation check.

Comment thread src/libs/Violations/ViolationsUtils.ts Outdated
canCalculateAmountViolations && !isInvoiceTransaction && TransactionUtils.hasReservationList(updatedTransaction) && isSameCurrency && expenseAmount > -updatedTransaction.amount;
const shouldCategoryShowOverLimitViolation =
canCalculateAmountViolations && !isInvoiceTransaction && typeof categoryOverLimit === 'number' && expenseAmount > categoryOverLimit && isControlPolicy;
canCalculateAmountViolations && !isInvoiceTransaction && typeof categoryOverLimit === 'number' && amountForLimitCheck > categoryOverLimit && isControlPolicy;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Per-night averaging also applies to the workspace-level Max expense amount rule (shouldShowOverLimitViolation uses amountForLimitCheck), but only the category copy mentions it. I think we should add the same sentence to maxExpenseAmountDescription?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The explicit requirement was to update just the category copy.

  1. Admin sets a "Flag amounts over" amount on the Hotel (or Car Rental) category and selects "Individual expense" as the limit type.
  2. If SmartScan detects reservationStartDate and reservationEndDate on a receipt in that category, we calculate the per-night average (amount / nights) and use that instead of the total expense amount when checking against the threshold.
  3. If no reservation dates are detected, the limit type behaves as it does today - flagging based on the full expense amount.
  4. We'll update the explainer text under "Individual expense" to note that multi-day reservations are evaluated using the per-night average rather than the total.

@sakluger can provide some more insight for this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That's a really good question. I hadn't considered that this would apply to the workspace limit, I originally intended for it to only apply to the category limits. Let me check with the team.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should only apply the nightly rate to category limits, not the overall workspace max expense amount. We would expect the global max amount to always trigger for any expenses over that amount.

@flaviadefaria

Copy link
Copy Markdown
Contributor

@Krishna2323 @akinwale @sakluger added a comment in Slack regarding this.

Should this logic also apply to the overall workspace "Max expense amount" limit, or only to category limits?

I’d expect the category-level max expense amount to override the general max whenever a specific limit is set for that category. Otherwise, there’s not much value in defining a category-specific maximum if it only takes effect when it’s lower than the general limit.

Especially if this matches how the product currently behaves, I’d keep it as is.

@sakluger

Copy link
Copy Markdown
Contributor

I agree with @flaviadefaria. We shouldn't change the logic for when to use a category limit vs the overall limit. I assume that today, any defined category limit overrides the workspace limit.

@flaviadefaria

Copy link
Copy Markdown
Contributor

Cool, so I think we're good to move forward. @akinwale @Krishna2323, what are the next steps here?

@Krishna2323

Copy link
Copy Markdown
Contributor

@akinwale, could you please resolve the conflicts and make the requested changes?

* Returns the number of nights covered by a SmartScanned reservation receipt, or 0 when the
* transaction has no usable reservation range.
*/
function getReservationNights(transaction: OnyxEntry<Transaction>): number {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would this be better suited in TravelUtils libs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not necessarily. It's more useful for determining if a particular transaction would be processed on a per-night amount basis for checking the transaction violations.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why these changes in actions files?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This must be from an incorrect or incomplete merge. I'll restore the file from the main branch.

@Krishna2323

Copy link
Copy Markdown
Contributor

@akinwale friendly bump

@akinwale

akinwale commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

@MonilBhavsar I have merged with the latest main. All tests are now passing.

@MonilBhavsar MonilBhavsar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@akinwale , there are still changes in .github/actions/javascript/isDeployChecklistLocked/index.js and tests are failing. Please check

@akinwale

Copy link
Copy Markdown
Contributor Author

@MonilBhavsar Restored the file from main. The only failing GH check I see is Verify peer review, which always fails.

@flaviadefaria

Copy link
Copy Markdown
Contributor

It would be really great if we could merge this by tomorrow at the latest.

Comment thread src/languages/fr.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unlike every other locale, expenseSubtitle is unchanged here. Is it expected?

@akinwale akinwale Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have regenerated the translations for the changed paths.


// A SmartScanned multi-day reservation is measured against its average nightly rate rather than its total
const reservationNights = TransactionUtils.getReservationNights(updatedTransaction);
const amountForLimitCheck = reservationNights > 0 ? expenseAmount / reservationNights : expenseAmount;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could this per-night calculation also affect Category total limits? For example, a $500 five-night hotel expense with a $300 daily category limit is checked as $100/night and would not be flagged. Should we restrict this behavior to the Individual expense limit type?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes, we only want to apply the per-night limit to the "Individual expense" category limit type, not the "Category total" limit type. See https://github.com/Expensify/Expensify/issues/632262#issuecomment-4997562779.

@sakluger

Copy link
Copy Markdown
Contributor

I have a call on Monday with the customer who requested this. What are the chances that we can get it merged tomorrow or Monday?

Copy link
Copy Markdown
Contributor

@MonilBhavsar @akinwale are we on track for merging this today?

@akinwale

Copy link
Copy Markdown
Contributor Author

@MonilBhavsar @akinwale are we on track for merging this today?

@MonilBhavsar I have updated the nightly limit check to Individual expense and tested to validate that it works as expected. The daily category limit type would trigger the violation.

@flaviadefaria Hopefully.

@MonilBhavsar
MonilBhavsar merged commit 9af638c into Expensify:main Sep 11, 2026
36 of 38 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

🚧 MonilBhavsar has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/MonilBhavsar in version: 9.4.77-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@MelvinBot

MelvinBot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Yes — help site changes are required, and I've opened a draft PR for them: #101008

Why

This PR changed how a workspace category's Flag amounts over cap is measured, and the help site described that setting as nothing more than "Set a spending cap for this category."

Two things are now undocumented:

  1. The cap can be measured per night. When the Type is Individual expense and the expense has a SmartScanned hotel receipt with check-in and check-out dates, the cap is compared against the average nightly rate (total ÷ nights) instead of the full total. An admin who sets a $300 cap and watches a $900 hotel expense go unflagged has nothing in the help site explaining why.
  2. The Type row itself was never documented. Neither article mentioned that Flag amounts over has Individual expense and Category total options, so there was no place for the new behavior to attach to.

What I changed

docs/articles/new-expensify/workspaces/Create-expense-categories.md

  • Expanded the Flag amounts over bullet to cover the Amount field and the Type row, with both options.
  • Added a FAQ entry, How is Flag amounts over applied to a multi-day hotel expense?, with a worked example and an explicit list of what still uses the full expense total.

docs/articles/new-expensify/workspaces/Workspace-Rules.md

  • Expanded the same bullet under How to configure Category Rules, cross-linked to the new FAQ.
  • Clarified that Max expense amount is always compared against the full expense total, including a multi-day hotel expense.
Scope boundaries I read out of the diff, and how I verified the labels

From ViolationsUtils.ts, the nightly average applies only to the category cap when the limit type is EXPENSE (or unset). These still compare against the full total, so the docs say so explicitly:

  • Max expense amountshouldShowOverLimitViolation still uses expenseAmount
  • Require receipts over and Require itemized receipts over — unchanged
  • Flag amounts over with Type set to Category total

I also documented the new violation string — Nightly rate over [amount]/person category limit — from en.ts, and noted that the full-total case still reads Amount over [amount]/person category limit. Since only overCategoryLimit got the new copy, the docs don't claim the workspace-level overLimit violation ever reports a nightly rate.

Every label in the new copy was read off the live app on web rather than the source: the Category rules section header, the Flag amounts over row and page, the Amount field, the Type row, the Individual expense / Category total options and their verbatim subtitles, and Max expense amount under Expenses on the Rules page. Screenshots are in the docs PR body.

docs/HELPSITE_NAMING_CONVENTIONS.md, docs/HELP_AUTHORING_GUIDELINES.md, and docs/TEMPLATE.md were applied: no new headings outside the existing # FAQ section, the new FAQ heading is a question, all UI labels are bolded with exact capitalization, and the cross-link is relative. npm run spell-changed passes on both files.


@akinwale, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR Ready for review


view run · no recording available

@m-natarajan

Copy link
Copy Markdown

@akinwale @Krishna2323 @garrettmknight Is applause.expensifail.com domain enabled for Advanced Duplicate Detection or do we have to provide accounts for the same?

@luacmartins

Copy link
Copy Markdown
Contributor

@akinwale @MonilBhavsar @Krishna2323 can you check the comment above please?

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/luacmartins in version: 9.4.77-4 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Bundle Size Analysis (Sentry):

@melvin-bot

melvin-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

🤖 Payment issue created: #101133

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants