# Dashboard Refactor — Plan & Story Analysis

Six business stories received 2026-08-30. This connects them to the codebase, records what
I verified against live data, and proposes a phase order. Companion to `RULES.md` / `REUSE.md`.

---

## 1. The one finding that reshapes everything: Story 1

Story 1 mandates that Total Sales / Purchases / Expenses / Net Profit are read from
**ledger account balances**, never summed from transaction tables.

That is a precise diagnosis of the legacy dashboard's actual defect:

* `RetrievingDataForDashboardService` sums `invoices.total`, `bills.total`, `expenses.total`
  — **transaction tables**.
* The Profit & Loss card on the same screen comes from `IncomeStatementChartService` →
  `IncomeStatementService` — **the ledger**.

Two bases, one screen. That *is* the 214,774-vs-370,732 discrepancy in Story 1's acceptance
criteria. It is not a rounding bug; it is an architectural one. Any manual journal entry
posted to a revenue account is invisible to the tiles and visible to the P&L card.

**Consequence for the refactor:** every ledger-basis figure in Stories 1, 4, 5 and 6 comes
from `journal_records`, and `RetrievingDataForDashboardService` is not adapted — it is
replaced card by card and deleted at the URL swap.

### The ledger machinery is already there and it is fast

Verified on `VOM_tenant_mohamedtolba`:

* **`accounting_trees` is a closure table** — `(account_id, parent_id, level)`, all ancestors
  stored, not just direct parents. 1,685 rows, max depth 3. So Story 1's *"and every child
  account under them"* is **one indexed join**, no recursion:
  `WHERE account_id IN (:roots) OR account_id IN (SELECT account_id FROM accounting_trees WHERE parent_id IN (:roots))`
* **`journal_records`** carries everything the stories need on one row: `account_id`, `value`,
  `transaction_type` (debit|credit), `journal_date`, `fiscal_year_id`, `cost_center_id`,
  `tax_id`, `customer_id`, `supplier_id`, `currency_code`, `deleted_at`.
* Account groups come from `AccountsEnum` + `AccountingSubCategoriesEnum` (seeded, fixed ids):
  revenue = `SALES(8)` + `OTHER_REVENUE(9)`; purchases/COGS = `PURCHASES(13)` +
  `COST_OF_GOODS_SOLD(64)`; expenses = `GENERAL_EXPENSES(12)`, `OPERATING_EXPENSES(11)`,
  `MARKETING_EXPENSES(10)`; cash & bank = `CASH_AND_CASH_EQUIVALENTS(1)`;
  VAT = `SALES_VAT(17)`, `PURCHASES_VAT(18)`, `VAT_ADJUSTMENTS(55)`.

This means **one shared `LedgerBalanceService`** — "signed movement on these account groups
+ descendants, between these dates, optionally filtered" — serves Stories 1, 4, 5 and 6.
That is the backbone of the whole refactor. New class, nothing existing touched.

---

## 2. Conflicts and gaps found in the stories

Ordered by how much they affect delivery. Each needs a decision from you.

### 2.1 POS detection — the story specifies a worse mechanism than the one that exists 🔴

Story 1: *"Every journal entry a POS integration posts carries a memo identifying its source
… matched against the tenant's configured list of integration memo patterns."*

The database already links these **exactly**:

| table | journal FK | discriminator |
|---|---|---|
| `success_synced_order_manual_entry` | `journal_entry_id` (indexed) | `integration_type`, `transaction_type` |
| `success_synced_order_single_records` | `journal_id` (indexed) | `integration_type`, `transaction_type` |

Both are written by the sync itself, and `transaction_type` already uses
`IntegratedOrderTransactionTypesEnum` (`sales`, `purchase`, `sales_returns`, `purchase_returns`).

Memo-pattern matching is strictly worse: it needs a new tenant-configuration feature that
doesn't exist, and it **misclassifies a manual journal entry whose memo happens to contain
"Foodics"** — which is exactly the accountant-correction case Story 1 is otherwise so careful
about. It also fails silently when an integration changes its memo wording.

**Recommendation:** classify by the FK join. Keep memo matching only as a documented fallback
for an integration that does not write these rows, and only if one actually exists.
Story 1's AC ("a posting is classified as from POS only when its memo matches…") would need
rewording to "…only when its journal entry is linked to a POS-synced order."

### 2.2 VAT filing frequency does not exist anywhere 🔴 blocks Story 4

