diff --git a/.claude/skills/app-coding-standards/SKILL.md b/.claude/skills/app-coding-standards/SKILL.md
index 17ab10ef3c30..26c437f45caf 100644
--- a/.claude/skills/app-coding-standards/SKILL.md
+++ b/.claude/skills/app-coding-standards/SKILL.md
@@ -22,7 +22,7 @@ Coding standards for the Expensify App. Each standard is a standalone file in `r
### Performance
- [PERF-1](rules/perf-1-no-spread-in-renderitem.md) — No spread in renderItem
- [PERF-2](rules/perf-2-early-return.md) — Return early before expensive work
-- [PERF-3](rules/perf-3-onyx-list-item-provider.md) — Use OnyxListItemProvider in renderItem
+- [PERF-3](rules/perf-3-onyx-list-item-provider.md) — Shared data in list rows comes from OnyxListItemProvider
- [PERF-5](rules/perf-5-shallow-comparison.md) — Shallow over deep comparisons
- [PERF-6](rules/perf-6-derive-state-from-props.md) — Derive state from props
- [PERF-7](rules/perf-7-reset-via-key-prop.md) — Reset via key prop
@@ -38,6 +38,9 @@ Coding standards for the Expensify App. Each standard is a standalone file in `r
- [PERF-17](rules/perf-17-pass-raw-index-on-demand.md) — Pass raw source, index on demand (no pre-built digest)
- [PERF-18](rules/perf-18-use-pre-mount-destination.md) — Use usePreMountDestination for RHP pre-mounting
- [PERF-19](rules/perf-19-scalar-collection-selectors.md) — Collection selectors return only what the consumer uses
+- [PERF-20](rules/perf-20-no-ad-hoc-deferral.md) — Defer heavy work to happen after visual transitions
+- [PERF-21](rules/perf-21-rows-read-own-data.md) — List rows read their own Onyx data
+- [PERF-22](rules/perf-22-row-single-member-subscriptions.md) — List rows subscribe to single collection members
### Consistency
- [CONSISTENCY-1](rules/consistency-1-no-platform-checks.md) — No platform-specific checks in components
diff --git a/.claude/skills/app-coding-standards/rules/perf-11-optimize-data-selection.md b/.claude/skills/app-coding-standards/rules/perf-11-optimize-data-selection.md
index 51f5817f0e33..6fdcbe071421 100644
--- a/.claude/skills/app-coding-standards/rules/perf-11-optimize-data-selection.md
+++ b/.claude/skills/app-coding-standards/rules/perf-11-optimize-data-selection.md
@@ -7,13 +7,15 @@ title: Optimize data selection and handling
### Reasoning
-`useOnyx` supports an optional `selector` to narrow data before it reaches the component. Selectors control both **what** data the component receives and **how** Onyx detects changes:
+`useOnyx` supports an optional `selector` to narrow data before it reaches the component. Onyx notifies subscribers per key: a write to a collection member notifies that member's subscribers and the collection's subscribers, and unchanged members keep their references. Selectors control both **what** data the component receives and **how** Onyx detects changes:
- **With a selector**: Onyx runs `deepEqual` on the selector output to decide whether to re-render. This guards against unnecessary re-renders when unrelated data changes, but the comparison cost scales with the size of the output.
-- **Without a selector**: Onyx uses `shallowEqual` on raw references, which is much cheaper. However, the component will re-render whenever **any** part of the subscribed data changes, since there is no narrowing to filter out irrelevant updates.
+- **Without a selector**: Onyx compares by reference only, which is free. However, the component re-renders on **every** write to the key, even when the new value has the same contents. For a collection key, that means every write to any member.
This means selectors are a double-edged sword. A well-written selector that returns a primitive or a small object is highly effective — it skips re-renders when unrelated data changes, and `deepEqual` on a small value is trivial. But a poorly-written selector that returns a large object, a full collection, or a non-plain type like `Set`/`Map` makes things worse — it forces an expensive `deepEqual` on every Onyx update with no re-render savings.
+**Prefer a member key:** when the component needs one or a few members of a collection and knows their IDs, subscribe to `${ONYXKEYS.COLLECTION.X}${id}` instead of the collection. It needs no selector and wakes only for that member. In list rows this is required, see [PERF-22](perf-22-row-single-member-subscriptions.md).
+
**When to use a selector:**
- To pick a few fields from a single Onyx key (reduces re-renders from unrelated field changes)
- To compute a final scalar (boolean, number, string) from a larger dataset
@@ -94,7 +96,10 @@ function UserProfile({userId}) {
return {user?.name};
}
-// GOOD: No selector on collection — shallowEqual is cheap, transform inline
+// GOOD: The member is known, so read it by key instead of selecting it from the collection
+const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+
+// GOOD: The component needs every policy. No selector: the reference check is free, transform inline
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const mappedPolicies = Object.fromEntries(
Object.entries(policies ?? {}).map(([key, policy]) => [
@@ -140,6 +145,7 @@ Flag ONLY when ANY of these are true:
- The selector meaningfully reduces a large dataset to a small result (e.g., a primitive or a few items) by iterating over the subscribed collection itself — the `deepEqual` cost on a small result is negligible
- The `useOnyx` call is on a single-item key (not a collection), and the selector picks specific fields
- The data structure is static or the function requires the entire object for valid operations
+- The code is a list row subscribing to a whole collection (`ONYXKEYS.COLLECTION.*`) or a derived map with an entry per record. Report [PERF-22](perf-22-row-single-member-subscriptions.md) instead. A row reading any other broad key, such as `BANK_ACCOUNT_LIST`, is still in scope here
**Search Patterns** (hints for reviewers):
- `useOnyx`
diff --git a/.claude/skills/app-coding-standards/rules/perf-20-no-ad-hoc-deferral.md b/.claude/skills/app-coding-standards/rules/perf-20-no-ad-hoc-deferral.md
new file mode 100644
index 000000000000..e056d4e8a130
--- /dev/null
+++ b/.claude/skills/app-coding-standards/rules/perf-20-no-ad-hoc-deferral.md
@@ -0,0 +1,184 @@
+---
+ruleId: PERF-20
+title: Defer heavy work to happen after visual transitions
+---
+
+## [PERF-20] Defer heavy work to happen after visual transitions
+
+### Reasoning
+
+Work that runs during a navigation, modal, or keyboard transition competes with the animation for the JS thread. `TransitionTracker` knows when these transitions start and end: screen push/pop/replace, modal open/close, and keyboard dismiss through `KeyboardUtils.dismiss`. The App has shared primitives built on it:
+
+- `usePreMountDestination` to mount the screen an RHP flow ends on while the RHP is still open
+- `` to mount a subtree after tracked transitions settle
+- `API.writeWhenReady` to apply an optimistic write once a barrier settles (by default, tracked transitions)
+- for one-off imperative work:
+ - the `afterTransition` callback option on `Navigation` methods and `KeyboardUtils.dismiss`
+ - `Navigation.runAfterTransition` and `Navigation.runAfterUpcomingTransition`
+ - `TransitionTracker.runAfterTransitions`
+
+`TransitionTracker.runAfterTransitions({callback})` runs the callback synchronously when no transition is active. When it is called before the transition starts (for example, right after a navigation call), pass `waitForUpcomingTransition`. These primitives wait for tracked transitions to settle, with a fallback timeout if none starts. Two of them can also release earlier:
+
+- `KeyboardUtils.dismiss` calls `afterTransition` right away when the keyboard is not visible, and always on desktop web
+- `API.writeWhenReady` runs the write right away when the app goes inactive or to the background
+
+See [DEFER_AFTER_PAINT.md](../../../../contributingGuides/DEFER_AFTER_PAINT.md) for which primitive fits which case, and for when `API.writeWhenReady` is not a safe replacement for `API.write`.
+
+### When to use
+
+Use one of these primitives when work triggered by an interaction is not needed for the first frame of the destination and would otherwise run while a navigation, modal, or keyboard transition is animating: a follow-up API write, focus or scroll, a follow-up navigation, or a heavy subtree on a freshly navigated screen.
+
+### When NOT to use
+
+- The delay is a UI duration that doesn't depend on a transition, such as a tooltip hover delay or how long a toast stays on screen
+- The code needs a first frame or layout pass for a reason other than a transition (measuring, hydration)
+- The destination needs the work to render its first frame
+- None of the primitives fits. Flag it to a human, the pattern needs to be extended thoughtfully instead of worked around with inline timing
+
+### Incorrect
+
+The names below (`SOME_COMMAND`, `pendingWrite.ts`, `setPendingWrite`, etc...) are placeholders, not real App symbols.
+
+```tsx
+// Pattern 1: timer or frame callback right after a navigation call
+Navigation.dismissModal();
+
+// Wait for the modal close animation before writing
+setTimeout(() => {
+ API.write(WRITE_COMMANDS.SOME_COMMAND, params, onyxData);
+}, 500);
+```
+
+```tsx
+// Pattern 2: a screen runs another flow's pending work when it gains focus
+
+// pendingWrite.ts
+let pendingWrite: (() => void) | undefined;
+function setPendingWrite(write: () => void) {
+ pendingWrite = write;
+}
+function hasPendingWrite() {
+ return !!pendingWrite;
+}
+function flushPendingWrite() {
+ pendingWrite?.();
+}
+function clearPendingWrite() {
+ pendingWrite = undefined;
+}
+
+// Submit flow: queues the write, then navigates to ReportScreen
+setPendingWrite(() => API.write(WRITE_COMMANDS.SOME_COMMAND, params, onyxData));
+Navigation.dismissModalWithReport({reportID});
+
+// ReportScreen.tsx: runs the queued write on focus, so it doesn't compete with the dismiss animation
+useFocusEffect(
+ useCallback(() => {
+ if (!hasPendingWrite()) {
+ return;
+ }
+ flushPendingWrite();
+ clearPendingWrite();
+ }, []),
+);
+```
+
+```tsx
+// Pattern 3: a hand-rolled "ready" flag that gates rendering
+function SomeScreenHeader() {
+ // Render the actions only after the navigation animation, they are expensive
+ const [isReady, setIsReady] = useState(false);
+ useEffect(() => {
+ const id = requestAnimationFrame(() => setIsReady(true));
+ return () => cancelAnimationFrame(id);
+ }, []);
+
+ return isReady ? : null;
+}
+```
+
+### Correct
+
+```tsx
+// Pattern 1: the follow-up work runs once the dismiss transition settles
+Navigation.dismissModal({afterTransition: () => doFollowUpWork()});
+
+// Or, when the delayed work is a single API.write that can be deferred (see the caveats below)
+const armed = API.armTransitionBarrier();
+Navigation.dismissModal();
+API.writeWhenReady(WRITE_COMMANDS.SOME_COMMAND, params, onyxData, armed.barrier);
+```
+
+```tsx
+// Pattern 2: the flow that navigates owns the deferral, the destination screen has no flush
+Navigation.dismissModalWithReport({reportID}, undefined, {
+ afterTransition: () => API.write(WRITE_COMMANDS.SOME_COMMAND, params, onyxData),
+});
+```
+
+```tsx
+// Pattern 3
+}>
+
+
+```
+
+---
+
+### Review Metadata
+
+**Only flag what the diff shows.** Every condition of a pattern must be visible in the added or changed lines or in the surrounding code shown in the diff. Do not infer behavior from a function's name alone. If a condition can't be confirmed from the diff, do not flag. Missing a case this way is intended because a false positive costs more than a miss.
+
+**Evidence of intent (patterns 1, 2 and 3).** Only flag when the code shows the delay exists to wait for a navigation, modal, or keyboard transition, for example a comment saying so or `CONST.ANIMATED_TRANSITION` as the delay. A navigation call before the timer is not enough on its own.
+
+Flag these patterns in added or changed lines:
+
+1. **Timer or frame after a navigation call.** In the same function body, `setTimeout` or `requestAnimationFrame` is called on a line after one of these calls. The timer must run on the same invocation of the function as the navigation call. Navigation calls include:
+ - `Navigation.navigate`, `Navigation.goBack`, `Navigation.dismissModal`, `Navigation.dismissModalWithReport`, etc...
+ - `KeyboardUtils.dismiss`
+
+ Nested `requestAnimationFrame` calls count. Suggest the `afterTransition` option of that navigation call.
+
+ Tell the author the fix must keep the old behavior:
+ - the navigation call still runs in the same cases as before, and the delayed work keeps the same guards. A guard that sat between the navigation call and the timer must not start skipping the navigation
+ - if the timer was cleared somewhere (for example on unmount), keep that. `afterTransition` has no cancel handle, so check a cancelled flag inside the callback. For `navigate`, `goBack` and `dismissModal` without `waitForTransition`, the work can instead be scheduled with `Navigation.runAfterUpcomingTransition` right after the call, and for `KeyboardUtils.dismiss` with `Navigation.runAfterTransition`. Then call `cancel()` on the returned handle in the cleanup
+ - some of the `Navigation` methods above can return early on some paths without calling `afterTransition`, while the old timer always ran. For example: `goBack` at the root of the stack, or when it falls back to resetting to the app root; `revealRouteBeforeDismissingModal` when navigation is not ready; `dismissToPreviousRHP` when there is nothing to dismiss. If the work must still run on those paths, it needs its own path
+ - the timing can change. `KeyboardUtils.dismiss` runs `afterTransition` synchronously when the keyboard is already hidden, and always on desktop web, so the work runs before the next line instead of later. When no transition starts, `afterTransition` waits up to `CONST.MAX_TRANSITION_START_WAIT_MS` (1000 ms), which can be later than the old timer
+
+ Do not flag pattern 1 when the delay is a UI duration that doesn't depend on a transition, such as a tooltip hover delay or how long a toast stays on screen. A tooltip or toast delayed to wait for a navigation, modal or keyboard transition is not exempt. If the purpose is unclear, do not flag.
+
+2. **Pending work flushed on focus.** The evidence of intent is visible, and all of these are visible in the diff:
+ - a `useFocusEffect` callback checks a flag or value set outside this component: a module-level variable, or a getter from another file. The diff must show the getter and setter reading and writing the same variable, not just their names
+ - when the flag is set, the callback runs an API write, or a flush function whose body in the diff runs queued work
+ - the flag is set in the same function as a `Navigation.*` or `KeyboardUtils.dismiss` call (as in pattern 1), before that call
+ - the diff shows that the navigation goes to the screen with the focus callback: the same route or screen constant appears at the navigation call and in that screen, or a comment at the navigation call names that screen. If the diff doesn't show this, do not flag
+
+ Suggest moving the deferred work into the `afterTransition` option of the flow's navigation call.
+
+ Do not flag pattern 2 when the `useFocusEffect` callback only fetches or refreshes data, starts or stops a subscription, or sets focus, or when a comment says the work needs the destination screen itself (its data, its layout, or real focus). If the code that sets the flag is not in the diff, do not flag.
+
+3. **Hand-rolled ready flag gating a render.** The whole component is visible in the diff, the evidence of intent is visible, and:
+ - a `useState` boolean starts as `false`
+ - it is set to `true` once, from an effect that runs on mount (empty dependency array), inside a `setTimeout`, `requestAnimationFrame`, or `TransitionTracker.runAfterTransitions` callback
+ - its setter is not called anywhere else and not passed as a value to another component or function (calling it inside the scheduled callback, or inside `startTransition` there, is fine)
+ - the scheduled callback does nothing except set the flag (wrapping the setter in `startTransition` still counts as only setting the flag)
+ - the flag is only used to decide whether children render (not in effects, event handlers or props)
+
+ Suggest ``, which mounts its children once after tracked transitions settle and yields to input with `startTransition`. It waits for an upcoming transition by default. Add a note that `waitForUpcomingTransition={false}` exists for components that can mount without a navigation transition (for example, remounted by a parent with a new `key`). Do not decide which value the component needs, since its mounting paths are usually not in the diff.
+
+ Do not flag pattern 3 when the delay is a UI duration that doesn't depend on a transition (a tooltip hover delay, a toast that shows after N ms). A tooltip or toast delayed to wait for a navigation, modal or keyboard transition is not exempt. Also do not flag when a comment says the code needs a first frame or layout pass for a reason other than a transition (measuring, hydration).
+
+**Caveats for suggesting `API.writeWhenReady`:** only suggest it when the delayed work is a single `API.write` call and none of the cases in [Do not use `writeWhenReady` when](../../../../contributingGuides/DEFER_AFTER_PAINT.md#deferring-writes) apply. If the diff can't confirm that, suggest `afterTransition` instead.
+
+**DO NOT flag if:**
+
+- The delayed work being reviewed is itself scheduled with the `afterTransition` option, `API.writeWhenReady`, `Navigation.runAfterTransition`, `Navigation.runAfterUpcomingTransition`, or `TransitionTracker.runAfterTransitions`. A separate timer in the same function is still checked on its own. This covers patterns 1 and 2 only: pattern 3 still applies to a ready flag set inside `runAfterTransitions`. The body of an `afterTransition` callback is still checked, so a new navigation call followed by a timer inside it is pattern 1.
+- The code is inside one of these files, which implement the primitives: `src/libs/Navigation/TransitionTracker.ts`, `src/libs/Navigation/Navigation.ts`, `src/libs/API/writeWhenReady.ts`, `src/components/NavigationDeferredMount.tsx`, `src/hooks/usePreMountDestination/`, `src/libs/Navigation/helpers/preMountBuffer.ts`
+- The file is under `tests/` or its name ends in `.test.ts`, `.test.tsx`, `Test.ts` or `Test.tsx`
+
+**Search patterns** (starting points only, not the full definition of a match; generic arguments like `useState(false)` and whitespace do not change a match):
+
+- `setTimeout(`
+- `requestAnimationFrame(`
+- `useFocusEffect(`
+- `useState(false)`
diff --git a/.claude/skills/app-coding-standards/rules/perf-21-rows-read-own-data.md b/.claude/skills/app-coding-standards/rules/perf-21-rows-read-own-data.md
new file mode 100644
index 000000000000..7d9869814708
--- /dev/null
+++ b/.claude/skills/app-coding-standards/rules/perf-21-rows-read-own-data.md
@@ -0,0 +1,81 @@
+---
+ruleId: PERF-21
+title: List rows read their own Onyx data
+---
+
+## [PERF-21] List rows read their own Onyx data
+
+### Reasoning
+
+If a list reads collections and passes members to its rows as props, or puts collections in FlashList `extraData`, then every write to those collections re-renders the list and every row, even rows whose data did not change. If the list passes only IDs and each row reads its own data, a write re-renders only the row that shows it, and the list does not re-render.
+
+### Incorrect
+
+```tsx
+// BAD: the list reads collections for its rows and passes them down.
+const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+
+ (
+
+ )}
+/>;
+```
+
+### Correct
+
+```tsx
+// GOOD: the list passes only the ID, and the row reads its own data.
+}
+/>;
+
+function ReportRow({reportID}: ReportRowProps) {
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`);
+ // ...
+}
+```
+
+### Not a violation
+
+```tsx
+// The list needs the whole collection to sort and filter, and passes only IDs to rows.
+const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+const reportIDs = getSortedReportIDs(reports);
+
+}
+/>;
+```
+
+---
+
+### Review Metadata
+
+**Flag when ALL of these are true:**
+
+- A list component reads an Onyx collection, or another key that holds data for every record.
+- It passes members or the whole value to rows as props, or puts it in `extraData`.
+- The row could read that data itself by key, from an ID it already receives.
+
+**DO NOT flag if:**
+
+- The list uses the collection only for its own work, such as sorting or filtering, and passes rows only IDs.
+- The value is shared data that every row needs, not the row's own item. See [PERF-3](perf-3-onyx-list-item-provider.md).
+- The list is not virtualized and renders only a few fixed items.
+
+**Related:** [PERF-22](perf-22-row-single-member-subscriptions.md) for what the row itself subscribes to.
+
+**Search Patterns** (hints for reviewers):
+- `extraData`
+- `renderItem`
+- `useOnyx\(ONYXKEYS\.COLLECTION\.[A-Z_]+\)`
diff --git a/.claude/skills/app-coding-standards/rules/perf-22-row-single-member-subscriptions.md b/.claude/skills/app-coding-standards/rules/perf-22-row-single-member-subscriptions.md
new file mode 100644
index 000000000000..3d7be821521d
--- /dev/null
+++ b/.claude/skills/app-coding-standards/rules/perf-22-row-single-member-subscriptions.md
@@ -0,0 +1,54 @@
+---
+ruleId: PERF-22
+title: List rows subscribe to single collection members
+---
+
+## [PERF-22] List rows subscribe to single collection members
+
+### Reasoning
+
+Onyx notifies subscribers per key. A write to a collection member notifies that member's subscribers and the collection's subscribers. A row that subscribes to a whole collection re-renders on a write to any member, so one write re-renders every visible row. A row that subscribes to its own member key re-renders only when its own data changes.
+
+### Incorrect
+
+```tsx
+// BAD: every row subscribes to the whole report collection to get its own report.
+function ReportRow({reportID}: ReportRowProps) {
+ const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+ const report = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
+ // ...
+}
+```
+
+### Correct
+
+```tsx
+// GOOD: the row subscribes only to its own members.
+function ReportRow({reportID}: ReportRowProps) {
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`);
+ // ...
+}
+```
+
+---
+
+### Review Metadata
+
+**Flag when ALL of these are true:**
+
+- The code is a list row component, or code that `renderItem` calls.
+- The row subscribes to a whole collection (`ONYXKEYS.COLLECTION.*` without a member ID), or to a derived map with an entry per record, such as `REPORT_ATTRIBUTES`.
+- The row uses only a few members of it, and their IDs come from the row's props or its own data.
+
+**DO NOT flag if:**
+
+- The row subscribes to a member key (`${ONYXKEYS.COLLECTION.X}${id}`), or uses a selector that returns only its own data.
+- The row needs the whole collection, for example to search across all members.
+- The code is not part of a list row.
+
+**Related:** [PERF-3](perf-3-onyx-list-item-provider.md) for shared data such as personal details, [PERF-21](perf-21-rows-read-own-data.md) when the list reads the data for its rows, [PERF-11](perf-11-optimize-data-selection.md) for selector cost.
+
+**Search Patterns** (hints for reviewers):
+- `renderItem`
+- `useOnyx\(ONYXKEYS\.COLLECTION\.[A-Z_]+\)`
diff --git a/.claude/skills/app-coding-standards/rules/perf-3-onyx-list-item-provider.md b/.claude/skills/app-coding-standards/rules/perf-3-onyx-list-item-provider.md
index cbf008f6724a..90795d706657 100644
--- a/.claude/skills/app-coding-standards/rules/perf-3-onyx-list-item-provider.md
+++ b/.claude/skills/app-coding-standards/rules/perf-3-onyx-list-item-provider.md
@@ -1,42 +1,61 @@
---
ruleId: PERF-3
-title: Use OnyxListItemProvider hooks instead of useOnyx in renderItem
+title: Use OnyxListItemProvider hooks for shared data in list rows
---
-## [PERF-3] Use OnyxListItemProvider hooks instead of useOnyx in renderItem
+## [PERF-3] Use OnyxListItemProvider hooks for shared data in list rows
### Reasoning
-Individual `useOnyx` calls in renderItem create separate subscriptions for each list item, causing memory overhead and update cascades. `OnyxListItemProvider` hooks provide optimized data access patterns specifically designed for list rendering performance.
+Some data is not the row's own item but is the same for every row, such as the session, betas or the card list. `OnyxListItemProvider` reads each of these keys once above the list and shares it with every row through context, so each row does not need its own subscription.
+
+A provider hook returns the whole value, and every write to that key re-renders every row that uses it. That is fine for small, rarely written keys that the row needs whole. When the row needs only a few records of a large shared key, such as the people it shows, it reads only those records.
### Incorrect
```tsx
-const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+// BAD: each row subscribes to the session separately.
+const [session] = useOnyx(ONYXKEYS.SESSION);
+```
+
+```tsx
+// BAD: the row shows two people but reads the whole personal details list,
+// so a write to any person re-renders every row.
+const personalDetails = usePersonalDetails();
```
### Correct
```tsx
-const personalDetails = usePersonalDetails();
+// GOOD: shared data that the row needs whole comes from the provider.
+const session = useSession();
+```
+
+```tsx
+// GOOD: the row reads only the people it shows.
+const [personalDetails] = usePersonalDetailsByIDs([report?.ownerAccountID, report?.managerID]);
```
---
### Review Metadata
-Flag ONLY when ALL of these are true:
+**Flag when ALL of these are true:**
-- Code is inside a renderItem function
-- A `useOnyx` hook is being called
-- A dedicated hook exists in OnyxListItemProvider for that data
+- The code is a list row component, or code that `renderItem` calls.
+- It reads data that is the same for every row, not the row's own item, and either:
+ - calls `useOnyx` on a key that has a hook in `OnyxListItemProvider`, or
+ - reads the whole personal details list (`usePersonalDetails`, `useAllPersonalDetails`) but uses only a few people whose IDs it knows.
**DO NOT flag if:**
-- Component is not inside renderItem
-- No OnyxListItemProvider hook exists for the data
-- Data structure is custom and requires raw Onyx access
+- The code is not part of a list row.
+- No `OnyxListItemProvider` hook exists for the key.
+- The row needs the whole personal details list, for example to search across all people.
+- The data is the row's own item. See [PERF-21](perf-21-rows-read-own-data.md) and [PERF-22](perf-22-row-single-member-subscriptions.md).
**Search Patterns** (hints for reviewers):
- `useOnyx`
- `renderItem`
+- `usePersonalDetails\(\)`
+- `useAllPersonalDetails`
diff --git a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts
index dd7bae51190b..25ac16ad11fb 100644
--- a/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts
+++ b/.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts
@@ -84,8 +84,9 @@ async function run() {
}
core.setOutput('MOBILE_EXPENSIFY_PR_LIST', mobileExpensifyPRList);
} catch (error) {
- console.error((error as Error).message);
- core.setFailed(error as Error);
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(message);
+ core.setFailed(error instanceof Error ? error : message);
}
}
diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js
index c6a083ec32cf..9fee4c2e6414 100644
--- a/.github/actions/javascript/getDeployPullRequestList/index.js
+++ b/.github/actions/javascript/getDeployPullRequestList/index.js
@@ -26716,8 +26716,9 @@ async function run() {
}
setOutput("MOBILE_EXPENSIFY_PR_LIST", mobileExpensifyPRList);
} catch (error2) {
- console.error(error2.message);
- setFailed(error2);
+ const message = error2 instanceof Error ? error2.message : String(error2);
+ console.error(message);
+ setFailed(error2 instanceof Error ? error2 : message);
}
}
if (import.meta.main) {
diff --git a/.github/actions/javascript/validateReassureOutput/action.yml b/.github/actions/javascript/validateReassureOutput/action.yml
index 9ef9e63316e7..e21b1a3e9bd1 100644
--- a/.github/actions/javascript/validateReassureOutput/action.yml
+++ b/.github/actions/javascript/validateReassureOutput/action.yml
@@ -1,9 +1,6 @@
name: 'Validate Regression Test Output'
description: 'Validates the output of regression tests and determines if a test action should fail.'
inputs:
- DURATION_DEVIATION_PERCENTAGE:
- description: Allowable percentage deviation for the mean duration in regression test results.
- required: true
COUNT_DEVIATION:
description: Allowable deviation for the mean count in regression test results.
required: true
diff --git a/.github/actions/javascript/validateReassureOutput/index.js b/.github/actions/javascript/validateReassureOutput/index.js
index e511f56ffd08..dd379f5b3565 100644
--- a/.github/actions/javascript/validateReassureOutput/index.js
+++ b/.github/actions/javascript/validateReassureOutput/index.js
@@ -19522,7 +19522,6 @@ import fs2 from "fs";
var run = () => {
const regressionOutput = JSON.parse(fs2.readFileSync(".reassure/output.json", "utf8"));
const countDeviation = Number(getInput("COUNT_DEVIATION", { required: true }));
- const durationDeviation = Number(getInput("DURATION_DEVIATION_PERCENTAGE", { required: true }));
if (regressionOutput.countChanged === void 0 || regressionOutput.countChanged.length === 0) {
console.log("No countChanged data available. Exiting...");
return true;
@@ -19543,13 +19542,6 @@ var run = () => {
} else {
console.log(`Render count difference ${renderCountDiff} is within the allowed deviation range of ${countDeviation}.`);
}
- const increasePercentage = (current.meanDuration - baseline.meanDuration) / baseline.meanDuration * 100;
- if (increasePercentage > durationDeviation) {
- setFailed(`Duration increase percentage exceeded the allowed deviation of ${durationDeviation}%. Current percentage: ${increasePercentage}%`);
- break;
- } else {
- console.log(`Duration increase percentage ${increasePercentage}% is within the allowed deviation range of ${durationDeviation}%.`);
- }
}
return true;
};
diff --git a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts
index e2603ba6dabf..6e0819ffde7b 100644
--- a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts
+++ b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts
@@ -6,7 +6,6 @@ import fs from 'fs';
const run = (): boolean => {
const regressionOutput = JSON.parse(fs.readFileSync('.reassure/output.json', 'utf8')) as CompareResult;
const countDeviation = Number(core.getInput('COUNT_DEVIATION', {required: true}));
- const durationDeviation = Number(core.getInput('DURATION_DEVIATION_PERCENTAGE', {required: true}));
if (regressionOutput.countChanged === undefined || regressionOutput.countChanged.length === 0) {
console.log('No countChanged data available. Exiting...');
@@ -34,14 +33,6 @@ const run = (): boolean => {
} else {
console.log(`Render count difference ${renderCountDiff} is within the allowed deviation range of ${countDeviation}.`);
}
-
- const increasePercentage = ((current.meanDuration - baseline.meanDuration) / baseline.meanDuration) * 100;
- if (increasePercentage > durationDeviation) {
- core.setFailed(`Duration increase percentage exceeded the allowed deviation of ${durationDeviation}%. Current percentage: ${increasePercentage}%`);
- break;
- } else {
- console.log(`Duration increase percentage ${increasePercentage}% is within the allowed deviation range of ${durationDeviation}%.`);
- }
}
return true;
diff --git a/.github/scripts/nitroSQLiteDatabaseLocation/HybridNitroSQLite.hpp b/.github/scripts/nitroSQLiteDatabaseLocation/HybridNitroSQLite.hpp
new file mode 100644
index 000000000000..b3d59db537e3
--- /dev/null
+++ b/.github/scripts/nitroSQLiteDatabaseLocation/HybridNitroSQLite.hpp
@@ -0,0 +1,12 @@
+#pragma once
+
+#include
+
+// OnLoad.mm only needs these storage paths from the HybridObject implementation.
+namespace margelo::nitro::rnnitrosqlite {
+class HybridNitroSQLite {
+public:
+ static inline std::string docPath;
+ static inline std::string migrationDocPath;
+};
+} // namespace margelo::nitro::rnnitrosqlite
diff --git a/.github/scripts/nitroSQLiteDatabaseLocation/RNNitroSQLite-Swift-Cxx-Umbrella.hpp b/.github/scripts/nitroSQLiteDatabaseLocation/RNNitroSQLite-Swift-Cxx-Umbrella.hpp
new file mode 100644
index 000000000000..f045b8704496
--- /dev/null
+++ b/.github/scripts/nitroSQLiteDatabaseLocation/RNNitroSQLite-Swift-Cxx-Umbrella.hpp
@@ -0,0 +1,3 @@
+#pragma once
+
+// The directory selector does not call any generated Swift bindings.
diff --git a/.github/scripts/nitroSQLiteDatabaseLocation/verifyLocation.mm b/.github/scripts/nitroSQLiteDatabaseLocation/verifyLocation.mm
new file mode 100644
index 000000000000..a0e64aff914a
--- /dev/null
+++ b/.github/scripts/nitroSQLiteDatabaseLocation/verifyLocation.mm
@@ -0,0 +1,28 @@
+#import
+#import
+#include
+
+// Execute the iOS branch on the existing macOS CI runner. NitroSQLite's macOS
+// default can differ from iOS and must not conceal a broken iOS plist setting.
+#undef TARGET_OS_OSX
+#define TARGET_OS_OSX 0
+#import NITRO_SQLITE_ONLOAD_PATH
+
+using margelo::nitro::rnnitrosqlite::HybridNitroSQLite;
+
+int main() {
+ @autoreleasepool {
+ // Given the host plist, when the installed native library runs +load.
+ NSString *expectedDirectory = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject;
+ NSString *actualDirectory = [NSString stringWithUTF8String:HybridNitroSQLite::docPath.c_str()];
+ // Then its database directory must remain Application Support.
+ if (actualDirectory == nil || ![actualDirectory.stringByResolvingSymlinksInPath isEqualToString:expectedDirectory.stringByResolvingSymlinksInPath]) {
+ fprintf(stderr, "NitroSQLite selected %s instead of Application Support. Check the installed dependency and the host plist.\n",
+ HybridNitroSQLite::docPath.c_str());
+ return 1;
+ }
+
+ puts("Native database location is Application Support.");
+ return 0;
+ }
+}
diff --git a/.github/scripts/verifyNitroSQLiteDatabaseLocation.sh b/.github/scripts/verifyNitroSQLiteDatabaseLocation.sh
new file mode 100644
index 000000000000..37d8d851a1db
--- /dev/null
+++ b/.github/scripts/verifyNitroSQLiteDatabaseLocation.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+TEST_DIR="$ROOT_DIR/.github/scripts/nitroSQLiteDatabaseLocation"
+NITRO_SQLITE_DIR="${NITRO_SQLITE_DIR:-$ROOT_DIR/node_modules/react-native-nitro-sqlite}"
+WORK_DIR="$(mktemp -d)"
+trap 'rm -rf "$WORK_DIR"' EXIT
+
+# Run the installed iOS directory selector, rather than copying its plist logic.
+# Only the bridge headers are replaced; the native selector runs unchanged.
+xcrun clang++ -std=c++20 -fobjc-arc -framework Foundation \
+ -I "$TEST_DIR" \
+ -DNITRO_SQLITE_ONLOAD_PATH="\"$NITRO_SQLITE_DIR/ios/OnLoad.mm\"" \
+ "$TEST_DIR/verifyLocation.mm" -o "$WORK_DIR/verifyLocation"
+
+PLISTS=(
+ "$ROOT_DIR/ios/NewExpensify/Info.plist"
+)
+if [[ "$#" -gt 0 ]]; then
+ PLISTS=("$@")
+fi
+
+for PLIST in "${PLISTS[@]}"; do
+ # Each plist runs in a fresh process because NitroSQLite selects its directory
+ # in Objective-C +load, before main() and before Onyx opens the database.
+ APP_DIR="$WORK_DIR/DatabaseLocation.app"
+ mkdir -p "$APP_DIR/Contents/MacOS"
+ cp "$WORK_DIR/verifyLocation" "$APP_DIR/Contents/MacOS/verifyLocation"
+ python3 - "$PLIST" "$APP_DIR/Contents/Info.plist" <<'PY'
+import plistlib
+import sys
+from pathlib import Path
+
+info = plistlib.loads(Path(sys.argv[1]).read_bytes())
+# Bundle metadata contains build substitutions in the source host plists.
+# Keep every database setting as supplied by the host, including unknown keys.
+info.update(CFBundleExecutable='verifyLocation', CFBundleIdentifier='com.expensify.database-location-test', CFBundlePackageType='APPL')
+Path(sys.argv[2]).write_bytes(plistlib.dumps(info))
+PY
+ echo "Checking native database location for $PLIST"
+ "$APP_DIR/Contents/MacOS/verifyLocation"
+done
diff --git a/.github/workflows/authorChecklist.yml b/.github/workflows/authorChecklist.yml
index 2603542b47f8..46b690fe4815 100644
--- a/.github/workflows/authorChecklist.yml
+++ b/.github/workflows/authorChecklist.yml
@@ -16,7 +16,7 @@ jobs:
if: |
github.actor != 'OSBotify'
&& github.actor != 'imgbot[bot]'
- && github.actor != 'MelvinBot'
+ && github.event.pull_request.user.login != 'MelvinBot'
steps:
- name: Checkout
uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
diff --git a/.github/workflows/buildAdHoc.yml b/.github/workflows/buildAdHoc.yml
index 3633c87fb9cb..9aa04f3f7089 100644
--- a/.github/workflows/buildAdHoc.yml
+++ b/.github/workflows/buildAdHoc.yml
@@ -127,8 +127,8 @@ jobs:
# v6
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ role-to-assume: arn:aws:iam::949552126497:role/app-adhoc-web-deploy-gh-actions
+ role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }}
aws-region: us-east-1
- name: Deploy to S3
diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml
index de8b5ff08840..8bdba2f7e5f3 100644
--- a/.github/workflows/buildAndroid.yml
+++ b/.github/workflows/buildAndroid.yml
@@ -156,8 +156,10 @@ jobs:
# v6
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions
+ role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }}
+ # Match the 6h job timeout because Rock does not refresh credentials before uploading
+ role-duration-seconds: 21600
aws-region: us-east-1
- name: Rock Remote Build - Android
diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml
index 7cdb09c2d8a1..c79ca9aec984 100644
--- a/.github/workflows/buildIOS.yml
+++ b/.github/workflows/buildIOS.yml
@@ -237,8 +237,10 @@ jobs:
# v6
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions
+ role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }}
+ # Match the 6h job timeout because Rock does not refresh credentials before uploading
+ role-duration-seconds: 21600
aws-region: us-east-1
- name: Prepare provisioning profiles JSON
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index eeb9592a36f2..d2cebadd8ba8 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -201,6 +201,8 @@ jobs:
androidBuild:
name: Build Android HybridApp
needs: [prep]
+ # Preserve the default token scopes while adding OIDC access for the called workflow
+ permissions: write-all
if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }}
uses: ./.github/workflows/buildAndroid.yml
with:
@@ -429,6 +431,8 @@ jobs:
iosBuild:
name: Build iOS HybridApp
needs: [prep]
+ # Preserve the default token scopes while adding OIDC access for the called workflow
+ permissions: write-all
if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }}
uses: ./.github/workflows/buildIOS.yml
with:
@@ -703,6 +707,8 @@ jobs:
name: Deploy Web to S3
needs: [prep, webBuild, buildStorybook]
if: ${{ always() && needs.webBuild.result == 'success' }}
+ # Preserve the default token scopes while adding OIDC access
+ permissions: write-all
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
@@ -735,8 +741,8 @@ jobs:
# v6
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ role-to-assume: arn:aws:iam::949552126497:role/app-${{ env.DEPLOY_ENV }}-web-deploy-gh-actions
+ role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }}
aws-region: us-east-1
- name: Deploy to S3
@@ -763,17 +769,19 @@ jobs:
CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }}
HOST: ${{ env.DEPLOY_ENV == 'production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }}
+ # The staging host is verified by webDeployStagingWorker, which deploys after this job.
- name: Verify deploy
+ if: ${{ env.DEPLOY_ENV == 'production' }}
run: |
APP_VERSION=$(jq -r .version < package.json)
./.github/scripts/verifyDeploy.sh "$HOST" "$APP_VERSION"
env:
- HOST: ${{ env.DEPLOY_ENV == 'production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }}
+ HOST: ${{ vars.WEB_PRODUCTION_HOST }}
webDeployTestStaging:
name: Deploy Web to test-staging.new.expensify.com
- needs: [prep, webBuild]
- if: ${{ needs.webBuild.result == 'success' && needs.prep.outputs.DEPLOY_ENV == 'staging' }}
+ needs: [prep, webBuild, buildStorybook]
+ if: ${{ always() && needs.webBuild.result == 'success' && needs.prep.outputs.DEPLOY_ENV == 'staging' }}
# Trial hosting for the web build, so a failure here must not fail the staging deploy.
continue-on-error: true
runs-on: blacksmith-2vcpu-ubuntu-2404
@@ -801,6 +809,14 @@ jobs:
- name: Extract web build
run: tar -xzf "${{ needs.webBuild.outputs.TAR_FILENAME }}"
+ - name: Download storybook docs artifact
+ continue-on-error: true
+ # v7
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
+ with:
+ name: storybook-docs-artifact
+ path: ./dist/docs
+
- name: Install Cloudflare Worker dependencies
working-directory: web/cloudflare-worker
run: npm ci
@@ -816,7 +832,86 @@ jobs:
- name: Verify deploy
run: |
APP_VERSION=$(jq -r .version < package.json)
- ./.github/scripts/verifyDeploy.sh https://test-staging.new.expensify.com "$APP_VERSION"
+ ./.github/scripts/verifyDeploy.sh "$HOST" "$APP_VERSION"
+ env:
+ HOST: https://test-staging.new.expensify.com
+
+ webDeployStagingWorker:
+ name: Deploy Web to the staging Cloudflare Worker
+ # After webDeploy, so the S3 copy of this build is in place before the Worker's version check.
+ needs: [prep, webBuild, buildStorybook, webDeploy]
+ if: ${{ always() && needs.webBuild.result == 'success' && needs.prep.outputs.DEPLOY_ENV == 'staging' }}
+ runs-on: blacksmith-2vcpu-ubuntu-2404
+ steps:
+ - name: Checkout
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
+ with:
+ ref: ${{ needs.prep.outputs.DEPLOY_SHA }}
+
+ - name: Setup Node
+ # v6.1.0
+ uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
+ with:
+ node-version-file: '.nvmrc'
+ cache: npm
+ cache-dependency-path: web/cloudflare-worker/package-lock.json
+
+ - name: Download web build artifact
+ # v7
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
+ with:
+ name: web-build-tar-gz-artifact
+ path: ./
+
+ - name: Extract web build
+ run: tar -xzf "${{ needs.webBuild.outputs.TAR_FILENAME }}"
+
+ # The Workers serve these to tabs still running this build after later deploys.
+ - name: Upload web build to the previous builds bucket
+ continue-on-error: true
+ run: |
+ if [[ -z "$AWS_ACCESS_KEY_ID" ]]; then
+ echo "::warning::CLOUDFLARE_R2_WEB_ASSETS_ACCESS_KEY_ID is not set, so this build is not available to future deploys."
+ exit 0
+ fi
+ aws configure set default.s3.preferred_transfer_client classic
+ # Re-uploads every file, so files that stay the same across builds don't hit the bucket's 60-day expiry while still in use.
+ aws s3 cp dist s3://new-expensify-web-assets-staging/ --recursive \
+ --endpoint-url "https://$CLOUDFLARE_ACCOUNT_ID.r2.cloudflarestorage.com" \
+ --no-progress \
+ --exclude '*.br' --exclude '*.map'
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_WEB_ASSETS_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_WEB_ASSETS_SECRET_ACCESS_KEY }}
+ AWS_DEFAULT_REGION: auto
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+
+ - name: Download storybook docs artifact
+ continue-on-error: true
+ # v7
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
+ with:
+ name: storybook-docs-artifact
+ path: ./dist/docs
+
+ - name: Install Cloudflare Worker dependencies
+ working-directory: web/cloudflare-worker
+ run: npm ci
+
+ - name: Deploy to Cloudflare Workers
+ working-directory: web/cloudflare-worker
+ run: npx wrangler deploy --env staging
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ WRANGLER_SEND_METRICS: false
+
+ - name: Verify deploy
+ run: |
+ APP_VERSION=$(jq -r .version < package.json)
+ ./.github/scripts/verifyDeploy.sh "$HOST" "$APP_VERSION"
+ env:
+ HOST: ${{ vars.WEB_STAGING_HOST }}
buildStorybook:
name: Build storybook docs
@@ -865,6 +960,7 @@ jobs:
iosSubmit,
webBuild,
webDeploy,
+ webDeployStagingWorker,
victoryChartRendererBuild,
]
steps:
@@ -880,7 +976,7 @@ jobs:
name: Post a Slack message when the deploy is cancelled manually
runs-on: blacksmith-2vcpu-ubuntu-2404
if: ${{ cancelled() }}
- needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy]
+ needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy, webDeployStagingWorker]
steps:
- name: Check whether a newer deploy run exists
id: check
@@ -919,7 +1015,7 @@ jobs:
ANDROID_RESULT: ${{ steps.platformResults.outputs.ANDROID_RESULT }}
IOS_RESULT: ${{ steps.platformResults.outputs.IOS_RESULT }}
WEB_RESULT: ${{ steps.platformResults.outputs.WEB_RESULT }}
- needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy, victoryChartRendererBuild]
+ needs: [androidBuild, androidUploadGooglePlay, androidSubmit, iosBuild, iosUploadTestflight, iosSubmit, webBuild, webDeploy, webDeployStagingWorker, victoryChartRendererBuild]
if: ${{ always() }}
steps:
# Determine effective result for each platform
@@ -954,9 +1050,11 @@ jobs:
echo "IOS_RESULT=${{ needs.iosUploadTestflight.result }}" >> "$GITHUB_OUTPUT"
fi
- # Web: propagate build failure even when deploy is skipped
+ # Web: propagate build failure even when deploy is skipped. Staging is only deployed once its Worker is too.
if [ "${{ needs.webBuild.result }}" == "failure" ]; then
echo "WEB_RESULT=failure" >> "$GITHUB_OUTPUT"
+ elif [ "${{ env.DEPLOY_ENV }}" == "staging" ] && [ "${{ needs.webDeployStagingWorker.result }}" != "success" ]; then
+ echo "WEB_RESULT=${{ needs.webDeployStagingWorker.result }}" >> "$GITHUB_OUTPUT"
else
echo "WEB_RESULT=${{ needs.webDeploy.result }}" >> "$GITHUB_OUTPUT"
fi
diff --git a/.github/workflows/publishReactNativeArtifacts.yml b/.github/workflows/publishReactNativeArtifacts.yml
index a385e36e4a37..fa134645b37e 100644
--- a/.github/workflows/publishReactNativeArtifacts.yml
+++ b/.github/workflows/publishReactNativeArtifacts.yml
@@ -41,6 +41,12 @@ on:
- patches/react-native/@react-native+*.patch
- Mobile-Expensify
- scripts/artifacts-utils/compute-patches-hash.sh
+ # Keep in sync with the "Check if artifact publishing workflows changed" step below.
+ - .github/workflows/publishReactNativeArtifacts.yml
+ - .github/workflows/publishReactNativeiOSArtifacts.yml
+ - .github/workflows/publishReactNativeAndroidArtifacts.yml
+ - .github/actions/composite/fetchReactNativePrebuild/action.yml
+ - scripts/artifacts-utils/ios/publish/**
jobs:
verifyPatches:
@@ -117,6 +123,24 @@ jobs:
echo "::notice::Detected changes in Standalone NewDot patches (${{ steps.getOldPatchesHash.outputs.STANDALONE_APP_HASH }} -> ${{ steps.getNewPatchesHash.outputs.STANDALONE_APP_HASH }})"
fi
+ - name: Check if artifact publishing workflows changed
+ if: ${{ github.event.after != '' }}
+ id: didWorkflowsChange
+ run: |
+ # Edits to the publishing workflows, the prebuild fetch action, or the iOS publish Gradle project
+ # change how artifacts are built without touching patches or the RN version. Keep in sync with `on.push.paths`.
+ if git diff --quiet '${{ github.event.before }}' '${{ github.event.after }}' -- \
+ .github/workflows/publishReactNativeArtifacts.yml \
+ .github/workflows/publishReactNativeiOSArtifacts.yml \
+ .github/workflows/publishReactNativeAndroidArtifacts.yml \
+ .github/actions/composite/fetchReactNativePrebuild/action.yml \
+ scripts/artifacts-utils/ios/publish; then
+ echo "DID_WORKFLOWS_CHANGE=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "DID_WORKFLOWS_CHANGE=true" >> "$GITHUB_OUTPUT"
+ echo "::notice::Detected changes in the artifact publishing workflows"
+ fi
+
- name: Get artifact build targets
id: getArtifactBuildTargets
run: |
@@ -139,8 +163,8 @@ jobs:
fi
# push path: derive targets from what actually changed between github.event.before and github.event.after.
- # Version change or standalone patch change forces a full rebuild of both hybrid and standalone.
- if [[ '${{ steps.didVersionChange.outputs.DID_VERSION_CHANGE }}' == 'true' || '${{ steps.didPatchesChange.outputs.DID_STANDALONE_APP_PATCHES_CHANGE }}' == 'true' ]]; then
+ # Version change, standalone patch change or a change to the publishing workflows forces a full rebuild of both hybrid and standalone.
+ if [[ '${{ steps.didVersionChange.outputs.DID_VERSION_CHANGE }}' == 'true' || '${{ steps.didPatchesChange.outputs.DID_STANDALONE_APP_PATCHES_CHANGE }}' == 'true' || '${{ steps.didWorkflowsChange.outputs.DID_WORKFLOWS_CHANGE }}' == 'true' ]]; then
echo "BUILD_TARGETS=[\"true\", \"false\"]" >> "$GITHUB_OUTPUT"
elif [[ '${{ steps.didPatchesChange.outputs.DID_HYBRID_APP_PATCHES_CHANGE }}' == 'true' ]]; then
echo "BUILD_TARGETS=[\"true\"]" >> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml
index 3eeb13a31bd5..00af550c2fe2 100644
--- a/.github/workflows/reassurePerformanceTests.yml
+++ b/.github/workflows/reassurePerformanceTests.yml
@@ -4,7 +4,57 @@ on:
pull_request:
types: [opened, synchronize]
branches-ignore: [staging, production]
- paths-ignore: [docs/**, help/**, .github/**, contributingGuides/**, tests/**, '**.md', '**.sh']
+ # Ordered: the last matching pattern wins.
+ paths:
+ - '**'
+ # Native and OldDot: Jest never loads these.
+ - '!ios/**'
+ - '!android/**'
+ - '!vendor/**'
+ - '!fastlane/**'
+ - '!Mobile-Expensify'
+ - '!Gemfile'
+ - '!Gemfile.lock'
+ - '!.ruby-version'
+ - '!.java-version'
+ - '!.buckconfig'
+ - '!.watchmanconfig'
+ # Web and Storybook builds: nothing under src/, jest/ or tests/ imports these.
+ - '!web/**'
+ - '!.well-known/**'
+ - '!.storybook/**'
+ - '!config/rsbuild/**'
+ - '!config/repack/**'
+ - '!config/certificatePinning/**'
+ # Lint, format and repo tooling config.
+ - '!eslint-plugin-local-rules/**'
+ - '!config/eslint/**'
+ - '!eslint.config.mjs'
+ - '!.oxfmtrc.json'
+ - '!cspell.json'
+ - '!codecov.yml'
+ - '!.imgbotconfig'
+ - '!.mcp.json'
+ # Docs and agent tooling.
+ - '!docs/**'
+ - '!contributingGuides/**'
+ - '!.claude/**'
+ - '!evals/**'
+ - '!prompts/**'
+ # Tests: only the perf harness feeds the measurement.
+ - '!tests/**'
+ - 'tests/perf-test/**'
+ - 'tests/utils/**'
+ - 'tests/unit/currencyList.json'
+ - 'tests/__mocks__/**'
+ # CI: only this check, its baseline seed, and the actions it runs.
+ - '!.github/**'
+ - '.github/workflows/reassurePerformanceTests.yml'
+ - '.github/workflows/seedJestPerfCache.yml'
+ - '.github/actions/javascript/validateReassureOutput/**'
+ - '.github/actions/composite/setupNode/**'
+ - '!**.md'
+ - '!**.sh'
# Cancel a run still measuring an earlier push to the same PR. pull_request-only, so github.ref is
# refs/pull//merge and the group is one PR.
@@ -212,5 +262,4 @@ jobs:
id: validateReassureOutput
uses: ./.github/actions/javascript/validateReassureOutput
with:
- DURATION_DEVIATION_PERCENTAGE: 20
COUNT_DEVIATION: 0
diff --git a/.github/workflows/remote-build-android.yml b/.github/workflows/remote-build-android.yml
index 7462d1498d15..e24ff55b08b6 100644
--- a/.github/workflows/remote-build-android.yml
+++ b/.github/workflows/remote-build-android.yml
@@ -47,6 +47,8 @@ jobs:
build:
needs: [prep, resolveRefs]
+ # Preserve the default token scopes while adding OIDC access
+ permissions: write-all
runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-2vcpu-ubuntu-2404' }}
strategy:
fail-fast: false
@@ -82,8 +84,10 @@ jobs:
# v6
uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59
with:
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions
+ role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }}
+ # Match the 6h job timeout because Rock does not refresh credentials before uploading
+ role-duration-seconds: 21600
aws-region: us-east-1
- name: Rock Remote Build - Android
diff --git a/.github/workflows/remote-build-ios.yml b/.github/workflows/remote-build-ios.yml
index 482f8a1d32f6..f246dcf43b0d 100644
--- a/.github/workflows/remote-build-ios.yml
+++ b/.github/workflows/remote-build-ios.yml
@@ -47,6 +47,8 @@ jobs:
build:
needs: [prep, resolveRefs]
+ # Preserve the default token scopes while adding OIDC access
+ permissions: write-all
runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }}
strategy:
fail-fast: false
@@ -94,8 +96,10 @@ jobs:
# v6
uses: aws-actions/configure-aws-credentials@99214aa6889fcddfa57764031d71add364327e59
with:
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ role-to-assume: arn:aws:iam::949552126497:role/app-rock-cache-gh-actions
+ role-session-name: gh-${{ github.run_id }}-${{ github.run_attempt }}
+ # Match the 6h job timeout because Rock does not refresh credentials before uploading
+ role-duration-seconds: 21600
aws-region: us-east-1
- name: Rock Remote Build - iOS
diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml
index c58bffcfb075..a29336e8684b 100644
--- a/.github/workflows/testBuild.yml
+++ b/.github/workflows/testBuild.yml
@@ -63,6 +63,8 @@ jobs:
buildApps:
needs: [prep, resolveRefs]
+ # Preserve the default token scopes while adding OIDC access for the called workflow
+ permissions: write-all
uses: ./.github/workflows/buildAdHoc.yml
with:
# Fall back to App main when nothing is resolved (test builds default to the latest NewDot).
diff --git a/.github/workflows/testBuildOnPush.yml b/.github/workflows/testBuildOnPush.yml
index b7f3d20e3d0b..286e2acfc44c 100644
--- a/.github/workflows/testBuildOnPush.yml
+++ b/.github/workflows/testBuildOnPush.yml
@@ -70,6 +70,8 @@ jobs:
buildApps:
needs: [prep]
+ # Preserve the default token scopes while adding OIDC access for the called workflow
+ permissions: write-all
uses: ./.github/workflows/buildAdHoc.yml
with:
APP_REF: ${{ needs.prep.outputs.APP_REF }}
diff --git a/.github/workflows/verifyHybridApp.yml b/.github/workflows/verifyHybridApp.yml
index f16b9f77ac5d..762f08d95427 100644
--- a/.github/workflows/verifyHybridApp.yml
+++ b/.github/workflows/verifyHybridApp.yml
@@ -75,6 +75,10 @@ jobs:
verify_android:
name: Verify Android HybridApp builds on main
+ permissions:
+ contents: read
+ id-token: write
+ pull-requests: write
if: ${{ !github.event.pull_request.head.repo.fork && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }}
cache-mode: read
uses: ./.github/workflows/buildAndroid.yml
@@ -85,6 +89,10 @@ jobs:
verify_ios:
name: Verify iOS HybridApp builds on main
+ permissions:
+ contents: read
+ id-token: write
+ pull-requests: write
if: ${{ !github.event.pull_request.head.repo.fork && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }}
cache-mode: read
uses: ./.github/workflows/buildIOS.yml
diff --git a/.github/workflows/verifyPodfile.yml b/.github/workflows/verifyPodfile.yml
index b9978711a9c3..e806fa0b1317 100644
--- a/.github/workflows/verifyPodfile.yml
+++ b/.github/workflows/verifyPodfile.yml
@@ -1,4 +1,4 @@
-name: Verify Podfile
+name: Verify iOS native configuration
on:
pull_request:
@@ -8,6 +8,10 @@ on:
- ios/**
- 'package.json'
- 'package-lock.json'
+ - 'patches/react-native-nitro-sqlite/**'
+ - '.github/scripts/verifyNitroSQLiteDatabaseLocation.sh'
+ - '.github/scripts/nitroSQLiteDatabaseLocation/**'
+ - '.github/workflows/verifyPodfile.yml'
jobs:
verify:
@@ -21,5 +25,8 @@ jobs:
- name: Setup Node
uses: ./.github/actions/composite/setupNode
+ - name: Verify native NitroSQLite database location
+ run: bash ./.github/scripts/verifyNitroSQLiteDatabaseLocation.sh
+
- name: Verify podfile
run: ./.github/scripts/verifyPodfile.sh
diff --git a/.nvmrc b/.nvmrc
index 3eb1386fcc08..63f87c20ef9f 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-26.5.0
+26.12.1
diff --git a/.storybook/mockPaths.ts b/.storybook/mockPaths.ts
index 2d97ca447146..960802b0d25d 100644
--- a/.storybook/mockPaths.ts
+++ b/.storybook/mockPaths.ts
@@ -8,6 +8,9 @@ const dirname = path.dirname(filename);
/* eslint-disable @typescript-eslint/naming-convention */
export default {
'@react-native-community/netinfo': path.resolve(dirname, '../__mocks__/@react-native-community/netinfo.ts'),
- '@react-navigation/native': path.resolve(dirname, '../__mocks__/@react-navigation/native'),
+ // The static mock, plus a story-owned route stack so a whole flow can mount
+ '@react-navigation/native': path.resolve(dirname, './mocks/react-navigation-native.ts'),
+ // Modal backdrops read the stack card animation, which no story mounts
+ '@react-navigation/stack': path.resolve(dirname, './mocks/react-navigation-stack.ts'),
};
/* eslint-enable @typescript-eslint/naming-convention */
diff --git a/.storybook/mocks/navigationStoryLoader.cjs b/.storybook/mocks/navigationStoryLoader.cjs
new file mode 100644
index 000000000000..6e7bfe49cd2c
--- /dev/null
+++ b/.storybook/mocks/navigationStoryLoader.cjs
@@ -0,0 +1,11 @@
+const path = require('path');
+
+/**
+ * Rewrites the App's Navigation module for Storybook so navigate and goBack land on the story's route stack.
+ * A loader is used because `@libs` is a prefix alias that no alias entry for a single module can outrank.
+ */
+module.exports = function navigationStoryLoader(source) {
+ const routerPath = JSON.stringify(path.resolve(__dirname, './storyRouter.tsx'));
+ const renamed = source.replace(/export default \{/, 'const navigationForStorybook = {');
+ return `import {withStoryRouter} from ${routerPath};\n${renamed}\nexport default withStoryRouter(navigationForStorybook);\n`;
+};
diff --git a/.storybook/mocks/react-navigation-native.ts b/.storybook/mocks/react-navigation-native.ts
new file mode 100644
index 000000000000..097840b9f798
--- /dev/null
+++ b/.storybook/mocks/react-navigation-native.ts
@@ -0,0 +1,86 @@
+import {useEffect} from 'react';
+
+import {useStoryRouter} from './storyRouter';
+
+const noop = () => {};
+
+const staticRoute = {key: 'story', name: 'StoryFlow', params: {}};
+const staticNavigation = {
+ navigate: noop,
+ goBack: noop,
+ setParams: noop,
+ canGoBack: () => false,
+ getState: () => ({routes: []}),
+ isFocused: () => true,
+ addListener: () => noop,
+};
+
+/** Inside a StoryRouterProvider the route comes from the story's stack. Elsewhere no story mounts a navigator. */
+function useRoute() {
+ const router = useStoryRouter();
+ return router ? {...staticRoute, params: router.current} : staticRoute;
+}
+
+function useNavigation() {
+ const router = useStoryRouter();
+ if (!router) {
+ return staticNavigation;
+ }
+ return {...staticNavigation, setParams: router.setParams, goBack: router.pop, canGoBack: () => true};
+}
+
+const useIsFocused = () => true;
+const useTheme = () => ({});
+const useLocale = () => ({});
+const useNavigationState = () => undefined;
+const usePreventRemove = noop;
+const triggerTransitionEnd = noop;
+const useFocusEffect = (callback: () => (() => void) | void) => {
+ useEffect(() => callback(), [callback]);
+};
+function Link() {
+ return null;
+}
+function LinkingContext() {
+ return null;
+}
+function NavigationContainer() {
+ return null;
+}
+function ServerContainer() {
+ return null;
+}
+function ThemeProvider() {
+ return null;
+}
+const DarkTheme = {};
+const DefaultTheme = {};
+const useLinkBuilder = () => null;
+const useLinkProps = () => null;
+const useLinkTo = () => null;
+const useScrollToTop = () => null;
+
+// Everything a story does not override comes from the navigation core
+export * from '@react-navigation/core';
+export {
+ useRoute,
+ useNavigation,
+ useIsFocused,
+ useTheme,
+ useLocale,
+ useNavigationState,
+ usePreventRemove,
+ triggerTransitionEnd,
+ useFocusEffect,
+ Link,
+ LinkingContext,
+ NavigationContainer,
+ ServerContainer,
+ ThemeProvider,
+ DarkTheme,
+ DefaultTheme,
+ useLinkBuilder,
+ useLinkProps,
+ useLinkTo,
+ useScrollToTop,
+};
diff --git a/.storybook/mocks/react-navigation-stack.ts b/.storybook/mocks/react-navigation-stack.ts
new file mode 100644
index 000000000000..6ef3f6ec056f
--- /dev/null
+++ b/.storybook/mocks/react-navigation-stack.ts
@@ -0,0 +1,27 @@
+// useCardAnimation returns React Native Animated values, not Reanimated ones
+// eslint-disable-next-line no-restricted-imports
+import {Animated} from 'react-native';
+
+// The alias points the package name at this file, so the real module has to be reached by path, which has no declaration
+// @ts-expect-error -- see above
+// eslint-disable-next-line import/no-relative-packages
+export * from '../../node_modules/@react-navigation/stack/lib/module/index.js';
+
+const presented = new Animated.Value(1);
+const idle = new Animated.Value(0);
+
+/** No story mounts a stack screen, so every modal is treated as fully presented */
+function useCardAnimation() {
+ return {
+ current: {progress: presented},
+ next: undefined,
+ closing: idle,
+ swiping: idle,
+ inverted: presented,
+ index: 0,
+ layouts: {screen: {width: 0, height: 0}},
+ insets: {top: 0, right: 0, bottom: 0, left: 0},
+ };
+}
+
+export {useCardAnimation};
diff --git a/.storybook/mocks/storyRouter.tsx b/.storybook/mocks/storyRouter.tsx
new file mode 100644
index 000000000000..41b38a883706
--- /dev/null
+++ b/.storybook/mocks/storyRouter.tsx
@@ -0,0 +1,106 @@
+import type {ReactNode} from 'react';
+
+import React, {createContext, useContext, useEffect, useMemo, useRef, useState} from 'react';
+
+type StoryRouteParams = {subPage?: string; action?: 'edit'};
+
+type StoryRouter = {
+ current: StoryRouteParams;
+ push: (params: StoryRouteParams) => void;
+ replace: (params: StoryRouteParams) => void;
+ pop: () => boolean;
+ setParams: (params: StoryRouteParams) => void;
+};
+
+type ActiveStoryRouter = {routePrefix: string; api: StoryRouter};
+
+const StoryRouterContext = createContext(undefined);
+
+let activeStoryRouter: ActiveStoryRouter | undefined;
+
+/** A one-screen stack for stories: the App's navigation hooks and helpers read and write it while a story is mounted */
+function StoryRouterProvider({routePrefix, initialParams = {}, children}: {routePrefix: string; initialParams?: StoryRouteParams; children: ReactNode}) {
+ const [stack, setStack] = useState([initialParams]);
+ const stackRef = useRef(stack);
+ useEffect(() => {
+ stackRef.current = stack;
+ }, [stack]);
+
+ const api = useMemo(
+ () => ({
+ current: stack.at(-1) ?? {},
+ push: (params) => setStack((previous) => [...previous, params]),
+ replace: (params) => setStack((previous) => [...previous.slice(0, -1), params]),
+ pop: () => {
+ if (stackRef.current.length <= 1) {
+ return false;
+ }
+ setStack((previous) => previous.slice(0, -1));
+ return true;
+ },
+ setParams: (params) => setStack((previous) => [...previous.slice(0, -1), {...previous.at(-1), ...params}]),
+ }),
+ [stack],
+ );
+
+ useEffect(() => {
+ activeStoryRouter = {routePrefix, api};
+ return () => {
+ activeStoryRouter = undefined;
+ };
+ }, [routePrefix, api]);
+
+ return {children};
+}
+
+function useStoryRouter(): StoryRouter | undefined {
+ return useContext(StoryRouterContext);
+}
+
+function getActiveStoryRouter(): ActiveStoryRouter | undefined {
+ return activeStoryRouter;
+}
+
+/** Reads `subPage` and `action` back out of a route the story's route builder produced */
+function parseStoryRoute(routePrefix: string, route: string): StoryRouteParams {
+ const path = route.split('?').at(0) ?? '';
+ const rest = path.startsWith(routePrefix) ? path.slice(routePrefix.length) : path;
+ const [subPage, action] = rest.split('/').filter(Boolean);
+ return {subPage, action: action === 'edit' ? 'edit' : undefined};
+}
+
+type NavigationLike = {
+ navigate: (route: string, options?: {forceReplace?: boolean}) => void;
+ goBack: (backToRoute?: string, options?: unknown) => void;
+};
+
+/** Wraps the App's Navigation object so that, while a story router is mounted, navigation lands on its stack */
+function withStoryRouter(navigation: T): T {
+ const navigate: NavigationLike['navigate'] = (route, options) => {
+ const router = getActiveStoryRouter();
+ if (!router) {
+ navigation.navigate(route, options);
+ return;
+ }
+ const params = parseStoryRoute(router.routePrefix, route);
+ if (options?.forceReplace) {
+ router.api.replace(params);
+ return;
+ }
+ router.api.push(params);
+ };
+ const goBack: NavigationLike['goBack'] = (backToRoute, options) => {
+ const router = getActiveStoryRouter();
+ if (!router) {
+ navigation.goBack(backToRoute, options);
+ return;
+ }
+ if (!router.api.pop() && backToRoute) {
+ router.api.replace(parseStoryRoute(router.routePrefix, backToRoute));
+ }
+ };
+ return {...navigation, navigate, goBack};
+}
+
+export {StoryRouterProvider, useStoryRouter, getActiveStoryRouter, parseStoryRoute, withStoryRouter};
+export type {StoryRouteParams};
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 8730a9acda24..da8a0da4930b 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -1,3 +1,4 @@
+import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider';
import EnvironmentProvider from '@components/EnvironmentContextProvider';
import OnyxListItemProvider from '@components/OnyxListItemProvider';
import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext';
@@ -9,7 +10,9 @@ import colors from '@styles/theme/colors';
import ComposeProviders from '@src/components/ComposeProviders';
import HTMLEngineProvider from '@src/components/HTMLEngineProvider';
+import KeyboardProvider from '@src/components/KeyboardProvider';
import {LocaleContextProvider} from '@src/components/LocaleContextProvider';
+import {ModalProvider} from '@src/components/Modal/Global/ModalContext';
import {KeyboardStateProvider} from '@src/components/withKeyboardState';
import CONST from '@src/CONST';
import IntlStore from '@src/languages/IntlStore';
@@ -22,12 +25,14 @@ import React from 'react';
import Onyx from 'react-native-onyx';
import {SafeAreaProvider} from 'react-native-safe-area-context';
+import currencyList from '../tests/unit/currencyList.json';
import './fonts.css';
registerMiddlewares();
Onyx.init({
keys: ONYXKEYS,
+ initialKeyStates: {[ONYXKEYS.CURRENCY_LIST]: currencyList},
});
IntlStore.load(CONST.LOCALES.EN);
@@ -38,9 +43,12 @@ const decorators = [
components={[
OnyxListItemProvider,
LocaleContextProvider,
+ CurrencyListContextProvider,
HTMLEngineProvider,
SafeAreaProvider,
PortalProvider,
+ KeyboardProvider,
+ ModalProvider,
EnvironmentProvider,
KeyboardStateProvider,
SearchContextProvider,
diff --git a/.storybook/rsbuild.config.ts b/.storybook/rsbuild.config.ts
index 5f7bae2e98ad..4f7bdb02b54a 100644
--- a/.storybook/rsbuild.config.ts
+++ b/.storybook/rsbuild.config.ts
@@ -1,11 +1,15 @@
import type {RsbuildConfig} from '@rsbuild/core';
import {mergeRsbuildConfig} from '@rsbuild/core';
+import path from 'path';
+import {fileURLToPath} from 'url';
// Storybook 10 loads TS files directly and requires .ts extension for ESM imports
// @ts-expect-error -- Can't use .ts extensions without allowImportingTsExtensions in tsconfig
import mockPaths from './mockPaths.ts';
+const dirname = path.dirname(fileURLToPath(import.meta.url));
+
let envFile: string;
switch (process.env.ENV) {
case 'production':
@@ -30,6 +34,15 @@ const rsbuildFinal = async (config: RsbuildConfig): Promise => {
// they're already set in `shared.resolve.alias` and `mergeRsbuildConfig` keeps both.
alias: mockPaths,
},
+ tools: {
+ rspack: (rspackConfig) => {
+ // `@libs` is a prefix alias no single-module alias can outrank, so the module is rewritten by a loader instead
+ rspackConfig.module?.rules?.push({
+ test: /src\/libs\/Navigation\/Navigation\.ts$/,
+ use: [{loader: path.resolve(dirname, './mocks/navigationStoryLoader.cjs')}],
+ });
+ },
+ },
});
};
diff --git a/Mobile-Expensify b/Mobile-Expensify
index 903b4645c4e9..31e1cb035552 160000
--- a/Mobile-Expensify
+++ b/Mobile-Expensify
@@ -1 +1 @@
-Subproject commit 903b4645c4e95b2a637d496db39b940292b84804
+Subproject commit 31e1cb035552ad475b8e512a74cdbf572e07c182
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 85d4aee072a0..f4cc74f78007 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -114,8 +114,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009050804
- versionName "9.5.8-4"
+ versionCode 1009051601
+ versionName "9.5.16-1"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/android/app/src/main/java/com/expensify/chat/AppStateTrackerModule.kt b/android/app/src/main/java/com/expensify/chat/AppStateTrackerModule.kt
index 2f96578a4af3..5546916c38f9 100644
--- a/android/app/src/main/java/com/expensify/chat/AppStateTrackerModule.kt
+++ b/android/app/src/main/java/com/expensify/chat/AppStateTrackerModule.kt
@@ -4,6 +4,7 @@ import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.UiThreadUtil
import com.expensify.chat.MainActivity
@@ -25,4 +26,14 @@ class AppStateTrackerModule(reactContext: ReactApplicationContext) : ReactContex
promise.resolve(false)
}
}
+
+ /**
+ * Tells Android that NewDot finished its first screen, which records time to full display for this launch.
+ */
+ @ReactMethod
+ fun reportFullyDrawn() {
+ UiThreadUtil.runOnUiThread {
+ reactApplicationContext.getCurrentActivity()?.reportFullyDrawn()
+ }
+ }
}
diff --git a/assets/animations/CustomAgents.lottie b/assets/animations/CustomAgents.lottie
deleted file mode 100644
index 6f6b40b8c463..000000000000
Binary files a/assets/animations/CustomAgents.lottie and /dev/null differ
diff --git a/assets/animations/ExpenseAssistant.lottie b/assets/animations/ExpenseAssistant.lottie
deleted file mode 100644
index e2c0d9729eae..000000000000
Binary files a/assets/animations/ExpenseAssistant.lottie and /dev/null differ
diff --git a/assets/animations/SpendAnalysis.lottie b/assets/animations/SpendAnalysis.lottie
deleted file mode 100644
index f1cc7ab61cb5..000000000000
Binary files a/assets/animations/SpendAnalysis.lottie and /dev/null differ
diff --git a/assets/emojis/types.ts b/assets/emojis/types.ts
index a0dbeb41add0..54488f1c41d0 100644
--- a/assets/emojis/types.ts
+++ b/assets/emojis/types.ts
@@ -1,3 +1,4 @@
+import type en from '@src/languages/en';
import type IconAsset from '@src/types/utils/IconAsset';
type Emoji = {
@@ -16,7 +17,7 @@ type Emoji = {
type HeaderEmoji = {
header: true;
icon: IconAsset;
- code: string;
+ code: keyof typeof en.emojiPicker.headers;
};
type PickerEmoji = Emoji | HeaderEmoji;
diff --git a/assets/images/integrationicons/exact-online-icon-square.svg b/assets/images/integrationicons/exact-online-icon-square.svg
new file mode 100644
index 000000000000..104d1617da63
--- /dev/null
+++ b/assets/images/integrationicons/exact-online-icon-square.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/product-illustrations/earth-with-coins.svg b/assets/images/product-illustrations/earth-with-coins.svg
new file mode 100644
index 000000000000..eb9c97faaf63
--- /dev/null
+++ b/assets/images/product-illustrations/earth-with-coins.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/images/user-pencil.svg b/assets/images/user-pencil.svg
new file mode 100644
index 000000000000..7cc7518c5fe0
--- /dev/null
+++ b/assets/images/user-pencil.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs
index e899da2a5472..1194e9100074 100644
--- a/config/eslint/eslint.config.mjs
+++ b/config/eslint/eslint.config.mjs
@@ -619,6 +619,15 @@ const config = defineConfig([
},
rules: {
'@typescript-eslint/no-deprecated': ['error', {allow: ['translateFn']}],
+
+ // `ignorePrimitives: true` narrows this to the cases a type checker can decide: it reports
+ // `||` only when the left operand cannot be `''`, `0` or `false`, so `||` and `??` are
+ // provably equivalent. When the left operand can hold one of those, whether it counts as
+ // "missing" is domain knowledge the rule does not have — many API fields represent "unset"
+ // as an empty string rather than `null`, and several Onyx keys require `|| undefined` so an
+ // empty string never reaches a collection subscription.
+ // See contributingGuides/STYLE.md#accessing-object-properties-and-default-values
+ '@typescript-eslint/prefer-nullish-coalescing': ['error', {ignoreIfStatements: true, ignorePrimitives: true, ignoreTernaryTests: true}],
},
},
@@ -643,7 +652,7 @@ const config = defineConfig([
},
{
- files: ['src/libs/Navigation/types.ts'],
+ files: ['src/libs/Navigation/types.ts', 'src/libs/Navigation/paramLists/**/*.ts'],
rules: {
'no-restricted-syntax': [
'error',
diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index 968073a518b3..126425377a3a 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -14,7 +14,6 @@
"../../.github/actions/javascript/failureNotifier/failureNotifier.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../.github/actions/javascript/formatCodeCovComment/formatCodeCovComment.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../.github/actions/javascript/getAndroidRolloutPercentage/getAndroidRolloutPercentage.ts" "rulesdir/no-default-id-values" 1
-"../../.github/actions/javascript/getDeployPullRequestList/getDeployPullRequestList.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../.github/actions/javascript/getPreviousVersion/getPreviousVersion.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../.github/actions/javascript/getPullRequestDetails/getPullRequestDetails.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../.github/actions/javascript/getPullRequestIncrementalChanges/getPullRequestIncrementalChanges.ts" "@typescript-eslint/no-unsafe-type-assertion" 5
@@ -69,9 +68,8 @@
"../../src/components/AttachmentPicker/index.tsx" "react-hooks/refs" 1
"../../src/components/AttachmentPreview.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2
-"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 3
+"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 2
"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-deprecated/LegacyComposedGesture" 1
-"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx" "react-hooks/refs" 3
"../../src/components/Attachments/AttachmentCarousel/index.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/Attachments/AttachmentCarousel/Pager/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Native" 2
@@ -84,16 +82,9 @@
"../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "react-hooks/refs" 1
"../../src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/AutoCompleteSuggestions/index.tsx" "react-hooks/set-state-in-effect" 1
-"../../src/components/AutoSubmitModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/AutoUpdateTime.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1
-"../../src/components/AvatarCropModal/AvatarCropView.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2
-"../../src/components/AvatarCropModal/AvatarCropView.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1
"../../src/components/AvatarCropModal/AvatarCropView.tsx" "no-restricted-imports" 1
-"../../src/components/AvatarCropModal/ImageCropView.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2
-"../../src/components/AvatarCropModal/ImageCropView.tsx" "@typescript-eslint/no-deprecated/LegacyPanGesture" 1
"../../src/components/AvatarCropModal/ImageCropView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/components/AvatarCropModal/Slider.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2
-"../../src/components/AvatarCropModal/Slider.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1
"../../src/components/AvatarCropModal/Slider.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/AvatarSelector.tsx" "no-restricted-syntax" 2
"../../src/components/AvatarWithImagePicker.tsx" "react-hooks/set-state-in-effect" 2
@@ -102,14 +93,8 @@
"../../src/components/BookTravelButton.tsx" "no-restricted-imports" 1
"../../src/components/ButtonWithDropdownMenu/index.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/CategoryPicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/components/Charts/hooks/useChartInteractions.ts" "@typescript-eslint/no-deprecated/Gesture.Hover" 2
-"../../src/components/Charts/hooks/useChartInteractions.ts" "@typescript-eslint/no-deprecated/Gesture.Race" 2
-"../../src/components/Charts/hooks/useChartInteractions.ts" "@typescript-eslint/no-deprecated/Gesture.Tap" 2
"../../src/components/Charts/hooks/useChartLabelFormats.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/Charts/hooks/useChartLabelMeasurements.ts" "rulesdir/no-raw-typography" 1
-"../../src/components/Charts/PieChart/PieChartContent.tsx" "@typescript-eslint/no-deprecated/Gesture.Hover" 2
-"../../src/components/Charts/PieChart/PieChartContent.tsx" "@typescript-eslint/no-deprecated/Gesture.Race" 2
-"../../src/components/Charts/PieChart/PieChartContent.tsx" "@typescript-eslint/no-deprecated/Gesture.Tap" 2
"../../src/components/Charts/utils/chartFontAssets.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 13
"../../src/components/Charts/utils/chartFontAssets.ts" "@typescript-eslint/no-unsafe-type-assertion" 13
"../../src/components/Charts/utils/chartFontsCache.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
@@ -118,10 +103,7 @@
"../../src/components/CheckboxWithLabel.tsx" "no-restricted-syntax" 1
"../../src/components/ColumnsSettingsList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/ColumnsSettingsList.tsx" "no-restricted-imports" 1
-"../../src/components/Composer/implementation/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/components/Composer/implementation/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../src/components/Composer/implementation/index.tsx" "react-hooks/set-state-in-effect" 2
-"../../src/components/ConfirmationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/ConfirmModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1
"../../src/components/ConnectionLayout.tsx" "no-restricted-imports" 1
"../../src/components/ConnectToMergeFlow/index.ios.tsx" "no-restricted-imports" 1
@@ -154,13 +136,8 @@
"../../src/components/DragAndDrop/Provider/index.tsx" "react-hooks/refs" 6
"../../src/components/DraggableList/SortableItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/DropZone/DropZoneWrapper.tsx" "react-hooks/refs" 1
-"../../src/components/DynamicForm/components/DynamicFormFieldRow.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1
"../../src/components/EditableCell/usePopoverEditState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/components/EmojiPicker/CategoryShortcutButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
-"../../src/components/EmojiPicker/EmojiPicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/EmojiPicker/EmojiPicker.tsx" "react-hooks/refs" 3
-"../../src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
-"../../src/components/EmojiPicker/EmojiPickerMenu/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts" "react-hooks/set-state-in-effect" 2
"../../src/components/EmojiPicker/EmojiSkinToneList.tsx" "react-hooks/set-state-in-effect" 2
"../../src/components/EmptySelectionListContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -193,7 +170,6 @@
"../../src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx" "@typescript-eslint/no-deprecated/fontSize" 3
"../../src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
-"../../src/components/HTMLEngineProvider/HTMLRenderers/HTMLTableScroll/index.native.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2
"../../src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -234,8 +210,6 @@
"../../src/components/Modal/BaseModal.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/Modal/Global/ConfirmModalWrapper.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1
"../../src/components/Modal/Global/ModalContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
-"../../src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2
-"../../src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx" "@typescript-eslint/no-deprecated/GestureType" 1
"../../src/components/Modal/ReanimatedModal/Container/index.web.tsx" "react-hooks/refs" 1
"../../src/components/MoneyReportHeader.tsx" "no-restricted-imports" 1
"../../src/components/MoneyReportHeaderActions/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
@@ -256,8 +230,6 @@
"../../src/components/MoneyRequestConfirmationListFooter/sections/ReceiptSection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/MoneyRequestHeader.tsx" "no-restricted-imports" 1
"../../src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
-"../../src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1
-"../../src/components/MoneyRequestReportView/MoneyRequestViewReportFields.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/MultifactorAuthentication/components/Modals/createCancelConfirmModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1
"../../src/components/MultifactorAuthentication/components/OutcomeScreen/OutcomeScreenBase.tsx" "no-restricted-imports" 1
"../../src/components/MultifactorAuthentication/config/scenarios/ChangePIN.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -269,7 +241,7 @@
"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Race" 2
"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Simultaneous" 2
"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/GestureRef" 1
-"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 2
+"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-deprecated/GestureType" 1
"../../src/components/MultiGestureCanvas/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/MultiGestureCanvas/index.tsx" "no-restricted-syntax" 1
"../../src/components/MultiGestureCanvas/index.tsx" "react-hooks/preserve-manual-memoization" 1
@@ -318,19 +290,13 @@
"../../src/components/RadioButtonWithLabel.tsx" "no-restricted-syntax" 1
"../../src/components/Reactions/AddReactionBubble.tsx" "react-hooks/refs" 1
"../../src/components/Reactions/MiniQuickEmojiReactions.tsx" "react-hooks/refs" 1
-"../../src/components/ReceiptCropView/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 4
-"../../src/components/ReceiptCropView/index.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 2
"../../src/components/ReceiptCropView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/ReceiptHoverZoom/useReceiptHoverZoom.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/ReimbursementAccountLoadingIndicator.tsx" "no-restricted-imports" 1
"../../src/components/ReportActionAvatars/useReportPreviewSenderID.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
-"../../src/components/ReportActionItem/IssueCardMessage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
-"../../src/components/ReportActionItem/MoneyReportView.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1
-"../../src/components/ReportActionItem/MoneyReportView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/ReportActionItem/MoneyRequestReceiptView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/ReportActionItem/MoneyRequestReceiptView.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/ReportActionItem/MoneyRequestView.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 5
-"../../src/components/ReportActionItem/receiptHoverUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/ReportActionItem/SupportTicketView.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1
"../../src/components/ReportActionItem/TaskPreview.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/ReportActionItem/TaskView.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1
@@ -371,7 +337,6 @@
"../../src/components/Search/index.tsx" "react-hooks/preserve-manual-memoization" 2
"../../src/components/Search/index.tsx" "react-hooks/refs" 5
"../../src/components/Search/index.tsx" "react-hooks/set-state-in-effect" 1
-"../../src/components/Search/SearchAutocompleteInput.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/components/Search/SearchBulkActionsButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/Search/SearchContextDefinitions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/Search/SearchList/ListItem/BaseListItemHeader.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -397,12 +362,10 @@
"../../src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/Search/SearchRouter/SearchRouter.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/Search/SearchRouter/SearchRouter.tsx" "no-restricted-imports" 1
-"../../src/components/Search/SearchRouter/SearchRouterUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/Search/SearchSingleSelectionPicker.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/SelectionButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/SelectionList/BaseSelectionList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/SelectionList/components/Footer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/components/SelectionList/hooks/useFlattenedSections.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/components/SelectionList/ListItem/SplitListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/SelectionList/utils/getListboxRole/index.web.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -423,7 +386,6 @@
"../../src/components/StatePicker/StateSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/StatePicker/StateSelectorModal.tsx" "no-restricted-imports" 1
"../../src/components/StateSelector.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1
-"../../src/components/StatusBadge.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/SubStepForms/AddressStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/components/SubStepForms/ConfirmationStep.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1
"../../src/components/SubStepForms/CountryFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -458,7 +420,6 @@
"../../src/components/TextPicker/TextSelectorModal.tsx" "no-restricted-imports" 1
"../../src/components/TextPicker/TextSelectorModal.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/TextWithTooltip/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/components/ThemeStylesContextProvider/default.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/ThreeDotsMenu/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/ThreeDotsMenu/index.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/TimeModalPicker.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1
@@ -485,10 +446,6 @@
"../../src/components/VideoPlayer/BaseVideoPlayer.tsx" "react-hooks/refs" 13
"../../src/components/VideoPlayer/BaseVideoPlayer.tsx" "react-hooks/set-state-in-effect" 2
"../../src/components/VideoPlayer/useHandleNativeVideoControls/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2
-"../../src/components/VideoPlayer/VideoPlayerControls/ProgressBar/index.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1
-"../../src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2
-"../../src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.tsx" "@typescript-eslint/no-deprecated/PanGestureChangeEventPayload" 1
"../../src/components/VideoPlayerContexts/PlaybackContext/playbackContextReportIDUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/WalletStatementModal/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/WideRHPContextProvider/useShouldRenderOverlay.ts" "react-hooks/set-state-in-effect" 1
@@ -526,15 +483,12 @@
"../../src/hooks/useCardFeedsForActivePolicies.ts" "no-restricted-imports" 1
"../../src/hooks/useCardFeedsForDisplay.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useCardFeedsForDisplay.ts" "no-restricted-imports" 1
-"../../src/hooks/useCreateReport.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useDebounce.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useDebouncedValue.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useDebounceNonReactive.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useDragAndDrop/types.ts" "@typescript-eslint/no-deprecated/React.MutableRefObject" 1
-"../../src/hooks/useDynamicBackPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useExportedToFilterOptions.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useFilterFormValues.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
-"../../src/hooks/useHtmlPaste/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/hooks/useHydrateReportsFromSnapshot.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useImportSpreadsheetConfirmModal.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useInitial.ts" "react-hooks/refs" 4
@@ -545,7 +499,6 @@
"../../src/hooks/useKeyboardShortcut.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useLazyAsset.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/hooks/useLazyAsset.ts" "react-hooks/set-state-in-effect" 1
-"../../src/hooks/useListKeyboardNav.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/hooks/useMarkdownStyle.ts" "rulesdir/no-raw-typography" 3
"../../src/hooks/useNewTransactions.ts" "react-hooks/refs" 2
"../../src/hooks/useOnyx.ts" "@typescript-eslint/no-unsafe-type-assertion" 10
@@ -574,11 +527,8 @@
"../../src/hooks/useStableIndexedHandler.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useStepFormSubmit.ts" "no-restricted-syntax" 1
"../../src/hooks/useSubPage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
-"../../src/hooks/useTackInputFocus/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
-"../../src/hooks/useTextWithMiddleEllipsis.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/hooks/useTransactionViolationOfWorkspace.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/hooks/useViolations.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/hooks/useWindowDimensions/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/hooks/useWorkletStateMachine/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/languages/flattenObject.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/AccountingUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -598,8 +548,7 @@
"../../src/libs/actions/CompanyCards.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/actions/CompanyCards.ts" "no-restricted-imports" 1
"../../src/libs/actions/CompanyCards.ts" "no-restricted-syntax" 4
-"../../src/libs/actions/connections/index.ts" "no-restricted-imports" 1
-"../../src/libs/actions/connections/index.ts" "no-restricted-syntax" 3
+"../../src/libs/actions/connections/index.ts" "no-restricted-syntax" 2
"../../src/libs/actions/connections/NetSuiteCommands.ts" "no-restricted-syntax" 2
"../../src/libs/actions/connections/QuickbooksDesktop.ts" "no-restricted-syntax" 2
"../../src/libs/actions/connections/QuickbooksOnline.ts" "no-restricted-syntax" 2
@@ -607,7 +556,6 @@
"../../src/libs/actions/connections/Xero.ts" "no-restricted-syntax" 2
"../../src/libs/actions/Delegate.ts" "no-restricted-syntax" 4
"../../src/libs/actions/Delegate.ts" "rulesdir/no-direct-personal-details-list" 1
-"../../src/libs/actions/Domain.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/libs/actions/Domain.ts" "no-restricted-syntax" 1
"../../src/libs/actions/ExitSurvey.ts" "no-restricted-syntax" 1
"../../src/libs/actions/getCompanyCardBankConnection/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4
@@ -625,29 +573,25 @@
"../../src/libs/actions/IOU/DeleteMoneyRequest.ts" "no-restricted-syntax" 1
"../../src/libs/actions/IOU/Duplicate.ts" "@typescript-eslint/no-unsafe-type-assertion" 7
"../../src/libs/actions/IOU/Duplicate.ts" "no-restricted-syntax" 2
-"../../src/libs/actions/IOU/Hold.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/libs/actions/IOU/Hold.ts" "no-restricted-syntax" 2
"../../src/libs/actions/IOU/index.ts" "rulesdir/no-onyx-connect" 7
"../../src/libs/actions/IOU/MoneyRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/actions/IOU/MoneyRequestBuilder.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2
"../../src/libs/actions/IOU/MoneyRequestBuilder.ts" "@typescript-eslint/no-deprecated/translateLocal" 1
"../../src/libs/actions/IOU/MoneyRequestBuilder.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
-"../../src/libs/actions/IOU/PayMoneyRequest.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2
"../../src/libs/actions/IOU/PayMoneyRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/actions/IOU/PayMoneyRequest.ts" "no-restricted-imports" 1
"../../src/libs/actions/IOU/PayMoneyRequest.ts" "no-restricted-syntax" 1
-"../../src/libs/actions/IOU/PerDiem.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/libs/actions/IOU/PerDiem.ts" "no-restricted-syntax" 2
"../../src/libs/actions/IOU/Receipt.ts" "no-restricted-syntax" 1
"../../src/libs/actions/IOU/RejectMoneyRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/actions/IOU/RejectMoneyRequest.ts" "no-restricted-syntax" 1
-"../../src/libs/actions/IOU/ReportWorkflow.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 3
+"../../src/libs/actions/IOU/ReportWorkflow.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 2
"../../src/libs/actions/IOU/ReportWorkflow.ts" "no-restricted-syntax" 1
"../../src/libs/actions/IOU/SendInvoice.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/actions/IOU/SendInvoice.ts" "no-restricted-syntax" 1
"../../src/libs/actions/IOU/SendMoney.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/actions/IOU/SendMoney.ts" "no-restricted-syntax" 1
-"../../src/libs/actions/IOU/Split.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 1
"../../src/libs/actions/IOU/Split.ts" "@typescript-eslint/no-deprecated/Localize.translateLocal" 2
"../../src/libs/actions/IOU/Split.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/actions/IOU/Split.ts" "no-restricted-syntax" 3
@@ -663,7 +607,6 @@
"../../src/libs/actions/Link.ts" "no-restricted-syntax" 2
"../../src/libs/actions/MapboxToken.ts" "no-restricted-syntax" 2
"../../src/libs/actions/MergeAccounts.ts" "no-restricted-syntax" 1
-"../../src/libs/actions/MergeTransaction.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 1
"../../src/libs/actions/MergeTransaction.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/actions/MergeTransaction.ts" "no-restricted-imports" 1
"../../src/libs/actions/MergeTransaction.ts" "no-restricted-syntax" 1
@@ -750,7 +693,6 @@
"../../src/libs/API/makeRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/AttendeeUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/libs/BankAccountUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/libs/canFocusInputOnScreenFocus/index.ts" "no-restricted-syntax" 1
"../../src/libs/CardFeedUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 7
"../../src/libs/CardUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 12
"../../src/libs/CategoryOptionListUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -779,7 +721,6 @@
"../../src/libs/fileDownload/getImageResolution.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/fileDownload/index.android.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/fileDownload/index.ios.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/libs/fileDownload/validateReceiptFile/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/filterArrayByMatch.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/focusComposerWithDelay/index.ts" "no-restricted-syntax" 1
"../../src/libs/focusComposerWithDelay/setTextInputSelection.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
@@ -790,7 +731,6 @@
"../../src/libs/hadTabNavigation.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/HttpUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/libs/ImportOnyxStateUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 7
-"../../src/libs/InputUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/interceptAnonymousUser.ts" "no-restricted-syntax" 1
"../../src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/KeyboardShortcut/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
@@ -833,7 +773,6 @@
"../../src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx" "no-restricted-syntax" 1
"../../src/libs/Navigation/AppNavigator/Navigators/SearchFullscreenNavigator.tsx" "no-restricted-syntax" 1
"../../src/libs/Navigation/AppNavigator/Navigators/SettingsSplitNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/libs/Navigation/AppNavigator/Navigators/TabNavigator.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx" "no-restricted-syntax" 1
"../../src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/Navigation/AppNavigator/routerExtensions/addPushParamsRouterExtension.ts" "@typescript-eslint/no-unsafe-type-assertion" 4
@@ -848,7 +787,8 @@
"../../src/libs/Navigation/helpers/dynamicRoutesUtils/getDynamicRouteAdaptedState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/Navigation/helpers/dynamicRoutesUtils/getDynamicRouteQueryParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/Navigation/helpers/dynamicRoutesUtils/getPathWithoutDynamicSuffix.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/libs/Navigation/helpers/getAdaptedStateFromPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 6
+"../../src/libs/Navigation/helpers/dynamicRoutesUtils/stripDynamicRouteSuffixFromPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
+"../../src/libs/Navigation/helpers/getAdaptedStateFromPath.ts" "@typescript-eslint/no-unsafe-type-assertion" 4
"../../src/libs/Navigation/helpers/getOnboardingRouteFromScreen.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/Navigation/helpers/getParamsFromRoute.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/Navigation/helpers/getPathFromState.ts" "@typescript-eslint/no-unsafe-type-assertion" 6
@@ -870,7 +810,6 @@
"../../src/libs/Navigation/linkingConfig/config.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/Navigation/linkingConfig/RELATIONS/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/libs/Navigation/Navigation.ts" "@typescript-eslint/no-unsafe-type-assertion" 10
-"../../src/libs/Navigation/OnyxTabNavigator.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx" "@typescript-eslint/no-deprecated/NavigationContent" 1
"../../src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx" "@typescript-eslint/no-deprecated/NavigationContent" 1
"../../src/libs/Navigation/PlatformStackNavigation/navigationOptions/animation/withAnimation.ts" "@typescript-eslint/no-unsafe-type-assertion" 6
@@ -897,14 +836,14 @@
"../../src/libs/PersonalDetailsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/PhoneNumber.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/PolicyUtils/connections.ts" "@typescript-eslint/no-unsafe-type-assertion" 4
-"../../src/libs/PolicyUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
+"../../src/libs/PolicyUtils/errors.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
+"../../src/libs/PolicyUtils/rules.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/prepareRequestPayload/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 5
"../../src/libs/prepareRequestPayload/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/Pusher/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 6
"../../src/libs/Pusher/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/libs/QuickActionUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/ReceiptUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/libs/ReportActionsUtils.ts" "@typescript-eslint/no-deprecated/reportAction.sequenceNumber" 1
"../../src/libs/ReportActionsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 109
"../../src/libs/ReportActionsUtils.ts" "rulesdir/no-onyx-connect" 2
@@ -914,11 +853,10 @@
"../../src/libs/ReportTitleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getPolicy" 27
"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getReportTransactions" 16
-"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 8
+"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 7
"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 31
"../../src/libs/ReportUtils.ts" "rulesdir/no-onyx-connect" 13
"../../src/libs/sanitizeLogParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
-"../../src/libs/SearchAutocompleteUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/libs/SearchQueryUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 48
"../../src/libs/SearchUIUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 71
"../../src/libs/SearchUIUtils.ts" "no-restricted-imports" 1
@@ -998,7 +936,6 @@
"../../src/pages/domain/Members/DomainMembersPage.tsx" "no-restricted-imports" 1
"../../src/pages/domain/Members/MoveUserBetweenGroupsPage.tsx" "no-restricted-imports" 1
"../../src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx" "no-restricted-imports" 1
-"../../src/pages/DynamicEditReportFieldPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/DynamicEditReportFieldPage.tsx" "no-restricted-imports" 1
"../../src/pages/DynamicFlagCommentPage.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1
"../../src/pages/DynamicFlagCommentPage.tsx" "no-restricted-imports" 1
@@ -1016,7 +953,6 @@
"../../src/pages/DynamicReportDetailsPage/ReportDetailsMenuPrivateNotesItem.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1
"../../src/pages/DynamicReportDetailsPage/ReportDetailsMenuSettingsItem.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1
"../../src/pages/DynamicReportDetailsPage/ReportDetailsMenuTrackExpenseItems.tsx" "@typescript-eslint/no-deprecated/MenuItem" 4
-"../../src/pages/DynamicReportDetailsPage/ReportDetailsNameSection.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 2
"../../src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1
"../../src/pages/DynamicReportParticipantDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/DynamicReportParticipantDetailsPage.tsx" "no-restricted-imports" 1
@@ -1066,7 +1002,7 @@
"../../src/pages/inbox/report/LinkPreviewer.tsx" "@typescript-eslint/no-deprecated/fontSize" 3
"../../src/pages/inbox/report/ReactionList/HeaderReactionList.tsx" "no-restricted-syntax" 1
"../../src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5
+"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx" "react-hooks/refs" 7
"../../src/pages/inbox/report/ReportActionCompose/SuggestionEmoji.tsx" "react-hooks/refs" 1
"../../src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -1076,7 +1012,7 @@
"../../src/pages/inbox/report/ReportActionCompose/useReceiptDrop.ts" "@typescript-eslint/no-unsafe-type-assertion" 5
"../../src/pages/inbox/report/ReportActionItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/pages/inbox/report/ReportActionItem.tsx" "react-hooks/set-state-in-effect" 1
-"../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
+"../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "no-restricted-syntax" 1
"../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "react-hooks/refs" 5
"../../src/pages/inbox/report/ReportActionItemMessageEdit.tsx" "react-hooks/set-state-in-effect" 1
@@ -1124,13 +1060,12 @@
"../../src/pages/iou/request/step/IOURequestEditReportCommon.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1
"../../src/pages/iou/request/step/IOURequestEditReportCommon.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/iou/request/step/IOURequestStepAmount.tsx" "react-hooks/set-state-in-effect" 1
-"../../src/pages/iou/request/step/IOURequestStepConfirmation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
-"../../src/pages/iou/request/step/IOURequestStepConfirmation.tsx" "no-restricted-imports" 1
-"../../src/pages/iou/request/step/IOURequestStepConfirmation.tsx" "react-hooks/preserve-manual-memoization" 1
+"../../src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
+"../../src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx" "no-restricted-imports" 1
+"../../src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx" "react-hooks/preserve-manual-memoization" 1
"../../src/pages/iou/request/step/IOURequestStepCurrencyModal.tsx" "no-restricted-imports" 1
"../../src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 2
"../../src/pages/iou/request/step/IOURequestStepDistanceMap.tsx" "react-hooks/set-state-in-effect" 1
-"../../src/pages/iou/request/step/IOURequestStepGPSTripEdit/Slider.tsx" "@typescript-eslint/no-deprecated/Gesture.Pan" 2
"../../src/pages/iou/request/step/IOURequestStepOdometerImage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -1146,7 +1081,6 @@
"../../src/pages/iou/request/step/withFullTransactionOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/iou/request/step/withWritableReportOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/LoadingPage.tsx" "no-restricted-imports" 1
-"../../src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx" "no-restricted-imports" 1
"../../src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx" "react-hooks/set-state-in-effect" 3
"../../src/pages/media/AttachmentModalScreen/AttachmentModalContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -1249,14 +1183,14 @@
"../../src/pages/SetDefaultWorkspacePage.tsx" "no-restricted-imports" 1
"../../src/pages/settings/AboutPage/AboutPage.tsx" "no-restricted-imports" 1
"../../src/pages/settings/AboutPage/AboutPage.tsx" "react-hooks/refs" 1
-"../../src/pages/settings/Agents/AddAgentPage.tsx" "no-restricted-imports" 1
"../../src/pages/settings/Agents/AgentsPage.tsx" "no-restricted-imports" 1
-"../../src/pages/settings/Agents/EditAgentPage.tsx" "no-restricted-imports" 1
-"../../src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx" "no-restricted-imports" 1
-"../../src/pages/settings/Agents/Fields/EditNamePage.tsx" "no-restricted-imports" 1
-"../../src/pages/settings/Agents/Fields/EditPromptPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/pages/settings/Agents/Fields/EditPromptPage.tsx" "no-restricted-imports" 1
-"../../src/pages/settings/Agents/NewAgentPage.tsx" "no-restricted-imports" 1
+"../../src/pages/settings/Agents/DynamicAddAgentPage.tsx" "no-restricted-imports" 1
+"../../src/pages/settings/Agents/DynamicEditAgentPage.tsx" "no-restricted-imports" 1
+"../../src/pages/settings/Agents/DynamicNewAgentPage.tsx" "no-restricted-imports" 1
+"../../src/pages/settings/Agents/Fields/DynamicEditAgentAvatarPage.tsx" "no-restricted-imports" 1
+"../../src/pages/settings/Agents/Fields/DynamicEditNamePage.tsx" "no-restricted-imports" 1
+"../../src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
+"../../src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx" "no-restricted-imports" 1
"../../src/pages/settings/AppDownloadLinks.tsx" "no-restricted-imports" 1
"../../src/pages/settings/Copilot/CopilotPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/pages/settings/Copilot/CopilotPage.tsx" "no-restricted-imports" 1
@@ -1613,8 +1547,6 @@
"../../src/pages/workspace/categories/DynamicDefaultCategorySelectorPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/categories/DynamicEditCategoryPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/categories/DynamicExpenseLimitTypeSelectorPage.tsx" "no-restricted-imports" 1
-"../../src/pages/workspace/categories/DynamicSpendCategorySelectorPage.tsx" "no-restricted-imports" 1
-"../../src/pages/workspace/categories/DynamicWorkspaceCategoriesSettingsPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/categories/ImportedCategoriesPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/categories/WorkspaceCategoriesPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/categories/WorkspaceCategoriesPage.tsx" "react-hooks/set-state-in-effect" 1
@@ -1695,7 +1627,6 @@
"../../src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1
"../../src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1
"../../src/pages/workspace/expensifyCard/DynamicWorkspaceExpensifyCardDetailsPage.tsx" "no-restricted-imports" 1
-"../../src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/pages/workspace/expensifyCard/DynamicWorkspaceSettlementAccountPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/expensifyCard/issueNew/CardTypeStep.tsx" "@typescript-eslint/no-deprecated/MenuItem" 2
"../../src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -1740,7 +1671,6 @@
"../../src/pages/workspace/members/ImportMembersPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/members/ImportMembersPage.tsx" "no-restricted-syntax" 1
"../../src/pages/workspace/members/ImportMembersPage.tsx" "rulesdir/no-default-id-values" 1
-"../../src/pages/workspace/members/WorkArrangementPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "react-hooks/set-state-in-effect" 1
@@ -1767,8 +1697,6 @@
"../../src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/receiptPartners/DynamicInviteReceiptPartnerPolicyPage.tsx" "no-restricted-imports" 1
-"../../src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx" "@typescript-eslint/no-deprecated/MenuItem" 1
-"../../src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/recruiting/approver/MergeATSApprovalModePage.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1
"../../src/pages/workspace/recruiting/merge/filters/MergeATSFiltersPage.tsx" "@typescript-eslint/no-deprecated/MenuItemWithTopDescription" 1
@@ -1802,7 +1730,6 @@
"../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "react-hooks/set-state-in-effect" 1
-"../../src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/taxes/NamePage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/taxes/ValuePage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx" "no-restricted-imports" 1
@@ -1849,7 +1776,6 @@
"../../src/pages/workspace/WorkspaceMembersPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/WorkspaceNamePage.tsx" "no-restricted-imports" 1
-"../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "react-hooks/set-state-in-effect" 3
"../../src/pages/workspace/WorkspaceOverviewClientIDPage.tsx" "no-restricted-imports" 1
diff --git a/contributingGuides/DEFER_AFTER_PAINT.md b/contributingGuides/DEFER_AFTER_PAINT.md
new file mode 100644
index 000000000000..232ceb932352
--- /dev/null
+++ b/contributingGuides/DEFER_AFTER_PAINT.md
@@ -0,0 +1,68 @@
+# Defer after paint
+
+When an interaction navigates (submit an expense, open a report, switch a tab), the user waits for one thing: the destination on screen. Everything the destination does not need for that first frame should run after the navigation or modal transition ends, not during it. Work that lands mid-transition competes with the animation for the JS thread and shows up as dropped frames, a gap before the destination appears, or a flash of the previous page.
+
+The App has a small set of primitives for this, all waiting on the "transition finished" signal from `TransitionTracker` by default (`API.writeWhenReady` can accept custom barriers as well). Pick one from the table below instead of building a new mechanism. If none fits, flag a human and tell them the pattern needs to be thoughtfully extended.
+
+## Pick a primitive
+
+| Shape of the problem | Use | Details |
+|---|---|---|
+| Dismissing an RHP should show a fullscreen screen other than the one currently behind it (e.g. submit from Home lands on Spend), and that screen is known when the RHP mounts | `usePreMountDestination` | [NAVIGATION.md](NAVIGATION.md#pre-mounting-a-destination-behind-an-rhp), [PERF-18](../.claude/skills/app-coding-standards/rules/perf-18-use-pre-mount-destination.md) |
+| A heavy subtree on a freshly navigated screen is not needed for first paint (dropdowns, secondary actions, heavy `useOnyx` consumers) | `` | [PERFORMANCE.md](PERFORMANCE.md#rendering-of-unnecessary-or-duplicated-child-components), [`NavigationDeferredMount.tsx`](../src/components/NavigationDeferredMount.tsx) |
+| An API write whose optimistic data would re-render screens while a transition is still running | `API.writeWhenReady` with an armed transition barrier | [Deferring writes](#deferring-writes) below |
+| One-off imperative work after a navigation, modal or keyboard transition (focus, scroll, a follow-up navigation) | `afterTransition` on `Navigation` methods and `KeyboardUtils.dismiss`, `Navigation.runAfterTransition` / `runAfterUpcomingTransition`, or `TransitionTracker.runAfterTransitions` | [INTERACTION_MANAGER.md](INTERACTION_MANAGER.md#when-to-use-runaftertransitions-directly) |
+
+The first two decide **what mounts when**. The last two decide **when work runs**. A single flow often combines them: the submit-expense flow pre-mounts the destination, reveals it, and only then applies the optimistic write through a barrier (see `SubmitExpenseOrchestrator.tsx`).
+
+## Deferring writes
+
+`API.writeWhenReady` works like `API.write`, but the optimistic update and the request both wait until a barrier settles, or until a safety timeout passes. After that, it runs the normal `write()` pipeline.
+
+Arm the barrier with `API.armTransitionBarrier()` when the interaction is triggered, before the navigation call, and pass it to the write once the write exists. A barrier attached later (after an `await`, a GPS lookup, or deep inside an action) can miss the transition. It then waits for the next one, which can be an unrelated keyboard or modal animation, or gives up after the start timeout (about 1s). One armed barrier can gate several writes from the same interaction. Call `cancel()` if the write never happens.
+
+```ts
+const armed = API.armTransitionBarrier();
+Navigation.dismissModal();
+const location = await getCurrentLocation(); // placeholder: may finish before or after the dismiss transition ends
+API.writeWhenReady(WRITE_COMMANDS.SOME_COMMAND, {...params, location}, onyxData, armed.barrier);
+```
+
+Arming isn't strictly required: when the write is built in the same function right after the navigation call, `API.createTransitionBarrier()` attaches in time too. Arming is the default because it doesn't depend on that timing.
+
+By default the barrier releases after the next transition of any kind. Arm it with `API.armTransitionBarrier('navigation')` when only a screen transition starting (or the ~1s start timeout) should end the start wait, for example when the same tap also dismisses the keyboard.
+
+A barrier is any `(signal: AbortSignal) => PromiseLike`, so a flow can wait on something other than a transition, for example Search's content layout in the submit flow.
+
+**Do not use `writeWhenReady` when:**
+
+- **The write may need to be cancelled.** A `writeWhenReady` call has no cancel handle once it is made. Use `afterTransition` with a cancelled flag instead.
+- **The write is conditional.** A barrier decides _when_ the write happens, never _whether_. A rejected barrier still releases the write. Decide whether to write before calling it.
+- **The write needs a `conflictResolver`.** A deferred request is not in the sequential queue until it executes, so conflict resolution can't see it.
+- **Order matters across calls.** Two `writeWhenReady` calls can run in either order, because each waits on its own barrier. A plain `API.write` called after a `writeWhenReady` reaches the queue first, because `API.write` doesn't wait.
+- **The barrier waits on a READ, without `{shouldClaimReadGate: false}`.** READs wait for pending deferred writes by default, so the READ waits for the write and the write waits for the READ until the safety timeout. Never let a barrier wait on a READ without passing that option (in the options object, the fifth argument of `writeWhenReady`).
+- **Losing the write would be hard to recover from.** Pending writes are flushed when the app goes inactive or to the background, but a hard kill or crash loses them. Only defer writes the user can simply redo.
+
+## Rejected alternatives
+
+These were tried or proposed. Do not reintroduce them:
+
+- **Inline deferral in screen components** (`useState` flags plus a `useFocusEffect` flush, `setTimeout` with a guessed delay, `requestAnimationFrame` chains). This spreads flow-specific timing into screens that should not know about it. `ReportScreen` had a `useFocusEffect` flush for one submit case, which was removed when the submit flow moved to write barriers. `useDeferNonEssentials`, which `ReportScreen` uses to hold back non-essential UI after a submit, still defers inline, its migration is tracked in [#88596](https://github.com/Expensify/App/issues/88596). Use one of the primitives above. Flagged by [PERF-20](../.claude/skills/app-coding-standards/rules/perf-20-no-ad-hoc-deferral.md).
+- **`deferredLayoutWrite`**, a channel that held writes until the destination screen flushed them on focus. Every destination screen had to flush it, and it was replaced by `API.writeWhenReady` barriers.
+- **`InteractionManager.runAfterInteractions`**. Deprecated, see [INTERACTION_MANAGER.md](INTERACTION_MANAGER.md).
+- **Compute-on-open for dropdowns and popovers** (subscribe to data only when the menu opens). It moves the cost to the moment the user taps, which hurts INP and makes the menu feel slow. Defer the mount with `NavigationDeferredMount` instead, so the work runs after the transition but before the tap.
+- **A separate `scheduleAfterTransition(fn)` helper.** `TransitionTracker.runAfterTransitions` and the `afterTransition` option on `Navigation` already cover imperative call sites.
+
+## Telemetry
+
+Deferring work changes what "done" means, so a span has to pick which moment it measures:
+
+- **Perceived ready**: the destination is on screen, even if parts of it are placeholders. This is what the user waits for and what navigation spans should end on. `ManualSubmitToDestinationVisible` ends when the destination is on screen, for example when the report gains focus or the deferred Search skeleton lays out ([OBSERVABILITY_METRICS.md](OBSERVABILITY_METRICS.md#submit-expense-to-destination-visible)).
+- **Fully mounted**: the deferred content is in. If this matters, measure it with a separate span instead of moving the end of the perceived-ready one. `Navigate to Reports Tab` has separate First Paint and Content Load spans for this reason.
+- **INP**: deferred work still runs on the JS thread. If it lands while the user is interacting, it shows up as slow input instead of a slow navigation. Keep deferred renders in `startTransition`, which `NavigationDeferredMount` already does, so React can yield to input.
+
+Things to watch for:
+
+- **A pre-mounted destination has already laid out before the user submits.** A layout-only end trigger has nothing to fire on at reveal, so pair it with focus. `useSubmitToDestinationVisible` accepts `[FOCUS, LAYOUT]` for this.
+- **A deferred write lands after the destination is visible.** If a span should include the optimistic data appearing, end it from the component that renders that data, not on focus.
+- **Record which path ran.** The submit flow sets the `fast_path_handler` span attribute with `setFastPath`, so a regression can be traced to one path instead of looking like noise across all of them.
diff --git a/contributingGuides/DYNAMIC_FORMS.md b/contributingGuides/DYNAMIC_FORMS.md
new file mode 100644
index 000000000000..6ffae2d5ea1c
--- /dev/null
+++ b/contributingGuides/DYNAMIC_FORMS.md
@@ -0,0 +1,142 @@
+# Dynamic Forms
+
+Some forms are described by data instead of JSX: the fields a Wise bank account needs depend on the country and currency, and the server sends them as a schema. `DynamicForm` renders such a schema with the regular form components, so these forms look and behave like every other form in the App.
+
+## Usage
+
+There are two ways in.
+
+### A whole flow: `DynamicFormFlow`
+
+`DynamicFormFlow` turns a schema into a multi-page flow, followed by a confirmation page with every answer.
+
+```tsx
+import DynamicFormFlow from "@components/DynamicForm";
+
+ ROUTES.SOME_FLOW.getRoute(pageName, action)}
+ onSubmit={(answers) => submitSomeFlow(answers)}
+ onBack={() => Navigation.goBack()}
+/>;
+```
+
+- `schema` is either `{pages}`, the pages as the server groups them, or `{fields}`, a flat list the App splits into pages (see [Pages](#pages)).
+- The current page is kept in the route, through `useSubPage`. Pass `shouldReplaceRoute` for flows on dynamic routes.
+- A new visit resumes on the first page its draft leaves incomplete, or on the last page when nothing is missing.
+- `hasConfirmation` decides whether a confirmation page follows the last page. It is on by default; without it the last page's Confirm button submits.
+- `layout` decides the step indicator: `auto` shows it at three or more shown pages, `stepper` always, `pages` never.
+- Answers are saved to the form draft as the user types, and again, cleaned, when they leave a page with Next.
+- `onPageSubmit` receives each page and its drafted answers, list entries included, when the user leaves it with Next, for flows that save page by page. Sensitive answers are not passed.
+- `onSubmit` receives the answers to every visible field, sensitive ones included. Answers left on fields that were hidden later are not sent.
+- `isSubmitting` and `submitError` show the confirmation page's Confirm button loading and the error from sending the answers.
+- `onRefreshRequirements` is called when a field marked `refreshOnChange` changes, so the screen can fetch the schema again. Typed fields call it when the user leaves the input, not on every keystroke.
+- `currency` is the currency of amount fields that let the user pick none. `maxFileSize` is the largest size of one file, in bytes, for file fields that set none.
+
+### One page: `DynamicFormFields`
+
+Render `DynamicFormFields` inside a `FormProvider` and validate the same content with `getDynamicFieldErrors`:
+
+```tsx
+import DynamicFormFields from "@components/DynamicForm/components/DynamicFormFields";
+import getContentFields from "@components/DynamicForm/utils/getContentFields";
+import getDynamicFieldErrors from "@components/DynamicForm/utils/getDynamicFieldErrors";
+
+ getDynamicFieldErrors(getContentFields(content), values, translate)}
+ onSubmit={onSubmit}
+>
+ {({ inputValues }) => }
+;
+```
+
+Pass `inputValues` from the render prop, so `showWhen` and `dependsOn` react to the user's answers as they type. When `content` is one page of a bigger form, pass the whole form as `allFields`, since a field can depend on an answer asked on another page. Outside `DynamicFormFlow`, list entries are edited in a modal.
+
+## Pages
+
+A page is a heading with its own `content`: `key`, `title` or `titleKey`, `description` or `descriptionKey`, and an optional `showWhen`. Its `content` holds headings and fields in screen order. An item without a `type` is a heading; a heading shows only while its `showWhen` matches and a visible field follows it before the next heading.
+
+When the schema is a flat `{fields}` list, the App builds the pages:
+
+- Addresses, files, multi-selects, lists, dependent selects and selects with more than 10 options get a page of their own.
+- Other fields share a page, up to 4 in a row.
+- A field with `showWhen` joins the page of its controller and does not count toward that limit.
+
+A page is shown while its own `showWhen` matches and it has a visible field; a page that is not shown is skipped, and its fields are neither asked nor submitted. A page without a title is headed by its only question: the field's `question` or `questionKey` when it has one, else its label. An untitled page with several questions has no headline and no section name on the confirmation page, since no one question names it. A label never names a confirmation section, since the label is already on the row below it. Page keys become route segments, so they are made URL-safe and unique, and `confirm` is reserved for the confirmation page.
+
+## The field format
+
+`DynamicFormField` in `src/types/onyx/DynamicFormField.ts` is the contract with the server. Each `type` has its own variant, so a property such as `rule` or `currencyKey` only exists on the types that use it.
+
+| Type | Input | Type-specific properties |
+| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
+| `text` | `TextInput` | `regex`, `minLength`, `maxLength`, `example`, `keyboard`, `multiline`, `rule: 'legalName' \| 'phone'` |
+| `number` | `TextInput` with the numeric keyboard | `regex`, `minLength`, `maxLength`, `example` |
+| `select` | `ValuePicker`, or `PushRowWithModal` above 10 options | `values`, `dependsOn`, `presentation: 'tabs'` |
+| `radio` | `RadioButtons` | `values`, `dependsOn`, `presentation: 'tabs'` |
+| `multiselect` | `PushRowWithModal` with checkboxes and a Save button | `values`, `dependsOn` |
+| `date` | `DatePicker` | `rule: 'dateOfBirth'` |
+| `boolean` | Yes and No radio buttons, where No is an answer. With `presentation: 'checkbox'`, `CheckboxWithLabel`, which must be ticked when required. | `presentation: 'checkbox'` |
+| `country` | `CountryPicker` | |
+| `countryMultiselect` | `PushRowWithModal` with every country | |
+| `currency` | `CurrencyPicker` | |
+| `address` | The standard address form. The street is the field's value; the other parts are stored under `.city`, `.zipCode` and so on. | `rule: 'zipCode'` |
+| `file` | `UploadFile` for PDF, JPG and PNG files, taking one file unless `maxFiles` allows more | `maxFiles`, `maxFileSize` |
+| `amount` | `AmountForm`. With `currencyKey`, a `CurrencyPicker` above it saves the currency under that key. | `currencyKey` |
+| `percent` | `PercentageForm`, from 1 to 100 | |
+| `list` | Rows with Add, Edit and Remove. Each entry is edited on its own page in the flow, or in a modal outside it. | `itemFields`, `itemLabel`, `addItemDescription`, `minItems`, `maxItems` |
+
+Every type also takes:
+
+- `key`: unique within the form, and the field's draft key. A field without one is left out and logged.
+- `label` or `labelKey`, and the same pair for `description`. A `*Key` is our translation and wins over the server's wording.
+- `question` or `questionKey`: the headline of an untitled page where this is the only question, such as "What's your account number?". Without one, that page is headed by the label, which a text input then repeats.
+- `required`. An optional address the user has started filling in is checked like a required one, so it is never sent in part.
+- `readonly`: shown as a row with its value, never edited or validated
+- `sensitive`: kept out of the form draft, see below
+- `showWhen`: shown only while another answer is one of the listed values. A list answer counts when any chosen value matches. A field whose controlling field is hidden stays hidden.
+- `refreshOnChange`: see `onRefreshRequirements` above
+
+A choice or list field that is the only question on its page is drawn as the page itself, as an inline list, with its question above it. A page without its own title takes the question as its headline, so the label is not repeated below it. Fields it reveals, such as an "Other" description, do not count, so the layout stays put when they appear. On a page with several questions, every choice, tabs included, shows its question above it.
+
+`maxFileSize` comes from the field when the schema sets it, else from the `maxFileSize` the screen passes. With neither, files of any size are taken.
+
+The server can send a type this App version does not know. The schema is typed `DynamicFormSchemaField` for that reason: such a field is left out and logged once, so a newer schema never blocks the form.
+
+## Privacy and accessibility
+
+- Every field row, read-only rows included, every confirmation row and the list entry editor are masked in session recordings. The mask sits on the row's `View`, so it also covers inputs that do not pass a FullStory class on, such as `CountryPicker`.
+- The confirmation before removing a list entry names the entry only in its masked prompt, since the modal title cannot be masked.
+- Radio buttons, Yes/No questions and tabs are named after their question for screen readers, and the bold labels above addresses, files and lists are headings.
+
+## Sensitive answers
+
+`sensitive` answers, such as SSNs, never reach the form draft. `DynamicFormFlow` keeps them in memory, in the RAM-only `ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS`, for one visit:
+
+- They are cleared when a new visit opens without a page in the route, and when the user leaves from the first page.
+- They are cleared once `isSubmitting` turns false without `submitError`, or right after `onSubmit` when the screen passes no `isSubmitting`.
+- They are never passed to `onPageSubmit`, since a page save would persist them in the request queue.
+- The confirmation page shows only their last four characters, as in `•••-••-6789` for an SSN.
+
+## List fields
+
+A `list` field holds repeated entries, such as a company's owners. Its value is an array of entries, each holding its answers keyed by item field key and an `id` made on the device.
+
+- In the flow each entry is edited on its own page, using the `dynamicFormListItemForm` form, which the page fills with the entry's answers when it opens and empties when it closes. The modal outside the flow does the same. These pages come after the confirmation page and Next and Back skip them, so only Add, Edit or an entry's row opens them.
+- Saving an entry drafts the list right away. Leaving the list page with Next drops the sensitive answers of entries the user removed.
+- Sensitive entry answers are kept in memory per entry and are sent back with their entry on submit. They are checked with their entry, so one lost with the memory, such as after a restart, is asked for again.
+- A list marked `sensitive` is kept in memory whole and never drafted.
+- On the confirmation page each entry is a row of its own, with a letter avatar, that opens the entry. An empty list is a plain row that opens its page.
+- `minItems`, `maxItems` and the first entry with a problem show as the list's error.
+
+## Adding a field type
+
+1. Add a variant to `DynamicFormField`.
+2. Typecheck then points at every place that has to handle it: the renderers in `renderers/`, the validators in `utils/getDynamicFieldErrors.ts`, the supported types in `utils/isSupportedField.ts` and the input map in `DynamicFormFieldsTest.tsx`.
+3. Map the field onto an existing input component. If the component's props do not fit a form input, add a thin adapter in `adapters/`: it takes `value`, `onInputChange` and `errorText`, renders one existing component, reads no Onyx and navigates nowhere, and passes `forwardedFSClass` on so the answer stays masked. If no component fits, add the input outside `DynamicForm` first, so other forms can use it too.
diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md
index 0880ce415641..478b1245f47b 100644
--- a/contributingGuides/FORMS.md
+++ b/contributingGuides/FORMS.md
@@ -341,6 +341,8 @@ InputWrapper component will automatically provide the following props to any inp
## Dynamic Form Inputs
+For forms whose fields are described by data rather than JSX, use the schema-driven renderer in [DYNAMIC_FORMS.md](DYNAMIC_FORMS.md).
+
It's possible to conditionally render inputs (or more complex components with multiple inputs) inside a form. For example, an IdentityForm might be nested as input for a Form component.
In order for Form to track the nested values properly, each field must have a unique identifier. It's not safe to use an index because adding or removing fields from the child Form component will not update these internal keys. Therefore, we will need to define keys and dynamically access the correlating child form data for validation/submission.
diff --git a/contributingGuides/INTERACTION_MANAGER.md b/contributingGuides/INTERACTION_MANAGER.md
index 2d5b10352e84..58cfc3f2b8cd 100644
--- a/contributingGuides/INTERACTION_MANAGER.md
+++ b/contributingGuides/INTERACTION_MANAGER.md
@@ -6,7 +6,7 @@
We used to carry patches that restored the real implementation (`react-native+…+restore-interaction-manager.patch`) and that opened interaction handles during screen transitions (`@react-navigation+native-stack+…+added-interaction-manager-integration.patch`). Both patches have been removed - the app no longer calls `InteractionManager` anywhere, so there was nothing left for them to serve.
-`InteractionManager.runAfterInteractions` has been replaced with purpose-built alternatives that are more precise.
+`InteractionManager.runAfterInteractions` has been replaced with purpose-built alternatives that are more precise. For how these fit together with pre-mounting, deferred mounts and deferred writes, see [DEFER_AFTER_PAINT.md](DEFER_AFTER_PAINT.md).
## Current state
@@ -98,4 +98,4 @@ For reference, here's how the available timing primitives compare:
| ---------------------- | ------------------------- | ------------------------- | --------------------- |
| `rAF` | Next frame (~16ms) | None — just "next paint" | Web + RN |
| `requestIdleCallback` | When idle (unpredictable) | None — "whenever free" | Web + RN (polyfilled) |
-| `runAfterInteractions` | Same as `setImmediate` (stubbed) | None - nothing blocks the queue | RN only |
+| `runAfterInteractions` | Native: same as `setImmediate` (stubbed). Web: after interaction handles clear, via `requestIdleCallback` | Native: none - nothing blocks the queue. Web: interaction handles | Web + RN |
diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md
index ffffbbc2c49d..9e55f24f6257 100644
--- a/contributingGuides/NAVIGATION.md
+++ b/contributingGuides/NAVIGATION.md
@@ -372,7 +372,7 @@ const config: LinkingOptions['config'] = {
};
```
-3. Define types for a new screen in `src/libs/Navigation/types.ts`.
+3. Define types for a new screen in `src/libs/Navigation/types.ts`. Screens of the Settings right-hand panel (`SettingsNavigatorParamList`) are defined in `src/libs/Navigation/paramLists/` instead: `AccountSettingsParamList.ts`, `WorkspaceSettingsParamList.ts` or `DomainSettingsParamList.ts`.
```ts
type SettingsSplitNavigatorParamList = {
diff --git a/contributingGuides/OBSERVABILITY_METRICS.md b/contributingGuides/OBSERVABILITY_METRICS.md
index 3c457f3b127f..cf6dc3286803 100644
--- a/contributingGuides/OBSERVABILITY_METRICS.md
+++ b/contributingGuides/OBSERVABILITY_METRICS.md
@@ -143,6 +143,19 @@ This document lists all implemented telemetry metrics in the Expensify App.
**Attributes**: `report_id`, `route_from`
**Notes**: The flow enters through a native OS share surface (the iOS Share Extension or Android share intent) and continues in the loaded app’s participant selector. It is scoped to the submit flow only (route `SHARE_SUBMIT_DETAILS`); the shared selector's track/share flow (`SHARE_DETAILS`) is not instrumented. Abandoned attempts (user backs out before the screen renders) are canceled on unmount and tagged `canceled`. The existing `ShareExtensionOpenSubmitFlow` name is retained for Sentry historical continuity.
+### Submit Expense to Destination Visible
+
+**Constant**: `CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE`
+**Sentry Name**: `ManualSubmitToDestinationVisible`
+**Threshold**: 400ms (P90)
+**What's Measured**: Time from submitting an expense to its destination (the report or Search) being on screen
+**Start**: The user submits an expense, from the confirmation step or a flow that skips it (`startTracking` in `src/libs/telemetry/submitFollowUpAction.ts`, called from `SubmitExpenseOrchestrator.tsx`, `submitWithDismissFirst.ts`, `SendMoney.ts` and `DynamicIOURequestStepCompanyInfo.tsx`)
+**End**:
+- User sees: The destination report or Search, possibly with deferred parts still showing placeholders
+- Technical: Destination focus or layout, depending on the follow-up action (`useSubmitToDestinationVisible`, `useEndSubmitNavigationSpans`, the deferred Search skeleton layout in `SearchWithNavigationDeferredMount.tsx`). For `dismiss_modal_only`, some dismiss paths end the span in their `afterTransition` callback (`submitDismissStrategies.ts`, `dismissModalAndOpenReportInInboxTab.ts`). The rest, e.g. dismissing a super-wide RHP, leave it to the destination report, which ends it on focus or layout (`ReportScreen.tsx`, `SearchMoneyRequestReportPage.tsx`)
+**Attributes**: `scenario`, `iou_type`, `iou_request_type`, `has_receipt`, `is_from_global_create`, `submit_follow_up_action`, `report_id`, `fast_path_handler`, `is_warm`, `was_list_empty`, `canceled`
+**Notes**: Measures perceived ready, not fully mounted. See [DEFER_AFTER_PAINT.md](DEFER_AFTER_PAINT.md#telemetry) for how deferred and pre-mounted destinations affect it. Spans still open after 60s are canceled and not sent.
+
### Send Message
**Constant**: `CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE`
diff --git a/contributingGuides/PAYMENT_VIA_EXPENSIFY.md b/contributingGuides/PAYMENT_VIA_EXPENSIFY.md
index 0c91c02dade1..e2ef97984173 100644
--- a/contributingGuides/PAYMENT_VIA_EXPENSIFY.md
+++ b/contributingGuides/PAYMENT_VIA_EXPENSIFY.md
@@ -18,6 +18,7 @@ Include in the Description field a link to the payment confirmation posted by th
**Important!:** If you’re new to NewDot payments, please double check your eligibility date against the date you were assigned to a GH issue. If you were assigned to a GH issue before your eligibility date, you must be paid via Upwork.
+Once reports are submitted for payments, they often take a couple days to be approved, please wait at least 3 business days before commenting on the report.
## For issues with deposits
If you're having an issue with the deposit, ie. a report gets _stuck_ in a state like Approved, start a chat with Concierge to ask for help. Provide as many details below as possible
diff --git a/contributingGuides/PERFORMANCE.md b/contributingGuides/PERFORMANCE.md
index 7bbffd894ab2..fc241fdb56bb 100644
--- a/contributingGuides/PERFORMANCE.md
+++ b/contributingGuides/PERFORMANCE.md
@@ -282,7 +282,7 @@ Since our codebase is very complex, it results in a large DOM tree rendered for
One of the most common issues is related to modals, popovers, and tooltips — elements that may appear on the screen. The problem is that they are usually present in the DOM tree even when initially invisible. Because of this, the initial render time of a screen may increase, ultimately slowing down the app.
-The solution is better control of invisible elements, making sure they are not included in the first render. This can be done, e.g., by a simple `return null`, smart usage of `lazy loading`, the `useTransition` hook, or the [``](/src/components/NavigationDeferredMount.tsx) component. `` gates a heavy subtree behind navigation transition completion via `TransitionTracker`, rendering a cheap `placeholder` until the nav animation has finished, then mounting its `children` inside `startTransition` — ideal for heavy subtrees (e.g. report headers, page-level secondary actions) mounted during navigation transitions that pull many `useOnyx` subscriptions or heavy hooks but don't need to be interactive on first render.
+The solution is better control of invisible elements, making sure they are not included in the first render. This can be done, e.g., by a simple `return null`, smart usage of `lazy loading`, the `useTransition` hook, or the [``](/src/components/NavigationDeferredMount.tsx) component. `` gates a heavy subtree behind navigation transition completion via `TransitionTracker`, rendering a cheap `placeholder` until the nav animation has finished, then mounting its `children` inside `startTransition` — ideal for heavy subtrees (e.g. report headers, page-level secondary actions) mounted during navigation transitions that pull many `useOnyx` subscriptions or heavy hooks but don't need to be interactive on first render. For how it fits with the other ways of deferring work past a transition, see [DEFER_AFTER_PAINT.md](DEFER_AFTER_PAINT.md).
Another issue worth mentioning is unnecessary code execution, especially for elements that are never shown on a specific platform. In theory, we separate the logic between platforms by using index.tsx/index.native.tsx files, but sometimes platform-specific logic may slip in, causing unnecessary execution. For example, this may happen when logic specific to a wide layout (applicable only for web) is included.
@@ -368,7 +368,7 @@ ___
## 6. Automated Tests & QA
*Tests are required by default. If you cannot add them, explain why.*
- [ ] **Unit Tests:** Added to prevent regression.
-- [ ] **Reassure Tests:** Added (Required for execution time improvements).
+- [ ] **Reassure Tests:** Added (Required for re-render improvements, since CI only checks render counts). For execution time improvements, add a `measureFunction` benchmark (`npm run perf-local`) and include the local before/after numbers from one machine in the PR description.
- [ ] **Exception:** I cannot add automated tests because: _________________
- [ ] **Manual Verification:** I have included manual verification steps (Required).
diff --git a/contributingGuides/PR_REVIEW_GUIDELINES.md b/contributingGuides/PR_REVIEW_GUIDELINES.md
index 622e82512dd5..73f5c9ca74c4 100644
--- a/contributingGuides/PR_REVIEW_GUIDELINES.md
+++ b/contributingGuides/PR_REVIEW_GUIDELINES.md
@@ -58,6 +58,7 @@ For a comprehensive guide on standards and best practices for both PR authors an
1. Make sure any `setTimeout`s are absolutely necessary, not just a workaround for a situation that isn’t fully understood (e.g. a race condition).
2. Ensure Onyx is used appropriately. ([docs](https://github.com/expensify/react-native-onyx#merging-data))
+3. Work delayed until a navigation or modal transition ends should use the shared primitives, not inline state flushed on focus or a guessed `setTimeout`. See [DEFER_AFTER_PAINT.md](DEFER_AFTER_PAINT.md).
## Considerations around code reuse
diff --git a/contributingGuides/REASSURE_PERFORMANCE_TEST.md b/contributingGuides/REASSURE_PERFORMANCE_TEST.md
index be1f6bbcc2c6..0b614e0330c5 100644
--- a/contributingGuides/REASSURE_PERFORMANCE_TEST.md
+++ b/contributingGuides/REASSURE_PERFORMANCE_TEST.md
@@ -22,6 +22,9 @@ We use Reassure for monitoring performance regression. It helps us check if our
- Identifying functions with heavy calculations.
- Targeting functions that are frequently used throughout the app.
+- `measureFunction` benchmarks only produce durations, so CI does not run them. Put new ones in `tests/perf-local/*.perf-local.ts`.
+- Run them with `npm run perf-local -- --baseline` on the base and `npm run perf-local` on your branch, on the same machine, and put the numbers in the PR description.
+- `perf-local` and `perf-test` share `.reassure/baseline.perf` and `current.perf`, so don't mix them. Pair `perf-local -- --baseline` with `perf-local`, and `perf-test -- --baseline` with `perf-test`, or every entry shows up as added or removed.
## Running tests locally
@@ -35,7 +38,7 @@ We use Reassure for monitoring performance regression. It helps us check if our
## Metrics for Regression Detection
- **Render count**: If the number of renders increases by one compared to the baseline, it will be considered a performance regression, leading to a failed test. This metric helps detect unexpected changes in component rendering behavior. *NOTE: sometimes regressions are intentional. For instance, if a new functionality is added to the tested component, causing an additional re-render, this regression is expected.*
-- **Render duration**: A performance regression will occur if the measured rendering time is 20% higher than the baseline, resulting in a failed test. This threshold allows for reasonable fluctuations and accounts for changes that may lead to longer rendering times.
+- **Render duration**: Durations are reported but not gated, because the baseline and the branch run on different machines.
## Tips for Performance Testing with Reassure
diff --git a/contributingGuides/STYLE.md b/contributingGuides/STYLE.md
index 98b3177b3c14..1e3144bb2b26 100644
--- a/contributingGuides/STYLE.md
+++ b/contributingGuides/STYLE.md
@@ -35,6 +35,7 @@
- [Object / Array Methods](#object--array-methods)
- [Asynchronous code](#asynchronous-code)
- [Accessing Object Properties and Default Values](#accessing-object-properties-and-default-values)
+ - [When `||` is the right operator](#when--is-the-right-operator)
- [JSDocs](#jsdocs)
- [Component props](#component-props)
- [Destructuring](#destructuring)
@@ -892,6 +893,33 @@ const name = user?.name || "default name";
const name = user?.name ?? "default name";
```
+### When `||` is the right operator
+
+`??` only falls back on `null` and `undefined`. Use `||` when an empty string, `0` or `false` must fall back too, and leave a comment saying why.
+
+This comes up often because many API fields represent "unset" as an empty string rather than `null`:
+
+```ts
+// GOOD - `exporter` is an empty string on a fresh connection, so it has to fall through
+const exporter = exportConfig?.exporter || policy?.owner;
+```
+
+It is required when an ID is interpolated into an Onyx key, because an empty string subscribes to the whole collection instead of one record. Normalize it to `undefined` first — see [Default value for inexistent IDs](#default-value-for-inexistent-IDs):
+
+```ts
+// GOOD - `|| undefined` stops an empty reportID subscribing to every report
+const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID || undefined}`);
+```
+
+`||` is also correct when the expression is a boolean OR rather than a default, since `??` does not short circuit on `false`:
+
+```ts
+// GOOD
+return disabled || shouldBeDisabledByScreenReader || isExecuting;
+```
+
+[`@typescript-eslint/prefer-nullish-coalescing`](https://typescript-eslint.io/rules/prefer-nullish-coalescing/) only reports `||` where the left operand cannot be `''`, `0` or `false` — that is, where the two operators are provably equivalent. The cases above are judgment calls it cannot make, so they are not reported and do not need a suppression.
+
## JSDocs
- Omit comments that are redundant with TypeScript. Do not declare types in `@param` or `@return` blocks. Do not write `@implements`, `@enum`, `@private`, `@override`. eslint: [`jsdoc/no-types`](https://github.com/gajus/eslint-plugin-jsdoc/blob/main/.README/rules/no-types.md)
diff --git a/contributingGuides/philosophies/SECURITY.md b/contributingGuides/philosophies/SECURITY.md
index b452f0106a0b..208307a519b9 100644
--- a/contributingGuides/philosophies/SECURITY.md
+++ b/contributingGuides/philosophies/SECURITY.md
@@ -10,6 +10,7 @@ Updated rules for managing members across all types of chats in New Expensify.
- Admins MUST NOT be able to leave or be removed from expense chats
- Members MUST NOT be able to leave or be removed from the #announce room
- Admins MUST NOT be able to leave or be removed from #admins
+- Auditors MUST NOT be able to leave or be removed from #admins
- Domain members MUST NOT be able to leave or be removed from their domain chat
- Report submitters MUST NOT be able to leave or be removed from their reports
- Report managers MUST NOT be able to leave or be removed from their reports
@@ -32,50 +33,44 @@ Updated rules for managing members across all types of chats in New Expensify.
- DM always has two participants. None of the participant can leave or be removed from the DM. Also no additional member can be invited to the chat.
### Workspace Rooms
-#### Workspace
-| | Creator | Member(Employee/User) | Admin | Auditor? |
-| :----------------: | :-----: | :-------------------: | :---: | :------: |
-| **Invite** | ✅ | ❌ | ✅ | ❌ |
-| **Remove** | ✅ | ❌ | ✅ | ❌ |
-| **Leave** | ❌ | ✅ | ❌ | ✅ |
-| **Can be removed** | ❌ | ✅ | ✅ | ✅ |
-
-- Creator can't leave or be removed from their own workspace
-- Admins can't leave from the workspace
-- Admins can remove other workspace admins, as well as workspace members, and invited guests
-- Creator can remove other workspace admins, as well as workspace members, and invited guests
-- Members and Auditors cannot invite or remove anyone from the workspace
-
#### Workspace #announce room
-| | Member(Employee/User) | Admin | Auditor? |
-| :----------------: | :-------------------: | :---: | :------: |
-| **Invite** | ❌ | ❌ | ❌ |
-| **Remove** | ❌ | ❌ | ❌ |
-| **Leave** | ❌ | ❌ | ❌ |
-| **Can be removed** | ❌ | ❌ | ❌ |
+| | Member(Employee/User) | Admin | Auditor |
+| :----------------: | :-------------------: | :---: | :-----: |
+| **Invite** | ❌ | ❌ | ❌ |
+| **Remove** | ❌ | ❌ | ❌ |
+| **Leave** | ❌ | ❌ | ❌ |
+| **Can be removed** | ❌ | ❌ | ❌ |
- No one can leave or be removed from the #announce room
+- Auditors are added to #announce when they join the workspace, so they can't leave or be removed from it either
+- Only workspace admins can post in #announce. Every other role, auditors included, sees the room as read-only
-#### Workspace #admin room
-| | Admin |
-| :----------------: | :---: |
-| **Invite** | ❌ |
-| **Remove** | ❌ |
-| **Leave** | ❌ |
-| **Can be removed** | ❌ |
+#### Workspace #admins room
+| | Admin | Auditor |
+| :----------------: | :---: | :-----: |
+| **Invite** | ❌ | ❌ |
+| **Remove** | ❌ | ❌ |
+| **Leave** | ❌ | ❌ |
+| **Can be removed** | ❌ | ❌ |
- Admins can't leave or be removed from #admins
+- Auditors are added to #admins when they are given the Auditor role, and membership follows the role rather than manual invites
+- Auditors can read and comment in #admins. Unlike #announce, the room has no admins-only write capability
+- No one can leave #admins, auditors included
+- Auditors lose #admins when their role changes to one that has no #admins access. That is a role change, not a removal from the room
#### Workspace rooms
-| | Creator | Member | Guest(outside of the workspace) |
-| :----------------: | :-----: | :----: | :-----------------------------: |
-| **Invite** | ✅ | ✅ | ✅ |
-| **Remove** | ✅ | ✅ | ❌ |
-| **Leave** | ✅ | ✅ | ✅ |
-| **Can be removed** | ✅ | ✅ | ✅ |
+| | Creator | Member | Auditor | Guest(outside of the workspace) |
+| :----------------: | :-----: | :----: | :-----: | :-----------------------------: |
+| **Invite** | ✅ | ✅ | ✅ | ✅ |
+| **Remove** | ✅ | ✅ | ✅ | ❌ |
+| **Leave** | ✅ | ✅ | ✅ | ✅ |
+| **Can be removed** | ✅ | ✅ | ✅ | ✅ |
- Everyone can be removed/can leave from the room including creator
- Guests are not able to remove anyone from the room
+- Auditors are added to the workspace rooms that are not private when they are given the Auditor role. Private rooms are skipped, so they join those by invitation only
+- Auditors are able to invite and remove other participants in the rooms that their role shared with them
#### Expense chats
| | Admin | Member(default) | Member(invited) |
@@ -90,6 +85,9 @@ Updated rules for managing members across all types of chats in New Expensify.
- Invited members(invited by members) are not able to invite or remove from the expense chat
- Invited members(invited by members) are able to leave the expense chat
- Default members and admins are able to remove invited members
+- Auditors are added to every workspace expense chat, so they can see the expenses on the workspace, and they are able to comment
+- Auditors are not able to be removed from an expense chat
+- Auditors are able to leave another member's expense chat, but not their own
### Domain chat
| | Member |
@@ -100,12 +98,26 @@ Updated rules for managing members across all types of chats in New Expensify.
- Domain members can't leave or be removed from their domain chat
-4. ### Reports
- | | Submitter | Manager |
- | :----------------: | :-------: | :-----: |
- | **Remove** | ❌ | ❌ |
- | **Leave** | ❌ | ❌ |
- | **Can be removed** | ❌ | ❌ |
+### Reports
+| | Submitter | Manager |
+| :----------------: | :-------: | :-----: |
+| **Remove** | ❌ | ❌ |
+| **Leave** | ❌ | ❌ |
+| **Can be removed** | ❌ | ❌ |
- Report submitters can't leave or be removed from their reports (eg, if they are the report.accountID)
- Report managers can't leave or be removed from their reports (eg, if they are the report.managerID)
+
+## Workspace membership
+| | Creator | Member(Employee/User) | Admin | Auditor |
+| :----------------: | :-----: | :-------------------: | :---: | :-----: |
+| **Invite** | ✅ | ❌ | ✅ | ❌ |
+| **Remove** | ✅ | ❌ | ✅ | ❌ |
+| **Leave** | ❌ | ✅ | ❌ | ✅ |
+| **Can be removed** | ❌ | ✅ | ✅ | ✅ |
+
+- Creator can't leave or be removed from their own workspace
+- Admins can't leave from the workspace
+- Admins can remove other workspace admins, as well as workspace members, and invited guests
+- Creator can remove other workspace admins, as well as workspace members, and invited guests
+- Members and Auditors cannot invite or remove anyone from the workspace
diff --git a/cspell.json b/cspell.json
index d8881a85216c..07b8cebe6eb5 100644
--- a/cspell.json
+++ b/cspell.json
@@ -733,6 +733,7 @@
"hybridapp",
"iOSQRCode",
"iaco",
+ "iban",
"idempotently",
"idfa",
"ifdef",
diff --git a/docs/articles/new-expensify/insights/View-the-Violations-by-submitter-report.md b/docs/Hidden/View-the-Violations-by-submitter-report.md
similarity index 100%
rename from docs/articles/new-expensify/insights/View-the-Violations-by-submitter-report.md
rename to docs/Hidden/View-the-Violations-by-submitter-report.md
diff --git a/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md b/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md
index 19b1ed1d8b83..c3aa80b9b295 100644
--- a/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md
+++ b/docs/articles/expensify-classic/connect-credit-cards/Reconcile-Company-Card-Expenses.md
@@ -94,5 +94,5 @@ Only Domain Admins can access the Reconciliation tool.
- Amount
- Last four digits of the card number
-**Note: Only posted transactions will be imported.**
+**Note: Only posted transactions will be imported, except for cards connected through Plaid. Plaid cards also import pending transactions, which are marked as pending until they post.**
diff --git a/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md b/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md
index 39bd550f1bad..754345740a96 100644
--- a/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md
+++ b/docs/articles/expensify-classic/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md
@@ -1,85 +1,90 @@
---
title: NS0068 Export Error in NetSuite Integration
-description: Learn how to fix the NS0068 export error in NetSuite when the Created From field is not visible on the preferred transaction form.
-keywords: NS0068, NetSuite Created From field not visible, permission to set value Created From, NetSuite transaction form visibility, Expensify Card journal entry error, Expensify NetSuite export error, Workspace Admin
-internalScope: Audience is Workspace Admins using the NetSuite integration. Covers fixing the NS0068 export error caused by the Created From field not being visible on the preferred NetSuite form. Does not cover other NetSuite error codes.
+description: Learn how to fix the NS0068 export error in NetSuite when Expensify can't set the Created From field (custbody_createdfrom_expensify) during export.
+keywords: NS0068, NetSuite Created From field not visible, custbody_createdfrom_expensify, permission to set value Created From, Transaction Body Fields NetSuite, NetSuite transaction form visibility, Expensify Connect bundle, Expensify Card journal entry error, Expensify NetSuite export error, workspace admin
+internalScope: Audience is workspace admins using the NetSuite integration. Covers fixing the NS0068 export error caused by the Created From field (custbody_createdfrom_expensify) not applying to the export record type, being hidden on the export form, not being editable by the Expensify role, or missing from NetSuite. Does not cover other NetSuite error codes.
---
+
+
# NS0068 Export Error in NetSuite Integration
If you see the error:
-NS0068: You do not have permission to set value for element — 'Created From'.
+NS0068: NetSuite won't let Expensify set the 'Created From' field (custbody_createdfrom_expensify).
+
+This means NetSuite is blocking Expensify from setting the **Created From** field during export.
-This means the **Created From** field is not visible on the preferred transaction form in NetSuite.
+The **Created From** field is a custom transaction body field with the ID `custbody_createdfrom_expensify`. The **Expensify Connect bundle** adds it to NetSuite.
---
-## Why the NS0068 Export Error Happens in NetSuite
+## Why the NS0068 export error happens in NetSuite
The NS0068 error occurs when:
+- The **Created From** field does not apply to the record type you're exporting to (vendor bills, expense reports, or journal entries).
- The **Created From** field is hidden on the preferred export form.
-- The form does not allow the integration to populate the Created From field.
-- Expensify Card transactions are exported as journal entries and the Journal Entry form does not show the Created From field.
+- The role used by the Expensify connection can't edit the **Created From** field.
+- The **Created From** field is missing because the **Expensify Connect bundle** is not installed or is out of date.
+- Expensify Card transactions are exported as journal entries and the journal entry form does not show the Created From field.
Expensify must be able to write to the Created From field during export.
---
-## How to Fix the NS0068 Export Error
+## How to check the Created From field settings in NetSuite
-### If Exporting Vendor Bills or Standard Transactions
+1. Log in to **NetSuite** as an administrator.
+2. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields**.
+3. Open the field with the ID **custbody_createdfrom_expensify**.
+4. On the **Applies To** tab, confirm the option for your export type is selected: **Purchase** for vendor bills, **Expense Report** for expense reports, or **Journal** for journal entries.
+5. On the **Access** tab, confirm **Default Access Level** is **Edit**. If the **Role** list includes the role used by the Expensify connection, such as **Expensify Integration**, confirm its access level is also **Edit**.
+6. Select **Save**.
-1. Log in to **NetSuite** as an Administrator.
-2. Go to **Customization**.
-3. Select **Forms**.
-4. Click **Transaction Forms**.
-5. Click **Edit** next to the form marked as **Preferred** for the export type.
-6. Locate the **Created From** field.
-7. Ensure the field is marked as **Show**.
-8. Save the form.
+If you can't find **custbody_createdfrom_expensify**, install or update the Expensify Connect bundle in NetSuite before continuing. [Learn how to install the Expensify Connect bundle in NetSuite](/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite).
+
+---
-Then in Expensify:
+## How to show the Created From field on vendor bill and expense report forms
-1. Go to **Settings**.
-2. Select **Workspaces**.
-3. Select your Workspace.
-4. Click **Accounting**.
-5. Click **Sync**.
-6. Retry exporting the report.
+1. Log in to **NetSuite** as an administrator.
+2. Go to **Customization > Forms > Transaction Forms**.
+3. Select **Edit** next to the form marked as **Preferred** for the export type. If you set a custom form ID for this export type in New Expensify, edit that form instead.
+4. Go to **Screen Fields > Main**.
+5. Confirm the **Created From** field is marked as **Show**.
+6. Select **Save**.
---
-### If Exporting Expensify Card Transactions as Journal Entries
+## How to show the Created From field on Expensify Card journal entry forms
+
+1. Log in to **NetSuite** as an administrator.
+2. Confirm **custbody_createdfrom_expensify** applies to journal entries, using the steps in **How to check the Created From field settings in NetSuite** above.
+3. Go to **Customization > Forms > Transaction Forms**.
+4. Select **Edit** next to the preferred **Journal Entry** form.
+5. Go to **Screen Fields > Main**.
+6. Confirm the **Created From** field is marked as **Show**.
+7. Select **Save**.
+
+---
-1. Log in to **NetSuite**.
-2. Go to **Customization**.
-3. Select **Forms**.
-4. Click **Transaction Forms**.
-5. Click **Edit** next to the preferred **Journal Entry** form.
-6. Go to **Screen Fields**.
-7. Open the **Main** tab.
-8. Ensure the **Created From** field is marked as **Show**.
-9. Save the form.
+## How to sync NetSuite and retry the export
-Then in Expensify:
+After updating NetSuite:
-1. Go to **Settings**.
-2. Select **Workspaces**.
-3. Select your Workspace.
-4. Click **Accounting**.
-5. Click **Sync**.
-6. Retry exporting the report.
+1. Go to **Settings > Workspaces > [workspace name] > Accounting**.
+2. Select **Sync Now**.
+3. Retry exporting the report.
---
# FAQ
-## Does NS0068 Mean My Integration Is Broken?
+## Does NS0068 mean my integration is broken?
-No. This error is caused by form visibility settings in NetSuite.
+No. This error is caused by the Created From field settings, form visibility, or role access in NetSuite.
-## Do I Need to Reconnect NetSuite?
+## Do I need to reconnect NetSuite?
-No. Updating the form settings and running **Sync** is typically sufficient.
+No. Updating the field and form settings and selecting **Sync Now** is typically sufficient. If the field is missing, install or update the Expensify Connect bundle first.
diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md
new file mode 100644
index 000000000000..fd62cd6ec6db
--- /dev/null
+++ b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md
@@ -0,0 +1,70 @@
+---
+title: INT133 Sync Error in Sage Intacct Integration
+description: Learn what the INT133 sync error means in Sage Intacct and how to reactivate the web services user to restore your connection.
+keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Expensify Sage Intacct sync error, PL04000076, Workspace Admin
+internalScope: Audience is Workspace Admins using the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122).
+---
+
+# INT133 Sync Error in Sage Intacct Integration
+
+If you see the error:
+
+INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again.
+
+This means Sage Intacct rejected the connection because the web services user is set to **Inactive**, preventing the sync from completing.
+
+---
+
+## Why the INT133 Sync Error Happens in Sage Intacct
+
+The INT133 error typically indicates:
+
+- The `xmlgateway_expensify` web services user was deactivated in Sage Intacct.
+- An administrator changed the user's status to **Inactive** during a user cleanup or license review.
+- The web services user that was used to connect the Workspace is no longer in use.
+
+Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first.
+
+---
+
+## How to Fix the INT133 Sync Error
+
+This issue must be resolved in Sage Intacct and then retried in Expensify.
+
+### Reactivate the Web Services User in Sage Intacct
+
+1. Log in to Sage Intacct as an administrator.
+2. Go to **Company > Admin > Web Services users**.
+3. Locate the **xmlgateway_expensify** web services user.
+4. Click **Edit**.
+5. Change the **Status** to **Active**.
+6. Click **Save**.
+
+If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials.
+
+---
+
+### Retry the Sync in Expensify
+
+1. Go to **Settings > Workspaces**.
+2. Select your Workspace.
+3. Click **Accounting**.
+4. Click **Sync Now**.
+
+Confirm the sync completes successfully.
+
+---
+
+# FAQ
+
+## Do I Need Sage Intacct Admin Access?
+
+Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct.
+
+## Do I Need to Reenter My Credentials?
+
+Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in Expensify.
+
+## Does INT133 Mean the Integration Is Disconnected?
+
+Not necessarily. The connection settings are kept, but Sage Intacct rejects every sync until the web services user is active again.
diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md
new file mode 100644
index 000000000000..fd62cd6ec6db
--- /dev/null
+++ b/docs/articles/expensify-classic/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md
@@ -0,0 +1,70 @@
+---
+title: INT133 Sync Error in Sage Intacct Integration
+description: Learn what the INT133 sync error means in Sage Intacct and how to reactivate the web services user to restore your connection.
+keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Expensify Sage Intacct sync error, PL04000076, Workspace Admin
+internalScope: Audience is Workspace Admins using the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122).
+---
+
+# INT133 Sync Error in Sage Intacct Integration
+
+If you see the error:
+
+INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again.
+
+This means Sage Intacct rejected the connection because the web services user is set to **Inactive**, preventing the sync from completing.
+
+---
+
+## Why the INT133 Sync Error Happens in Sage Intacct
+
+The INT133 error typically indicates:
+
+- The `xmlgateway_expensify` web services user was deactivated in Sage Intacct.
+- An administrator changed the user's status to **Inactive** during a user cleanup or license review.
+- The web services user that was used to connect the Workspace is no longer in use.
+
+Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first.
+
+---
+
+## How to Fix the INT133 Sync Error
+
+This issue must be resolved in Sage Intacct and then retried in Expensify.
+
+### Reactivate the Web Services User in Sage Intacct
+
+1. Log in to Sage Intacct as an administrator.
+2. Go to **Company > Admin > Web Services users**.
+3. Locate the **xmlgateway_expensify** web services user.
+4. Click **Edit**.
+5. Change the **Status** to **Active**.
+6. Click **Save**.
+
+If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials.
+
+---
+
+### Retry the Sync in Expensify
+
+1. Go to **Settings > Workspaces**.
+2. Select your Workspace.
+3. Click **Accounting**.
+4. Click **Sync Now**.
+
+Confirm the sync completes successfully.
+
+---
+
+# FAQ
+
+## Do I Need Sage Intacct Admin Access?
+
+Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct.
+
+## Do I Need to Reenter My Credentials?
+
+Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in Expensify.
+
+## Does INT133 Mean the Integration Is Disconnected?
+
+Not necessarily. The connection settings are kept, but Sage Intacct rejects every sync until the web services user is active again.
diff --git a/docs/articles/expensify-classic/reports/Report-Approvals.md b/docs/articles/expensify-classic/reports/Report-Approvals.md
index 1af13e89bf83..e6d41e32b1ab 100644
--- a/docs/articles/expensify-classic/reports/Report-Approvals.md
+++ b/docs/articles/expensify-classic/reports/Report-Approvals.md
@@ -263,7 +263,7 @@ Only **Domain Admins** have access.
- Amount
- The last four digits of the card
-**Note:** Only posted credit card transactions will import.
+**Note:** Only posted credit card transactions will import, except for cards connected through Plaid. Plaid cards also import pending transactions, which are marked as pending until they post.
No. Concierge Receipt Audit is automatically enabled for all Control plan workspaces and cannot be turned off.
@@ -282,7 +282,7 @@ Use the **Reconciliation tool** to find missing expenses:
- Amount
- Last four digits of the card number
-**Note:** Only **posted transactions** will be imported.
+**Note:** Only **posted transactions** will be imported, except for cards connected through Plaid. Plaid cards also import pending transactions, which are marked as pending until they post.
## Can admins delete expenses on behalf of members?
diff --git a/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md b/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md
index 75edd7314e20..4bb9b13535a7 100644
--- a/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md
+++ b/docs/articles/new-expensify/ai-agents/AI-Agent-Capabilities.md
@@ -52,6 +52,18 @@ To accomplish this, include phrases like:
---
+## How to write instructions that unapprove expense reports
+
+**Goal:** Undoes the approval of an Approved expense report so it can be reviewed and approved again. The report returns to Outstanding (submitted), or to Draft if the submitter approved it themselves. Requires workspace admin role, unless the agent is the approver who gave the final approval. On a workspace that uses Dynamic External Workflow approvals, only a workspace admin can unapprove. A report that has already been paid through Expensify cannot be unapproved.
+
+To accomplish this, include phrases like:
+- Unapprove reports over $10,000
+- Unapprove this report so it can be reviewed again
+- Undo the approval on reports that were approved with violations
+- Unapprove reports that were approved while an expense was missing a receipt
+
+---
+
## How to write instructions that reject expense reports
**Goal:** Sends an entire Outstanding (submitted) expense report back to the submitter with a user-facing rejection comment explaining why.
@@ -177,7 +189,7 @@ To accomplish this, include phrases like:
## How to write instructions that delete expense reports
-**Goal:** Deletes an unsubmitted expense report the requester owns, after they confirm the deletion. Only reports that are still open or in draft can be deleted — a report that has been submitted, approved, reimbursed, or closed cannot be. Deleting an individual expense rather than a whole report is covered by the edit-expenses capability.
+**Goal:** Deletes an unsubmitted expense report the requester owns, after they confirm the deletion. Only draft reports can be deleted — a report that has been submitted, approved, reimbursed, or closed cannot be. Deleting an individual expense rather than a whole report is covered by the edit-expenses capability.
To accomplish this, include phrases like:
- Delete this report
@@ -236,7 +248,7 @@ To accomplish this, include phrases like:
To accomplish this, include phrases like:
- Send each employee a reminder to submit their expenses at the end of the month
- Message the submitter directly when their report is rejected
-- Notify individual workspace members of a workspace change
+- Notify individual workspace members of an expense policy change
- DM each person with outstanding reports a personalized nudge
- Send a private message to the submitter when a receipt is missing
- Reach out to each team member individually with their spending summary
@@ -268,9 +280,21 @@ To accomplish this, include phrases like:
---
+## How to write instructions that export a report as a PDF
+
+**Goal:** Generates a PDF of an expense report and attaches it to the chat as a downloadable file. When the chat is itself an expense report, it exports that report. From any other chat, such as a Concierge DM, the requester can choose a report by report ID, by submitter, or by what was bought. It does not change the report, and anyone who can view the report can ask for one.
+
+To accomplish this, include phrases like:
+- Send me a PDF of this report
+- Download this expense report as a PDF
+- Export Alice's latest report to PDF
+- Send me a PDF of my most recent report
+
+---
+
## How to write instructions that manage workspace settings and members
-**Goal:** Inspects workspace configuration, answers membership questions, and — for workspace admins — adds or removes members, lists and assigns company cards from a connected card feed, and modifies workspace settings such as categories and approval rules.
+**Goal:** Inspects workspace configuration, answers membership questions, reads the Expensify Card limit and remaining spend for anyone allowed to see the Expensify Card, and — for workspace admins — adds or removes members, lists and assigns company cards from a connected card feed, and modifies workspace settings such as categories and approval rules.
To accomplish this, include phrases like:
- Add new employees to the workspace when they join
@@ -280,6 +304,7 @@ To accomplish this, include phrases like:
- What is the current approval mode for this workspace?
- Copy the category list from one workspace to another
- Assign all unassigned company cards to the matching members
+- How much of our Expensify Card limit is left?
---
@@ -297,4 +322,4 @@ To accomplish this, include phrases like:
---
-*This reference covers all 22 AI agent capabilities.*
+*This reference covers all 24 AI agent capabilities.*
diff --git a/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md b/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md
index 35d0c3fdcd6c..4481d01a8a34 100644
--- a/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md
+++ b/docs/articles/new-expensify/ai-agents/AI-Agent-Triggers.md
@@ -636,6 +636,12 @@ To react to this event, include phrases like:
- When QuickBooks / NetSuite / Xero sync fails
- When the accounting connection breaks
+**A running count of how many of the workspace's reports are currently failing to export. There is one of these per workspace in its #admins room, kept up to date in place as reports start and stop failing, and addressed to the workspace's configured exporter.**
+
+To react to this event, include phrases like:
+- When reports fail to export to accounting
+- How many reports failed to export
+
**An accounting integration export failed for a specific report. This notification appears on the expense report itself, not in #admins.**
To react to this event, include phrases like:
@@ -920,4 +926,4 @@ To react to this event, include phrases like:
---
-*This reference covers all 126 events AI agents can react to.*
+*This reference covers all 127 events AI agents can react to.*
diff --git a/docs/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions.md b/docs/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions.md
index 1fb8f2825430..d70ec3a64667 100644
--- a/docs/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions.md
+++ b/docs/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions.md
@@ -11,7 +11,7 @@ order: 2
# Learn About Billing Permissions
-Every Workspace has a Workspace owner, sometimes called the billing owner. The Workspace owner is responsible for the Workspace's Expensify subscription and is the only person who can manage billing-related settings like plans and subscriptions.
+Every Workspace has a Workspace owner, sometimes called the billing owner. The Workspace owner is responsible for the Workspace's Expensify subscription and is the only person who can manage billing-related settings like the subscription and payment method. Any Workspace Admin can change the Workspace's plan, which affects the Workspace owner's bill.
To change the Workspace owner, see [Transfer Workspace Ownership](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership).
@@ -57,7 +57,7 @@ No. Each Workspace has one Workspace owner, although the same member can own mul
## Can Workspace Admins manage billing?
-No. Workspace Admins can only manage workspace settings, but only the Workspace owner can manage billing and related settings. To learn more about the difference between Workspace Admin and Workspace owner, see [What member terms mean](/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Terms-and-Definitions).
+Partly. Workspace Admins can manage Workspace settings and change the Workspace's plan, but only the Workspace owner can manage the subscription, payment method, and other billing settings. To learn more about changing the plan, see [Change Your Workspace Plan](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan). To learn more about the difference between Workspace Admin and Workspace owner, see [What member terms mean](/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Terms-and-Definitions).
## Who can view the Expensify billing receipts?
diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md
index 89d5a075c53c..f83efcdd6778 100644
--- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md
+++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md
@@ -1,8 +1,8 @@
---
title: Change Your Workspace Plan
description: Change your Workspace's Plan type to Collect or Control.
-keywords: [New Expensify, change workspace plan, switch plan, Collect plan, Control plan, Submit plan, plan type, upgrade, downgrade, upgrade to Control, downgrade to Collect, upgrade Submit workspace, workspace admin]
-internalScope: Audience is Workspace owners. Covers how to switch a Workspace between the Collect and Control plans, and how upgrading a free Submit workspace works. Does not cover the differences between plans or how to change a subscription.
+keywords: [New Expensify, change workspace plan, switch plan, Collect plan, Control plan, Submit plan, plan type, upgrade, downgrade, upgrade to Control, downgrade to Collect, upgrade Submit workspace, Workspace Admin]
+internalScope: Audience is Workspace Admins. Covers how to switch a Workspace between the Collect and Control plans, what happens in the #admins room after the plan changes, and how upgrading a free Submit workspace works. Does not cover the differences between plans or how to change a subscription.
retrievalIntent: How to switch a Workspace plan between Collect and Control.
contentType: task
platform: new
@@ -19,12 +19,23 @@ To learn which plan is right for your organization, see [Compare Collect and Con
## Who can change the workspace plan
-Only the Workspace owner can change a workspace's plan.
+Any Workspace Admin can change a Workspace's plan, including upgrading from Collect to Control and downgrading from Control to Collect.
+
+The Workspace owner pays for the Workspace, so plan changes made by any Workspace Admin affect the Workspace owner's bill.
If you have an Annual subscription, changes to your plan may be restricted. To learn more, see [When You Can Change Your Plan](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management#when-you-can-change-your-plan).
---
+## What happens after you change the Workspace plan
+
+When a Workspace Admin changes the plan, Expensify posts a message in the Workspace's **#admins** room. The message shows which Workspace Admin made the change, and the Workspace owner and all Workspace Admins can see it.
+
+- **Upgrade to Control:** The message says "[Admin name] upgraded this workspace to the Control plan." The Workspace Admin who upgraded also gets a private message in #admins with Control plan pricing.
+- **Downgrade to Collect:** The message says "[Admin name] downgraded this workspace to the Collect plan."
+
+---
+
## How to switch to the Collect or Control plans
1. In the navigation tabs (on the left on web, at the bottom on mobile), go to **Workspaces > [workspace name]**.
@@ -52,6 +63,14 @@ A free Submit workspace can be upgraded to Collect or Control. Once upgraded, it
# FAQ
+## Can a Workspace Admin change the Workspace plan?
+
+Yes. Any Workspace Admin can upgrade or downgrade the Workspace plan. Subscription settings, such as the subscription type and payment card, are still managed by the Workspace owner.
+
+## How will the Workspace owner know if a Workspace Admin changed the plan?
+
+Expensify posts every plan change in the Workspace's **#admins** room, which the Workspace owner and all Workspace Admins can see. The message shows which Workspace Admin upgraded or downgraded the Workspace.
+
## Can I switch back to Control after downgrading to Collect?
Yes. You can upgrade a workspace to Control at any time by returning to the **Plan type** setting.
diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management.md
index 390ec0b500d1..cd7a34adcdb2 100644
--- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management.md
+++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management.md
@@ -1,8 +1,8 @@
---
title: Learn About Plan and Subscription Management
description: Learn who can change a Workspace's plan or subscription in New Expensify and the conditions that apply, including when you can and can't downgrade.
-keywords: [New Expensify, plan management, subscription management, change plan, change subscription, downgrade plan, workspace Owner, Collect, Control, Annual, Pay-per-use]
-internalScope: Audience is Workspace owners. Covers who can change a Workspace's plan or subscription and the conditions that apply. Does not cover plan features or how to change a subscription.
+keywords: [New Expensify, plan management, subscription management, change plan, change subscription, downgrade plan, workspace Owner, Workspace Admin, Collect, Control, Annual, Pay-per-use]
+internalScope: Audience is Workspace owners and Workspace Admins. Covers who can change a Workspace's plan or subscription and the conditions that apply. Does not cover plan features or how to change a subscription.
retrievalIntent: When are Workspace plan or subscription changes allowed.
contentType: topic
platform: new
@@ -11,7 +11,7 @@ order: 1
# Learn About Plan and Subscription Management
-Every paid Workspace has a plan and a subscription, and together they determine how billing is calculated. The Workspace owner can manage them, but changes may be restricted depending on your current subscription type.
+Every paid Workspace has a plan and a subscription, and together they determine how billing is calculated. Any Workspace Admin can change the plan, and the Workspace owner manages the subscription. Changes may be restricted depending on your current subscription type.
If you're new to Expensify billing, start with [Learn How Billing and Subscriptions Work](/articles/new-expensify/billing-and-subscriptions/Learn-How-Billing-and-Subscriptions-Work).
@@ -19,9 +19,9 @@ If you're new to Expensify billing, start with [Learn How Billing and Subscripti
## Who can manage a plan or subscription
-Only the Workspace owner can manage the Workspace's plan or subscription. Each workspace has exactly one owner, whose payment card is charged for the subscription.
+Any Workspace Admin can change the Workspace's plan, including upgrading from Collect to Control and downgrading from Control to Collect.
-Being a Workspace Admin doesn't include plan and subscription management permissions. Admins can manage members and workspace settings, but they can't change the Workspace's plan or subscription unless they first take over ownership.
+Only the Workspace owner can manage the Workspace's subscription. Each Workspace has exactly one owner, whose payment card is charged for the subscription. Plan changes made by any Workspace Admin affect the Workspace owner's bill.
To learn more, see [Learn About Billing Permissions](/articles/new-expensify/billing-and-subscriptions/Learn-About-Billing-Permissions).
@@ -57,7 +57,7 @@ Use these articles to complete common plan and subscription tasks:
## Can a Workspace Admin change the plan or subscription?
-No. Only the Workspace owner can manage the Workspace's plan or subscription. If an admin needs to make these changes, they must first become the Workspace owner. [Learn how to transfer workspace ownership](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership).
+A Workspace Admin can change the plan, but not the subscription. Only the Workspace owner can manage the Workspace's subscription. If a Workspace Admin needs to change the subscription, they must first become the Workspace owner. [Learn how to transfer Workspace ownership](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership).
## Why can't I switch from Control to Collect?
diff --git a/docs/articles/new-expensify/concierge-ai/Concierge-Basics.md b/docs/articles/new-expensify/concierge-ai/Concierge-Basics.md
index f89eb4ce7de1..0299cec75300 100644
--- a/docs/articles/new-expensify/concierge-ai/Concierge-Basics.md
+++ b/docs/articles/new-expensify/concierge-ai/Concierge-Basics.md
@@ -64,7 +64,7 @@ Yes! Just be clear in your message. Concierge can understand multi-step instruct
# Can I talk to a real person instead of Concierge?
-Absolutely. Just say you’d like to chat with a human, and we’ll connect you—24/7.
+Absolutely. Just say you’d like to chat with a human, and we’ll connect you—24/7. You can also select **Account > Help > Talk to a human**.
If you’re working with an onboarding specialist or account manager, Concierge can schedule a call with them for you.
diff --git a/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md b/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md
index 29403cce3bac..aa5d5e5eff58 100644
--- a/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md
+++ b/docs/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards.md
@@ -41,6 +41,7 @@ If you haven’t set up a company card feed yet, learn how to set one up:
## What happens after you assign company cards
- Posted transactions from the assigned card import automatically into the member's account as expenses.
+- If the card is connected through Plaid, pending transactions also import and are marked as pending until they post. [Learn which connections import pending transactions](/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards#do-pending-transactions-import-from-a-direct-connection).
- Expensify imports transactions from the **Transaction start date** onward.
- The imported company card expenses are added to reports and submitted according to workspace rules.
diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md b/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md
index 6ba11d39b978..17f47f5b2997 100644
--- a/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md
+++ b/docs/articles/new-expensify/connect-credit-cards/connect-and-manage-personal-cards/Connect-Personal-Cards.md
@@ -24,12 +24,14 @@ Anyone with an Expensify account can connect up to two personal cards for free.
2. Tap **Add personal card**.
3. Select your country from the country selector.
4. If you selected **United States**, choose your bank from the bank selector.
- - If your bank is listed, you'll be prompted to connect via your bank's secure login (oAuth).
+ - If your bank is listed, you'll be prompted to directly connect via your bank's secure login.
- If your bank is not listed, select **Other** to connect via Plaid.
5. Follow the prompts to log into your bank and authorize the connection.
Once your card is connected, it will appear under **Cards** in the **Wallet**, and transactions will begin importing automatically.
+If your card is connected through Plaid, pending transactions also import. They're marked as pending and update automatically when they post. Cards connected directly through your bank's secure login import transactions after they post.
+
---
## What to do if you see a company card warning
diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md
index fe63c007544a..31606ab30014 100644
--- a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md
+++ b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards.md
@@ -1,7 +1,7 @@
---
title: Set up a Direct Connection for Company Cards
description: Learn how to connect a company card account using a direct connection so its cards can be assigned to workspace members and their transactions imported into Expensify.
-keywords: [New Expensify, set up direct company card connection, connect company cards Expensify, direct connection Expensify, direct feed Expensify, Plaid company cards]
+keywords: [New Expensify, set up direct company card connection, connect company cards Expensify, direct connection Expensify, direct feed Expensify, Plaid company cards, pending company card transactions, Plaid pending transactions, company card current balance, company card remaining limit]
internalScope: Audience is workspace admins and card admins. Covers connecting a company card account using a direct connection or Plaid. Does not cover assigning cards, managing cards, or troubleshooting connections.
retrievalIntent: How to set up a direct connection to a bank or card provider for company cards.
contentType: task
@@ -48,6 +48,8 @@ To set up a direct connection, you must:
- Cards with recent expenses appear as a list and can be assigned to workspace members.
- After a card is assigned, posted transactions import into the assigned member's account automatically as expenses.
+- If the card is connected through Plaid, pending transactions also import. They're marked as pending until they post. [Learn which connections import pending transactions](#do-pending-transactions-import-from-a-direct-connection).
+- For connections made through Plaid, the **Company cards** page shows the account's **Current balance** and **Remaining limit** when your bank reports them.
[Learn how to assign company cards](/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards).
@@ -92,6 +94,20 @@ If you're still unable to connect to your bank, you can [import company card tra
Yes, direct connections can be shared across workspaces. [Learn how to share a company card connection across workspaces](/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces).
+## Do pending transactions import from a direct connection?
+
+Pending transactions import only for cards connected through Plaid (the **Other** option in the bank list). Cards connected to any other bank in the list import transactions after they post, which typically takes 1–3 business days.
+
+When a pending transaction imports:
+
+- It's marked as pending until it posts.
+- It updates automatically when the transaction posts.
+- It's removed if the bank cancels the transaction or it doesn't post.
+
+A report can't be submitted or approved while every expense on it is pending.
+
+If you delete a pending expense, the transaction may import again after it posts.
+
## Can I import debit card transactions?
Yes, debit card transactions can be imported by following the same steps for setting up a direct connection for company cards.
diff --git a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md
index 8b429d92725c..d86370751e60 100644
--- a/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md
+++ b/docs/articles/new-expensify/connect-credit-cards/connect-company-cards/Share-a-Company-Card-Connection-Across-Workspaces.md
@@ -52,6 +52,7 @@ To share a company card connection, you must:
- Individual cards appear as a list and can be assigned to workspace members.
- After a card is assigned, posted transactions import into the assigned member's account automatically as expenses.
+- If the card is connected through Plaid, pending transactions also import and are marked as pending until they post. [Learn which connections import pending transactions](/articles/new-expensify/connect-credit-cards/connect-company-cards/Set-up-a-Direct-Connection-for-Company-Cards#do-pending-transactions-import-from-a-direct-connection).
[Learn how to assign company cards](/articles/new-expensify/connect-credit-cards/configure-and-manage-company-cards/Assign-Company-Cards).
diff --git a/docs/articles/new-expensify/connections/campfire/Configure-Campfire.md b/docs/articles/new-expensify/connections/campfire/Configure-Campfire.md
index 49fb760cd1a7..d79213a477e3 100644
--- a/docs/articles/new-expensify/connections/campfire/Configure-Campfire.md
+++ b/docs/articles/new-expensify/connections/campfire/Configure-Campfire.md
@@ -39,7 +39,7 @@ On the Campfire connection, select **Import** to configure:
- **Chart of accounts** – Imports your Campfire accounts as categories. This setting is always on.
- **Enable newly imported accounts** – Choose whether new Campfire accounts arrive as enabled categories. This setting is off by default.
- **Department** and Campfire dimensions – Under **All Campfire dimensions import as tags**, turn on each dimension you want to import. Each one you turn on becomes its own tag list. All dimensions are off by default.
-- **Tax rates** – Import Campfire tax rates as workspace taxes. This option only appears when your Campfire subsidiary has tax rates.
+- **Tax rates** – Import Campfire tax rates as workspace taxes. This option only appears when your Campfire entity has tax rates.
---
diff --git a/docs/articles/new-expensify/connections/campfire/Connect-to-Campfire.md b/docs/articles/new-expensify/connections/campfire/Connect-to-Campfire.md
index 115bcd07b846..504873da269b 100644
--- a/docs/articles/new-expensify/connections/campfire/Connect-to-Campfire.md
+++ b/docs/articles/new-expensify/connections/campfire/Connect-to-Campfire.md
@@ -1,14 +1,14 @@
---
title: Connect to Campfire
-description: Learn how to connect Campfire to Expensify with an API key, choose a subsidiary, and reconnect or disconnect the integration.
-keywords: [New Expensify, Campfire integration, connect Campfire, Campfire API key, Campfire subsidiary, Campfire accounting sync, Campfire authentication error, disconnect Campfire, Workspace Admin]
-internalScope: Audience is Workspace Admins connecting Campfire as an accounting integration. Covers adding the Campfire connection, entering the API key, choosing a subsidiary, fixing an authentication error, and disconnecting. Does not cover configuring import, export, or advanced sync settings, or exporting reports.
+description: Learn how to connect Campfire to Expensify with an API key, choose an entity, and reconnect or disconnect the integration.
+keywords: [New Expensify, Campfire integration, connect Campfire, Campfire API key, Campfire entity, Campfire accounting sync, Campfire authentication error, disconnect Campfire, Workspace Admin]
+internalScope: Audience is Workspace Admins connecting Campfire as an accounting integration. Covers adding the Campfire connection, entering the API key, choosing an entity, fixing an authentication error, and disconnecting. Does not cover configuring import, export, or advanced sync settings, or exporting reports.
order: 1
---
# Connect to Campfire
-Connect Campfire to your Expensify workspace to sync your accounting data. This article walks you through connecting Campfire with an API key, choosing a subsidiary, and completing the initial setup.
+Connect Campfire to your Expensify workspace to sync your accounting data. This article walks you through connecting Campfire with an API key, choosing an entity, and completing the initial setup.
Once connected, the integration imports:
@@ -47,10 +47,10 @@ Expensify validates the API key and starts the first sync with Campfire.
After the connection is established, the **Connections** section updates to show your connected Campfire integration, including:
- The connection status and last sync timestamp.
-- The selected subsidiary.
+- The selected entity.
- The **Import**, **Export**, and **Advanced** configuration tabs.
-If your Campfire organization has multiple subsidiaries, you can choose which one to connect. If only one eligible subsidiary is available, Expensify selects it automatically. The selected subsidiary determines which accounting data is available in Expensify.
+If your Campfire organization has multiple entities, you can choose which one to connect. If only one eligible entity is available, Expensify selects it automatically. The selected entity determines which accounting data is available in Expensify.
After the first sync, you can configure how data imports and exports. Learn how to [configure Campfire import, export, and advanced settings](/articles/new-expensify/connections/campfire/Configure-Campfire).
@@ -97,10 +97,10 @@ If your Campfire API key is rejected or later becomes invalid, the connection sh
Yes. Expensify encrypts your API key and stores it securely on its servers. The key is never exposed in the app or stored on your device.
-## Why can’t I select a Campfire subsidiary?
+## Why can’t I select a Campfire entity?
-**Subsidiary** is only selectable when your Campfire organization has more than one eligible subsidiary. Expensify only uses active entities and skips elimination and adjusting entities. If none are eligible, the sync fails. Add an active entity in Campfire, then sync the connection again.
+**Entity** is only selectable when your Campfire organization has more than one eligible entity. Expensify only uses active entities and skips elimination and adjusting entities. If none are eligible, the sync fails. Add an active entity in Campfire, then sync the connection again.
## Why don’t I see tax rates after connecting to Campfire?
-The **Tax rates** option only appears on the **Import** page when your Campfire subsidiary has tax rates set up.
+The **Tax rates** option only appears on the **Import** page when your Campfire entity has tax rates set up.
diff --git a/docs/articles/new-expensify/connections/campfire/Export-Reports-to-Campfire.md b/docs/articles/new-expensify/connections/campfire/Export-Reports-to-Campfire.md
index ec8e46dfbb6b..67947ec9a326 100644
--- a/docs/articles/new-expensify/connections/campfire/Export-Reports-to-Campfire.md
+++ b/docs/articles/new-expensify/connections/campfire/Export-Reports-to-Campfire.md
@@ -47,7 +47,7 @@ The report history shows when the export starts and when it finishes.
3. In the selection bar, select **Export**.
4. Select **Campfire**.
-**Note:** You can only export reports together when their workspaces connect to the same Campfire subsidiary.
+**Note:** You can only export reports together when their workspaces connect to the same Campfire entity.
---
diff --git a/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md b/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md
index ca1fd7f15564..276b758e94ef 100644
--- a/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md
+++ b/docs/articles/new-expensify/connections/dualentry/Configure-DualEntry.md
@@ -28,8 +28,6 @@ Each Workspace has its own DualEntry integration. To view or update the configur
2. Select **Accounting**.
3. On the DualEntry connection, select **Import**, **Export**, or **Advanced**.
-{:width="100%"}
-
---
## How to configure DualEntry import settings
diff --git a/docs/articles/new-expensify/connections/dualentry/Connect-to-DualEntry.md b/docs/articles/new-expensify/connections/dualentry/Connect-to-DualEntry.md
index e889edf5515c..d6a1a1865471 100644
--- a/docs/articles/new-expensify/connections/dualentry/Connect-to-DualEntry.md
+++ b/docs/articles/new-expensify/connections/dualentry/Connect-to-DualEntry.md
@@ -2,13 +2,13 @@
title: Connect to DualEntry
description: Learn how to connect DualEntry to Expensify with an API key to sync your accounting data.
keywords: [New Expensify, DualEntry integration, connect DualEntry, DualEntry API key, accounting sync, DualEntry setup]
-internalScope: Audience is Workspace Admins connecting DualEntry as an accounting integration. Covers adding the DualEntry connection, entering the API key, and selecting a subsidiary. Does not cover configuring import, export, or advanced sync settings.
+internalScope: Audience is Workspace Admins connecting DualEntry as an accounting integration. Covers adding the DualEntry connection, entering the API key, and selecting a company. Does not cover configuring import, export, or advanced sync settings.
order: 1
---
# Connect to DualEntry
-Connect DualEntry to your Expensify workspace to sync your accounting data. This article walks you through connecting DualEntry, selecting a subsidiary, and completing the initial setup.
+Connect DualEntry to your Expensify workspace to sync your accounting data. This article walks you through connecting DualEntry, selecting a company, and completing the initial setup.
Once connected, the integration imports:
@@ -35,7 +35,7 @@ To connect DualEntry, you must:
4. In the **DualEntry setup** pages, follow the on-screen instructions to generate an API key in DualEntry.
5. Paste the key into the **API key** field and select **Confirm**.
-Expensify validates the API key, connects to your DualEntry organization, and retrieves your available subsidiaries.
+Expensify validates the API key, connects to your DualEntry organization, and retrieves your available companies.
**Note:** Your API key is encrypted and stored securely on Expensify's servers. It is never exposed in the app or stored on your device.
@@ -48,10 +48,10 @@ Expensify validates the API key, connects to your DualEntry organization, and re
After the connection is established, the **Connections** section updates to show your connected DualEntry integration, including:
- The connection status and last sync timestamp.
-- The selected subsidiary.
+- The selected company.
- The **Import**, **Export**, and **Advanced** configuration settings.
-If your DualEntry organization has multiple subsidiaries, you can choose which one to connect. If only one eligible subsidiary is available, Expensify selects it automatically. The selected subsidiary determines which accounting data is available in Expensify.
+If your DualEntry organization has multiple companies, you can choose which one to connect. If only one eligible company is available, Expensify selects it automatically. The selected company determines which accounting data is available in Expensify.
After the initial sync completes, you can configure your import, export, and advanced accounting settings. Learn how to [configure your DualEntry import, export, and advanced settings](/articles/new-expensify/connections/dualentry/Configure-DualEntry).
@@ -87,9 +87,9 @@ If your DualEntry API key is rejected or later becomes invalid, the connection s
Tax rates are only available when DualEntry returns active VAT or GST rates for your organization. When available, tax rate import is off by default and can be enabled from the DualEntry **Import** settings.
-## Can I change which subsidiary is used?
+## Can I change which company is used?
-Yes. If your DualEntry organization has multiple subsidiaries, you can select a different subsidiary at any time from the **Connections** section. Expensify will sync data from the newly selected subsidiary, including its accounts, dimensions, and tax rates.
+Yes. If your DualEntry organization has multiple companies, you can select a different company at any time from the **Connections** section. Expensify will sync data from the newly selected company, including its accounts, dimensions, and tax rates.
## How can I disconnect DualEntry from a workspace?
diff --git a/docs/articles/new-expensify/connections/dualentry/Manage-Vendor-Matching-for-DualEntry.md b/docs/articles/new-expensify/connections/dualentry/Manage-Vendor-Matching-for-DualEntry.md
index b63936276903..042198847fce 100644
--- a/docs/articles/new-expensify/connections/dualentry/Manage-Vendor-Matching-for-DualEntry.md
+++ b/docs/articles/new-expensify/connections/dualentry/Manage-Vendor-Matching-for-DualEntry.md
@@ -1,8 +1,8 @@
---
title: Manage Vendor Matching for DualEntry
-description: Learn how DualEntry vendor matching assigns vendors to non-reimbursable company card expenses before they export, including automatic matching, manual selection, subsidiary scoping, and default vendor behavior.
+description: Learn how DualEntry vendor matching assigns vendors to non-reimbursable company card expenses before they export, including automatic matching, manual selection, company scoping, and default vendor behavior.
keywords: [DualEntry, vendor matching, vendor, company card expenses, default vendor for all company cards, Credit Card Misc, company card export]
-internalScope: Audience is workspace admins using the DualEntry connection for company card exports. Covers imported DualEntry vendors, subsidiary scoping, automatic and manual vendor assignment, default vendor behavior, the Credit Card Misc export fallback, and vendors that are no longer valid. Does not cover DualEntry connection setup, other DualEntry configuration settings, or vendor matching for other accounting connections.
+internalScope: Audience is workspace admins using the DualEntry connection for company card exports. Covers imported DualEntry vendors, company scoping, automatic and manual vendor assignment, default vendor behavior, the Credit Card Misc export fallback, and vendors that are no longer valid. Does not cover DualEntry connection setup, other DualEntry configuration settings, or vendor matching for other accounting connections.
order: 3
---
@@ -24,9 +24,9 @@ If your workspace isn't connected to DualEntry yet, learn how to [connect to Dua
Expensify only imports the vendors a workspace can actually export to. A DualEntry vendor appears in Expensify when it is:
- Active in DualEntry.
- - Either assigned to the **Subsidiary** selected for the connection, or not assigned to any subsidiary.
+ - Either assigned to the **Company** selected for the connection, or not assigned to any company.
-Vendors assigned to a different subsidiary are not available, so changing the **Subsidiary** on the connection changes which vendors you can select.
+Vendors assigned to a different company are not available, so changing the **Company** on the connection changes which vendors you can select.
The same list is used everywhere vendors appear in Expensify: automatic matching, the **Vendor** field on an expense, workspace merchant rules, and the **Default vendor for all company cards** setting.
@@ -83,7 +83,7 @@ If no **Credit Card Misc** vendor exists in DualEntry, Expensify creates one on
## What happens when a DualEntry vendor is no longer valid
-If a vendor assigned to an expense is deactivated in DualEntry, or is no longer available to the selected **Subsidiary**, the expense displays a **Vendor no longer valid** error, similar to category, tag, and tax violations.
+If a vendor assigned to an expense is deactivated in DualEntry, or is no longer available to the selected **Company**, the expense displays a **Vendor no longer valid** error, similar to category, tag, and tax violations.
Select an active vendor on the expense to clear the error. You can also select **None** to leave the **Vendor** field empty.
@@ -105,11 +105,11 @@ Yes. Once a workspace admin manually assigns a vendor to an expense, Expensify p
## Why don't I see any vendors to choose from?
-The vendor selector shows **No vendors found** when no DualEntry vendors are available to your workspace. This happens when your DualEntry vendor list is empty, when every vendor is inactive, or when every vendor belongs to a different subsidiary than the one selected for the connection. Add or activate the vendors in DualEntry, then sync the connection again.
+The vendor selector shows **No vendors found** when no DualEntry vendors are available to your workspace. This happens when your DualEntry vendor list is empty, when every vendor is inactive, or when every vendor belongs to a different company than the one selected for the connection. Add or activate the vendors in DualEntry, then sync the connection again.
-## Why did a vendor disappear after I changed the subsidiary?
+## Why did a vendor disappear after I changed the company?
-Vendors are scoped to the **Subsidiary** selected for the DualEntry connection. After you change the subsidiary, vendors that belong only to the previous subsidiary are no longer available, and expenses or merchant rules using them need an available vendor selected instead.
+Vendors are scoped to the **Company** selected for the DualEntry connection. After you change the company, vendors that belong only to the previous company are no longer available, and expenses or merchant rules using them need an available vendor selected instead.
## How do I know why Expensify assigned a vendor automatically?
diff --git a/docs/articles/new-expensify/connections/intuit-enterprise-suite/Configure-Intuit-Enterprise-Suite.md b/docs/articles/new-expensify/connections/intuit-enterprise-suite/Configure-Intuit-Enterprise-Suite.md
new file mode 100644
index 000000000000..68fe4ed51d1b
--- /dev/null
+++ b/docs/articles/new-expensify/connections/intuit-enterprise-suite/Configure-Intuit-Enterprise-Suite.md
@@ -0,0 +1,83 @@
+---
+title: Configure Intuit Enterprise Suite
+description: Learn how to configure Intuit Enterprise Suite import, export, and advanced settings in Expensify, including importing custom dimensions as tags.
+keywords: [New Expensify, Intuit Enterprise Suite, IES, Intuit Enterprise Suite configuration, Intuit Enterprise Suite import, Intuit Enterprise Suite export, custom dimensions, custom dimensions as tags]
+internalScope: Audience is workspace admins configuring an existing Intuit Enterprise Suite connection. Covers import settings (including custom dimensions), export settings, and advanced settings. Does not cover the initial connection.
+---
+
+# Configure Intuit Enterprise Suite
+
+Configure how Expensify imports accounting data from Intuit Enterprise Suite and exports expenses back to your accounting system.
+
+If you haven't connected Intuit Enterprise Suite yet, learn how to [connect to Intuit Enterprise Suite](/articles/new-expensify/connections/intuit-enterprise-suite/Connect-to-Intuit-Enterprise-Suite).
+
+Once configured, you can:
+
+- Import categories and dimensions from Intuit Enterprise Suite.
+- Export out-of-pocket and company card expenses.
+- Automatically sync reports and reimbursements.
+
+---
+
+## Where to find Intuit Enterprise Suite settings
+
+Each workspace has its own Intuit Enterprise Suite integration. To view or update the configuration settings:
+
+1. In the navigation tabs (on the left on web, on the bottom on mobile) select **Workspaces > [workspace name]**.
+2. Select **Accounting**.
+3. On the Intuit Enterprise Suite connection, choose **Import**, **Export**, or **Advanced**.
+
+{:width="100%"}
+
+---
+
+## How to configure Intuit Enterprise Suite import settings
+
+The **Import** tab controls which accounting data is imported from Intuit Enterprise Suite into Expensify.
+
+- **Chart of accounts** – Imports as Categories. This setting is always enabled.
+- **Dimensions** – Choose which Intuit Enterprise Suite custom dimensions to import as tags. Each available dimension can be enabled independently.
+
+---
+
+## How to configure Intuit Enterprise Suite export settings
+
+The **Export** tab determines how Expensify sends data to Intuit Enterprise Suite.
+
+- **Preferred exporter** – Assign the workspace admin to automatically receive reports to export.
+- **Export date** – Choose the date to use when exporting reports to Intuit Enterprise Suite. You can choose **Date of last expense**, **Export date**, or **Submitted date**.
+- **Export out-of-pocket expenses as** – Choose whether reimbursable expenses export as **Check**, **Journal entry**, or **Vendor bill**.
+- **Export invoices to** – Choose which account to use when exporting invoices to Intuit Enterprise Suite.
+- **Export company card expenses as** – Choose whether non-reimbursable expenses export as **Credit card**, **Debit card**, or **Vendor bill**.
+- **Export Expensify Card transactions as** – Expensify Card transactions export as **Credit card**.
+
+---
+
+## How to configure Intuit Enterprise Suite advanced settings
+
+The **Advanced** tab controls settings like sync frequency, reimbursement, and settlement automation.
+
+- **Auto-sync** – Automatically sync Expensify and Intuit Enterprise Suite every day and export reports automatically using your selected export method.
+- **Invite employees** – Choose whether to import Intuit Enterprise Suite employee records and automatically invite employees to the workspace.
+- **Auto-create entities** – Choose whether Expensify automatically creates vendors in Intuit Enterprise Suite when they don't already exist, and creates customers when exporting invoices.
+- **Sync reimbursed reports** – Automatically create bill payments in Intuit Enterprise Suite when reports are reimbursed in Expensify.
+
+---
+
+# FAQ
+
+## Why don't I see my custom dimensions on the Import page?
+
+Custom dimensions only appear when:
+
+- The workspace is connected to Intuit Enterprise Suite, not QuickBooks Online.
+- The custom dimension is active in the connected Intuit Enterprise Suite entity.
+- The connection has synced since the custom dimension was created.
+
+## Can I import a custom dimension as report fields instead of tags?
+
+No. Custom dimensions always import as tags.
+
+## Who can configure Intuit Enterprise Suite settings?
+
+Workspace admins on the Control plan with **Accounting** enabled under **More features** and Intuit Enterprise Suite connected to the workspace.
diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite.md b/docs/articles/new-expensify/connections/intuit-enterprise-suite/Connect-to-Intuit-Enterprise-Suite.md
similarity index 85%
rename from docs/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite.md
rename to docs/articles/new-expensify/connections/intuit-enterprise-suite/Connect-to-Intuit-Enterprise-Suite.md
index d654711a7476..5cebd282751a 100644
--- a/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite.md
+++ b/docs/articles/new-expensify/connections/intuit-enterprise-suite/Connect-to-Intuit-Enterprise-Suite.md
@@ -1,8 +1,8 @@
---
title: Connect to Intuit Enterprise Suite
description: Connect Intuit Enterprise Suite to your workspace in New Expensify to sync your accounting data automatically.
-keywords: [New Expensify, Intuit Enterprise Suite, IES, connect Intuit Enterprise Suite, Intuit Enterprise Suite integration, QuickBooks Online, accounting sync, Control plan, Workspace Admin]
-internalScope: Audience is Workspace Admins. Covers connecting Intuit Enterprise Suite to a workspace in New Expensify. Does not cover configuring import and export settings after connecting, connecting QuickBooks Online, or QuickBooks Desktop.
+keywords: [New Expensify, Intuit Enterprise Suite, IES, connect Intuit Enterprise Suite, Intuit Enterprise Suite integration, QuickBooks Online, accounting sync, Control plan, workspace admin]
+internalScope: Audience is workspace admins. Covers connecting Intuit Enterprise Suite to a workspace in New Expensify. Does not cover configuring import and export settings after connecting, connecting QuickBooks Online, or QuickBooks Desktop.
---
# Connect to Intuit Enterprise Suite
@@ -12,16 +12,18 @@ Connect Intuit Enterprise Suite to your Expensify workspace to sync your account
Once connected, the integration imports:
- Your chart of accounts as categories.
- - Your dimensions, like Departments, Projects, and Classes, as tags.
+ - Your custom dimensions as tags (when enabled).
- Tax rates (when enabled).
+Custom dimensions are turned off until you enable each one. Learn how to [configure Intuit Enterprise Suite](/articles/new-expensify/connections/intuit-enterprise-suite/Configure-Intuit-Enterprise-Suite).
+
---
## Who can connect Intuit Enterprise Suite to a workspace
To connect Intuit Enterprise Suite, you must:
-- Be a Workspace Admin with a workspace on the Control plan.
+- Be a workspace admin with a workspace on the Control plan.
- Have **Accounting** enabled on your workspace, under **More features**.
- Have Intuit Enterprise Suite login credentials.
@@ -56,7 +58,7 @@ The Intuit Enterprise Suite integration is only available on the Control plan. O
## Can I change which Entity is used?
-Yes. If your Intuit Enterprise Suite organization has multiple entities, you can select which entity to sync with your Expensify Workspace under **Entities**. Expensify will sync data from the newly selected entity, including its chart of accounts, departments, projects, classes and tax rates.
+Yes. If your Intuit Enterprise Suite organization has multiple entities, you can select which entity to sync with your Expensify workspace under **Entities**. Expensify will sync data from the newly selected entity, including its chart of accounts, departments, projects, classes and tax rates.
## Can I connect both QuickBooks Online and Intuit Enterprise Suite to the same workspace?
diff --git a/docs/articles/new-expensify/connections/intuit-enterprise-suite/Manage-Vendor-Matching-for-Intuit-Enterprise-Suite.md b/docs/articles/new-expensify/connections/intuit-enterprise-suite/Manage-Vendor-Matching-for-Intuit-Enterprise-Suite.md
new file mode 100644
index 000000000000..399fb352eba7
--- /dev/null
+++ b/docs/articles/new-expensify/connections/intuit-enterprise-suite/Manage-Vendor-Matching-for-Intuit-Enterprise-Suite.md
@@ -0,0 +1,93 @@
+---
+title: Manage Vendor Matching for Intuit Enterprise Suite
+description: Learn how Intuit Enterprise Suite vendor matching assigns vendors to non-reimbursable company card expenses before they export, including automatic matching, manual selection, and default vendor behavior.
+keywords: [Intuit Enterprise Suite, IES, vendor matching, vendor, company card expenses, default company card vendor, credit cards, company card export]
+internalScope: Audience is workspace admins using the Intuit Enterprise Suite connection for company card exports. Covers imported Intuit Enterprise Suite vendors, automatic and manual vendor assignment, default company card vendor behavior, and vendors that are no longer valid. Does not cover Intuit Enterprise Suite connection setup, other Intuit Enterprise Suite configuration settings, or vendor matching for other accounting connections.
+---
+
+# Manage Vendor Matching for Intuit Enterprise Suite
+
+Intuit Enterprise Suite vendor matching lets workspace admins review and update the vendor assigned to non-reimbursable company card expenses before they export to Intuit Enterprise Suite. Expensify imports your Intuit Enterprise Suite vendor list, automatically matches vendors where possible, and lets admins set or update the **Vendor** field before export. This helps ensure expenses export with the correct vendor instead of requiring manual corrections in Intuit Enterprise Suite.
+
+## Who can use Intuit Enterprise Suite vendor matching
+
+This feature is available to workspace admins whose workspace:
+
+ - Is connected to Intuit Enterprise Suite.
+ - Has **Export company card expenses as** set **Credit card** or **Debit card** in the Intuit Enterprise Suite configuration in Expensify.
+
+If your workspace isn't connected to Intuit Enterprise Suite yet, learn how to [connect to Intuit Enterprise Suite](/articles/new-expensify/connections/intuit-enterprise-suite/Connect-to-Intuit-Enterprise-Suite).
+
+## How vendors are matched to Intuit Enterprise Suite company card expenses
+
+Expensify assigns vendors automatically in the following order:
+
+ - If a workspace merchant rule specifies a vendor, that vendor is assigned.
+ - Otherwise, Expensify automatically matches the merchant name against your imported Intuit Enterprise Suite vendor list. For example, **STARBUCKS #456 DOWNTOWN** matches **Starbucks**.
+ - If no match is found, the **Vendor** field remains empty until a workspace admin selects one.
+
+Whenever a vendor is assigned automatically, Concierge posts a system message on the expense indicating whether the vendor was set by a merchant rule or by vendor matching.
+
+The **Vendor** field appears only on non-reimbursable expenses. It isn't shown on reimbursable expenses or on invoices.
+
+## How to select an Intuit Enterprise Suite vendor on an expense
+
+1. Open the non-reimbursable expense.
+2. Select **Vendor**.
+3. Search for the vendor by name.
+4. Select the vendor you want to assign.
+
+Once a vendor is selected manually, Expensify preserves that selection and won't overwrite it with automatic matching.
+
+## Where to find your imported Intuit Enterprise Suite vendors
+
+1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [workspace name]**.
+2. Select **Vendors**.
+3. Use **Find vendor** to search the list by name.
+
+Vendors are managed in Intuit Enterprise Suite, so the list is read-only in Expensify and refreshes when the connection syncs.
+
+## How to set a default company card vendor for Intuit Enterprise Suite
+
+1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [workspace name]**.
+2. Select **Accounting**.
+3. On the Intuit Enterprise Suite connection, select **Export**.
+4. Select **Export company card expenses as**.
+5. Select **Default vendor**.
+6. Select a vendor from your imported Intuit Enterprise Suite vendor list.
+
+The default company card vendor is used only when an expense doesn't already have a vendor assigned.
+
+## How vendors export to Intuit Enterprise Suite
+
+When company card expenses are exported, Expensify assigns vendors in the following order:
+
+1. The vendor selected on the expense.
+2. The **Default company card vendor** configured in the Intuit Enterprise Suite export settings.
+3. **Credit Card Misc.** or **Debit Card Misc.**, if neither of the above is available.
+
+## How vendors that are no longer valid affect expenses
+
+If a vendor assigned to an expense is removed or deactivated in Intuit Enterprise Suite, the expense displays a **Vendor no longer valid** error, similar to category, tag, and tax violations.
+
+Select an active vendor on the expense to clear the error.
+
+# FAQ
+
+## Do I have to set a vendor?
+
+No. The **Vendor** field is optional.
+
+Expensify automatically attempts to match a vendor using your imported Intuit Enterprise Suite vendor list. If no match is found, the field can remain blank, and the expense exports using the **Default company card vendor** configured in your Intuit Enterprise Suite export settings.
+
+## Does manually assigning a vendor stop automatic matching?
+
+Yes. Once a workspace admin manually assigns a vendor to an expense, Expensify preserves that selection and won't replace it with automatic matching.
+
+## Why don't I see any vendors to choose from?
+
+If your Intuit Enterprise Suite vendor list is empty, you won't see any vendors to choose from. Add vendors in Intuit Enterprise Suite, then sync the connection again.
+
+## How do I know why Expensify assigned a vendor automatically?
+
+When Expensify automatically assigns a vendor, Concierge posts a system message on the expense indicating whether the vendor was assigned by a merchant rule or by vendor matching.
diff --git a/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md b/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md
index c46a0defe2d2..11eee5820a50 100644
--- a/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md
+++ b/docs/articles/new-expensify/connections/netsuite/Configure-Netsuite.md
@@ -41,7 +41,7 @@ To control what data is imported from NetSuite:
- Employee Defaults
- **Customers and Projects** – Import as Tags or Report Fields
- To support **cross-subsidiary data**, enable **Intercompany Time and Expense** under **Setup > Company > Enable Features > Advanced Features**
-- **Tax** – Imports NetSuite Tax Groups into the **Taxes** tab
+- **Tax** – Imports Tax Groups from legacy tax accounts or individual Tax Codes from SuiteTax accounts
- **Custom Segments and Records** – Import as Tags or Report Fields
- Use Field IDs from NetSuite’s **Transactions** tab
- Do not use the “Filtered by” feature in NetSuite Custom Segments
diff --git a/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md b/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md
index d841ec715e4b..d990c2770358 100644
--- a/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md
+++ b/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md
@@ -38,7 +38,7 @@ Complete these steps in order. Each step has its own section below with detailed
7. Check your expense report transaction forms
8. Check your vendor bill transaction forms
9. Check your vendor credit transaction forms
-10. Set up tax groups in NetSuite
+10. Configure taxes in NetSuite
11. Connect Expensify to NetSuite using OAuth 2.0
If you're connecting with token-based authentication instead, follow the steps in **How to connect to NetSuite using token-based authentication** below.
@@ -134,9 +134,29 @@ Expense categories map to Expensify categories and are linked to GL accounts so
---
-## How to set up tax groups in NetSuite
+## How to configure taxes in NetSuite
-Expensify supports Tax Groups, not individual Tax Codes, except when you use SuiteTax. Under **Setup > Accounting > Set Up Taxes**, set **Tax Code Lists Include** to **Tax Groups And Tax Codes** or **Tax Groups Only**. In the UK and Ireland, make sure each Tax Group contains only one Tax Code.
+SuiteTax accounts import individual Tax Codes. Legacy tax accounts import Tax Groups.
+
+**SuiteTax accounts**
+
+If the account uses SuiteTax, complete these steps:
+
+1. Go to **Setup > Users/Roles > Manage Roles**.
+2. Edit the role used for the Expensify connection.
+3. Under **Permissions > Lists**, set **Tax Details Tab** to **Full**.
+4. Select **Save**.
+5. Go to **Setup > SuiteTax Engine > Manage SuiteTax Engine Roles**.
+6. Select **Assign SuiteTax Engine Permissions to Role**.
+7. In the **Role** field, choose the role used for the Expensify connection.
+8. On the **SuiteTax Engine Permissions** subtab, set each permission to **Full**.
+9. Select **Save**.
+
+Do not complete the **Legacy tax accounts** steps.
+
+**Legacy tax accounts**
+
+If the account uses legacy taxes, under **Setup > Accounting > Set Up Taxes**, set **Tax Code Lists Include** to **Tax Groups And Tax Codes** or **Tax Groups Only**. In the UK and Ireland, make sure each Tax Group contains only one Tax Code.
To create a Tax Group:
@@ -198,7 +218,7 @@ Complete these steps in order. Steps 1 and 5–11 are the same as for an OAuth 2
8. Check your expense report transaction forms
9. Check your vendor bill transaction forms
10. Check your vendor credit transaction forms
-11. Set up tax groups in NetSuite
+11. Configure taxes in NetSuite
12. Connect Expensify to NetSuite using access tokens
---
diff --git a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0056-Export-Error.md b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0056-Export-Error.md
index 0a79d8edcef6..bdee8a586a0f 100644
--- a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0056-Export-Error.md
+++ b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0056-Export-Error.md
@@ -168,6 +168,8 @@ Only reports with a positive reimbursable amount can be exported.
4. Under **Permissions > Lists**, set **Tax Details Tab** to **Full**.
5. Click **Save**.
+This permission remains required when **Export Foreign Currency Amount** is disabled.
+
---
# FAQ
diff --git a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md
index ae03aac56e27..6f19e7d253bb 100644
--- a/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md
+++ b/docs/articles/new-expensify/connections/netsuite/Troubleshooting/Export-Errors/NS0068-Export-Error.md
@@ -1,111 +1,114 @@
---
title: NS0068 Export Error in NetSuite Integration
-description: Learn what the NS0068 export error means and how to make the Created From field visible on NetSuite transaction forms before exporting.
-keywords: NS0068, NetSuite Created From field error, permission to set Created From NetSuite, transaction form visibility NetSuite, Expensify Card journal entry error, Expensify NetSuite integration, Workspace Admin
-internalScope: Audience is Workspace Admins using the NetSuite integration. Covers resolving the NS0068 export error caused by the Created From field not being visible on NetSuite transaction forms. Does not cover role permission or token configuration issues.
+description: Learn what the NS0068 export error means and how to configure the Created From field (custbody_createdfrom_expensify) in NetSuite so Expensify can set it during export.
+keywords: NS0068, NetSuite Created From field error, custbody_createdfrom_expensify, permission to set Created From NetSuite, Transaction Body Fields NetSuite, transaction form visibility NetSuite, custom form ID NetSuite, Expensify Connect bundle, Expensify Card journal entry error, Expensify NetSuite integration, workspace admin
+internalScope: Audience is workspace admins using the NetSuite integration. Covers resolving the NS0068 export error caused by the Created From field (custbody_createdfrom_expensify) not applying to the export record type, being hidden on the export form, not being editable by the Expensify role, or missing from NetSuite. Does not cover token configuration issues.
---
+
+
# NS0068 Export Error in NetSuite Integration
If you see the error:
-NS0068 Export Error: You do not have permission to set value for element — 'Created From'.
+NS0068 Export Error: NetSuite won't let Expensify set the 'Created From' field (custbody_createdfrom_expensify).
-This means the **Created From** field is not visible on the preferred export form in NetSuite.
+This means NetSuite is blocking Expensify from setting the **Created From** field during export.
-The Workspace must be able to access and populate the Created From field during export.
+The **Created From** field is a custom transaction body field with the ID `custbody_createdfrom_expensify`. The **Expensify Connect bundle** adds it to NetSuite.
---
-## Why the NS0068 Export Error Happens in NetSuite
+## Why the NS0068 export error happens in NetSuite
The NS0068 error typically occurs when:
-- The **Created From** field is hidden on the NetSuite transaction form used for exports.
-- The preferred transaction form does not allow the field to be displayed.
-- NetSuite blocks the integration from setting the field value because it is not visible.
-
-If the field is hidden, NetSuite prevents the integration from setting its value and the export fails.
+- The **Created From** field does not apply to the record type you're exporting to (vendor bills, expense reports, or journal entries).
+- The **Created From** field is hidden on the transaction form Expensify uses for your export type.
+- The role used by the Expensify connection can't edit the **Created From** field.
+- The **Created From** field is missing from NetSuite because the **Expensify Connect bundle** is not installed or is out of date.
-This is a transaction form visibility issue, not a role permission or token configuration issue.
+If any of these apply, NetSuite prevents the integration from setting the field value and the export fails.
---
-## How to Fix the NS0068 Export Error
+## How to fix the NS0068 export error
-Follow the steps below to make the Created From field visible.
+Follow the steps below to check the field setup, form visibility, and role access in NetSuite.
---
-## Make the Created From Field Visible in NetSuite
+## How to check the Created From field settings in NetSuite
1. Log in to NetSuite as an administrator.
-2. Go to **Customization > Forms > Transaction Forms**.
-3. Locate the transaction form marked as **Preferred** for your export type.
-4. Click **Edit**.
-5. Locate the **Created From** field.
-6. Ensure the field is marked as **Show**.
-7. Click **Save**.
+2. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields**.
+3. Open the field with the ID **custbody_createdfrom_expensify**.
+4. On the **Applies To** tab, confirm the option for your export type is selected: **Purchase** for vendor bills, **Expense Report** for expense reports, or **Journal** for journal entries.
+5. On the **Access** tab, confirm **Default Access Level** is **Edit**. If the **Role** list includes the role used by the Expensify connection, such as **Expensify Integration**, confirm its access level is also **Edit**.
+6. Select **Save**.
-Confirm the field is visible on the preferred form before proceeding.
+If you can't find **custbody_createdfrom_expensify**, update or reinstall the Expensify Connect bundle before continuing. [Learn how to update the Expensify Connect bundle in NetSuite](/articles/new-expensify/connections/netsuite/Update-Expensify-Connect-Bundle-in-NetSuite).
---
-## Sync the Workspace in Expensify
+## How to make the Created From field visible on the export form
+
+Expensify exports with the transaction form marked as **Preferred** in NetSuite. If you set a custom form under **Workspaces > [workspace name] > Accounting > Advanced > Custom form ID**, Expensify uses that form instead.
-After updating the form:
+1. Go to **Customization > Forms > Transaction Forms**.
+2. Locate the transaction form Expensify uses for your export type.
+3. Select **Edit**.
+4. Go to **Screen Fields > Main**.
+5. Confirm the **Created From** field is marked as **Show**.
+6. Select **Save**.
-On web:
+---
-1. Go to the navigation tabs on the left and select **Workspaces**.
-2. Select your Workspace.
-3. Click **Accounting**.
-4. Click the three-dot menu next to the NetSuite connection.
-5. Click **Sync Now**.
+## How to sync the workspace in Expensify
-On mobile:
+After updating NetSuite:
-1. Tap the navigation tabs on the bottom and select **Workspaces**.
-2. Select your Workspace.
-3. Tap **Accounting**.
-4. Tap the three-dot menu next to the NetSuite connection.
-5. Tap **Sync Now**.
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name] > Accounting**.
+2. Select **More (⋮)** next to the NetSuite connection.
+3. Select **Sync now**.
---
-## Retry the Export
+## How to retry the export
1. Open the report.
2. Retry exporting to NetSuite.
-If the Created From field is visible on the preferred form, the export should complete successfully.
+If the Created From field applies to your export type, is visible on the export form, and is editable by the Expensify role, the export should complete successfully.
---
-# Additional Steps for Expensify Card Journal Entries
+## How to fix the NS0068 export error for Expensify Card journal entries
-If you are exporting **Expensify Card transactions as journal entries**, confirm the Created From field is visible on the journal entry form.
+If you are exporting **Expensify Card transactions as journal entries**, confirm the Created From field is set up for journal entries.
-1. Go to **Customization > Forms > Transaction Forms**.
-2. Click **Edit** next to the preferred journal entry form.
-3. Go to **Screen Fields > Main**.
-4. Confirm **Created From** is marked as **Show**.
-5. Click **Save**.
+1. Go to **Customization > Lists, Records, & Fields > Transaction Body Fields** and open **custbody_createdfrom_expensify**.
+2. On the **Applies To** tab, confirm **Journal** is selected, then select **Save**.
+3. Go to **Customization > Forms > Transaction Forms**.
+4. Select **Edit** next to the preferred journal entry form.
+5. Go to **Screen Fields > Main**.
+6. Confirm **Created From** is marked as **Show**.
+7. Select **Save**.
-Then return to the Workspace and select **Sync Now** before retrying the export.
+Then sync the workspace in Expensify before retrying the export.
---
# FAQ
-## Does the NS0068 Export Error Affect All Export Types?
+## Does the NS0068 export error affect all export types?
-It affects exports using a transaction form where the Created From field is hidden.
+It affects any export type where the Created From field doesn't apply to the record, is hidden on the export form, or isn't editable by the Expensify role.
-## Do I Need NetSuite Admin Access to Fix the NS0068 Export Error?
+## Do I need NetSuite admin access to fix the NS0068 export error?
-Yes. Updating transaction form field visibility requires NetSuite administrator permissions.
+Yes. Updating custom field settings and transaction form visibility requires NetSuite administrator permissions.
-## Do I Need to Reconnect the Integration?
+## Do I need to reconnect the integration?
-No. Making the Created From field visible and selecting **Sync Now** is typically sufficient.
+No. Updating the Created From field settings and selecting **Sync now** is typically sufficient. If the field is missing, update or reinstall the Expensify Connect bundle first.
diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md b/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md
index 7f22ed3ba93f..b26b52417db3 100644
--- a/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md
+++ b/docs/articles/new-expensify/connections/quickbooks-online/Connect-to-QuickBooks-Online.md
@@ -1,7 +1,7 @@
---
title: Connect to QuickBooks Online
description: Integrate QuickBooks Online with Expensify
-keywords: [New Expensify, QuickBooks Online, QBO integration, connect QuickBooks, reconnect QuickBooks, accounting sync, Expensify control plan, accounting sync, QuickBooks ISE, QuickBooks Essentials,Quickbooks Simple Start]
+keywords: [New Expensify, QuickBooks Online, QBO integration, connect QuickBooks, reconnect QuickBooks, accounting sync, Expensify control plan, accounting sync, QuickBooks Essentials,Quickbooks Simple Start]
internalScope: Audience is Workspace Admins. Covers connecting and reconnecting QuickBooks Online to Expensify and supported features. Does not cover QuickBooks Desktop or advanced accounting configuration. QuickBooks Online, QBO integration, connect QuickBooks Online, reconnect QuickBooks, QuickBooks Online red dot error, Intuit login error, accounting sync, Workspace Admin, automate expense syncing
order: 1
---
@@ -9,8 +9,6 @@ order: 1
To use the QuickBooks Online connection, you must have a **QuickBooks Online account** and an Expensify **Collect** plan.
-**Intuit Enterprise Suite** is a separate connection, listed directly below **QuickBooks Online** on the **Accounting** page, and it requires the **Control** plan. Learn how to [connect to Intuit Enterprise Suite](/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite).
-
The **QuickBooks Self-Employed** subscription is not supported.
---
@@ -19,20 +17,20 @@ The **QuickBooks Self-Employed** subscription is not supported.
Expensify's QuickBooks Online integration includes different features depending on your subscription level. While some options may appear in the app, you’ll see an error if your plan doesn’t support them.
-| Feature | Simple Start | Essentials | Essentials Plus | Intuit Enterprise Suite |
-|----------------------------|--------------|------------|-----------------|------------------------|
-| Expense Reports | ✅ | ✅ | ✅ | ✅ |
-| GL Accounts as Categories | ✅ | ✅ | ✅ | ✅ |
-| Credit Card Transactions | ✅ | ✅ | ✅ | ✅ |
-| Debit Card Transaction | | ✅ | ✅ | ✅ |
-| Classes | | ✅ | ✅ | ✅ |
-| Customers | | ✅ | ✅ | ✅ |
-| Projects | | ✅ | ✅ | ✅ |
-| Vendor Bills | | ✅ | ✅ | ✅ |
-| Journal Entries | | ✅ | ✅ | ✅ |
-| Tax | | ✅ | ✅ | ✅ |
-| Billable | | | ✅ | ✅ |
-| Location | | | ✅ | ✅ |
+| Feature | Simple Start | Essentials | Essentials Plus |
+|----------------------------|--------------|------------|-----------------|
+| Expense Reports | ✅ | ✅ | ✅ |
+| GL Accounts as Categories | ✅ | ✅ | ✅ |
+| Credit Card Transactions | ✅ | ✅ | ✅ |
+| Debit Card Transaction | | ✅ | ✅ |
+| Classes | | ✅ | ✅ |
+| Customers | | ✅ | ✅ |
+| Projects | | ✅ | ✅ |
+| Vendor Bills | | ✅ | ✅ |
+| Journal Entries | | ✅ | ✅ |
+| Tax | | ✅ | ✅ |
+| Billable | | | ✅ |
+| Location | | | ✅ |
---
diff --git a/docs/articles/new-expensify/connections/rillet/Configure-Rillet.md b/docs/articles/new-expensify/connections/rillet/Configure-Rillet.md
index 1eb68780ed2e..18ba4f9fb572 100644
--- a/docs/articles/new-expensify/connections/rillet/Configure-Rillet.md
+++ b/docs/articles/new-expensify/connections/rillet/Configure-Rillet.md
@@ -27,8 +27,6 @@ Each Workspace has its own Rillet integration. To view or update the configurati
2. Select **Accounting**.
3. On the Rillet connection, select **Import**, **Export**, or **Advanced**.
-{:width="100%"}
-
---
## How to configure Rillet import settings
@@ -40,7 +38,7 @@ On the Rillet integration, choose **Import** to configure:
- **Chart of accounts** – Imports active Expense, Current Asset, and Fixed Asset accounts as Categories. This setting is always enabled.
- **Enable newly imported accounts** – Choose whether newly imported accounts arrive as enabled or disabled categories. This defaults to off, so new accounts import as disabled categories.
- **Dimensions** – Choose which Rillet dimensions to import as tags. Each available dimension (such as Departments, Projects, or Classes) can be enabled independently.
-- **Tax rates** – Import tax rates from Rillet. This option only appears when tax rates are available for the selected subsidiary.
+- **Tax rates** – Import tax rates from Rillet. This option only appears when tax rates are available for the selected entity.
---
diff --git a/docs/articles/new-expensify/connections/rillet/Connect-to-Rillet.md b/docs/articles/new-expensify/connections/rillet/Connect-to-Rillet.md
index 61eae1d34973..678f27ea6b9f 100644
--- a/docs/articles/new-expensify/connections/rillet/Connect-to-Rillet.md
+++ b/docs/articles/new-expensify/connections/rillet/Connect-to-Rillet.md
@@ -2,13 +2,13 @@
title: Connect to Rillet
description: Learn how to connect Rillet to Expensify with an API key to sync your accounting data.
keywords: [New Expensify, Rillet integration, connect Rillet, Rillet API key, accounting sync, Rillet setup]
-internalScope: Audience is Workspace Admins connecting Rillet as an accounting integration. Covers adding the Rillet connection, entering the API key, and selecting a subsidiary. Does not cover configuring import, export, or advanced sync settings.
+internalScope: Audience is Workspace Admins connecting Rillet as an accounting integration. Covers adding the Rillet connection, entering the API key, and selecting an entity. Does not cover configuring import, export, or advanced sync settings.
order: 1
---
# Connect to Rillet
-Connect Rillet to your Expensify workspace to sync your accounting data. This article walks you through connecting Rillet, selecting a subsidiary, and completing the initial setup.
+Connect Rillet to your Expensify workspace to sync your accounting data. This article walks you through connecting Rillet, selecting an entity, and completing the initial setup.
Once connected, the integration imports:
@@ -35,7 +35,7 @@ To connect Rillet, you must:
3. In the **Connections** section next to **Rillet**, select **Connect**.
4. In the **Rillet setup** page that opens, follow the on-screen instructions to generate an API key in Rillet, paste it into the **API key** field, and click **Confirm**.
-Expensify validates the API key, connects to your Rillet organization, and retrieves your available subsidiaries.
+Expensify validates the API key, connects to your Rillet organization, and retrieves your available entities.
**Note:** Your API key is encrypted and stored securely on Expensify's servers. It is never exposed in the app or stored on your device.
@@ -48,11 +48,11 @@ Expensify validates the API key, connects to your Rillet organization, and retri
After the connection is established, the **Connections** section updates to show your connected Rillet integration, including:
- The connection status and last sync timestamp.
-- The selected subsidiary.
+- The selected entity.
- The **Import**, **Export**, and **Advanced** configuration settings.
- Additional integration options under **Other**.
-If your Rillet organization has multiple subsidiaries, you can choose which one to connect. If only one eligible subsidiary is available, Expensify selects it automatically. The selected subsidiary determines which accounting data is available in Expensify.
+If your Rillet organization has multiple entities, you can choose which one to connect. If only one eligible entity is available, Expensify selects it automatically. The selected entity determines which accounting data is available in Expensify.
After the initial sync completes, you can configure your import, export, and advanced accounting settings. Learn how to [configure your Rillet import, export, and advanced settings](/articles/new-expensify/connections/rillet/Configure-Rillet).
@@ -84,6 +84,6 @@ Yes. Expensify encrypts your API key and stores it securely on its servers. The
Tax rates are only imported if your Rillet organization returns them. If no tax rates are available, tax rate options won't appear in Expensify.
-## Can I change which subsidiary is used?
+## Can I change which entity is used?
-Yes. If your Rillet organization has multiple subsidiaries, you can select a different subsidiary at any time from the **Connections** section. Expensify will sync data from the newly selected subsidiary, including its accounts, dimensions, and tax rates.
+Yes. If your Rillet organization has multiple entities, you can select a different entity at any time from the **Connections** section. Expensify will sync data from the newly selected entity, including its accounts, dimensions, and tax rates.
diff --git a/docs/articles/new-expensify/connections/rillet/Manage-Vendor-Matching-for-Rillet.md b/docs/articles/new-expensify/connections/rillet/Manage-Vendor-Matching-for-Rillet.md
index 752cbb9102fa..d804d48c84ab 100644
--- a/docs/articles/new-expensify/connections/rillet/Manage-Vendor-Matching-for-Rillet.md
+++ b/docs/articles/new-expensify/connections/rillet/Manage-Vendor-Matching-for-Rillet.md
@@ -3,8 +3,6 @@ title: Manage Vendor Matching for Rillet
description: Learn how Rillet vendor matching assigns vendors to non-reimbursable company card expenses before they export, including automatic matching, manual selection, and default vendor behavior.
keywords: [Rillet, vendor matching, vendor, company card expenses, default company card vendor, credit cards, company card export]
internalScope: Audience is Workspace Admins using the Rillet connection for company card exports. Covers imported Rillet vendors, automatic and manual vendor assignment, default company card vendor behavior, and vendors that are no longer valid. Does not cover Rillet connection setup, other Rillet configuration settings, or vendor matching for other accounting connections.
-noindex: true
-sitemap: false
---
# Manage Vendor Matching for Rillet
diff --git a/docs/articles/new-expensify/connections/sage-intacct/Manage-Vendor-Matching-for-Sage-Intacct.md b/docs/articles/new-expensify/connections/sage-intacct/Manage-Vendor-Matching-for-Sage-Intacct.md
index 6c4450529b7c..17f1d29be1b6 100644
--- a/docs/articles/new-expensify/connections/sage-intacct/Manage-Vendor-Matching-for-Sage-Intacct.md
+++ b/docs/articles/new-expensify/connections/sage-intacct/Manage-Vendor-Matching-for-Sage-Intacct.md
@@ -3,8 +3,6 @@ title: Manage Vendor Matching for Sage Intacct
description: Learn how Sage Intacct vendor matching assigns vendors to non-reimbursable company card expenses before they export, including automatic matching, manual selection, and default vendor behavior.
keywords: [Sage Intacct, vendor matching, vendor, company card expenses, default vendor, Credit Card Charges, credit card export]
internalScope: Audience is Workspace Admins using the Sage Intacct connection with Credit Card Charges company card exports. Covers imported vendors, automatic and manual vendor assignment, default vendor behavior, and vendor export order. Does not cover Sage Intacct connection setup or other export types.
-noindex: true
-sitemap: false
---
# Manage Vendor Matching for Sage Intacct
diff --git a/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md
new file mode 100644
index 000000000000..321331f5b379
--- /dev/null
+++ b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Authentication-and-Login-errors/INT133-Sync-Error.md
@@ -0,0 +1,75 @@
+---
+title: INT133 Sync Error in Sage Intacct Integration
+description: Learn what the INT133 sync error means and how to reactivate the Sage Intacct web services user to restore the connection.
+keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Sage Intacct sync authentication failure, PL04000076, Workspace Admin
+internalScope: Audience is Workspace Admins managing the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122).
+---
+
+# INT133 Sync Error in Sage Intacct Integration
+
+If you see the error:
+
+INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again.
+
+This means Sage Intacct rejected the connection because the web services user is set to **Inactive**.
+
+When the web services user is inactive, syncing and exports will not work.
+
+---
+
+## Why the INT133 Sync Error Happens in Sage Intacct
+
+The INT133 error typically occurs when:
+
+- The **xmlgateway_expensify** web services user was deactivated in Sage Intacct.
+- An administrator changed the user's status to **Inactive** during a user cleanup or license review.
+- The web services user that was used to connect the Workspace is no longer in use.
+
+Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first.
+
+---
+
+## How to Fix the INT133 Sync Error
+
+Follow the steps below to reactivate the web services user and restore the connection.
+
+---
+
+## Reactivate the Web Services User in Sage Intacct
+
+1. Log in to Sage Intacct as an administrator.
+2. Go to **Company > Admin > Web Services users**.
+3. Locate the **xmlgateway_expensify** web services user.
+4. Click **Edit**.
+5. Change the **Status** to **Active**.
+6. Click **Save**.
+
+If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials.
+
+---
+
+## Retry the Sync
+
+After reactivating the web services user:
+
+1. Go to **Workspaces > [Workspace Name] > Accounting**.
+2. Select **Sync Now**.
+3. Confirm the sync completes successfully.
+
+If the web services user is active, the sync should complete without errors.
+
+---
+
+# FAQ
+
+## Do I Need Sage Intacct Admin Access to Fix This?
+
+Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct.
+
+## Do I Need to Reenter My Credentials?
+
+Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in the Workspace.
+
+## Does This Error Affect Exports?
+
+Yes. Exports and other integration features will not work until the web services user is active again.
diff --git a/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md
new file mode 100644
index 000000000000..321331f5b379
--- /dev/null
+++ b/docs/articles/new-expensify/connections/sage-intacct/Troubleshooting/Sync-Errors/INT133-Sync-Error.md
@@ -0,0 +1,75 @@
+---
+title: INT133 Sync Error in Sage Intacct Integration
+description: Learn what the INT133 sync error means and how to reactivate the Sage Intacct web services user to restore the connection.
+keywords: INT133, Sage Intacct inactive user, xmlgateway_expensify inactive, Sage Intacct web services user inactive, Sage Intacct sync authentication failure, PL04000076, Workspace Admin
+internalScope: Audience is Workspace Admins managing the Sage Intacct integration. Covers resolving the INT133 sync error caused by an inactive Sage Intacct web services user. Does not cover incorrect credentials or Sender ID errors (see INT122).
+---
+
+# INT133 Sync Error in Sage Intacct Integration
+
+If you see the error:
+
+INT133 Sync Error: The Sage Intacct web services user is inactive. Please reactivate the user in Sage Intacct and attempt sync again.
+
+This means Sage Intacct rejected the connection because the web services user is set to **Inactive**.
+
+When the web services user is inactive, syncing and exports will not work.
+
+---
+
+## Why the INT133 Sync Error Happens in Sage Intacct
+
+The INT133 error typically occurs when:
+
+- The **xmlgateway_expensify** web services user was deactivated in Sage Intacct.
+- An administrator changed the user's status to **Inactive** during a user cleanup or license review.
+- The web services user that was used to connect the Workspace is no longer in use.
+
+Reentering the same credentials will not fix this error. The user must be reactivated in Sage Intacct first.
+
+---
+
+## How to Fix the INT133 Sync Error
+
+Follow the steps below to reactivate the web services user and restore the connection.
+
+---
+
+## Reactivate the Web Services User in Sage Intacct
+
+1. Log in to Sage Intacct as an administrator.
+2. Go to **Company > Admin > Web Services users**.
+3. Locate the **xmlgateway_expensify** web services user.
+4. Click **Edit**.
+5. Change the **Status** to **Active**.
+6. Click **Save**.
+
+If the web services user was deleted or can't be reactivated, create a new web services user and reconnect the Workspace with the new credentials.
+
+---
+
+## Retry the Sync
+
+After reactivating the web services user:
+
+1. Go to **Workspaces > [Workspace Name] > Accounting**.
+2. Select **Sync Now**.
+3. Confirm the sync completes successfully.
+
+If the web services user is active, the sync should complete without errors.
+
+---
+
+# FAQ
+
+## Do I Need Sage Intacct Admin Access to Fix This?
+
+Yes. Changing the status of a web services user requires administrator permissions in Sage Intacct.
+
+## Do I Need to Reenter My Credentials?
+
+Not usually. If you reactivate the same web services user, the existing credentials will work. If you create a new web services user, enter the new credentials in the Workspace.
+
+## Does This Error Affect Exports?
+
+Yes. Exports and other integration features will not work until the web services user is active again.
diff --git a/docs/articles/new-expensify/domains/Manage-Domain-Members.md b/docs/articles/new-expensify/domains/Manage-Domain-Members.md
index b5cb0976cde8..6afb1b123909 100644
--- a/docs/articles/new-expensify/domains/Manage-Domain-Members.md
+++ b/docs/articles/new-expensify/domains/Manage-Domain-Members.md
@@ -111,8 +111,9 @@ Domain Admins can assign or unassign a vacation delegate on behalf of a domain m
3. Click **Domain Members**.
4. Select the member.
5. Click **Vacation delegate**.
-6. Select the member you want to assign as the delegate.
-7. Click **Confirm**.
+6. Select **Choose delegate**, then select the member you want to assign as the delegate.
+7. Under **Clear after (recommended)**, choose the last day the delegate should cover (optional).
+8. Select **Save**.
---
diff --git a/docs/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-Pay-or-Google-Wallet.md b/docs/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-Pay-or-Google-Wallet.md
index c139e8f82a42..d4d22ec15d00 100644
--- a/docs/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-Pay-or-Google-Wallet.md
+++ b/docs/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-Pay-or-Google-Wallet.md
@@ -8,6 +8,7 @@ internalScope: Audience is Expensify Cardholders. Covers adding an Expensify Car
# Add the Expensify Card to Apple Pay or Google Wallet
You can use your Expensify Card for contactless in-person payments by adding it to Apple Pay or Google Wallet.
+Please note that Apple Pay and Google Wallet support is currently available for US cardholders only. Support for UK and EU cardholders is coming soon.
---
diff --git a/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md b/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md
index d9d34895a316..2b1478f80152 100644
--- a/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md
+++ b/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md
@@ -20,8 +20,7 @@ It gives you a quick way to see whether anything needs attention, get oriented,
Home is the default view when you sign in.
-- **Web:** Click the **Home** icon in the navigation tabs on the left to return to Home at any time.
-- **Mobile:** Tap the **Home** icon in the navigation tabs on the bottom to return to Home.
+To return to Home at any time, select **Home** in the navigation tabs (on the left on web, on the bottom on mobile).
---
@@ -106,28 +105,27 @@ To review every trip instead of just the next 7 days, learn how to [view upcomin
The **Recently added** section shows your most recently added expenses, so you can quickly get back to what you just created or imported.
- Select any expense to open it.
-- To see your full list of expenses, select the three dots **(⋮)** in the **Recently added** section, then select **View all expenses**.
+- To see your full list of expenses, select **More (⋮)** in the **Recently added** section, then select **View all expenses**.
---
-## How the Spend over time chart and insights work on Home
+## How insights work on Home
-The chart section on Home shows one spending insight at a time, starting with **Spend over time**, which displays a line chart of your workspace spending trends.
+**Home** displays one insight at a time, with **Spend over time** selected by default.
-Select the insight title (with the chevron next to it) to open the insight picker, then choose a different insight to show in the chart. The available insights match the ones you can open under **Spend**, and can include:
+To display a different insight, select the insight title and choose another insight, such as:
- **Spend over time**
- **Top categories**
- **Top merchants**
- **Top spenders**
-Your selection is saved, so the chart shows the same insight the next time you return to Home, even after you sign out and back in.
+To view all available insights:
-To open the selected insight in full, select the three dots **(⋮)**, then select **View**. This takes you to that insight on the **Spend** page.
+ - On web, select **Insights** in the navigation tabs on the left.
+ - On mobile, select **Spend** in the navigation tabs on the bottom,.
-This section is available to Workspace Admins, Auditors, and approvers on paid workspaces. If an insight doesn't have enough data yet to draw a chart, the section still appears and shows a **Chart unavailable** message instead. If you don't hold one of these roles on any workspace, this section won't appear.
-
-To learn more about each insight, see [Learn how to use Insights in Expensify](/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify).
+To learn more about each insight, see [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights).
---
@@ -199,7 +197,7 @@ The **Time sensitive** section only appears when there is an urgent issue or lim
## Why don’t I see the Spend over time section?
-The Home chart section only appears if you are a Workspace Admin, Auditor, or approver on a paid workspace. If you don't hold one of these roles on any workspace, this section won't be visible. If you do hold one of these roles but an insight doesn't have enough data yet, the section still appears and shows a **Chart unavailable** message rather than disappearing. You can switch which insight the chart shows using the insight title dropdown.
+The Home chart section only appears if you're a member of a paid workspace. If you're on a paid workspace but an insight doesn't have enough data yet, the section still appears and shows a **Chart unavailable** message rather than disappearing. You can switch which insight the chart shows using the insight title dropdown.
## Why don’t I see the Getting started section?
diff --git a/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md b/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md
index 6564b3a6164b..ebc15df77a14 100644
--- a/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md
+++ b/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md
@@ -5,7 +5,7 @@ keywords: [New Expensify, join workspace, set up profile, add expense, enable tw
---
-Welcome to Expensify! If you've received an invite to join your company's workspace, this guide walks you through setting up your account and getting started.
+Welcome to Expensify! Whether you've received an invite to join your company's workspace or need to instruct your admin to send you one, this guide walks you through setting up your account and getting started.
---
@@ -19,6 +19,14 @@ Ask one of your company's Expensify workspace admins to send you an invite using
Now, check your email inbox for the invite to join your company's workspace. If you don't see it, check your **Spam** and **Archive** folders.
+If you're creating your Expensify account, you can also find your company's workspace yourself:
+
+1. When asked **What do you want to do today?**, choose **Join my company workspace**.
+2. If prompted, add your work email and enter the code sent to it.
+3. Select **Join now** next to your company's workspace, or **Ask to join** if it needs admin approval.
+
+If you select **Skip** during these steps, Concierge sends you a task so you can finish later.
+
---
# Step 1: Access Expensify
diff --git a/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md b/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md
index f6098d22ba53..17aaaa8fd656 100644
--- a/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md
+++ b/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md
@@ -1,21 +1,21 @@
---
title: Navigating New Expensify
-description: Learn how to move around New Expensify using the navigation tabs and Search, including Home, Inbox, Spend, Workspaces, and Account on web and mobile.
-keywords: [New Expensify, navigation tabs, navigation, Home tab, Inbox tab, Inbox filters, Inbox tabs, All tab, Unread tab, To-dos, Mark all as read, mark chats as read, clear unread messages, unread badge, Spend tab, Workspaces tab, Account tab, Scan button, Create button, workspace filter, Search, go to, jump to destination]
+description: Learn how to move around New Expensify using the navigation tabs and Search, including Home, Inbox, Spend, Insights, Workspaces, and Account on web and mobile.
+keywords: [New Expensify, navigation tabs, navigation, Home tab, Inbox tab, Inbox filters, Inbox tabs, All tab, Unread tab, To-dos, Mark all as read, mark chats as read, clear unread messages, unread badge, Spend tab, Insights tab, Workspaces tab, Account, where did Account go, Account on mobile, profile picture, Scan button, Create button, workspace filter, Search, go to, jump to destination]
internalScope: Audience is all Expensify members. Covers how to navigate Expensify using the navigation tabs, how to filter and clear the Inbox using the Inbox tabs, the primary action buttons, and Search navigation suggestions. Does not explain the detailed behavior of the Home tab sections.
---
# Navigating New Expensify
-New Expensify uses a consistent navigation layout across web and mobile so you can quickly move between Home, Inbox, Spend, Workspaces, and Account settings.
+New Expensify uses a consistent navigation layout across web and mobile so you can quickly move between Home, Inbox, Spend, Insights, Workspaces, and Account settings.
This guide explains where to find each tab and what it’s used for.
If you'd like a guided walkthrough of the New Expensify experience, explore the interactive product tours:
**For workspace admins:**
-- [interactive web tour for Workspace Admins](https://expensify.storylane.io/share/rbncowjn0nav)
-- [interactive mobile tour for Workspace Admins](https://expensify.storylane.io/share/em54uaq7wbgk)
+- [interactive web tour for workspace admins](https://expensify.storylane.io/share/rbncowjn0nav)
+- [interactive mobile tour for workspace admins](https://expensify.storylane.io/share/em54uaq7wbgk)
**For submitters and approvers:**
- [interactive web tour for submitters and approvers](https://expensify.storylane.io/share/rbncowjn0nav)
@@ -26,18 +26,16 @@ You can find additional training resources in [How to Access Expensify Training]
---
-## How to use navigation tabs
+## How to use the navigation tabs
-The **navigation tabs** are your main way to move through the app.
+The navigation tabs are your main way to move through the app. They appear on the left on web and on the bottom on mobile.
-- **Web:** Navigation tabs appear on the left side of the screen.
-- **Mobile:** Navigation tabs appear on the bottom of the screen.
-
-Tabs include:
+The navigation tabs include:
- **Home**
- **Inbox**
- **Spend**
+- **Insights** (web only)
- **Workspaces**
- **Account**
@@ -92,13 +90,23 @@ From **Spend**, you can:
---
+## What the Insights tab on web is used for
+
+**Insights** shows charts of your spending, such as **Spend over time**, **Top categories**, **Top merchants**, and **Top spenders**.
+
+Use Insights to see spending trends and what changed at a glance. From any chart, select **More (⋮)** to open matching expenses in **Spend**.
+
+Learn more about [Insights](/articles/new-expensify/insights/Learn-About-Insights).
+
+---
+
## What the Workspaces tab is used for
The **Workspaces** tab displays all workspaces you are a member of.
-Workspace Admins can open a workspace to manage workspace-level settings.
+Workspace admins can open a workspace to manage workspace-level settings.
-From **Workspaces**, Workspace Admins can:
+From **Workspaces**, workspace admins can:
- Create a new workspace
- Manage members under **Members**
@@ -111,11 +119,11 @@ Learn how to [create and set up a company workspace](/articles/new-expensify/get
---
-## What the Account tab is used for
+## What Account is used for
-The **Account** tab contains personal and account-level settings.
+**Account** contains personal and account-level settings. Select **Account** to open it.
-From Account, you can manage:
+From **Account**, you can manage:
- **Profile** to update your personal details
- **Subscription** to manage your Expensify subscription
@@ -138,23 +146,21 @@ Use Account for personal settings and billing.
## How to find your assigned support contacts on the Help page
-Paid Workspace Admins may have dedicated support contacts assigned to their account, such as an Account Manager, Partner Manager, or Account Executive. These contacts appear directly on the **Help** page so you can quickly start a conversation with them.
+Workspace admins on a paid plan may have dedicated support contacts assigned to their account, such as an account manager, partner manager, or account executive. These contacts appear directly on the **Help** page so you can quickly start a conversation with them.
-1. Click the navigation tabs (on the left on web, on the bottom on mobile).
-2. Select **Settings**.
-3. Select **Help**.
-4. Below **Concierge**, look for your assigned support contacts.
-5. Click a contact to open a direct message with them.
+1. Select **Account > Help**.
+2. Below **Concierge**, look for your assigned support contacts.
+3. Select a contact to open a direct message with them.
-If a support contact has a scheduling link set up, a **Book a call** button also appears next to them here. This works for your Account Manager, Partner Manager, and Guide. Learn [how to book a call with your support contact](#how-to-book-a-call-with-your-support-contact) below.
+If a support contact has a scheduling link set up, a **Book a call** button also appears next to them here. This works for your account manager, partner manager, and guide. Learn [how to book a call with your support contact](#how-to-book-a-call-with-your-support-contact) below.
---
## How to book a call with your support contact
-If your Account Manager, Partner Manager, or Guide has a scheduling link set up, you can book a call with them directly using the **Book a call** button. Clicking **Book a call** opens that person's calendar in a new tab so you can pick a time. The button appears in these places:
+If your account manager, partner manager, or guide has a scheduling link set up, you can book a call with them directly using the **Book a call** button. Selecting **Book a call** opens that person's calendar in a new tab so you can pick a time. The button appears in these places:
-- On the **Help** page, next to each support contact that has a scheduling link (Account Manager, Partner Manager, or Guide).
+- On the **Help** page, next to each support contact that has a scheduling link (account manager, partner manager, or guide).
- In the header of your direct message with that support contact.
- In the header of your **Concierge** chat.
@@ -182,6 +188,7 @@ If you’re not sure where to go:
- Use **Home** to see what needs attention.
- Use **Inbox** to manage conversations and approvals.
- Use **Spend** to view or filter expense reports.
+- Use **Insights** to see spending trends.
- Use **Workspaces** to manage workspace settings.
- Use **Account** to update personal settings or subscription details.
@@ -191,8 +198,8 @@ If you’re not sure where to go:
You can move to a top-level destination without using the navigation tabs by typing its name in **Search**.
-1. Select **Search** (the magnifying glass icon).
-2. Type the name of the destination you want to open: **Home**, **Inbox**, **Spend**, **Workspaces**, **Domains** or **Account**. You can also type **go** or **go to** before the name, such as **go to inbox**.
+1. Select the **Search** icon.
+2. Type the name of the destination you want to open: **Home**, **Inbox**, **Spend**, **Insights**, **Workspaces**, **Domains**, or **Account**. You can also type **go** or **go to** before the name, such as **go to inbox**.
3. Select the destination to open it.
You can also use keyboard shortcuts on web to jump directly to specific items:
@@ -206,31 +213,34 @@ You can also use keyboard shortcuts on web to jump directly to specific items:
You can start common actions from **Search** instead of using the **➕ Create** button.
-1. Select **Search** (the magnifying glass icon).
+1. Select the **Search** icon.
2. Type the name of the action you want to start, such as **Create expense**, **Create report**, **Track distance**, **Start chat**, or **Book travel**. You can also type **go** or **go to** before the name, such as **go to book travel**.
3. Select the action to start it.
Only the actions available to you appear. **Book travel** appears only when Expensify Travel is enabled on your active workspace, and it opens the same booking tool as the **Book travel** option in the **➕ Create** button.
Learn how to [enable Expensify Travel on a workspace](/articles/travel/company-setup/Enable-Travel-on-a-Workspace).
+
+---
+
## How to jump to a domain page using Search
-If you are a Domain Admin, **Search** also suggests the pages of each domain you administer, so you can open them without going through **Workspaces** > **Domains**.
+If you are a domain admin, **Search** also suggests the pages of each domain you administer, so you can open them without going through **Workspaces > Domains**.
-1. Select **Search** (the magnifying glass icon).
+1. Select the **Search** icon.
2. Type the name of the page you want to open — **Domain members**, **Domain admins**, **Groups**, or **SAML** — or type the domain name, such as **example.com**.
3. Select the suggestion to open that page for that domain.
Each domain suggestion shows the domain name on the right of the row, so you can tell the suggestions apart when you administer more than one domain.
-These suggestions only appear for domains you administer. If you are not a Domain Admin of any domain, only **Go to Domains** appears.
+These suggestions only appear for domains you administer. If you are not a domain admin of any domain, only **Go to Domains** appears.
-Learn more about [what Domain Admins can manage](/articles/new-expensify/domains/Domain-Admins).
+Learn more about [what domain admins can manage](/articles/new-expensify/domains/Domain-Admins).
---
@@ -243,23 +253,25 @@ On web, navigation tabs appear on the left. On mobile, navigation tabs appear on
## Can I use Search to move between sections of Expensify?
-Yes. Select **Search** (the magnifying glass icon), then type the name of a top-level destination: **Home**, **Inbox**, **Spend**, **Workspaces**, or **Account**. Select the **Go to [destination]** result to open it. You can also type **go** or **go to** before the name.
+Yes. Select the **Search** icon, then type the name of a top-level destination: **Home**, **Inbox**, **Spend**, **Insights**, **Workspaces**, or **Account**. Select the **Go to [destination]** result to open it. You can also type **go** or **go to** before the name.
## Can I book travel from Search?
-Yes. Select **Search** (the magnifying glass icon), type **Book travel**, then select the **Book travel** result. This opens the same booking tool as the **Book travel** option in the **➕ Create** button.
+Yes. Select the **Search** icon, type **Book travel**, then select the **Book travel** result. This opens the same booking tool as the **Book travel** option in the **➕ Create** button.
## Why don’t I see Book travel in Search?
-**Book travel** appears only when Expensify Travel is enabled on your active workspace. If a different workspace has Expensify Travel enabled, make that workspace active and search again. If no workspace has it enabled, ask a Workspace Admin to [enable Expensify Travel on a workspace](/articles/travel/company-setup/Enable-Travel-on-a-Workspace).
+**Book travel** appears only when Expensify Travel is enabled on your active workspace. If a different workspace has Expensify Travel enabled, make that workspace active and search again. If no workspace has it enabled, ask a workspace admin to [enable Expensify Travel on a workspace](/articles/travel/company-setup/Enable-Travel-on-a-Workspace).
## Where do I manage workspace settings?
-Open the **Workspaces** tab from the navigation tabs.
+In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces**.
## Where do I update my personal profile or billing information?
-Open the **Account** tab from the navigation tabs.
+Personal information is managed under **Account** in the navigation tabs (on the left on web, on the bottom on mobile).
+
+Then select **Profile** to update your personal information, or **Subscription** to manage billing.
## What does the green Scan button do?
@@ -273,18 +285,14 @@ The **➕ Create** button starts actions such as creating a report, sending mone
Assigned support contacts only appear if:
-- You are a Workspace Admin on a paid plan
+- You are a workspace admin on a paid plan
- A support contact has been assigned to your account
If no contacts are assigned, these options will not appear on the **Help** page.
-## How do I get an Account Manager assigned?
-
-Account Managers are typically assigned to accounts with subscriptions that include 10 or more members. Contact **Concierge** if you believe your account qualifies.
-
## Why don't I see the Book a call button?
-You'll only see the **Book a call** button if you have an assigned support contact (Account Manager, Partner Manager, or Guide) who has a scheduling link configured. If no support contact is assigned or none has a scheduling link set up, the button won't appear. In **Concierge**, only one **Book a call** button shows at a time.
+You'll only see the **Book a call** button if you have an assigned support contact (account manager, partner manager, or guide) who has a scheduling link configured. If no support contact is assigned or none has a scheduling link set up, the button won't appear. In **Concierge**, only one **Book a call** button shows at a time.
## Are there keyboard shortcuts for opening reports or workspaces?
diff --git a/docs/articles/new-expensify/getting-started/Troubleshoot-Adding-Your-Work-Email.md b/docs/articles/new-expensify/getting-started/Troubleshoot-Adding-Your-Work-Email.md
index 1c6782b3a412..4011fb2d2882 100644
--- a/docs/articles/new-expensify/getting-started/Troubleshoot-Adding-Your-Work-Email.md
+++ b/docs/articles/new-expensify/getting-started/Troubleshoot-Adding-Your-Work-Email.md
@@ -83,7 +83,7 @@ No. Adding a work email is optional. On the **What’s your work email?** form,
## Can I try a different work email on the same account?
-Yes, while the work email step is still open. If the message appears under the field, correct the address and click **Add work email** again. Once you click **Skip** or **Got it**, the step doesn’t appear again — add the address you want as a contact method after setup instead.
+Yes, while the work email step is still open. If the message appears under the field, correct the address and click **Add work email** again. Once you click **Skip** or **Got it**, the step only appears again if you choose **Join my company workspace** during setup or open the **Add your work email** task from Concierge. Otherwise, add the address you want as a contact method after setup instead.
## Can I merge the existing account into my new account instead?
diff --git a/docs/articles/new-expensify/insights/Explore-the-Spend-Over-Time-Insight.md b/docs/articles/new-expensify/insights/Explore-the-Spend-Over-Time-Insight.md
new file mode 100644
index 000000000000..d8d7834eecfd
--- /dev/null
+++ b/docs/articles/new-expensify/insights/Explore-the-Spend-Over-Time-Insight.md
@@ -0,0 +1,130 @@
+---
+title: Explore the Spend Over Time Insight
+description: Learn what the Spend over time insight shows, how spending trends are calculated, and how to group expenses by different time periods.
+keywords: [Spend over time, spending trends, spending over time, expense trends, spending chart, monthly spending, daily spending, weekly spending, quarterly spending, yearly spending, Group by, Insights, spending analysis]
+internalScope: Audience is members who can access Insights. Covers what Spend over time shows, who can see it, how expenses are grouped by time period, and how to explore the underlying expenses in Spend. Does not cover detailed shared filtering instructions, exporting expenses, or other insights.
+contentType: topic
+platform: new-expensify
+order: 3
+---
+
+# Explore the Spend Over Time Insight
+
+**Spend over time** shows how total expenses change over a selected date range, helping you identify spending trends, increases, and decreases.
+
+You can use **Spend over time** to:
+
+- Monitor overall spending trends.
+- Identify unusual increases or decreases in spending.
+- Compare spending across days, weeks, months, quarters, or years.
+- Spot recurring or seasonal spending patterns.
+- Support budgeting and forecasting decisions.
+
+---
+
+## What Spend over time shows
+
+**Spend over time** displays spending trends as a line chart, making it easy to see how spending changes throughout the selected date range.
+
+Each point on the chart represents a time period. You can view:
+
+- **Total spend:** The combined amount of expenses during that period.
+- **Percentage of total spend:** The period's share of overall spending.
+- **Number of expenses:** How many expenses contribute to the total.
+
+This makes it easier to identify periods with unusually high or low spending and compare spending patterns over time.
+
+{:width="100%"}
+
+---
+
+## Who can see Spend over time
+
+**Spend over time** is available to all members.
+
+The expenses included depend on your permissions:
+
+- **Workspace admins and auditors** can see expenses across their workspace.
+- **Approvers** can see expenses submitted to them.
+- **Members** can see their own expenses.
+
+You won't see expenses you don't have permission to access.
+
+[Learn who can see insights](/articles/new-expensify/insights/Learn-About-Insights#who-can-see-insights).
+
+---
+
+## How Spend over time is calculated
+
+**Spend over time** groups expenses by time period and calculates the total spending for each period.
+
+Results include expenses within the selected date range that you have permission to view. Amounts are displayed using the selected **Group currency**.
+
+**Spend over time** includes a **Group by** option that lets you change how expenses are grouped along the timeline.
+
+The available options are:
+
+- **Day:** View daily spending changes.
+- **Week:** Compare spending across weeks.
+- **Month:** Track monthly spending trends.
+- **Quarter:** Compare spending across quarters.
+- **Year:** Review annual spending trends.
+
+**Month** is selected by default.
+
+To change the grouping:
+
+1. On the **Spend over time** visualization, select **Group by**.
+2. Choose **Day**, **Week**, **Month**, **Quarter**, or **Year**.
+3. Select **Apply**.
+
+The chart updates to show spending grouped by your selected time period.
+
+The **Date**, **Workspace**, and **Group currency** filters are shared across all insights, allowing you to adjust which data appears.
+
+[Learn how to filter insights](/articles/new-expensify/insights/How-to-Use-Insights#how-to-filter-insights).
+
+---
+
+## How to explore Spend over time
+
+**Spend over time** displays spending across the selected date range, grouped by your chosen time period.
+
+To explore the underlying expenses, select **More (⋮)** on the visualization, then select **View on Spend**. This opens the related results in **Spend**, where you can review individual expenses and analyze spending in more detail.
+
+[Learn how to explore expenses from an insight](/articles/new-expensify/insights/How-to-Use-Insights#how-to-explore-expenses-from-an-insight).
+
+---
+
+## Related articles
+
+- [How to Use Insights](/articles/new-expensify/insights/How-to-Use-Insights)
+- [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights)
+
+# FAQ
+
+## Can you export Spend over time?
+
+Grouped totals and chart summaries can't be exported directly from **Spend over time**, but you can export the underlying expenses from **Spend**.
+
+[Learn how to export expenses](/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses).
+
+## What other insights are available?
+
+Additional insights help you analyze spending from different perspectives:
+
+- **Top spenders** shows which members have the highest total spending.
+- **Top merchants** shows which merchants account for the most spending.
+- **Top categories** shows which expense categories account for the most spending.
+
+[Learn more about the available insights](/articles/new-expensify/insights/Learn-About-Insights#what-insights-are-available).
+
+## Why is an expense missing from Spend over time?
+
+An expense might not appear if:
+
+- You don't have permission to view it.
+- It falls outside the selected date range.
+- It belongs to a workspace excluded by the **Workspace** filter.
+
+To review the underlying expenses and apply additional filters, select **More (⋮)**, then **View on Spend**.
diff --git a/docs/articles/new-expensify/insights/Explore-the-Top-Categories-Insight.md b/docs/articles/new-expensify/insights/Explore-the-Top-Categories-Insight.md
new file mode 100644
index 000000000000..cee8248c3704
--- /dev/null
+++ b/docs/articles/new-expensify/insights/Explore-the-Top-Categories-Insight.md
@@ -0,0 +1,117 @@
+---
+title: Explore the Top Categories Insight
+description: Learn what the Top categories insight shows, who can see it, how categories are ranked, and how to explore expenses by category.
+keywords: [Top categories, top categories chart, category spending, spend by category, expense categories, highest spending categories, where money goes, Insights, donut chart, can't see Top categories]
+internalScope: Audience is members who can access Insights on workspaces with categories enabled. Covers what Top categories shows, who can see it, how categories are ranked, which expenses are included, and how to explore category expenses in Spend. Does not cover detailed filtering instructions, exporting expenses, setting up categories, or other insights.
+contentType: topic
+platform: new-expensify
+order: 6
+---
+
+# Explore the Top Categories Insight
+
+**Top categories** shows which expense categories account for the highest total spending across expenses you have permission to view.
+
+You can use **Top categories** to:
+
+- Identify which expense types account for the most spending.
+- Compare spending across categories such as Travel, Meals, or Office Supplies.
+- Monitor spending patterns by category.
+- Identify areas where you may need to adjust budgets.
+
+---
+
+## What Top categories shows
+
+**Top categories** highlights the five expense categories with the highest total spending, ranked from highest to lowest.
+
+Each category is represented by a slice of a donut chart, making it easy to compare its share of spending. The table below the chart shows:
+
+- **Number of expenses:** How many expenses contribute to the category's total.
+- **Total spend:** The combined amount of expenses in that category.
+- **Percentage of total spend:** The category's share of overall spending.
+
+This makes it easier to understand which expense types account for the largest portion of spending.
+
+{:width="100%"}
+
+---
+
+## Who can see Top categories
+
+**Top categories** only appears if a workspace you can access has categories enabled.
+
+The expenses included depend on your permissions:
+
+- **Workspace admins and auditors** can see expenses across their workspace.
+- **Approvers** can see expenses submitted to them.
+- **Members** can see their own expenses.
+
+If categories aren't enabled, a workspace admin can [learn how to create expense categories](/articles/new-expensify/workspaces/Create-expense-categories).
+
+[Learn who can see insights](/articles/new-expensify/insights/Learn-About-Insights#who-can-see-insights).
+
+---
+
+## How Top categories is calculated
+
+**Top categories** groups expenses by category and ranks categories from highest to lowest total spend.
+
+Results include expenses within the selected date range that you have permission to view. Amounts are converted to the selected **Group currency**.
+
+The **Date**, **Workspace**, and **Group currency** filters are shared across all insights, allowing you to adjust which data appears.
+
+[Learn how to filter insights](/articles/new-expensify/insights/How-to-Use-Insights#how-to-filter-insights).
+
+---
+
+## How to explore Top categories
+
+**Top categories** displays the five categories with the highest total spend for the selected date range.
+
+To see spending for every category, select **More (⋮)** on the visualization, then select **View on Spend**. This opens the related results in **Spend**, where you can explore spending beyond the top five categories.
+
+You can also select a slice of the donut chart to open **Spend** with the expenses in that category, listed from newest to oldest.
+
+[Learn how to explore expenses from an insight](/articles/new-expensify/insights/How-to-Use-Insights#how-to-explore-expenses-from-an-insight).
+
+---
+
+## Related articles
+
+- [How to Use Insights](/articles/new-expensify/insights/How-to-Use-Insights)
+- [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights)
+
+# FAQ
+
+## Can you export Top categories?
+
+Grouped totals and summary data can't be exported directly from **Top categories**, but you can export the underlying expenses from **Spend**.
+
+[Learn how to export expenses](/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses).
+
+## What other insights are available?
+
+Additional insights help you analyze spending from different perspectives:
+
+- **Spend over time** shows how total expenses change over a date range.
+- **Top spenders** shows which members have the highest total spending.
+- **Top merchants** shows which merchants account for the most spending.
+
+[Learn more about the available insights](/articles/new-expensify/insights/Learn-About-Insights#what-insights-are-available).
+
+## Why can't I see Top categories?
+
+**Top categories** only appears when at least one workspace you can access has categories enabled.
+
+If the insight isn't available, check whether categories are enabled for your workspace. A workspace admin can [create expense categories](/articles/new-expensify/workspaces/Create-expense-categories).
+
+## Why is an expense missing from Top categories?
+
+An expense might not appear if:
+
+- You don't have permission to view it.
+- It falls outside the selected date range.
+- Its category isn't among the five highest-spending categories shown in the visualization.
+
+To explore spending beyond the top five categories, select **More (⋮)**, then **View on Spend**.
diff --git a/docs/articles/new-expensify/insights/Explore-the-Top-Merchants-Insight.md b/docs/articles/new-expensify/insights/Explore-the-Top-Merchants-Insight.md
new file mode 100644
index 000000000000..9894c86aa498
--- /dev/null
+++ b/docs/articles/new-expensify/insights/Explore-the-Top-Merchants-Insight.md
@@ -0,0 +1,120 @@
+---
+title: Explore the Top Merchants Insight
+description: Learn what the Top merchants insight shows, who can see it, how merchants are ranked, and how to explore merchant expenses.
+keywords: [Top merchants, top merchants chart, vendor spending, merchant spending, highest spending merchants, spend by merchant, Insights, merchant names look different, top vendors]
+internalScope: Audience is all members who can access Insights. Covers what Top merchants shows, who can see it, how merchants are ranked, which expenses are included, and how to explore merchant expenses in Spend. Does not cover detailed filtering instructions, exporting expenses, or other insights.
+contentType: topic
+platform: new-expensify
+order: 5
+---
+
+# Explore the Top Merchants Insight
+
+**Top merchants** shows which merchants account for the highest total spending across expenses you have permission to view.
+
+You can use **Top merchants** to:
+
+- Identify merchants with the highest spending.
+- Monitor spending patterns by merchant.
+- Find recurring vendor costs.
+- Spot opportunities to negotiate better rates.
+
+---
+
+## What Top merchants shows
+
+**Top merchants** highlights the five merchants with the highest total spending, ranked from highest to lowest.
+
+Each merchant is represented by a bar, making it easy to compare spending amounts. The table below the chart shows:
+
+- **Number of expenses:** How many expenses contribute to the merchant's total.
+- **Total spend:** The combined amount of expenses for that merchant.
+- **Percentage of total spend:** The merchant's share of overall spending.
+
+This makes it easier to compare spending across merchants and identify where the most money is being spent.
+
+{:width="100%"}
+
+---
+
+## Who can see Top merchants
+
+**Top merchants** is available to all members.
+
+The expenses included depend on your permissions:
+
+- **Workspace admins and auditors** can see expenses across their workspace.
+- **Approvers** can see expenses submitted to them.
+- **Members** can see their own expenses.
+
+You won't see expenses you don't have permission to access.
+
+[Learn who can see insights](/articles/new-expensify/insights/Learn-About-Insights#who-can-see-insights).
+
+---
+
+## How Top merchants is calculated
+
+**Top merchants** groups expenses by merchant and ranks merchants from highest to lowest total spend.
+
+Results include expenses within the selected date range that you have permission to view. Amounts are converted to the selected **Group currency**.
+
+Merchant names come from receipt data and card transactions, so the same vendor may appear under different names. Each merchant name is counted separately.
+
+The **Date**, **Workspace**, and **Group currency** filters are shared across all insights, allowing you to adjust which data appears.
+
+[Learn how to filter insights](/articles/new-expensify/insights/How-to-Use-Insights#how-to-filter-insights).
+
+---
+
+## How to explore Top merchants
+
+**Top merchants** displays the five merchants with the highest total spend for the selected date range.
+
+To see spending for every merchant, select **More (⋮)** on the visualization, then select **View on Spend**. This opens the related results in **Spend**, where you can explore spending beyond the top five merchants.
+
+You can also select a merchant's bar to open **Spend** with the expenses for that merchant, listed from newest to oldest.
+
+[Learn how to explore expenses from an insight](/articles/new-expensify/insights/How-to-Use-Insights#how-to-explore-expenses-from-an-insight).
+
+---
+
+## Related articles
+
+- [How to Use Insights](/articles/new-expensify/insights/How-to-Use-Insights)
+- [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights)
+
+# FAQ
+
+## Can you export Top merchants?
+
+Grouped totals and summary data can't be exported directly from **Top merchants**, but you can export the underlying expenses from **Spend**.
+
+[Learn how to export expenses](/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses).
+
+## What other insights are available?
+
+Additional insights help you analyze spending from different perspectives:
+
+- **Spend over time** shows how total expenses change over a date range.
+- **Top spenders** shows which members have the highest total spending.
+- **Top categories** shows which expense categories account for the most spending.
+
+[Learn more about the available insights](/articles/new-expensify/insights/Learn-About-Insights#what-insights-are-available).
+
+## Why does the same merchant appear more than once in Top merchants?
+
+Merchant names come from receipts and card transactions, so the same vendor may appear under different names, such as "Amazon.com" and "AMZN Marketplace."
+
+Each name is counted separately. Select a merchant to review its expenses in **Spend** and confirm whether the expenses belong to the same vendor.
+
+## Why is an expense missing from Top merchants?
+
+An expense might not appear if:
+
+- You don't have permission to view it.
+- It falls outside the selected date range.
+- Its merchant isn't among the five highest-spending merchants shown in the visualization.
+- It's recorded under a different merchant name.
+
+To explore spending beyond the top five merchants, select **More (⋮)**, then **View on Spend**.
diff --git a/docs/articles/new-expensify/insights/Explore-the-Top-Spenders-Insight.md b/docs/articles/new-expensify/insights/Explore-the-Top-Spenders-Insight.md
new file mode 100644
index 000000000000..fc9278369586
--- /dev/null
+++ b/docs/articles/new-expensify/insights/Explore-the-Top-Spenders-Insight.md
@@ -0,0 +1,107 @@
+---
+title: Explore the Top Spenders Insight
+description: Learn what the Top spenders insight shows, who can see it, how members are ranked, and how to open a member's expenses.
+keywords: [Top spenders, top spenders chart, visualization, highest spenders, employee spending, who spent the most, spend by member, Insights, workspace admin, auditor, approver, can't see Top spenders]
+internalScope: Audience is workspace admins, auditors, and approvers. Covers what Top spenders shows, who can see it, how members are ranked, which expenses are included, and how to view all members in Spend. Does not cover detailed filtering instructions, exporting expenses, or other insights.
+contentType: topic
+platform: new-expensify
+order: 4
+---
+
+# Explore the Top Spenders Insight
+
+**Top spenders** shows which members submitted the highest total expenses across workspaces where you're a workspace admin, approver, or auditor.
+
+You can use **Top spenders** to:
+
+- Identify unusually high spending.
+- Monitor spending patterns by member.
+- Spot spending that may need a closer review for policy compliance.
+- Support expense approval and reimbursement reviews.
+
+---
+
+## What Top spenders shows
+
+**Top spenders** highlights the five members with the highest total spending, ranked from highest to lowest.
+
+Each member is represented by a bar, making it easy to compare spending amounts. For each member, you can see:
+
+- **Total spend:** The combined amount of their expenses.
+- **Number of expenses:** How many expenses contribute to their total.
+
+This makes it easier to compare spending across members and identify expenses that may need further review.
+
+{:width="100%"}
+
+---
+
+## Who can see Top spenders
+
+**Top spenders** is available to workspace admins, auditors, and approvers on workspaces with at least two members.
+
+The expenses included depend on your permissions:
+
+- **Workspace admins and auditors** can see expenses across their workspace.
+- **Approvers** can see expenses submitted to them.
+
+Members without these permissions won't see **Top spenders**.
+
+[Learn who can see insights](/articles/new-expensify/insights/Learn-About-Insights#who-can-see-insights).
+
+---
+
+## How Top spenders is calculated
+
+**Top spenders** groups expenses by the member who submitted them and ranks members from highest to lowest total spend.
+
+Results include expenses within the selected date range from workspaces where you have the required permissions. Expenses submitted in individual chats instead of to a workspace aren't included.
+
+The **Date**, **Workspace**, and **Group currency** filters are shared across all insights, allowing you to adjust which data appears.
+
+[Learn how to filter insights](/articles/new-expensify/insights/How-to-Use-Insights#how-to-filter-insights).
+
+---
+
+## How to explore Top spenders
+
+**Top spenders** displays the five members with the highest total spend for the selected date range.
+
+To see spend for every member, select **More (⋮)** on the visualization, then select **View on Spend**. This opens the related results in **Spend**, where you can explore spend beyond the top five members.
+
+[Learn how to explore expenses from an insight](/articles/new-expensify/insights/How-to-Use-Insights#how-to-explore-expenses-from-an-insight).
+
+---
+
+## Related articles
+
+- [How to Use Insights](/articles/new-expensify/insights/How-to-Use-Insights)
+- [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights)
+
+# FAQ
+
+## Can you export Top spenders?
+
+**Top spenders** totals can't be exported directly. However, if you expand each group to reveal the individual expenses, you can then select those expenses and use Export to CSV to download the raw data.
+
+[Learn how to export expenses](/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses).
+
+## What other insights are available?
+
+Additional insights help you analyze spending from different perspectives:
+
+- **Spend over time** shows how total expenses change over a date range.
+- **Top merchants** shows which vendors received the most payments.
+- **Top categories** shows the highest-spending expense types (e.g., Travel, Meals).
+
+[Learn more about the available insights](/articles/new-expensify/insights/Learn-About-Insights#what-insights-are-available)
+
+## Why is an expense missing from Top spenders?
+
+An expense might not appear if:
+- It belongs to a workspace you don't have permission to view.
+- It falls outside the selected date range.
+- It was submitted in an individual chat rather than to a workspace.
+- The member isn't among the five highest spenders shown in the visualization.
+
+To see spend for members outside the top five, select **More (⋮)**, then **View on Spend**.
diff --git a/docs/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify.md b/docs/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify.md
deleted file mode 100644
index a351ce79b304..000000000000
--- a/docs/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify.md
+++ /dev/null
@@ -1,225 +0,0 @@
----
-title: How to Use Insights in Expensify
-description: Learn how to use Insights to analyze spending trends and make data-driven financial decisions in New Expensify.
-keywords: [New Expensify, Insights, spending analysis, expense reports, analytics, Top Spenders, Top Categories, Top Merchants, Spend over time, financial insights, budget analysis, expense trends]
-internalScope: Audience is all workspace members. Covers how Insights reports work, how to access them, available report types, grouping logic, view modes, exporting data, and customization. Does not cover troubleshooting individual reports.
----
-
-# How to Use Insights in Expensify
-
-Insights are pre-built reports that help you understand spending patterns and trends without exporting data or creating complex custom searches. Each Insight is a suggested search that groups and analyzes your expense data automatically.
-
----
-
-## Who can use Insights
-
-Anyone can use Insights. What you can see depends on your role in the workspace.
-
-- Regular members can view Insights that include their own submitted expenses.
-- Approvers can view Insights that include their own expenses plus expenses submitted to them for approval.
-- Workspace Admins can view Insights that include all workspace expenses.
-- Auditors can view Insights that include all workspace expenses.
-
-Insights always respect workspace permissions. You will only see data you’re allowed to access.
-
-## Where to find Insights
-
-You can access Insights on both web and mobile.
-
-1. Click **Spend** in the navigation tabs (on the left on web, or on the bottom on mobile).
-2. Scroll to **Insights**.
-3. Select an Insight to open the report.
-
----
-
-## How to use Spend over time
-
-Spend over time shows how total expenses change across a selected date range.
-
-You can use Spend over time to:
-
- - Monitor overall spending trends
- - Identify increases or decreases in expenses
- - Compare spend across months or custom date ranges
- - Track seasonal or recurring spending patterns
- - Support budgeting and forecasting decisions
-
-[Learn more about the Spend over time report](https://help.expensify.com/articles/new-expensify/insights/View-the-Spend-over-time-report).
-
-## How to use Top Spenders
-
-Top Spenders shows which members submitted the highest total expenses in the previous calendar month, across the workspaces where you're a Workspace Admin, Approver, or Auditor.
-
-You can use Top Spenders to:
-
-- Identify unusually high spenders
-- Monitor employee-level spending patterns
-- Evaluate policy compliance
-- Support reimbursement and approval reviews
-
-[Learn more about the Top Spenders report](https://help.expensify.com/articles/new-expensify/insights/View-the-Top-Spenders-report).
-
----
-
-## How to use Top Categories
-
-Top Categories shows which expense categories had the highest total spend in the previous calendar month.
-
-You can use Top Categories to:
-
-- Understand where most money is being spent by expense type
-- Compare spending across categories like Travel, Meals, and Office Supplies
-- Make budget allocation decisions
-
-[Learn more about the Top Categories report](https://help.expensify.com/articles/new-expensify/insights/View-the-Top-Categories-report)
-
----
-
-## How to use Top Merchants
-
-Top Merchants shows which merchants or vendors received the highest total payments in the previous calendar month.
-
-You can use Top Merchants to:
-
-- Track vendor spending trends
-- Identify opportunities for volume discounts
-- Monitor subscription and recurring expenses
-
-[Learn more about the Top Merchants report](https://help.expensify.com/articles/new-expensify/insights/View-the-Top-Merchants-report)
-
----
-
-## How to use Violations by submitter
-
-Violations by submitter shows which members submitted the most expenses that broke your workspace rules in the previous calendar month. It’s available to Workspace Admins and Auditors on Control workspaces that have Rules enabled.
-
-You can use Violations by submitter to:
-
-- See who is repeatedly submitting expenses with policy violations
-- Spot compliance patterns without reviewing reports one by one
-- Prioritize coaching or policy guidance for specific members
-
-[Learn more about the Violations by submitter report](https://help.expensify.com/articles/new-expensify/insights/View-the-Violations-by-submitter-report)
-
----
-
-## How Insights work
-
-Each Insight is powered by Expensify's search query engine using grouping filters and operators to:
-
-- Automatically group expenses by relevant dimensions (employee, category, merchant, time period)
-- Calculate totals and counts
-- Sort results to show the highest values first
-- Update dynamically as new expenses are added
-
----
-
-## How to switch between views in Insights
-
-Insights support four viewing modes:
-
-**Bar View**
-Displays grouped results as a bar chart. This is the default view for **Top categories** and makes comparisons easy at a glance.
-
-**Table View**
-Displays grouped data in rows and columns for more detailed analysis. This is the default view for **Top spenders** and simplifies side-by-side comparisons.
-
-**Line View**
-Displays grouped data in a line chart for analyzing trends. This is the default view for **Spend over time** and helps you monitor changes and patterns.
-
-**Pie view**
-Displays grouped data as a pie chart to show proportional distribution. This is the default for **Top merchants** and helps you understand distribution instantly.
-
----
-
-To switch views:
-
-1. Open any Insight.
-2. Click **View** in the top navigation.
-3. Select **Table**, **Bar**, **Line** or **Pie**.
-
----
-
-## How to customize Insights
-
-While you can't modify the suggested Insight searches directly, you can:
-
-- Apply additional date filters
-- Filter by workspace or member
-- Expand groups to view individual expenses
-- Export data to CSV
-- Save a modified search
-
-## How to create a custom spending report using Insights
-
-If you want to build your own grouped report:
-
-1. Open an Insight as your starting point.
-2. Modify the filters to match your needs.
-3. Adjust the date range if needed.
-4. Click **Save** to store your custom view.
-
-This allows you to automate recurring analysis without rebuilding the search each time.
-
-[Learn how to use search operators and grouping](/articles/new-expensify/reports-and-expenses/Using-search-operators)
-
----
-
-## How to export data from an Insight
-
-Insights cannot export grouped totals directly. However, you can export the underlying expenses.
-
-To export Insight data:
-
-1. Open the Insight.
-2. Expand a group to show individual expenses.
-3. Select the expenses you want to export.
-4. Click **Export to CSV**.
-
-This is helpful for external reporting or sharing detailed data with finance teams.
-
----
-
-## Grouping options used in Insights
-
-Insights use grouping operators to summarize expenses. These include:
-
-- **group-by:from** - Group by employee (who submitted)
-- **group-by:category** - Group by expense category
-- **group-by:merchant** - Group by merchant or vendor
-- **group-by:tag** - Group by expense tags
-- **group-by:month** - Group by calendar month
-- **group-by:week** - Group by calendar week
-- **group-by:quarter** - Group by fiscal quarter
-- **group-by:year** - Group by calendar year
-
-You can use these operators in custom searches to create your own insights and analyze spending from different perspectives.
-
-[View all available search operators](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-search-operators)
-
----
-
-# FAQ
-
-## Can I export grouped totals from Insights in Expensify?
-
-No. Grouped totals cannot be exported directly. You must expand each group and export the individual expenses as a CSV file.
-
----
-
-## How often do Insights update in Expensify?
-
-Insights update in realtime as expenses are created, edited, approved, or reimbursed.
-
----
-
-## Can I change the time period in Insights?
-
-Yes. Use date filters to adjust the time period.
-
-Common filters include:
-
-- `date:this-month`
-- `date:last-month`
-- `date:year-to-date`
-- Custom date ranges
diff --git a/docs/articles/new-expensify/insights/How-to-Use-Insights.md b/docs/articles/new-expensify/insights/How-to-Use-Insights.md
new file mode 100644
index 000000000000..7bea3beadfbe
--- /dev/null
+++ b/docs/articles/new-expensify/insights/How-to-Use-Insights.md
@@ -0,0 +1,88 @@
+---
+title: How to Use Insights
+description: Learn how to use Insights in Expensify to filter spending data, explore expenses, change visualizations, and create custom spending reports.
+keywords: [Insights, how to use Insights, filter Insights, customize Insights, spending analysis, change chart view, view expenses, export Insight data, save custom search, spending reports]
+internalScope: Audience is members who can access Insights. Covers how to access and interact with Insights, adjust shared filters, explore underlying expenses, and use related reporting capabilities. Does not cover individual insight calculations, detailed search operators, or step-by-step export and saved-search workflows.
+contentType: topic
+platform: new-expensify
+order: 2
+---
+
+# How to Use Insights
+
+Insights help you analyze spending to spot changes and understand patterns. You can adjust which expenses are included, explore spending trends from different perspectives, and open the underlying expenses in **Spend** for further review.
+
+For an introduction to the available insights and what they show, see [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights).
+
+## Where to find Insights
+
+
+Insights displays spending visualizations based on your permissions and workspace configuration. Where you access insights depends on your platform.
+
+On web:
+In the navigation tabs on the left, select **Insights**.
+
+On mobile:
+In the navigation tabs on the bottom, select **Spend**. Then, in the navigation menu, select the individual insight you want to view.
+
+## How to filter insights
+
+You can adjust the data shown across **Insights** using three shared filters:
+
+- **Date:** Choose the period you want to analyze.
+- **Workspace:** Choose which workspace expenses to include.
+- **Group currency:** Choose the currency used to group spending amounts.
+
+These filters apply across all insights, allowing you to compare spending trends, merchants, categories, and members using the same date range, workspaces, and currency.
+
+{:width="100%"}
+
+## How to explore expenses from an insight
+
+Insights summarize spending data, while **Spend** lets you review individual expenses and analyze spending in more detail.
+
+To explore the expenses behind an insight, select **More (⋮)** on the visualization, then select **View on Spend**. This opens the related results in **Spend**, where you can review individual expenses, apply additional filters, and use grouping options to analyze spending from different perspectives.
+
+[Learn how to use search operators to analyze spend](/articles/new-expensify/reports-and-expenses/Using-search-operators).
+
+## How to change how insight data is displayed
+
+Each insight uses a predefined visualization to highlight spending patterns. To explore the same expense data in a different format, open it in **Spend**, where you can switch between bar, line, pie, and table views.
+
+The available views are:
+
+- **Bar chart:** Helpful for comparing spending amounts across members, merchants, or categories.
+- **Line chart:** Helpful for identifying changes and trends over time.
+- **Pie chart:** Helpful for understanding how spending is distributed across groups.
+- **Table:** Helpful for reviewing grouped spending data across rows and columns.
+
+To display the expense data in a different view:
+
+1. On the insight, select **More (⋮) > View on Spend**.
+2. Select **Display > View**.
+3. Choose the view you want, then select **Apply**.
+
+{:width="100%"}
+
+## How to save a customized search from an insight
+
+If you regularly review the same spending data, you can save a customized search in **Spend**. Saved searches let you return to a particular combination of filters and grouping options without rebuilding the search each time.
+
+You can start with an insight, open its results in **Spend**, and customize the search to match your reporting needs.
+
+[Learn how to create a saved search](/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify).
+
+## How to export insight data
+
+Insights summarize expense data, but grouped totals and chart summaries cannot be exported directly.
+
+To export the underlying expenses, open the related results in **Spend** and select the individual expenses you want to export. This lets you download expense-level data for additional analysis or reporting outside Expensify.
+
+[Learn how to export expenses](/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses).
+
+## Related articles
+
+- [Explore the Spend Over Time Insight](/articles/new-expensify/insights/explore-the-spend-over-time-insight)
+- [Explore the Top Spenders Insight](/articles/new-expensify/insights/explore-the-top-spenders-insight)
+- [Explore the Top Merchants Insight](/articles/new-expensify/insights/explore-the-top-merchants-insight)
+- [Explore the Top Categories Insight](/articles/new-expensify/insights/explore-the-top-categories-insight)
diff --git a/docs/articles/new-expensify/insights/Learn-About-Insights.md b/docs/articles/new-expensify/insights/Learn-About-Insights.md
new file mode 100644
index 000000000000..4b142c4552ac
--- /dev/null
+++ b/docs/articles/new-expensify/insights/Learn-About-Insights.md
@@ -0,0 +1,60 @@
+---
+title: Learn About Insights
+description: Learn what Insights are in Expensify, which insights are available, who can see them, and how they help you understand spending patterns.
+keywords: [Insights, spending insights, spending analytics, spending trends, Spend over time, Top spenders, Top merchants, Top categories, who can see Insights, Insights vs Spend]
+internalScope: Audience is all members. Covers what Insights are, the available spending insights, general visibility rules, and how Insights relates to Spend. Does not cover detailed filtering instructions, individual insight calculations, or saved-search and export workflows.
+contentType: topic
+platform: new-expensify
+order: 1
+---
+
+# Learn About Insights
+
+Insights help you understand spending patterns, track changes over time, and identify where money is being spent. They summarize expense data in visualizations so you can quickly see spending trends, compare expenses across members, merchants, and categories, and identify areas that may need closer review.
+
+## What insights are available
+
+**Insights** includes visualizations that help you understand spending from different perspectives:
+
+- **Spend over time** shows how total spending changes over a selected period, helping you identify trends and unusual increases or decreases.
+- **Top spenders** shows which members have the highest total spending, helping you identify high spenders and monitor spending patterns by member.
+- **Top merchants** shows which merchants account for the most spending, helping you understand where money is being spent and identify recurring vendor expenses.
+- **Top categories** shows which expense categories account for the most spending, helping you understand spending by expense type and identify opportunities to adjust budgets.
+
+All insights share the same **Date**, **Workspace**, and **Group currency** filters, so you can compare spending across the same period, workspaces, and currency.
+
+{:width="100%"}
+
+## Who can see Insights
+
+Insights are available to all members, but the data you see depends on your permissions in Expensify.
+
+- **Workspace admins and auditors** can see expenses across their workspaces.
+- **Approvers** can see expenses submitted to them for approval.
+- **Members** can see their own expenses.
+
+Some insights have additional requirements:
+
+- **Top spenders** is available to workspace admins, auditors, and approvers on workspaces with at least two members.
+- **Top categories** appears only when a workspace you can access has categories enabled.
+
+Insights that don't meet these requirements won't appear.
+
+## How Insights and Spend work together
+
+**Insights** and **Spend** provide different ways to explore the same expense data.
+
+- **Insights** summarizes spending in visualizations, helping you identify trends and understand which members, merchants, or categories account for the most spend.
+- **Spend** provides the underlying expense and report details, where you can search, review, and take action on specific expenses.
+
+You can open an insight's underlying results in **Spend** by selecting **More (⋮) > View on Spend** to investigate spending patterns, apply additional filters, or explore expenses beyond those displayed in a visualization.
+
+[Learn how to use Insights](/articles/new-expensify/insights/How-to-Use-Insights).
+
+## Related articles
+
+- [How to Use Insights](/articles/new-expensify/insights/How-to-Use-Insights)
+- [Explore the Spend Over Time Insight](/articles/new-expensify/insights/explore-the-spend-over-time-insight)
+- [Explore the Top Spenders Insight](/articles/new-expensify/insights/explore-the-top-spenders-insight)
+- [Explore the Top Merchants Insight](/articles/new-expensify/insights/explore-the-top-merchants-insight)
+- [Explore the Top Categories Insight](/articles/new-expensify/insights/explore-the-top-categories-insight)
diff --git a/docs/articles/new-expensify/insights/View-the-Top-Categories-report.md b/docs/articles/new-expensify/insights/View-the-Top-Categories-report.md
deleted file mode 100644
index c1f20a055b7b..000000000000
--- a/docs/articles/new-expensify/insights/View-the-Top-Categories-report.md
+++ /dev/null
@@ -1,130 +0,0 @@
----
-title: View the Top Categories report
-description: Learn how to use the Top Categories report to understand category-level spending trends.
-keywords: [New Expensify, Top Spenders, Top Categories, Top Merchants, Spend over time, expense categories, category spending, monthly spending, Workspace Admin, Approver, Auditor, category insights, expense analytics, report by category]
-internalScope: Audience is Workspace Admins, Approvers, and Auditors. Covers using the Top Categories suggested search to view expense totals by category. Does not cover employee-level analysis or merchant-level grouping.
----
-
-# View the Top Categories report
-
-The **Top Categories** report shows which expense categories had the highest total spend last month. It’s an easy way to:
-
-- Understand where the most money is being spent
-- Spot trends without exporting data
-- Make data-driven budget decisions with less effort
-
-This report is a pre-built suggested search that uses filters to group your expenses by category.
-
-{:width="100%"}
-
----
-
-## Who can use the Top Categories report
-
-The Top Categories report is available to all workspace members on web and mobile.
- - **Workspace members** can view top categories for expenses on reports they’ve submitted or approved.
- - **Workspace Admins** and **Auditors** can view top categories for all expenses on the workspace.
-
----
-
-## Where to find the Top Categories report
-
-**Web:**
-Click **Spend** in the left navigation, then click **Top categories** under the **Insights** section
-
-**Mobile:**
-Tap **Spend** from the navigation tabs on the bottom, then tap the hamburger menu in the top-right corner. Under **Insights**, tap **Top categories**.
-
----
-
-## What the Top Categories report shows
-
-The Top Categories report is powered by Expensify’s search query engine using grouping and filters. The report shows:
-
-- All **expense categories** sorted by total spend in the last month
-- The **total amount spent** in each category
-- The **number of expenses** in each category
-
----
-
-## How to interpret the Top Categories report
-
-Each category entry represents a single expense category, ordered from highest to lowest total spend. The report shows:
-
-- **Number of expenses** in the category
-- **Total amount spent** in that category
-
-Select a category to review all expenses included in that grouping.
-
----
-
-## How to customize the Top Categories report
-
-You can adjust filters like status, expense type or employee to explore spending trends.
-
-The Top Categories report is a suggested search template. You cannot save changes directly to this report, but you can create a custom version.
-
-To create and save a custom report:
-
-1. Go to the **Spend** or **Expenses** tab.
-2. Use filters to adjust the category grouping or timeframe.
-3. Apply filters and click **Save** to save your custom view.
-
-[Learn how to create custom reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify#how-to-use-spend-search-query-commands)
-
----
-
-## How to switch views in the Top Categories report
-
-By default, the Top Categories report displays a **Bar** chart. You can also switch to **Line**, **Table** or **Pie** view depending on how you want to analyze your data.
-
-- **Line** shows spending trends over time.
-- **Table** displays merchants in rows sorted by total spend.
-- **Pie** shows how data is distributed.
-
-To change the view:
-
-1. Open the Top Categories report.
-2. Click **View** in the top navigation.
-3. Select **Line**, **Table** or **Pie**.
-
-The report will update instantly to reflect your selected view.
-
----
-
-## Ways to use the Top Categories report
-
-Workspace Admins and finance teams use the Top Categories report to:
-
-- Monitor which expense categories are driving most of the spend
-- Quickly compare expense volume by category
-- Make budget and policy decisions without needing spreadsheets
-
----
-
-# FAQ
-
-## Can I export the Top Categories report?
-
-The Top Categories report can’t be exported with its grouped totals or summary data directly. However, if you expand each group to reveal the individual expenses, you can then select those expenses and use Export to CSV to download the raw data.
-
-To export expenses:
-
-1. Go to the **Spend** or **Expenses** tab.
-2. Use filters to group by category and apply the same timeframe.
-3. Expand the category group, select the expenses, and click **Export to CSV**.
-
-## What’s the difference between the Top Categories report and other Insights reports?
-
-Each report provides a different view of your spending:
-
-- **Spend over time** shows how total expenses change over a date range.
-- **Top Merchants** shows which vendors received the most payments.
-- **Top Categories** shows the highest-spending expense types (e.g., Travel, Meals).
-- **Top Spenders** shows which members submitted the most expenses.
-
-## How is the Top Categories report calculated?
-
-The report uses all expenses from the previous calendar month and groups them by category. It shows the **top 5 categories** based on total spend.
-
----
diff --git a/docs/articles/new-expensify/insights/View-the-Top-Merchants-report.md b/docs/articles/new-expensify/insights/View-the-Top-Merchants-report.md
deleted file mode 100644
index 19dffad6df3e..000000000000
--- a/docs/articles/new-expensify/insights/View-the-Top-Merchants-report.md
+++ /dev/null
@@ -1,136 +0,0 @@
----
-title: View the Top Merchants report
-description: Learn how to use the Top Merchants report to understand merchant-level spending trends.
-keywords: [New Expensify, Top Merchants, merchant spending, vendor analysis, monthly spending, Workspace Admin, Approver, Auditor, merchant insights, expense analytics, report by merchant]
-internalScope: Audience is Workspace Admins, Approvers, and Auditors. Covers using the Top Merchants suggested search to view expense totals by merchant. Does not cover employee-level analysis or category-level grouping.
----
-
-# View the Top Merchants report
-
-The **Top Merchants** report shows which merchants or vendors received the highest total spend last month. It's an easy way to:
-
-- Understand which vendors are receiving the most payments
-- Identify recurring merchant relationships
-- Spot trends without exporting data
-- Make data-driven purchasing decisions with less effort
-
-This report is a pre-built suggested search that uses filters to group your expenses by merchant.
-
----
-
-## Who can use the Top Merchants report
-
-The Top Merchants report is available to all workspace members on web and mobile.
- - **Workspace members** can view top merchants for expenses on reports they've submitted or approved.
- - **Workspace Admins** and **Auditors** can view top merchants for all expenses on the workspace.
-
----
-
-## Where to find the Top Merchants report
-
-**Web:**
-Click **Spend** in the left navigation, then click **Top merchants** under the **Insights** section
-
-**Mobile:**
-Tap **Spend** from the navigation tabs on the bottom, then tap the hamburger menu in the top-right corner. Under **Insights**, tap **Top merchants**.
-
----
-
-## What the Top Merchants report shows
-
-The Top Merchants report is powered by Expensify's search query engine using grouping and filters. The report shows:
-
-- All **merchants** sorted by total spend in the last month
-- The **total amount spent** at each merchant
-- The **number of expenses** for each merchant
-
----
-
-## How to interpret the Top Merchants report
-
-Each merchant entry represents a single vendor or merchant, ordered from highest to lowest total spend. The report shows:
-
-- **Number of expenses** for that merchant
-- **Total amount spent** at that merchant
-
-Select a merchant to review all expenses included in that grouping.
-
----
-
-## How to customize the Top Merchants report
-
-You can adjust filters like status, expense type, or employee to explore spending trends.
-
-The Top Merchants report is a suggested search template. You cannot save changes directly to this report, but you can create a custom version.
-
-To create and save a custom report:
-
-1. Go to the **Spend** or **Expenses** tab.
-2. Use filters to adjust the merchant grouping or timeframe.
-3. Apply filters and click **Save** to save your custom view.
-
-[Learn how to create custom reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify#how-to-use-spend-search-query-commands)
-
----
-
-## How to switch views in the Top Merchants report
-
-By default, the Top Merchants report displays a **Pie** chart. You can also switch to **Table**, **Bar** or **Line** view depending on how you want to analyze your data.
-
-- **Table** displays merchants in rows sorted by total spend.
-- **Bar** shows merchant totals visually for quick comparison.
-- **Line** shows spending trends over time.
-
-To change the view:
-
-1. Open the Top Merchants report.
-2. Click **View** in the top navigation.
-3. Select **Table**, **Bar**, or **Line**.
-
-The report will update instantly to reflect your selected view.
-
----
-
-## Ways to use the Top Merchants report
-
-Workspace Admins and finance teams use the Top Merchants report to:
-
-- Monitor vendor spending patterns
-- Identify opportunities for volume discounts or contract negotiations
-- Quickly compare expense volume by vendor
-- Track subscription and recurring service costs
-- Make purchasing decisions without needing spreadsheets
-
----
-
-# FAQ
-
-## Can I export the Top Merchants report?
-
-The Top Merchants report can't be exported with its grouped totals or summary data directly. However, if you expand each group to reveal the individual expenses, you can then select those expenses and use Export to CSV to download the raw data.
-
-To export expenses:
-
-1. Go to the **Spend** or **Expenses** tab.
-2. Use filters to group by merchant and apply the same timeframe.
-3. Expand the merchant group, select the expenses, and click **Export to CSV**.
-
-## What's the difference between the Top Merchants report and other insights reports?
-
-Each report provides a different view of your spending:
-
-- **Spend over time** shows how total expenses change over a date range.
-- **Top Categories** shows the highest-spending expense types (e.g., Travel, Meals).
-- **Top Spenders** shows which members submitted the most expenses.
-
-All reports help identify trends from different perspectives to support better financial decisions.
-
-## How is the Top Merchants report calculated?
-
-The report uses all expenses from the previous calendar month and groups them by merchant. It shows the **top 5 merchants** based on total spend.
-
-## Why do some merchant names look different?
-
-Merchant names are captured from receipt data and card transactions. The same vendor might appear under different names (e.g., "Amazon.com" vs "AMZN Marketplace"). You may need to review the individual expenses to confirm they're from the same merchant.
-
----
diff --git a/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md b/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md
deleted file mode 100644
index fa4b3de39a50..000000000000
--- a/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md
+++ /dev/null
@@ -1,135 +0,0 @@
----
-title: View the Top Spenders report
-description: Learn how Workspace Admins, Approvers, and Auditors can use the Top Spenders report to understand spending trends.
-keywords: [New Expensify, Top Spenders, Top Categories, Top Merchants, Spend over time, employee spending, high spenders, expense trends, Workspace Admin, Approver, Auditor, monthly spending, spending insights, virtual CFO, analytics, insight, budget]
-internalScope: Audience is Workspace Admins, Approvers, and Auditors. Covers using the Top Spenders suggested search to view employee-level spending, and which expenses are included in the results. Does not cover custom reports, exporting data, or grouping by category or merchant.
----
-
-# View the Top Spenders report
-
-The Top Spenders report shows which employees submitted the highest total expenses last month. It’s a fast way to:
-
-- Identify high or unusual spenders
-- Spot trends without exporting data
-- Make data-backed decisions with minimal effort
-
-This report is a pre-built suggested search so you can review spending activity at a glance.
-
-.
-
----
-
-## Ways to use the Top Spenders report
-
-Workspace Admins and finance teams use the Top Spenders report to:
-
-- Identify high or unusual spenders at a glance
-- Spot spending trends without exporting data
-- Make data-backed decisions quickly
-
----
-
-## How to switch views in the Top Spenders report
-
-By default, the Top Spenders report displays a **Table**. You can also switch to **Bar**, **Line** or **Pie** view depending on how you want to analyze your data.
-
- - **Bar** shows totals for easy comparison.
- - **Line** shows trends over time.
- - **Pie** shows how data is distributed.
-
-To change the view:
-
-1. Open the Top Spenders report.
-2. Click **View** in the top navigation.
-3. Select **Bar**, **Line**, or **Pie**.
-
-The report will update instantly to reflect your selected view.
-
----
-
-# FAQ
-
-## Can you export the Top Spenders report?
-
-Not directly — the Top Spenders report can’t be exported with its grouped totals or summary data. However, if you expand each group to reveal the individual expenses, you can then select those expenses and use Export to CSV to download the raw data.
-
-1. Go to the **Spend** tab.
-2. Apply filters to create your own view.
-3. Click **Export to CSV**.
-
-## What other Insights reports are available?
-
-Additional Insights reports help you analyze spending from different perspectives:
-
-- **Spend over time** shows how total expenses change over a date range.
-- **Top Merchants** shows which vendors received the most payments.
-- **Top Categories** shows the highest-spending expense types (e.g., Travel, Meals).
-
-[Learn more about all available Insights](https://help.expensify.com/articles/new-expensify/insights/Insights-Overview)
-
-## Can other people see my Top Spenders report?
-
-Only Workspace Admins, Approvers, and Auditors can view a workspace's **Top Spenders** report. Regular members do not have access.
-
-## How is the Top Spenders report calculated?
-
-The Top Spenders report uses expenses from the previous calendar month that belong to the workspaces where you're a Workspace Admin, Approver, or Auditor, and groups them by submitter (employee). It shows the top 5 people by total amount spent.
-
-## Why is an expense missing from the Top Spenders report?
-
-The expense is left out when it doesn't belong to one of your workspaces. An expense submitted in an individual chat isn't part of a workspace, so it never appears in **Top spenders**. Expenses dated outside the previous calendar month are also excluded.
diff --git a/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md b/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md
index 3bf0ecdd4980..1b5cae7f2dc9 100644
--- a/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md
+++ b/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md
@@ -2,29 +2,29 @@
title: Accounting Search Shortcuts
description: Use the Accounting search shortcuts on the Spend page to review expense status, prepare for export, and reconcile transactions with your accounting records.
keywords: [New Expensify, accounting expensify, reconcile expenses, awaiting export, cash accruals, card accruals, card statements, bank reconciliation, month-end close, search shortcut, accounting shortcut]
-internalScope: Audience is Workspace Admins and Auditors. Covers using the Accounting section to review expense states and support reconciliation workflows. Does not cover exporting or performing reconciliation in detail.
+internalScope: Audience is workspace admins and auditors. Covers using the Accounting section to review expense states and support reconciliation workflows. Does not cover exporting or performing reconciliation in detail.
---
# Accounting Search Shortcuts
The **Accounting** search shortcuts on the **Spend** page bring together the expense views you need to reconcile your records and close your books. It helps you identify what’s ready to export, what still needs attention, and what should be matched against statements or bank data.
-Workspace Admins and Auditors can use these shortcuts to validate totals, track liabilities, and ensure your expenses align with accounting records before and after export.
+Workspace admins and auditors can use these shortcuts to validate totals, track liabilities, and ensure your expenses align with accounting records before and after export.
---
## Who can use Accounting search shortcuts
-Workspace Admins and Auditors with:
-- Approvals turned on
+Workspace admins and auditors with:
+- Approvals enabled
- Either reimbursements enabled, at least one card feed connected, or Expensify Cards enabled
---
## How to use the Accounting search shortcuts
-1. In the navigation tabs (on the left on web, on the bottom on mobile) select **Spend**.
-2. In the **Accounting** section, choose a search shortcut based on what you need to review.
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Spend**.
+2. In the **Accounting** section, select a search shortcut based on what you need to review.
3. Use filters like **Date**, **Feed**, or **Posted** to refine your view.
4. Review the **Total** in the footer to understand the amount for that view.
@@ -54,7 +54,7 @@ Once you’ve reviewed the expenses, you can export them to a CSV if you need to
# FAQ
## How can I create an aging report?
-An aging report is a breakdown of expenses by how long they have been in a given status. You can create an aging report by using the **Cash accruals** or **Card accruals** shortcut and changing **Display** to group by month, week, quarter or year. [Learn how to use Insights grouping operators](/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify#grouping-options-used-in-insights).
+An aging report is a breakdown of expenses by how long they have been in a given status. You can create an aging report by using the **Cash accruals** or **Card accruals** shortcut and changing **Display** to group by month, week, quarter or year. [Learn how to group results using search operators](/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze#how-to-group-and-visualize-results-using-search-operators).
## Why don’t I see all Accounting search shortcuts?
diff --git a/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md
index eb0f3ddc1f7d..6af9bfc99e0d 100644
--- a/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md
+++ b/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md
@@ -168,7 +168,7 @@ The outdated row is removed from the report. The expense itself is not deleted
# FAQ
## Why can't I action a pending expense?
-Expensify Card expenses show as pending until the merchant posts them. This can take 1–3 business days. Hotel or rental car holds may take longer (up to 31 days for hotels). Only posted expenses can be approved.
+Expensify Card expenses, and expenses from cards connected through Plaid, show as pending until the merchant posts them. This can take 1–3 business days. Hotel or rental car holds may take longer (up to 31 days for hotels). Only posted expenses can be approved.
## Why can’t I see Bypass approvers?
Only Workspace admins can bypass the prescribed approval workflow. If **Prevent Self-Approval** is enabled, an admin cannot bypass approvals to approve their own report.
diff --git a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md
index cc0b8a686ee0..d7d4cb2a50f2 100644
--- a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md
+++ b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md
@@ -229,9 +229,9 @@ Unsubmitted reports will remain in your Open reports section. You can go back an
The **Submit** button only appears once your report includes at least one valid expense. If the report has already been submitted, the button will no longer be visible. If your workspace has no approval workflow, the button will say **Mark as done** instead of **Submit**.
-## Why can’t I submit a report with pending Expensify Card transactions?
+## Why can’t I submit a report with pending card transactions?
-If all transactions on your report are pending Expensify Card transactions, the report can't be submitted yet. The **Submit** button won't appear, and the report isn't counted in your **Submit** to-do. Pending transactions may take a few days to post. After at least one transaction has posted, the **Submit** button appears again and you can submit the report.
+If all transactions on a report are pending, the report can’t be submitted yet. This applies to Expensify Card transactions and to company or personal cards connected through Plaid. You can submit the report once at least one transaction posts.
## What happens to held expenses when I submit a report?
diff --git a/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md b/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md
index 8df4e57bc7d8..a7309ebdd5d4 100644
--- a/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md
+++ b/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md
@@ -162,7 +162,7 @@ How long **Cancel payment** stays available depends on how the report was paid:
| **Unapprove** | After approval, before payment | Approver | Reverts the approval if the report hasn’t been re-approved or paid |
| **Mark as exported** | On approved/paid reports when integrated to an accounting system| Admin, Exporter | Marks the report as manually exported for tracking purposes |
| **Export to [accounting system]** | When an accounting system is connected | Admin, Exporter | Sends the report to an external system such as Xero or QuickBooks Online |
-| **Duplicate expense** | On non-card expenses in any state | Submitter | Creates a copy of the expense on your primary workspace with the same details. Date is set to today and receipts are not copied |
+| **Duplicate expense** | On non-card expenses in any state | Submitter | Creates a copy of the expense with the same details. The copy is added to the same report if it can still accept expenses, or to your primary workspace if not. Date is set to today and receipts are not copied |
| **Duplicate report** | On reports the submitter owns, in any state | Submitter | Creates a copy of the report and its non-card expenses. Date is set to today and receipts are not copied |
| **Download report** | Any report state | All roles on reports that aren't Draft. Submitter only on Draft reports, including reports rejected back to the submitter | Downloads a copy of the report as a PDF |
| **Print** | Any report status except Draft | All roles | Opens the report in a printable format and triggers the browser's print dialog |
diff --git a/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md b/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md
index 11cbde863e99..7b3a17fa9d48 100644
--- a/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md
+++ b/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md
@@ -12,12 +12,12 @@ The Spend page in New Expensify gives you a full list of your reports and relate
Report previews are summaries shown directly in your workspace chat in the **Inbox**. They let you quickly check a report’s status without opening it.
-## Visibility of Report Previews
+## Who can see report previews
- Visible to **all members** in the workspace chat.
- Only the **report creator** sees interactive options like **Add expense**.
- **Workspace admins** can approve and pay reports, as well as reject and hold reports.
-## Preview Details
+## What report previews show
- Report title and status (e.g., Approved, Paid)
- Up to 10 expense previews with:
- Date and category
@@ -27,7 +27,7 @@ Report previews are summaries shown directly in your workspace chat in the **Inb
- **+X more** link if there are over 10 expenses
- Action button: Submit, Approve, Pay, etc., depending on the role
-## Preview Behavior and Updates
+## How report previews update
- Updates in **real time** as expenses are added.
- If a report is deleted, the preview is removed.
@@ -48,16 +48,16 @@ Each row represents an expense and includes:
- Description
- Tags (if enabled)
-## Available Actions on the Table
-- **Click an expense** to view or edit it in the right-hand panel
-- **Edit a cell directly:** Hover over **date**, **merchant**, **description**, **category**, **tag**, or **amount** and click the pencil icon to edit the value inline without opening the expense details.
+## What you can do in the report table
+- **Select an expense** to view or edit it in the right-hand panel
+- **Edit a cell directly:** Hover over **date**, **merchant**, **description**, **category**, **tag**, or **amount** and select the edit icon to edit the value inline without opening the expense details.
- **Select multiple expenses** using checkboxes, then apply bulk actions such as:
- **Move to another report:** When you need to have multiple reports or need to break up expenses across multiple weeks or months.
- **Download:** For exporting to a CSV file for analysis or to share with your accountant.
- **Hold:** Use this when you need to temporarily pause the approval of an expense until all required information is provided.
- **Delete:** To remove expenses from the expense report.
-**Note:** The selection bar appears after selecting at least one expense.
+**Note:** The selection bar appears after you select at least one expense.
## How to move expenses between reports
@@ -74,8 +74,8 @@ Each row represents an expense and includes:
**From the expense details**
-1. Click the expense to edit it in the right-hand panel.
-2. Click **Move to report** from the bulk action menu.
+1. Select the expense to edit it in the right-hand panel.
+2. Select **Move to report**.
3. In the right-hand panel, select from the following options:
- Create report
- An existing draft or submitted report
@@ -105,11 +105,11 @@ The total is shown in your default currency until you choose a different one.
### How to change the total spend currency
1. On the **Spend** page, optionally select the rows you want to total using the checkboxes. Leave everything unselected to convert the whole search result.
-2. In the footer, click the currency shown next to the **Total spend** amount.
+2. In the footer, select the currency shown next to the **Total spend** amount.
3. In the currency picker, search for and select the currency you want.
-4. Click **Apply**.
+4. Select **Apply**.
-The **Total spend** amount updates to show the converted value in the currency you selected. Click **Reset** in the picker to return to your default currency.
+The **Total spend** amount updates to show the converted value in the currency you selected. Select **Reset** in the picker to return to your default currency.
Conversion works across the different ways you can view and select expenses:
@@ -132,15 +132,15 @@ The currency picker can't be opened in these cases:
On a wide screen:
1. On the **Spend** page, select **Expenses**.
-2. Click the **Amount** column header to sort from largest to smallest.
-3. Click the **Amount** column header again to reverse the order.
+2. Select the **Amount** column header to sort from largest to smallest.
+3. Select the **Amount** column header again to reverse the order.
On mobile, or in a narrow browser window, the column headers aren't shown, so sort from the **Display** menu instead:
1. On the **Spend** page, select **Expenses**.
-2. Tap the **Display** icon.
-3. Tap **Sort by**, select **Amount**, then tap **Apply**.
-4. To change the direction, tap the **Display** icon again, tap **Sort by**, tap **Sort order**, select **Descending** (largest to smallest) or **Ascending** (smallest to largest), then tap **Apply**.
+2. Select the **Display** icon.
+3. Select **Sort by**, choose **Amount**, then select **Apply**.
+4. To change the direction, select the **Display** icon again, select **Sort by > Sort order**, choose **Descending** (largest to smallest) or **Ascending** (smallest to largest), then select **Apply**.
The **Amount** column is only sortable when your results are shown as a table. When you group your results, this column reads **Total** instead of **Amount**.
@@ -179,26 +179,26 @@ A -$4,000 credit therefore sorts below a $10 expense rather than next to a $4,00
## How to apply filters in New Expensify
-Web:
+On web:
-1. In the navigation tabs on the left, click **Spend**.
-2. Click **Filters**.
-3. Click **Type** and select **Expense**, **Expense Report**, **Chat**, **Invoice**, **Trip**, or **Task**.
-5. Select the filters you want to apply. Available filters vary based on the selected **Type**.
+1. In the navigation tabs on the left, select **Spend**.
+2. Select **Filters**.
+3. Select **Type** and choose **Expense**, **Expense Report**, **Chat**, **Invoice**, **Trip**, or **Task**.
+4. Select the filters you want to apply. Available filters vary based on the selected **Type**.
Selected filter values are applied to the list immediately.
-Mobile:
+On mobile:
-1. In the navigation tabs on the bottom, tap **Spend**.
-2. Tap the **Filters** icon next to the search box.
-3. Tap **Type** and select **Expense**, **Expense Report**, **Chat**, **Invoice**, **Trip**, or **Task**.
-4. Tap **Save**.
+1. In the navigation tabs on the bottom, select **Spend**.
+2. Select the **Filters** icon next to the search field.
+3. Select **Type** and choose **Expense**, **Expense Report**, **Chat**, **Invoice**, **Trip**, or **Task**.
+4. Select **Save**.
5. Select the filters you want to apply. Available filters vary based on the selected **Type**.
-6. Tap **Save** after selecting each filter.
-7. Tap **View Results**.
+6. Select **Save** after selecting each filter.
+7. Select **View results**.
-For more advanced filtering, you can enter search operators directly into the search box on the **Spend** page. [Learn how to use search operators to filter and analyze](/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze).
+For more advanced filtering, you can enter search operators directly into the search field on the **Spend** page. [Learn how to use search operators to filter and analyze](/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze).
## How filtering works on the Spend page
@@ -244,7 +244,10 @@ If you create a draft report and have unreported expenses in your SelfDM, a prom
# FAQ
## Can I export reports from the Spend page?
-Yes! Choose the **Current view** option within the **Export** menu to download a CSV of exactly what you're seeing—your active filters, columns, and ordering are all preserved. You can also use a template-based export for a standard set of columns. Learn more about [exporting from the Spend page](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses).
+Yes! Choose the **Current view** option within the **Export** menu to download a CSV of exactly what you're seeing—your active filters, columns, and ordering are all preserved. You can also use a template-based export for a standard set of columns. Learn more about [exporting from the Spend page](/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses).
+
+## Where did the Insights on the Spend page go?
+The **Spend over time**, **Top categories**, **Top merchants**, and **Top spenders** charts moved from the **Insights** section of the Spend page to the **Insights** page. In the navigation tabs (on the left on web, on the bottom on mobile), select **Insights** to see them. For more details about insights, see [Learn About Insights](/articles/new-expensify/insights/Learn-About-Insights).
## Do smart suggestions work on mobile?
Yes, smart suggestions also appear in the mobile Inbox and draft reports.
diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md
index 8d2d2be6b2b7..d40c67ed40f7 100644
--- a/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md
+++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md
@@ -7,7 +7,7 @@ internalScope: Audience is all members. Covers how to duplicate a single expense
# How to duplicate an expense
-You can create a copy of one or more expenses using **Duplicate expense**. This generates new expenses with the same details as the originals, including the merchant, amount, category, tags, and tax. The date is set to today and receipt images are not copied. An expense that is on a report is copied onto your primary workspace, while an Unreported expense is copied onto your personal space.
+You can create a copy of one or more expenses using **Duplicate expense**. This generates new expenses with the same details as the originals, including the merchant, amount, category, tags, and tax. The date is set to today and receipt images are not copied. If you duplicate a single expense that is on a report still able to accept expenses (such as a Draft report), the copy stays on that report. Otherwise, an expense on a report is copied to your primary workspace, and an Unreported expense is copied to your personal space.
This is useful when you need to create similar expenses quickly without re-entering all the details manually. You can duplicate a single expense or select multiple expenses and duplicate them all at once.
@@ -66,7 +66,7 @@ Purpose: Helps the user identify the bulk duplicate option on the selection bar.
The duplicate expense keeps the original expense's merchant, amount, category, tags, tax, and billable or reimbursable status. The date is set to today and receipt images are not included.
-The duplicate matches the reported state of the original. If the original expense is on a report, the copy is created on your primary workspace. If the original is an Unreported expense, the copy is created on your personal space and is not added to a report.
+The duplicate matches the reported state of the original. If you duplicate a single expense on a report that can still accept expenses (such as a Draft report), the copy stays on that report. Otherwise, if the original expense is on a report that cannot accept new expenses, such as an Approved or Paid report, the copy is created on your primary workspace. If the original is an Unreported expense, the copy is created on your personal space and is not added to a report.
If the original expense's coding (such as categories or tags) is not available on your primary workspace, the duplicate expense will still be created. You will be prompted to correct the coding on the expense.
diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md
index 53f91d30e6fa..7793fbec6871 100644
--- a/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md
+++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md
@@ -27,7 +27,8 @@ If you want to learn why duplicate expenses happen, see [Why Expenses Duplicate]
You can merge duplicate expenses if you can edit both expenses.
- Submitters can merge their own unreported expenses, expenses on Draft reports, and expenses on Outstanding reports.
-- Approvers and Workspace Admins can merge expenses on the same Draft or Outstanding report.
+- Approvers can merge expenses on the same Draft or Outstanding report.
+- Workspace admins can merge a member's expenses across that member's Draft and Outstanding reports.
You can't merge expenses that are Approved or Paid. Learn more about [expense statuses](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions).
diff --git a/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md
index e770c6e3be6c..8bf9fc80993e 100644
--- a/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md
+++ b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md
@@ -1,7 +1,7 @@
---
title: Use Search Operators to Filter and Analyze
description: Learn how to use search operators, filters, and grouping to find, organize, and analyze expenses, chats, reports, and tasks in Expensify.
-keywords: [New Expensify, search operators, advanced filters, search rules, expense search, report search, chat filters, advanced search, group-by, view, chart, search syntax, bank account filter, custom field, international reimbursement IDs, transaction status filter, pending card expenses, posted card expenses, paid by filter, paid-by]
+keywords: [New Expensify, search operators, advanced filters, search rules, expense search, report search, chat filters, advanced search, group-by, view, chart, search syntax, bank account filter, custom field, international reimbursement IDs, transaction status filter, pending card expenses, posted card expenses, paid by filter, paid-by, vendor filter, rejected filter, declined]
internalScope: Audience is all Expensify members. Covers search operator syntax for filtering, grouping, and chart views. Does not cover saved search management or Search page UI navigation.
---
@@ -70,6 +70,7 @@ You can use the following operators to filter expenses:
| `merchant:` | Filter by expense merchant name | `merchant:"Delta Air Lines"` |
| `category:` | Filter by expense category label | `category:"Travel"` |
| `tag:` | Filter by one or multiple tags | `tag:"Client A"` |
+| `vendor:` | Filter by the vendor assigned to an expense. Available only when one of your workspaces uses vendor matching. | `vendor:"Starbucks"` |
| `amount:` / `purchase-amount:` | Filter by amount using `=`, `>`, `<`, `>=`, or `<=` | `amount:>100` |
| `status:` | Filter by expense status (`unreported`, `draft`, `outstanding`, `approved`, `paid`, `done`) | `status:approved` |
| `date:` | Filter by expense date using relative dates or comparison operators for a date range | `date:this-month` |
@@ -78,6 +79,7 @@ You can use the following operators to filter expenses:
| `receipt-type:` | Filter by receipt type (`ereceipt`, `itemized`) | `receipt-type:itemized` |
| `reimbursable:` | Filter by whether an expense is reimbursable (`yes` or `no`) | `reimbursable:yes` |
| `billable:` | Filter by whether an expense is billable (`yes` or `no`) | `billable:no` |
+| `declined:` | Filter by whether an expense was rejected (`yes` or `no`). Matches the **Rejected** filter | `declined:yes` |
| `attendee:` | Filter by expense attendee using an email, phone number, display name, or `me` | `attendee:"Jason Mills"` |
| `posted:` | Filter by the date a credit card expense was posted | `posted:last-statement` |
| `transaction-status:` | Filter by whether a card expense is still `pending` or has `posted`. Accepts one value at a time, and only card expenses are returned. Available only when you have at least one card. | `transaction-status:pending` |
@@ -106,6 +108,7 @@ You can use the following operators to filter reports:
| `withdrawn:` | Filter by ACH withdrawal date | `withdrawn:2024-01-15` |
| `withdrawal-type:` | Filter by withdrawal type (`reimbursement`, `expensify-card`, `central-travel-invoicing`) | `withdrawal-type:reimbursement` |
| `paid-status:` | Filter by how the report was paid (`markedAsPaid`, `withdrawing`, `confirmed`). Combine multiple values with commas | `paid-status:markedAsPaid,confirmed` |
+| `declined:` | Filter by whether a report was rejected (`yes` or `no`). Matches the **Rejected** filter | `declined:yes` |
| `paid-by:` | Filter by the member who paid the report (email, phone, display name, or `me`). Combine multiple members with commas. Reports that have not been paid never match. This operator also works on expense, invoice, and trip searches | `paid-by:me,alice@acme.com` |
| `action:` | Filter by blocking report action | `action:approve` |
| `submitter-user-id:` | Filter by the Custom field 1 value set for the report submitter | `submitter-user-id:12345` |
@@ -180,7 +183,6 @@ Use `group-by:` to group results by a specific dimension.
| **Syntax** | **Description** | **Example** |
|---|---|---|
-| `group-by:report` | Group results by report | `type:expense group-by:report` |
| `group-by:from` | Group results by submitter | `type:expense group-by:from` |
| `group-by:card` | Group results by card | `type:expense group-by:card` |
| `group-by:withdrawal-id` | Group results by withdrawal ID | `type:expense group-by:withdrawal-id` |
diff --git a/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md b/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md
index 5dd2c097d3c4..c14c4fb2ee4d 100644
--- a/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md
+++ b/docs/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate.md
@@ -35,14 +35,14 @@ Expensify automatically merges a SmartScanned receipt with a matching card trans
- The transaction dates match
- The currencies match or fall within the allowed foreign exchange tolerance
- Only one possible match exists
-- The card transaction posts within seven days of the receipt being SmartScanned
+- The card transaction posts within seven days of the receipt being SmartScanned. For cards connected through Plaid, the pending transaction can merge before it posts.
**Expenses will not automatically merge if:**
- The receipt was not SmartScanned or SmartScan did not complete
-- Expense details were edited before the card transaction posted
+- Expense details were edited before the card transaction posted (or, for cards connected through Plaid, before the pending transaction imported)
- The amount, date, or currency differs beyond tolerance
-- The card transaction posts after seven days
+- The card transaction posts after seven days (or, for cards connected through Plaid, the pending transaction imports after seven days)
- The foreign exchange difference exceeds five percent
- The receipt is already matched to another expense
- Both expenses are card transactions
@@ -94,7 +94,7 @@ If both expenses are card transactions, they cannot be merged. Instead, [delete
This happens when the receipt and card transaction did not merge into a single expense. This is usually due to differences in amount, date, currency, or timing.
## Why didn’t my receipt merge with my card transaction?
-SmartScan requires a close match in amount, date, and currency. If those details differ, or if the card transaction has not posted within seven days, the expenses will not merge automatically.
+SmartScan requires a close match in amount, date, and currency. If those details differ, or if the card transaction has not posted within seven days (for cards connected through Plaid, imported as pending within seven days), the expenses will not merge automatically.
## Can uploading the same receipt twice create duplicates?
diff --git a/docs/articles/new-expensify/settings/Account-Settings.md b/docs/articles/new-expensify/settings/Account-Settings.md
index 5873b0e72242..5e28d24743f3 100644
--- a/docs/articles/new-expensify/settings/Account-Settings.md
+++ b/docs/articles/new-expensify/settings/Account-Settings.md
@@ -2,11 +2,14 @@
title: Account Settings
description: Learn how to update your profile settings, preferences, and notifications in Expensify.
keywords: [New Expensify, profile, account preferences, notifications, language, theme, high contrast mode, timezone, personal information, phone number, security code]
+internalScope: Audience is all members. Covers how to update profile, status, pronouns, timezone, language, theme, notification, and personal information settings in Account. Does not cover security, wallet, or subscription settings.
---
# Account Settings
-Expensify allows you to personalize your experience by customizing your profile, preferences, and notifications. This guide shows you how to update your photo, language, timezone, notification settings, and more—all from the left-hand navigation menu.
+Expensify allows you to personalize your experience by customizing your profile, preferences, and notifications. This guide shows you how to update your photo, language, timezone, notification settings, and more—all from **Account**.
+
+You can access **Account** in the navigation tabs (on the left on web, on the bottom on mobile).
---
@@ -14,15 +17,15 @@ Expensify allows you to personalize your experience by customizing your profile,
You can update the following profile settings:
-- **Profile Photo** – Upload or change your photo.
-- **Customized Avatar** – Select an avatar instead of a profile photo.
+- **Profile photo** – Upload or change your photo.
+- **Custom avatar** – Select an avatar instead of a profile photo.
- **Status** – Add a custom message and emoji to show your current status.
- **Pronouns** – Display your preferred pronouns on your profile.
- **Language** – Update your account to your preferred language.
- **Timezone** – Set the correct timezone for expense and report timestamps.
-- **Theme** – Toggle between light mode, dark mode, or follow device settings, and enable high contrast mode.
+- **Theme** – Switch between light mode, dark mode, or your device settings, and enable high contrast mode.
- **Notifications** – Customize how and when you receive email and app notifications.
-- **Personal Information** – Add or update your legal name, date of birth, phone number, and address.
+- **Personal information** – Add or update your legal name, date of birth, phone number, and address.
---
@@ -30,9 +33,10 @@ You can update the following profile settings:
To upload or update a profile photo:
-1. In the navigation tabs, click **Account > Profile**.
-2. Click the pencil icon next to your profile image.
-3. Select **Upload Image** and choose a new photo.
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**.
+2. Select **Profile**.
+3. Select the edit icon next to your profile image.
+4. Select **Upload photo** and choose a new photo.
---
@@ -40,11 +44,12 @@ To upload or update a profile photo:
To use an avatar instead of a profile photo:
-1. In the navigation tabs, click **Account > Profile**.
-2. Click the pencil icon next to your profile image.
-3. Scroll down to **Or choose a custom avatar**.
-4. Select an avatar in your desired colored palette.
-5. Click **Save**.
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**.
+2. Select **Profile**.
+3. Select the edit icon next to your profile image.
+4. Scroll down to **Or choose a custom avatar**.
+5. Choose an avatar in your preferred color palette.
+6. Select **Save**.
---
@@ -52,12 +57,11 @@ To use an avatar instead of a profile photo:
To manually set or update your timezone:
-1. Go to **Account > Profile**.
-2. Click **Timezone**.
-3. Turn off **Automatically determine your location**.
-4. Click **Timezone**
-5. Select the your time zone from the list.
-6. Click **Save**.
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**.
+2. Select **Profile > Time zone**.
+3. Disable **Automatically determine your location**.
+4. Choose your timezone from the list.
+5. Select **Save**.
---
@@ -65,11 +69,11 @@ To manually set or update your timezone:
To set or change your language:
-1. In the navigation tabs, select **Account > Preferences**.
-2. Tap or click on **Language**.
+1. On web, select **Account** in the navigation tabs. On mobile, select your profile icon.
+2. Select **Preferences > Language**.
3. Choose your preferred language from the list.
-4. The checkmark will confirm the selected language.
-5. Your account will update automatically to reflect the new language setting.
+
+A checkmark confirms the selected language, and your account updates automatically to the new language.
**Note:** Language changes affect your personal account view only. Workspace members must update their own preferences individually.
@@ -97,8 +101,9 @@ Expensify supports the following languages:
To manage notification settings:
-1. Go to **Account > Preferences > App preferences**.
-2. Adjust the notification toggles:
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**.
+2. Select **Preferences**.
+3. Under **App preferences**, enable or disable these toggles:
- **Receive relevant feature updates and Expensify news**
- **Mute all sounds from Expensify**
@@ -108,11 +113,12 @@ To manage notification settings:
Set your profile status with an emoji and optional message.
-1. Go to **Account > Profile**.
-2. Click **Status** and enter your custom message.
-3. (Optional) Add an emoji.
-4. Choose when to clear the status (e.g., 1 hour, 1 day, or Never).
-5. Click **Save**.
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**.
+2. Select **Profile > Status**.
+3. Enter your custom message.
+4. (Optional) Add an emoji.
+5. Under **Clear after**, choose when to clear the status, such as **1 hour**, **Today**, or **Never**.
+6. Select **Save**.
---
@@ -120,11 +126,11 @@ Set your profile status with an emoji and optional message.
To set or update your pronouns:
-1. Go to **Account > Profile**.
-2. Click **Pronouns**.
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**.
+2. Select **Profile > Pronouns**.
3. Type a word or letters into the **Search to see options** field. No pronouns are listed until you type a search term.
4. Select your pronouns from the search results.
-5. Click **Save**.
+5. Select **Save**.
---
@@ -132,12 +138,10 @@ To set or update your pronouns:
To change the app’s appearance:
-1. Go to **Account > Preferences**.
-2. Click **Theme** and choose from:
- - **Light**
- - **Dark**
- - **Use device settings**
-3. Click **Save**.
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**.
+2. Select **Preferences > Theme**.
+3. Choose **Light**, **Dark**, or **Use device settings**.
+4. Select **Save**.
---
@@ -145,10 +149,10 @@ To change the app’s appearance:
High contrast mode increases the visual contrast of UI elements, making text and controls easier to distinguish.
-1. Go to **Account > Preferences**.
-2. Click **Theme**.
-3. Toggle **High contrast mode** on.
-4. Click **Save**.
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**.
+2. Select **Preferences > Theme**.
+3. Enable **High contrast mode**.
+4. Select **Save**.
You can also enable high contrast mode from the [login page](https://new.expensify.com/) by selecting **Enable high contrast**.
@@ -157,12 +161,14 @@ You can also enable high contrast mode from the [login page](https://new.expensi
## How to update your personal information
To update your legal name, date of birth, phone number, or address:
-1. Go to **Account > Profile**.
-2. Scroll to the **Private** section.
-3. Click **Legal Name**, **Date of Birth**, **Phone Number**, or **Address**.
-4. Enter the updated information.
-5. Click **Save**.
-6. Enter the security code sent to your email or phone to verify the changes.
+
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**.
+2. Select **Profile**.
+3. Scroll to the **Private** section.
+4. Select **Legal name**, **Date of birth**, **Phone number**, or **Address**.
+5. Enter the updated information.
+6. Select **Save**.
+7. Enter the security code sent to your email or phone to verify the changes.
When typing in the **Address line 1** field, a list of address suggestions appears. Select a suggestion to auto-populate the address fields including city, state, ZIP code, and country.
@@ -173,14 +179,18 @@ If no address is saved, the **Country** field is pre-filled based on your curren
## How to update your display name or legal name
To edit your display name:
-1. Go to **Account > Profile**.
-2. Click **Display Name**, enter your preferred name, and click **Save**.
+
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**.
+2. Select **Profile > Display name**.
+3. Enter your preferred name and select **Save**.
To edit your legal name:
-1. Go to **Account > Profile**.
-2. Scroll to **Private** and click **Legal Name**.
-3. Enter the updated information and click **Save**.
-4. Enter the security code sent to you to verify the change.
+
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**.
+2. Select **Profile**.
+3. Scroll to **Private** and select **Legal name**.
+4. Enter the updated information and select **Save**.
+5. Enter the security code sent to you to verify the change.
---
@@ -193,7 +203,11 @@ Your photo helps teammates identify you easily in chats, reports, and notificati
No. It only changes the language of your user interface, not the content of your reports.
## Can I disable all Expensify app notifications?
-Yes. From the navigation tabs, go to **Account > Preferences** and toggle off both update and sound notifications.
+Yes. To mute notifications:
+
+1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Account**.
+2. Select **Preferences**.
+3. Disable **Receive relevant feature updates and Expensify news** and enable **Mute all sounds from Expensify**.
## Can I disable email notifications for reports that I approve for reimbursement?
No. To comply with regulations in several US states, Expensify is required to send you an email whenever you send money to another individual.
@@ -202,5 +216,4 @@ No. To comply with regulations in several US states, Expensify is required to se
Yes! Your chosen theme and high contrast mode setting apply across web, mobile, and desktop versions of Expensify.
## Can I set a permanent status message?
-Yes. After entering your status, choose **Never** under “Clear After” to keep it active indefinitely.
-
+Yes. After entering your status, choose **Never** under **Clear after** to keep it active indefinitely.
diff --git a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md
index eef91bd6fa0e..8d3097664a55 100644
--- a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md
+++ b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md
@@ -2,14 +2,14 @@
title: Delegate When Out of Office
description: Set a vacation delegate to approve reports on your behalf while you're away.
keywords: [New Expensify, delegate approvals, out-of-office approver, vacation, out of office, approval delegation, temporary delegate, vacation delegate, copilot vs delegate, copilot, shared access, proxy, behalf of]
-internalScope: Audience is all members. Covers assigning and removing your own vacation delegate from your profile status. Does not cover Copilot access or Expensify Classic delegates.
+internalScope: Audience is all members. Covers assigning, scheduling the end of, and removing your own vacation delegate from your profile. Does not cover Copilot access or Expensify Classic delegates.
---
# Delegate When Out of Office
If you're out of the office or on vacation, you can assign a vacation delegate to handle your approvals while you're away.
-A vacation delegate can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. When you're back, just remove the delegate to resume handling your own reports.
+A vacation delegate can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. Pick a **Clear after** date and time and the delegate is removed automatically at that time, so reports come back to you when you return.
All delegate actions are tracked in the report history for full visibility.
@@ -17,19 +17,20 @@ All delegate actions are tracked in the report history for full visibility.
## How to assign a vacation delegate
-1. Go to **Account > Profile > Status**.
-2. Under **Vacation delegate**, enter the email address or phone number of your delegate.
-3. Click **Save**.
+1. Go to **Account > Profile > Vacation delegate**.
+2. Select the member who should approve on your behalf.
+3. Under **Clear after (recommended)**, pick the last day the delegate should cover (optional).
+4. Under **Time**, pick when the delegate clears on that day (defaults to the end of the day).
+5. Click **Save**.
-Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments.
+Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. The delegate and the date and time it clears are shown under **Vacation delegate** on your profile.
## How to remove a vacation delegate
-When you're ready to take back control of your reports:
+If you set a **Clear after** date, the delegate is removed automatically. To remove it sooner:
-1. Go to **Account > Profile > Status**.
-2. Under **Vacation delegate**, clear the delegate's email or phone number.
-3. Click **Save**.
+1. Go to **Account > Profile > Vacation delegate**.
+2. Click **Remove vacation delegate**.
---
@@ -49,7 +50,7 @@ Your delegate can assign their own vacation delegate too. This ensures approvals
## If I clear my status, does that remove my vacation delegate?
-No. Clearing your status won't affect your delegate. You'll need to manually remove them when you're back.
+No. Clearing your status won't affect your delegate. Set a **Clear after** date on your vacation delegate, or remove them when you're back.
## What if my delegate isn’t in the same workspace?
diff --git a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md
index 7179adb64976..8164bc74d89f 100644
--- a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md
+++ b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md
@@ -1,7 +1,7 @@
---
title: Managing Workspace Members in New Expensify
description: Learn how to add, remove, filter, and manage user roles in a New Expensify workspace.
-keywords: [New Expensify, workspace members, add user, remove user, user roles, workspace admin, card admin, people admin, payments admin, invite users, filter members, role filter]
+keywords: [New Expensify, workspace members, add user, remove user, user roles, workspace admin, card admin, people admin, payments admin, guest, invite users, filter members, role filter, member display name]
---
@@ -12,9 +12,10 @@ A **Workspace member** is anyone added to a workspace with one of the following
- **Member** – Can submit and/or approve expenses.
- **Workspace admin** – Can manage members, roles, workflows, and settings.
- **Card admin** – Can manage the Expensify Card and Company cards, including issuing cards, setting limits, and assigning company cards. Has read-only access to Overview and Members, and all other workspace settings pages are hidden. (Control workspaces only)
-- **People admin** – Can manage workspace membership and configure approval workflows. People admins can add and remove Members and Auditors only, and cannot add or remove admins or other scoped admin roles. They have read-only access to the workspace overview and edit access to Members and the Approvals section of Workflows. All other workspace editor pages are hidden. (Control workspaces only)
+- **People admin** – Can manage workspace membership and configure approval workflows. People admins can add and remove Members, Guests, and Auditors only, and cannot add or remove admins or other scoped admin roles. They have read-only access to the workspace overview and edit access to Members and the Approvals section of Workflows. All other workspace editor pages are hidden. (Control workspaces only)
- **Payments admin** – Can manage workspace payment settings in the **Payments** section of **Workflows**, including business bank accounts, the authorized expense payer, and payment methods. Payment permissions are otherwise unchanged, so paying a report still depends on the relevant bank account being shared with the payer. Has read-only access to the workspace overview and Members, and all other workspace editor pages are hidden. (Control workspaces only)
- **Auditor** – Can view and comment on reports and has read-only access to all workspace editor pages (such as Categories, Tags, Rules, Workflows, and Accounting). Auditors cannot change any workspace settings or take workflow actions such as approving, paying, or taking control. (Control workspaces only)
+- **Guest** – Can submit reports with limited visibility. Guests can’t view the workspace member list or create rooms. (Control workspaces only)
- **Editor** – Can edit workspace settings, but cannot see all expenses associated with the workspace. They cannot add admins to the workspace or change other members' roles. (Submit workspaces only)
---
@@ -32,7 +33,7 @@ To invite someone to your workspace:
**Tip:** You can also invite members under **Settings > Profile** by clicking **Share** to send the workspace’s URL or QR code.
-**Note:** Workspace admins and People admins can invite members. People admins can only invite members as **Members** or **Auditors** — they cannot assign admin roles.
+**Note:** Workspace admins and People admins can invite members. People admins can only invite members as **Members**, **Guests**, or **Auditors** — they cannot assign admin roles.
---
@@ -49,6 +50,7 @@ You can filter the member list by role to quickly find specific groups of member
- **People Admins** – Members with the People Admin role (Control workspaces only).
- **Payments admins** – Shows only members with the Payments admin role (Control workspaces only).
- **Auditors** – Members with the Auditor role (Control workspaces only).
+ - **Guests** – Members with the Guest role (Control workspaces only).
- **Editors** – Members with the Editor role (Submit workspaces only).
- **Members** – Members with the Member role.
@@ -64,7 +66,7 @@ If no members match the selected filters, an empty state is displayed with the m
If someone no longer needs access to your workspace, you can remove them individually or in bulk to keep your member list up to date.
-**Note:** Workspace admins and People admins can remove members. People admins can only remove **Members** and **Auditors** — they cannot remove admins or other scoped admin roles.
+**Note:** Workspace admins and People admins can remove members. People admins can only remove **Members**, **Guests**, and **Auditors** — they cannot remove admins or other scoped admin roles.
**Note:** If you remove a member who is an approver on an Outstanding report, the workspace owner replaces them as the approver. Learn more about [automatic approver reassignment](/articles/new-expensify/workspaces/Add-Approvals).
@@ -94,12 +96,25 @@ If someone no longer needs access to your workspace, you can remove them individ
On the web, you can also change a member’s role directly from the members list. Hover over the member’s **Role** and click the pencil icon that appears to select a new role.
-**Note:** People admins can change a member’s role between **Member** and **Auditor** only. Granting or removing the admin role or any scoped admin role (such as Card admin, People admin, or Payments admin) requires a Workspace admin.
+**Note:** People admins can change a member’s role between **Member**, **Guest**, and **Auditor** only. Granting or removing the admin role or any scoped admin role (such as Card admin, People admin, or Payments admin) requires a Workspace admin.
**Note:** You can’t change the role of the member set as the workspace’s authorized payer. Their **Role** can’t be edited and role-change options are hidden until you assign a different payer. To change their role, first go to **Workspaces > Workflows**, open **Payments**, and set a different admin as the **Payer**.
---
+# How to Set a Member’s Display Name
+
+Workspace admins can set a display name for a member who hasn’t added a name of their own.
+
+1. Go to **Workspaces > [Workspace Name] > Members**.
+2. Click the member’s name.
+3. In the right-hand panel, click **Profile**.
+4. Click **Display name**, enter a first and last name, and click **Save**.
+
+The name applies in every workspace the member belongs to. After it’s saved, only the member can change it.
+
+---
+
# Transfer Workspace Ownership
To transfer ownership to another admin:
diff --git a/docs/assets/images/Accounting_Intuit-Enterprise-Suite.png b/docs/assets/images/Accounting_Intuit-Enterprise-Suite.png
new file mode 100644
index 000000000000..33df8fbd5bc1
Binary files /dev/null and b/docs/assets/images/Accounting_Intuit-Enterprise-Suite.png differ
diff --git a/docs/assets/images/DualEntry_accounting.png b/docs/assets/images/DualEntry_accounting.png
new file mode 100644
index 000000000000..508ab4895d37
Binary files /dev/null and b/docs/assets/images/DualEntry_accounting.png differ
diff --git a/docs/assets/images/Rillet_accounting.png b/docs/assets/images/Rillet_accounting.png
new file mode 100644
index 000000000000..2a3088631908
Binary files /dev/null and b/docs/assets/images/Rillet_accounting.png differ
diff --git a/docs/assets/images/home.png b/docs/assets/images/home.png
index 37c95e644a31..988294af8e4a 100644
Binary files a/docs/assets/images/home.png and b/docs/assets/images/home.png differ
diff --git a/docs/assets/images/home2.png b/docs/assets/images/home2.png
index b2bb5c1ef908..7662e67dce34 100644
Binary files a/docs/assets/images/home2.png and b/docs/assets/images/home2.png differ
diff --git a/docs/assets/images/insight-view-on-spend.png b/docs/assets/images/insight-view-on-spend.png
new file mode 100644
index 000000000000..aa9063fdea84
Binary files /dev/null and b/docs/assets/images/insight-view-on-spend.png differ
diff --git a/docs/assets/images/insights-filters.png b/docs/assets/images/insights-filters.png
new file mode 100644
index 000000000000..030b959c3f73
Binary files /dev/null and b/docs/assets/images/insights-filters.png differ
diff --git a/docs/assets/images/insights-page.png b/docs/assets/images/insights-page.png
new file mode 100644
index 000000000000..e199f4997bc8
Binary files /dev/null and b/docs/assets/images/insights-page.png differ
diff --git a/docs/assets/images/insights-spend-over-time.png b/docs/assets/images/insights-spend-over-time.png
new file mode 100644
index 000000000000..2f81a0653b2a
Binary files /dev/null and b/docs/assets/images/insights-spend-over-time.png differ
diff --git a/docs/assets/images/insights-top-categories.png b/docs/assets/images/insights-top-categories.png
new file mode 100644
index 000000000000..961f5b92b206
Binary files /dev/null and b/docs/assets/images/insights-top-categories.png differ
diff --git a/docs/assets/images/insights-top-merchant.png b/docs/assets/images/insights-top-merchant.png
new file mode 100644
index 000000000000..cc4eb59c79b8
Binary files /dev/null and b/docs/assets/images/insights-top-merchant.png differ
diff --git a/docs/assets/images/insights-top-spender.png b/docs/assets/images/insights-top-spender.png
new file mode 100644
index 000000000000..00270a92e176
Binary files /dev/null and b/docs/assets/images/insights-top-spender.png differ
diff --git a/docs/new-expensify/hubs/connections/intuit-enterprise-suite.html b/docs/new-expensify/hubs/connections/intuit-enterprise-suite.html
new file mode 100644
index 000000000000..86641ee60b7d
--- /dev/null
+++ b/docs/new-expensify/hubs/connections/intuit-enterprise-suite.html
@@ -0,0 +1,5 @@
+---
+layout: default
+---
+
+{% include section.html %}
diff --git a/docs/redirects.csv b/docs/redirects.csv
index baee3b41b63f..ae9d7ff07781 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -1033,7 +1033,13 @@ https://help.expensify.com/articles/Unlisted/Configure-Rillet,https://help.expen
https://help.expensify.com/articles/Unlisted/Connect-to-Rillet,https://help.expensify.com/articles/new-expensify/connections/rillet/Connect-to-Rillet
https://help.expensify.com/articles/Unlisted/Manage-Vendor-Matching-for-Intacct,https://help.expensify.com/articles/new-expensify/connections/sage-intacct/Manage-Vendor-Matching-for-Sage-Intacct
https://help.expensify.com/articles/Unlisted/Manage-Vendor-Matching-for-Xero,https://help.expensify.com/articles/new-expensify/connections/xero/Manage-Vendor-Matching-for-Xero
+https://help.expensify.com/articles/new-expensify/connections/quickbooks-online/Connect-to-Intuit-Enterprise-Suite,https://help.expensify.com/articles/new-expensify/connections/intuit-enterprise-suite/Connect-to-Intuit-Enterprise-Suite
https://help.expensify.com/articles/new-expensify/getting-started/Understanding-the-For-you-Section,https://help.expensify.com/articles/new-expensify/getting-started/Understanding-the-To-dos-Section
https://help.expensify.com/articles/new-expensify/ai-agents/Create-Agent-Rules,https://help.expensify.com/articles/new-expensify/ai-agents/Automate-Workflows-with-Agent-Rules
https://help.expensify.com/articles/new-expensify/ai-agents/Create-and-Use-Custom-Agents,https://help.expensify.com/articles/new-expensify/ai-agents/Create-and-Use-Agents
https://help.expensify.com/articles/new-expensify/ai-agents/How-to-Write-Agent-Rules,https://help.expensify.com/articles/new-expensify/ai-agents/How-to-Write-Agent-Instructions
+https://help.expensify.com/articles/new-expensify/insights/View-the-Top-Spenders-report,https://help.expensify.com/articles/new-expensify/insights/Explore-the-Top-Spenders-Insight
+https://help.expensify.com/articles/new-expensify/insights/View-the-Top-Categories-report,https://help.expensify.com/articles/new-expensify/insights/Explore-the-Top-Categories-Insight
+https://help.expensify.com/articles/new-expensify/insights/View-the-Top-Merchants-report,https://help.expensify.com/articles/new-expensify/insights/Explore-the-Top-Merchants-Insight
+https://help.expensify.com/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify,https://help.expensify.com/articles/new-expensify/insights/How-to-Use-Insights
+https://help.expensify.com/articles/new-expensify/insights/View-the-Violations-by-submitter-report,https://help.expensify.com/Hidden/View-the-Violations-by-submitter-report
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index e09fb2b24b29..bab3003cb63c 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -23,7 +23,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 9.5.8
+ 9.5.16CFBundleSignature????CFBundleURLTypes
@@ -44,7 +44,7 @@
CFBundleVersion
- 9.5.8.4
+ 9.5.16.1FullStoryOrgId
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 22acf7c996d6..8484d8799e04 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 9.5.8
+ 9.5.16CFBundleVersion
- 9.5.8.4
+ 9.5.16.1NSExtensionNSExtensionPointIdentifier
diff --git a/ios/Podfile b/ios/Podfile
index 12d17f06cc44..d9fef99b568f 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -3,6 +3,9 @@ require File.join(File.dirname(`node --print "require.resolve('expo/package.json
# This value is used by $RNMapboxMaps
$RNMapboxMapsImpl = 'mapbox'
$VCDisableFrameProcessors = true
+# Compiles react-native-screens' experimental (gamma) iOS code, which ScrollViewMarker needs.
+# Without it, rendering ScrollViewMarker crashes on iOS.
+ENV['RNS_GAMMA_ENABLED'] = '1'
def node_require(script)
# Resolve script with node to allow for hoisting
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index b1f52d4f86b6..b02b59ac794b 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -4116,7 +4116,7 @@ SPEC CHECKSUMS:
RNPermissions: 32bd3b977f28442af0900b102f4458f0f9f9b77c
RNReactNativeHapticFeedback: 6c8677db48f62a635e6a2065f5d2bd02498d7a73
RNReanimated: e75c988a6527ec651ea2957b93891ddb64d530d4
- RNScreens: 8708989f700bdc3dc5abc3c77aa3b5f3e73ce2b2
+ RNScreens: 433b8c24dd1dfeaa3b61f72d55052cb64fd19c5f
RNSentry: 3dcc26572ed12d8ee7edbe80d198dda70ac10d4c
RNShare: eb378c57de30a83b0253eed74762ee99249f47d7
RNSVG: 394cfd0518613b144c65ffe69045f827b78a7a7c
@@ -4134,6 +4134,6 @@ SPEC CHECKSUMS:
VisionCamera: 30b358b807324c692064f78385e9a732ce1bebfe
Yoga: c30c859b7e9b75e547b600b486fe33165f60de00
-PODFILE CHECKSUM: 64b30fca02abc208520075c2e1275b24533fa805
+PODFILE CHECKSUM: c937d1b7b774ce019c0c848e4ad1e850f08c0e90
COCOAPODS: 1.16.2
diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist
index b8b01434878f..77fe0db48495 100644
--- a/ios/ShareViewController/Info.plist
+++ b/ios/ShareViewController/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 9.5.8
+ 9.5.16CFBundleVersion
- 9.5.8.4
+ 9.5.16.1NSExtensionNSExtensionAttributes
diff --git a/jest.config.js b/jest.config.js
index 61fe030d49f2..7aebdba5e6f2 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,11 +1,11 @@
const testFileExtension = 'ts?(x)';
-// Reassure runs only the performance tests, matched by the `.perf-test` suffix / `__perf__` folder,
+// Reassure runs only the performance tests, matched by the `.perf-test` suffix / `__perf__` folder (or the `.perf-local` suffix for local benchmarks),
// which it passes to Jest as `--testMatch`. We detect that from the test-runner argv so we can leave
// `performance` real for perf runs only: enabling Jest's modern fake timers globally also fakes
// `performance`, and React's Scheduler captures that (frozen) clock once at module load, which zeroes
// every React Profiler render duration — and thus every Reassure `[render]` measurement.
-const isPerfTestRun = process.argv.some((arg) => arg.includes('perf-test') || arg.includes('__perf__'));
+const isPerfTestRun = process.argv.some((arg) => /perf-(test|local)|__perf__/.test(arg));
// With `--coverage`, Jest also instruments every file matched by `collectCoverageFrom` that no test loaded, so
// untested files show up at 0%. Under `--shard=N/M` each shard would repeat that pass over all of `src/**`
@@ -62,6 +62,8 @@ module.exports = {
testEnvironment: 'jsdom',
setupFiles: ['/jest/setup.ts', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'],
setupFilesAfterEnv: ['/jest/setupAfterEnv.ts', '/tests/perf-test/setupAfterEnv.ts'],
+ // Restarts a worker between suites once it holds this much memory, so long-lived CI workers don't get OOM killed.
+ workerIdleMemoryLimit: '2GB',
cacheDirectory: '/.jest-cache',
coverageReporters: ['json', 'lcov', 'text-summary'],
moduleNameMapper: {
diff --git a/jest/setup.ts b/jest/setup.ts
index d7e3eec8321e..4589474f054c 100644
--- a/jest/setup.ts
+++ b/jest/setup.ts
@@ -350,6 +350,10 @@ jest.mock('@shopify/react-native-skia', () => ({
listFontFamilies: jest.fn(() => []),
}));
+// The native tab bar icons are drawn with Skia's imperative API, which the mock above leaves out, so the tabs get no icons.
+jest.mock('@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/tabIconRasterizer', () => jest.fn(() => undefined));
+jest.mock('@libs/Navigation/AppNavigator/Navigators/NativeTabNavigator/useTabAvatarImage', () => jest.fn(() => undefined));
+
jest.mock('@sbaiahmed1/react-native-biometrics', () => ({
isSensorAvailable: jest.fn(() => Promise.resolve({available: false})),
createKeys: jest.fn(() => Promise.resolve({publicKey: ''})),
@@ -365,6 +369,8 @@ jest.mock('victory-native', () => ({
CartesianChart: jest.fn(
({children}: {children?: (args: Record) => ReactNode}) => children?.({points: {y: []}, chartBounds: {left: 0, right: 0, top: 0, bottom: 0}}) ?? null,
),
+ Pie: {Chart: jest.fn(() => null)},
+ PolarChart: jest.fn(() => null),
}));
// Provide a default global fetch mock for tests that do not explicitly set it up
diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts
index 49701c9c77bb..10955574c996 100644
--- a/jest/setupAfterEnv.ts
+++ b/jest/setupAfterEnv.ts
@@ -10,11 +10,6 @@ import mockUseSingleExecution from '../tests/utils/mockUseSingleExecution';
jest.useRealTimers();
-jest.mock('@hooks/useAIFeaturesPromoModal', () => ({
- __esModule: true,
- default: jest.fn(),
-}));
-
// The real hook relies on real navigation transitions to settle `isExecuting` (see
// `runAfterPredictedTransition`/`TransitionTracker`), which don't happen in unit tests and would
// otherwise leave buttons stuck disabled. Mocked globally so no test needs to import the navigation
diff --git a/knip.json b/knip.json
index 0f5564cf69bf..730d221fe72e 100644
--- a/knip.json
+++ b/knip.json
@@ -41,6 +41,7 @@
"ignore": [
".github/actions/**/index.js",
"tests/perf-test/**",
+ "tests/perf-local/**",
"web/snippets/gib.js",
"web/cloudflare-worker/**",
"src/libs/actions/connections/FinancialForce.ts",
diff --git a/package-lock.json b/package-lock.json
index 7ab9deb59a10..7a9cc581a99e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.5.8-4",
+ "version": "9.5.16-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.5.8-4",
+ "version": "9.5.16-1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -152,7 +152,7 @@
"react-web-config": "^1.0.0",
"react-webcam": "^7.1.1",
"scheduler": "0.27.0",
- "victory-native": "^41.21.0",
+ "victory-native": "^42.0.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
@@ -166,9 +166,9 @@
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@callstack/reassure-compare": "^1.0.0-rc.4",
- "@callstack/repack": "5.3.0",
- "@callstack/repack-plugin-expo-modules": "5.3.0",
- "@callstack/repack-plugin-reanimated": "5.3.0",
+ "@callstack/repack": "5.4.1",
+ "@callstack/repack-plugin-expo-modules": "5.4.1",
+ "@callstack/repack-plugin-reanimated": "5.4.1",
"@dword-design/eslint-plugin-import-alias": "^5.0.0",
"@fullstory/babel-plugin-annotate-react": "^2.4.0",
"@fullstory/babel-plugin-react-native": "^1.7.1",
@@ -198,6 +198,7 @@
"@rsdoctor/rspack-plugin": "^1.5.17",
"@rspack/core": "^2.1.2",
"@rspack/plugin-react-refresh": "^2.0.2",
+ "@sentry/browser-utils": "10.75.0",
"@sentry/cli": "3.3.0",
"@sentry/webpack-plugin": "5.2.0",
"@storybook/addon-a11y": "10.4.6",
@@ -295,8 +296,8 @@
},
"engines": {
"bun": "1.3.14",
- "node": "26.5.0",
- "npm": "11.17.0"
+ "node": "26.12.1",
+ "npm": "11.20.0"
}
},
"modules/background-task": {
@@ -4027,13 +4028,13 @@
}
},
"node_modules/@callstack/repack": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/@callstack/repack/-/repack-5.3.0.tgz",
- "integrity": "sha512-/5pnG6bz+nkV8DkVZfRDpRSVMUU+znLEfKy49HQgxsJx5vxlxL3MV7Pn2+APetitB9NEGFP+GQApj5hesUKQCw==",
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/@callstack/repack/-/repack-5.4.1.tgz",
+ "integrity": "sha512-vlpXdlVLiu9mi2ca3TMBv1MydnTGX789EHTDQ7RMBZO43DHx6RDrWh5snzbWclhy3+HmvdUkKA4eyYduRoDWUg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@callstack/repack-dev-server": "5.3.0",
+ "@callstack/repack-dev-server": "5.4.1",
"@discoveryjs/json-ext": "^0.5.7",
"@rspack/plugin-react-refresh": "1.0.0",
"babel-loader": "^9.2.1",
@@ -4081,9 +4082,9 @@
}
},
"node_modules/@callstack/repack-dev-server": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/@callstack/repack-dev-server/-/repack-dev-server-5.3.0.tgz",
- "integrity": "sha512-WW5onAddhO12wM59VCCpLQYn7NIPZc2HvM+mmcTvJsi8k3ggeW61pYsOnnX6GMNWmy/2Wu4AiecouQa4+zGuwA==",
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/@callstack/repack-dev-server/-/repack-dev-server-5.4.1.tgz",
+ "integrity": "sha512-aRXPBx1ZRWuO9m3JVI5RWXO9GqxTLRX1Z3Ca+T724CQqqj/VK7q91+uEc7ReqiqHKrCMYwTY2CM8lNpZDKggUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4183,22 +4184,22 @@
}
},
"node_modules/@callstack/repack-plugin-expo-modules": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/@callstack/repack-plugin-expo-modules/-/repack-plugin-expo-modules-5.3.0.tgz",
- "integrity": "sha512-4tnnb4ujLckWqYQOAzPIeOM9vWvVPdzS4SD+y5kjiFckVu0N6COzF29gl3EYZfJwKLU4tGkPqUqfvIa60HzQaQ==",
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/@callstack/repack-plugin-expo-modules/-/repack-plugin-expo-modules-5.4.1.tgz",
+ "integrity": "sha512-YCCQXbjdnI3euHZYcpCMSngwBVovNFNUfD3HOsntAzCEyClpU1DVg2kWdI9wM9ZsTUPRr/eH49L2KZ1xyyzzOA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
- "@callstack/repack": "^5.3.0"
+ "@callstack/repack": "^5.4.1"
}
},
"node_modules/@callstack/repack-plugin-reanimated": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/@callstack/repack-plugin-reanimated/-/repack-plugin-reanimated-5.3.0.tgz",
- "integrity": "sha512-TtuVhhAlvLdg+wrHf8k15Q5zNLiemOuFavsFiOQQhQP5vX299eEp2lw/1tr25vYsdoGvdc0Na0BsePRIF4ujGg==",
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/@callstack/repack-plugin-reanimated/-/repack-plugin-reanimated-5.4.1.tgz",
+ "integrity": "sha512-qwiiLlqb1xWf3K2Qv3yVO/ujnkaTOkGSg2gPq4+x8IcFqZRftiW3qlZYTRN913OXqB7e3ZajOepwDUUMWmZJCg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4209,7 +4210,7 @@
},
"peerDependencies": {
"@babel/core": "^7.20",
- "@callstack/repack": "^5.3.0"
+ "@callstack/repack": "^5.4.1"
}
},
"node_modules/@callstack/repack-plugin-reanimated/node_modules/semver": {
@@ -8613,9 +8614,9 @@
"license": "MIT"
},
"node_modules/@fastify/proxy-addr": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
- "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.1.tgz",
+ "integrity": "sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==",
"dev": true,
"funding": [
{
@@ -8634,9 +8635,9 @@
}
},
"node_modules/@fastify/sensible": {
- "version": "6.0.5",
- "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.5.tgz",
- "integrity": "sha512-v4mGejwT6EJA2zd2rS6XwlyUSva2ILsqzG0aGkyrlS7l2jIpUgFACclSPN3kDijX//cf8+GutRLhJVKx3ae2Cw==",
+ "version": "6.0.6",
+ "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.6.tgz",
+ "integrity": "sha512-2OhR9prEDmjeVSrBUBXgP9e595WWrBGQxjo/JMweA7DgJ5gseotN0OhRWZLom1nnmFkLywkccdQc10ihFtT6fw==",
"dev": true,
"funding": [
{
@@ -22645,7 +22646,9 @@
}
},
"node_modules/bytes": {
- "version": "3.0.0",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -23347,15 +23350,18 @@
}
},
"node_modules/compression": {
- "version": "1.7.4",
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
+ "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
"license": "MIT",
"dependencies": {
- "accepts": "~1.3.5",
- "bytes": "3.0.0",
- "compressible": "~2.0.16",
+ "bytes": "3.1.2",
+ "compressible": "~2.0.18",
"debug": "2.6.9",
- "on-headers": "~1.0.2",
- "safe-buffer": "5.1.2",
+ "destroy": "1.2.0",
+ "negotiator": "~0.6.4",
+ "on-headers": "~1.1.0",
+ "safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
@@ -23373,6 +23379,35 @@
"version": "2.0.0",
"license": "MIT"
},
+ "node_modules/compression/node_modules/negotiator": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"devOptional": true,
@@ -27204,9 +27239,9 @@
}
},
"node_modules/fast-uri": {
- "version": "4.1.4",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
- "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
+ "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
"dev": true,
"funding": [
{
@@ -27262,9 +27297,9 @@
}
},
"node_modules/fastify": {
- "version": "5.12.3",
- "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz",
- "integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==",
+ "version": "5.12.5",
+ "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz",
+ "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==",
"dev": true,
"funding": [
{
@@ -33806,9 +33841,9 @@
}
},
"node_modules/mathjs": {
- "version": "15.1.1",
- "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.1.1.tgz",
- "integrity": "sha512-rM668DTtpSzMVoh/cKAllyQVEbBApM5g//IMGD8vD7YlrIz9ITRr3SrdhjaDxcBNTdyETWwPebj2unZyHD7ZdA==",
+ "version": "15.2.0",
+ "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz",
+ "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -35031,7 +35066,9 @@
}
},
"node_modules/on-headers": {
- "version": "1.0.2",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -42208,9 +42245,9 @@
}
},
"node_modules/victory-native": {
- "version": "41.21.0",
- "resolved": "https://registry.npmjs.org/victory-native/-/victory-native-41.21.0.tgz",
- "integrity": "sha512-qyldgowkkiAWzN765sODzp4REh2MxBoex+DF5LerIpssTXpxGYEF21bIrhAoQJLDXiZ2rrCj1Dye+MNVYJd7vw==",
+ "version": "42.0.1",
+ "resolved": "https://registry.npmjs.org/victory-native/-/victory-native-42.0.1.tgz",
+ "integrity": "sha512-W3dNBMeRtZ8AQGBkV1PrYYKLFSstTy6uulmlJJFch4hvPX/R1xH1VJXzZopK6GGbcq/ejcr8WwntWwhqTo0lzA==",
"license": "MIT",
"dependencies": {
"d3-scale": "^4.0.2",
@@ -42220,11 +42257,11 @@
"react-fast-compare": "^3.2.2"
},
"peerDependencies": {
- "@shopify/react-native-skia": ">=1.2.3 <3.0.0",
+ "@shopify/react-native-skia": ">=2.6.0 <3.0.0",
"react": "*",
"react-native": "*",
"react-native-gesture-handler": ">=2.0.0",
- "react-native-reanimated": ">=3.0.0"
+ "react-native-reanimated": ">=3.19.1"
}
},
"node_modules/vlq": {
diff --git a/package.json b/package.json
index 69ac415da967..37db67de4dde 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.5.8-4",
+ "version": "9.5.16-1",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -48,6 +48,7 @@
"test:verbose": "TZ=utc NODE_OPTIONS=\"--experimental-vm-modules --max_old_space_size=4096\" JEST_VERBOSE=true jest",
"test:debug": "TZ=utc NODE_OPTIONS='--inspect-brk --experimental-vm-modules' jest --runInBand",
"perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure",
+ "perf-local": "NODE_OPTIONS=--experimental-vm-modules npx reassure --testMatch '/tests/perf-local/**/*.perf-local.ts'",
"typecheck": "bun scripts/typecheck.ts",
"lint": "bun scripts/lint/index.ts",
"lint-changed": "./scripts/lintChanged.sh",
@@ -228,7 +229,7 @@
"react-web-config": "^1.0.0",
"react-webcam": "^7.1.1",
"scheduler": "0.27.0",
- "victory-native": "^41.21.0",
+ "victory-native": "^42.0.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
@@ -242,9 +243,9 @@
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@callstack/reassure-compare": "^1.0.0-rc.4",
- "@callstack/repack": "5.3.0",
- "@callstack/repack-plugin-expo-modules": "5.3.0",
- "@callstack/repack-plugin-reanimated": "5.3.0",
+ "@callstack/repack": "5.4.1",
+ "@callstack/repack-plugin-expo-modules": "5.4.1",
+ "@callstack/repack-plugin-reanimated": "5.4.1",
"@dword-design/eslint-plugin-import-alias": "^5.0.0",
"@fullstory/babel-plugin-annotate-react": "^2.4.0",
"@fullstory/babel-plugin-react-native": "^1.7.1",
@@ -274,6 +275,7 @@
"@rsdoctor/rspack-plugin": "^1.5.17",
"@rspack/core": "^2.1.2",
"@rspack/plugin-react-refresh": "^2.0.2",
+ "@sentry/browser-utils": "10.75.0",
"@sentry/cli": "3.3.0",
"@sentry/webpack-plugin": "5.2.0",
"@storybook/addon-a11y": "10.4.6",
@@ -459,7 +461,7 @@
},
"engines": {
"bun": "1.3.14",
- "node": "26.5.0",
- "npm": "11.17.0"
+ "node": "26.12.1",
+ "npm": "11.20.0"
}
}
diff --git a/patches/@callstack/repack/@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch b/patches/@callstack/repack/@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch
deleted file mode 100644
index a9ea9ca1aa0e..000000000000
--- a/patches/@callstack/repack/@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch
+++ /dev/null
@@ -1,48 +0,0 @@
-diff --git a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js
-index 0121929..7c2856b 100644
---- a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js
-+++ b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js
-@@ -17,6 +17,7 @@ class Compiler {
- statsCache = {};
- resolvers = {};
- progressSenders = {};
-+ lastProgress = {};
- isCompilationInProgress = false;
- // late-init
-
-@@ -24,7 +25,9 @@ class Compiler {
- this.reporter = reporter;
- this.rootDir = rootDir;
- const handler = (platform, value) => {
-- const percentage = Math.floor(value * 100);
-+ const rawPercentage = Math.floor(value * 100);
-+ const percentage = Math.max(rawPercentage, this.lastProgress[platform] ?? 0);
-+ this.lastProgress[platform] = percentage;
- this.progressSenders[platform]?.forEach(sendProgress => {
- sendProgress({
- completed: percentage,
-@@ -36,7 +39,7 @@ class Compiler {
- message: [{
- progress: {
- platform,
-- value
-+ value: percentage / 100
- }
- }],
- timestamp: Date.now(),
-@@ -78,6 +81,7 @@ class Compiler {
- setupCompiler() {
- this.compiler.hooks.watchRun.tap('repack:watch', () => {
- this.isCompilationInProgress = true;
-+ this.lastProgress = {};
- this.platforms.forEach(platform => {
- if (platform === 'android') {
- void (0, _index2.runAdbReverse)({
-@@ -96,6 +100,7 @@ class Compiler {
- });
- this.compiler.hooks.invalid.tap('repack:invalid', () => {
- this.isCompilationInProgress = true;
-+ this.lastProgress = {};
- this.platforms.forEach(platform => {
- this.devServerContext.notifyBuildStart(platform);
- this.devServerContext.broadcastToHmrClients({
diff --git a/patches/@callstack/repack/@callstack+repack+5.3.0+002+lazy-platform-compilation.patch b/patches/@callstack/repack/@callstack+repack+5.3.0+002+lazy-platform-compilation.patch
deleted file mode 100644
index 9865f8152739..000000000000
--- a/patches/@callstack/repack/@callstack+repack+5.3.0+002+lazy-platform-compilation.patch
+++ /dev/null
@@ -1,358 +0,0 @@
-diff --git a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.d.ts b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.d.ts
-index 26d726c..d8999a0 100644
---- a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.d.ts
-+++ b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.d.ts
-@@ -13,8 +13,11 @@ export declare class Compiler {
- statsCache: Record;
- resolvers: Record void>>;
- progressSenders: Record;
-- isCompilationInProgress: boolean;
-+ isCompilationInProgress: Record;
- devServerContext: Server.DelegateContext;
-+ private pendingCompilations;
-+ private activePlatforms;
-+ private isClosed;
- constructor(configs: MultiRspackOptions, reporter: Reporter, rootDir: string);
- get devServerOptions(): import("@rspack/core").DevServer;
- get watchOptions(): import("@rspack/core").WatchOptions;
-@@ -23,7 +26,10 @@ export declare class Compiler {
- private removeProgressSender;
- setDevServerContext(ctx: Server.DelegateContext): void;
- private setupCompiler;
-+ private setupChildCompilerHooks;
-+ private activatePlatform;
- start(): void;
-+ close(callback?: (error?: Error | null) => void): void;
- getAsset(filename: string, platform: string, sendProgress?: SendProgress): Promise;
- getSource(filename: string, platform: string | undefined, sendProgress?: SendProgress): Promise;
- getSourceMap(filename: string, platform: string | undefined): Promise;
-diff --git a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js
-index 7c2856b..cabce76 100644
---- a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js
-+++ b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js
-@@ -18,13 +18,20 @@ class Compiler {
- resolvers = {};
- progressSenders = {};
- lastProgress = {};
-- isCompilationInProgress = false;
-+ isCompilationInProgress = {};
- // late-init
-
-+ pendingCompilations = new Map();
-+ activePlatforms = new Set();
-+ isClosed = false;
-+
- constructor(configs, reporter, rootDir) {
- this.reporter = reporter;
- this.rootDir = rootDir;
- const handler = (platform, value) => {
-+ // An unrequested platform is parked, so its progress must not reach the terminal or
-+ // the device: it would drive a second progress bar beside the platform in use.
-+ if (!this.activePlatforms.has(platform)) return;
- const rawPercentage = Math.floor(value * 100);
- const percentage = Math.max(rawPercentage, this.lastProgress[platform] ?? 0);
- this.lastProgress[platform] = percentage;
-@@ -79,43 +86,66 @@ class Compiler {
- this.devServerContext = ctx;
- }
- setupCompiler() {
-- this.compiler.hooks.watchRun.tap('repack:watch', () => {
-- this.isCompilationInProgress = true;
-- this.lastProgress = {};
-- this.platforms.forEach(platform => {
-- if (platform === 'android') {
-- void (0, _index2.runAdbReverse)({
-- port: this.devServerContext.options.port,
-- logger: this.devServerContext.log
-- });
-+ // rspack's MultiCompiler fires the aggregated `done` only when every child has reported
-+ // (@rspack/core: `doneCompilers === this.compilers.length`). A parked child never reports, so
-+ // that event stays pending and the server never settles. Upstream moves this bookkeeping onto
-+ // the child compilers for the same reason.
-+ for (const childCompiler of this.compiler.compilers) {
-+ this.setupChildCompilerHooks(childCompiler);
-+ }
-+ }
-+ setupChildCompilerHooks(childCompiler) {
-+ const platform = childCompiler.options.name;
-+ childCompiler.hooks.watchRun.tapAsync('repack:lazy-compilation', (_compiler, done) => {
-+ if (this.activePlatforms.has(platform)) {
-+ done();
-+ return;
-+ }
-+ this.pendingCompilations.set(platform, () => {
-+ // Exclude time spent waiting for the platform to be requested and
-+ // avoid replaying file changes that happened before that request.
-+ if (childCompiler.watching) {
-+ const startTime = Date.now();
-+ childCompiler.watching.startTime = startTime;
-+ childCompiler.watching.lastWatcherStartTime = startTime;
- }
-- this.devServerContext.notifyBuildStart(platform);
-- this.devServerContext.broadcastToHmrClients({
-- action: 'compiling',
-- body: {
-- name: platform
-- }
-- });
-+ done();
- });
- });
-- this.compiler.hooks.invalid.tap('repack:invalid', () => {
-- this.isCompilationInProgress = true;
-- this.lastProgress = {};
-- this.platforms.forEach(platform => {
-- this.devServerContext.notifyBuildStart(platform);
-- this.devServerContext.broadcastToHmrClients({
-- action: 'compiling',
-- body: {
-- name: platform
-- }
-+ childCompiler.hooks.watchRun.tap('repack:watch', () => {
-+ if (!this.activePlatforms.has(platform)) return;
-+ this.isCompilationInProgress[platform] = true;
-+ this.lastProgress[platform] = 0;
-+ if (platform === 'android') {
-+ void (0, _index2.runAdbReverse)({
-+ port: this.devServerContext.options.port,
-+ logger: this.devServerContext.log
- });
-+ }
-+ this.devServerContext.notifyBuildStart(platform);
-+ this.devServerContext.broadcastToHmrClients({
-+ action: 'compiling',
-+ body: {
-+ name: platform
-+ }
- });
- });
-- this.compiler.hooks.done.tap('repack:done', multiStats => {
-- const stats = multiStats.toJson({
-+ childCompiler.hooks.invalid.tap('repack:invalid', () => {
-+ if (!this.activePlatforms.has(platform)) return;
-+ this.isCompilationInProgress[platform] = true;
-+ this.lastProgress[platform] = 0;
-+ this.devServerContext.notifyBuildStart(platform);
-+ this.devServerContext.broadcastToHmrClients({
-+ action: 'compiling',
-+ body: {
-+ name: platform
-+ }
-+ });
-+ });
-+ childCompiler.hooks.done.tap('repack:done', stats => {
-+ const childStats = stats.toJson({
- all: false,
- assets: true,
-- children: true,
- outputPath: true,
- timings: true,
- hash: true,
-@@ -123,49 +153,46 @@ class Compiler {
- warnings: true
- });
- try {
-- stats.children.map(childStats => {
-- const platform = childStats.name;
-- this.devServerContext.broadcastToHmrClients({
-- action: 'hash',
-- body: {
-- name: platform,
-- hash: childStats.hash
-- }
-- });
-- this.statsCache[platform] = childStats;
-- const assets = childStats.assets;
-- this.assetsCache[platform] = assets.filter(asset => asset.type === 'asset').reduce((acc, {
-- name,
-+ this.devServerContext.broadcastToHmrClients({
-+ action: 'hash',
-+ body: {
-+ name: platform,
-+ hash: childStats.hash
-+ }
-+ });
-+ this.statsCache[platform] = childStats;
-+ const assets = childStats.assets;
-+ this.assetsCache[platform] = assets.filter(asset => asset.type === 'asset').reduce((acc, {
-+ name,
-+ info,
-+ size
-+ }) => {
-+ const assetPath = _nodePath.default.join(childStats.outputPath, name);
-+ const data = this.filesystem.readFileSync(assetPath);
-+ const asset = {
-+ data,
- info,
- size
-- }) => {
-- const assetPath = _nodePath.default.join(childStats.outputPath, name);
-- const data = this.filesystem.readFileSync(assetPath);
-- const asset = {
-- data,
-- info,
-- size
-- };
-- acc[(0, _index.adaptFilenameToPlatform)(name)] = asset;
-- if (info.related?.sourceMap) {
-- const sourceMapName = Array.isArray(info.related.sourceMap) ? info.related.sourceMap[0] : info.related.sourceMap;
-- const sourceMapPath = _nodePath.default.join(childStats.outputPath, sourceMapName);
-- const sourceMapData = this.filesystem.readFileSync(sourceMapPath);
-- const sourceMapAsset = {
-- data: sourceMapData,
-- info: {
-- hotModuleReplacement: info.hotModuleReplacement,
-- size: sourceMapData.length
-- },
-+ };
-+ acc[(0, _index.adaptFilenameToPlatform)(name)] = asset;
-+ if (info.related?.sourceMap) {
-+ const sourceMapName = Array.isArray(info.related.sourceMap) ? info.related.sourceMap[0] : info.related.sourceMap;
-+ const sourceMapPath = _nodePath.default.join(childStats.outputPath, sourceMapName);
-+ const sourceMapData = this.filesystem.readFileSync(sourceMapPath);
-+ const sourceMapAsset = {
-+ data: sourceMapData,
-+ info: {
-+ hotModuleReplacement: info.hotModuleReplacement,
- size: sourceMapData.length
-- };
-- acc[(0, _index.adaptFilenameToPlatform)(sourceMapName)] = sourceMapAsset;
-- }
-- return acc;
-- },
-- // keep old assets
-- this.assetsCache[platform] ?? {});
-- });
-+ },
-+ size: sourceMapData.length
-+ };
-+ acc[(0, _index.adaptFilenameToPlatform)(sourceMapName)] = sourceMapAsset;
-+ }
-+ return acc;
-+ },
-+ // keep old assets
-+ this.assetsCache[platform] ?? {});
- } catch (error) {
- this.reporter.process({
- type: 'error',
-@@ -174,32 +201,41 @@ class Compiler {
- message: ['An error occured while processing assets from compilation:', String(error)]
- });
- }
-- this.isCompilationInProgress = false;
-- stats.children?.forEach(childStats => {
-- const platform = childStats.name;
-- const time = childStats.time;
-- this.callPendingResolvers(platform);
-- this.devServerContext.notifyBuildEnd(platform);
-- this.devServerContext.broadcastToHmrClients({
-- action: 'ok',
-- body: {
-- name: platform
-+ this.isCompilationInProgress[platform] = false;
-+ this.callPendingResolvers(platform);
-+ this.devServerContext.notifyBuildEnd(platform);
-+ this.devServerContext.broadcastToHmrClients({
-+ action: 'ok',
-+ body: {
-+ name: platform
-+ }
-+ });
-+ this.reporter.process({
-+ issuer: 'DevServer',
-+ message: [{
-+ progress: {
-+ platform,
-+ time: childStats.time
- }
-- });
-- this.reporter.process({
-- issuer: 'DevServer',
-- message: [{
-- progress: {
-- platform,
-- time
-- }
-- }],
-- timestamp: Date.now(),
-- type: 'progress'
-- });
-+ }],
-+ timestamp: Date.now(),
-+ type: 'progress'
- });
- });
- }
-+ activatePlatform(platform) {
-+ if (!this.platforms.includes(platform)) {
-+ throw new _index.CLIError(`Unrecognized platform: ${platform}`);
-+ }
-+ if (this.activePlatforms.has(platform)) return;
-+ this.activePlatforms.add(platform);
-+ this.isCompilationInProgress[platform] = true;
-+ const resumeCompilation = this.pendingCompilations.get(platform);
-+ if (resumeCompilation) {
-+ this.pendingCompilations.delete(platform);
-+ resumeCompilation();
-+ }
-+ }
- start() {
- this.compiler.watch(this.watchOptions, error => {
- if (!error) return;
-@@ -208,14 +244,33 @@ class Compiler {
- });
- });
- }
-+ close(callback = () => {}) {
-+ this.isClosed = true;
-+ const error = new Error('Compiler closed before compilation completed');
-+ this.platforms.forEach(platform => {
-+ this.callPendingResolvers(platform, error);
-+ });
-+
-+ // Resume pending compilations so the Watching instances can be closed
-+ for (const resumeCompilation of this.pendingCompilations.values()) {
-+ resumeCompilation();
-+ }
-+ this.pendingCompilations.clear();
-+ this.compiler.close(callback);
-+ }
- async getAsset(filename, platform, sendProgress) {
-+ if (this.isClosed) {
-+ throw new Error('Compiler closed before compilation completed');
-+ }
-+ this.activatePlatform(platform);
-+
- // Return file from assetsCache if exists
- const fileFromCache = this.assetsCache[platform]?.[filename];
- if (fileFromCache) {
- return fileFromCache;
- }
- this.addProgressSender(platform, sendProgress);
-- if (!this.isCompilationInProgress) {
-+ if (!this.isCompilationInProgress[platform]) {
- this.removeProgressSender(platform, sendProgress);
- return Promise.reject(new Error(`File ${filename} for ${platform} not found in compilation assets (no compilation in progress)`));
- }
-diff --git a/node_modules/@callstack/repack/dist/commands/rspack/start.js b/node_modules/@callstack/repack/dist/commands/rspack/start.js
-index cc57d82..6ae7930 100644
---- a/node_modules/@callstack/repack/dist/commands/rspack/start.js
-+++ b/node_modules/@callstack/repack/dist/commands/rspack/start.js
-@@ -191,7 +191,15 @@ async function start(_, cliConfig, args) {
- return {
- stop: async () => {
- reporter.stop();
-- await stop();
-+ try {
-+ // A platform nobody asked for is parked mid watch-run; resume it so the Watching
-+ // instances can shut down instead of hanging (callstack/repack#1430).
-+ await new Promise((resolve, reject) => {
-+ compiler.close(error => (error ? reject(error) : resolve()));
-+ });
-+ } finally {
-+ await stop();
-+ }
- }
- };
- }
-\ No newline at end of file
diff --git a/patches/@callstack/repack/@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch b/patches/@callstack/repack/@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch
new file mode 100644
index 000000000000..1e29ef76c8e9
--- /dev/null
+++ b/patches/@callstack/repack/@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch
@@ -0,0 +1,46 @@
+--- a/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js
++++ b/node_modules/@callstack/repack/dist/commands/rspack/Compiler.js
+@@ -17,6 +17,7 @@
+ statsCache = {};
+ resolvers = {};
+ progressSenders = {};
++ lastProgress = {};
+ isCompilationInProgress = {};
+ // late-init
+
+@@ -28,7 +29,9 @@
+ this.rootDir = rootDir;
+ const handler = (platform, value) => {
+ if (!this.activePlatforms.has(platform)) return;
+- const percentage = Math.floor(value * 100);
++ const rawPercentage = Math.floor(value * 100);
++ const percentage = Math.max(rawPercentage, this.lastProgress[platform] ?? 0);
++ this.lastProgress[platform] = percentage;
+ this.progressSenders[platform]?.forEach(sendProgress => {
+ sendProgress({
+ completed: percentage,
+@@ -40,7 +43,7 @@
+ message: [{
+ progress: {
+ platform,
+- value
++ value: percentage / 100
+ }
+ }],
+ timestamp: Date.now(),
+@@ -104,6 +107,7 @@
+ childCompiler.hooks.watchRun.tap('repack:watch', () => {
+ if (!this.activePlatforms.has(platform)) return;
+ this.isCompilationInProgress[platform] = true;
++ this.lastProgress[platform] = 0;
+ if (platform === 'android') {
+ void (0, _index2.runAdbReverse)({
+ port: this.devServerContext.options.port,
+@@ -121,6 +125,7 @@
+ childCompiler.hooks.invalid.tap('repack:invalid', () => {
+ if (!this.activePlatforms.has(platform)) return;
+ this.isCompilationInProgress[platform] = true;
++ this.lastProgress[platform] = 0;
+ this.devServerContext.notifyBuildStart(platform);
+ this.devServerContext.broadcastToHmrClients({
+ action: 'compiling',
diff --git a/patches/@callstack/repack/details.md b/patches/@callstack/repack/details.md
index 66ab25dec30b..ad585c4c8c31 100644
--- a/patches/@callstack/repack/details.md
+++ b/patches/@callstack/repack/details.md
@@ -1,6 +1,6 @@
# `@callstack/repack` patches
-### [@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch](@callstack+repack+5.3.0+001+clamp-dev-server-progress.patch)
+### [@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch](@callstack+repack+5.4.1+001+clamp-dev-server-progress.patch)
- Reason:
@@ -12,32 +12,12 @@
and resets it on the `watchRun` and `invalid` hooks when a new compilation starts.
```
-- Upstream PR/issue: https://github.com/callstack/repack/issues/1459 - filed with a standalone reproduction. Delete this patch once a release clamps the forwarded percentage.
+- Rewritten against 5.4.1 on the bump from 5.3.0. The 5.3.0 version of this patch targeted the pre-lazy-compilation `Compiler.js` and no longer applies: upstream released per-platform lazy compilation, so the progress handler now sits behind an `activePlatforms` guard and `isCompilationInProgress` is a per-platform record. Same clamp, new anchors.
+- Upstream PR/issue: https://github.com/callstack/repack/issues/1459 - filed with a standalone reproduction; closed as will-not-fix, so this stays until upstream changes its mind.
- E/App issue: None. Reported during review of the PR below.
- PR Introducing Patch: https://github.com/Expensify/App/pull/99407
-### [@callstack+repack+5.3.0+002+lazy-platform-compilation.patch](@callstack+repack+5.3.0+002+lazy-platform-compilation.patch)
+Removed on the bump to `@callstack/repack@5.4.1`:
-- Reason:
-
- ```
- Back-port of upstream lazy platform compilation. On 5.3.0 the dev server starts every configured
- platform compiling immediately, so a machine running one app also pays for a full compile of the
- other platform (measured: iOS 188.3s and Android 190.7s, both compiled although only iOS was ever
- requested). Narrowing the server to one platform does not fix it: that server cannot serve the
- other platform's bundle at all, so a second app reusing the running server gets nothing.
-
- This patch keeps every platform configured and parks each child compiler at its first watch run
- until something actually asks for that platform's bundle. A platform is activated by
- `getAsset`, i.e. by a bundle request, and only activated platforms emit progress and build
- signals, so one progress stream serves the platform in use. Bookkeeping moved from the
- MultiCompiler hooks to the child compilers because rspack only fires the aggregated `done` once
- every child has reported, and a parked child never reports. `close()` releases parked compilations
- before the compiler closes, as upstream does.
- ```
-
-- Also updates `dist/commands/rspack/Compiler.d.ts`: its declarations describe the pre-patch shape (`isCompilationInProgress: boolean`) and omit the new members.
-- Depends on `001`: both patches edit the progress handling in `dist/commands/rspack/Compiler.js`, and this one is authored against the post-`001` file. Keep them in numeric order.
-- Upstream PR/issue: https://github.com/callstack/repack/pull/1430 - merged into `main`, not in any release yet; staged for 5.4.0 by https://github.com/callstack/repack/pull/1441. Delete this patch when a release containing it is pinned.
-- E/App issue: None. Found while answering review on the PR below.
-- PR Introducing Patch: https://github.com/Expensify/App/pull/99407
+- `002+lazy-platform-compilation` — upstream released the feature (`dist/commands/rspack/Compiler.js` now parks each child compiler until `getAsset` requests that platform). Was https://github.com/callstack/repack/pull/1430.
+- `003+android-fonts-raw` and `004+android-keep-xml` — https://github.com/callstack/repack/issues/1472 fixed both: the assets loader emits `require()`'d font files to `res/raw` as well as `res/font` (`dist/loaders/assetsLoader/assetsLoader.js`), and the assets copy step writes `res/raw/keep.xml` with `tools:keep` for every copied resource (`dist/plugins/utils/AssetsCopyProcessor.js`), matching Metro. Verified against the published 5.4.1 tarball, and on device: Expensify/App#103243 shipped an AdHoc build on the 5.4.1 canary where fonts resolve from `raw` and all 765 `raw` assets plus 22 drawables survive `shrinkResources=true`. E/App issue: https://github.com/Expensify/App/issues/102619.
diff --git a/patches/@rnmapbox/maps/@rnmapbox+maps+10.3.2+001+immediate-follow-camera-transition.patch b/patches/@rnmapbox/maps/@rnmapbox+maps+10.3.2+001+immediate-follow-camera-transition.patch
index c572e6089d3e..8ae94b426eb1 100644
--- a/patches/@rnmapbox/maps/@rnmapbox+maps+10.3.2+001+immediate-follow-camera-transition.patch
+++ b/patches/@rnmapbox/maps/@rnmapbox+maps+10.3.2+001+immediate-follow-camera-transition.patch
@@ -125,6 +125,18 @@ index c8e61a0..d3e50b9 100644
followUserMode: followUserMode,
followZoomLevel: followZoomLevel,
followPitch: followPitch,
+diff --git a/node_modules/@rnmapbox/maps/lib/module/specs/RNMBXCameraNativeComponent.ts b/node_modules/@rnmapbox/maps/lib/module/specs/RNMBXCameraNativeComponent.ts
+index 79fb0d0..acd20c1 100644
+--- a/node_modules/@rnmapbox/maps/lib/module/specs/RNMBXCameraNativeComponent.ts
++++ b/node_modules/@rnmapbox/maps/lib/module/specs/RNMBXCameraNativeComponent.ts
+@@ -28,6 +28,7 @@ export interface NativeProps extends ViewProps {
+ userTrackingMode?: OptionalProp;
+
+ followUserLocation?: OptionalProp;
++ followUserLocationUseImmediateTransition?: OptionalProp;
+ followUserMode?: OptionalProp;
+ followZoomLevel?: OptionalProp;
+ followPitch?: OptionalProp;
diff --git a/node_modules/@rnmapbox/maps/src/components/Camera.tsx b/node_modules/@rnmapbox/maps/src/components/Camera.tsx
index c8e61a0..d3e50b9 100644
--- a/node_modules/@rnmapbox/maps/src/components/Camera.tsx
diff --git a/patches/@shopify/react-native-skia/details.md b/patches/@shopify/react-native-skia/details.md
index b4741c128c44..d09ae410b58d 100644
--- a/patches/@shopify/react-native-skia/details.md
+++ b/patches/@shopify/react-native-skia/details.md
@@ -156,9 +156,9 @@
Untransformed canvases keep the previous ratio, so nothing else changes.
```
-- Upstream PR/issue:
+- Upstream PR/issue: https://github.com/Shopify/react-native-skia/issues/4084 was closed as not a Skia bug: the maintainer considers a canvas painted under a CSS transform an app concern, and said neither https://github.com/Shopify/react-native-skia/pull/4085 (this approach) nor https://github.com/Shopify/react-native-skia/pull/4105 (an opt-in pixelDensity prop) is the way to fix it. No upstream fix is planned, so this patch has to be carried across Skia upgrades unless the charts stop being fitted with a transform.
- E/App issue: https://github.com/Expensify/App/issues/95221
-- PR introducing patch:
+- PR introducing patch: https://github.com/Expensify/App/pull/101291
### [@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch](@shopify+react-native-skia+2.11.2+005+load-skia-web-fail-closed.patch)
diff --git a/patches/react-native-gesture-handler/details.md b/patches/react-native-gesture-handler/details.md
new file mode 100644
index 000000000000..2cac998214b0
--- /dev/null
+++ b/patches/react-native-gesture-handler/details.md
@@ -0,0 +1,17 @@
+# `react-native-gesture-handler` patches
+
+### [react-native-gesture-handler+3.3.0+001+start-hover-on-pointer-move.patch](react-native-gesture-handler+3.3.0+001+start-hover-on-pointer-move.patch)
+
+- Reason:
+
+ ```
+ On web the hover gesture begins only on the DOM pointerenter event. The Gesture Handler 3 hook detector attaches
+ its listeners in an effect, after paint, so when the view mounts under a pointer that is already there (e.g. quickly
+ switching charts in the Insights card), pointerenter fires before the listeners exist and the hover never begins
+ until the pointer leaves the view and enters it again. The patch begins the hover on the first pointer move over
+ the view when it has not begun yet.
+ ```
+
+- Upstream PR/issue: https://github.com/software-mansion/react-native-gesture-handler/pull/4624
+- E/App issue: https://github.com/Expensify/App/issues/102782
+- PR introducing patch: https://github.com/Expensify/App/pull/103457
diff --git a/patches/react-native-gesture-handler/react-native-gesture-handler+3.3.0+001+start-hover-on-pointer-move.patch b/patches/react-native-gesture-handler/react-native-gesture-handler+3.3.0+001+start-hover-on-pointer-move.patch
new file mode 100644
index 000000000000..74729022523b
--- /dev/null
+++ b/patches/react-native-gesture-handler/react-native-gesture-handler+3.3.0+001+start-hover-on-pointer-move.patch
@@ -0,0 +1,35 @@
+diff --git a/node_modules/react-native-gesture-handler/lib/module/web/handlers/HoverGestureHandler.js b/node_modules/react-native-gesture-handler/lib/module/web/handlers/HoverGestureHandler.js
+index 7ba7b7b..6f0567f 100644
+--- a/node_modules/react-native-gesture-handler/lib/module/web/handlers/HoverGestureHandler.js
++++ b/node_modules/react-native-gesture-handler/lib/module/web/handlers/HoverGestureHandler.js
+@@ -33,6 +33,12 @@ export default class HoverGestureHandler extends GestureHandler {
+ this.end();
+ }
+ onPointerMove(event) {
++ // `pointerenter` is missed when the pointer is already over the view before the listeners are attached
++ // (the v3 detector attaches them in an effect, after paint), so start hovering on the first move instead.
++ if (this.state === State.UNDETERMINED) {
++ this.onPointerMoveOver(event);
++ return;
++ }
+ this.tracker.track(event);
+ this.stylusData = event.stylusData;
+ super.onPointerMove(event);
+diff --git a/node_modules/react-native-gesture-handler/src/web/handlers/HoverGestureHandler.ts b/node_modules/react-native-gesture-handler/src/web/handlers/HoverGestureHandler.ts
+index 0f13b55..46967aa 100644
+--- a/node_modules/react-native-gesture-handler/src/web/handlers/HoverGestureHandler.ts
++++ b/node_modules/react-native-gesture-handler/src/web/handlers/HoverGestureHandler.ts
+@@ -49,6 +49,13 @@ export default class HoverGestureHandler extends GestureHandler {
+ }
+
+ protected override onPointerMove(event: AdaptedEvent): void {
++ // `pointerenter` is missed when the pointer is already over the view before the listeners are attached
++ // (the v3 detector attaches them in an effect, after paint), so start hovering on the first move instead.
++ if (this.state === State.UNDETERMINED) {
++ this.onPointerMoveOver(event);
++ return;
++ }
++
+ this.tracker.track(event);
+ this.stylusData = event.stylusData;
+
diff --git a/patches/react-native-pdf/details.md b/patches/react-native-pdf/details.md
index af4fe90697b7..8f00de232f70 100644
--- a/patches/react-native-pdf/details.md
+++ b/patches/react-native-pdf/details.md
@@ -77,3 +77,20 @@
- Upstream PR/issue: https://github.com/wonday/react-native-pdf/issues/674
- E/App issue: https://github.com/Expensify/App/issues/101895
- PR introducing patch: https://github.com/Expensify/App/pull/102389
+
+
+### [react-native-pdf+7.0.2+006+fix-scale-changed-on-resize.patch](react-native-pdf+7.0.2+006+fix-scale-changed-on-resize.patch)
+
+- Reason:
+
+ ```
+ This patch stops onScaleChanged on Android from reporting a zoom change when the PDF view is resized, e.g. when rotating the device between portrait and landscape.
+
+ Android reported the scale as the drawn page width divided by the page width of the first draw. A resize changes the page width, so after rotating the scale was reported as e.g. 2 or 0.5 even though the user didn't zoom. The patch reports the viewer's zoom (getZoom()) instead, which is relative to the page size fitted to the current view size, and only emits the event when the zoom changes. This matches iOS, which recalculates its base scale factor for the new view size on every resize.
+
+ In the attachment carousel a scale different from 1 is treated as zoomed in, which hid the carousel arrows and disabled swiping between attachments after rotating.
+ ```
+
+- Upstream PR/issue: https://github.com/wonday/react-native-pdf/pull/1050, https://github.com/wonday/react-native-pdf/issues/1049
+- E/App issue: https://github.com/Expensify/App/issues/103207
+- PR introducing patch: https://github.com/Expensify/App/pull/103781
diff --git a/patches/react-native-pdf/react-native-pdf+7.0.2+006+fix-scale-changed-on-resize.patch b/patches/react-native-pdf/react-native-pdf+7.0.2+006+fix-scale-changed-on-resize.patch
new file mode 100644
index 000000000000..54f813c6333b
--- /dev/null
+++ b/patches/react-native-pdf/react-native-pdf+7.0.2+006+fix-scale-changed-on-resize.patch
@@ -0,0 +1,47 @@
+diff --git a/node_modules/react-native-pdf/android/src/main/java/org/wonday/pdf/PdfView.java b/node_modules/react-native-pdf/android/src/main/java/org/wonday/pdf/PdfView.java
+--- a/node_modules/react-native-pdf/android/src/main/java/org/wonday/pdf/PdfView.java
++++ b/node_modules/react-native-pdf/android/src/main/java/org/wonday/pdf/PdfView.java
+@@ -86,9 +86,7 @@
+ private boolean scrollEnabled = true;
+ private boolean enableRTL = false;
+
+- private float originalWidth = 0;
+- private float lastPageWidth = 0;
+- private float lastPageHeight = 0;
++ private float lastZoom = 0;
+
+ // used to store the parameters for `super.onSizeChanged`
+ private int oldW = 0;
+@@ -256,17 +254,16 @@
+
+ @Override
+ public void onLayerDrawn(Canvas canvas, float pageWidth, float pageHeight, int displayedPage){
+- if (originalWidth == 0) {
+- originalWidth = pageWidth;
+- }
+-
+- if (lastPageWidth>0 && lastPageHeight>0 && (pageWidth!=lastPageWidth || pageHeight!=lastPageHeight)) {
++ // Report the viewer's zoom instead of the page width relative to the first drawn page width.
++ // The page width also changes when the view is resized (e.g. on rotation), which isn't a zoom change.
++ float zoom = getZoom();
++ if (lastZoom>0 && zoom!=lastZoom) {
+ // maybe change by other instance, restore zoom setting
+ Constants.Pinch.MINIMUM_ZOOM = this.minScale;
+ Constants.Pinch.MAXIMUM_ZOOM = this.maxScale;
+
+ WritableMap event = Arguments.createMap();
+- event.putString("message", "scaleChanged|"+(pageWidth/originalWidth));
++ event.putString("message", "scaleChanged|"+zoom);
+ ThemedReactContext context = (ThemedReactContext) getContext();
+ EventDispatcher dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, getId());
+ int surfaceId = UIManagerHelper.getSurfaceId(this);
+@@ -284,8 +281,7 @@
+ // );
+ }
+
+- lastPageWidth = pageWidth;
+- lastPageHeight = pageHeight;
++ lastZoom = zoom;
+ }
+
+ @Override
diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md
index babf9982cb42..d903512dcac5 100644
--- a/patches/react-native-screens/details.md
+++ b/patches/react-native-screens/details.md
@@ -67,3 +67,40 @@
- Upstream PR/issue: https://github.com/software-mansion/react-native-screens/issues/4156, https://github.com/software-mansion/react-native-screens/pull/4157
- E/App issue: https://github.com/Expensify/App/issues/102046
- PR introducing patch: https://github.com/Expensify/App/pull/103126
+
+### [react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch](react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch)
+
+- Reason: The App hides the native tab bar on screens that are not a tab root, which React Navigation forwards to RNScreens as `tabBarHidden` (it derives the flag from `tabBarStyle.display === 'none'`). RNScreens calls UIKit's `setTabBarHidden:animated:` with `animated:NO`, so the bar blinks in and out instead of travelling with the screen that hid it. The patch animates the change once the bar is in a window, which lets UIKit run its own show/hide animation without animating the bar out on the first render. The pre-iOS 18 branch is left alone: it assigns `tabBar.hidden` directly and has no animated counterpart. **This patch can be removed on the bump to react-native-screens 4.29.0**, the first release with the upstream fix linked below.
+- Upstream PR/issue: fixed upstream by [software-mansion/react-native-screens#4632](https://github.com/software-mansion/react-native-screens/pull/4632), which animates by default behind a new `ios.tabBarHiddenAnimationEnabled` prop, and [#4673](https://github.com/software-mansion/react-native-screens/pull/4673), which skips the animation on the first render. Both are merged after the 4.28.0 release and labeled for the v4 backport.
+- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169)
+- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339)
+
+### [react-native-screens+4.28.0+003+android-tab-label-typography.patch](react-native-screens+4.28.0+003+android-tab-label-typography.patch)
+
+- Reason: Two changes to the Android tab labels, both in `TabsAppearanceApplicator`, which React Navigation has no option for.
+ - Bold selected label. The Android tab bar takes one font weight for every label, while the design marks the selected tab with a bold label, the same way the JS side bar does. Material's `BottomNavigationView` draws each item with two labels, a small one shown while unselected and a large one shown while selected. `updateFontStyles` sets the large label's typeface with `Typeface.BOLD`, the way Material applies its own bold, so Android picks the bold face of the same font family. Material's `setItemTextAppearanceActiveBoldEnabled` is not used for this: it re-applies Material's 12sp text size to the large label alone and recomputes the selected item's offset from the difference between the two labels' sizes, so every selected tab would sit higher than the others.
+ - Letter spacing. `updateFontStyles` replaces the typeface and size of Material's tab labels but keeps the rest of Material 3's `LabelMedium` text appearance, which tracks letters 0.5sp apart. With Expensify Neue the labels read too spread out next to the rest of the app, so the patch sets the letter spacing of both labels to 0.
+- Upstream PR/issue: not reported, because both changes are the App's typography (a bold selected label and its letter spacing) rather than a library defect, and RNScreens has no option for either.
+- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169)
+- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339)
+
+### [react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch](react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch)
+
+- Reason: The Android account tab shows the user's avatar. `TabsAppearanceApplicator` assigns `bottomNavigationView.itemIconTintList` unconditionally, and a `ColorStateList` tint is `SRC_IN`, so it flattens the avatar to a solid silhouette in the tint color. React Navigation's `tinted: false` only reaches iOS, since `getPlatformIcon` in `@react-navigation/bottom-tabs` always hands Android a plain `imageSource`. The patch drops the icon tint list so Android draws every icon bitmap as supplied. The App recolors the glyphs off-screen in Skia for both selection states, so they keep the design's colors. Label colors are untouched and keep coming from `tabBarItemTitleFontColor`.
+- Upstream PR/issue: not reported, because dropping the tint for every icon fits only a bar whose icons all arrive pre-colored, as the App's do. The disabled and focused icon colors go with it, which the App does not use.
+- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169)
+- PR introducing patch: [#101339](https://github.com/Expensify/App/pull/101339)
+
+### [react-native-screens+4.28.0+005+hidden-android-tab-bar-touches.patch](react-native-screens+4.28.0+005+hidden-android-tab-bar-touches.patch)
+
+- Reason: On Android, a hidden native tab bar is set to `View.GONE`. `TabsContainer` is a `FrameLayout`, which skips `GONE` children during layout, so the bar keeps the bounds it had while visible. React Native's `TouchTargetHelper` walks native children without checking visibility, so a touch inside those stale bounds targets the hidden bar instead of the React views drawn there. On a chat, that swallowed taps on the composer's emoji and + buttons while the keyboard was closed. The touch target search skips a child whose transform is non-invertible, so the patch scales the hidden bar to zero on the Y axis and back to one when it is shown. Its layout is left alone, so the bar's height and the insets derived from it do not change.
+- Upstream PR/issue: TBD
+- E/App issue: [#104226](https://github.com/Expensify/App/issues/104226)
+- PR introducing patch: [#104238](https://github.com/Expensify/App/pull/104238)
+
+### [react-native-screens+4.28.0+006+stack-scroll-to-top-uses-scroll-view-marker.patch](react-native-screens+4.28.0+006+stack-scroll-to-top-uses-scroll-view-marker.patch)
+
+- Reason: On iOS, a second tap on a native tab scrolls the tab's content to the top. When the tab's root is a native stack, as with the Inbox tab's split navigator, `RNSScreenStack` handles that tap and picks the scroll view by following each view's first native subview. On the Inbox the first one it reaches is the horizontal tab selector above the list. That selector is already at the top, so nothing scrolls. A `ScrollViewMarker` around the list registers the list's scroll view with the tabs screen, but the stack never reads it. The patch makes the stack scroll the marked scroll view while it shows a single screen, and fall back to the first-subview search otherwise, since the marker can sit on any screen of the stack. The marker only exists on iOS when `RNS_GAMMA_ENABLED=1` is set at `pod install`, which both Podfiles do.
+- Upstream PR/issue: [#4985](https://github.com/software-mansion/react-native-screens/pull/4985)
+- E/App issue: https://github.com/Expensify/App/issues/104284
+- PR introducing patch: [#104368](https://github.com/Expensify/App/pull/104368)
diff --git a/patches/react-native-screens/react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch b/patches/react-native-screens/react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch
new file mode 100644
index 000000000000..632508daac5b
--- /dev/null
+++ b/patches/react-native-screens/react-native-screens+4.28.0+002+animate-hiding-the-native-tab-bar.patch
@@ -0,0 +1,14 @@
+diff --git a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm
+--- a/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm
++++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabsHostComponentView.mm
+@@ -244,7 +244,9 @@
+ _tabBarHidden = newComponentProps.tabBarHidden;
+ #if RNS_IPHONE_OS_VERSION_AVAILABLE(18_0)
+ if (@available(iOS 18.0, *)) {
+- [_controller setTabBarHidden:_tabBarHidden animated:NO];
++ // Animated so the bar slides out with the screen that hid it instead of blinking away, except before the bar
++ // is on screen, where the first render would animate it out.
++ [_controller setTabBarHidden:_tabBarHidden animated:[[_controller tabBar] window] != nil];
+ } else
+ #endif // RNS_IPHONE_OS_VERSION_AVAILABLE(18_0)
+ {
diff --git a/patches/react-native-screens/react-native-screens+4.28.0+003+android-tab-label-typography.patch b/patches/react-native-screens/react-native-screens+4.28.0+003+android-tab-label-typography.patch
new file mode 100644
index 000000000000..a532d6a859e5
--- /dev/null
+++ b/patches/react-native-screens/react-native-screens+4.28.0+003+android-tab-label-typography.patch
@@ -0,0 +1,32 @@
+diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt
+index 10cd91b..6f059bd 100644
+--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt
++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt
+@@ -3,6 +3,7 @@ package com.swmansion.rnscreens.gamma.tabs.appearance
+ import android.annotation.SuppressLint
+ import android.content.Context
+ import android.content.res.ColorStateList
++import android.graphics.Typeface
+ import android.graphics.drawable.StateListDrawable
+ import android.util.TypedValue
+ import android.view.MenuItem
+@@ -161,13 +162,18 @@ internal class TabsAppearanceApplicator(
+ tabBarAppearance?.tabBarItemTitleLargeLabelFontSize?.takeIf { it > 0 }?.let { PixelUtil.toPixelFromSP(it) }
+ ?: context.resources.getDimension(R.dimen.design_bottom_navigation_text_size)
+
++ // Both labels drop the 0.5sp letter tracking of Material's text appearance, which reads wider than the app's labels.
+ // Inactive
+ smallLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, smallFontSize)
+ smallLabel.typeface = fontFamily
++ smallLabel.letterSpacing = 0f
+
+ // Active
++ // Bold is set here rather than with `setItemTextAppearanceActiveBoldEnabled`, which re-applies Material's text size
++ // to this label alone, so Material lifts the selected item by the size difference between the two labels.
+ largeLabel.setTextSize(TypedValue.COMPLEX_UNIT_PX, largeFontSize)
+- largeLabel.typeface = fontFamily
++ largeLabel.setTypeface(fontFamily, Typeface.BOLD)
++ largeLabel.letterSpacing = 0f
+ }
+ }
+
diff --git a/patches/react-native-screens/react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch b/patches/react-native-screens/react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch
new file mode 100644
index 000000000000..95dc7a725632
--- /dev/null
+++ b/patches/react-native-screens/react-native-screens+4.28.0+004+no-android-tab-icon-tint.patch
@@ -0,0 +1,33 @@
+diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt
+index 9238416..d7d2024 100644
+--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt
++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt
+@@ -64,24 +64,10 @@ internal class TabsAppearanceApplicator(
+ bottomNavigationView.itemTextColor = ColorStateList(states, fontColors)
+
+ // Icon color
+- val iconDisabledColor =
+- tabBarAppearance?.disabled?.tabBarItemIconColor
+- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant)
+-
+- val iconFocusedColor =
+- tabBarAppearance?.focused?.tabBarItemIconColor
+- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant)
+-
+- val iconSelectedColor =
+- tabBarAppearance?.selected?.tabBarItemIconColor
+- ?: resolveColorAttr(context, R.attr.colorOnSecondaryContainer)
+-
+- val iconNormalColor =
+- tabBarAppearance?.normal?.tabBarItemIconColor
+- ?: resolveColorAttr(context, R.attr.colorOnSurfaceVariant)
+-
+- val iconColors = intArrayOf(iconDisabledColor, iconSelectedColor, iconFocusedColor, iconNormalColor)
+- bottomNavigationView.itemIconTintList = ColorStateList(states, iconColors)
++ // Every tab icon arrives as a bitmap that already carries its own colors: the recolored glyph and the user's
++ // avatar on the account tab. A tint list paints over all of it with a single color, so no tint is applied and
++ // the bitmap is drawn as supplied, which is how `tinted: false` already behaves on iOS.
++ bottomNavigationView.itemIconTintList = null
+
+ // LabelVisibilityMode
+ // From docs: can be one of LABEL_VISIBILITY_AUTO, LABEL_VISIBILITY_SELECTED, LABEL_VISIBILITY_LABELED, or LABEL_VISIBILITY_UNLABELED
diff --git a/patches/react-native-screens/react-native-screens+4.28.0+005+hidden-android-tab-bar-touches.patch b/patches/react-native-screens/react-native-screens+4.28.0+005+hidden-android-tab-bar-touches.patch
new file mode 100644
index 000000000000..9eea6a155402
--- /dev/null
+++ b/patches/react-native-screens/react-native-screens+4.28.0+005+hidden-android-tab-bar-touches.patch
@@ -0,0 +1,14 @@
+diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt
+--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt
++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt
+@@ -36,6 +36,10 @@
+ isTabBarHidden: Boolean,
+ ) {
+ bottomNavigationView.isVisible = !isTabBarHidden
++ // A GONE child keeps the bounds it had while visible, and React Native's TouchTargetHelper does not check
++ // visibility, so the hidden bar would take touches meant for the React views drawn there. The touch target search
++ // skips a child with a non-invertible transform, so the hidden bar is scaled to zero, which leaves its layout as is.
++ bottomNavigationView.scaleY = if (isTabBarHidden) 0f else 1f
+ bottomNavigationView.setBackgroundColor(
+ tabBarAppearance?.tabBarBackgroundColor
+ ?: resolveColorAttr(context, R.attr.colorSurfaceContainer),
diff --git a/patches/react-native-screens/react-native-screens+4.28.0+006+stack-scroll-to-top-uses-scroll-view-marker.patch b/patches/react-native-screens/react-native-screens+4.28.0+006+stack-scroll-to-top-uses-scroll-view-marker.patch
new file mode 100644
index 000000000000..833db323d6bf
--- /dev/null
+++ b/patches/react-native-screens/react-native-screens+4.28.0+006+stack-scroll-to-top-uses-scroll-view-marker.patch
@@ -0,0 +1,18 @@
+diff --git a/node_modules/react-native-screens/ios/RNSScreenStack.mm b/node_modules/react-native-screens/ios/RNSScreenStack.mm
+--- a/node_modules/react-native-screens/ios/RNSScreenStack.mm
++++ b/node_modules/react-native-screens/ios/RNSScreenStack.mm
+@@ -143,8 +143,12 @@ namespace react = facebook::react;
+ tabScreenController.tabScreenComponentView.shouldUseRepeatedTabSelectionPopToRootSpecialEffect) {
+ return [[self popToRootViewControllerAnimated:true] count] > 0;
+ } else if (tabScreenController.tabScreenComponentView.shouldUseRepeatedTabSelectionScrollToTopSpecialEffect) {
+- UIScrollView *scrollView =
+- [RNSScrollViewFinder findScrollViewInFirstDescendantChainFrom:[[self topViewController] view]];
++ // A ScrollViewMarker registers its scroll view with the tabs screen, not with this stack, so it can be on any
++ // screen of the stack. It is used only while the stack shows a single screen.
++ UIScrollView *markedScrollView =
++ [[self viewControllers] count] == 1 ? [tabScreenController.tabScreenComponentView cachedContentScrollView] : nil;
++ UIScrollView *scrollView = markedScrollView
++ ?: [RNSScrollViewFinder findScrollViewInFirstDescendantChainFrom:[[self topViewController] view]];
+ return [scrollView rnscreens_scrollToTop];
+ }
+
diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch
new file mode 100644
index 000000000000..ee75c7d7fdea
--- /dev/null
+++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch
@@ -0,0 +1,13 @@
+diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
+index 1183c51..303635c 100644
+--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
++++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
+@@ -160,7 +160,7 @@ export function NativeBottomTabView({
+ ios: PlatformColor('label'),
+ default: colors.text
+ });
+- const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined;
++ const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? (typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined);
+ const onTransitionStart = ({
+ closing,
+ route
diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch
new file mode 100644
index 000000000000..8ed497d4f84d
--- /dev/null
+++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch
@@ -0,0 +1,173 @@
+diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
+index b087433de3b..f729d3ecbf1 100644
+--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
++++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
+@@ -4,11 +4,11 @@ import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@reac
+ import { CommonActions, NavigationMetaContext, StackActions, useTheme } from '@react-navigation/native';
+ import Color from 'color';
+ import * as React from 'react';
+-import { Platform, PlatformColor } from 'react-native';
++import { Platform, PlatformColor, StyleSheet, View } from 'react-native';
+ import { Tabs } from 'react-native-screens';
+ import { Deferred } from "./Deferred.js";
+ import { NativeScreen } from "./NativeScreen/NativeScreen.js";
+-import { jsx as _jsx } from "react/jsx-runtime";
++import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
+ const meta = {
+ type: 'native-tabs'
+ };
+@@ -32,9 +32,21 @@ export function NativeBottomTabView({
+ fonts
+ } = useTheme();
+ const focusedRouteKey = state.routes[state.index].key;
++ // A route with `tabBarItemHidden` gets no native tab item. While it is focused, it is drawn over the native host
++ // and the tab bar is hidden, and the host keeps the last focused visible tab selected underneath.
++ const isRouteHidden = route => descriptors[route.key]?.options.tabBarItemHidden === true;
++ const isFocusedRouteHidden = isRouteHidden(state.routes[state.index]);
++ // Derived from the history rather than remembered, so a route that became hidden since it was focused is skipped.
++ const isRouteKeyVisible = key => state.routes.some(route => route.key === key && !isRouteHidden(route));
++ const nativeSelectedRouteKey = isFocusedRouteHidden ? state.history.findLast(entry => isRouteKeyVisible(entry.key))?.key ?? state.routes.find(route => !isRouteHidden(route))?.key ?? focusedRouteKey : focusedRouteKey;
++ // Hidden routes stay mounted once visited, the same as the native tabs.
++ const visitedHiddenRouteKeysRef = React.useRef(new Set());
++ if (isFocusedRouteHidden) {
++ visitedHiddenRouteKeysRef.current.add(focusedRouteKey);
++ }
+ const [nativeState, dispatch] = React.useReducer(reducer, {
+ confirmed: {
+- routeKey: focusedRouteKey,
++ routeKey: nativeSelectedRouteKey,
+ provenance: 0
+ }
+ });
+@@ -88,7 +100,9 @@ export function NativeBottomTabView({
+ target: route.key
+ });
+ }
+- if (actionOrigin === 'programmatic-js' || focusedRouteKey === route.key) {
++ // While a hidden route is focused, native keeps the tab underneath it selected, so native reporting that tab is
++ // not a request to leave the hidden route.
++ if (actionOrigin === 'programmatic-js' || nativeSelectedRouteKey === route.key) {
+ dispatch({
+ type: 'CONFIRM_STATE',
+ confirmed
+@@ -114,7 +128,9 @@ export function NativeBottomTabView({
+ console.error(`Received 'tabSelectionRejected' for route that doesn't exist: ${selectedScreenKey}`);
+ return;
+ }
+- if (focusedRouteKey === route.key) {
++ // Android re-sends an unchanged request on a re-render and rejects it as repeated, which reports the tab under a
++ // focused hidden route. Following it would close the hidden route.
++ if (nativeSelectedRouteKey === route.key) {
+ dispatch({
+ type: 'CONFIRM_STATE',
+ confirmed
+@@ -189,17 +205,17 @@ export function NativeBottomTabView({
+ const tabBarMinimizeBehavior = currentOptions.tabBarMinimizeBehavior === 'auto' ? 'automatic' : currentOptions.tabBarMinimizeBehavior === 'none' ? 'never' : currentOptions.tabBarMinimizeBehavior;
+ const shouldHideTabBar = currentOptions.tabBarStyle?.display === 'none';
+ const bottomAccessory = currentOptions.bottomAccessory;
+- return /*#__PURE__*/_jsx(SafeAreaProviderCompat, {
+- children: /*#__PURE__*/_jsx(Tabs.Host, {
++ return /*#__PURE__*/_jsxs(SafeAreaProviderCompat, {
++ children: [/*#__PURE__*/_jsx(Tabs.Host, {
+ navStateRequest: {
+- selectedScreenKey: focusedRouteKey,
++ selectedScreenKey: nativeSelectedRouteKey,
+ baseProvenance: nativeState.confirmed.provenance
+ },
+ rejectStaleNavStateUpdates: true,
+ onTabSelected: onTabSelected,
+ onTabSelectionRejected: onTabSelectionRejected,
+ onTabSelectionPrevented: onTabSelectionPrevented,
+- tabBarHidden: shouldHideTabBar,
++ tabBarHidden: shouldHideTabBar || isFocusedRouteHidden,
+ colorScheme: dark ? 'dark' : 'light',
+ nativeContainerStyle: {
+ backgroundColor: colors.background
+@@ -213,6 +229,9 @@ export function NativeBottomTabView({
+ tabBarTintColor: activeTintColor
+ },
+ children: state.routes.map((route, index) => {
++ if (isRouteHidden(route)) {
++ return null;
++ }
+ const {
+ options,
+ render,
+@@ -224,6 +243,7 @@ export function NativeBottomTabView({
+ title,
+ lazy = false,
+ tabBarLabel,
++ tabBarAccessibilityLabel,
+ tabBarSelectionEnabled,
+ tabBarBadgeStyle,
+ tabBarIcon,
+@@ -290,6 +310,7 @@ export function NativeBottomTabView({
+ preventNativeSelection: tabBarSelectionEnabled === false,
+ badgeValue: tabBarBadge?.toString(),
+ title: tabTitle,
++ tabBarItemAccessibilityLabel: tabBarAccessibilityLabel,
+ nativeContainerStyle: {
+ backgroundColor: colors.background
+ },
+@@ -369,9 +390,40 @@ export function NativeBottomTabView({
+ })
+ }, route.key);
+ })
+- })
++ }, "native-tabs-host"), ...state.routes.map((route, index) => {
++ if (!isRouteHidden(route) || !visitedHiddenRouteKeysRef.current.has(route.key)) {
++ return null;
++ }
++ const {
++ options,
++ render,
++ navigation
++ } = descriptors[route.key];
++ const isFocused = state.index === index;
++ return /*#__PURE__*/_jsx(View, {
++ style: [StyleSheet.absoluteFill, {
++ backgroundColor: colors.background
++ }, !isFocused && styles.hiddenRoute],
++ pointerEvents: isFocused ? 'auto' : 'none',
++ children: /*#__PURE__*/_jsx(ScreenWithHeader, {
++ isFocused: isFocused,
++ route: route,
++ navigation: navigation,
++ options: options,
++ children: /*#__PURE__*/_jsx(NavigationMetaContext.Provider, {
++ value: meta,
++ children: render()
++ })
++ })
++ }, route.key);
++ })]
+ });
+ }
++const styles = StyleSheet.create({
++ hiddenRoute: {
++ display: 'none'
++ }
++});
+ function ScreenWithHeader({
+ isFocused,
+ route,
+diff --git a/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts b/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts
+index ac25ed1..c05b04b 100644
+--- a/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts
++++ b/node_modules/@react-navigation/bottom-tabs/lib/typescript/src/unstable/types.d.ts
+@@ -78,6 +78,15 @@ type IconIOSSfSymbol = {
+ };
+ export type NativeBottomTabIcon = IconIOSSfSymbol | IconImage;
+ export type NativeBottomTabNavigationOptions = NativeHeaderOptions & {
++ /**
++ * Whether the tab gets no item in the native tab bar. While a hidden tab is focused, it is drawn over the
++ * native tabs and the tab bar is hidden.
++ */
++ tabBarItemHidden?: boolean;
++ /**
++ * Accessibility label for the tab bar item, read by the screen reader. Useful when the tab has no visible label.
++ */
++ tabBarAccessibilityLabel?: string;
+ /**
+ * Title text for the screen.
+ */
diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch
new file mode 100644
index 000000000000..cb8623e23cac
--- /dev/null
+++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch
@@ -0,0 +1,88 @@
+diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
+index f729d3ecbf1..8f349432b40 100644
+--- a/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
++++ b/node_modules/@react-navigation/bottom-tabs/lib/module/unstable/NativeBottomTabView.native.js
+@@ -39,6 +39,10 @@ export function NativeBottomTabView({
+ // Derived from the history rather than remembered, so a route that became hidden since it was focused is skipped.
+ const isRouteKeyVisible = key => state.routes.some(route => route.key === key && !isRouteHidden(route));
+ const nativeSelectedRouteKey = isFocusedRouteHidden ? state.history.findLast(entry => isRouteKeyVisible(entry.key))?.key ?? state.routes.find(route => !isRouteHidden(route))?.key ?? focusedRouteKey : focusedRouteKey;
++ // Native tabs are identified by route name, which survives a route getting a new key. Keyed by route key, a re-keyed
++ // route replaces its native tab, and Android never removes the old one, so the bar shows the tab twice.
++ const getRouteForScreenKey = screenKey => state.routes.find(route => route.name === screenKey);
++ const nativeSelectedScreenKey = state.routes.find(route => route.key === nativeSelectedRouteKey)?.name ?? nativeSelectedRouteKey;
+ // Hidden routes stay mounted once visited, the same as the native tabs.
+ const visitedHiddenRouteKeysRef = React.useRef(new Set());
+ if (isFocusedRouteHidden) {
+@@ -46,7 +50,7 @@ export function NativeBottomTabView({
+ }
+ const [nativeState, dispatch] = React.useReducer(reducer, {
+ confirmed: {
+- routeKey: nativeSelectedRouteKey,
++ routeKey: nativeSelectedScreenKey,
+ provenance: 0
+ }
+ });
+@@ -89,7 +93,7 @@ export function NativeBottomTabView({
+ routeKey: selectedScreenKey,
+ provenance
+ };
+- const route = state.routes.find(route => route.key === selectedScreenKey);
++ const route = getRouteForScreenKey(selectedScreenKey);
+ if (!route) {
+ console.error(`Received 'tabSelected' for route that doesn't exist: ${selectedScreenKey}`);
+ return;
+@@ -123,7 +127,7 @@ export function NativeBottomTabView({
+ routeKey: selectedScreenKey,
+ provenance
+ };
+- const route = state.routes.find(route => route.key === selectedScreenKey);
++ const route = getRouteForScreenKey(selectedScreenKey);
+ if (!route) {
+ console.error(`Received 'tabSelectionRejected' for route that doesn't exist: ${selectedScreenKey}`);
+ return;
+@@ -154,7 +158,7 @@ export function NativeBottomTabView({
+ });
+ navigation.emit({
+ type: 'tabPress',
+- target: preventedScreenKey
++ target: getRouteForScreenKey(preventedScreenKey)?.key ?? preventedScreenKey
+ });
+ };
+ const currentOptions = descriptors[state.routes[state.index].key]?.options;
+@@ -208,7 +212,7 @@ export function NativeBottomTabView({
+ return /*#__PURE__*/_jsxs(SafeAreaProviderCompat, {
+ children: [/*#__PURE__*/_jsx(Tabs.Host, {
+ navStateRequest: {
+- selectedScreenKey: nativeSelectedRouteKey,
++ selectedScreenKey: nativeSelectedScreenKey,
+ baseProvenance: nativeState.confirmed.provenance
+ },
+ rejectStaleNavStateUpdates: true,
+@@ -306,7 +310,7 @@ export function NativeBottomTabView({
+ closing: true,
+ route
+ }),
+- screenKey: route.key,
++ screenKey: route.name,
+ preventNativeSelection: tabBarSelectionEnabled === false,
+ badgeValue: tabBarBadge?.toString(),
+ title: tabTitle,
+@@ -374,6 +378,7 @@ export function NativeBottomTabView({
+ },
+ overrideScrollViewContentInsetAdjustmentBehavior
+ },
++ // Keyed by route key, so a re-keyed route still remounts its content inside the same native tab.
+ children: /*#__PURE__*/_jsx(Deferred, {
+ lazy: lazy,
+ visible: isFocused || isPreloaded,
+@@ -387,8 +392,8 @@ export function NativeBottomTabView({
+ children: render()
+ })
+ })
+- })
+- }, route.key);
++ }, route.key)
++ }, route.name);
+ })
+ }, "native-tabs-host"), ...state.routes.map((route, index) => {
+ if (!isRouteHidden(route) || !visitedHiddenRouteKeysRef.current.has(route.key)) {
diff --git a/patches/react-navigation/@react-navigation+native+7.4.1+001+initial.patch b/patches/react-navigation/@react-navigation+native+7.4.1+001+initial.patch
index b16466d5d91c..c323f024fed0 100644
--- a/patches/react-navigation/@react-navigation+native+7.4.1+001+initial.patch
+++ b/patches/react-navigation/@react-navigation+native+7.4.1+001+initial.patch
@@ -84,7 +84,7 @@ index 0000000..b996822
+}
+//# sourceMappingURL=findFocusedRouteKey.js.map
diff --git a/node_modules/@react-navigation/native/lib/module/useLinking.js b/node_modules/@react-navigation/native/lib/module/useLinking.js
-index af0cd61..fb06350 100644
+index af0cd61..2805780 100644
--- a/node_modules/@react-navigation/native/lib/module/useLinking.js
+++ b/node_modules/@react-navigation/native/lib/module/useLinking.js
@@ -4,6 +4,7 @@ import { CommonActions, findFocusedRoute, getActionFromState as getActionFromSta
@@ -139,7 +139,19 @@ index af0cd61..fb06350 100644
export function useLinking(ref, {
enabled = true,
config,
-@@ -411,6 +449,9 @@ export function useLinking(ref, {
+@@ -266,7 +304,10 @@ export function useLinking(ref, {
+ if (record?.path === path && record?.state) {
+ const currentState = navigation.getRootState();
+ const [currentFocused, recordFocused] = findMatchingState(currentState, record.state);
+- if (previousIndex - index === 1 && currentFocused && recordFocused && isPoppingLastEntry(currentFocused, recordFocused)) {
++ // Custom string history entries (e.g. modal back-guards) aren't routes, so no router can pop them with GO_BACK.
++ // Reset to the record's state instead, which drops the custom entry.
++ const isPoppingCustomHistoryEntry = typeof currentFocused?.history?.at(-1) === 'string';
++ if (previousIndex - index === 1 && currentFocused && recordFocused && !isPoppingCustomHistoryEntry && isPoppingLastEntry(currentFocused, recordFocused)) {
+ const pending = pendingPopStateDeltaRef.current;
+
+ // If we detect that the state change is popping the last entry
+@@ -411,6 +452,9 @@ export function useLinking(ref, {
const historyDelta = getHistoryLength(focusedState) - getHistoryLength(previousFocusedState) - (
// Subtract the delta already applied by the browser to sync only the remaining changes
pendingPopStateDelta ?? 0);
@@ -149,7 +161,7 @@ index af0cd61..fb06350 100644
if (historyDelta > 0) {
// If history length is increased, we should pushState
// Note that path might not actually change here, for example, drawer open should pushState
-@@ -422,7 +463,8 @@ export function useLinking(ref, {
+@@ -422,7 +466,8 @@ export function useLinking(ref, {
// If history length is decreased, i.e. entries were removed, we want to go back
const nextIndex = history.backIndex({
@@ -159,7 +171,7 @@ index af0cd61..fb06350 100644
});
const currentIndex = history.index;
try {
-@@ -435,7 +477,18 @@ export function useLinking(ref, {
+@@ -435,7 +480,18 @@ export function useLinking(ref, {
// We couldn't find an existing entry to go back to, so we'll go back by the delta
// This won't be correct if multiple routes were pushed in one go before
// Usually this shouldn't happen and this is a fallback for that
@@ -179,7 +191,7 @@ index af0cd61..fb06350 100644
}
// Store the updated state as well as fix the path if incorrect
-@@ -448,10 +501,26 @@ export function useLinking(ref, {
+@@ -448,10 +504,26 @@ export function useLinking(ref, {
}
} else {
// If history length is unchanged, we want to replaceState
diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md
index 542721926812..7d55986533fa 100644
--- a/patches/react-navigation/details.md
+++ b/patches/react-navigation/details.md
@@ -42,6 +42,7 @@
- PR Introducing Patch: [#24165](https://github.com/Expensify/App/pull/24165)
- PR Updating Patch: [#32087](https://github.com/Expensify/App/pull/32087) [#42465](https://github.com/Expensify/App/pull/42465) [#64155](https://github.com/Expensify/App/pull/64155) [#103346](https://github.com/Expensify/App/pull/103346) [#101715](https://github.com/Expensify/App/issues/101715)
- Note: Re-ported onto 7.4.1. `createMemoryHistory` and `useLinking` were refactored upstream (`getPathWithoutHash`, `getHistoryLength`, `pendingPopStateDelta`, `isPoppingLastEntry`), so the hunks were re-anchored instead of applied verbatim: `backIndex` keeps upstream's hash handling and adds the focused-route-key match, and `useLinking` keeps upstream's `pendingPopStateDelta` guard and `getHistoryLength` delta while re-adding `historyDeltaByKeys`/`getStaleHistoryDiff`. Two parts of the old patch became redundant and were dropped: the `findFocusedRouteKey` helper file now matches upstream's own module, and the extra `history.replace` right after `history.go(nextIndex - currentIndex)` duplicated the replace that upstream already runs at the end of the same `try` block.
+- Note: The `popstate` listener skips upstream's `isPoppingLastEntry` → `GO_BACK` shortcut when the last root history entry is a custom string entry (e.g. a `useSyncModalWithHistory` back-guard). No router can pop such an entry with `GO_BACK`, so it falls back to `resetRoot(record.state)` (the pre-7.3 behavior), which drops the entry and lets the modal close on browser Back.
### [@react-navigation+stack+7.11.2+001+edge-drag-gesture.patch](@react-navigation+stack+7.11.2+001+edge-drag-gesture.patch)
@@ -62,6 +63,30 @@
- Note: Not fully covered by the public `detachPreviousScreen` option (this also forces `activityState`). v8 replaces `detachInactiveScreens`/`detachPreviousScreen`/`freezeOnBlur` with a single `inactiveBehavior` option — re-evaluate this patch then.
- Note: Re-ported onto 7.11.2. `STATE_TRANSITIONING_OR_BELOW_TOP` is now a module-level constant upstream, so the patch no longer declares it. 7.11.2 (https://github.com/react-navigation/react-navigation/pull/13255) removed the `overflow: active ? undefined : 'hidden'` line from `CardA11yWrapper`, so the `zIndex` hunk was re-anchored on the line above it. The added lines are unchanged. With that `overflow: hidden` gone from `CardA11yWrapper`, the Safari case the `zIndex` part was added for may no longer happen. That has not been verified, so the `zIndex` part stays.
+### [@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch](@react-navigation+bottom-tabs+7.19.2+002+active-indicator-color-precedence.patch)
+
+- Reason: `tabBarActiveIndicatorColor` was never applied on the native Android tab bar: a missing pair of parentheses made `??` bind to the `typeof` check, so the indicator always got `tabBarActiveTintColor` at 10% alpha. The patch restores the intended precedence, so the active indicator pill takes the color from the design. **This patch can be removed on the upgrade to React Navigation v8**, whose rewritten native tab view no longer has the bug.
+- Upstream PR/issue: not reported, because the v8 branch already fixes it and the fix only matters until the App moves to v8. Still present on the `7.x` branch as of 7.20.0.
+- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169)
+- PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339)
+- PR Updating Patch: N/A
+
+### [@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch](@react-navigation+bottom-tabs+7.19.2+003+hidden-tab-items.patch)
+
+- Reason: Adds a `tabBarItemHidden` option to the native bottom tabs. The App registers six tabs but the native bar can show at most five (Material's `BottomNavigationView` throws past five, and `UITabBar` folds the rest into a "More" tab), and which five depends on the Insights beta: Insights with it, Account without it. A native tab bar cannot hide one of its items, so a route with `tabBarItemHidden` gets no `Tabs.Screen` at all. When such a route is focused, it is drawn in JS over the native host with the tab bar hidden, while the host keeps the last focused visible tab selected underneath. A hidden route stays mounted once visited. Nothing native changes. The host's selection is derived from the tab history, skipping routes that are hidden by then, so a beta change that hides the selected route cannot leave the host pointing at a removed tab. A route that moves between hidden and visible is remounted, and a hidden route ignores preloading and emits no transition events. Native reporting the tab that sits under a focused hidden route, as Android does when it rejects an unchanged request as repeated on a re-render, confirms the state instead of moving JS to that tab, so the hidden route stays open. The patch also forwards a `tabBarAccessibilityLabel` option to RNScreens' `tabBarItemAccessibilityLabel`. The iOS tab labels are drawn into the icon images, so without it VoiceOver announces the tabs with no name. **This part can be removed on the upgrade to React Navigation v8**, which forwards the same option. The `tabBarItemHidden` part has no upstream counterpart and stays.
+- Upstream PR/issue: not reported, because hiding an item from a native tab bar has no native counterpart and this works around it in JS for the App's tab set; the accessibility label part is already in v8.
+- E/App issue: [#101169](https://github.com/Expensify/App/issues/101169)
+- PR Introducing Patch: [#101339](https://github.com/Expensify/App/pull/101339)
+- PR Updating Patch: N/A
+
+### [@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch](@react-navigation+bottom-tabs+7.19.2+004+stable-native-tab-identity.patch)
+
+- Reason: Identifies each native tab by its route name instead of its route key. The App gives the focused tab route a new key on purpose when it pre-inserts a screen under the RHP (`markFocusedTabRouteForRemount` in `GetStateForActionHandlers.ts`, #90985), so the split navigator remounts without a push-transition flash. Keyed by route key, that new key replaced the whole `Tabs.Screen`. Android never removes the old one: `TabsHostViewManager` does not override `getChildAt`/`getChildCount`, so Fabric's `removeViewAt` finds no child at that index ("view already removed from parent!") and skips `TabsHost.unmountReactSubviewAt`, while the insert goes through. The bar then shows the tab twice, and the stale copy opens a blank page. With this patch the native tab (`key`, `screenKey`, `navStateRequest.selectedScreenKey` and the selection events) uses the route name, which stays the same, and only the tab's content is keyed by the route key, so a re-keyed route still remounts its content inside the same native tab. **Remove this patch** once react-native-screens removes Android tabs correctly and React Navigation's native tab view no longer recreates a tab when its route key changes.
+- Upstream PR/issue: not reported yet. The Android removal bug belongs to react-native-screens (`TabsHostViewManager`).
+- E/App issue: [#104230](https://github.com/Expensify/App/issues/104230), [#104233](https://github.com/Expensify/App/issues/104233)
+- PR Introducing Patch: [#104238](https://github.com/Expensify/App/pull/104238)
+- PR Updating Patch: N/A
+
### Removed patches
- `@react-navigation+native+7.1.33+003+increase-history-go-popstate-fallback-timeout.patch` was dropped: upstream raised the `createMemoryHistory.go()` fallback timeout from 100ms to 1000ms in 7.3.18 (https://github.com/react-navigation/react-navigation/pull/13217), with the same Firefox rationale as our patch. Original context: [#94571](https://github.com/Expensify/App/issues/94571) / [#95980](https://github.com/Expensify/App/pull/95980), upstream issue https://github.com/react-navigation/react-navigation/issues/11145.
diff --git a/patches/sentry-browser-utils/@sentry+browser-utils+10.75.0+001+data-sentry-label.patch b/patches/sentry-browser-utils/@sentry+browser-utils+10.75.0+001+data-sentry-label.patch
new file mode 100644
index 000000000000..c5ca7c9b3015
--- /dev/null
+++ b/patches/sentry-browser-utils/@sentry+browser-utils+10.75.0+001+data-sentry-label.patch
@@ -0,0 +1,114 @@
+diff --git a/node_modules/@sentry/browser-utils/build/cjs/htmlTreeAsString.js b/node_modules/@sentry/browser-utils/build/cjs/htmlTreeAsString.js
+index e230278..73016d3 100644
+--- a/node_modules/@sentry/browser-utils/build/cjs/htmlTreeAsString.js
++++ b/node_modules/@sentry/browser-utils/build/cjs/htmlTreeAsString.js
+@@ -54,7 +54,41 @@ function htmlTreeAsString(elem, options = {}) {
+ len += nextStr.length;
+ currentElem = _safeRead(currentElem, "parentNode");
+ }
+- return out.reverse().join(separator);
++ const cssSelector = out.reverse().join(separator);
++ let labelElem = null;
++ let dataLabel = null;
++ let pressableElem = null;
++ let current = elem;
++ for (let i = 0; i < 15 && current; i++) {
++ if (typeof HTMLElement !== "undefined" && current instanceof HTMLElement) {
++ const dataset = _safeRead(current, "dataset");
++ if (dataset) {
++ if (_safeRead(current, "getAttribute", "aria-disabled") === "true") {
++ current = _safeRead(current, "parentNode");
++ continue;
++ }
++ if (!dataLabel && dataset["sentryLabel"]) {
++ dataLabel = dataset["sentryLabel"];
++ labelElem = current;
++ }
++ if (!pressableElem && dataset["tag"] === "pressable") {
++ pressableElem = current;
++ }
++ if (dataLabel && pressableElem) break;
++ }
++ }
++ current = _safeRead(current, "parentNode");
++ }
++ if (!dataLabel) {
++ return cssSelector;
++ }
++ if (pressableElem && labelElem === pressableElem) {
++ return `[data-sentry-label="${dataLabel}"]`;
++ }
++ if (cssSelector.includes('[data-sentry-label="')) {
++ return cssSelector;
++ }
++ return `[data-sentry-label="${dataLabel}"] ${cssSelector}`;
+ } catch {
+ return "";
+ }
+@@ -69,6 +103,9 @@ function _htmlElementAsString(el, keyAttrs) {
+ if (el instanceof HTMLElement) {
+ const dataset = _safeRead(el, "dataset");
+ if (dataset) {
++ if (dataset["sentryLabel"]) {
++ return `[data-sentry-label="${dataset["sentryLabel"]}"]`;
++ }
+ if (dataset["sentryComponent"]) {
+ return dataset["sentryComponent"];
+ }
+diff --git a/node_modules/@sentry/browser-utils/build/esm/htmlTreeAsString.js b/node_modules/@sentry/browser-utils/build/esm/htmlTreeAsString.js
+index 6f8e53a..50f9f83 100644
+--- a/node_modules/@sentry/browser-utils/build/esm/htmlTreeAsString.js
++++ b/node_modules/@sentry/browser-utils/build/esm/htmlTreeAsString.js
+@@ -52,7 +52,41 @@ function htmlTreeAsString(elem, options = {}) {
+ len += nextStr.length;
+ currentElem = _safeRead(currentElem, "parentNode");
+ }
+- return out.reverse().join(separator);
++ const cssSelector = out.reverse().join(separator);
++ let labelElem = null;
++ let dataLabel = null;
++ let pressableElem = null;
++ let current = elem;
++ for (let i = 0; i < 15 && current; i++) {
++ if (typeof HTMLElement !== "undefined" && current instanceof HTMLElement) {
++ const dataset = _safeRead(current, "dataset");
++ if (dataset) {
++ if (_safeRead(current, "getAttribute", "aria-disabled") === "true") {
++ current = _safeRead(current, "parentNode");
++ continue;
++ }
++ if (!dataLabel && dataset["sentryLabel"]) {
++ dataLabel = dataset["sentryLabel"];
++ labelElem = current;
++ }
++ if (!pressableElem && dataset["tag"] === "pressable") {
++ pressableElem = current;
++ }
++ if (dataLabel && pressableElem) break;
++ }
++ }
++ current = _safeRead(current, "parentNode");
++ }
++ if (!dataLabel) {
++ return cssSelector;
++ }
++ if (pressableElem && labelElem === pressableElem) {
++ return `[data-sentry-label="${dataLabel}"]`;
++ }
++ if (cssSelector.includes('[data-sentry-label="')) {
++ return cssSelector;
++ }
++ return `[data-sentry-label="${dataLabel}"] ${cssSelector}`;
+ } catch {
+ return "";
+ }
+@@ -67,6 +101,9 @@ function _htmlElementAsString(el, keyAttrs) {
+ if (el instanceof HTMLElement) {
+ const dataset = _safeRead(el, "dataset");
+ if (dataset) {
++ if (dataset["sentryLabel"]) {
++ return `[data-sentry-label="${dataset["sentryLabel"]}"]`;
++ }
+ if (dataset["sentryComponent"]) {
+ return dataset["sentryComponent"];
+ }
diff --git a/patches/sentry-browser-utils/details.md b/patches/sentry-browser-utils/details.md
new file mode 100644
index 000000000000..531bcb9621d6
--- /dev/null
+++ b/patches/sentry-browser-utils/details.md
@@ -0,0 +1,15 @@
+# `@sentry/browser-utils` patches
+
+### [@sentry+browser-utils+10.75.0+001+data-sentry-label.patch](@sentry+browser-utils+10.75.0+001+data-sentry-label.patch)
+
+- Reason: Enhances the `htmlTreeAsString` function to support `data-sentry-label` attributes for better element identification in Sentry spans. The patch:
+ - Always includes `data-sentry-label` in the list of checked attributes for each DOM element
+ - Searches up to 15 levels up the DOM tree to find a `data-sentry-label` attribute
+ - Prefixes the CSS selector with the found `data-sentry-label` value (e.g., `[data-sentry-label="MyLabel"] div.css-146c3p1.r-1udh08x.r-1udbk01.r-1iln25a > svg`)
+
+ This allows us to identify UI elements by meaningful labels rather than just CSS selectors, making Sentry spans more actionable.
+
+ `@sentry/browser-utils` ships its own copy of `htmlTreeAsString`, which INP, CLS and LCP spans, click breadcrumbs and Session Replay use. This logic used to be patched into the `@sentry/core` copy, which the browser SDK stopped calling after the upgrade to `@sentry/core` 10.73 (https://github.com/Expensify/App/commit/9689acf98e3), so labels disappeared from web spans from release 9.4.76 on.
+- Upstream PR/issue: https://github.com/getsentry/sentry-javascript/pull/18398
+- E/App issue: https://github.com/Expensify/App/issues/76128
+- PR Introducing Patch: https://github.com/Expensify/App/pull/104340
diff --git a/patches/sentry-core/@sentry+core+10.75.0+001+initial.patch b/patches/sentry-core/@sentry+core+10.75.0+001+initial.patch
index e7b617fb3000..0f994503d38c 100644
--- a/patches/sentry-core/@sentry+core+10.75.0+001+initial.patch
+++ b/patches/sentry-core/@sentry+core+10.75.0+001+initial.patch
@@ -1,57 +1,3 @@
-diff --git a/node_modules/@sentry/core/build/cjs/utils/browser.js b/node_modules/@sentry/core/build/cjs/utils/browser.js
-index 9880e2d..6aee1b0 100644
---- a/node_modules/@sentry/core/build/cjs/utils/browser.js
-+++ b/node_modules/@sentry/core/build/cjs/utils/browser.js
-@@ -29,7 +29,38 @@ function htmlTreeAsString(elem, options = {}) {
- len += nextStr.length;
- currentElem = currentElem.parentNode;
- }
-- return out.reverse().join(separator);
-+ const cssSelector = out.reverse().join(separator);
-+ let labelElem = null;
-+ let dataLabel = null;
-+ let pressableElem = null;
-+ let current = elem;
-+ for (let i = 0; i < 15 && current; i++) {
-+ if (WINDOW.HTMLElement && current instanceof HTMLElement && current.dataset) {
-+ if (current.getAttribute("aria-disabled") === "true") {
-+ current = current.parentNode;
-+ continue;
-+ }
-+ if (!dataLabel && current.dataset["sentryLabel"]) {
-+ dataLabel = current.dataset["sentryLabel"];
-+ labelElem = current;
-+ }
-+ if (!pressableElem && current.dataset["tag"] === "pressable") {
-+ pressableElem = current;
-+ }
-+ if (dataLabel && pressableElem) break;
-+ }
-+ current = current.parentNode;
-+ }
-+ if (!dataLabel) {
-+ return cssSelector;
-+ }
-+ if (pressableElem && labelElem === pressableElem) {
-+ return `[data-sentry-label="${dataLabel}"]`;
-+ }
-+ if (cssSelector.includes('[data-sentry-label="')) {
-+ return cssSelector;
-+ }
-+ return `[data-sentry-label="${dataLabel}"] ${cssSelector}`;
- } catch {
- return "";
- }
-@@ -42,6 +73,9 @@ function _htmlElementAsString(el, keyAttrs) {
- }
- if (WINDOW.HTMLElement) {
- if (elem instanceof HTMLElement && elem.dataset) {
-+ if (elem.dataset["sentryLabel"]) {
-+ return `[data-sentry-label="${elem.dataset["sentryLabel"]}"]`;
-+ }
- if (elem.dataset["sentryComponent"]) {
- return elem.dataset["sentryComponent"];
- }
diff --git a/node_modules/@sentry/core/build/esm/fetch.js b/node_modules/@sentry/core/build/esm/fetch.js
index b8fac8e..14ac969 100644
--- a/node_modules/@sentry/core/build/esm/fetch.js
@@ -67,57 +13,3 @@ index b8fac8e..14ac969 100644
const contentLength = handlerData.response?.headers?.get("content-length");
if (contentLength) {
const contentLengthNum = parseInt(contentLength);
-diff --git a/node_modules/@sentry/core/build/esm/utils/browser.js b/node_modules/@sentry/core/build/esm/utils/browser.js
-index ca93f32..502acba 100644
---- a/node_modules/@sentry/core/build/esm/utils/browser.js
-+++ b/node_modules/@sentry/core/build/esm/utils/browser.js
-@@ -27,7 +27,38 @@ function htmlTreeAsString(elem, options = {}) {
- len += nextStr.length;
- currentElem = currentElem.parentNode;
- }
-- return out.reverse().join(separator);
-+ const cssSelector = out.reverse().join(separator);
-+ let labelElem = null;
-+ let dataLabel = null;
-+ let pressableElem = null;
-+ let current = elem;
-+ for (let i = 0; i < 15 && current; i++) {
-+ if (WINDOW.HTMLElement && current instanceof HTMLElement && current.dataset) {
-+ if (current.getAttribute("aria-disabled") === "true") {
-+ current = current.parentNode;
-+ continue;
-+ }
-+ if (!dataLabel && current.dataset["sentryLabel"]) {
-+ dataLabel = current.dataset["sentryLabel"];
-+ labelElem = current;
-+ }
-+ if (!pressableElem && current.dataset["tag"] === "pressable") {
-+ pressableElem = current;
-+ }
-+ if (dataLabel && pressableElem) break;
-+ }
-+ current = current.parentNode;
-+ }
-+ if (!dataLabel) {
-+ return cssSelector;
-+ }
-+ if (pressableElem && labelElem === pressableElem) {
-+ return `[data-sentry-label="${dataLabel}"]`;
-+ }
-+ if (cssSelector.includes('[data-sentry-label="')) {
-+ return cssSelector;
-+ }
-+ return `[data-sentry-label="${dataLabel}"] ${cssSelector}`;
- } catch {
- return "";
- }
-@@ -40,6 +71,9 @@ function _htmlElementAsString(el, keyAttrs) {
- }
- if (WINDOW.HTMLElement) {
- if (elem instanceof HTMLElement && elem.dataset) {
-+ if (elem.dataset["sentryLabel"]) {
-+ return `[data-sentry-label="${elem.dataset["sentryLabel"]}"]`;
-+ }
- if (elem.dataset["sentryComponent"]) {
- return elem.dataset["sentryComponent"];
- }
diff --git a/patches/sentry-core/details.md b/patches/sentry-core/details.md
index c08ddd227f0d..73cc3cc78a33 100644
--- a/patches/sentry-core/details.md
+++ b/patches/sentry-core/details.md
@@ -2,17 +2,7 @@
### [@sentry+core+10.75.0+001+initial.patch](@sentry+core+10.75.0+001+initial.patch)
-- Reason: Enhances the `htmlTreeAsString` function to support `data-sentry-label` attributes for better element identification in Sentry spans. The patch:
- - Always includes `data-sentry-label` in the list of checked attributes for each DOM element
- - Searches up to 15 levels up the DOM tree to find a `data-sentry-label` attribute
- - Prefixes the CSS selector with the found `data-sentry-label` value (e.g., `[data-sentry-label="MyLabel"] div.css-146c3p1.r-1udh08x.r-1udbk01.r-1iln25a > svg`)
-
- This allows us to identify UI elements by meaningful labels rather than just CSS selectors, making Sentry spans more actionable.
-- Upstream PR/issue: https://github.com/getsentry/sentry-javascript/pull/18398
-- E/App issue: https://github.com/Expensify/App/issues/76128
-- PR Introducing Patch: https://github.com/Expensify/App/pull/76547
-
-The patch also adds `request-id` from fetch response headers to spans, making it easier to correlate Sentry data with backend logs.
-- Upstream PR/issue: N/A
+- Reason: Adds `request-id` from fetch response headers to spans, making it easier to correlate Sentry data with backend logs.
+- Upstream PR/issue: N/A
- E/App issue: https://github.com/Expensify/App/issues/75588
- PR Introducing Patch: https://github.com/Expensify/App/pull/79883
diff --git a/patches/victory-native/details.md b/patches/victory-native/details.md
index 218061dd9c1d..ad919a91a15c 100644
--- a/patches/victory-native/details.md
+++ b/patches/victory-native/details.md
@@ -1,6 +1,6 @@
# `victory-native` patches
-### [victory-native+41.21.0+001+horizontal-bars.patch](victory-native+41.21.0+001+horizontal-bars.patch)
+### [victory-native+42.0.1+001+horizontal-bars.patch](victory-native+42.0.1+001+horizontal-bars.patch)
- Reason:
@@ -12,30 +12,18 @@
- E/App issue: https://github.com/Expensify/App/issues/91883
- PR introducing patch: https://github.com/Expensify/App/pull/91659
-### [victory-native+41.21.0+002+fix-piesliceangularinset-bug.patch](victory-native+41.21.0+002+fix-piesliceangularinset-bug.patch)
-- Reason:
-
- ```
- Fix bug https://github.com/FormidableLabs/victory-native-xl/issues/652
- ```
-
-- Upstream PR/issue: https://github.com/FormidableLabs/victory-native-xl/pull/666
-- E/App issue: https://github.com/Expensify/App/issues/92114
-- PR introducing patch: https://github.com/Expensify/App/pull/92130
-
-### [victory-native+41.21.0+003+canvas-props.patch](victory-native+41.21.0+003+canvas-props.patch)
+### [victory-native+42.0.1+002+tickCount-4121.patch](victory-native+42.0.1+002+tickCount-4121.patch)
- Reason:
```
- Forwards a `canvasProps` prop from CartesianChart/PolarChart to the underlying Skia