# Dashboard Refactor — Working Rules

Standing context for every session on the dashboard refactor. Read this first, every time.

Branch: `refactor/dashboard` (off `development`).
Prototype: `VoM-Dashboard-Design_1 (1).html` (design intent + data inventory, not a pixel spec).

---

## 1. Scope discipline

1. **Only dashboard-refactor work.** No drive-by fixes, no unrelated cleanups, no
   "while I'm here" changes to other modules.
2. **Any extraction/centralisation must be provably harmless.** Adding a new shared class,
   helper or component is fine. *Moving* or *changing* something an existing feature already
   calls is not — flag it to Mirna first and get an explicit yes before touching it.
   Corollary: when an existing v2 pattern is copy-pasted across files (e.g. the `--ard-*`
   tokens), **copy it again for the dashboard** rather than refactoring the existing users.
3. **Never merge `development` into this branch.** `development` carries other devs'
   unready work. Resolve at merge time on their side instead.
4. This branch is deployed **in phases, at a separate URL**. `/{locale}/companyuser` stays
   untouched and live for end users until the whole dashboard is transformed and we
   deliberately swap the URL.

---

## 2. Architecture the refactor must follow

### Routing / deployment model

| | Legacy | New |
|---|---|---|
| URL | `/{locale}/companyuser` | `/{locale}/dashboard` (working URL, TBC) |
| Controller | `Modules\CompanyUser\Http\Controllers\CompanyUserController@index` | new controller(s) |
| Blade | `companyuser::index` | new blade, master layout unchanged |
| Data | one fat server-side render | one JSON endpoint per card |

The dashboard **page** lives in `Modules/CompanyUser/Routes/web.php`, inside the
`prefix => LaravelLocalization::setLocale()` + `domain => {subdomain}.{APP_URL_BASE}` group,
under `prefix('dashboard')->name('company.dashboard-v2.')` → `middleware('company.auth')`.
Card **JSON** endpoints live in `Modules/CompanyUser/Routes/api.php` under
`prefix('v2')->middleware('api.v2.auth')` → `prefix('companyuser')` (same tenant domain,
named `company.dashboard-v2.*`). The blade still passes each URL via `route()`.
Every controller extends `App\Http\Controllers\TenantBaseController`.

### Page composition (Phase 0 shape)

Master blade stays a blade. Cards are Vue components mounted inside it:

```
companyuser::layouts.master
  └── new dashboard blade
        ├── <dashboard-alerts-strip :data-url="..."/>
        ├── <dashboard-sales-card    :data-url="..."/>
        └── … one component per card
```

### Non-negotiable per-card contract

* **One card = one Vue component = one API endpoint.** No shared mega-payload.
* Every card owns a **skeleton loader** while its request is in flight.
* Every card degrades on its own: an error or a 403 on one card must never blank the page.
* Cards fetch with `fetch()` + `credentials: 'same-origin'` +
  `Accept: application/json`, `X-Requested-With: XMLHttpRequest` — same as the v2 reports.
* Guard against out-of-order responses with a monotonic `generation` counter
  (see `CashFlowDirectReportV2.vue:530-576` for the reference implementation).

### All card endpoints now live under `api.php`'s `v2` group, for mobile reuse (2026-09-02)

`/alerts` moved first (prior commit); `/totals`, `/period` (GET+POST) followed the same
pattern this session, leaving `web.php`'s `dashboard` group with only the page route
(`GET /` → the blade) — no card JSON on `web.php` any more. All four live inside the
*existing* `Route::prefix('v2')->middleware('api.v2.auth')` group in
`Modules/CompanyUser/Routes/api.php` (not the separate `prefix('internal/v2')` block that
also exists there) — plain `v2`, matching the alerts precedent, since which of
`v2`/`internal/v2` these belong under long-term is still unspecified. Nested inside a
`Route::group(['domain' => '{subdomain}'.'.'.getenv('APP_URL_BASE')])` re-declaration,
because the surrounding `v2` group is not itself tenant-subdomain-scoped (unlike the
`companyuser` prefix group above it). Route names unchanged
(`company.dashboard-v2.{alerts,totals,period.show,period.store}`) so nothing else had to
change to keep resolving them via `route()`.

**Auth bug found and fixed by this move**: `api.v2.auth` middleware
(`App\Http\Middleware\Api\isAuthenticated`) checks `Auth::guard('sanctum')->check()`
directly but never calls `Auth::setUser()` — so it never bridges the Sanctum identity into
the *default* guard. Any controller still calling bare `auth()->id()` gets `null` even
though the request is genuinely authenticated. Caught live: a real HTTP request with a
valid Sanctum token 500'd on exactly this in `DashboardPeriodController` (`get(): Argument
#1 ($userId) must be of type int, null given`). Fixed **scoped to that controller only**
(`Auth::guard('sanctum')->id() ?? auth()->id()`) rather than touching the shared
`isAuthenticated` middleware, which serves the whole `api.v2` surface. Any future
dashboard controller moved from `web.php` to this `api.php` group needs the same check if
it touches bare `auth()->`.

**Frontend header change that comes with this move**: a card fetched via `api.v2.auth`
needs `Authorization: Bearer <token>` instead of the CSRF header, since it's Sanctum-only,
not session-based. `window.token` is already set globally by
`master.blade.php:1306`, and `useDashboardFetch.js` already sends it (from the alerts
move); `DashboardPeriodSelector.vue`'s `store()` does its own separate `fetch()` and
needed the same header added by hand. Both also need `Accept-Language: 'ar'|'en'`
explicitly — the API's locale middleware accepts only the exact code, not a browser
default like `en-US`.

Verified end-to-end with real Sanctum tokens against live Herd tenants (`curl` with
`Authorization: Bearer`), not just unit-level: `/totals` byte-identical to the
already-verified web-route figures; `/period` GET+POST both 500'd before the auth fix and
both succeeded after. Test tokens and the `user_configurations` rows the test writes were
deleted afterward.

**`/receivables`, `/payables`, and their `/documents` drilldowns followed the same move
(2026-09-03)**, on `refactor/dashboard-receivables` once this branch had pulled the above
forward from `refactor/dashboard-phase2`. Same exact pattern: added into the existing
`v2 → dashboard` group in `api.php`, route names unchanged
(`company.dashboard-v2.{receivables,payables,receivables.documents,payables.documents}`),
`web.php`'s dashboard group now has only the page route.

The Sanctum-bridge bug recurred immediately, in a new place: `ReportsAgingCustomerService`
and `ReportsAgingSupplierService`'s `applyPermissionFilter()` — the cost-center permission
layer both the aging report AND these dashboard cards share — calls bare
`auth()->user()->adjustRolesLikeOwnerPermissions()` **unconditionally on every call**, not
gated behind whether a cost-center filter was even requested. That method can't be scoped
the way `DashboardPeriodController`'s own `authUserId()` fix was scoped, because it also
serves the full aging report page (session auth, stays on `web.php`, must keep working
unmodified there). Fixed once at the right altitude instead: a `bridgeSanctumUser()`
helper on `BaseDashboardCardController` — `Auth::setUser(Auth::guard('sanctum')->user())`
when the default guard has no user yet — called at the top of every action on
`DashboardReceivablesController`/`DashboardPayablesController` (both `__invoke()` and
`documents()`; the main card fetch calls `getGrandTotalSummary()`, which reaches the same
`applyPermissionFilter()`). Scoped to dashboard card controllers only, not the shared
`isAuthenticated` middleware every `api.v2` endpoint uses — the same "don't touch what
other features call" boundary as the first fix, just moved one level up so the next new
card automatically inherits it instead of needing its own copy.

Verified live with a real Sanctum token against `mohamedtolba.vom.test`: `/receivables`
and `/payables` returned the same bucket figures already verified via raw SQL/tinker
(one bucket had shifted 500.00 SAR from `period_3` to `period_4` between the two
verification passes — expected, a document crossed the 90-day boundary when the
calendar day rolled over, not a discrepancy); both `/documents` endpoints returned
correct rows with working `document_url`/`entity_url` on the first real HTTP request —
i.e. `bridgeSanctumUser()` was confirmed both necessary (would have 500'd without it,
same failure mode as the period controller before its fix) and sufficient. Test token
deleted afterward.

---

## 3. The dashboard blade rendering bug (Phase 0 — fix before anything else)

Two real defects on `Modules/CompanyUser/Resources/views/index.blade.php`:

1. **The Vue bundle is never loaded on the dashboard.** `resources/js/app.js` is the only
   Vue entry (`new Vue({ el: '#app' })`, `webpack.mix.js` → `public/js/app.js`). The master
   layout does **not** include it — every Vue page pulls it in per-page, e.g.
   `Modules/Reports/Resources/views/cash_flow_direct_v2/cash-flow-direct-v2.blade.php:3-5`.
   The dashboard never does, so any `<my-component/>` on it renders as an inert unknown
   element. This is the "vue components don't render" symptom.

2. **`@section('extra-styles')` is a dead section.** The master layout yields
   `extra_styles` (underscore) at `layouts/master.blade.php:13`. The dashboard declares
   `extra-styles` (hyphen) at `index.blade.php:4`, so the `dashboard/index.css` link and
   ~160 lines of inline CSS have never reached the page. **The hyphenated spelling is used
   by ~18 other CompanyUser views too** — only `includes/pdf-headrtl.blade.php` yields it.

   ⚠️ **Do not "fix" this by adding `@yield('extra-styles')` to master.** That would switch
   on ~18 dormant stylesheets across the app at once. Violates rule 1.2.
   ⚠️ Renaming the section on the *legacy* dashboard also isn't free — it would apply CSS
   that page has never had. Legacy page gets the script tag only; the new dashboard blade
   is written correctly from the start.

Safe mechanism for per-page assets: `@push('styles')` / `@push('extra_scripts')` — master
has `@stack('styles')` (`:14`) and `@stack('extra_scripts')` (`:1011`).

**Vue mount constraint:** `<div id="app">` spans `master.blade.php:883-979` and carries the
comment *"do not put any script tag inside it otherwise error will appear in console"* —
Vue 2 compiles that subtree as an in-DOM template. All scripts go in `extra_scripts`, which
is yielded at `:1010`, outside `#app`.

---

## 4. Design language — inherited from the v2 reports, not from the prototype CSS

The prototype supplies **information architecture and hierarchy**. Visual tokens come from
the v2 report components. Copy these into a dashboard-local token block:

```css
--ard-surface:   #ffffff;
--ard-border:    rgba(26, 43, 54, 0.12);   /* 0.5px borders, not 1px */
--ard-radius-lg: 12px;                      /* cards */
--ard-text:      #1a2b36;
--ard-muted:     #5a6b7a;
/* KPI tile (ReportKpiCard.vue) */
--rk-surface: #f4f6f8;  --rk-radius-md: 8px;
--rk-info: #0d6efd; --rk-success: #198754; --rk-warning: #e67e22; --rk-danger: #c0392b;
```

* Cards: `.ard-hero.ard-hero--surface` — white, `12px` radius, `0.5px` hairline border,
  `box-shadow: 0 1px 4px rgba(15, 52, 67, .06)`, padding `18-20px`.
* Skeleton: `.ard-skeleton` / `.ard-skeleton__row` — 12px tall, `6px` radius, `#e4e9ed`,
  8px gap, staggered widths (72% / 55% / 88%).
* Riyal: `class="icon-saudi_riyal"` for SAR; ISO code text for any other currency
  (`ReportKpiCard.vue:27-34`). **Never** reuse the base64 mask hack from the prototype.
* Reusable as-is: `resources/js/components/ReportKpiCard.vue`,
  `Modules/Reports/.../ReportV2EmptyState.vue`.
* RTL: every component takes `lang` and sets `:dir`. Arabic is the primary locale here.

---

## 4a. In-DOM template gotcha — never self-close a custom element tag

Found and fixed in Phase 2: a self-closed custom tag (`<dashboard-period-selector ... />`)
silently swallowed every sibling written after it as its own discarded children, because
this page is an **in-DOM template** — Vue 2 compiles whatever the browser already parsed
into `#app`, not a template string, and browsers do not treat `/>` as self-closing a
non-void custom element; they just drop the slash and leave the tag open.

Confirmed both ways: parsing the exact markup with a spec-compliant HTML5 parser nested
`dashboard-alerts-strip` and `dashboard-card` inside `dashboard-period-selector`; the same
check against the real rendered page (through `DashboardController::index()`, tenant
`orderapp`) showed the same nesting before the fix and true sibling depth after it. Since
`DashboardPeriodSelector.vue` has no `<slot>`, the swallowed content simply vanished — no
console error, no failed request, nothing to see. This is why the alerts had shipped and
worked correctly through Phase 1 (a single top-level element has nothing after it to
swallow) but broke the moment Phase 2 added a sibling in front of it.

**Rule for every future dashboard component tag in this blade: always write an explicit
closing tag (`<dashboard-x ...></dashboard-x>`), never `/>`.**

---

### Custom date range — the shared v2 DatePickerComponent, not a bespoke row

Rebuilt to use `date-picker-component` (`resources/js/components/DatePickerComponent.vue`,
a flatpickr wrapper already global via `Vue.component('date-picker-component', ...)` and
used throughout the v2 report filters, e.g. inside `ReportFiscalYearOrCustomRange.vue`).

The picker itself is never visible: it is stretched invisibly (`position:absolute;
inset:0; opacity:0`) over the exact footprint of the "Custom range" pill inside one
`position:relative` wrapper, and the pill's click calls the picker's public `.open()`
method (exposed via a template `ref`) directly — since flatpickr always anchors its
calendar to whichever input it is bound to, the calendar visually opens right off the
pill with no separate row and no visible text input of its own. Selecting a complete
range (`mode="range"` only emits `input` once both dates are picked) applies immediately;
there is no separate confirm step. Deep-reaching flatpickr's own generated `<input>` from
scoped CSS uses `::v-deep`, matching this codebase's existing vue-loader 15 convention
(confirmed against `report-kpi-card` overrides across several v2 report components) —
**not** `:deep()`, which is a Vue 3 SFC compiler syntax this build does not support.

Once a custom range is applied, `activeType` becomes `'custom'` and nothing else — since
the segmented buttons and the pill all key off that single reactive value, none of the
four presets can show active at the same time by construction, and the pill's own label
switches from the generic "Custom range" to the resolved dates so the selection stays
visible without a second element.

### Header-to-content gap

`.content-wrapper { padding: 1.8rem; }` (`public/assets/css/app.min.css`) is this theme's
uniform default spacing on every side of the content area. The period selector cancels
just the top of it (`margin-top: -1.8rem` on `.dps`) so the card sits flush under the app
header. Deliberately **not** the `-3rem` every v2 report's `.raf-toolbar-full` uses for
the same purpose — theirs also cancels a breadcrumb row rendered above their own toolbar,
which this page has none of (confirmed: this master layout has no `@yield('breadcrumb')`
anywhere — the dead `@section('breadcrumb')` block that was in this blade did nothing and
has been removed). Copying `-3rem` here would overshoot and tuck the card in under the
header.

## 4b. Ledger basis — how account groups are resolved (Phase 2)

`LedgerBalanceService` is the backbone Story 1 requires: figures come from
`journal_records`, where invoice/bill postings AND hand-written journal entries both land,
so a manual entry to a revenue account moves the tile by construction. It is deliberately
not built on `RetrievingDataForDashboardService`, which sums transaction tables — the
exact defect Story 1 describes.

**Groups are defined by `accounts.subcategory_id`, never by account id.** Two reasons, both
measured, not assumed:

* **Seeded account ids are not stable across tenants.** `AccountsEnum::COST_OF_GOODS_SOLD`
  is 64, but on `orderapp` account 64 is a user-created bank account. Only the parent
  accounts seeded at install keep their ids; anything in the "children" range is whatever
  that tenant created. Any earlier note in this file pairing a group with an account id
  from that range is wrong — use the subcategory.
* **Some seeded names are wrong anyway.** `AccountsEnum::SALES_DISCOUNT` (27) is seeded
  with `name_en = "Purchases Discount"`, and `PURCHASES_DISCOUNT` (48) with
  `"Sales Discount"`. Their `subcategory_id` is right where their name is not.

**No `accounting_trees` join is needed** for Story 1's "and every child account under
them": a child always carries its parent's `subcategory_id`. Verified by joining every
level-1 parent/child edge on `orderapp` and looking for a mismatch — there are none.

| Tile | Subcategories | Direction |
|---|---|---|
| Total Sales | 8 Sales, 9 Other Revenue | credits − debits |
| Total Purchases | 13 Purchases | debits − credits |
| Total Expenses | 10 Marketing, 11 Operating, 12 General | debits − credits |
| Net Profit | *derived* | Sales − Purchases − Expenses |

`accounting_sub_categories.category_id` puts **Purchases under the Expenses category**, but
the P&L reports it as its own line and it is its own tile, so Total Expenses excludes it
and Net Profit subtracts both — otherwise the two tiles double-count. Net Profit is derived
from the other three rather than queried separately, so the four tiles can never disagree
on screen (Story 1 AC).

Scoped by `journal_records.journal_date` (never null — 0 of 667,623 rows on `orderapp`), so
a back-dated entry lands in the period it belongs to. Soft-deleted records excluded (1,217
on `orderapp`).

Verified against raw SQL on two tenants — `orderapp` (16,869,814.52 / 0 / 16,464,181.44)
and `mohamedtolba` (8,011,933.05 / 1,213,416.28 / 2,819,214.16) — exact to the halala.
Note: reconciliation is against the P&L's *grouping structure* via SQL, not against a live
`IncomeStatementService` run; that end-to-end comparison is still worth doing before
release, per Story 1's AC.