Re-confirmed: `settings` has `taxable`, `tax_id_number`, `country` — **no filing-frequency
column**, nothing in `accounting_settings` either. Story 4 AC3 ("respects whether the tenant
files monthly or quarterly") cannot be built without a schema change plus a settings screen
plus a backfill decision for every existing tenant. Nobody has costed that.

Options: (a) add the column + setting as part of this refactor — honest, but it is a Settings
feature, not a dashboard one; (b) infer quarterly for everyone and ship the rest of Story 4;
(c) hold Story 4 until Settings owns it. **I'd take (a) and scope it explicitly.**

### 2.3 Story 3 AC8 orders me to centralise — this overrides your ground rule 2 🟡

*"The ageing report, the dashboard widgets and the weekly insights email all read one
calculation."* That means modifying `InsightsCalculationService`, which powers the shipped
weekly email. Your rule 2 says flag before touching anything an existing feature calls.
**Flagging: this one is mandated by the story, so I need an explicit yes.**
Story 1 couples the same way — *"with the cash basis selected, the revenue figure equals the
revenue in the weekly insights email"*.

### 2.4 Payables ageing has to be built from scratch 🟡

Confirmed again: `report_customer_aging` + `ReportsAgingCustomerService` exist for
receivables; there is **no supplier equivalent anywhere**. Story 3 requires identical bands
over bills and bill returns. This is no longer optional (as I framed it last time) — it is
required. It is also the single largest hidden cost in the six stories.

Good news: the existing service takes `daysPerPeriod` / `numberOfPeriods` dynamically
(`DEFAULT_DAYS_PER_PERIOD = 30`), so Story 3's fixed five bands are just 30 × 4 — the
receivables side needs no change, only pinning.

### 2.5 Story 2 AC7 vs. your ground rule 4 🟢 resolvable

AC7 says *"every widget updates together rather than one at a time"*; your rule says every
card owns its API and its skeleton. Both hold if period changes fan out in parallel and swap
content behind a render barrier once all responses settle. First page load stays per-card
progressive. Calling it out so the behaviour is a choice, not an accident.

### 2.6 Story 5 AC5 — FX precision 🟡

*"Foreign currency balances translated at the period end rate."* Both
`journal_records.exchange_rate` and `invoices.exchange_rate` are `double(8,2)` — two decimal
places. For a weak currency that is a materially lossy rate. Flagging; not a dashboard bug.

### 2.7 VAT reconciliation risk — measured, and currently clean 🟢

Story 4 reads VAT from **VAT accounts**; `NewTaxesReportService` groups by **`tax_id`**, and
already reads `journal_records` for exactly this reason (there's a comment in it saying so).
Those two agree only if every VAT-account posting carries a `tax_id`.

Measured on `mohamedtolba`: 719 VAT-account lines, **100% carry a `tax_id`**, and **zero**
`tax_id` lines sit outside the VAT accounts. Clean today. The divergence case is a manual
journal entry posted straight to Sales VAT with no tax selected — worth an invariant test
rather than an assumption.

### 2.8 No local tenant can verify the POS split 🟡

`success_synced_order_*` are **empty in all five local tenants**. Story 1 requires the split
verified "against live tenant data for a POS-integrated tenant across at least three periods".
That needs a prelive/production tenant with Foodics or Marn. Please point me at one.

### 2.9 "Database free" alerts — confirming my reading 🟢

You said the alert system must be "completely database free … we can't afford to hit the db
every time someone logs in." I'm reading that as **no persistence, no new tables, no cache
layer — computed live from cheap indexed aggregates**, not "no queries" (which isn't possible).
Say if you meant something stricter.

---

## 3. Where each story's data comes from

| Story | Basis | Source | Status |
|---|---|---|---|
| 1 · Sales/Purchases/Expenses/Net Profit | Ledger | `journal_records` × `accounting_trees` | new `LedgerBalanceService` |
| 1 · POS split | Ledger | + `success_synced_order_*` join | see 2.1 |
| 1 · reconcile to P&L | — | `IncomeStatementService` | exists |
| 2 · period + comparison | — | `fiscal_year`; **`user_configurations`** for the remembered choice | table has no other live reader/writer — see RULES.md |
| 3 · receivables ageing | Documents | `ReportsAgingCustomerService` / `report_customer_aging` | exists, reuse |
| 3 · payables ageing | Documents | — | **build** (2.4) |
| 4 · VAT position | Ledger | `journal_records` on accts 17/18/55 | blocked on 2.2 |
| 4 · reconcile to VAT return | — | `NewTaxesReportService` | exists; see 2.7 |
| 5 · cash position | Ledger | `journal_records` on `CASH_AND_CASH_EQUIVALENTS` | CFD V2 classifier reusable |
| 5 · reconcile to Balance Sheet | — | `BalanceSheetService` | exists |
| 6 · trend chart | Ledger | `journal_records` grouped by period | replaces the `format('W')` bare-week bug |
| 6 · expense split | Ledger | grouped by `accounting_categories` | new |

---

## 4. Proposed phase order

**Your stated Phase 1 was the alert strip. I'd move it.** The three alert cards are thin
presentation over engines that don't exist yet — "invoices past due" is Story 3's ageing
calculation, "VAT return due" is Story 4's filing-period calculation. Building the alerts
first means writing throwaway queries for both, then rewriting them two phases later.

| Phase | Content | Why here |
|---|---|---|
| **0** | Fix Vue rendering; new route + blade; `LedgerBalanceService`; `BaseDashboardCardController`; `<dashboard-card>` shell | Nothing renders without it; the ledger service is the backbone of four stories |
| **1** | Story 2 period selector + Story 1 four tiles (no POS split) | First visible value; proves the ledger basis and the reconciliation to P&L |
| **2** | Story 3 receivables + payables + **the alert strip** | Alerts now sit on a real ageing engine; payables build lands with its consumer |
| **3** | Story 4 VAT position + VAT alert card | Gated on the filing-frequency decision (2.2) |
| **4** | Story 5 cash position; Story 6 trend + expense split | Reuses the phase-0 ledger service; removes the P&L card and the bare-week bug |
| **5** | Story 1 POS split; Story 1 cash-basis toggle; insights-email convergence (2.3) | The genuinely optional/coupled work, last, once totals are trusted |

If you want the alert strip visible earlier for demo reasons, I can ship it in Phase 1 with
the ZATCA card only — that one has no engine dependency (`invoices.zatca_reported` +
`CompanyInfo::zatcaComply()`) — and add the other two cards in Phase 2.

---

## 5. Decisions — ANSWERED 2026-08-30

1. **POS classification → use the FK.** `success_synced_order_manual_entry.journal_entry_id`
   and `success_synced_order_single_records.journal_id`, discriminated by `integration_type`.
   No memo matching, no tenant pattern configuration. Story 1's memo wording is superseded.

2. **VAT deadlines → quarterly, from a JSON config file, never the DB.** No filing-frequency
   column, no settings screen — Story 4's monthly/quarterly per-tenant choice is descoped.
   All tenants are quarterly. The engine is a countdown from today to the next configured
   date, and the dates are edited by hand in the JSON.
   ⚠️ **Discrepancy to confirm:** Mirna listed *31 Mar / 30 Jun / 30 Sep / 31 Dec* — those are
   quarter **ends**. Story 4 and the prototype both define the **deadline** as the last day of
   the month *following* the quarter (Q3 ends 30 Sep → due 31 Oct; the prototype's "67 days"
   from 25 Aug only works to 31 Oct). ZATCA's real rule is the month-after. Built JSON-driven
   so both are one config edit apart; seeded with the month-after deadlines per Story 4.

3. **Insights → call `InsightsCalculationService`, do not modify it by one bit.** Read-only
   consumption. Story 3 AC8 therefore inverts: the email does not adopt our calculation, the
   dashboard must *match* the email's. ⚠️ Before wiring this up, confirm which of its methods
   are side-effect free — `getStoredInsights()` reads; `calculateWeeklyInsights()` may write
   to `weekly_insights`. Only read-only methods may be called from a dashboard request.

4. **Payables ageing → build `report_supplier_aging` as a mirror of `report_customer_aging`**,
   with a repopulate command/job and observers keeping it current.
   ⚠️ Note: the receivables side does **not** use observers — it updates through inline calls
   to `ReportsAgingCustomerService::updateTransactionRecord()` from the Sales services. The two
   sides will not be structurally identical. Observers are the cleaner choice and are used
   elsewhere (`#[ObservedBy(InvoiceObserver::class)]`), so proceeding as instructed.

5. **Phasing → alerts stay Phase 1, original plan holds.** My objection is moot: decision 2
   removed the VAT schema blocker, and the overdue card rides on `report_customer_aging`,
   which already exists. **No story requirement needs to land before the alerts** — payables
   ageing is the only thing that must be built, and no alert card depends on it.

6. **POS-integrated tenant** — Mirna providing 2026-08-31. POS split is the last phase; not blocking.

---

## 6. Phase order (confirmed)

| Phase | Content |
|---|---|
| **0** | Fix Vue rendering on the legacy dashboard; new route + blade at `/{locale}/dashboard`; `BaseDashboardCardController`; `<dashboard-card>` shell component |
| **1** | Alert system — controller, service, DTO, horizontally scrollable strip. Cards: ZATCA, VAT countdown, invoices past due |
| **2** | Story 2 period selector + Story 1 four tiles (no POS split) — introduces `LedgerBalanceService` |
| **3** | Story 3 receivables + `report_supplier_aging` build + payables widget |
| **4** | Story 4 VAT position widget; Story 5 cash position |
| **5** | Story 6 trend + expense split; removes the P&L card and the bare-week-number bug |
| **6** | Story 1 POS split; cash-basis toggle; insights parity |

`LedgerBalanceService` is deliberately **not** in Phase 0 — no alert card touches the ledger,
and unused abstractions are not worth building early. It lands with the tiles in Phase 2.
