# ADR 003: Position comparison ("Compare POS") UI

## Status

Accepted

> **Read this before changing** the Position Comparison dialog, the Compare POS by customer page, or their BFF
> routes. The backend rules and the full list of invariants are in the Yii repo:
> `coporate_yii2/docs/architecture/POSITION_COMPARISON.md`. Section "Do not remove" below is the Next.js half.

## Context

Two screens compare a bank's earlier POS statement with a later one and tag each line Buy / Sell / Partial buy /
Partial sell / Unchanged:

- the **Position Comparison dialog** on a POS import view (one upload against the statement before it), and
- the **Compare POS by customer** page (`/dashboard/xml-apis/upload-xml/compare-by-customer`): one customer, or all
  customers, over a date range, day by day. Its "Open" shows one day in the **same dialog**.

They used to be separate pipelines. They now share one Yii comparison object and one dialog payload.

## Decision

- **One dialog, one payload.** `UploadXmlComparePosDialog` is the only table. Both screens feed it
  `UploadXmlComparePositionData`, normalised by `normalizeComparePositionPayload`
  (`app/dashboard/xml-apis/upload-xml/_lib/upload-xml-fetch-api.ts`). Do not build dialog data in TypeScript from raw
  statements (that was `buildStepDetail`, now deleted).
- **Day-by-day = plan, then steps, then pair.** The page calls `plan` (which statements exist), then one `step` per
  consecutive pair (counts only, for progress on long ranges), and `pair` only when a day is opened.
- **Display names only.** Buy / Sell / Partial buy / Partial sell are labels (`tradeTagOf`, `TRADE_TAG_LABEL`,
  `TRADE_TAG_STYLE`). The saved history keeps the backend statuses `new` / `removed` / `changed`.
- **Save-history is shared.** "Save Buy, Sell & Partial" and "View History" appear for a single upload and for a
  day-by-day pair (saved under the later statement's upload, with `historyTarget.customerId` so one customer's
  comparison saves only that customer's lines; omitted for "All customers").

## File map

| Area | Files |
|---|---|
| Page | `app/dashboard/xml-apis/upload-xml/compare-by-customer/page.tsx`, `components/xml-apis/upload-xml/upload-xml-customer-compare-page.tsx` |
| Page components | `components/xml-apis/upload-xml/components/customer-compare/*` (`customer-compare-workspace`, `-form`, `customer-select`, `-results`, `-steps-table`, `-summary-cards`, `use-customer-compare`, `use-customer-compare-detail`) |
| Dialog | `components/xml-apis/upload-xml/components/upload-xml-compare-pos-dialog.tsx`, `upload-xml-compare-pos.logic.ts` (`buildCompareRows`, `countByTrade`, `comparePosHeaderActions`), `...format.ts`, `upload-xml-trade-tag-style.ts` |
| Pure logic | `components/xml-apis/upload-xml/_lib/customer-compare.ts` (`planSteps`, empty-state text), `customer-compare-assemble.ts` (worker pool, timeline), `customer-compare-summary.ts`, `customer-compare-customers.ts`, `compare-previous-notice.ts` |
| Client API | `components/xml-apis/upload-xml/api/upload-xml-customer-compare.api.ts` (`fetchCustomerCompareCustomers` / `Plan` / `Step` / `Pair`), `upload-xml.api.ts` (`fetchUploadXmlComparePosition`, `saveUploadXmlComparePositionHistory`) |
| BFF routes | `app/dashboard/xml-apis/upload-xml/customer-compare/{customers,plan,step,pair,transactions}/route.ts`, `compare-position/route.ts`, `compare-position/save-history/route.ts` |
| Server libs | `app/dashboard/xml-apis/upload-xml/_lib/` (`customer-compare-request.ts`, `customer-compare-parse.ts`, `upload-xml-customer-compare-server-api.ts`, `save-history-body.ts`) |
| Config | `config/frontend-routes/dashboard.ts` (`compareByCustomer`, `customerCompare*`), `config/backend-endpoints/xml-apis.ts` |
| Entry points | `upload-xml-toolbar.tsx` (list page button "Compare POS by customer"), `upload-xml-import-footer.tsx` (the Compare POS button on a POS file) |
| Shared date UI | `components/date-range-picker.tsx` (`DatePicker`, `dateLimitMatchers`, `isDayDisabled`) |

## Do not remove

1. **The Compare POS button and its dialog show only for position files** (`processMeta.tempType === "P"`), never for a
   transaction file. Test: `upload-xml-import-footer.test.ts` "Compare POS button".
2. **The customer page's "Compare POS by customer" button lives on the Upload XML list page**, not inside the dialog
   (the dialog has no "By customer" button). Test: `upload-xml-toolbar.test.ts`.
3. **Buy/Sell logic is mirrored in PHP.** `buildCompareRows` here and `PositionDiff` in Yii are pinned by
   `position-diff-cases.fixture.json` / `position_diff_cases.json` (191 cases). Test: `position-diff-parity.test.ts`.
   Change both sides or neither.
4. **A statement that could not be read is never shown as empty.** The notice (`ComparePreviousNotice`,
   `describePreviousNotice`, `isComparable`) must keep hiding tags, Net and Save when there is nothing to compare
   with; "Not compared" days must stay un-openable (`describeUnavailableStep`). Tests: `compare-previous-notice.test.ts`,
   `customer-compare.test.ts`.
5. **Any date range** is allowed (the page runs one request per comparison). Do not re-add a day cap in the UI.
6. **Use the shared `DatePicker`**, not a native date input.
7. **The customer dropdown is drawn inline** (absolute, no portal). A portal outside the Radix Dialog loses focus and
   scroll to the dialog's focus trap. Test: `customer-compare-customers.test.ts`, `customer-compare-components.test.ts`.
8. **The dialog does not use `useRouter`** (it navigates with `window.location.assign`), because many SSR render tests
   mount it without an app router.
9. **`customer_id` reaches the save endpoint** only through `parseSaveHistoryBody` (positive integer, else ignored).
   Tests: `save-history-body.test.ts`, `upload-xml.api.test.ts`.

## Bank transactions check (customer page)

Each day row on the Compare POS by customer page has **Bank trx** next to Open. It asks Yii
`customer_position_compare/transactions` (BFF `customer-compare/transactions`, same body as `pair`) whether the Buy / Sell /
Partial movements of that pair are the ones the bank's own transaction file(s) of that day report, and shows the answer in
`CustomerCompareTransactionsDialog` → `CustomerCompareTransactionsReport`: a verdict, counts that double as filters
(Quantity differs / Not in bank file / Not in positions / Matched), one row per position (statements net, bank net,
difference, expandable to the bank's own lines), and collapsible "other lines in the bank file" and "position movements
that cannot be checked". Backend rules: `POSITION_COMPARISON.md` section 5c.

Files: `_lib/customer-compare-transactions.ts` (types, `describeTransactionCheck`, `dayLabel`, filters), parser
`parseCustomerCompareTransactions` in `app/.../_lib/customer-compare-parse.ts`, client `fetchCustomerCompareTransactions`,
server `fetchBackendCustomerCompareTransactions`, hook `use-customer-compare-transactions.ts`, the button in
`customer-compare-steps-table.tsx` (only when `onCheckTransactions` is given).

Do not remove: a check without a bank file, or with one that could not be read, says so instead of claiming
transactions are missing (`describeTransactionCheck`; `complete` is true only when Yii sends exactly `true`); an unknown
row status is dropped by the parser, never shown as another one; a missing side stays `null`, never `0`; the Bank trx
button is disabled for a day that could not be compared. Tests: `customer-compare-transactions.test.ts`,
`customer-compare-parse.test.ts`, `customer-compare-components.test.ts`, `upload-xml.api.test.ts`.

## Transaction files from saved history

The Compare POS history page (`/dashboard/xml-apis/pos-compare-history?upload_id=<id>`) has two buttons, **Import XML View**
and **Common Rules** (both `CreateTransactionFileButton`, `mode="import"` / `"commonRules"`). They make the same
file (one per POS upload, so the second click reuses the first one's entry) and differ only in where they open it:
the plain import view, or `?detect=commonrules` (`compareTransactionFileHref`). Importing then goes through that
entry's normal import, so what is imported is a saved Upload XML entry. It makes the bank's own transaction file from the saved rows (Yii
`create_compare_transaction_file`, see `POSITION_COMPARISON.md` section 5b), tells the user how many transactions
were made and what was skipped, and opens the new Upload XML entry's import view. Nothing opens when it fails; the
reason is shown under the button. The Upload XML list has a third tab, **Compare transactions**
(`source=compare-transactions`), listing those files, except that a POS OCR bank's generated file is listed on the
**POS OCR uploads** tab. The toast says which (`tab` in the response).

Files: `app/dashboard/xml-apis/pos-compare-history/_components/create-transaction-file-button.tsx`,
`components/xml-apis/upload-xml/_lib/compare-transaction-file.ts` (`parseCompareTransactionFile`,
`describeCompareTransactionFile`, `openCompareTransactionFile`), BFF
`app/dashboard/xml-apis/upload-xml/compare-position/create-transaction-file/route.ts`,
`upload-xml-mutate-api.ts` (`createBackendCompareTransactionFile`), client `createUploadXmlCompareTransactionFile`,
tabs `upload-xml-source-tabs.tsx`, `UPLOAD_XML_LIST_SOURCES` / `parseUploadXmlListSource` in `types.ts`.

Do not remove: `compareTransactionFileHref` / `openCompareTransactionFile(uploadId, mode, …)` and their tests
(`compare-transaction-file.test.ts`: both modes open the generated entry, never the POS upload); the tab list and the URL round trip of `source=compare-transactions`
(`upload-xml-source-tabs.test.ts`, `upload-xml-list-filters.test.ts`); the rule that nothing is opened or announced
when the file could not be made (`compare-transaction-file.test.ts`).

## Note on bank-specific line rules

Which lines a bank contributes is decided in Yii, never in the dialog: the dialog shows exactly the lines it is given.
For example UBS is compared on its trade-date (`TRAD`) lines only (Yii `ApiCategory::comparedBasis()`); do not add a
second filter in the UI.

## Consequences

- Backend changes to the payload shape must be mirrored in `normalizeComparePositionPayload`
  (test: `compare-position-payload.test.ts`).
- A change to how lines are tagged needs the fixture regenerated from the table code and both test suites run.
- Run `npm test`, `npx tsc --noEmit`, and `npx eslint components app` before finishing.