### Change % uses the ABSOLUTE previous value

Story 2 gives the formula as `(current − previous) ÷ previous × 100`. That is right for the
ordinary case but **inverts the sign when the previous period is negative**, which happens
for real: a signed ledger group is credits minus debits, so a window where returns and
discounts outweighed sales comes out below zero (`orderapp`'s pre-fiscal-year window has
revenue of −501,620.70). With the signed denominator that tenant's Total Sales rendered as
**"−3463.1%" beside an UP arrow and a "good" verdict** — the number contradicting the
direction it was describing. Dividing by `abs(previous)` keeps the percentage sign agreeing
with `direction` in every case, and is identical to the story's formula whenever previous
is positive.

### Sign handling reviewed against the existing weekly-insights email

Asked explicitly to test the total cards for credibility/accuracy and to mirror how the
existing weekly-insights email (`InsightsCalculationService`, sent via
`resources/views/emails/weekly_insights.blade.php`) handles sign and negative numbers,
since it is the one other place in the codebase that already does "period-over-period
change, coloured good or bad" for these same four metrics. Two things came out of that
comparison, one confirmed and one fixed:

* **Purchases is HIGHER_IS_BAD, not NEUTRAL — this was wrong and is now fixed.** The
  insights email's own per-metric config marks purchases `'isPositiveTrend' => false`, the
  *same bucket as expenses* ("more purchases might be bad (depends on context)"), not
  neutral. An earlier version of `DashboardTotalsService` gave Total Purchases
  `MetricDeltaMeaningEnum::NEUTRAL`, so a rising purchases tile was never coloured either
  way. Changed to `HIGHER_IS_BAD` to match: less purchases now reads "good" (green), more
  reads "bad" (red), exactly like Expenses. Verified on `clientissue` this-year: Total
  Purchases −16,904.32, direction `down`, now verdict `good` (previously `neutral`).
* **The insights email's own percentage formula was NOT copied, on purpose.** Its
  `getPercentageChange()` branches on `previous < 0 && current > previous` and only takes
  `abs()` in that one branch; worked through by hand, it still disagrees with its own
  `trend` field in the case it doesn't special-case — previous negative, current *more*
  negative (e.g. −100 → −150 computes "+50%" next to a `trend` of `down`). The template
  works around this by never trusting the percentage's sign at all: it always displays
  `abs($percentageChange)` and derives both the arrow and the colour from `trend`
  (a plain `current <=> previous` comparison) instead. This dashboard already does the
  same two things — `ComputingPeriodChangeService::direction()` is a plain comparison, and
  `DashboardTotalsRow.vue`'s `formatPercent()` takes `Math.abs()` before display — but goes
  one step further at the source: dividing by `abs(previous)` unconditionally (no branch)
  makes `change_percent`'s own sign agree with `direction` in *every* sign combination,
  including the one where the insights formula doesn't. Verified with a ten-case matrix
  covering every sign combination of current/previous (both metric meanings, crossing
  zero in both directions, flat, improving-loss, worsening-loss) — sign agreed with
  direction in all ten; see `scratchpad/change_matrix.php` in this session's transcript.

### Total cards — credibility re-verified against raw SQL (2026-09-02)

Re-ran the ledger totals through fresh, independent raw SQL (not reusing the query
builder under test) on two live tenants, after the purchases-meaning fix above, since
nothing in `LedgerBalanceService`/`LedgerAccountGroupEnum` itself changed:

* `orderapp`, this-quarter (2026-07-01 .. 2026-09-02): revenue, expenses and purchases all
  matched a hand-written SQL query against `journal_records` (`transaction_type`/`value`)
  to the halala — 795,478.37 / 134,223.28 / 0.00.
* `clientissue`, this-year (2025-01-01 .. 2026-09-02), which exercises **both** ledgers at
  once: revenue 6,577,231.20, expenses 68,653,421.93, purchases −16,904.32 all matched raw
  SQL summed across `journal_records` **and** `single_record_accounts` /
  `single_record_journal_entries` independently, to the halala.
* Net Profit coherence (`revenue − purchases − expenses == net_profit`) holds exactly on
  every period checked, including where purchases itself is negative (clientissue: 6,577,231.20 − (−16,904.32) − 68,653,421.93 = −62,059,286.41, matching the service's own
  output).

No discrepancy found; the ledger totals engine is unchanged and this re-confirms the
exact-match verification recorded above under "TWO LEDGERS" and "POS split".

### TWO LEDGERS — the single most important fact in this phase

**This estate has two independent ledgers and every financial figure must read both.**

| Ledger | Tables | Shape |
|---|---|---|
| Standard | `journal_records` | `transaction_type` discriminator + `value` |
| Single-record | `single_record_accounts` + `single_record_journal_entries` | separate `debit` / `credit` columns |

Independent id sequences, no overlap, no view joining them. Measured on
`VOM_tenant_clientissue` for Jan–Sep 2026: `journal_records` holds revenue of
**1,348,489.02** and the single-record ledger holds a further **1,342,386.00**. The first
version of `LedgerBalanceService` read only `journal_records` and therefore showed that
tenant **roughly half its real revenue**, silently. `clientissue` has **zero invoices** —
all of its trade is Foodics orders posted straight into the single-record ledger — so
nothing about the invoice tables would have revealed this.

Any future figure added to this refactor (cash position, VAT, trend chart, expense split)
must union both ledgers or it will be quietly wrong for every single-record-mode tenant.

### POS split — VERIFIED against real Foodics data

Provenance is recorded differently per ledger, so classification differs per ledger:

| Ledger | POS signal |
|---|---|
| Single-record | `single_record_journal_entries.integration_type` — set on the entry itself (`'foodics'` on all 369,526 rows on `clientissue`) |
| Standard | `success_synced_order_manual_entry.journal_entry_id` (exact FK) **UNION**ed with an exact-match on a fixed set of integration-generated memo strings — see below, this is a 2026-09-02 correction |

`integration_type` is preferred over joining `success_synced_order_single_records`: no join,
and it is strictly more complete (the sync table matches 369,515 of those 369,526 entries).

⚠️ **`success_synced_order_single_records.journal_id` does NOT reference `journal_entries`.**
It references `single_record_journal_entries` (100% match, 369,515/369,515; ids run to
388,561 where `journal_entries` maxes at 13,010). An earlier version joined it against
`journal_entries` and matched only 10,190 rows by coincidence of overlapping id ranges.

Verified on `clientissue`, current fiscal year, against raw SQL on both sides (figures
below are POST the 2026-09-02 memo-match correction; see that section for the pre-fix
numbers this superseded):

```
Total Sales  6,577,231.20  =  VoM −3,885,272.64  +  POS 10,462,503.84
```

The POS and VoM components each match an independent SQL query exactly. The VoM share is
derived as `rounded total − rounded POS` rather than rounded independently, because
rounding all three separately left them one halala apart on this tenant — a visibly
inconsistent sum on a card whose whole point is that the split explains the total without
changing it (Story 1). VoM being *negative* here is real and explained below, not a bug in
the split arithmetic — see "the residual VoM figure" at the end of the next section.

A tenant with no integration-sourced postings gets `pos.enabled = false` and plain
single-figure tiles, unchanged — confirmed still true on `orderapp`.

### Standard-ledger POS signal corrected — `success_synced_order_manual_entry` is empty everywhere (2026-09-02)

The user noticed `clientissue`'s Total Sales VoM/POS split for this-quarter showed
**87,783.59 as "VoM"** despite the tenant having **zero rows in `invoices`** and zero
customer receipts — there was no possible source for genuine VoM-created sales. Investigation:

* All 52 standard-ledger revenue rows behind that figure were real — 48 were Foodics
  orders (memo `"Foodics اضافة طلب فى"` etc.), 4 were genuine non-POS "Other Income - Used
  oil" sales (1,028.50). The Foodics rows failed the `success_synced_order_manual_entry`
  check and fell into the VoM bucket by default.
* **`success_synced_order_manual_entry` has ZERO rows on every local tenant, `clientissue`
  included** — not just for this window, tenant-wide, always. The exact-FK signal this
  filter has relied on since Phase 2 has never actually been populated locally. Flagged as
  a separate, out-of-scope bug in the Accounting sync pipeline (`SyncingOrderService::
  createSuccessOrderManualEntry()` — spawned as its own task, not fixed here).
* **`migration_batch` (a JSON key in `journal_entries.meta_data`) was tried first and
  rejected** — it is not a POS signal at all, it marks "this row was part of the pre-go-live
  ledger backfill." Clientissue's one batch (1,887 rows) is dominated by 1,222 completely
  unrelated entries (bank, salaries, rent, depreciation, VAT settlement — 5.4M SAR) with no
  memo at all. Trusting it alone would have misclassified all of that as POS.
* **The gap turned out to be live and ongoing, not historical.** 2,514 standard-ledger
  entries with a Foodics memo and no `migration_batch` exist tenant-wide, spanning
  2024-10-01 through 2026-08-05 (days before this was found) — clientissue's Foodics
  integration keeps writing directly into the standard ledger, continuously, with no
  `success_synced_order_manual_entry` row ever appearing for any of it.
* **Fix: an exact match against the finite set of literal memo strings the sync code
  itself generates.** `SyncingOrderService::prepareJournalEntryObject()` and
  `PreparingOrderDataForCreatingJournalEntryService::execute()` both build the memo as
  `__('companyuser::integration.'.$transactionType.' manual entry description
  '.$integrationType)` — extracting every value that key pattern can produce, across both
  `en`/`ar` and all 5 integrations (Foodics, Marn, LazyWait, Prexle, Zid), gives a fixed
  list of 30 exact strings (`LedgerBalanceService::INTEGRATION_ENTRY_MEMOS`). This is an
  **exact match on a code-controlled, enumerable set**, not the free-text pattern *search*
  Story 1 warns against — full machine-generated sentences like "Create purchase order
  into Foodics" are not something a hand-written correction produces by accident.
  Confirmed against a real counter-example: `orderapp` has a genuine memo containing
  "Foodics" — `"Card last 4 digit: 0581 / Merchant: Foodics / Card label: MKT -
  Subscriptions"` (a card-statement import for paying the tenant's own Foodics
  *subscription bill*) — which does not equal any of the 30 strings and is correctly left
  classified as non-POS.
* **Confirmed a no-op everywhere else.** Checked every local tenant against the full
  30-string list: only `clientissue` has any matches (3,179 rows, all Foodics — the tenant
  doesn't use Marn/LazyWait/Prexle/Zid). `orderapp`, `mohamedtolba`, `26accounting` are
  unaffected.
* **Verified against real data.** `clientissue` this-quarter: VoM/POS split corrected from
  (87,783.59 / 86,857.25) to (**1,028.50 / 173,612.34**) — 1,028.50 is exactly the 4
  "Other Income - Used oil" rows, the only genuinely non-POS revenue left. Totals
  themselves are unchanged on every tenant (174,640.84 for the quarter, 6,577,231.20 for
  the year) — Story 1's "the split explains the total, never changes it" holds.

**The residual VoM figure (−3,885,272.64 for the full year) is a known, flagged
limitation, not something this fix resolves.** It is entirely one opaque bucket: 50 debit
and 57 credit rows against revenue accounts, `transaction_type_id=8`
(MANUAL_JOURNAL_ENTRIES), **no memo at all** — the same historical migration-batch bucket
that also covers the tenant's entire unrelated general ledger (payroll, rent, bank,
depreciation). There is no reliable signal in this data to tell whether any given row in
that bucket is a Foodics consolidation entry or an unrelated manual correction — the memo
is empty, so the exact-match fix above cannot reach it, and guessing based on account
subcategory alone (`if it touches revenue, assume Foodics`) is exactly the kind of
unverified assumption RULES.md's scope discipline says to flag rather than make. Left as
VoM; noted here so a future pass doesn't have to re-derive this.

### Riyal glyph — inherits the figure's size exactly, never scaled down

The government-issued glyph (loaded from `@emran-alhaddad/saudi-riyal-font`; confirmed by
fetching the package's own CSS) is authored as `.icon-saudi_riyal::before { font-size:
inherit; color: inherit; }` — it is designed to sit at exactly the height and colour of
the number beside it. Both `.dtr__riyal` (totals row) and `.das__riyal` (alert strip)
previously overrode this with their own `font-size` (0.42em and 0.9em) and, on the totals
row, a muted colour — shrinking and greying out a symbol the spec requires to match the
figure exactly. Fixed by removing the overrides; both classes now do spacing only
(`margin-inline-start`) and let the glyph inherit its container's size and colour.

### `status` — a second signal on each total, separate from `change.verdict`

`change.verdict` (good/bad/neutral) answers "is this moving the right way" — see
[Change % uses the ABSOLUTE previous value](#) above. It says nothing about whether the
figure itself is currently a problem, and the two can disagree: measured on `clientissue`
(this-year), `net_profit` is -62,059,286.41 yet reads `down/bad` for a completely
different reason (it is *falling further* into loss) — but even a loss that is
*improving* would still read `good` while still being a loss. A card showing only
`change` can render an active loss in green with nothing on it saying so.

`DashboardTotalCard::toArray()` now adds a `status` block:

```json
"status": { "is_loss": true, "label": "Loss" }
```

Computed uniformly from the sign of `total` — every group can go negative, not just Net
Profit (measured on `clientissue`: Total Purchases -16,904.32, returns outweighing the
period's purchases). `net_profit` passes its own `lossLabel` (`totals.loss` = "Loss" /
"خسارة"); every other tile falls back to the generic `totals.negative` ("Negative" /
"سالب") since "loss" is not the right word for negative purchases. `DashboardTotalsRow.vue`
renders this as a filled danger chip (`--dtr-danger` / `--dtr-danger-bg`, deliberately a
different token from `--dtr-bad` which colours the delta line) next to the label, plus a
matching danger tint on the card's top edge — so the card is unmistakable regardless of
what `change.verdict` says.

### Top-edge hairline — solid, not a gradient, and now animates in on every load (2026-09-02)

`.dtr__card::before` used to be a `linear-gradient` fade; there is no gradient anywhere
else in this design, so it's now a flat `background: var(--dtr-accent)` (or
`var(--dtr-danger)` for the loss variant above — that rule had its **own** separate
gradient, easy to miss since it's declared far from the base rule; both had to be fixed,
not just the base one). Still exactly 8px tall, unchanged.

Animates its width in from 0 on every load, the same technique
`DashboardAgingCard.vue`'s bucket bars use: a `::before` pseudo-element can't be bound
directly by Vue, so the real `<article>` element carries an inline
`:style="{ '--hairline-width': animated ? '100%' : '0%' }"`, and the pseudo-element reads
it via `width: var(--hairline-width, 0%)` plus a `transition`. `animated` resets to
`false` and re-arms via a double-`requestAnimationFrame` (after `nextTick`) on every load,
not just the first — so a period change replays the animation, matching the aging cards'
own behaviour on their reloads. `prefers-reduced-motion: reduce` drops the transition
entirely. The pre-existing `.dtr__card--skeleton::before { background: none; }` rule needed
no change: it already suppresses the hairline outright regardless of width, so skeleton
cards were never affected by either the old gradient or the new animated width.

### Alert strip empty state — was disappearing entirely, not "hidden" by data

`DashboardAlertsStrip.vue` rendered nothing at all — not even a wrapping `<section>` —
once loaded with zero applicable alerts, per a deliberate original design choice ("an
empty strip is noise"). In practice this made "nothing needs attention" indistinguishable
from a broken fetch: no skeleton, no message, just a gap. `dashboard_v2.php`'s
`alerts.empty` string (in both `en`/`ar`) already existed for exactly this case — defined
in Phase 1, never wired to anything.

Confirmed this is genuinely reachable, not just theoretical: `clientissue` (`taxable=0`,
`zatca_comply=0`, zero rows in `invoices` and `report_customer_aging` — a POS-only tenant
with no formal invoicing) resolves to an empty alert list today (verified live via
`DashboardAlertsService::execute()`), so this tenant saw a blank strip.

Fixed by rendering `report-v2-empty-state` (already global, already used by every v2
report for its own "nothing here" case — §4: "Reusable as-is") in the empty case instead
of hiding the section. The strip's root `<section>` is no longer conditional at all: it
always shows exactly one of skeleton / error / empty-state / cards.

---

## 4c. Receivables & Payables — how the aging cards are resolved (Phase 3)

Two cards, one DTO shape (`DashboardAgingCard`), reused for both — total outstanding, a
5-bucket breakdown (Current / 1-30 / 31-60 / 61-90 / 91+), document/entity counts, and an
optional action link. Both are **as-of-today, not period-selector-driven** — an aging
balance is a position at an instant, the same reasoning the overdue-invoices alert card
already established for this exact table.

### Receivables: zero new calculation, by design

Explicit instruction for this card: no new bucket math, no new query — call
`ReportsAgingCustomerService::getGrandTotalSummary()` exactly as the full Accounts
Receivable Aging Report screen already does, and add only the one thing nothing needed
before this card existed: each bucket's percentage of the total.
`DashboardReceivablesService` does exactly that and nothing else. `getAgingPeriodLabels()`
(already public on that service) supplies the bucket labels, so even the labels are not
duplicated.

Percentage is `bucket_amount / abs(total) * 100`, not `/ total` — dividing by the signed
total would flip every bucket's sign a second time in a net-credit position (total < 0,
returns outweigh invoices), the same class of bug already fixed once for the totals row's
change-percent in `ComputingPeriodChangeService`. A percentage that rounds to `-0` (a
halala-scale bucket in an otherwise large total) is normalized to `0` before it reaches
JSON — cosmetic, but `"-0%"` on screen reads as a bug even when the math is right.

### Payables: `report_supplier_aging` — a genuine mirror, built from scratch

No payables equivalent of `report_customer_aging` existed anywhere before this phase.
Built as a structural mirror of the receivables side, end to end, not just a lean query
for the dashboard card — the instruction was explicit: full backend, so an actual Accounts
Payable Aging Report screen can be built later needing only its controller/frontend
(filters, pagination UI, etc.), no further backend work.

| Receivables | Payables |
|---|---|
| `report_customer_aging` | `report_supplier_aging` (new migration, same shape, `supplier_id` not `customer_id`) |
| `ReportCustomerAging` model | `ReportSupplierAging` model — same scopes, same accessors |
| `ReportsAgingCustomerService` (1123 lines: summary-by-customer, detail drill-down, net balance, grand total, real-time sync, column/label helpers) | `ReportsAgingSupplierService` — full mirror of every method, `s/customer/supplier/`, `invoice`/`invoice_return`/`debit_note` → `purchase_bill`/`bill_return` |
| Inline `updateTransactionRecord()` calls from 11 Sales services | **Observers instead** (`BillAgingReportObserver`, `BillReturnAgingReportObserver`) — a deliberate asymmetry, not an oversight: Purchases has no existing inline call site to piggyback on, and `Model::increment()`/`decrement()` (how `paid_amount` actually changes on a bill — `AssigningBillsToCurrentReceiptService`, `DeletingSupplierReceiptService`) does fire `updating`/`updated` events in this Laravel version — confirmed by reading `Model::incrementOrDecrement()` itself before relying on it |
| — | **Plus a second observer on the pivot tables** (`BillReceiptAllocationAgingReportObserver`, on `BillPaymentReceipt`/`BillReturnPaymentReceipt`) — mirrors `BillReceiptAllocationReportObserver`, which already exists for `report_purchases_summary` and solves the identical problem for that table: a bill/bill-return observer alone misses a pivot row changing without the parent being saved |
| `report:customer-aging:populate-transactions` | `report:supplier-aging:populate-transactions` — same `--website-id` / `--sync` options, same truncate-and-rebuild-from-the-pivot strategy (recomputes `paid_amount` from `bills_payment_receipts`/`bills_returns_payment_receipts`, not the live column, so a rebuild self-heals any drift) |

**Mirrors the receivables side's own gaps on purpose, not just its behaviour**: deletion
isn't wired to either side (checked `DeletingInvoiceService` — it never touches
`report_customer_aging` either), so a deleted bill's aging row is only cleared by the next
full repopulate on both sides equally. Making payables "better" here would make Story 3
AC8 ("one calculation") harder to reason about, not easier.

No action link on the payables card yet — there is no Accounts Payable Aging Report route
to deep-link to. `DashboardAgingCard` already treats a null action as "no link" (same
convention as `DashboardAlert`), so the card renders correctly without one.

### Credibility — verified against raw SQL and the real aging report, not just self-consistency

* **Populate job vs. independent raw SQL** (`mohamedtolba`, the only local tenant with
  real payables data): hand-written SQL over `purchase_bills`/`bill_returns` joined to
  their pivots, computed with no shared code — 73 outstanding bills (842,354.22), 23
  outstanding bill returns (33,248.18), net **809,106.04**. `report_supplier_aging` after
  `--sync` populate: **809,106.04**, exact match.
* **`ReportsAgingSupplierService::getGrandTotalSummary()` vs. the same raw SQL**: total
  outstanding 809,106.04 (matches), `total_transactions` 96 = 73 + 23 (matches), and the
  5 bucket amounts sum to 809,106.04 exactly (107.49 + (−41.68) + 144,000 + 500 +
  664,540.23).
* **Receivables card vs. the alert strip's own figure**: `orderapp`'s
  `getGrandTotalSummary()` returns total_outstanding 3,477,848.35 across 1,269
  transactions — the *exact* figure `OverdueInvoicesAlertResolver` already shows on the
  alert strip ("1269 invoices past their due date", 3,477,848.35 SAR), computed by a
  completely independent query. Two different code paths reading the same table agree to
  the halala.
* **Zero-result tenants checked, not assumed**: `clientissue`/`orderapp`/`26accounting`
  have zero rows in `purchase_bills` at all (not a bug, a POS-only or bills-unused
  tenant); `clientissue2` has 42 bills, all already fully paid (`total = paid_amount` on
  every row) — a legitimately empty payables card, confirmed by querying the source table
  directly rather than trusting the empty result.
* **Applied and populated on all 5 local tenants**: `clientissue`, `clientissue2`,
  `orderapp`, `mohamedtolba`, `26accounting` — one more tenant (`clientissue2`) than every
  earlier phase in this document tracked; it only surfaced because the populate command
  iterates `Website::all()` rather than a hand-maintained list, which is exactly why it
  does that instead of hardcoding tenant names.

### Design

Both cards share one Vue component, `DashboardAgingCard.vue` — a label, the total figure
(same riyal-inherits-figure-size convention as every other card), one **segmented bar**
(all 5 buckets as contiguous proportional slices of a single track, not five separate
bars), then a legend list below it.

**Revised once, on design review.** The first pass gave each bucket its own full-width
bar with the amount above it and the percentage beside it — on review this put the amount
and the percentage close enough together to misread which was which. Fixed by putting
label, amount and percentage on the *same* row as three independently right-aligned grid
columns, and collapsing the five separate bars into one combined bar (closer to how a
stacked-bar aging chart is usually drawn, and it reads as one "shape of the debt" rather
than five things to compare).

**Colour — revised a second time, on further design review.** The first pass reused the
aging report's own `.aging-badge.current/.period-1../.period-4` colours
(`customer_aging/index.blade.php`: saturated green → amber → orange → red → darker red)
verbatim. Feedback: too loud once blown up into solid bar segments and swatches rather
than small text-on-tint badges, and asked to stay within VoM's actual brand palette
(Brand Guidelines: primary teal `#17baa3`/`#0d8a7a`, indigo `#0352d1`, orange `#ff7300`,
magenta `#e370c7`) except where a colour is genuinely a success/danger state. Only the two
ends of the scale are actually semantic — "not yet due" is success, "severely overdue" is
danger — so only those two reuse this app's own already-muted tokens verbatim
(`--dtr-good` `#198754` and `--dtr-danger` `#b3261e`, both from `DashboardTotalsRow.vue`).
The three buckets in between aren't a danger escalation yet, they're just "further
along", so they now use the brand teal/indigo/orange instead of a manufactured
amber-to-red ramp for a distance that isn't dangerous. Every segment and swatch is
additionally rendered at reduced opacity (0.82 / 0.88) rather than full-strength — the
second, complementary way the same "too loud" note was addressed, softening the colour by
letting the track/card background show through rather than by re-deriving new, less
saturated hex values by hand. A bucket that is actually a **credit** (`amount < 0` — a
return outweighing what's owed in that window, seen for real on `mohamedtolba`'s payables
1-30-day bucket) breaks from this scale entirely and uses the brand magenta instead: it
isn't a worse version of 90+ days, it's a different kind of number, and colouring it red
would say the opposite of what it means.

**The action link lives in the header, not a trailing row — and the first fix for this
was incomplete.** A card with no action (payables has none yet) and one with an action
must still come out the same height and width. Moving the link into a header icon was the
right call but wasn't sufficient on its own: with `v-if="card.action"` on the icon
element itself, the header row's height was still set by whichever child was tallest, and
for a tenant with zero documents (hiding the meta line entirely) the 30px icon became
that tallest child on the card that had one — so the card *with* an action still came out
taller than the one without, exactly reproducing the original complaint on empty data.
Caught live on `clientissue` (both cards legitimately at 0.00) via a real browser
screenshot, not assumed. Fixed by keeping a fixed 30×30 `.dac__action-slot` in the DOM
unconditionally and only conditioning the `<a>` inside it — the header's height no longer
depends on whether an action exists at all. Verified side by side afterward with two
outlined empty-data cards ending at the identical pixel row.

Every segment animates from 0 width to its real percentage on load (and on every reload,
not just the first) — a double `requestAnimationFrame` between resetting the width to 0
and setting the real value, because a single rAF (or a bare `nextTick`) sometimes lands
before the browser has actually painted the 0-width frame, and the bar then just appears
at full width with no visible motion. Verified live (not assumed from the code):
screenshotted the same page immediately after reload vs. after settling, and the
31-60/91+ day segments are visibly shorter mid-animation than at rest — re-verified again
after the redesign, same result.

A skeleton loader is non-negotiable on every dashboard component per instruction — this
card's is a title bar, a figure bar, and a row of bar-shaped placeholders, matching the
shimmer convention already used by the alerts strip and totals row.

### Top-edge hairline — brought in line with the totals-row fix (2026-09-02)

Same gradient-removal pass applied to `DashboardTotalsRow.vue` (see that phase's own
notes) extended here: `.dac::before` was a `linear-gradient` fade of the card's teal
accent (`rgba(13, 138, 122, 0.55)` → transparent); there is no gradient anywhere in this
design, so it's now solid `var(--jr-teal)` — the same teal already used for the corner
action-link icon, so the hairline and the icon read as the same accent colour rather than
two different teals.

Animates its width in from 0 on load exactly like the bucket bars just above — in fact it
reuses the *same* `animated` ref rather than adding a second one, via
`:style="{ '--hairline-width': animated ? '100%' : '0%' }"` on the root `<section>`
(the whole component IS one card here, unlike `DashboardTotalsRow.vue`'s `v-for` over
four, so there's only one hairline to drive and no per-card variable needed). The bar
segments and the hairline therefore draw in together on the same load, not on
independent timers. No separate "skeleton suppresses it" rule was needed the way
`DashboardTotalsRow.vue` needed one: `animated` starts `false` before the first load
resolves, so the hairline is already at 0% width for the entire loading state — nothing
extra to add.

### AC5 — clicking a band opens the filtered document list (2026-09-02)

Flagged in review as missing against Story 3's acceptance criteria: neither the bar
segments nor the legend rows did anything on click, only the corner icon (which links to
the full, unfiltered aging report).

**What "the document list" actually is.** Checked what already exists to link to first:
the aging report's own summary table is one row per customer/supplier with bucket
*columns* — there is no view anywhere, and no query param, that lists documents across
every customer/supplier filtered to one age band. Two ways to get there: extend the
shared aging report itself (bigger change to a widely-used page), or add something
self-contained to the dashboard feature. Asked the user; chose the dashboard-only route.

**Backend — `getDocumentsByBucket()`, not a new calculation.** Added to both
`ReportsAgingCustomerService` and `ReportsAgingSupplierService`, deliberately built as the
*same* query and the *same* `determineAgingBucket()` classification `getGrandTotalSummary()`
already uses — filtering to the rows that land in the requested bucket instead of summing
every row into a bucket total. `bucket_total` in the response is the sum of ALL matching
rows (not just the current page), computed by that identical query+classification, so it
is guaranteed to equal the widget's own bucket amount **by construction** — this is what
AC5's "the list totals match the band" actually requires, not something to keep in sync
by hand. Verified on `mohamedtolba`: all 5 receivables buckets and all 5 payables buckets
match `getGrandTotalSummary()` to the halala (current 117,753.34, period_1 66,174.67,
period_2 511,977.93, period_3 51,124.50, period_4 1,670,732.50 — receivables; and the
payables side including the one genuinely negative bucket, period_1 at −41.68 from a bill
return). Caught two real bugs in the process: `customer`/`supplier` were queried with
`name_ar`/`name_en` (copied from the unrelated `costCenter:id,name_ar,name_en` eager-load
a few lines up) — both tables only have a single `name` column; a live tinker run against
real data surfaced the `Unknown column` error immediately.

**New endpoints**: `GET .../receivables/documents?bucket=period_4&page=1` and the payables
mirror, on `web.php` alongside the existing card routes (this branch hasn't pulled
`refactor/dashboard-phase2`'s later move of card endpoints to `api.php` forward yet — that
migration is a separate, already-flagged follow-up, not bundled into this fix). No
`as_of_date` is accepted from the request: like the card itself, this always reads
"today", never whatever period is selected elsewhere on the dashboard (AC6).

**Frontend — a modal, not a page.** New `DashboardDocumentsModal.vue`, `<Teleport>`-ed to
`<body>` (Vue 2.7 supports it natively) so it isn't clipped by the card's own
`overflow: hidden`. Opened from the legend row, not the bar segment — a thin
low-percentage segment is a poor click target, and the legend row is already full-width
with room for a visible hover/focus-visible ring. Keyboard-operable (`role="button"`,
`tabindex="0"`, Enter/Space to open, Escape to close). Shows reference + document type,
customer/supplier name, due date, and balance (a `bill_return`/`invoice_return` row's
negative balance gets the same magenta credit tone used elsewhere), a footer total, and
plain previous/next pagination — local tenant bucket sizes (single digits to low hundreds
of documents) don't justify a page-number picker. Verified live via the same static
bundle-preview technique used for the cards themselves, in both LTR and RTL — the modal
mirrors correctly (header, table columns, close button and pager all flip) since every
directional CSS property used is a logical one (`inset-inline`, `text-align: start/end`),
matching the convention the rest of this component already follows.

### Reference and party columns are hyperlinks — no schema change needed (2026-09-03)

Asked to link the modal's reference column to the document's own show page and the
customer/supplier column to their own page, and to check whether that needs a migration
first. It doesn't: `report_customer_aging`/`report_supplier_aging` already store
`transaction_id`, `transaction_type` and `customer_id`/`supplier_id` — `getDocumentsByBucket()`
was already selecting all of them, just not returning `transaction_id`/the id column in
the response row. Added those two fields to both services' output, then built the actual
URLs in the **controller** layer (`document_url`, `entity_url`), not the service —
mirroring exactly how `CustomerAgingController::getDetailsHtml()` already builds
`transaction_url`/`customer_url` for the report's own detail view, reusing the same route
names rather than inventing a new scheme:

* Receivables: `invoice`/`debit_note` → `company.invoices.show` (param `invoice`);
  `invoice_return` → `company.invoice-returns.show` (param `invoiceReturn`); customer →
  `company.customers.show` (param `customer`).
* Payables: `purchase_bill` → `company.bills.show` (param `purchaseBill`); `bill_return`
  → `company.bill-returns.show` (param `billReturn`); supplier → `company.suppliers.show`
  (param `supplier`).

Verified live against real `mohamedtolba` data for all five transaction types plus both
entity links — every URL resolved to the correct real record id (e.g. `invoice_return`
id 49 → `.../sales/invoice-returns/49`, `purchase_bill` id 1 → `.../purchases/purchase-bills/1`).
`DashboardDocumentsModal.vue`'s reference and party cells render as an `<a target="_blank"
rel="noopener">` when a url is present and plain text otherwise (a row with no due date or
malformed type would still show, just unlinked, rather than erroring).

---

## 4d. VAT position & Cash position (Phase 4 — BUILT, 2026-09-03)

Both endpoints live in `api.php`'s `v2 → dashboard` group from day one (no web.php phase
for these two) — `company.dashboard-v2.vat-position` / `.cash-position`.

### Two decisions made with the user before writing any code

**VAT filing frequency is hardcoded to `quarterly`.** There is no per-tenant
filing-frequency column anywhere (confirmed by research), and RULES.md already records a
prior, deliberate team decision not to add one (§5, VAT alert). Asked the user rather than
silently re-opening that decision or silently picking a side: chose to keep the decision,
hardcode `quarterly` in `DashboardVatPositionService::FILING_FREQUENCY`, but still branch
on monthly-vs-quarterly in the deadline formula (which is frequency-agnostic anyway — see
below) so the code path for a future real per-tenant value already exists and needs no
redesign, only that one constant swapped for a real read.

**Both widgets follow the dashboard period selector — including VAT, on the user's own
explicit call, overriding what the story text describes.** The VAT story's own AC's
describe an intrinsic "current filing period" (this calendar month/quarter) with no
mention of the selector, matching how alerts/receivables/payables are already
period-independent. Flagged this conflict; the user chose "VAT also follows the period
selector" anyway. Consequence, reconciled as follows: `output_vat`/`input_vat`/`net` and
the shown period are whatever `type=this_month|this_quarter|...` the selector resolves
to (same `ResolvingDashboardPeriodService` every period-following card already uses) —
**not** necessarily a real, fileable calendar month/quarter. The deadline formula ("last
day of the month following the end of the filing period") is applied to *whatever period
end the selector gives*, which generalises correctly to any range (a custom range ending
15 Mar is due 30 Apr) — so deadline/days-left/state stay meaningful even though the
period itself is no longer frequency-derived. One real, known consequence: for an
in-progress period (e.g. `this_quarter` before the quarter has ended), `currentTo()` is
*today*, not the quarter's real end — so output/input VAT is a quarter-to-date running
total, matching exactly how the Totals row's revenue/expense tiles already behave for an
in-progress period. This is the direct, accepted cost of the user's choice, not a bug.

### VAT — reuses NewTaxesReportService directly, does not re-derive from the ledger

`DashboardVatPositionService` builds a `TaxesReportRequestEntity` (`from`/`to` = the
resolved period, duration mode) and calls `NewTaxesReportService::execute()` itself —
**not** a leaner ledger-only reconstruction. That service is genuinely hybrid: the normal
case reads `journal_records` by `tax_id`, but zero-percentage tax lines are summed
straight off invoice/bill/expense/return detail tables (a 0% tax never posts a nonzero
journal line), and POS/integration orders are aggregated from a third source entirely
(`SuccessSyncedOrderManualEntryRepository`/`...SingleRecordRepository`). AC2 ("must equal
the VAT Return report exactly") can only hold by construction by running the literal same
calculation, not an approximation that would silently disagree for any tenant hitting
either special case. Confirmed live on `mohamedtolba` (Q3 2026, quarter-to-date):
output 93,089.95, input 44,160.29, net 48,929.66 (no VAT adjustment on this tenant, so net
= output − input exactly; where an adjustment exists, `net` is `totalVatAmount`, the
report's actual bottom line, not the simplified output-minus-input the story text
describes — chosen because AC2's "match exactly" is the stronger, non-negotiable
requirement here).

Deadline/severity reuse `VatFilingDeadlineCalendar::severityForDaysLeft()` (30/7-day
thresholds, one JSON file, shared with the "VAT return due" alert) rather than a second
copy of those two numbers; `'info'` is relabelled `'normal'` to match this story's three
named states. A negative days-left (deadline passed) is already `<= critical`, so overdue
resolves to `critical` automatically. There is no "was this actually filed" tracking
anywhere in the system (nor does any story ask for one) — "overdue" is pure date math,
the same limitation the existing alert already has.

`net` is exposed as an **unsigned magnitude** with a separate `is_reclaimable` boolean —
there is no code path that can print a minus sign in front of it, which is what the story
means by "never as a negative amount payable" literally enforced by the response shape,
not just by frontend formatting discipline.

Whole card is a real `<a href>` (not a div+click handler) per AC7's literal "clicking the
widget" — no competing internal click target on this card, unlike the aging cards.

### Cash — replicates Balance Sheet's own point-in-time cascade, does not call it

`BalanceSheetController::index()` sets `set_time_limit(600)` and
`ini_set('memory_limit', '3000M')` to build the full balance sheet (every category, full
account tree) — far too heavy to run on every dashboard load for one line. Instead
`CashPositionLedgerService` replicates the exact primitives
`BalanceSheetService::getBalanceForDuration()` uses per account —
`JournalRecordsRepository::getBalanceAfterTransactionForAccountDuringDates()` →else
`...BeforeDate()` →else `AccountingFiscalYearBalanceRepository::...OpeningBalanceForAccountBeforeDate()`
→else 0 — scoped to only `accounts.subcategory_id = CASH_AND_CASH_EQUIVALENTS` (the one
subcategory covering `AccountsEnum::CASH`/`BANK_ACCOUNTS`/`FINANCIAL_DEPENDENTS` together;
confirmed there is no bank-vs-cash split anywhere in this codebase — no `is_bank` column,
nothing). This is the same cascade over the same data as Balance Sheet's own duration
mode, so AC3 ("match the Balance Sheet exactly") holds without paying the full report's
cost. Deliberately **not** `LedgerBalanceService`: that service unions `journal_records`
with the POS single-record ledger (right for the P&L tiles, wrong here — Balance Sheet /
Trial Balance / Cash Flow Direct all read `journal_records` only for cash), and it has no
opening-balance concept at all (movement-only, built for flow accounts, cash is a stock
account).

**`opening` is derived, not read: `closing − money_in + money_out`, computed after
`closing` and both movement figures are already known — not an independent
before-the-period balance query.** This was a real, empirically-forced decision, not a
style preference: computing opening via the *same* before-period cascade and comparing
against a fresh movement sum initially came up **300.00 SAR short** on `mohamedtolba`
(Jan–Jun 2026 test window). Traced to one account (id 1948, "Naqd"): its own
`balance_after_transaction` column doesn't reconcile with a fresh sum of its own
debits/credits (1000 debit, 300 credit, 500 debit should read 1200, the stored column
reads 1500) — a genuine, pre-existing ledger data quality issue on this one tenant/account,
not something this widget can or should fix. Every other report that trusts this same
column (Balance Sheet, Trial Balance, Cash Flow Direct) inherits the same quirk silently;
deriving `opening` algebraically instead makes AC2 ("closing = opening + in − out")
hold **exactly, by construction, always** — immune to this whole class of drift — while
`closing` still matches Balance Sheet exactly because it's read the same way. Re-verified
after the fix: `opening + in − out` now equals `closing` to the cent on the same test
window.

**Independent cross-check beyond the arithmetic identity**: closing cash for
`mohamedtolba`, quarter-to-date ending 2026-09-03, came out **56,558,032.41** — the exact
same figure already verified against Cash Flow Direct V2 for this tenant's FY4 in an
earlier, unrelated session (`available_cash_duration`, recorded in memory). Two
independently-built calculations agreeing to the halala on live data.

**Foreign currency (AC5) — confirmed not buildable, not silently skipped.** Grepped the
whole codebase: every `journal_records.exchange_rate` is hardcoded to `1` at write time
(`CreatingNewJournalEntryService.php`, comment: "to be fixed later when building exchange
rates engine"), `accounts.currency_code` always defaults to the tenant's single configured
currency and is never read to drive a conversion, and there is no `exchange_rates` table
or FX service anywhere in this codebase. There is nothing to translate yet — the widget
states the tenant's single base currency as `currency_code`, which is the entire "basis"
that currently exists. This AC cannot be satisfied for real until a genuine multi-currency
ledger exists; flagging rather than building a translation step against data that is never
actually foreign-currency-denominated today.

Drill-down (AC4, "clicking through opens the account balances") links to Trial Balance V2
(`company.reports.trial-balance.index`, same `is_dates`/`from`/`to` convention as every
other report deep-link here) — the only existing page with a genuine per-account
opening/period/closing breakdown; it is not pre-filtered to just cash accounts (no such
URL param exists on that report), so it opens showing every account, cash included.

AC6 (single cash account renders correctly): the aggregate query has no special-casing by
count — one account sums exactly the same way fifty-two do. Verified with a one-account
fixture in the static bundle preview.

### Shared frontend pattern — usePeriodFollowingCard.js

Both new cards are period-following, so both need the same lifecycle
`DashboardTotalsRow.vue` established first (reset animation → fetch → double-rAF re-arm →
repeat on every `dashboard:period-changed`). Extracted into
`composables/usePeriodFollowingCard.js` rather than copy-pasting a third time — used by
`DashboardVatPositionCard.vue` and `DashboardCashPositionCard.vue`; `DashboardTotalsRow.vue`
itself was left as-is (already shipped and working, retrofitting it wasn't asked for and
isn't needed for these two cards to work). Same solid (never gradient), animated hairline
treatment as every other card this phase, driven by the same `--hairline-width` custom
property pattern.

`days_left_text` / `accounts_text` are pre-pluralised **server-side** via `trans_choice()`
and sent as plain strings, not raw templates for the frontend to interpolate a `:count`
into — a client-side `.replace(':count', n)` cannot select the correct Arabic plural form
(Arabic has distinct one/two/few/many/other forms), only `trans_choice()` can. Same
convention the alert resolvers already use for their pluralised titles/subtitles.

Verified end-to-end with real Sanctum tokens against `mohamedtolba.vom.test` (both
`this_month` and `this_quarter`), and visually in both LTR and RTL via the static
bundle-preview technique, including the warning/critical/overdue and single-account edge
cases via fixtures (real data only ever showed the "normal" state).

### Two real bugs found the day after shipping, both against `clientissue` (2026-09-03)

**Cash: `movement()` was missing `deleted_at IS NULL`, inflating both money_in and
money_out.** The user independently summed `journal_records` for this tenant's cash
accounts this quarter by hand and got debit 447,311.96 / credit 453,783.35; the widget
said 451,191.64 / 458,009.68. Traced exactly: `JournalRecord` the Eloquent model uses
`SoftDeletes`, so the point-in-time balance cascade (which goes through that model via
`JournalRecordsRepository`) already excludes soft-deleted rows automatically via
Eloquent's own global scope — but `CashPositionLedgerService::movement()` queries
`journal_records` directly with the query builder for the aggregate `SUM/CASE`, which
bypasses that scope entirely. 29 soft-deleted rows in this window, worth exactly
3,879.68 debit / 4,226.33 credit — added the missing `whereNull('deleted_at')` and the
widget now matches the user's manual sum to the cent. Fixed the same latent gap in
`cashAccountIds()` too (`Account` also uses `SoftDeletes`; zero deleted cash accounts on
this tenant so it didn't change anything here, but the same class of bug was there
waiting). The query now handed back to the user as the accurate reference:

```sql
SELECT
    SUM(CASE WHEN transaction_type = 'debit'  THEN value ELSE 0 END) AS money_in,
    SUM(CASE WHEN transaction_type = 'credit' THEN value ELSE 0 END) AS money_out
FROM journal_records
WHERE account_id IN (/* accounts.id WHERE subcategory_id = 1 (CASH_AND_CASH_EQUIVALENTS)
                        AND deleted_at IS NULL */)
  AND journal_date BETWEEN '2026-07-01 00:00:00' AND '2026-09-03 23:59:59'
  AND deleted_at IS NULL;
```

**Separately raised, not fixed**: `clientissue` also has real POS-sourced cash activity
in a completely different table — `single_record_accounts`/`single_record_journal_entries`
(the same "single record" ledger `LedgerBalanceService` unions for the P&L tiles).
`single_record_accounts.account_id` references the *same* `accounts.id` space (confirmed:
id 52 resolves to "Revenue- Cash in safe" on both sides) — 70,513.60 in debits across
accounts 52 and 249 this quarter, invisible to the query above. Left excluded on purpose
for now: Balance Sheet, Trial Balance and Cash Flow Direct all read `journal_records`
only for cash too, so including it here would make this widget diverge from AC3's
"match the Balance Sheet exactly" rather than the other way around. Whether the *other*
reports should also start counting POS cash is a real, separate product question, not
something to resolve by quietly changing just this widget's scope.

**VAT: a non-taxable tenant's card stayed on its skeleton forever.** Root cause was in
the shared `useDashboardFetch.js`, not anything VAT-specific:
`payload.value = body.data ?? body` mishandles a legitimate `data: null` response (the
"not applicable to this tenant" envelope `BaseDashboardCardController::cardNotApplicable()`
already documented as a first-class convention) — `null ?? body` falls through to the
*whole envelope*, not `null`, so `payload.value` became a truthy
`{status,data,errors,success}` object. The card's own `v-else-if="!card"` empty-state
check never triggered because `card` was truthy, and reading a field off that bogus
object (`card.deadline.days_left_text`) threw mid-render — which is why the skeleton
never got replaced by anything. This was a latent, pre-existing gap in the shared
composable: nothing shipped before the VAT card ever actually returned `data: null` in
practice, so it had never been exercised. Fixed at the source (`'data' in body ?
body.data : body`) rather than special-cased in the VAT card, so every other card gets
the fix too. Confirmed live against `clientissue` (`taxable = 0`): backend already
correctly returned `{"data":null,"success":true}` before this fix — the bug was entirely
client-side.

### The "open product decision" above is now resolved: the card is a Taxes Report summary, always shown (2026-09-03)

The temporary "Nothing to show yet." treatment for a non-taxable tenant is gone. Reframed
per the user: **this card is fundamentally a summary of, and a link into, the Taxes
Report for the selected period — every tenant gets it, the same way every tenant can open
the report itself.** What's actually taxable-only is the ZATCA-specific layer on top of
that summary (the filing deadline countdown and the "payable/reclaimable" framing), not
the card's existence.

`DashboardVatPositionService::execute()` no longer gates on `tenantIsTaxable()` at all —
the only remaining gate is the subscription/plan check (`SubscriptionReportStatusService`
for `ReportsEnum::TAXES_REPORT`), which is orthogonal to VAT registration and stays
because a card linking to a report the tenant's plan doesn't include would be a broken
link regardless of taxability. Output VAT / input VAT / net are computed and shown for
every tenant unconditionally (`NewTaxesReportService` doesn't care about the `taxable`
flag either — it's a pure ledger/document calculation). `deadline`, `state`,
`is_overdue`, and `filing_frequency` are now `null`/`false` for a non-taxable tenant
(previously they didn't exist on the response at all, since the whole card didn't) —
`DashboardVatPositionCard.vue` hides the frequency label and the entire deadline/
countdown footer row when `card.is_taxable` is false, showing just the period text
instead.

The "payable/reclaimable" sign distinction on the net figure still applies to every
tenant (it's just describing whether the number is a credit or a debit), but the
ZATCA-branded wording ("Payable to **ZATCA**") is taxable-only — a non-taxable tenant
sees the same sign-driven colour coding under a generic "Net VAT" label instead, since
the figure isn't a real filing obligation for them. New `netVatLabel` prop /
`vat_position.net_vat` lang key for that case.

Verified live on both ends: `clientissue` (`taxable = 0`) now returns
`{"is_taxable":false,"deadline":null,"state":null,"filing_frequency":null,...}` with real
output/input/net figures and a working `action.url`, instead of `data: null`;
`mohamedtolba` (`taxable = 1`) is unchanged (`is_taxable:true`, full deadline/state/
frequency). Visually confirmed via the static bundle preview: the taxable fixtures still
show "Quarterly filer" / "Payable to ZATCA" / the deadline countdown exactly as before,
and a new non-taxable-with-real-activity fixture shows the figures with a plain "Net VAT"
label and no frequency/deadline row at all.

### Visual redesign, inspired by a reference the user provided — layout borrowed, colours rebuilt from our own tokens (2026-09-03)

The user supplied a static HTML/CSS prototype (sign-toned accent bar, headline figure +
delta chip, stacked labelled meter bars, a due-date pill with a countdown ring) with an
explicit instruction: borrow the layout ideas, but its actual colours aren't ours and
shouldn't be copied. Every colour in the rebuild traces to something already established
in this codebase or the Brand Guidelines PDF (`--dtr-good #198754` / `--dtr-danger
#b3261e` from `DashboardTotalsRow.vue`, brand orange `#ff7300`) — none of the prototype's
own hex values (`#0f6e56`, `#a32d2d`, `#854f0b`, etc.) made it in.

**Both cards now lead with a sign-toned identity**: the top accent shrank from 8px to 4px
and switched from the fixed brand-teal every other card uses to a *status* colour —
success-green when the headline figure is favourable, danger-red when it isn't — and the
headline figure itself picks up the same colour. Cash: closing balance ≥ 0 is good, < 0
is bad (a tenant with negative cash is a real problem worth colouring, not a neutral
fact). VAT: reclaimable is good, payable is bad — independent of, and layered separately
from, the deadline-urgency colouring described below (one colour slot answers "is this
good or bad news", a different one answers "how soon").

**Cash card**: added a delta chip under the headline (closing − opening, with an
up/down arrow) — the single number that answers "did my cash grow or shrink" at a
glance, which the old waterfall buried among three other figures. Money in/money out
became two stacked labelled meter bars (green/red, widths normalised to whichever of the
two is larger — "how do they compare to each other", not a share of some total that
in + out was never a meaningful whole of) rather than the old three-column waterfall.
Footer simplified to opening balance + period, two-sided, small and muted.

**VAT card**: output/input moved from a side-by-side split into the same stacked
meter-bar treatment as cash, but the colour is fixed by what each figure *means*, not by
which is bigger this period — input VAT (a reclaimable credit) is always the green bar,
output VAT (what's owed) is always the red one, so the colour never flips depending on
whose figure happens to be larger this quarter. The old plain footer became a "due" pill
(only rendered when `is_taxable`) with a small SVG progress ring — a purely illustrative
"how much of the filing window has passed" indicator (window = period end → deadline,
elapsed = window − days_left), computed client-side from fields already on the payload
rather than adding a backend field just for a decorative ring. The ring and the
days-left text are neutral by default and only pick up colour at the same two
thresholds the severity state already uses: `--dvp-warning` (brand orange) at ≤30 days,
`--dvp-danger` at ≤7 days or overdue — confirmed live via computed-style checks in the
static preview (58 days: neutral `rgb(90,107,122)`; 20 days: `rgb(255,115,0)`; 5 days and
overdue: `rgb(179,38,30)`) after catching and fixing a first pass that left the "normal"
(58-day) case coloured orange by mistake.

Verified in both LTR and RTL via the static bundle preview across every state (normal,
warning, critical, overdue, reclaimable, non-taxable, single-account) — RTL mirrors
correctly throughout (header, bars, chip, due-row, footer) since every directional
property used is logical (`inset-inline-start`, flex `justify-content` reordering),
matching the convention every other card in this phase already follows.

### Three issues from the first live review of the redesign (2026-09-03)

**1. Cards didn't match height.** The redesign's footer areas were asymmetric on
purpose, mirroring the reference: VAT's due-row was a padded grey box, cash's footer was
bare text. Side by side on the real dashboard that reads as a height mismatch. Fixed by
giving all three footer variants — cash's `.dcp__foot`, VAT's `.dvp__due` (already
boxed), and VAT's own plain-text `.dvp__foot` (the "no due-row" case, see #3 below) — the
identical box: same background, padding, radius, font-size. Whichever variant renders,
the two cards now occupy the same height by construction.

**2. The "go to report" arrow was the wrong colour and pointed the wrong way in RTL.**
Two separate misses reproducing the reference: its neutral grey icon-button carried over
instead of this app's own established teal (`DashboardAgingCard.vue`'s `--jr-teal
#0d8a7a` action icon), and the RTL mirror fix that same aging-card icon already has
(`[dir='rtl'] svg { transform: scaleX(-1) }`) was never carried over either — an SVG
arrow drawn pointing up-right in its own coordinates only reads as "outward, away from
the card" when the icon sits in the top-right corner; in RTL the header reorders so the
icon sits top-left instead, and without the flip the same arrow visually points *into*
the card. Both fixed to match the aging card exactly, confirmed via computed-style
checks in the static preview (`rgb(13, 138, 122)` fill, `matrix(-1, 0, 0, 1, 0, 0)`
transform under `dir="rtl"`).

**3. "I'm looking at a past quarter — why does it say the return is overdue?" — a real
design bug, not a display bug.** Root cause of the tension flagged back when "VAT follows
the period selector" was first decided: the deadline formula
(`period_end.addMonthNoOverflow().endOfMonth()`) is mathematically correct for *any*
period, including one that closed months ago — so browsing history via the period
selector could show a deadline that passed long ago as "overdue", which reads as a live
alarm about something current when it's really just old, settled history. Caught live on
`26accounting` via a custom past range. Fixed by gating the whole deadline/countdown/
overdue block on a second condition beyond `is_taxable`: `today` must fall inside the
*selected* period (`CarbonImmutable::betweenIncluded($from, $to)`) — i.e. the deadline
section only renders when the period on screen is the one you're actually filing for
right now, never for a historical browse. `filing_frequency` ("Quarterly filer") stays
visible regardless — it's a tenant fact, true no matter which period is on screen, unlike
the deadline itself. Verified against `26accounting` (taxable): a past custom range
(Q1 2026) now returns `deadline: null, state: null, is_overdue: false`; `this_quarter`
(the live period) is unchanged (`deadline`, `state: "normal"`, real countdown).

**Found and fixed a second bug while verifying #3**: the frontend's due-row was gated on
`card.is_taxable` alone, so a taxable tenant with the new `deadline: null` (a past period)
hit `card.deadline.days_left_text` on a null object and threw mid-render — the exact same
failure shape as the earlier `useDashboardFetch` null-handling bug, just a fresh instance
of it in a new place. Fixed by gating on `card.is_taxable && card.deadline` together.

### The countdown ring never actually moved — anchored to the wrong end of the window (2026-09-03)

User asked for the ring to be verified against real elapsed time, not just eyeballed once.
It failed: `ringOffset` computed the filing window as `deadline - period.to`, but for the
live/common case (`this_quarter`, `this_month` — "period to date") `period.to` *is*
`today` by construction, so `windowDays` always came out exactly equal to `daysLeft` and
the fraction pinned at 0% forever, no matter how many days actually passed. Fixed by
anchoring the window to `period.from` instead — the quarter/month's calendar start, which
never drifts — and computing `elapsed = today - period.from`.

Verified two ways before trusting the browser: first a standalone Node.js simulation of
the formula across a range of "today" values, confirming monotonic 0% → 52.5% → 100%
(clamped) progression; then four purpose-built fixtures in the static preview
(`vat_ring_0pct/50pct/90pct/overdue_full.json`) with `period.from`/`deadline.date` chosen
relative to the real date so real `Date.now()` lands at known checkpoints. All four
matched the Node prediction exactly on a fresh tab: dashoffset 75.40/37.70/7.54/0 against
a circumference of 75.40 (0%, 50%, 90%, 100%-clamped-overdue).

One test-harness gotcha worth recording: `requestAnimationFrame` (used by
`usePeriodFollowingCard`'s double-rAF animation-arm) is fully suspended while the
`Claude_Browser` tab reports `document.hidden`, so a static preview can look permanently
stuck at 0% even when the fix is correct — a `computer` screenshot forces a real paint and
unsticks it. Not a real bug, just something to rule out before chasing a ghost.

---

## 4e. Sales/Purchases trend, expense breakdown, and two customer lists (Phase 5 — BUILT, 2026-09-03)

Story: "Add a sales and purchases trend and an expense breakdown" — the 12-period trend
chart and the expense-category donut. The same screenshot that illustrated the story also
showed two list widgets not in the story text — "Chase These First" (top 5 most-overdue
unpaid invoices) and "Top Customers" (top 5 revenue contributors) — commissioned for the
same phase from that screenshot's data. Chart.js (`^4.4.1`, already a dependency) is used
directly, no wrapper package.

**Chart granularity follows the period selector** (confirmed product decision): `this_month`
→ 12 trailing weeks, `this_quarter` → 12 trailing months, `this_year` → 12 trailing
quarters, `today`/`custom` → monthly (not covered by the story's three label formats,
lowest-risk default). Every window anchors to `period->currentTo()` and steps backward —
`DashboardSalesPurchasesTrendService`. Axis labels and tooltip ranges are formatted
**client-side** via `Intl.DateTimeFormat`, not baked into the PHP response, matching how
`DashboardVatPositionCard.vue` already formats its own deadline date — Arabic month/quarter
names never need to live in PHP. Quarter label reuses the exact `Q:number :year` template
already defined for the VAT alert (`dashboard_v2.alerts.vat.quarter`), one format for the
whole dashboard.

**The trend chart stays ledger-based** (`LedgerBalanceService::signedMovement()`, called
once per bucket, 12 × 2 = 24 calls to the existing dual-ledger-union query — no new SQL),
so it can never disagree with the Total Sales/Total Purchases tiles for an equivalent
range. Verified against `clientissue`: a direct `signedMovement()` call for March 2026
matched the trend service's own March bucket exactly (227,596.48).

**The expense breakdown needed a new per-account grouping method.**
`LedgerAccountGroupEnum::EXPENSES` is only 3 coarse subcategories — far coarser than the
named categories (Salaries, Rent, Utilities...) the chart needs. Added
`LedgerBalanceService::movementByAccount()`, additive only (mirrors the existing dual-ledger
private methods, `GROUP BY account_id` instead of summing) — does not change
`signedMovement()` or its private helpers. Account names come from `accounts.name_en`/
`name_ar` directly.

**Bug caught during verification, not before shipping**: the first version of
`DashboardExpenseBreakdownService` filtered out zero/negative-movement accounts (a fully-
reversed expense nets negative for its own account) before computing the total, on the
theory that they "carry no visual meaning". Verified against `clientissue`
(2025-01-01–2026-09-03): the breakdown's reported total was 68,653,436.95 against
`signedMovement()`'s own 68,653,421.9307 for the identical filter — a 15.02 SAR gap, because
dropping negative contributions before summing silently inflated the total instead of
preserving it. AC4 ("the six slices must sum to Total Expenses exactly") is violated by
that filter, not satisfied by it. Fixed by never dropping an account: every account's
movement, whatever its sign, lands in either a top-5 slice or the folded "Other" slice —
re-verified exact match to the penny (68,653,421.93) after the fix.

**"Chase These First" reads `report_customer_aging` directly** — document-level (single
invoice, not a per-customer bucket aggregate like `ReportsAgingCustomerService::
getSummaryByCustomer()`), `WHERE amount > paid_amount AND transaction_due_date < today`,
top 5 by days overdue. As-of-today, like the aging cards it shares a table with — takes no
period. New file (`DashboardMostOverdueService`) alongside the existing
`DashboardReceivablesService`, not a change to it. Reuses the aging card's own document/
entity route-building (`company.invoices.show`/`invoice-returns.show`/`customers.show`).
**Superseded — the direct-table read was replaced in §4f; see "Reporting basis" below.**

**"Top Customers" is invoice-based, not ledger-based** — a deliberate, documented gap:
`LedgerBalanceService` carries no customer dimension (only ever joins `accounts`), and the
single-record (POS) ledger has no customer reference at all, so a ledger-based figure would
silently miss every POS-sourced tenant's customers. Reads `invoices`/`invoice_returns`
directly instead (each netted by its own `date` within the period, per customer) — the same
lens the aging cards already use for AR. Follows the period selector, unlike its sibling.
This card's total will not exactly equal the ledger-based Total Sales tile; it answers
"which customers matter most", not "does this reconcile with the P&L" — same kind of gap
that already exists between the aging cards and the ledger totals today.
**Superseded — the "documented gap" was withdrawn in §4f. Staying off the ledger was and
remains right; summing the raw invoice tables was not, because it did not reconcile with
the sales-by-customer report either. See §4f below.**

**AC6 ("The Profit and Loss card is removed and its figure appears as the Net Profit
tile") was already satisfied before this phase started.** `DashboardTotalsService` has
computed `net_profit` = Sales − Purchases − Expenses since Phase 2, and no separate P&L
card exists anywhere in the new dashboard v2. The only "Profit and Loss" heading left in
the codebase is in the OLD pre-refactor dashboard (`income-chart-index.blade.php`), which
stays untouched per §1.3 — not touched by this phase.

Verified end-to-end three ways before calling this done: (1) `tinker` against
`26accounting`/`clientissue` for every service's raw output, including the exact-sum check
above; (2) a live HTTP round-trip via `curl` with a real Sanctum bearer token
(`$user->createToken(...)->plainTextToken`, same technique as the branch's own earlier
route-testing scripts) confirming routing/middleware/controller/JSON envelope all work
un-mocked, not just the service layer; (3) the static-preview harness (real API responses
saved as fixtures, not fabricated) confirming the actual Vue render — weekly/monthly/
quarterly axis labels, the tooltip's full date range (`Jun 15, 2026 – Jun 21, 2026`), the
donut's name+amount legend, both list cards, and RTL mirroring (icon flip, bar direction,
Arabic labels) all matched on inspection.

---

## 4f. Reporting basis — the two customer cards put on the correct basis (2026-09-06)

Story: "Put every dashboard figure on the correct reporting basis." This section covers only
the two Phase 5 list cards; the ledger tiles are unchanged by it.

**The story names two independent axes, and conflating them is what made the old wording
confusing.** Both are now declared in each card's payload rather than left to a docblock:

| Axis | Values | Meaning | Enum |
|---|---|---|---|
| Reporting basis | `ledger` / `documents` | WHERE the figure is read from | `DashboardReportingBasisEnum` |
| Accounting basis | `accrual` / `cash` | WHEN a transaction counts | `DashboardBasisEnum` |

Both cards are **`documents`** — the story assigns receivables/payables ageing, overdue
lists, top customers and ZATCA counts to that basis explicitly. Neither moves to the ledger.

**"Chase These First" no longer queries `report_customer_aging` itself.** It now delegates
to a new `ReportsAgingCustomerService::getMostOverdueDocuments()`, exactly as its sibling
`DashboardReceivablesService` delegates to `getGrandTotalSummary()`. Additive method only,
same precedent as `LedgerBalanceService::movementByAccount()` in §4e — no existing method
touched, so §1.2 is satisfied.

This was not cosmetic. Reaching past the report service skipped two things the report
applies:

1. **`applyCostCenterFilter()`, and with it `applyPermissionFilter()`** — the card was
   showing documents from cost centres the user has no `view` permission for. This is the
   real reason the change was worth making, independent of the story.
2. **The `transaction_date <= as_of_date` cut-off** — a future-dated document could appear
   in a list of things that are *already* overdue.

Consequence: `DashboardMostOverdueController` now needs `bridgeSanctumUser()`, for the same
reason `DashboardReceivablesController` already did — `applyPermissionFilter()` calls
`auth()->user()->adjustRolesLikeOwnerPermissions()` unconditionally and 500s on a null user.

Two narrowings against `getGrandTotalSummary()` are kept and documented in the method: only
rows past the reference date, and only **debit** balances. A credit balance is money we owe
the customer, which is not something to chase. Neither narrowing changes a figure the report
publishes — they only pick which of the report's own rows make the list.

**"Top Customers" now reads `report_sales_summary`**, through
`ReportSalesSummary::queryForSalesSummaryReport()`, aggregated with the same expressions
that report's own KPI row uses. The AC is "Top customers = the sales by customer report", and
there is no report by that literal name — the canonical per-customer revenue report is the
**Sales Summary** report, which is what this now reconciles to, by reading its source through
its own query builder.

Three concrete reasons the old direct `invoices`/`invoice_returns` sum could not reconcile:

- it summed `total`, which **excludes other fees**, where the report uses the journal-derived
  `total_amount`;
- it counted **debit notes as ordinary invoices** (they live in the `invoices` table), where
  the report types them separately;
- it applied **no cost-centre permission filter** at all.

Cancelled and drafted documents are excluded by the strongest available means: rows are
removed from `report_sales_summary` at sync time, so they are absent from the table rather
than filtered at read. Credit notes reduce revenue in the period they were **issued**, since
`invoice_return` rows carry their own `date`.

**Accounting basis applies to Top Customers but NOT to Chase These First**, and the payload
says so (`basis_applies`) rather than leaving it implied:

- `accrual` (default) = the report's `total_sales` KPI, per customer.
- `cash` = the report's `total_payment` KPI (receipt `pay_in − pay_out`) per customer,
  dated by the receipt. Deliberately **not** `paid_amount` on the invoice rows: that is
  allocation as it stands today, so it would attribute the cash to the invoice's period
  instead of the period the money actually arrived in.
- Chase These First reports `basis: null, basis_applies: false` — it is a position as at
  today, and the story requires overdue figures to be unaffected when the basis is switched.
  Reporting `null` rather than defaulting to `accrual` is what makes "unaffected" visible.

`top_share_percentage` is now a share of the **report's** period total, not of the 5 rows'
own sum. Because the ranking drops customers who netted negative while the total still nets
them in, the 5 can exceed the total; that is clamped to 100% in the DTO, since "112% of this
period's revenue" reads as a bug.

### Verified against live tenant data (2026-09-06)

Top Customers, on `orderapp` (the only local tenant whose report tables are fully
populated — see the backfill note below), across **six** periods (`this_month`,
`this_quarter`, `this_year`, CY2025, CY2024, 2020→today) and **both** bases:

- card `total_revenue` vs the report's own KPI (`total_sales` for accrual,
  `total_payment` for cash): **delta 0.00 on all twelve combinations**;
- each of the 5 listed customers re-derived by re-running the report scoped to that one
  customer: **all exact**;
- the two bases genuinely differ (e.g. `this_quarter` 928,397.61 accrual vs 923,589.45
  cash), so the AC "the figure changes when the basis is switched" is met rather than
  merely wired up;
- `top_share_percentage` stayed inside 0–100 everywhere;
- credit notes: `INVRET2615` (2026-07-10) makes that day's revenue **−4,999.00**, i.e. it
  reduces revenue in the period it was **issued**;
- draft and soft-deleted invoices: present in `invoices`, **absent** from
  `report_sales_summary` on every tenant checked, so they cannot reach the card.

Chase These First, on `orderapp` (12,737 aging rows, 3,477,848.35 outstanding across 1,269
documents): every listed document was found in the report's own bucket listing with an
**identical balance and days_overdue**; ordering is descending by days overdue and stable
across identical calls.

### Bug found in live review — every overdue row read "2 days" (2026-09-06)

Reported on `mohamedtolba`, where the real ages are 544/353/350/345/344 days. Not a data or
ordering fault: the blade passed the days-overdue string as a **template**, resolved once at
page-render time —

```blade
days-overdue-template="{{ trans_choice('...most_overdue.days_overdue', 2) }}"
```

`Translator::choice()` picks one plural branch **and** assigns `$replace['count'] = $number`
itself, so with a hardcoded `2` the string handed to Vue is already the finished
`"2 days"` — `"يومان"` in Arabic. `daysOverdueLabel()` then ran `.replace(':count', days)`
against a string with no `:count` left in it, so every row rendered that same literal. Broken
in **both** locales, not just Arabic; Arabic was merely more obviously wrong, since `{2}`
selects the dual form `يومان`, a word that carries no number at all.

Fixed by pluralising per row, server-side, in `DashboardMostOverdueService`:
`trans_choice(key, $days, ['count' => $days])` emitted as `days_overdue_label` on each entry —
the same thing `OverdueInvoicesAlertResolver` already does for its own count. It cannot be
done in the component: only `trans_choice` knows the plural rules, and Arabic needs four
forms (`{1}`, `{2}`, `[3,10]`, `[11,*]`) two of which contain no placeholder, which no
client-side `:count` substitution can reproduce. Raw `days_overdue` stays in the payload —
the component still needs the number for its 60-day severity split. The `daysOverdueTemplate`
prop and the blade attribute are gone.

**Do not reintroduce a `*-template` prop for a pluralised string.** A template resolved in
blade is fixed at page render; per-row plurals must come from the endpoint, which also suits
the mobile reuse these v2 endpoints exist for.

Verified over a real HTTP round-trip (kernel + `api` middleware group, Sanctum bearer):
`en` → `"544 days"`, `ar` → `"544 يومًا"`, correct per row in both.

### Period independence — re-confirmed, and now guarded in the component

The story requires overdue figures to be unaffected by the basis and period selectors. Held
already, and re-verified rather than assumed:

- `DashboardMostOverdueController::__invoke()` takes no `Request` at all — there is no
  parameter for a period or a basis to arrive through;
- `DashboardMostOverdueCard.vue` uses `useDashboardFetch`, **not**
  `usePeriodFollowingCard`, and has no `dashboard:period-changed` listener;
- over HTTP, the payload is **byte-identical** with `?type=today`, `?type=this_year`,
  `?type=custom&from=2020-01-01&to=2020-12-31`, `?basis=cash` and deliberate garbage
  (`?type=not_a_period&basis=nonsense`) — checked against a non-empty baseline, so the
  comparison is not vacuous.

A standing "do not add a period listener here" comment now sits on the component's `load()`,
mirroring the one `DashboardAlertsStrip.vue` already carries.

Harness note for anyone repeating this: a synthetic `Request::create()` with only
`Accept: application/json` gets an **empty 403** from
`TenantFeaturesAndRestrictions::checkIntegrationValidity()` — a JSON request that is not ajax
and has no `Api-Agent` header is rejected. Send `X-Requested-With: XMLHttpRequest`, as
`useDashboardFetch.js` does. The 403 is not a fault in the card.

### Release prerequisite found while verifying — `report_sales_summary` needs a backfill

Coverage of eligible (non-draft, non-deleted) invoices in `report_sales_summary`:

| Tenant | Eligible invoices | Present | Covered |
|---|---|---|---|
| `orderapp` | 112,264 | 112,264 | **100%** |
| `mohamedtolba` | 231 | 39 | 16.9% |
| `clientissue2` | 734 | 77 | 10.5% |
| `26accounting` | 736 | 76 | 10.3% |

On the three under-populated tenants the card still agrees with the Sales Summary report
exactly — which is what the AC asks — but **both understate reality together**, because the
report reads the same incomplete table. That is why the old invoice-table sum looked
*larger* there (e.g. `mohamedtolba` CY2025: 3,857,403.30 old vs 141,450.00 report): the old
number was not more correct, it was reading a source the report does not use.

So this is a data/deployment prerequisite, not a code defect: **`PopulateSalesSummaryReportJob`
(`report:sales-summary` populate command) must be run per tenant before release**, exactly
as `report_customer_aging` and `report_supplier_aging` already required. Until it is, the
Sales Summary report screen is wrong on those tenants too, with or without this card.

**Still outstanding from this story, deliberately not built here** — these are dashboard-wide,
not properties of these two cards, and doing them inside one card would produce a selector
that silently disagrees with every other widget:

- the **user-facing accrual/cash toggle**, its per-user persistence (alongside
  `DashboardPeriodPreferenceRepository`) and propagation through
  `usePeriodFollowingCard.js`. The API and both services already accept and honour `basis`;
  only the shared selector is missing, so Top Customers currently always resolves to the
  accrual default in the UI.
- the **three-period live-tenant reconciliation** the ACs require before release (both the
  ledger figures and the POS split). Not yet run for these two cards.

## 5. The alert system (Phase 1 — BUILT)

Endpoint `company.dashboard-v2.alerts` → `GET /api/v2/companyuser/dashboard/alerts`.

* **Zero persistence.** No table, no cache, no scheduled job. Computed live per request from
  indexed aggregates. Measured: 3–4 tenant queries, 29–96 ms across the local tenants.
* Returns `{ alerts: [...] }` — a flat ordered list. **An alert that does not apply is absent
  from the array.** No null placeholder, no `visible` flag.
* **Adding a card = one resolver class + one line in `DashboardAlertsService::RESOLVERS`.**
  Zero frontend change; the strip renders whatever it receives, for any count including zero.
* Each resolver is wrapped in try/catch — a resolver that throws is logged and skipped, and
  the rest of the strip still renders. Verified with a deliberately throwing resolver.
* A card leads with **either** `amount` (money headline, SAR icon or ISO code) **or** `title`.

Files: `Services/Dashboard/Alerts/` (interface, 3 resolvers, `VatFilingDeadlineCalendar`,
`DashboardAlertsService`), `DTO/Dashboard/DashboardAlert.php`,
`Enums/DashboardAlertSeverityEnum.php`, `Http/Controllers/Dashboard/DashboardAlertsController.php`,
`Resources/assets/js/components/DashboardAlertsStrip.vue`.

### Applicability gates (verified against live tenants)

| Alert | Gate | Omitted when |
|---|---|---|
| ZATCA documents | `company_info.zatca_comply = 1` (central DB, via `RetrievingTenantBySubdomainService`) **and** `zatca_comply = 1` per row | count is 0 |
| VAT return due | `settings.taxable = 1` (tenant DB) | no deadline resolvable |
| Invoices past due | none | count is 0 or outstanding ≤ 0 |

Observed: `orderapp` → 3 cards; `clientissue2` → 2 cards (ZATCA gate passes, nothing pending,
so the query runs and the card is still omitted); `mohamedtolba` / `26accounting` → 2 cards
(gate short-circuits, ZATCA query never runs).

### ZATCA states — only the ones the client can act on

Verified against where `ZatcaReporterService` actually writes each value, and against the
resend button's own eligibility check (`Sales/invoices/index.blade.php:728`, which allows
re-reporting anything except `REPORTED`/`REPORTED_WITH_WARNINGS`). Three states are counted;
each gets its own clause, and only non-zero ones are shown.

| Status | Written when | Card wording | Client action |
|---|---|---|---|
| `PENDING` (0) | created while the tenant is ZATCA-compliant, never sent | "not yet submitted to ZATCA" | submit it |
| `FAILED` (3) | ZATCA rejected it (also HTTP 429) | "rejected by ZATCA" | fix and resubmit |
| `SERVER_ERROR` (6) | HTTP 500/503/504 or an unrecognised reply | "failed to send to ZATCA" | retry |

**`PROCESSING` (2) is deliberately excluded**, on Mirna's instruction after review: it is
written immediately *before* the API call and overwritten by every response path within the
same queued job (`ReportInvoicesToZatcaJob`), so under normal operation it is a sub-second
transient the client is never meant to see, let alone act on. A document still sitting there
when this card runs means its job died mid-run — an infrastructure fault for the team
running the queue, not a task for the client. The earlier wording ("submitted, awaiting
ZATCA response") read as a routine, no-problem state when the underlying cause is the
opposite; removing it avoids both the false alarm and the false calm. `REPORTED` (1),
`REPORTED_WITH_WARNINGS` (5) and `NOT_APPLIED` (4) are never counted.

Credit notes (`invoice_returns`) are counted alongside invoices — same `zatca_reported`
column, same resend job. Missing them undercounted `orderapp` by 6 documents.

The wording describes **state only and never promises a retry**: the resend job only picks
documents up inside a window — 14 days for standard, 1 day for simplified
(`InvoicesRepository::getFailedZatca*`) — so outside it nothing retries on its own.

**Credit notes are counted with invoices.** `invoice_returns` carries the same
`zatca_reported` column and the same resend job covers it; excluding it hid 6 stuck
documents on `orderapp` alone. It has no `is_draft` column. Because the card spans both
tables its wording is "documents", not "invoices".

### The alert strip is INDEPENDENT of the period selector — final, by team-lead decision

The strip takes no period at all and always reports the position as at today. One endpoint
(`GET /dashboard/alerts`), no query params, no `dashboard:period-changed` listener in
`DashboardAlertsStrip.vue`, no period argument anywhere in the resolver interface.

This went back and forth; the decision is settled and the reasoning is worth keeping so it
does not get relitigated. Alerts answer "what needs my attention right now", which is not a
question a selected reporting window can change. Coupling them produced two concrete
failures, both seen on the live page:

* **"Today" emptied the strip.** Scoping the overdue card by transaction date turned it
  into "invoices *raised* today that are already overdue" — essentially always none, since
  an invoice raised today cannot be past its own due date.
* **"Today"/"This month"/"This quarter" were indistinguishable.** Using `periodTo` as an
  as-of date is a no-op across those three: every non-custom preset sets `to = today` by
  construction in `ResolvingDashboardPeriodService`.

**Do not reintroduce period coupling here without that product decision being revisited.**
The period selector still exists and still broadcasts — it is for the Story 2 tiles and the
period-following cards that come next, not for this strip.

### Alert action links

Each link is scoped to match its card's own figure exactly, so clicking never lands on a
different number:

* **ZATCA → invoice list.** The card counts every document needing attention, unfiltered by
  date or fiscal year (a document rejected two fiscal years ago is still a live compliance
  problem). The link therefore carries the list's own **empty** filter keys rather than no
  query string: an unfiltered request makes `InvoiceController::index` default to
  `current_year_receipts` (current fiscal year only), which would show fewer rows than the
  card counted. Empty keys keep `request()->query()` non-empty so that default never fires.
  Verified by rendering the list with this exact query string — `current_year_receipts`
  unchecked, DataTables ajax carrying the empty set.
* **Overdue → ageing report**, `is_dates=1&as_of_date=<today>`. `is_dates=1` is required,
  not optional — see the note below on why omitting it silently scopes the table.
* **VAT → tax report**, `is_dates=1&from=<quarter start>&to=<quarter end>`, the filing
  quarter the countdown is about.

### Header-to-content gap — corrected twice

First pass found `app.min.css`'s `body.fixed-navbar { padding-top: 4rem }` and called that
the geometric minimum. **Wrong** — missed that `public/assets/css/mainStyle.css`, a vendor
theme file loaded on every page (`companyuser::includes.head:52`), carries a MORE
important, later-cascading rule at the same breakpoint:

```css
@media (min-width: 768px) {
  body { padding-top: 150px !important; }
  .header-navbar { height: 110px; }
}
```

That `!important` wins over `app.min.css`'s plain `4rem`, so the real reserved space was
150px, not 64px — 86px of genuine slack the first pass missed entirely.

Found via Mirna testing live overrides in DevTools (`body { padding-top: 70px !important }`,
`.header-navbar { height: 10px }`) and confirming the result looked right, then asking for
it made permanent. Implemented as a `<style>` block pushed to the `styles` stack from
**`dashboard/index.blade.php` only** — not by editing `mainStyle.css` itself, which is
shared by every page in the module; editing it directly would shrink the navbar and pull
content up everywhere, not just here.

The scoping works two ways at once, both confirmed against the real rendered page rather
than assumed:

* **Blast radius** — a `<style>` block only exists in the markup one route renders. No
  other page's `<head>` includes it, confirmed by grepping the legacy `/companyuser` blade
  for the same string and finding nothing.
* **Cascade order** — `master.blade.php` includes `companyuser::includes.head` (and with it
  `mainStyle.css`) first, then yields `@stack('styles')`, so this page's pushed `<style>`
  block always renders after `mainStyle.css` in the document. On the equal-specificity,
  equal-`!important` tie for `body`, later source wins — confirmed by finding both
  strings' byte offsets in the actual rendered HTML and checking the order.

`.header-navbar { height: 10px }` needs no `!important`: the original rule
(`height: 110px`) never used one, so the later, page-scoped declaration overrides it
through ordinary cascade rules alone.

Card padding and bottom margin were also tightened (`14px 20px`, `16px`), and
`margin-top: -1.8rem` on `.dps` still separately cancels `.content-wrapper`'s own
`padding: 1.8rem` — an unrelated, smaller gap that stacks with the fix above rather than
overlapping it.

### Custom date range — the shared v2 DatePickerComponent, not a bespoke row

Rebuilt to use `date-picker-component` (`resources/js/components/DatePickerComponent.vue`,
a flatpickr wrapper already global via `Vue.component('date-picker-component', ...)` and
used throughout the v2 report filters, e.g. inside `ReportFiscalYearOrCustomRange.vue`).

The picker itself is never visible: it is stretched invisibly (`position:absolute;
inset:0; opacity:0`) over the exact footprint of the "Custom range" pill inside one
`position:relative` wrapper, and the pill's click calls the picker's public `.open()`
method (exposed via a template `ref`) directly — since flatpickr always anchors its
calendar to whichever input it is bound to, the calendar visually opens right off the
pill with no separate row and no visible text input of its own. Selecting a complete
range (`mode="range"` only emits `input` once both dates are picked) applies immediately;
there is no separate confirm step. Deep-reaching flatpickr's own generated `<input>` from
scoped CSS uses `::v-deep`, matching this codebase's existing vue-loader 15 convention
(confirmed against `report-kpi-card` overrides across several v2 report components) —
**not** `:deep()`, which is a Vue 3 SFC compiler syntax this build does not support.

Once a custom range is applied, `activeType` becomes `'custom'` and nothing else — since
the segmented buttons and the pill all key off that single reactive value, none of the
four presets can show active at the same time by construction, and the pill's own label
switches from the generic "Custom range" to the resolved dates so the selection stays
visible without a second element.

### Header-to-content gap

`.content-wrapper { padding: 1.8rem; }` (`public/assets/css/app.min.css`) is this theme's
uniform default spacing on every side of the content area. The period selector cancels
just the top of it (`margin-top: -1.8rem` on `.dps`) so the card sits flush under the app
header. Deliberately **not** the `-3rem` every v2 report's `.raf-toolbar-full` uses for
the same purpose — theirs also cancels a breadcrumb row rendered above their own toolbar,
which this page has none of (confirmed: this master layout has no `@yield('breadcrumb')`
anywhere — the dead `@section('breadcrumb')` block that was in this blade did nothing and
has been removed). Copying `-3rem` here would overshoot and tuck the card in under the
header.

## 5. The alert system (Phase 1 — BUILT)

Endpoint `company.dashboard-v2.alerts` → `GET /{locale}/dashboard/alerts`.

* **Zero persistence.** No table, no cache, no scheduled job. Computed live per request from
  indexed aggregates. Measured: 3–4 tenant queries, 29–96 ms across the local tenants.
* Returns `{ alerts: [...] }` — a flat ordered list. **An alert that does not apply is absent
  from the array.** No null placeholder, no `visible` flag.
* **Adding a card = one resolver class + one line in `DashboardAlertsService::RESOLVERS`.**
  Zero frontend change; the strip renders whatever it receives, for any count including zero.
* Each resolver is wrapped in try/catch — a resolver that throws is logged and skipped, and
  the rest of the strip still renders. Verified with a deliberately throwing resolver.
* A card leads with **either** `amount` (money headline, SAR icon or ISO code) **or** `title`.

Files: `Services/Dashboard/Alerts/` (interface, 3 resolvers, `VatFilingDeadlineCalendar`,
`DashboardAlertsService`), `DTO/Dashboard/DashboardAlert.php`,
`Enums/DashboardAlertSeverityEnum.php`, `Http/Controllers/Dashboard/DashboardAlertsController.php`,
`Resources/assets/js/components/DashboardAlertsStrip.vue`.

### Applicability gates (verified against live tenants)

| Alert | Gate | Omitted when |
|---|---|---|
| ZATCA documents | `company_info.zatca_comply = 1` (central DB, via `RetrievingTenantBySubdomainService`) **and** `zatca_comply = 1` per row | count is 0 |
| VAT return due | `settings.taxable = 1` (tenant DB) | no deadline resolvable |
| Invoices past due | none | count is 0 or outstanding ≤ 0 |

Observed: `orderapp` → 3 cards; `clientissue2` → 2 cards (ZATCA gate passes, nothing pending,
so the query runs and the card is still omitted); `mohamedtolba` / `26accounting` → 2 cards
(gate short-circuits, ZATCA query never runs).

### ZATCA states — only the ones the client can act on

Verified against where `ZatcaReporterService` actually writes each value, and against the
resend button's own eligibility check (`Sales/invoices/index.blade.php:728`, which allows
re-reporting anything except `REPORTED`/`REPORTED_WITH_WARNINGS`). Three states are counted;
each gets its own clause, and only non-zero ones are shown.

| Status | Written when | Card wording | Client action |
|---|---|---|---|
| `PENDING` (0) | created while the tenant is ZATCA-compliant, never sent | "not yet submitted to ZATCA" | submit it |
| `FAILED` (3) | ZATCA rejected it (also HTTP 429) | "rejected by ZATCA" | fix and resubmit |
| `SERVER_ERROR` (6) | HTTP 500/503/504 or an unrecognised reply | "failed to send to ZATCA" | retry |

**`PROCESSING` (2) is deliberately excluded**, on Mirna's instruction after review: it is
written immediately *before* the API call and overwritten by every response path within the
same queued job (`ReportInvoicesToZatcaJob`), so under normal operation it is a sub-second
transient the client is never meant to see, let alone act on. A document still sitting there
when this card runs means its job died mid-run — an infrastructure fault for the team
running the queue, not a task for the client. The earlier wording ("submitted, awaiting
ZATCA response") read as a routine, no-problem state when the underlying cause is the
opposite; removing it avoids both the false alarm and the false calm. `REPORTED` (1),
`REPORTED_WITH_WARNINGS` (5) and `NOT_APPLIED` (4) are never counted.

Credit notes (`invoice_returns`) are counted alongside invoices — same `zatca_reported`
column, same resend job. Missing them undercounted `orderapp` by 6 documents.

The wording describes **state only and never promises a retry**: the resend job only picks
documents up inside a window — 14 days for standard, 1 day for simplified
(`InvoicesRepository::getFailedZatca*`) — so outside it nothing retries on its own.

**Credit notes are counted with invoices.** `invoice_returns` carries the same
`zatca_reported` column and the same resend job covers it; excluding it hid 6 stuck
documents on `orderapp` alone. It has no `is_draft` column. Because the card spans both
tables its wording is "documents", not "invoices".

### Alert period behaviour — the definitive matrix

Two endpoints, split by refresh cadence:

* **`GET /dashboard/alerts`** → VAT only. Fetched **once, on mount**, never refetched.
  Verified byte-identical across calls. VAT is a countdown to a fixed regulatory quarter;
  no selected range can change it, and refetching it on every click only made it flash.
* **`GET /dashboard/alerts/period?type=&from=&to=`** → ZATCA + overdue. Refetched on every
  `dashboard:period-changed`. It takes the period **type** and re-resolves it server-side
  through the same `ResolvingDashboardPeriodService` the selector uses, so the two sides
  cannot drift on what "This quarter" means.

| Period | ZATCA count | ZATCA "Review" link | Overdue figure | Overdue "Chase" link |
|---|---|---|---|---|
| Today | dated today, current FY | `date_from=today&date_to=today&current_year_receipts=on` | as of **today** | `is_dates=1&as_of_date=today` |
| This month | dated 1st–**month end**, current FY | `date_from=1 Sep&date_to=30 Sep&current_year_receipts=on` | as of **today** (same as Today) | same as Today |
| This quarter | dated quarter start–**quarter end**, current FY | `date_from=1 Jul&date_to=30 Sep&current_year_receipts=on` | as of **today** (same as Today) | same as Today |
| This year | whole current fiscal year | **no params at all** (the list already defaults to current FY) | current FY, as of its closing date (today while open) | `fiscal_year_id=N` only |
| Custom | dated within the range | `date_from`/`date_to`, **no** `current_year_receipts` | as of the range's end | `is_dates=1&as_of_date=<end>` |

Two rules that make the whole table hang together:

1. **The ZATCA count and its link are always scoped identically**, so the number on the
   card is exactly what the invoice list shows. Today/month/quarter pin the fiscal year on
   both sides (`current_year_receipts=on` + a `fiscal_year_id` filter) so a period
   straddling a fiscal-year boundary cannot make them disagree. Custom deliberately does
   not pin it — a custom range may legitimately reach into an earlier fiscal year.
2. **Outstanding is a position at an instant, never a sum over a window.** Today, This
   month and This quarter all end today, so all three give the same figure and the same
   link. That is correct, not lazy. Only This year (a different *as-of*, via fiscal year)
   and Custom (as of the range end) differ.

Uses `DashboardPeriod::naturalEnd()` — the period's **calendar** end (30 Sep for "This
month" on 1 Sep), distinct from `currentTo()` which stops at today. Scope data by
`currentTo()`; put `naturalEnd()` in outgoing links so the destination's filter reads as
the whole period the user picked rather than a range truncated at today.

**Two wrong versions preceded this; neither should return.**

* *as-of = periodTo*: every non-custom preset sets `to = today` by construction, so all
  four presets returned an identical figure — reported as "always getting Today's result".
* *population scoped to `transaction_date BETWEEN from AND to`*: this made "Today" mean
  "invoices **raised** today that are already overdue", which is essentially always none
  (an invoice raised today cannot be past its own due date), so the card silently vanished
  on Today and This month.

Every figure above was cross-checked against raw SQL on `orderapp`, and the invoice list
was rendered with the generated URL to confirm it truly opens filtered: `date_from`
input = `2026-07-01`, `date_to` = `2026-09-30`, `current_year_receipts` checked, and the
DataTable's ajax `data` (`json_encode(request()->all())`) carrying the whole set.

### Custom date range — the shared v2 DatePickerComponent, not a bespoke row

Rebuilt to use `date-picker-component` (`resources/js/components/DatePickerComponent.vue`,
a flatpickr wrapper already global via `Vue.component('date-picker-component', ...)` and
used throughout the v2 report filters, e.g. inside `ReportFiscalYearOrCustomRange.vue`).

The picker itself is never visible: it is stretched invisibly (`position:absolute;
inset:0; opacity:0`) over the exact footprint of the "Custom range" pill inside one
`position:relative` wrapper, and the pill's click calls the picker's public `.open()`
method (exposed via a template `ref`) directly — since flatpickr always anchors its
calendar to whichever input it is bound to, the calendar visually opens right off the
pill with no separate row and no visible text input of its own. Selecting a complete
range (`mode="range"` only emits `input` once both dates are picked) applies immediately;
there is no separate confirm step. Deep-reaching flatpickr's own generated `<input>` from
scoped CSS uses `::v-deep`, matching this codebase's existing vue-loader 15 convention
(confirmed against `report-kpi-card` overrides across several v2 report components) —
**not** `:deep()`, which is a Vue 3 SFC compiler syntax this build does not support.

Once a custom range is applied, `activeType` becomes `'custom'` and nothing else — since
the segmented buttons and the pill all key off that single reactive value, none of the
four presets can show active at the same time by construction, and the pill's own label
switches from the generic "Custom range" to the resolved dates so the selection stays
visible without a second element.

### Header-to-content gap

`.content-wrapper { padding: 1.8rem; }` (`public/assets/css/app.min.css`) is this theme's
uniform default spacing on every side of the content area. The period selector cancels
just the top of it (`margin-top: -1.8rem` on `.dps`) so the card sits flush under the app
header. Deliberately **not** the `-3rem` every v2 report's `.raf-toolbar-full` uses for
the same purpose — theirs also cancels a breadcrumb row rendered above their own toolbar,
which this page has none of (confirmed: this master layout has no `@yield('breadcrumb')`
anywhere — the dead `@section('breadcrumb')` block that was in this blade did nothing and
has been removed). Copying `-3rem` here would overshoot and tuck the card in under the
header.

## 5. The alert system (Phase 1 — BUILT)

Endpoint `company.dashboard-v2.alerts` → `GET /{locale}/dashboard/alerts`.

* **Zero persistence.** No table, no cache, no scheduled job. Computed live per request from
  indexed aggregates. Measured: 3–4 tenant queries, 29–96 ms across the local tenants.
* Returns `{ alerts: [...] }` — a flat ordered list. **An alert that does not apply is absent
  from the array.** No null placeholder, no `visible` flag.
* **Adding a card = one resolver class + one line in `DashboardAlertsService::RESOLVERS`.**
  Zero frontend change; the strip renders whatever it receives, for any count including zero.
* Each resolver is wrapped in try/catch — a resolver that throws is logged and skipped, and
  the rest of the strip still renders. Verified with a deliberately throwing resolver.
* A card leads with **either** `amount` (money headline, SAR icon or ISO code) **or** `title`.

Files: `Services/Dashboard/Alerts/` (interface, 3 resolvers, `VatFilingDeadlineCalendar`,
`DashboardAlertsService`), `DTO/Dashboard/DashboardAlert.php`,
`Enums/DashboardAlertSeverityEnum.php`, `Http/Controllers/Dashboard/DashboardAlertsController.php`,
`Resources/assets/js/components/DashboardAlertsStrip.vue`.

### Applicability gates (verified against live tenants)

| Alert | Gate | Omitted when |
|---|---|---|
| ZATCA documents | `company_info.zatca_comply = 1` (central DB, via `RetrievingTenantBySubdomainService`) **and** `zatca_comply = 1` per row | count is 0 |
| VAT return due | `settings.taxable = 1` (tenant DB) | no deadline resolvable |
| Invoices past due | none | count is 0 or outstanding ≤ 0 |

Observed: `orderapp` → 3 cards; `clientissue2` → 2 cards (ZATCA gate passes, nothing pending,
so the query runs and the card is still omitted); `mohamedtolba` / `26accounting` → 2 cards
(gate short-circuits, ZATCA query never runs).

### ZATCA states — only the ones the client can act on

Verified against where `ZatcaReporterService` actually writes each value, and against the
resend button's own eligibility check (`Sales/invoices/index.blade.php:728`, which allows
re-reporting anything except `REPORTED`/`REPORTED_WITH_WARNINGS`). Three states are counted;
each gets its own clause, and only non-zero ones are shown.

| Status | Written when | Card wording | Client action |
|---|---|---|---|
| `PENDING` (0) | created while the tenant is ZATCA-compliant, never sent | "not yet submitted to ZATCA" | submit it |
| `FAILED` (3) | ZATCA rejected it (also HTTP 429) | "rejected by ZATCA" | fix and resubmit |
| `SERVER_ERROR` (6) | HTTP 500/503/504 or an unrecognised reply | "failed to send to ZATCA" | retry |

**`PROCESSING` (2) is deliberately excluded**, on Mirna's instruction after review: it is
written immediately *before* the API call and overwritten by every response path within the
same queued job (`ReportInvoicesToZatcaJob`), so under normal operation it is a sub-second
transient the client is never meant to see, let alone act on. A document still sitting there
when this card runs means its job died mid-run — an infrastructure fault for the team
running the queue, not a task for the client. The earlier wording ("submitted, awaiting
ZATCA response") read as a routine, no-problem state when the underlying cause is the
opposite; removing it avoids both the false alarm and the false calm. `REPORTED` (1),
`REPORTED_WITH_WARNINGS` (5) and `NOT_APPLIED` (4) are never counted.

Credit notes (`invoice_returns`) are counted alongside invoices — same `zatca_reported`
column, same resend job. Missing them undercounted `orderapp` by 6 documents.

The wording describes **state only and never promises a retry**: the resend job only picks
documents up inside a window — 14 days for standard, 1 day for simplified
(`InvoicesRepository::getFailedZatca*`) — so outside it nothing retries on its own.

**Credit notes are counted with invoices.** `invoice_returns` carries the same
`zatca_reported` column and the same resend job covers it; excluding it hid 6 stuck
documents on `orderapp` alone. It has no `is_draft` column. Because the card spans both
tables its wording is "documents", not "invoices".

### The alert strip: two endpoints split by refresh cadence — corrected twice now

First cut (Phase 2a) passed the period to every resolver uniformly and fetched all three
together. Two real problems surfaced from that, both from Mirna testing the live page,
not from a script:

1. **VAT visibly reacted to the period selector even though its value never changed.**
   All three cards shared one JSON response, so every period change re-fetched and
   re-rendered the whole strip — VAT flashed/reloaded for no reason on every click.
2. **The overdue figure never actually changed for Today/Month/Quarter/Year.** The first
   version used `periodTo` alone as an "outstanding as of" snapshot date. Every non-custom
   preset sets `to = today` by construction (`ResolvingDashboardPeriodService`), so the
   four presets were mathematically guaranteed to produce the identical figure — "still
   getting Today's result" on every single click, reproducibly, not a timing fluke.

Fixed by splitting `DashboardAlertsService` into two groups with two endpoints:

* **`GET /dashboard/alerts` → `executeStatic()`** — VAT only. The frontend
  (`DashboardAlertsStrip.vue`) fetches this exactly once, on mount, and never again.
  Verified: called twice with different arguments in between, byte-identical response
  both times.
* **`GET /dashboard/alerts/period?from=&to=` → `executePeriod()`** — ZATCA + overdue. The
  frontend refetches this on every `dashboard:period-changed` broadcast (once for the
  remembered selection on mount, again on every change) — same `followsPeriod` contract
  `DashboardCard.vue` already uses. Overdue now scopes the *population* to
  `transaction_date BETWEEN periodFrom AND periodTo` (matching how ZATCA already worked),
  evaluating overdue/outstanding status against **today**, never against the period
  bounds — a document's due-date status is a live fact, not something that changes
  because a different window was selected. Verified on `orderapp`: This month
  (Aug 1 – Sep 1) → 9 documents / 9,800.00; This quarter (Jul 1 – Sep 1) → 12 / 11,254.15
  plus 1 ZATCA document — genuinely different populations, not just different labels.

`useDashboardFetch`'s `load()` never clears `payload` before a request resolves (only
replaces it atomically on success), so a `periodFetch` refetch never blanks the cards
that are already showing — confirmed by reading the composable, not assumed.

Known limitation, not fixed: the customer-aging report's own filter model is a single
as-of-date snapshot with no transaction-date-range filter, so the overdue alert's action
link cannot deep-link to exactly its own population-by-range calculation — it opens the
report as-of today, the closest the destination actually supports. Making them match
exactly would mean adding a real date-range filter to the aging report itself, a
different module, out of scope here without an explicit go-ahead.

The action *links* were also extended, verified against each destination's real query
contract:

* **VAT → taxes report**: now links with `is_dates=1&from=<filing period start>&to=<filing
  period end>` — the SAME quarter the alert is about (from `VatFilingDeadlineCalendar`),
  not the dashboard's selected period. Verified by reading `TaxesReportValidator` (rules
  identical in shape to `CashFlowDirectValidator`, already confirmed end-to-end for the
  aging report) and `NewTaxesReportController::index()` (`hasAny(['fiscal_year_id','from',
  'to'])` renders results immediately on those params, same as the aging report). Full
  render-through-controller verification hit an unrelated harness limitation
  (`getSubdomain()` returns `'System'` when PHP is invoked via CLI, since
  `app()->runningInConsole()` is true) — verified by contract match instead of a live
  render for this one.
* **ZATCA → invoices list**: left unchanged. The list's ZATCA-status filter is wired to a
  DataTables per-column search triggered by a `<select>` change event
  (`invoices/index.blade.php:1054-1059`), not a page-load query parameter — there is no
  safe single query string that deep-links "all four problem statuses" on that page, and
  guessing one risks repeating the aging-report mistake.
* **Overdue → aging report**: unchanged, still `is_dates=1&as_of_date=<today>` — correct
  as-is, since this alert is explicitly "as at today" regardless of the dashboard period.

### Card action links carry their filter

The overdue card links to the ageing report with `is_dates=1&as_of_date=<today>` — **both
params, not `as_of_date` alone.** Verified by rendering `CustomerAgingController::index()`
directly (bypassing HTTP/session) with each URL shape:

* `as_of_date` alone: the fiscal-year `<select>` still defaults to "current fiscal year"
  (`customer_aging/index.blade.php:480` only skips that default when `is_dates` is present).
  Its value is sent as `fiscal_year_id` on every DataTable row request **regardless of
  as_of_date**, silently scoping the customer-by-customer table to one fiscal year while
  the grand totals above it stay unscoped — a real mismatch between the two numbers on the
  same screen, which is what surfaced this.
* `is_dates=1&as_of_date=...`: the "custom date" option is selected instead,
  `fiscal_year_id` resolves empty, and nothing narrows the rows.

Rendering the controller directly is now the standing way to verify any future report
deep-link from a dashboard card — the query string a report *documents* is not always the
query string its own filter form would produce.

**Correction, decided during Phase 2's build:** the sentence that used to stand here about
the period selector landing and these two params carrying the selected period instead was
wrong — that was tried, and reverted by explicit team-lead decision. The alert strip stays
completely independent of the dashboard period selector, permanently: it always reports
the position as at today, on every card. See "The alert strip is independent of the
period selector" below for the full reasoning. Nothing in this file should describe the
alerts as period-aware again without that decision being revisited.

### The alert strip is independent of the period selector — final decision

The strip takes no period anywhere: no query params on `GET /api/v2/companyuser/dashboard/alerts`, no
`dashboard:period-changed` listener in `DashboardAlertsStrip.vue`, no period argument on
`DashboardAlertResolverInterface::resolve()`. This was tried the other way during Phase 2
(each resolver taking a period, ZATCA/overdue scoped by the selected range) and reverted:
it produced alerts that vanished or changed meaning depending on an unrelated control —
e.g. "Today" made the overdue card ask "invoices raised today that are already overdue",
which is nearly always none. An alert answers "what needs my attention right now", which
is not a question a selected reporting window can change. Do not reintroduce period
coupling here without that decision being revisited.

The VAT return countdown and the two link fixes below (VAT return quarter, ZATCA invoice
list) were carried forward regardless of that reversion — they are genuine bug fixes
independent of the period-coupling experiment, not part of what got undone.

* **VAT return due — action link now deep-links to the SAME filing quarter the alert is
  about.** `VatReturnDueAlertResolver` previously linked to the tax report with no query
  string at all; it now sends `is_dates=1&from=<quarter start>&to=<quarter end>` (the
  quarter `VatFilingDeadlineCalendar` resolved), verified against
  `TaxesReportValidator`/`NewTaxesReportController` rendering results immediately with
  that exact shape — the same `is_dates=1` contract already confirmed above for the
  ageing report.
* **ZATCA documents — "Review" link now carries the invoice list's own empty filter
  keys** (`customer=&cost_center_id=&date_from=&date_to=&amount_from=&amount_to=`), not no
  query string. Every filter in `InvoicesRepository` is guarded with `! empty()`, so these
  are inert as filters — their only job is to keep `request()->query()` non-empty, because
  an unfiltered request makes `InvoiceController::index` silently apply its
  `current_year_receipts` default (current fiscal year only), which would show fewer rows
  than the ZATCA card counted (the card is deliberately unscoped by date or fiscal year —
  see the ZATCA states section above). Verified by rendering the list with this exact
  query string.

### Alert strip empty state — renders "all clear", not nothing

`DashboardAlertsStrip.vue` originally rendered nothing at all — not even a wrapping
`<section>` — once loaded with zero applicable alerts, on the reasoning that "an empty
strip is noise." In practice this made "nothing needs attention" indistinguishable from a
broken fetch: no skeleton, no message, just a gap, with nothing on screen telling the user
the alert system even ran. The `dashboard_v2.php` lang file already carried an
`alerts.empty` string (both `en`/`ar`) for exactly this case since Phase 1, defined but
never wired to anything until this fix.

Fixed by rendering `report-v2-empty-state` — a component the v2 reports already use
globally for their own "nothing here" case (already registered in `resources/js/app.js`
before this dashboard refactor existed; §4's design language explicitly lists it
"reusable as-is") — instead of hiding the section. The strip's root `<section>` is no
longer conditional at all: it always shows exactly one of skeleton / error / empty-state /
cards, driven by a new `all-clear-label` prop (`index.blade.php` passes
`dashboard_v2.alerts.empty` into it).

### VAT deadlines — JSON, never the DB

