Skip to content

[$175] Missing attendees violation is hidden when an approver views another member's expense #104596

Open

Description

@MelvinBot

Problem

An approver or admin doesn't see the "Multiple attendees required for this category." violation on another member's expense. The expense creator does see it.

The backend leaves out the report owner when it counts attendees. The client check leaves out the current viewer instead. So when the approver views the expense, the client leaves out the approver, counts the creator as an extra attendee, and hides the violation.

Steps to reproduce

Preconditions: a Control workspace with attendee tracking on, and a category that requires attendees. Invite a member.

  1. As the member, create an expense in that category. Leave the member as the only attendee.
  2. As the member, go to Spend > Expenses. The expense shows the attendees violation.
  3. Log in as the admin or approver, or clear the cache if already logged in.
  4. Go to Spend > Expenses.

Expected: The member's expense shows the "Multiple attendees required for this category." violation.

Actual: The violation is hidden for the approver.

Reproducible on staging.

Creator Approver
Creator sees the violation Approver doesn't see the violation

Root cause

getIsMissingAttendeesViolation takes the viewer's personal details and leaves that person out of the attendee count (AttendeeUtils.ts:71-72).

The violation builder in ViolationsUtils already leaves out the report owner, with the current user as the fallback when the report isn't available (ViolationsUtils.ts:981-995). The two checks disagree whenever the viewer isn't the owner.

These callers pass the current user:

The confirmation page (useConfirmationValidation.ts) can keep the current user, because there the current user is the creator.

Suggested fix

Have getIsMissingAttendeesViolation and syncMissingAttendeesViolation leave out the report owner's login, like the backend does. Use the current user as the fallback for unreported expenses. Update every caller listed above.

Context

Found during review of Fix false missingAttendees violation in Search rows. See the review analysis and the screenshots.

The same problem was reported earlier in Attendee - Multiple attendee violation only applicable for the owner of the WS, which was closed in January 2026.

Issue OwnerCurrent Issue Owner: @VickyStash
Upwork Automation - Do Not Edit

