# Admin Finance Hub — Demo→Portal Parity Audit (v0.28)

Date: 2026-07-27 · Demo @ `cb48c8d` (v0.28.0) · Portal branch `tailwind-poc`

**Demo sources:** `Navagoo_MI/navagoo-app/src/portals/admin/finance/{AdminFinanceLayout,TransferRequests,AdminInvoices,AdminCharges,ShopBalances,CommercialConfig}.tsx` · i18n `src/i18n/en.ts` lines 1343–1502 (`admin.finance.*`)
**Portal sources:** `backend/views/admin-finance/{_tabs,transfer-requests,_settle_modal,shop-balances,shop-ledger}.php` · `backend/views/admin-invoice/index.php` · `backend/views/admin-charge/index.php` · `backend/views/commercial-config/index.php` · controllers `AdminFinanceController` (842 L), `AdminInvoiceController` (248 L), `AdminChargeController` (393 L), `CommercialConfigController` (93 L)

Legend: **GAP** = demo has it, portal doesn't · **DIFF** = both have it, behaviour/copy differs · **SUPERSET** = portal-only extra (listed separately, not a defect).

---

## 0. Hub layout / tab nav (`_tabs.php` vs `AdminFinanceLayout.tsx`)

Tabs match in label, order, and live badge logic (requested / to-verify / owing counts on tabs 1–3, none on 4–5). Active-tab detection covers all five controllers. Solid port.

| # | Sev | Type | Finding |
|---|-----|------|---------|
| 0.1 | MED | GAP | **Hub subtitle missing.** Demo PageHeader: `layout.subtitle` = `'Platform-wide settlements, invoices, ledger & commercial rates'` directly under the "Finance" h1. Portal `_tabs.php:103-106` renders only the h1; each tab page instead carries its own one-line description (different copy per page). Rendered-demo truth shows the subtitle in the hub header. |
| 0.2 | LOW | DIFF | Demo TabNav badge = count pill on active AND idle tabs with same styling family; portal recolors amber when idle / brand when active — visual-only, acceptable aurora dialect. |
| 0.3 | LOW | SUPERSET | Portal adds a lucide icon per tab (`arrow-left-right`, `file-text`, `scale`, `receipt`, `sliders`); demo tabs are text+count only. |

---

## 1. Transfer Requests (`transfer-requests.php` + `_settle_modal.php` vs `TransferRequests.tsx`)

Columns present and in demo order: TR ID · Shop · Earned · Tips · Marketing · Processing · Notifications · Fee VAT · Net Payout · Amount Paid · Status (+ portal-extra Created) · action.

### Gaps / diffs

| # | Sev | Type | Finding |
|---|-----|------|---------|
| 1.1 | HIGH | DIFF | **ID format.** Demo rows read `NTR-4402` (`tr.id` seed format, `id-mono`). Portal renders `TR-<?= (int)$m->id ?>` (`transfer-requests.php:139`) → `TR-12`. Note the portal's own `earnings/_search.php:192` placeholder already says "Transfer Request ID (NTR-YMMXXXXX)" — the display prefix is inconsistent with the portal's own convention as well as the demo. |
| 1.2 | HIGH | GAP | **Row click does not open the modal.** Demo `DataTable onRowClick={(tr) => setActive(tr)}` (TransferRequests.tsx:147) — any row opens Review/View. Portal only the `.js-settle-open` button opens it; rows are hover-highlighted but inert. |
| 1.3 | MED | DIFF | **Column header copy.** Demo "PAYMENT PROCESSING" (`shop.finance.settlement.termPaymentProcessing` = `'Payment Processing'`) → portal "Processing" (`transfer-requests.php:113`). All other headers match (Earned/Tips/Marketing/Notifications/Fee VAT/Net Payout/Amount Paid/Status). |
| 1.4 | MED | DIFF | **Action label.** Demo requested-state button = `actionReview` = `'Review'`; portal = `'Review & settle'` (`transfer-requests.php:157`). Settled-state "View" matches. |
| 1.5 | MED | GAP | **`incl. {amount} packages` sub-line** under Earned when `totalPackageEarned > 0` (demo `inclPackages` = `'incl. {{amount}} packages'`, TransferRequests.tsx:63-67). Portal Earned cell is a bare number — package share never surfaced (M4 Phase 9 informational line). |
| 1.6 | MED | GAP | **Settle modal — confirmation DataRows missing.** Demo shows `dataRowShopConfirmation` = `'Shop confirmation'` (user name), `dataRowConfirmedAt` = `'Confirmed at'`, and when settled `dataRowSettledAt` = `'Settled at'` (TransferRequests.tsx:269-281). Portal `_settle_modal.php` has none of the three — no visibility of who/when the shop confirmed. |
| 1.7 | MED | GAP | **"Generate invoice via API" disabled step missing.** Demo renders a permanently-disabled dashed button between the two uploads: `generateViaApiTitle` = `'Generate invoice via API'`, desc `'Inactive — enabled after billing-system integration'`, tooltip `'Available after billing-system integration'` (TransferRequests.tsx:297-312). Portal has only the two file inputs. |
| 1.8 | LOW | DIFF | **Mismatch warning copy.** Demo `mismatchText` = `'Mismatch: {{amount}} vs net payout {{net}} (Δ {{delta}}). Confirm before settling.'` — shows both amounts and signed delta. Portal (`_settle_modal.php:99`): `'Amount paid differs from the net payout by'` + absolute delta only. |
| 1.9 | LOW | DIFF | **Settled banner copy.** Demo: `'Settled — {{amount}} paid. Charges flipped to **paid**. Documents: {{invoicePdf}}, {{receipt}}.'` Portal: `'This transfer request is settled.'` — amount, charge-flip note, and document names dropped (docs shown only in the associated-invoices list). |
| 1.10 | LOW | DIFF | **Associated invoices section.** Demo heading switches `'Invoices this settlement will clear'` (unsettled) / `'Settled invoices'` (settled), rows are expandable to inline `InvoiceDocument`. Portal heading is always `'Associated invoices'`, rows non-expandable (external doc link instead). Demo also labels the trigger (`'settlement'`); portal shows `Invoice::typeOptions()` type. |
| 1.11 | LOW | DIFF | **Modal subtitle.** Demo: `'{{count}} bookings · created {{date}}'`. Portal: "Shop: X · Created <date>" — booking count missing. |
| 1.12 | LOW | DIFF | **Amount Paid column.** Demo shows `tr.amountPaid` (the actual recorded paid amount, possibly ≠ net). Portal shows `$m->amount` when settled — if the admin settled with a mismatched amount, the portal shows... `amount` (verify which column stores paid vs net; demo distinguishes `netPayout` vs `amountPaid`). |
| 1.13 | LOW | DIFF | **Settle button gating.** Demo also disables submit until BOTH uploads done — parity OK — but demo settle is followed by `toastSettledTitle/Body` (`'Settled'` / `'{{id}} · paid {{amount}}'`); portal uses `data-confirm` native-ish confirm + flash. Uses `data-confirm` attribute — confirm this routes through `ngConfirm`, not native `confirm` (aurora rule). |

### Portal supersets (keep)
- KPI tile row (Total requests / Net payout (page) / Settled requests).
- Status filter `<select>` (demo has no status filter on this tab).
- Created column + pagination (demo table unpaginated).
- Real file uploads (demo uploads are simulated clicks).

---

## 2. Invoices (`admin-invoice/index.php` vs `AdminInvoices.tsx`)

Pills All | Unpaid | To verify | Paid ✔ (portal adds per-pill counts — superset). Columns Invoice · Shop · Period · Charges · Fee VAT · Total · Status · Document · Due · actions ✔. Document "Awaiting"/"Attached" ✔. Upload doc / Verify flows exist ✔.

| # | Sev | Type | Finding |
|---|-----|------|---------|
| 2.1 | HIGH | GAP | **No "View" action / invoice-document modal.** Demo's first action per row is `t('admin.shops.view')` = `'View'` opening `<InvoiceDocument invoice/>` in a modal titled `invoiceModalTitle` = `'Invoice {{id}}'` (AdminInvoices.tsx:96-100, 170-176). Portal row actions are Transfer slip / Upload doc / Verify / Paid-label — there is **no way to view the rendered invoice document** from this tab. |
| 2.2 | HIGH | DIFF | **ID format.** Demo `INV-2989001`, `INV-SUB-NAC` style ids. Portal renders `#<?= (int)$inv->id ?>` (`index.php:153`) → `#7`. (The settle modal partial does use `INV-<id>` — inconsistent between the two surfaces.) |
| 2.3 | MED | DIFF | **Date format.** Demo `date()` lib → `'18 May 2026'`. Portal `php:d/m/Y` → `18/05/2026` (`index.php:27-29`). Applies to Due and Paid dates. |
| 2.4 | LOW | DIFF | **Period format.** Demo `i.period` seeds like `'May 2026'`. Portal falls back to `php:Y-m` (`2026-05`) when the `period` column is empty (`index.php:36-47`) — derived rows read differently from demo. |
| 2.5 | LOW | DIFF | **Empty state.** Demo `emptyTitle` = `'No invoices'` (with FileText icon EmptyState). Portal `'No invoices match this filter.'` |
| 2.6 | LOW | DIFF | **Paid label.** Demo `paidLabel` = `'Paid {{date}}'` — same in portal ✔ (concatenated, fine). Upload toast demo `'{{id}} document uploaded'`; portal uses flash messages. |
| 2.7 | LOW | DIFF | **Upload-doc semantics.** Demo `attachInvoiceDocument(i.id)` is instant. Portal opens a modal where the file is *optional* ("confirming without a file still marks the document verified") — a business-rule divergence worth a deliberate decision, not just UI. |

### Portal supersets (keep)
- Per-pill counts on the Segmented filter.
- "Transfer slip" link (shop-uploaded payment slip) — demo has no equivalent surface here.
- Attached → hyperlink to the stored doc.
- Footer caption "Total = charges + fee VAT. Verifying a payment also clears the invoice's settlement charges."
- `ngConfirm`-based Verify confirmation (aurora-compliant).

---

## 3. Shop Balances (`shop-balances.php` + `shop-ledger.php` vs `ShopBalances.tsx`)

Pills "Owing shops"/"All shops" ✔ (default `owing` matches demo default `'negative'`). Columns Shop · Collectable · Outstanding fees · Invoiced due · Running balance · Credit limit · In negative ✔. "Issue invoice now" / "Invoiced — awaiting payment" ✔. Auto-bill reconcile pass ported ✔.

| # | Sev | Type | Finding |
|---|-----|------|---------|
| 3.1 | HIGH | GAP | **Balance-breakdown drawer missing (replaced by a page that lacks the waterfall).** Demo: clicking ANY row opens `BalanceBreakdownDrawer` — subtitle `'Balance breakdown — Withdrawable vs Running balance'` with the foots-to-the-halala waterfall: `'Collectable earnings (eligible, not yet paid out)'` − `'Fees on those bookings'` = `'Withdrawable (what the shop sees)'` + `'Fees already invoiced / locked (added back)'` − `'Fees on bookings still in hold window'` − `'SMS/WA fees left behind / non-booking fees'` − `'Open invoice due'` = Running balance, plus context rows `'Requested transfer in flight'`, `'Open invoice'`, `'Of which, package sales'`, `'Credit limit'`, `'Oldest unpaid fee'` (ShopBalances.tsx:162-256). Portal: only the shop-name cell links to `shop-ledger` (rest of the row inert), and `shop-ledger.php` shows just 4 KPI tiles (Collectable / Outstanding fees / Running balance / Credit limit) + raw charges/invoices tables — **none of the 7 waterfall lines and none of the 5 context rows** (withdrawable, held-window fees, invoiced/locked add-back, non-booking fees, requested-TR-in-flight, package share, oldest-unpaid-fee age are all absent). |
| 3.2 | MED | DIFF | **Hint copy truncated.** Demo `hint`: `'Negative = the shop owes Navagoo; it auto-bills when it crosses the credit limit. Click a row for the full breakdown.'` Portal drops the final sentence (`shop-balances.php:63`) — consistent with 3.1 since rows aren't clickable, but the rendered-demo truth includes it. |
| 3.3 | LOW | DIFF | **Row sort.** Demo sorts most-negative first always. Portal controller comment says the same — verify `all` filter keeps that order (`AdminFinanceController::actionShopBalances`). |
| 3.4 | LOW | DIFF | **"Issue invoice now" confirm.** Demo issues instantly on click (`ev.stopPropagation()`); portal wraps in `data-confirm`. Acceptable hardening, but a behaviour diff. |
| 3.5 | LOW | DIFF | **In negative badge.** Demo plain `tnum` text `{age}d`; portal red pill `bg-[#dc5757]/10`. Visual dialect. |

### Portal supersets (keep)
- KPI tiles (Collectable earnings / Outstanding fees / Shops owing Navagoo).
- Full shop-ledger drill-down page (charges ledger + invoices tables + issuable-netting banner) — richer than the demo drawer *except* for the missing waterfall (3.1).
- Auto-bill flash "Auto-billed {count} shop(s)…", pagination.

---

## 4. Detailed Charges (`admin-charge/index.php` vs `AdminCharges.tsx`)

Pills All | Unpaid | Pending | Paid | Reversed ✔. Shop select + type select ✔. Export CSV + Print/PDF ✔. Columns Charge ID · Shop · Booking · Charge Date · Type · Rate · Charge · Fee VAT · Status ✔ (order: portal inserts Booking Amount after Booking and Transfer Request before Status — supersets). Pending caption ✔ near-verbatim.

| # | Sev | Type | Finding |
|---|-----|------|---------|
| 4.1 | MED | DIFF | **ID formats.** Demo `CHG-0193` and booking `NB-…` mono ids; portal `#<?= (int)$c->id ?>` and `#<booking_id>` (`index.php:325,334`). |
| 4.2 | MED | DIFF | **Rate label currency noise.** Demo `rateLabel`: processing `'3.5% + 1.00'` (`money(x,false)` = no symbol), SMS/WA `'0.20/ea'`. Portal prepends "SAR": `'3.5% + SAR 1.00'`, `'SAR 0.20/ea'` (`index.php:78-89`). Demo `perEach` = `'/ea'`; portal `'/'.Yii::t('backend','ea')` ✔ word matches. |
| 4.3 | MED | DIFF | **Date format.** Demo `'18 May 2026'`; portal `php:d/m/Y` → `18/05/2026` (`index.php:36-38`). |
| 4.4 | LOW | GAP | **Print-only header block.** Demo `.print-only` block prints `printTitle` = `'Platform Charges'` (+ shop scope), `'{{n}} charge(s)'`, filters, `'generated {{time}}'` above the table when using Print/PDF (AdminCharges.tsx:153-165). Portal binds `window.print()` with no print-only header, so the browser print lacks title/scope/timestamp (the separate Export-PDF route may cover this — `_pdf.php` exists — but the in-page print path doesn't). |
| 4.5 | LOW | DIFF | **Type filter option list.** Demo iterates `CHARGE_META` (5 types: marketing, processing, subscription, sms_fee, wa_fee). Portal filter matches those 5 ✔ but table rows can also carry `net_from_settlement` / `reversal` badges that the filter cannot select — harmless superset asymmetry. |
| 4.6 | LOW | DIFF | **CSV filename.** Demo `navagoo-charges-{scope}-{date}.csv`; portal server-side (verify `actionExportCsv` name parity — cosmetic). |

### Portal supersets (keep) — NVG-BEA-006 W1
- Date-range + Booking-ID filter row, Booking Amount (SAR) column, Transfer Request column, charge-description second line under Type, reversal cross-reference (`reverses #id`), per-type + overall totals `<tfoot>`, server Export PDF.

---

## 5. Commercial Config (`commercial-config/index.php` vs `CommercialConfig.tsx`)

All three demo sections exist (Fees & tax, Settlement & billing incl. the two subscription knobs with their exact hint strings ✔, Messaging per-channel SMS/WhatsApp with live margin badge ✔). Sell/Cost/Refund-on-fail/Free hints match (`'Charged to shop'`, `'Provider cost'`, `'0 = provider bills fails'`).

| # | Sev | Type | Finding |
|---|-----|------|---------|
| 5.1 | MED | DIFF | **Warning banner demoted + reworded.** Demo: prominent top info banner `'Changing a rate affects **new** charges only — existing ledger rows keep the rate stamped at creation.'` (noticeBefore/Strong/After, CommercialConfig.tsx:33-38). Portal: small footer note next to Save, `'Rate changes apply to new charges only — existing ledger rows keep the stamped rate.'` (`index.php:271`) — different copy AND position; rendered-demo truth puts it first. |
| 5.2 | MED | DIFF | **Save button.** Demo `saveButton` = `'Save commercial config'`, disabled until dirty, wrapped in `DisabledAction perm="admin.finance.editConfig"`, success toast `'Commercial config saved'` / `'Applies to new charges only (rates are stamped)'`. Portal: label `'Save'`, always enabled, full-page POST + flash, no dirty tracking, no permission gate. |
| 5.3 | MED | DIFF | **Card subtitles missing + section-title casing.** Demo `'Fees & tax' / 'Platform default rates'` and `'Settlement & billing' / 'Thresholds and windows'`; portal `'Fees & Tax'` and `'Settlement & Billing'` with no subtitles. |
| 5.4 | MED | DIFF | **Field-label copy drift** (demo en.ts vs portal Yii::t): `'Payment processing fee %'`→`'Processing Fee %'`; `'Payment processing fixed (⃁)'`→`'Fixed Fee (SAR)'`; `'Min marketing fee (⃁)'`→`'Min Marketing Fee'`; `'Min withdrawal (⃁)'`→`'Min Withdrawal'`; `'Carry-forward threshold (⃁)'`→`'Carry-Forward Threshold'` **and its hint `'Shop credit limit'` is dropped**; `'Settlement hold (days)'`→`'Settlement Hold Days'`; `'Marketing fee %'`→`'Marketing Fee %'` (casing). Demo suffixes the riyal glyph in the label; portal uses an in-input suffix (acceptable pattern, but the credit-limit hint is a genuine loss). |
| 5.5 | LOW | DIFF | **Margin readout.** Demo `marginLabel` = `'Margin ⃁ {{margin}}/msg'`, `toFixed(2)` (rendered "Margin ⃁0.13/msg"). Portal `'Margin: {symbol} {amount}/msg'` with `toFixed(4)` → "Margin: ⃁ 0.1300/msg". Tooltip `'Selling price − provider cost'` matches ✔. |
| 5.6 | LOW | DIFF | **Layout.** Demo: 2-column `lg:grid-cols-2` card grid with compact 2-col fields inside. Portal: stacked full-width cards with label-left/input-right rows. Visual-parity gap vs rendered demo. |

### Portal supersets (keep)
- `ppf_change_reason` mandatory-reason textarea + rate-change audit trail (NVG-BEA-002 W3).
- Group Bookings card (`max_group_size`, NVG-BEA-011 W1).
- 4-decimal messaging precision, "Blank = default" free-allowance fallback to `ShopNotificationSetting` constants.

---

## Cross-cutting

| # | Sev | Finding |
|---|-----|---------|
| X.1 | MED | **ID display convention** is inconsistent portal-wide: `TR-` (transfer table), `#` (invoices, charges), `INV-` (settle modal) vs demo `NTR-` / `INV-` / `CHG-` everywhere. One formatter helper (e.g. `IdFormatter::tr()/inv()/chg()`) would fix 1.1, 2.2, 4.1 at once. |
| X.2 | MED | **Date format**: demo uses long-form `'18 May 2026'` everywhere in this hub; portal mixes `asDate()` default (transfer-requests) and `d/m/Y` (invoices, charges). Pick the demo long-form for parity. |
| X.3 | LOW | Demo `Money` renders the riyal glyph `⃁`; portal renders literal `SAR ` prefix in all five tabs. Site-wide convention decision (MoneyHelper::riyalSign exists and is already used in commercial-config suffixes). |

---

## Ranked Top-10

1. **[Shop Balances] Row-click balance-breakdown waterfall missing** — the drawer's 7-line foots-to-the-halala reconciliation + 5 context rows (withdrawable, held fees, requested TR, package share, oldest unpaid fee) have no portal equivalent; shop-ledger page shows only 4 KPI tiles. (3.1)
2. **[Invoices] No "View" action / InvoiceDocument modal** — admins cannot see the rendered invoice from the Invoices tab at all. (2.1)
3. **[Transfer Requests] Settle-modal audit rows missing** — Shop confirmation / Confirmed at / Settled at never shown. (1.6)
4. **[Cross-cutting] ID formats** — `NTR-/INV-/CHG-` demo ids vs `TR-/#` portal, inconsistent even inside the portal. (X.1, 1.1, 2.2, 4.1)
5. **[Transfer Requests] Rows not clickable** to open Review/View modal. (1.2)
6. **[Commercial Config] Rate-stamping warning demoted** from top banner to footer fine-print, copy reworded; Save button label/dirty-gating/permission-gate diverge. (5.1, 5.2)
7. **[Cross-cutting] Date format** `18/05/2026` vs demo `'18 May 2026'` on Invoices + Charges. (X.2, 2.3, 4.3)
8. **[Transfer Requests] Missing demo micro-content**: "incl. X packages" earned sub-line, disabled "Generate invoice via API" step, mismatch text without both amounts/Δ. (1.5, 1.7, 1.8)
9. **[Commercial Config] Label/copy drift** incl. lost `'Shop credit limit'` hint on Carry-forward threshold; card subtitles dropped; margin readout format/precision. (5.3-5.5)
10. **[Charges] Rate column currency noise** (`3.5% + SAR 1.00` vs `3.5% + 1.00`) + missing print-only header on Print/PDF; **[Shop Balances] hint sentence "Click a row for the full breakdown." dropped** (dependent on #1). (4.2, 4.4, 3.2)

**Overall:** structural parity is strong (all 5 tabs, all pills, all columns, badge counts, auto-billing, quota logic ported); the residual gap is concentrated in **drill-down/inspection surfaces** (balance waterfall, invoice document view, settle-modal audit trail) and **formatting conventions** (ids, dates, currency prefix). Portal supersets are substantial and intentional (BEA-002/006/011) — do not remove during parity fixes.