`Modules/CompanyUser/Config/vat-filing-deadlines.json`, hand-edited. Every tenant is a
quarterly filer; there is no per-tenant filing-frequency setting and none is to be added.
Deadline = last day of the month **following** the quarter (Q1→30 Apr, Q2→31 Jul, Q3→31 Oct,
Q4→31 Jan next year), per Story 4 and the prototype. The engine picks the earliest deadline
still ahead of today. Verified: 25 Aug 2026 → 67 days to 31 Oct, matching the prototype.
Severity: >30 days info, ≤30 warning, ≤7 critical (thresholds also in the JSON).

⚠️ Story 4 AC6 ("says the return is overdue when the deadline has passed and it is not
filed") is **not implementable** — nothing in the schema records whether a return was filed.

### Known cost — flagged, not fixed

The ZATCA count is a **full table scan**: `invoices` has no index on `zatca_reported` or
`zatca_comply`. Measured 50 ms over 112,266 rows on `orderapp`, versus 4.4 ms for the
indexed ageing aggregate. It scales linearly, so a million-invoice tenant pays ~450 ms on
every dashboard load. A composite index on `(zatca_comply, zatca_reported)` fixes it, but
that is a migration across every tenant database on a hot table — rule 1.2 says flag first.

---

## 6. Data facts worth not re-deriving

**Stack:** Laravel 10, `nwidart/laravel-modules`, `hyn/multi-tenant` v5.9.1,
Vue **2.7.16** (native `<script setup>` works — the v2 report components use it),
Laravel Mix / webpack 5, axios 0.26 present but the v2 reports use native `fetch`.
CI runs Pint + Rector only — **no test gate**. Verify by running things yourself.

**`invoices` (tenant DB)**
* `zatca_reported` tinyint — values from `Modules\Sales\Enums\Zatca\ReportInvoiceEnum`
  (`PENDING 0, REPORTED 1, PROCESSING 2, FAILED 3, NOT_APPLIED 4, REPORTED_WITH_WARNINGS 5,
  SERVER_ERROR 6`; `NOT_REPORTED = [0,2,6,3]`).
* `zatca_comply` bool (per invoice), `zatca_error` longtext.
* `due_date`, `paid_amount`, `total_amount`, `is_draft`, `invoice_status_id`, `fiscal_year_id`.
* `invoice_statuses`: 1 drafted, 2 paid, 3 not_paid, 4 due, 5 overdue, 6 returned,
  7 partially_returned (mirrored in `Modules\Purchases\Enums\BillStatusEnum`).

**`settings` (tenant DB)** — `taxable`, `online_invoicing`, `currency`, `country`,
`tax_id_number`, `name_ar` / `name_en`, `img`. Read via
`Modules\Settings\Repositories\GeneralSettingsRepository::getLast()`.
There is **no VAT filing-period column** — the quarterly/monthly cadence in the prototype
has no home in the schema yet. Open question, see §8.

**Ledger (the backbone — Stories 1, 4, 5, 6)**
* `journal_records`: `account_id`, `value` decimal(63,4), `transaction_type` enum(debit|credit),
  `journal_date`, `fiscal_year_id`, `cost_center_id`, `tax_id`, `customer_id`, `supplier_id`,
  `currency_code`, `exchange_rate` **double(8,2) — only 2dp**, `deleted_at`.
* `journal_entries`: `memo`, `meta_data` json, `transaction_type_id`, `journal_date`, `amount`.
* **`accounting_trees` is a closure table** — `(account_id, parent_id, level)`, every ancestor
  stored, 1,685 rows, max depth 3. "Account + all descendants" is one indexed join, no recursion.
* Account groups: `AccountsEnum` (fixed seeded ids) + `AccountingSubCategoriesEnum` —
  revenue `SALES(8)`/`OTHER_REVENUE(9)`, purchases `PURCHASES(13)`/`COST_OF_GOODS_SOLD(64)`,
  expenses `GENERAL(12)`/`OPERATING(11)`/`MARKETING(10)`, cash `CASH_AND_CASH_EQUIVALENTS(1)`,
  VAT accounts `SALES_VAT(17)`, `PURCHASES_VAT(18)`, `VAT_ADJUSTMENTS(55)`.
* `account_fiscal_year_balances`: `(account_id, year_id)` → opening/closing debit/credit at 4dp.

**POS linkage (Story 1 split)** — exact FKs, not memo strings:
`success_synced_order_manual_entry(journal_entry_id, integration_type, transaction_type)` and
`success_synced_order_single_records(journal_id, integration_type, transaction_type)`.
`transaction_type` uses `IntegratedOrderTransactionTypesEnum` (sales/purchase/*_returns).
**Empty in all five local tenants** — the POS split cannot be verified locally.

**Per-user state (Story 2 "remembered between visits")** — `user_configurations(user_id, key,
value)`. Correction to an earlier note here: this table is **not** actually in live use
anywhere — grepped and confirmed its only touches are a factory/seeder and
`ResetDataJob`'s tenant-clone copy. The `key='preferred_language'` rows seen earlier were
seed data, not a real feature (the real preferred-language flow uses a column on `users`).
That makes it a safe, ownerless generic KV store for us to become the first real consumer
of — `Modules\CompanyUser\Services\Dashboard\Period\DashboardPeriodPreferenceRepository`
uses key `dashboard_period_selection`. Built for the period choice; the accrual/cash basis
choice (Phase 6) can reuse the same table under its own key.

**VAT reconciliation** — `NewTaxesReportService` is hybrid: it sums document details *and*
reads `journal_records` by `tax_id` (there is a comment saying this is deliberate, so the tax
report agrees with the account statement). Measured on `mohamedtolba`: 719 VAT-account lines,
100% carry a `tax_id`, zero `tax_id` lines outside the VAT accounts. The divergence case is a
manual entry posted to a VAT account with no tax selected — worth an invariant test.

**Local tenants:** `VOM_tenant_mohamedtolba` (main test subject, 4 fiscal years, all
activity in FY4), `_26accounting`, `_clientissue`, `_clientissue2`, `_orderapp`.
Query directly: `mysql -h127.0.0.1 -uroot -p"$(grep '^DB_PASSWORD=' .env | cut -d= -f2-)" VOM_tenant_mohamedtolba`.

---

## 7. Reusable services (survey — see REUSE.md for the full list)

Green (use as-is): `ReportsAgingCustomerService::getGrandTotalSummary()`,
`RetrievingCurrentFinancialYearService`, `RetrievingSettingsService`,
`RetrievingTenantCurrencyService`, `GeneralSettingsRepository`,
`CompanyInfo::scopeZatcaComply()`, `SubscriptionReportStatusService`.

Red (do **not** extend): `Modules\Settings\Services\Dashboard\RetrievingDataForDashboardService`
— 340 lines, hydrates every invoice/bill/return/expense into memory and groups in PHP.
It is the legacy dashboard's data layer. Leave it alone, let the new cards replace it
piece by piece, delete it only at the final URL swap.

---

## 4f. Data-integrity audit of Top Customers / Most Overdue / Trend / Expense breakdown (2026-09-06)

Full audit requested against the two live stories ("correct reporting basis" and "trend +
expense breakdown"), scoped to the four cards unique to `refactor/dashboard-money-and-top-
customers`. Findings:

**Reporting Basis story is only partially implemented.** `DashboardTopCustomersService` was
rewritten (since Phase 5) to read `report_sales_summary` through
`ReportSalesSummary::queryForSalesSummaryReport()`/`salesSummaryTotalsForQuery()` — the same
source and KPI expressions the Sales-by-Customer report uses — and correctly branches
`accrual` (invoice date) vs `cash` (receipt date) via a `basis` query param, validated
against `DashboardBasisEnum`. `DashboardMostOverdueService` correctly declares
`basis_applies: false` and now delegates entirely to a new
`ReportsAgingCustomerService::getMostOverdueDocuments()` (permission-filtered via
`applyCostCenterFilter()` → `applyPermissionFilter()`, same as the aging report). Both are
solid. **But nothing else got the treatment**: `LedgerBalanceService`,
`DashboardTotalsService`, `DashboardVatPositionService`, `CashPositionLedgerService`,
`DashboardSalesPurchasesTrendService`, `DashboardExpenseBreakdownService` have zero `basis`
parameter and zero `reporting_basis`/`basis`/`period` fields in their payloads — and **no
UI exists anywhere to switch basis** (no selector component, no persistence repository
alongside `DashboardPeriodPreferenceRepository`). `DashboardTopCustomersCard.vue` only
displays whichever basis the server defaults to; nothing ever requests `cash`. Per Mirna
(2026-09-06): scope the full basis-toggle build (selector + persistence + threading
through the 6 ledger-basis services) as a separate follow-up plan, not part of this branch.

**`report_sales_summary` was badly stale on local tenants — a backfill gap, not a code
bug.** 26accounting had 736 real invoices but only 76 synced rows; clientissue2 similarly
734 vs 77. Traced every live sync path (`InvoiceObserver`, `InvoiceReturnObserver`,
`CustomerPaymentReceiptObserver` all correctly call `ReportSalesSummarySynchronizer` on
save) — the incremental sync is correct. The gap was purely a missing one-time historical
backfill (`php artisan report:populate-sales-summary --website-id=<id> --sync`), likely
because these tenants' historical data was seeded via direct inserts that never fired
Eloquent observers. Ran the backfill for 26accounting/clientissue/clientissue2; row counts
now match invoice counts exactly. Worth checking whether any staging/production tenant —
especially a fresh clone or a newly POS-integrated one — has ever had this backfill run;
the symptom (Top Customers looks emptyish rather than erroring) is easy to mistake for
"this tenant just has no data."

`clientissue` still shows zero Top Customers/Most Overdue after the backfill — verified
correct: it is a pure-Foodics-POS tenant with zero rows in `invoices` (all revenue is
ledger-only, per this file's own `LedgerBalanceService` docblock), so there is genuinely no
customer to rank. Same "looks broken, isn't" class as the `clientissue2` expense-breakdown
check two turns ago.

**Test coverage — corrected finding.** First pass concluded "zero tests exist in this
entire repo", which was wrong: a bad `find` path filter (`*/Tests/*`, capital T) missed the
project's real suite at `tests/Unit`/`tests/Feature` (55 existing files covering Accounting/
Sales/Settings). The accurate gap was "zero tests for CompanyUser/Dashboard specifically."
Wrote 22 tests across `tests/Unit/Dashboard/` and `tests/Feature/Dashboard/`: pure
Mockery-based unit tests for `DashboardSalesPurchasesTrendService` (granularity mapping,
bucket boundaries, Monday-start weeks, calendar quarters) and `DashboardExpenseBreakdownService`
(a direct regression test for the filter-before-sum bug fixed in §4e — asserts a negative-
net account still lands in "Other" and the six slices sum exactly), a DTO-only test for
`DashboardTopCustomersCard`'s 100%-clamp, an integration test against real (now-backfilled)
26accounting data reconciling both bases against `report_sales_summary`'s own totals, and
controller-level validation tests (unknown period type / unknown basis → a card error, never
an uncaught exception or a silent fallback). All 22 pass; running the full existing suite
alongside them surfaced 3 pre-existing, unrelated failures in Accounting/Sales tests
(`FindingDuplicateInvoiceJournalEntriesService` missing a `cost_center_id` column,
`ProformaInvoiceLineCalculationService` class not found) — not touched, flagged for whoever
owns those tests.

---

## 4g. The accrual/cash reporting-basis toggle (2026-09-06 — BUILT)

Closes the gap §4f found: the toggle now has a real UI, real persistence, and real cash-
basis computations for every ledger-basis widget except VAT position and Cash position,
which are exempt (confirmed with Mirna): VAT liability is legally accrual under KSA rules
regardless of the toggle, and Cash position's own figures already *are* cash movements
with nothing to convert to.

**Cash-basis revenue and purchases deliberately do NOT reuse the weekly-insights email's
calculation** (`InsightsCalculationService::calculateRevenue()`), even though the story's
own AC names it as the reconciliation target — that calculation is known-buggy and fixing
it was ruled out of scope (Mirna, 2026-09-06). Instead, per her direction ("independent
services unless using v2 report services, these are tested and bug-proof"):
- `CashBasisSalesService`/`CashBasisPurchasesService` wrap `ReportSalesSummary`/
  `ReportPurchasesSummaryTransaction`'s existing `total_payment` KPI — the *same* v2-report
  aggregate `DashboardTopCustomersService` already used for its own cash mode, so "cash
  basis" means one consistent thing everywhere on the dashboard now, anchored on
  `report_sales_summary`/`report_purchases_summary` rather than two different definitions.
- `CashBasisExpensesService` is genuinely new (no v2 report exists for it): `Expense`
  records have no payment-date tracking at all (confirmed via migration — no `paid_amount`,
  posts as already-paid at entry via `payment_account_id`), so `Expense.date` already IS
  the cash event; summed with any manual journal entry posted directly to an
  EXPENSES-subcategory account (also no separate payment step, so its own `journal_date`
  is equally a cash event). **The double-count trap**: every `Expense::save()` already
  creates one such posting, so the manual-JE side must exclude every
  `expenses.journal_entry_id` or the same expense counts twice. New
  `LedgerBalanceService::manualStandardLedgerMovement()` takes an exclusion list for
  exactly this — additive only, doesn't touch `signedMovement()`. Verified live on
  `mohamedtolba` (the only local tenant with real `Expense` rows): without the exclusion,
  manual movement read 2,818,304.16; with it, 2,677,303.73 — a real 141,000.43 SAR
  difference that would have been double-counted.

**Expense breakdown's cash basis has no per-account split** — `expense_details` is keyed
by `product_id`, not an expense-category account (confirmed via migration), so there is
nothing to group cash-basis expenses BY beyond the scalar `CashBasisExpensesService`
total. Rather than guess at a split, the donut shows one honestly-labelled slice
("Expenses paid this period") on the cash basis, per the plan's own documented fallback.

**Persistence and UI mirror the period selector exactly, as two independent axes**:
`DashboardBasisPreferenceRepository`/`DashboardBasisController` are structural copies of
`DashboardPeriodPreferenceRepository`/`DashboardPeriodController` (same
`Auth::guard('sanctum')->id() ?? auth()->id()` gotcha, same GET-on-mount/POST-on-change
shape) — new files, not shared code, since a user can change either selection without
touching the other. `DashboardPeriodSelector.vue` gained a second segmented control
(Accrual/Cash) using its own existing `.dps-seg` CSS verbatim, broadcasting a *separate*
`dashboard:basis-changed` window event alongside the existing `dashboard:period-changed` —
kept separate rather than merged into one payload so every already-shipped
period-changed-only listener keeps working unchanged. New composable
`usePeriodAndBasisFollowingCard.js` (not a change to `usePeriodFollowingCard.js` — VAT/
Cash position are exempt and must keep using the plain one untouched, per §1.2) tracks
last-known period AND basis so a change to either alone still re-fetches correctly.
`DashboardTotalsRow.vue` already had its own hand-rolled period-following lifecycle
(predating the shared composable's extraction) — extended in place rather than migrated
to the composable, to minimise risk to an already-shipped, heavily-used component.

Verified end-to-end over real HTTP on `26accounting` (`GET`/`POST /basis` round-trip
persists correctly, an invalid basis 422s), and live in a browser: clicking Accrual→Cash on
the real segmented control correctly re-fetched and updated Total Sales
(850,060.97 → 985,785.62) and Net Profit (130,127.64 → 265,852.29) with zero console
errors. Added `DashboardTotalsServiceTest` (3 tests: accrual reads the ledger, cash reads
the cash services and never touches the ledger, POS split never shows on the cash basis)
plus cash-basis cases in the existing Trend/Expense-breakdown test files — 28 dashboard
tests total, all passing, zero regressions in the other 274.

---

## 8. Open questions (raise, don't guess)

Superseded by the six business stories received 2026-08-30 — see `PLAN.md` §2 and §5 for the
live decision list. Resolved since:

* **VAT filing period** — still has no column anywhere. Story 4 assumes it exists. Blocked.
* **POS integrations** — Story 1 names Foodics/Marn "or any future integration" and specifies
  memo matching; the DB already links POS journal entries by FK. See `PLAN.md` §2.1.
* **Permissions per card** — still open, no story covers it.
* **AC8 does not hold for the weekly insights email — flagged, not fixed (2026-09-02).**
  `InsightsCalculationService::calculateOverduePayments()` is a third, independent
  implementation: reads `invoices` directly (not `report_customer_aging`), its own
  0-7/8-30/30+ day bands (not Current/1-30/31-60/61-90/91+), scoped to the current fiscal
  year only, and — the real correctness bug — blanket-excludes any invoice with status
  `returned`/`partially_returned` even when it still carries a genuine outstanding
  balance, which is exactly backwards from AC4 ("credit notes reduce the document they
  were raised against, they don't remove it"). Verified on `mohamedtolba` as of
  2026-09-02 by calling `calculateWeeklyInsightsForWebsite()` directly (never the
  `run:calculate-weekly-insights` artisan command, which always chains an email-send job)
  and deleting the test row afterward: report/widget total **2,300,009.60** (83 docs) vs.
  insights **2,263,225.10** (64 docs) — the entire 10-document, ~36.8K gap traced to
  exactly that status exclusion. There is also no payables/overdue-bills metric in the
  insights email at all, so the payables widget has nothing there to even diverge from.
  User's call: leave the email as-is for now (it is live, shared production code, not
  scoped to this dashboard story) and track it separately — see the ticket handed off
  alongside this note rather than duplicating its detail here.