Activity

  1. nkdengineer commented on Oct 21, 2026

    @nkdengineer
    Contributor

    @MelvinBot

    Let's update the image in the issue's description to be correct.

    • Approver
    Image
    • Creator
    Image
  2. MelvinBot commented on Oct 21, 2026

    @MelvinBot
    ContributorAuthor

    Done. I updated the Creator / Approver table in the issue description with your screenshots:

    • Creator: the mer expense shows "Multiple attendees required for this category."
    • Approver: the same mer expense from nkde243 shows no violation.

    The rest of the description is unchanged.


    view run

  3. added
    BugSomething is broken. Auto assigns a BugZero manager.
    and removed on Oct 21, 2026
  4. MelvinBot commented on Oct 21, 2026

    @MelvinBot
    ContributorAuthor

    Proposal

    What is the root cause of that problem?

    The Spend > Expenses row rechecks the attendees violation on the client. The client check leaves out the viewer when it counts attendees, but the backend leaves out the report owner.

    When an approver views the member's expense, the client leaves out the approver. It then counts the creator as a real attendee and decides there's no violation. syncMissingAttendeesViolation then removes the backend's violation from the row.

    Code references

    These callers have the same mismatch but only add violations, so they can miss a client-added violation but can't hide a backend one:

    The two creation-flow callers, useConfirmationValidation.ts:276 and ViolationsUtils.ts:594 (via useFormErrorManagement), are correct as is, because there the current user is the creator.

    Fix false missingAttendees violation in Search rows only changed where attendees are read from. It didn't change who gets left out.

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

    1. In AttendeeUtils.ts, change getIsMissingAttendeesViolation and syncMissingAttendeesViolation to take the owner's login instead of the current user's personal details. Leave out attendees whose email matches that login, the same way ViolationsUtils does. If no login is available, use the same fallback: assume one attendee is the owner (attendees.length - 1).

    2. Pass the report owner's login, falling back to the current user's login for unreported expenses:

      • TransactionListItem: submitterLogin (this fixes the reported bug)
      • ExpenseReportListItem: submitterLogin
      • MoneyRequestView: iouReportOwnerLogin
    3. In useConfirmationValidation and getIsViolationFixed, pass the current user's login. Behavior there doesn't change.

    4. Add unit tests in tests/unit/AttendeeUtilsTest.ts:

      • The viewer isn't the owner and the owner is the only attendee: the violation shows, and sync keeps the backend violation.
      • The viewer isn't the owner and is the only attendee: no violation.

      Then update the existing getIsViolationFixed tests in ViolationUtilsTest.ts for the new argument.

    What alternative solutions did you explore? (Optional)

    Keep the current parameter type and have callers pass {...currentUserDetails, login: ownerLogin, email: ownerLogin}. This is a smaller diff, but it hides the intent, so changing the parameter is clearer.

    Related issues and open questions
    • Related: Fix false missingAttendees violation in Search rows. That issue fixed where Search rows read attendees from. This bug is about who gets left out.
    • If an approver doesn't have the owner's personal details loaded, submitterLogin is undefined. The row then falls back to the current user and the old behavior returns for that row.
    • I didn't reproduce this in the app. The issue's screenshots already show it, and the code path is clear.

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


    view run

  5. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 21, 2026
  6. melvin-bot commented on Oct 21, 2026

    @melvin-bot

    Current assignee @nkdengineer is eligible for the External assigner, not assigning anyone new.

  7. changed the title [-]Missing attendees violation is hidden when an approver views another member's expense[/-] [+][$175] Missing attendees violation is hidden when an approver views another member's expense[/+] on Oct 21, 2026
  8. melvin-bot commented on Oct 21, 2026

    @melvin-bot
  9. melvin-bot commented on Oct 21, 2026

    @melvin-bot

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

  10. melvin-bot commented on Oct 22, 2026

    @melvin-bot

    📣 @Abdallah-1551! 📣
    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>
    
  11. Abdallah-1551 commented on Oct 22, 2026

    @Abdallah-1551

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On Spend > Expenses, an approver or admin viewing another member's expense doesn't see the "Multiple attendees required for this category." violation, though the creator does. The member is the only attendee and the category requires several, so the backend sends missingAttendees. The client then hides it for everyone except the creator.

    What is the root cause of that problem?

    The backend and the client count attendees differently.

    • Backend, and the client violation builder in ViolationsUtils.ts:983-995: leave out the report owner. The owner is ownerLoginParam ?? getCurrentUserEmail().
    • AttendeeUtils.getIsMissingAttendeesViolation and syncMissingAttendeesViolation (AttendeeUtils.ts:71-78, 108-125): leave out the current viewer, taken from userPersonalDetails.login/email.

    When an approver views the member's expense, the client leaves out the approver's login, so the member counts as an extra attendee. getIsMissingAttendeesViolation returns false. syncMissingAttendeesViolation then sees a backend MISSING_ATTENDEES violation, no client-side violation, and filters the backend violation out. That is why the violation disappears instead of just not being added.

    The check only works when the viewer is the owner. Search rows, the expense view and the reports list use it for any viewer, so they break for approvers.

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

    1. src/libs/AttendeeUtils.ts: make the check owner-based.

    Replace the userPersonalDetails: CurrentUserPersonalDetails parameter with ownerLogin: string | undefined in both functions. Leave out attendees whose email matches the owner. This mirrors ViolationsUtils.

    function getAttendeesMinusOwnerCount(attendees: Attendee[], ownerLogin?: string): number {
        const owner = ownerLogin ?? getCurrentUserEmail() ?? '';
        return attendees.filter((a) => !!a?.email && a.email !== owner).length;
    }
    
    function getIsMissingAttendeesViolation(
        category: PolicyCategory | undefined,
        iouAttendees: Attendee[] | string | undefined,
        ownerLogin: string | undefined,
        isAttendeeTrackingEnabled: boolean,
        isControlPolicy: boolean,
        isManualDistanceRequest = false,
        isPerDiemRequest = false,
    ): boolean {
        // ...existing early returns unchanged...
        const attendees = convertAttendeesToArray(iouAttendees);
        const attendeesMinusOwnerCount = getAttendeesMinusOwnerCount(attendees, ownerLogin);
        return attendeesMinusOwnerCount < 1 /* keep existing threshold logic */;
    }

    Keep the existing threshold logic. Only the "who is left out" part changes.

    syncMissingAttendeesViolation takes ownerLogin and passes it through to getIsMissingAttendeesViolation. Its add/remove logic stays the same.

    2. Update the callers that are wrong for non-owner viewers.

    Pass the report owner's login, with the current user's login as the fallback for unreported expenses, or when the owner isn't known.

    • TransactionListItem/index.tsx (~211-220): pass submitterLogin ?? currentUserDetails.login. submitterLogin is already loaded around line 136. This fixes the reported bug.
    • ExpenseReportListItem.tsx (~204): pass submitterLogin ?? currentUserDetails.login.
    • MoneyRequestView.tsx (~810): pass iouReportOwnerLogin ?? currentUserPersonalDetails.login. This variable is already defined near line 232.

    3. Callers where the current user is the creator.

    • useConfirmationValidation.ts:276: pass currentUserPersonalDetails.login ?? currentUserPersonalDetails.email. Behavior is unchanged.
    • ViolationsUtils.getIsViolationFixed (~594): add an optional ownerLogin parameter. Default it to getCurrentUserEmail() and pass it to getIsMissingAttendeesViolation. Callers that know the report owner can pass it. The creation flow keeps the current behavior.

    4. Make the owner lookup robust in Search rows.

    If submitterLogin is undefined because the owner's personal details aren't loaded, resolve it from report.ownerAccountID through personalDetails. Do this before falling back to the current user. Otherwise the old bug returns for that row.

    5. Tests.

    In tests/unit/AttendeeUtilsTest.ts:

    • Viewer is an approver, ownerLogin = member@…, attendees [member]: getIsMissingAttendeesViolation returns true.
    • syncMissingAttendeesViolation with that input and an existing backend MISSING_ATTENDEES keeps the violation.
    • Owner is the only attendee and the owner is the viewer: true, as before.
    • Attendees [member, approver] and ownerLogin = member: false, because one non-owner attendee exists.
    • ownerLogin undefined: falls back to the current user's email.

    Update the existing getIsViolationFixed tests in ViolationUtilsTest.ts for the new signature.

    What alternative solutions did you explore? (Optional)

    • Keep the userPersonalDetails signature and have callers pass {...currentUserDetails, login: ownerLogin, email: ownerLogin}. This is a smaller diff, but it is misleading and error-prone. Changing the parameter to an owner login states the intent.
    • Stop running syncMissingAttendeesViolation in Search rows and trust the backend. This would fix the bug, but it would reintroduce the false positives that PR Fix false missingAttendees violation in Search rows #104540 fixed, so I rejected it.
    • Use attendees.length - 1 when no owner is known. This is wrong when the viewer-owner is not in the attendee list. Falling back to the current user's email matches ViolationsUtils and is more predictable.

    Testing

    1. Unit tests: run npm test -- AttendeeUtilsTest ViolationUtilsTest and confirm the new and updated cases pass.
    2. Manual, reproducing the issue:
      • Set up a Control workspace with attendee tracking on and a category that requires attendees. Invite a member.
      • As the member, create an expense in that category with only the member as attendee. Confirm the violation shows in Spend > Expenses.
      • Log in as the admin or approver and clear the cache. Open Spend > Expenses and confirm the member's expense shows "Multiple attendees required for this category."
      • Check Spend > Reports and the expense detail view (MoneyRequestView) as the approver. The violation should also show there.
    3. Regression:
      • As the creator, the violation still shows.
      • Adding a second real attendee clears the violation for both the creator and the approver.
      • The confirmation page still validates attendees as before.
      • An unreported expense still uses the current user as the owner.
  12. bonnhatnguyen commented on Oct 22, 2026

    @bonnhatnguyen

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/~0139ed4098bdc50182

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    An approver or admin viewing another member's expense does not see the "Multiple attendees required for this category." violation on Spend > Expenses, whereas the expense creator sees the violation correctly.

    What is the root cause of that problem?

    1. The backend attendee validation excludes the report owner (creator) when checking if multiple attendees are present.
    2. However, on the client, getIsMissingAttendeesViolation in src/libs/AttendeeUtils.ts:71-78 filters out attendees matching userPersonalDetails.login or userPersonalDetails.email, which represents the current viewer:
      function getIsMissingAttendeesViolation(
          category: PolicyCategory | undefined,
          iouAttendees: Attendee[] | string | undefined,
          userPersonalDetails: OnyxEntry<PersonalDetails>, // <-- represents viewer, not owner
          ...
      )
    3. In TransactionListItem/index.tsx:211-220, the row passes currentUserDetails to syncMissingAttendeesViolation.
    4. When an approver views a member's expense, the client check excludes the approver instead of the expense owner. Because the expense creator is treated as a valid attendee (non-approver), the client calculates that enough attendees exist and returns false.
    5. syncMissingAttendeesViolation (AttendeeUtils.ts:122-125) then removes the backend's MISSING_ATTENDEES violation from the transaction, causing the violation to disappear for the approver.

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

    1. In src/libs/AttendeeUtils.ts:
      Refactor getIsMissingAttendeesViolation and syncMissingAttendeesViolation to accept ownerLogin: string | undefined instead of userPersonalDetails.

      function getAttendeesMinusOwnerCount(attendees: Attendee[], ownerLogin?: string): number {
          const owner = ownerLogin ?? getCurrentUserEmail() ?? '';
          return attendees.filter((a) => !!a?.email && a.email !== owner).length;
      }

      Filter out attendees whose email matches ownerLogin. If ownerLogin is undefined, fall back to getCurrentUserEmail().

    2. In src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx:
      Pass the expense submitter/owner login (submitterLogin ?? currentUserDetails.login) to syncMissingAttendeesViolation. If submitterLogin is not yet available, resolve it via report.ownerAccountID from personalDetails.

    3. In src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx:
      Pass submitterLogin ?? currentUserDetails.login.

    4. In src/components/ReportActionItem/MoneyRequestView.tsx:
      Pass iouReportOwnerLogin ?? currentUserPersonalDetails.login.

    5. In src/hooks/useConfirmationValidation.ts:276:
      Pass currentUserPersonalDetails.login ?? currentUserPersonalDetails.email (behavior unchanged since creator is current user).

    6. In src/libs/Violations/ViolationsUtils.ts (getIsViolationFixed):
      Pass ownerLogin to getIsMissingAttendeesViolation.

    What alternative solutions did you explore? (Optional)

    • Skipping syncMissingAttendeesViolation for non-owners: Rejected because that would leave stale violations if attendees were updated while viewing. Excluded attendee calculation based on report owner ensures 100% parity with backend business logic.

    What automated tests can you add to prevent this from re-occurring?

    In tests/unit/AttendeeUtilsTest.ts:

  13. melvin-bot commented on Oct 22, 2026

    @melvin-bot

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

  14. VickyStash commented on Oct 22, 2026

    @VickyStash
    Contributor

    UPD: I haven't had enough time for this one today, but me or my teammate will take a look at it tomorrow!

    Yesterday, I was able to reproduce the same bug as in the attached screenshots, and at first glance, it wasn't exactly due to the problem described in the issue. For me it was cause for the workspace member app didn't return policyCategories_ in OpenApp, so due to policyCategories_ missing, the app couldn't correctly handle areAttendeesRequired check.
    It means that there are potentially two bugs to be addressed, but it should be re-verified.

  15. rinej commented on Oct 23, 2026

    @rinej
    Contributor

    Hello, I'm Tomek from Callstack and I'm going to take this one over from @VickyStash

    I've reproduced it and I'm working on a solution now. I am also checking the policyCategories_ missing from OpenApp that you mentioned, to verify if we got second bug or the same one. Will try to cover it in one fix

  16. rinej commented on Oct 23, 2026

    @rinej
    Contributor

    I investigated the issue and reproduced it on staging with an admin as approver account and a member account.
    I confirmed it's what the issue describes.

    I also checked the missing policyCategories_ idea. In my repro both users had the categories loaded, so that wasn't the cause here.

    Created the fix, the app now leaves out the expense owner when it counts attendees, the same as the backend does, so the warning shows the same for everyone. Before/after videos and more details are in the PR, so please have a look.

    Here is the PR - #104855

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

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.ExternalAdded to denote the issue can be worked on by a contributorReviewingHas a PR in reviewWeeklyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions