MUNICIPAL FINANCE · ONE CONTROLLED SOURCE

Municipal finance,
governed from source
to decision.

One synchronized operating environment for accounting, treasury, projects, budgets, GRAP reporting, performance and accountable oversight.

CAPTUREVALIDATEPOSTDECIDE
DEVELOPED BYIntellica AnalyticsIntelligence that delivers.
SECURE DEMONSTRATION ACCESS

Select a MATOS portal

Each role opens only the financial information, decisions and statutory actions within its mandate.

▣ Role-based demonstration✓ MFMA / mSCOA controls⌁ Shared reporting cut-off

▶Operational runs

MATOS  ›  ▶Operational runs
RM
Mode 3 — full year. Audited full-year position at 30 June. This is the base every module reads unless the period selector says otherwise.
Municipal Accounting & Treasury Operating System

CFO executive cockpit

One deterministic municipal finance spine from source transaction to council decision. Every card reads the same controlled ledger and reporting cut-off.

Revenue
R1 494 146k
FY2025
Expenditure
R1 044 870k
69.9% of revenue
Operating surplus
R449 276k
Flows directly into affordability
Cash
R1 194 011k
474 operating days
Liquidity cover
16.71x
Free cash / annual debt service
Credit profile
BB-
Deterministic municipal scorecard
BRT capital cost
R1 240 000k
0 of 14 documents recognised
Open journal balance
0
0 balanced entries

Financial position and flow

FY2025 control totals · R'000
Revenue
R1 494 146k
Expenditure
R1 044 870k
Cash
R1 194 011k
PPE
R2 548 000k

Decision queue

Click to open the authoritative module

23-module operating chronology

Five stages · one ledger · one reporting cut-off
1 · Capture5 modules
  1. Source transactions
  2. Billing & debtors
  3. Payroll & HR
  4. Cash & bank
  5. Supplier capture
2 · Account5 modules
  1. Journals
  2. VAT controls
  3. General ledger
  4. Trial balance
  5. Asset sub-ledger
3 · Report4 modules
  1. GRAP AFS
  2. Budget statements
  3. Performance / SDBIP
  4. Audit evidence
4 · Analyse4 modules
  1. Treasury ratios
  2. Credit model
  3. Risk & sustainability
  4. Project / contract risk
5 · Decide5 modules
  1. Project appraisal
  2. Funding structure
  3. CFO cockpit
  4. MM / council pack
  5. Human approval gate
Operate

Operational runs

Ten deterministic monthly runs — payroll, project status, contract reporting, bank imports, billing, grants, assets and audit action-plan updates. Every run posts to the shared ledger and cascades exactly like a manual entry.

OPERATE · CONTROLLED BATCHES

Calculation-grade operational runs

Review the source rows and arithmetic, confirm the review, then follow five explained control stages before posting. Every completed run leaves a granular, individually-clearable change trace.

Project milestone batch update

Review required
Ready

Compare planned and actual milestones across the capital programme; route schedule and cost exceptions.

Review: worked source scheduleCascade: line-level trace

Certified progress claims

Calculation ready
Ready

Validate CP-01/02/03 certificates and release qualifying conditional-grant revenue.

Review: worked source scheduleCascade: line-level trace

30-day payment run

Calculation ready
Ready

Settle certified non-retention balances older than 30 days after supplier and cash controls.

Review: worked source scheduleCascade: line-level trace

Log contract variation

Review required
Ready

Confirm over-committed contracts and route the MFMA section 116(3) approval trail.

Review: worked source scheduleCascade: line-level trace

Roll the forecast forward

Calculation ready
Ready

Recalculate estimate-at-completion from current project cost and schedule performance.

Review: worked source scheduleCascade: line-level trace

Post audit adjustments

Review required
Ready

Confirm the authorised AJE schedule and refresh GRAP statements and controls.

Review: worked source scheduleCascade: line-level trace

Run the control sweep

Calculation ready
Ready

Execute every deterministic integrity, evidence and compliance test.

Review: worked source scheduleCascade: line-level trace

Collection run

Calculation ready
Ready

Bank and allocate the controlled rates and service-charge receipt batch.

Review: worked source scheduleCascade: line-level trace

Write off irrecoverable debt

Review required
Ready

Apply the Council-authorised credit-control write-off schedule.

Review: worked source scheduleCascade: line-level trace

Unplanned maintenance orders

Calculation ready
Ready

Post verified burst, blockage, roads and transformer work orders.

Review: worked source scheduleCascade: line-level trace

Re-price floating tranche

Calculation ready
Ready

Apply the approved +100bp reset to the JIBAR-linked borrowing tranche.

Review: worked source scheduleCascade: line-level trace

Run disaster response

Calculation ready
Ready

Cost storm and flood restoration and match approved external funding.

Review: worked source scheduleCascade: line-level trace
Oversight

Performance

SDBIP and service-delivery KPIs, sourced from the same figures the Integrated Workbook uses.

Revenue vs budget
16 146k
Actual against the adjusted budget
Expenditure vs budget
3 870k
Actual against the adjusted budget
Surplus (actual)
449 276k
Against budgeted surplus of R437 000k
Collection rate
97.0%
Against the 95% target

Revenue and expenditure by line

Same figures the Integrated Workbook and CFO cockpit read
LineAdjusted budgetActualVariance
Property rates196 000198 450 2 450
Service charges - electricity370 000374 850 4 850
Service charges - water240 000242 550 2 550
Service charges - sanitation98 00099 225 1 225
Service charges - refuse65 00066 150 1 150
Rental of facilities and equipment9 0008 820 (180)
Interest earned - external investments13 00010 000 (3 000)
Fines, penalties and forfeits18 00016 538 (1 462)
Licences and permits6 0005 513 (487)
Equitable share (unconditional)300 000320 000 20 000
Conditional grants received145 000130 000 (15 000)
Other income18 00022 050 4 050
Employee related costs318 000313 000 (5 000)
Remuneration of councillors20 00019 845 (155)
Debt impairment30 00041 000 11 000
Depreciation and amortisation104 000103 000 (1 000)
Finance costs27 00026 000 (1 000)
Bulk purchases - water and electricity325 000333 000 8 000
Contracted services63 00066 000 3 000
Grants and subsidies paid11 00011 025 25
Repairs and maintenance95 00078 000 (17 000)
Other general expenses48 00054 000 6 000
Capture → validate → post

Accounting system integration

A faithful desktop accounting simulation of Pastel, Sage and CaseWare. Actions post balanced journals into MATOS and identify every downstream surface affected.

Controlled model

Integrated workbook

Chronological source-to-report sheets. Inputs are blue; formulas and derived outputs are locked to prevent parallel calculations.

fx=Selected_Mode + Reporting_Cutoff + Role_Controlderived · locked
00 · SYNCHRONIZED WORKSHEET

Control Centre

Mode, cut-off, ownership and validation

5populated rows
#ControlCurrent selectionOwnerValidation
1Operating modeMode 3CFOActive
2Reporting cut-offFY2025CFO FinanceApplied
3User roleCFOSystem administratorAuthorised
4Ledger records35Finance ManagerLoaded
5Posted journals0Chief AccountantBalanced
● Source-linked✓ Formula ownership: =Selected_Mode + Reporting_Cutoff + Role_ControlReporting cut-off: FY2025Select a cell for its formula · select # for the whole row
Human approval before issue

Reports & outputs

Reconciled management, statutory, budget and investment outputs generated from the current cut-off.

FY2025● Reconciled

CFO management pack

Financial health, liquidity, delivery, audit and immediate decisions

Revenue1 494 146
Operating surplus449 276
Cash1 194 011
FY2025● Reconciled

GRAP annual financial statements

Statements, mapped balances, note controls and reconciliation

Assets4 106 011
Liabilities905 000
Net assets3 201 011
FY2025● Reconciled

MFMA budget pack

Budget performance, revenue, expenditure and corrective actions

Revenue actual1 494 146
Expenditure actual1 044 870
Operating result449 276
FY2025● Reconciled

Treasury & liquidity report

Cash ladder, debtor ageing, obligations and funding cover

Cash1 194 011
Liquidity cover16,7
Days cash474
FY2025● Reconciled

Capital programme report

Projects, contracts, grants, assets and delivery risk

Approved programme408 000
Projects5
Contracts8
FY2025● Reconciled

BRT appraisal memorandum

Investment case, funding structure, affordability and risk gates

Economic NPV482 000
Financial NPV126 000
Project IRR0,1
MFMA-first control framework

Governance & controls

Automated checks do not replace accountable approval. External issue remains gated by preparer, reviewer and authorised sign-off.

Journal balance
0
Debits less credits
Asset control tie
0
Register / ledger / AFS
Reporting cut-off
FY2025
Applied to every module
External issue
Human approval
Automated output is never self-authorising

Control register

Live evidence from the common ledger
ControlEvidenceOwnerStatus
Double-entry integrityMX_JOURNAL debits = creditsCFO FinancePass
PPE control accountAsset register → GL → GRAP noteAsset ManagerPass
Audit-to-credit linkageProjected opinion penalty appliedInternal AuditPass
Investment upload14-document BRT checklist plus 12 assumptionsPMUPass
Role segregationPosting buttons follow role matrixSystem AdministratorPass
External issue gatePreparer → reviewer → authorised signatoryAccounting OfficerApproval required
No figure computed twice

Integration & data lineage

The addendum supersedes the former duplicate ALM, scenario, bankability, capital-structure and fixed-asset calculations. These live bridges show the surviving ownership.

AUTHORITATIVE LEDGERS
1
Balanced journal → account control
LIVE BRIDGES
12 / 12
Five supplied + seven completed
DUPLICATE ENGINES
0
Superseded renderers retired
BRT INPUT DOCUMENTS
14
Plus assumptions and model files

Cross-module contract

All links execute before the shared render pass
FromToValue transferredOwner / formulaStatus
Audit findingsCredit ratingProjected audit outcomemxOpinion().opLive
Treasury towerCredit ratingFree cash, debt service, ratio setaxBase()Live
Risk scenarioCredit ratingStressed ratiosaxScenarioRating()Live
Risk scenarioLong-run resilienceMonth 36 closing positionaxLongRun(true)Live
Balanced postingAll downstream modulesAccount movements and evidencemxPost()Live
Project riskAsset registerCertified work to WIPmxPortfolio().certTD → integratedWipLive · integrated
Contract monitorTreasury ladderAmounts due in 0–3 monthsmxContractTotals().due → axLadder()[0]Live · integrated
Asset registerRisk managementCondition-derived renewal backlogintegratedAssetRenewal() → AX_LR.backlogOpenLive · integrated
Asset registerTreasury towerCarrying value, depreciation, maintenancearTotals() → axBase()Live · integrated
Project appraisalTreasury towerSelected annual debt servicepaFunding()[paSel] → axLadder()[0]Live · integrated
Risk managementProject appraisalRevenue and cost growthAX_LR.revGrowth / costGrowth → step 11Live · integrated
Project appraisalAsset registerCapital components and useful livesPA.capex → projected asset intakeLive · integrated

Treasury Control Tower

The three tabs that used to sit apart — revenue and expenditure, the asset and liability model, and the MFD-MM Master — are one module here, in the order the money actually moves. Flows first: what was billed and what it cost. Then position: what the flows left on the balance sheet and when it falls due. Then the cascade: how a rand of rates billed becomes a rand of renewed infrastructure, and where it leaks on the way.

Revenue collected in cash
R1 412 582k
R18 643k of billed revenue was not collected
Operating surplus
R449 276k
30.1% of revenue · depreciation of R103 000k is inside it
Free cash
R1 170 011k
474 days of cash operating cost · R24 000k is restricted grant
Reinvested from own funds
R32 600k
Against depreciation of R103 000k — a renewal ratio of 1.58x including grant funding
Step one — what came in and what went out. Billed against collected, budget against actual, and the mix between revenue the municipality controls and revenue it receives. Everything downstream in this module is built from these two columns.

Revenue

FY2025 · R'000
SourceBilledCollectedRateControl
Property ratesCouncil sets the rate in the rates policy under the Municipal Property Rates Act 198 450190 909 96.2% Own
Service charges - electricityRetail tariff approved by council, bulk price set by NERSA 374 850366 603 97.8% Own
Service charges - waterTariff approved by council, bulk price set by the water board 242 550235 516 97.1% Own
Service charges - sanitationTariff approved by council 99 22596 050 96.8% Own
Service charges - refuseTariff approved by council 66 15063 173 95.5% Own
Rental of facilities and equipmentLease agreements 8 8208 203 93.0% Own
Interest earned - external investmentsInterest on the investment portfolio 10 00010 000 100.0% Own
Fines, penalties and forfeitsSet by by-law, realisation is poor 16 5386 781 41.0% Own
Licences and permitsSet by by-law 5 5135 458 99.0% Own
Equitable share (unconditional)Division of Revenue Act — formula driven, not negotiable 320 000320 000 100.0% Transfer
Conditional grants receivedDivision of Revenue Act — conditional, reverts if unspent 130 000130 000 100.0% Transfer
Other incomeSundry 22 05019 845 90.0% Own
Total revenue1 494 146 1 452 537 97.2% 69.9% own

Expenditure

FY2025 · R'000
CategoryActualShareCashNature
Employee related costsContractual, set by the national bargaining council 313 00030.0% 313 000 Committed
Remuneration of councillorsDetermined by the Minister by notice 19 8451.9% 19 845 Committed
Debt impairmentAccounting estimate against receivables 41 0003.9% Non-cash
Depreciation and amortisationConsumption of the asset base 103 0009.9% Non-cash
Finance costsContractual debt service 26 0002.5% 26 000 Committed
Bulk purchases - water and electricityBulk supply agreements, take-or-pay in parts 333 00031.9% 333 000 Committed
Contracted servicesContracted services, cancellable at notice 66 0006.3% 66 000 Discretionary
Grants and subsidies paidTransfers made by council resolution 11 0251.1% 11 025 Discretionary
Repairs and maintenanceDeferrable in year, costly over time 78 0007.5% 78 000 Discretionary
Other general expensesGeneral expenses 54 0005.2% 54 000 Discretionary
Total expenditure1 044 870100.0% 900 870 66.2% committed

Revenue mix and its stability

Hover a source to read its exposure
Property rates
Svc charges
Svc charges
Svc charges
Svc charges
Rental of
Interest earned
Fines, penalties
Licences and
Equitable share
Conditional grants
Other income
Own revenue — the municipality sets the tariffTransfers — set nationallyAmount lost to non-collection
Hover a revenue source to see what proportion the municipality controls and how much it loses to non-payment.

Operating result

Accrual, then reconciled to cash
LineR'000% of revenueNote
Total revenue1 494 146 100.0%
Less: operating expenditure(1 044 870) -69.9%
Operating surplus (accrual)449 276 30.1%This is the figure the statement of financial performance reports
Add back: depreciation103 000 6.9%Non-cash — the asset was consumed, no money left
Add back: debt impairment41 000 2.7%Non-cash — revenue was billed and written down, no money arrived either way
Cash generated from operations593 276 39.7%
Less: capital expenditure(162 600) -10.9%Of which R130 000k is grant funded
Less: debt service(70 000) -4.7%Interest plus the portion of borrowing falling due
Cash available for discretion360 676 24.1%What is genuinely free after everything contractual and capital
Step two — what the flows left behind. The balance sheet is grouped by what treasury can actually do with each item: cash it can deploy, receivables it must collect, assets it must maintain, obligations it must fund.

Statement of financial position, grouped by treasury use

Illustrative full-year · R'000
ItemFY2023FY2024FY2025MovementTreasury view
Cash treasury can deploy
Cash and cash equivalents273 000720 000 1 194 011474 011 Deployable, subject to the restriction shown below
Subtotal273 000 720 0001 194 011
Amounts owed to the municipality
Receivables from exchange transactions (trade debtors)65 00071 000 79 0008 000 Collectable in principle; the impairment charge says how much is not
Receivables from non-exchange transactions (rates & taxes)140 000158 000 179 00021 000 Collectable in principle; the impairment charge says how much is not
Subtotal205 000 229 000258 000
Assets that must be maintained
Property, plant and equipment (net)2 450 0002 491 000 2 548 00057 000 Generate the billing and consume the maintenance budget
Investment property85 00087 000 89 0002 000 Generate the billing and consume the maintenance budget
Intangible assets6 0006 500 7 000500 Generate the billing and consume the maintenance budget
Inventory9 0009 500 10 000500 Generate the billing and consume the maintenance budget
Subtotal2 550 000 2 594 0002 654 000
Amounts owed by the municipality within a year
Trade and other payables155 000168 000 182 00014 000 First call on the cash box
Current portion of long-term borrowing38 00041 000 44 0003 000 First call on the cash box
Unspent conditional grants22 00019 000 24 0005 000 First call on the cash box
VAT payable14 00015 000 16 0001 000 First call on the cash box
Subtotal229 000 243 000266 000
Long-dated obligations
Long-term borrowing420 000441 000 483 00042 000 Funded from future operating surpluses, not from today’s cash
Employee benefit obligations140 000148 000 156 0008 000 Funded from future operating surpluses, not from today’s cash
Subtotal560 000 589 000639 000
Net assets2 239 000 2 711 0003 201 011 490 011

Working capital

Hover a component
FY2023
FY2024
FY2025
Current assetsCurrent liabilitiesNet working capital
Hover a year to read the working capital position.

Cash quality

Not all cash is available
LayerR'000RestrictionDays of cover
Cash and equivalents1 194 011 None — the gross balance on the statement of financial position484
Less: unspent conditional grants(24 000) Ring-fenced under the Division of Revenue Act; reverts if not spent on the approved purpose
Less: consumer deposits and trust money(28 000) Held for third parties, repayable on demand
Free cash1 142 011 Genuinely available to treasury463

Headline cash is R1 194 011k; treasury can deploy R1 142 011k. Every liquidity measure in this system uses the free figure, because a municipality that spends restricted grant on salaries has not solved a liquidity problem — it has created an audit finding.

Step three — the cash cycle and what it can absorb. Working capital first, because that is where a municipality actually runs out of money; then the thirteen-week projection that treasury manages against; then debt coverage; then the maturity ladder underneath it all. Four levers at the foot of the tab let you test what a collection push or a stretched creditor actually buys.
Cash conversion cycle
10 days
63 debtor + 11 inventory − 64 creditor days
Net working capital
R1 196 011k
Current ratio 5.50x against a 1.5x to 2.0x norm
Free cash
R1 170 011k
474 days of cash operating cost
Debt service cover
8.26x
Operating balance plus depreciation plus interest, over debt service of R70 000k

The cash conversion cycle

FY2025 · days
StageHow it is measuredFY2023FY2024FY2025NormResult
Debtor daysThe single biggest lever on municipal cash. Every day here is one day of billed revenue sitting outside the bank. Exchange and non-exchange receivables over daily total revenue 555963 ≤ 60Outside
Inventory daysSmall for a municipality — most consumption is bulk purchased and passed straight through. Inventory over daily bulk purchases 111111 ≤ 30Within
Creditor daysAbove thirty days is a breach of MFMA section 65(2)(e) and an audit finding, not a funding strategy. Trade and other payables over daily total expenditure 606264 ≤ 30Outside
Cash conversion cycleHow many days of operating cost the municipality has to fund itself between billing a consumer and being paid. Debtor days plus inventory days less creditor days 6810 ≤ 45Within

The cycle runs at 10 days. At daily expenditure of R2 863k that is R29 799k of cash the municipality has to carry permanently just to bridge the gap between billing and being paid. Closing ten days of it releases R28 627k once, and then keeps it.

Working capital components

Hover a year
FY2023
FY2024
FY2025
ReceivablesInventoryPayablesNet working capital excluding cash
Hover a year to read the components and the net position.

Receivables quality

Ageing drives both cash and impairment
Ageing bandBalanceShareExpected to be collectedTreatment
Current — 30 days105 78041.0% 97.0% Collected in the normal cycle
31 to 60 days43 86017.0% 88.0% Reminder and final notice stage
61 to 90 days28 38011.0% 71.0% Disconnection notice under the credit control policy
91 to 180 days33 54013.0% 42.0% Handed to debt collection; partially impaired
Over 180 days46 44018.0% 9.0% Substantially impaired; write-off requires council resolution under section 32
Total receivables258 000100.0% 69.6%Expected realisation

Expected credit loss across the book is R78 380k against an impairment provision of R41 000k charged in the year and a receivables balance of R258 000k. The provision is broadly consistent with the ageing. Over-180-day balances carry 18.0% of the book and are expected to realise 9.0%.

Cash quality — what treasury can actually deploy

LayerR'000RestrictionDays of cover

Thirteen-week cash projection

Opening free cash R1 170 011k · minimum buffer R75 073k
W1
W2
W3
W4
W5
W6
W7
W8
W9
W10
W11
W12
W13
ReceiptsPaymentsClosing balanceMinimum operating buffer
Hover a week to read receipts, payments and the closing balance.
WeekW1W2W3W4W5W6W7W8W9W10W11W12W13
Receipts70 55324 80524 80524 80570 55324 80524 80524 80570 55324 80524 80524 80538 053
Payments10 40338 15310 40338 14010 40338 15310 40338 14010 40338 15310 40338 14010 403
Net movement60 150(13 348)14 402(13 335)60 150(13 348)14 402(13 335)60 150(13 348)14 402(13 335)27 650
Closing balance1 230 1611 216 8131 231 2151 217 8801 278 0301 264 6821 279 0841 265 7491 325 8991 312 5511 326 9531 313 6181 341 268

The trough is week 2 at R1 216 813k, R1 141 741k above the one-month buffer. Municipal cash is lumpy by construction: grant tranches land in weeks one, five and nine, payroll leaves every fourth week and bulk purchases every fourth week offset by two. A projection that smooths those flows will show comfort that is not there in the week it matters.

Commitments inside the window

What is already contracted and cannot be deferred
CommitmentAmountTimingDeferrableConsequence of deferral
Employee and councillor costs83 211Weeks 4, 8 and 12 No Contractual; a late payroll is an unauthorised expenditure event and an immediate labour dispute
Bulk purchases — Eskom and water board83 250Weeks 2, 6 and 10 No Supply interruption, interest at the supplier’s penalty rate, and the arrears that drive Circular 124 eligibility
Debt service instalment11 000Week 7 No Event of default; acceleration of the facility
Capital certificates40 650Throughout Partly Deferrable by agreement with the contractor; forfeits grant if it delays qualifying expenditure past year end
Contracted services and general30 000Throughout Partly Cancellable at notice; breaches the thirty-day rule if merely delayed rather than renegotiated

Debt coverage and covenant headroom

Click a row for the formula and the headroom calculation
Cover measureWhat it protects againstValueCovenantHeadroomResult
Debt service coverClick for the formula and the headroom A fall in the operating balance leaving debt service unpayable8.26x≥ 1.50x
451% headroom
Compliant
Formula(operating balance + depreciation + interest) ÷ (interest + current portion)
Value8.26x
Covenant or norm≥ 1.50x
Headroom450.7% above the requirement — the measure could move 6.76x before it breaches
What breaches itA fall in the operating balance leaving debt service unpayable. In the risk module, the levers that move this measure are the interest rate lever and anything that reduces the operating balance.
Consequence of breachA lender can call an event of default, accelerate the facility and price any refinancing at a materially wider spread.
Interest coverClick for the formula and the headroom Interest alone outrunning the surplus22.24x≥ 3.00x
641% headroom
Compliant
Formula(operating balance + depreciation + interest) ÷ interest
Value22.24x
Covenant or norm≥ 3.00x
Headroom641.4% above the requirement — the measure could move 19.24x before it breaches
What breaches itInterest alone outrunning the surplus. In the risk module, the levers that move this measure are the interest rate lever and anything that reduces the operating balance.
Consequence of breachA lender can call an event of default, accelerate the facility and price any refinancing at a materially wider spread.
Liquidity coverClick for the formula and the headroom Cash running out before the next instalment falls due16.71x≥ 1.00x
1571% headroom
Compliant
Formulafree cash ÷ debt service falling due within twelve months
Value16.71x
Covenant or norm≥ 1.00x
Headroom1571.4% above the requirement — the measure could move 15.71x before it breaches
What breaches itCash running out before the next instalment falls due. In the risk module, the levers that move this measure are collection, the disaster event and capital delivery.
Consequence of breachNational Treasury and the provincial treasury are notified under the MFMA reporting regime, and the measure becomes a rating driver.
Borrowing to operating revenueClick for the formula and the headroom Leverage rising beyond the regulated ceiling38.6%≤ 45.0%
14% headroom
Compliant
Formula(long-term borrowing + current portion) ÷ operating revenue
Value38.6%
Covenant or norm≤ 45.0%
Headroom14.2% above the requirement — the measure could move 6.4% before it breaches
What breaches itLeverage rising beyond the regulated ceiling. In the risk module, the levers that move this measure are the interest rate lever and new borrowing drawn for the capital programme.
Consequence of breachNational Treasury and the provincial treasury are notified under the MFMA reporting regime, and the measure becomes a rating driver.
Debt service to operating revenueClick for the formula and the headroom Debt service crowding out service delivery5.1%≤ 8.0%
36% headroom
Compliant
Formula(interest + current portion) ÷ operating revenue
Value5.1%
Covenant or norm≤ 8.0%
Headroom35.9% above the requirement — the measure could move 2.9% before it breaches
What breaches itDebt service crowding out service delivery. In the risk module, the levers that move this measure are the interest rate lever and anything that reduces the operating balance.
Consequence of breachNational Treasury and the provincial treasury are notified under the MFMA reporting regime, and the measure becomes a rating driver.
Cost coverageClick for the formula and the headroom Being unable to fund one month of operations from cash15.59x≥ 1.00x
1459% headroom
Compliant
Formulafree cash ÷ one month of cash operating cost
Value15.59x
Covenant or norm≥ 1.00x
Headroom1458.5% above the requirement — the measure could move 14.59x before it breaches
What breaches itBeing unable to fund one month of operations from cash. In the risk module, the levers that move this measure are collection, the disaster event and capital delivery.
Consequence of breachNational Treasury and the provincial treasury are notified under the MFMA reporting regime, and the measure becomes a rating driver.

Interest rate repricing

InstrumentBalanceBasisRateRepricing
DBSA amortising loan 2019164 220 Fixed 9.15%Fixed to maturity in 2034
DBSA amortising loan 2023125 580 Fixed 9.85%Fixed to maturity in 2038
Commercial bank term facility135 240 Floating 10.10%Reprices quarterly against three-month JIBAR
Annuity loan — fleet and plant57 960 Floating 10.65%Reprices monthly against prime
Current portion falling due44 000 Mixed 9.40%Repaid within twelve months
Weighted average527 00037% floating 9.75%

37% of the book reprices within a year, so a 100 basis point move costs R1 932k a year — 0.13% of operating revenue. Maturity and repricing are shown separately because a loan can be safely long-dated and dangerously floating at the same time.

Borrowing capacity

What could still be raised without breaching a limit
ConstraintLimitUsedCapacity remaining
Borrowing to operating revenue — Circular 71 ceiling of 45%613 866527 000 86 866
Debt service to operating revenue — norm band upper bound of 8%1 160 975744 681 416 294
Debt service cover of 1.5x on the existing facility385 51770 000 315 517
Binding constraintBorrowing to operating revenue 86 866

Additional borrowing capacity is R86 866k, set by the tightest of the three constraints. Capacity is not permission: MFMA section 46 still requires the borrowing to be for capital, disclosed in the budget, and preceded by public comment.

Maturity ladder

FY2025 · R'000 · contractual maturity
BucketInflowsOutflowsNet gapCumulativeCover
0-3 months812 527431 485 381 041 381 041
1.88x cover
3-12 months451 923426 905 25 019 406 060
1.06x cover
1-3 years152 281121 860 30 421 436 481
1.25x cover
3-5 years25 800137 760 (111 960) 324 521
0.19x cover
5-10 years19 480211 020 (191 540) 132 981
0.09x cover
Over 10 years50 960168 360 (117 400) 15 581
0.30x cover
Total1 512 971 1 497 390 15 581

Gap profile

Hover a bucket
0-3
3-12
1-3
3-5
5-10
Over
InflowsOutflowsCumulative gap
Hover a maturity bucket to read inflows, outflows and the cumulative position.
Four things treasury can actually do, and what each one buys. These are working-capital levers, not budget levers — none of them changes the surplus. They change when cash arrives and when it leaves, which is a different problem and usually the more urgent one.

Levers

Debtor days0 days
-40 daysbase+20 days
A credit-control push shortens the collection cycle. Each day recovered releases one day of billed revenue into cash without changing the surplus.
Creditor days0 days
-20 daysbase+40 days
Stretching creditors buys cash at the cost of the thirty-day rule in MFMA section 65(2)(e) and an audit finding. It is borrowing from suppliers at an infinite implied rate.
Capital spend deferred0%
0%base+60%
Deferring capital preserves cash inside the window but forfeits conditional grant and grows the renewal backlog.
Grant drawdown brought forward0%
0%base+100%
Claiming against qualifying expenditure earlier moves cash in without changing entitlement. It requires the expenditure to have been incurred and evidenced.
Cash released
R0k
Move a lever to see the effect
Cash conversion cycle
10 days
Base 10 days · movement 0 days
Free cash
R1 170 011k
474 days of cover, base 474
Months of cost covered
15.6x
Above the one-month minimum

Result

No lever applied
MeasureBaseAfter the leversMovementEffect
Debtor days63 d63 d No change
Creditor days64 d64 d No change
Cash conversion cycle10 d10 d No change
Free cashR1 170 011kR1 170 011k No change
Days cash on hand474 d474 d No change
Operating surplusR449 276kR449 276k Unaffected by design

No lever is applied. These four change the timing of cash, not the amount of it — the operating surplus row stays flat on purpose.

Cash position over thirteen weeks

Hover a week
W1
W2
W3
W4
W5
W6
W7
W8
W9
W10
W11
W12
W13
Base closing balanceAfter the leversMinimum buffer
Hover a week to compare the two paths.
Step four — the synthesis. The MFD-MM Master is a single question asked in seven steps: of every rand billed to a ratepayer, how much ends up as renewed infrastructure, and where did the rest go? Each stage shows the amount carried forward and the leakage taken out. Click a stage to see which module controls it.

Rates-to-Resilience cascade

FY2025 · every figure from the ledger
1
Billed to ratepayers and consumersRates and the four trading services, as raised on the billing masterfile · controlled by Revenue module
R981 225k100.0% of billed
2
Collected in cashAt 98.1% collection · controlled by Revenue & credit control
R962 582k98.1% of billed
− R18 643kNon-payment and indigent relief
3
After bulk purchasesEskom and the water board are paid before anything else · controlled by Treasury — bulk contracts
R629 582k64.2% of billed
− R333 000kBulk purchases
4
After the wage billEmployees and councillors · controlled by Corporate services
R296 737k30.2% of billed
− R332 845kEmployee related cost
5
After other operating costContracted services, transfers made, general expenses · controlled by Departments
R165 712k16.9% of billed
− R131 025kOther operating expenditure
6
After debt serviceInterest on the borrowing book · controlled by Treasury — debt
R139 712k14.2% of billed
− R26 000kFinance cost
7
Available for asset renewalWhat is left to keep the infrastructure that generates the billing · controlled by Infrastructure
R61 712k6.3% of billed
− R78 000kRepairs and maintenance
8
Actually reinvested from own fundsCapital spend net of conditional grant funding · controlled by Council — capital budget
R32 600k3.3% of billed
− R29 112kRetained, not reinvested

Of every rand billed to a ratepayer, 3.3 cents ends up reinvested in the infrastructure that makes the billing possible. The cascade is the single most useful view in this system because it is the only one that shows the stages in the order they actually bind: bulk suppliers are paid before employees, employees before maintenance, and maintenance before renewal. Anything that goes wrong upstream cannot be recovered downstream.

Where the leakage goes

Hover a stage
S1
S2
S3
S4
S5
S6
S7
S8
Carried to the next stageLost at this stage
Hover a stage to read what it carries forward and what it loses.

Who controls each leak

LeakR'000Controlled byLever that moves itModule
Non-payment and indigent relief18 643Revenue & credit control Collection rateTreasury — revenue
Bulk purchases333 000Treasury — bulk contracts Bulk cost above tariff increaseScenario lab
Employee related cost332 845Corporate services Wage settlement above budgetScenario lab
Other operating expenditure131 025Departments No lever — departmental budget decisionTreasury — expenditure
Finance cost26 000Treasury — debt Interest rate movementTreasury — maturity & liquidity
Repairs and maintenance78 000Infrastructure Repairs and maintenanceScenario lab
Retained, not reinvested29 112Council — capital budget Capital programme deliveryProject risk

Treasury ratio set

Click a row for the formula and the three-year path
RatioGroupFY2023FY2024FY2025NormResult
Operating surplus marginClick for the three-year path and the cross-references Performance30.9%30.5%30.1% ≥ 0%Within
GroupPerformance
Norm≥ 0% — MFMA Circular 71 municipal financial ratio guidance
DirectionHigher is stronger
Three-year movement30.9% → 30.5% → 30.1%
TrendDeteriorating
Where else this ratio appears
Credit rating modelScores a rating factor directly
Scenario labRecomputed under every lever setting
SustainabilityNot projected; it is a within-year measure
Own revenue shareClick for the three-year path and the cross-references Performance69.9%69.8%69.9% ≥ 50%Within
GroupPerformance
Norm≥ 50% — MFMA Circular 71 municipal financial ratio guidance
DirectionHigher is stronger
Three-year movement69.9% → 69.8% → 69.9%
TrendImproving
Where else this ratio appears
Credit rating modelScores a rating factor directly
Scenario labRecomputed under every lever setting
SustainabilityNot projected; it is a within-year measure
Collection rateClick for the three-year path and the cross-references Performance98.1%98.1%98.1% ≥ 95%Within
GroupPerformance
Norm≥ 95% — MFMA Circular 71 municipal financial ratio guidance
DirectionHigher is stronger
Three-year movement98.1% → 98.1% → 98.1%
TrendFlat
Where else this ratio appears
Credit rating modelScores a rating factor directly
Scenario labRecomputed under every lever setting
SustainabilityNot projected; it is a within-year measure
Current ratioClick for the three-year path and the cross-references Liquidity2.13x3.94x5.50x 1.5 – 2.0Within
GroupLiquidity
Norm1.5 – 2.0 — MFMA Circular 71 municipal financial ratio guidance
DirectionHigher is stronger
Three-year movement2.13x → 3.94x → 5.50x
TrendImproving
Where else this ratio appears
Credit rating modelInforms the factor but is not itself scored
Scenario labRecomputed under every lever setting
SustainabilityNot projected; it is a within-year measure
Days cash on handClick for the three-year path and the cross-references Liquidity113 d299 d474 d ≥ 30 daysWithin
GroupLiquidity
Norm≥ 30 days — MFMA Circular 71 municipal financial ratio guidance
DirectionHigher is stronger
Three-year movement113 d → 299 d → 474 d
TrendImproving
Where else this ratio appears
Credit rating modelInforms the factor but is not itself scored
Scenario labRecomputed under every lever setting
SustainabilityNot projected; it is a within-year measure
Cost coverageClick for the three-year path and the cross-references Liquidity3.72x9.85x15.59x 1 – 3 monthsWithin
GroupLiquidity
Norm1 – 3 months — MFMA Circular 71 municipal financial ratio guidance
DirectionHigher is stronger
Three-year movement3.72x → 9.85x → 15.59x
TrendImproving
Where else this ratio appears
Credit rating modelScores a rating factor directly
Scenario labRecomputed under every lever setting
SustainabilityNot projected; it is a within-year measure
Debt to operating revenueClick for the three-year path and the cross-references Debt36.9%37.1%38.6% ≤ 45%Within
GroupDebt
Norm≤ 45% — MFMA Circular 71 municipal financial ratio guidance
DirectionLower is stronger
Three-year movement36.9% → 37.1% → 38.6%
TrendDeteriorating
Where else this ratio appears
Credit rating modelScores a rating factor directly
Scenario labRecomputed under every lever setting
SustainabilityA structural indicator — projected ten years out
Debt service to operating revenueClick for the three-year path and the cross-references Debt4.8%5.0%5.1% 6 – 8%Within
GroupDebt
Norm6 – 8% — MFMA Circular 71 municipal financial ratio guidance
DirectionLower is stronger
Three-year movement4.8% → 5.0% → 5.1%
TrendDeteriorating
Where else this ratio appears
Credit rating modelScores a rating factor directly
Scenario labRecomputed under every lever setting
SustainabilityNot projected; it is a within-year measure
Interest coverClick for the three-year path and the cross-references Debt24.41x23.23x22.24x ≥ 2.0xWithin
GroupDebt
Norm≥ 2.0x — MFMA Circular 71 municipal financial ratio guidance
DirectionHigher is stronger
Three-year movement24.41x → 23.23x → 22.24x
TrendDeteriorating
Where else this ratio appears
Credit rating modelScores a rating factor directly
Scenario labRecomputed under every lever setting
SustainabilityNot projected; it is a within-year measure
Creditor payment daysClick for the three-year path and the cross-references Working capital60 d62 d64 d ≤ 30 daysOutside
GroupWorking capital
Norm≤ 30 days — MFMA Circular 71 municipal financial ratio guidance
DirectionLower is stronger
Three-year movement60 d → 62 d → 64 d
TrendDeteriorating
Where else this ratio appears
Credit rating modelInforms the factor but is not itself scored
Scenario labRecomputed under every lever setting
SustainabilityNot projected; it is a within-year measure
Debtor daysClick for the three-year path and the cross-references Working capital55 d59 d63 d ≤ 60 daysOutside
GroupWorking capital
Norm≤ 60 days — MFMA Circular 71 municipal financial ratio guidance
DirectionLower is stronger
Three-year movement55 d → 59 d → 63 d
TrendDeteriorating
Where else this ratio appears
Credit rating modelInforms the factor but is not itself scored
Scenario labRecomputed under every lever setting
SustainabilityNot projected; it is a within-year measure
Impairment to receivablesClick for the three-year path and the cross-references Working capital17.1%16.6%15.9% ≤ 16%Within
GroupWorking capital
Norm≤ 16% — MFMA Circular 71 municipal financial ratio guidance
DirectionLower is stronger
Three-year movement17.1% → 16.6% → 15.9%
TrendImproving
Where else this ratio appears
Credit rating modelScores a rating factor directly
Scenario labRecomputed under every lever setting
SustainabilityNot projected; it is a within-year measure
Capital renewal to depreciationClick for the three-year path and the cross-references Asset1.45x1.53x1.58x ≥ 1.0xWithin
GroupAsset
Norm≥ 1.0x — MFMA Circular 71 municipal financial ratio guidance
DirectionHigher is stronger
Three-year movement1.45x → 1.53x → 1.58x
TrendImproving
Where else this ratio appears
Credit rating modelScores a rating factor directly
Scenario labRecomputed under every lever setting
SustainabilityA structural indicator — projected ten years out
Repairs and maintenance to infrastructureClick for the three-year path and the cross-references Asset2.9%3.0%3.1% ≥ 8%Outside
GroupAsset
Norm≥ 8% — MFMA Circular 71 municipal financial ratio guidance
DirectionHigher is stronger
Three-year movement2.9% → 3.0% → 3.1%
TrendImproving
Where else this ratio appears
Credit rating modelScores a rating factor directly
Scenario labRecomputed under every lever setting
SustainabilityA structural indicator — projected ten years out
Capital spend to total spendClick for the three-year path and the cross-references Asset12.8%13.2%13.5% ≥ 10%Within
GroupAsset
Norm≥ 10% — MFMA Circular 71 municipal financial ratio guidance
DirectionHigher is stronger
Three-year movement12.8% → 13.2% → 13.5%
TrendImproving
Where else this ratio appears
Credit rating modelScores a rating factor directly
Scenario labRecomputed under every lever setting
SustainabilityNot projected; it is a within-year measure

Asset register & asset management plan

The register is the sub-ledger behind the largest number on the statement of financial position, and it is where most municipal audit qualifications originate. This module carries the seventeen fields GRAP 17 and the audit assertions require, the class-by-class breakdown validated against the general ledger, componentisation, the depreciation engine, work in progress, the three-way reconciliation to the annual financial statements, physical verification, and the asset management plan that says what it will cost to keep the base standing.

Carrying amount on the register
R2 548 000k
13 536 items across 11 classes · gross cost R3 894 400k
Difference to the general ledger
R0k
Of which R(11 600)k is movement posted by other modules since the register was struck
Asset base consumed
34.6%
R2 548 000k of service potential remains against R3 894 400k of original cost
Register data completeness
84.8%
9 of 17 required fields below 90% populated
One register, one carrying amount, one number in the statements. Everything on the tabs that follow reconciles back to the total below. Where it does not, the difference is a reconciling item with a name, an owner and a correcting entry — not a rounding.

Register at a glance

FY2025 · R'000 · ties to the PPE line on the statement of financial position
Asset classItemsGross costAccumulated depreciationCarrying amountConsumedGRAP treatment
LandGRAP 17 — not depreciated; impairment still applies1 842 186 000 186 000
0.0% consumed
Indefinite — not depreciated
Buildings and officesGRAP 17 — componentise structure, roof, services318 412 000(128 400) 283 600
31.2% consumed
30 to 50 years
Infrastructure — roads and stormwaterGRAP 17 — componentise subgrade, base, surfacing, kerbs2 140 985 000(386 000) 599 000
39.2% consumed
Surfacing 8 to 15 years; componentised 12 to 40
Infrastructure — waterGRAP 17 — componentise civil, mechanical, electrical1 685 742 000(241 000) 501 000
32.5% consumed
Pipes 30 to 60 years
Infrastructure — sanitationGRAP 17 — componentise reticulation and treatment plant1 204 508 000(177 000) 331 000
34.8% consumed
30 to 50 years
Infrastructure — electricityGRAP 17 — componentise network, substations, metering1 516 624 000(228 000) 396 000
36.5% consumed
25 to 40 years
Community assetsGRAP 17 — halls, parks, cemeteries, libraries462 168 000(61 000) 107 000
36.3% consumed
20 to 40 years
Plant and equipmentGRAP 17 — units of production where use varies874 96 000(54 000) 42 000
56.3% consumed
8 to 15 years
Motor vehiclesGRAP 17 — residual value 10 to 20% of cost typical216 82 000(49 000) 33 000
59.8% consumed
Light 5 to 10; heavy 10 to 15
Furniture, fittings and ITGRAP 17 — low residual value; IT obsolescence rapid3 268 31 000(22 000) 9 000
71.0% consumed
IT 3 to 5; furniture 8 to 15
Capital work in progressGRAP 17.23 — directly attributable cost only11 60 400 60 400
0.0% consumed
Not depreciated until available for use
Register total13 5363 894 400 (1 346 400)2 548 000 34.6% consumedThe sub-ledger as last rolled forward
Movements posted since the register was struck Capitalised certified work, disaster restoration and audit adjustments posted by the other modules (11 600)(11 600) Reconciling item To be absorbed at the next roll-forward, with each asset identified
Unreconciled register-to-ledger differences Disposed vehicles not removed from the ledger, and fourth-quarter depreciation not processed in the register 11 60011 600 Correction required See the three-way reconciliation tab
Carrying amount per the general ledger 2 548 000 Agrees to the PPE line on the statement of financial position

Where the value sits

Hover a class
Land
Buildings
Infra
Infra
Infra
Infra
Community
Plant
Motor
Capital
Gross costCarrying amountConsumed to date
Hover an asset class to read its gross cost, what has been consumed and what remains.

Remaining life of the asset base

Carrying amount as a share of gross cost
Asset classLife remainingYears leftPosition
Buildings and offices
68.8% remaining
29 Early life
Infrastructure — roads and stormwater
60.8% remaining
17 Early life
Infrastructure — water
67.5% remaining
32 Early life
Infrastructure — sanitation
65.2% remaining
29 Early life
Infrastructure — electricity
63.5% remaining
23 Early life
Community assets
63.7% remaining
22 Early life
Plant and equipment
43.8% remaining
5 Second half of life
Motor vehicles
40.2% remaining
4 Second half of life
Furniture, fittings and IT
29.0% remaining
2 Near end of life

The weighted average remaining life across the depreciable base is 24.3 years. Roads are the shortest-lived material class at 17 years remaining, which is why the renewal profile in the asset management plan is front-loaded. Remaining life computed off carrying amount assumes straight-line consumption; where an asset has been componentised, the component lives give a sharper answer than the class average does.

Seventeen fields, each tied to an audit assertion. A register is not compliant because it exists; it is compliant when every asset carries every field the assertion depends on. Click a field to see what it proves, what goes wrong with it, and which assets are missing it.
Fields fully populated
2 of 17
A field is only complete when every asset carries it
Fields below 90%
9
Each one is an assertion the register cannot currently support
Weakest field
Component information
39.4% populated
Overall completeness
84.8%
Simple average across the seventeen required fields

Minimum required data fields

GRAP 17 and the AGSA assertion set
#FieldPurposeAssertionCompletePopulationResult
1Unique asset ID / barcodeClick for the detail Enables one-to-one physical linkage to the assetExistence 98.2%
244 missing
Gaps
What this field provesEnables one-to-one physical linkage to the asset
Assertion it supportsExistence — without this field the assertion cannot be evidenced, whatever else the register contains
Populated98.2% of 13 536 items — approximately 244 assets are missing it
What goes wrongDuplicate identifiers, and assets with no barcode tag physically attached
Consequence if not fixedWithin tolerance, but the exceptions still need clearing before year end.
2Asset descriptionClick for the detail Identifies the asset with enough specificity to be found and verifiedCompleteness / Existence 91.8%
1 110 missing
Gaps
What this field provesIdentifies the asset with enough specificity to be found and verified
Assertion it supportsCompleteness / Existence — without this field the assertion cannot be evidenced, whatever else the register contains
Populated91.8% of 13 536 items — approximately 1 110 assets are missing it
What goes wrongGeneric descriptions — "computer equipment" with no make, model or serial number
Consequence if not fixedWithin tolerance, but the exceptions still need clearing before year end.
3Asset classClick for the detail Determines which standard applies and where it is disclosedClassification 99.6%
54 missing
Complete
What this field provesDetermines which standard applies and where it is disclosed
Assertion it supportsClassification — without this field the assertion cannot be evidenced, whatever else the register contains
Populated99.6% of 13 536 items — approximately 54 assets are missing it
What goes wrongIntangibles sitting in the PPE register; investment property classified as PPE
Consequence if not fixedWithin tolerance, but the exceptions still need clearing before year end.
4Physical locationClick for the detail Enables verification and supports the existence assertionExistence 87.4%
1 706 missing
Material gap
What this field provesEnables verification and supports the existence assertion
Assertion it supportsExistence — without this field the assertion cannot be evidenced, whatever else the register contains
Populated87.4% of 13 536 items — approximately 1 706 assets are missing it
What goes wrongLocation not updated after transfer; "stores" recorded for assets long since deployed
Consequence if not fixedAn audit finding and a management letter point; not a qualification on its own, but it compounds with others.
5Asset custodianClick for the detail Assigns accountability under MFMA section 63Rights and obligations 79.1%
2 829 missing
Material gap
What this field provesAssigns accountability under MFMA section 63
Assertion it supportsRights and obligations — without this field the assertion cannot be evidenced, whatever else the register contains
Populated79.1% of 13 536 items — approximately 2 829 assets are missing it
What goes wrongNo custodian assigned; assets of departed staff never reallocated
Consequence if not fixedAn audit finding and a management letter point; not a qualification on its own, but it compounds with others.
6Date available for useClick for the detail Determines when depreciation startsAccuracy / Cut-off 93.4%
893 missing
Gaps
What this field provesDetermines when depreciation starts
Assertion it supportsAccuracy / Cut-off — without this field the assertion cannot be evidenced, whatever else the register contains
Populated93.4% of 13 536 items — approximately 893 assets are missing it
What goes wrongInvoice date used instead of the date the asset was available for use
Consequence if not fixedWithin tolerance, but the exceptions still need clearing before year end.
7Cost or historical costClick for the detail Opening carrying amount and the basis for depreciationValuation / Accuracy 98.8%
162 missing
Gaps
What this field provesOpening carrying amount and the basis for depreciation
Assertion it supportsValuation / Accuracy — without this field the assertion cannot be evidenced, whatever else the register contains
Populated98.8% of 13 536 items — approximately 162 assets are missing it
What goes wrongEstimated cost recorded where an actual invoice was available
Consequence if not fixedWithin tolerance, but the exceptions still need clearing before year end.
8Accumulated depreciationClick for the detail Supports the carrying amount and reconciles to the ledgerValuation 96.1%
528 missing
Gaps
What this field provesSupports the carrying amount and reconciles to the ledger
Assertion it supportsValuation — without this field the assertion cannot be evidenced, whatever else the register contains
Populated96.1% of 13 536 items — approximately 528 assets are missing it
What goes wrongManually adjusted without recalculation; not rolled forward annually
Consequence if not fixedWithin tolerance, but the exceptions still need clearing before year end.
9Useful life — original and revisedClick for the detail Basis for the annual charge; must be reviewed each yearValuation / Accuracy 84.7%
2 071 missing
Material gap
What this field provesBasis for the annual charge; must be reviewed each year
Assertion it supportsValuation / Accuracy — without this field the assertion cannot be evidenced, whatever else the register contains
Populated84.7% of 13 536 items — approximately 2 071 assets are missing it
What goes wrongNever revised since initial GRAP adoption
Consequence if not fixedAn audit finding and a management letter point; not a qualification on its own, but it compounds with others.
10Residual valueClick for the detail Reduces the depreciable amount; annual review requiredValuation 61.2%
5 252 missing
Material gap
What this field provesReduces the depreciable amount; annual review required
Assertion it supportsValuation — without this field the assertion cannot be evidenced, whatever else the register contains
Populated61.2% of 13 536 items — approximately 5 252 assets are missing it
What goes wrongZero for every asset with no assessment ever performed
Consequence if not fixedA qualification risk in its own right — the assertion fails at population level.
11Depreciation methodClick for the detail Documents the policy choice and lets an auditor recalculateConsistency / Accuracy 99.4%
81 missing
Complete
What this field provesDocuments the policy choice and lets an auditor recalculate
Assertion it supportsConsistency / Accuracy — without this field the assertion cannot be evidenced, whatever else the register contains
Populated99.4% of 13 536 items — approximately 81 assets are missing it
What goes wrongInconsistent methods applied within the same asset class
Consequence if not fixedWithin tolerance, but the exceptions still need clearing before year end.
12Carrying amountClick for the detail Must agree to the ledger balance and the noteValuation / Completeness 95.8%
569 missing
Gaps
What this field provesMust agree to the ledger balance and the note
Assertion it supportsValuation / Completeness — without this field the assertion cannot be evidenced, whatever else the register contains
Populated95.8% of 13 536 items — approximately 569 assets are missing it
What goes wrongRegister carrying amount does not tie to the ledger — the most common reconciliation failure
Consequence if not fixedWithin tolerance, but the exceptions still need clearing before year end.
13Condition ratingClick for the detail Supports impairment assessment and maintenance planningValuation / Disclosure 70.3%
4 020 missing
Material gap
What this field provesSupports impairment assessment and maintenance planning
Assertion it supportsValuation / Disclosure — without this field the assertion cannot be evidenced, whatever else the register contains
Populated70.3% of 13 536 items — approximately 4 020 assets are missing it
What goes wrongNot captured, or recorded as "good" for every asset on the register
Consequence if not fixedAn audit finding and a management letter point; not a qualification on its own, but it compounds with others.
14Disposal statusClick for the detail Identifies assets disposed, scrapped, stolen or written offCompleteness / Rights 88.6%
1 543 missing
Material gap
What this field provesIdentifies assets disposed, scrapped, stolen or written off
Assertion it supportsCompleteness / Rights — without this field the assertion cannot be evidenced, whatever else the register contains
Populated88.6% of 13 536 items — approximately 1 543 assets are missing it
What goes wrongDisposed assets not removed; removal without a section 14 council resolution
Consequence if not fixedAn audit finding and a management letter point; not a qualification on its own, but it compounds with others.
15Component informationClick for the detail Required wherever an asset has been componentisedCompleteness / Accuracy 39.4%
8 203 missing
Material gap
What this field provesRequired wherever an asset has been componentised
Assertion it supportsCompleteness / Accuracy — without this field the assertion cannot be evidenced, whatever else the register contains
Populated39.4% of 13 536 items — approximately 8 203 assets are missing it
What goes wrongComponents not separately identified; a building carried as a single line
Consequence if not fixedA qualification risk in its own right — the assertion fails at population level.
16Supporting document referenceClick for the detail Links the asset to its invoice, contract or resolutionRights and obligations 82.2%
2 409 missing
Material gap
What this field provesLinks the asset to its invoice, contract or resolution
Assertion it supportsRights and obligations — without this field the assertion cannot be evidenced, whatever else the register contains
Populated82.2% of 13 536 items — approximately 2 409 assets are missing it
What goes wrongNo document reference recorded; untraceable when the file is requested
Consequence if not fixedAn audit finding and a management letter point; not a qualification on its own, but it compounds with others.
17Insurance informationClick for the detail Assets must be insured at replacement costCompleteness 75.8%
3 276 missing
Material gap
What this field provesAssets must be insured at replacement cost
Assertion it supportsCompleteness — without this field the assertion cannot be evidenced, whatever else the register contains
Populated75.8% of 13 536 items — approximately 3 276 assets are missing it
What goes wrongInsured value not updated; cover taken at cost rather than replacement cost
Consequence if not fixedAn audit finding and a management letter point; not a qualification on its own, but it compounds with others.
The breakdown, validated line by line. Register against general ledger, class by class, with the difference and its cause named. Click a class to open its movement schedule for the year.

Register against general ledger

Materiality R10 449k · differences above it are misstatements
Asset classReg. grossGL grossReg. accum.GL accum.CA differenceMatch
Land186 000 186 000 (0) (0) Agrees
Buildings and offices412 000 412 000 (128 400) (128 400) Agrees
Infrastructure — roads and stormwater985 000 985 000 (386 000) (386 000) Agrees
Infrastructure — water742 000 742 000 (241 000) (241 000) Agrees
Infrastructure — sanitation508 000 508 000 (177 000) (177 000) Agrees
Infrastructure — electricity624 000 624 000 (228 000) (228 000) Agrees
Community assets168 000 168 000 (61 000) (61 000) Agrees
Plant and equipment96 000 96 000 (54 000) (47 800) 6 200 Difference
Motor vehicles82 000 87 400 (49 000) (49 000) 5 400 Difference
Furniture, fittings and IT31 000 31 000 (22 000) (22 000) Agrees
Capital work in progress60 400 60 400 (0) (0) Agrees
Total3 894 4003 899 800 (1 346 400)(1 340 200) 0 Clean

Validation tests

Run against the register itself, not against the ledger
TestWhat it catchesExceptionsValue at riskResult
Carrying amount is not negativeAccumulated depreciation exceeding cost, usually from a manual adjustment Pass
Accumulated depreciation does not exceed depreciable amountDepreciation continuing past the residual value 34 1 240 Fail
Every asset has a useful life greater than zeroAssets that will never depreciate because the field is blank 148 8 600 Fail — material
Date available for use is not in the futureCut-off error at year end 7 3 100 Fail
No duplicate barcodesThe same asset counted twice 21 2 400 Fail
Disposed assets carry a council resolution referenceRemoval without the authority MFMA section 14 requires 12 5 400 Fail — material
Insured value is at replacement, not costUnder-insurance, and a premium that is fruitless if the asset does not exist 2612 Fail
Every component links to a parent assetOrphan components that depreciate against nothing Pass
GRAP 17.43 — each part with a cost significant in relation to the total is depreciated separately. There is no prescribed percentage; the AGSA position is that componentisation is expected where a component exceeds ten to twenty per cent of asset cost and its useful life differs materially. A building depreciated as one line with a fifty-year life is understating depreciation on a roof that will be replaced in fifteen.

Componentisation status by class

Click a class for its component structure
Asset classCarrying amountComponentisedCoverageDepreciation understatedResult
Buildings and officesClick for the rule and the effect 283 60096 424
34.0% componentised
3 237 Partial
Componentisation ruleGRAP 17 — componentise structure, roof, services
Coverage34.0% of carrying amount in this class is held as separately depreciated components; the balance is carried as single lines.
Why it matters hereA building carried as one line at forty-two years is depreciating a fifteen-year roof and twenty-year services at the structural rate. The roof replacement is then capitalised without the original being derecognised, which double-counts the asset.
Estimated depreciation understatedR3 237k a year
Infrastructure — roads and stormwaterClick for the rule and the effect 599 000107 820
18.0% componentised
23 602 Not componentised
Componentisation ruleGRAP 17 — componentise subgrade, base, surfacing, kerbs
Coverage18.0% of carrying amount in this class is held as separately depreciated components; the balance is carried as single lines.
Why it matters hereA road surfaced with asphalt has a twelve-year surfacing life sitting on a forty-year subgrade. Depreciating the whole at twenty-eight years understates the charge on the part that actually wears out, and the resurfacing then arrives as an unbudgeted capital event.
Estimated depreciation understatedR23 602k a year
Infrastructure — waterClick for the rule and the effect 501 000260 520
52.0% componentised
3 887 Adequate
Componentisation ruleGRAP 17 — componentise civil, mechanical, electrical
Coverage52.0% of carrying amount in this class is held as separately depreciated components; the balance is carried as single lines.
Why it matters hereCivil structures last fifty years; mechanical plant twenty; membranes seven. A single life across all three is wrong for each of them.
Estimated depreciation understatedR3 887k a year
Infrastructure — sanitationClick for the rule and the effect 331 000155 570
47.0% componentised
3 497 Partial
Componentisation ruleGRAP 17 — componentise reticulation and treatment plant
Coverage47.0% of carrying amount in this class is held as separately depreciated components; the balance is carried as single lines.
Why it matters hereCivil structures last fifty years; mechanical plant twenty; membranes seven. A single life across all three is wrong for each of them.
Estimated depreciation understatedR3 497k a year
Infrastructure — electricityClick for the rule and the effect 396 000162 360
41.0% componentised
6 209 Partial
Componentisation ruleGRAP 17 — componentise network, substations, metering
Coverage41.0% of carrying amount in this class is held as separately depreciated components; the balance is carried as single lines.
Why it matters hereNetwork assets and the metering on them have materially different lives.
Estimated depreciation understatedR6 209k a year
Community assetsClick for the rule and the effect 107 0009 630
9.0% componentised
2 784 Not componentised
Componentisation ruleGRAP 17 — halls, parks, cemeteries, libraries
Coverage9.0% of carrying amount in this class is held as separately depreciated components; the balance is carried as single lines.
Why it matters hereNetwork assets and the metering on them have materially different lives.
Estimated depreciation understatedR2 784k a year
Plant and equipmentClick for the rule and the effect 42 0000
0.0% componentised
3 429 Not componentised
Componentisation ruleGRAP 17 — units of production where use varies
Coverage0.0% of carrying amount in this class is held as separately depreciated components; the balance is carried as single lines.
Why it matters hereNetwork assets and the metering on them have materially different lives.
Estimated depreciation understatedR3 429k a year
Motor vehiclesClick for the rule and the effect 33 0000
0.0% componentised
5 532 Not componentised
Componentisation ruleGRAP 17 — residual value 10 to 20% of cost typical
Coverage0.0% of carrying amount in this class is held as separately depreciated components; the balance is carried as single lines.
Why it matters hereNetwork assets and the metering on them have materially different lives.
Estimated depreciation understatedR5 532k a year

Worked component structure — water treatment works

The pattern applied to every componentised asset
ComponentCost% of totalUseful lifeResidualAnnual depreciation
Civil structureConcrete reservoirs, inlet works and channels18 200 42.7%50 yrs500 354
Mechanical plantPumps, blowers, screens and dosing equipment9 800 23.0%20 yrs300 475
Electrical systemsSwitchgear, motor control centres, cabling6 400 15.0%25 yrs150 250
Filtration membranesConsumable by design; replaced on a fixed cycle4 200 9.9%7 yrs 600
SCADA and controlInstrumentation, telemetry and control software2 800 6.6%10 yrs100 270
Access roads and civilsSite access, hardstanding and fencing1 200 2.8%30 yrs 40
Total42 600100.0% 1 050 1 989

Carried as a single asset on a forty-year life the annual charge would be R1 039k. Componentised it is R1 989k — 1.9 times higher, because the membranes at seven years and the mechanical plant at twenty are consuming far faster than the civil structure. The higher charge is not a penalty; it is the correct measurement of what is being used up, and it is what makes the renewal profile in the asset management plan credible.

GRAP 17.51 requires useful life and residual value to be reviewed at every reporting date. Not at adoption, not when convenient — every year. An entity that has not reviewed its estimates for three years or more carries a GRAP 3 prior-period error risk, and the AGSA routinely queries registers still running the lives adopted in 2009 to 2012.

Useful lives against the National Treasury benchmark

Click a class for the annual charge and the review position
Asset classLife appliedNT benchmarkLast reviewedAnnual chargePosition
Buildings and officesClick for the review position 42 yrs30 to 50 years 2019 9 810 6 years stale
Life applied42 years, straight line, against a National Treasury benchmark of 30 to 50 years
Annual chargeR9 810k at the applied life; R10 300k at the benchmark
Last documented review2019 — 6 years ago. GRAP 17.51 requires a review at every reporting date. Three years or more without one is a GRAP 3 prior-period error risk if the effect is material, and the AGSA routinely queries registers still running the lives adopted at initial GRAP conversion.
Effect of moving to the benchmark 490 a year on the depreciation charge, and the same amount off the surplus
ActionPerform and document a useful life and residual value review before year end; where the change is material, disclose it as a change in accounting estimate under GRAP 3 and apply it prospectively.
Infrastructure — roads and stormwaterClick for the review position 28 yrsSurfacing 8 to 15 years; componentised 12 to 40 2018 35 179 7 years stale
Life applied28 years, straight line, against a National Treasury benchmark of surfacing 8 to 15 years; componentised 12 to 40
Annual chargeR35 179k at the applied life; R44 773k at the benchmark
Last documented review2018 — 7 years ago. GRAP 17.51 requires a review at every reporting date. Three years or more without one is a GRAP 3 prior-period error risk if the effect is material, and the AGSA routinely queries registers still running the lives adopted at initial GRAP conversion.
Effect of moving to the benchmark 9 594 a year on the depreciation charge, and the same amount off the surplus
ActionPerform and document a useful life and residual value review before year end; where the change is material, disclose it as a change in accounting estimate under GRAP 3 and apply it prospectively.
Infrastructure — waterClick for the review position 48 yrsPipes 30 to 60 years 2023 15 458 Reviewed
Life applied48 years, straight line, against a National Treasury benchmark of pipes 30 to 60 years
Annual chargeR15 458k at the applied life; R16 489k at the benchmark
Last documented review2023 — 2 years ago. Within the annual review requirement.
Effect of moving to the benchmark 1 031 a year on the depreciation charge, and the same amount off the surplus
ActionNo action — the review is current. Retain the evidence for the audit file.
Infrastructure — sanitationClick for the review position 44 yrs30 to 50 years 2023 11 545 Reviewed
Life applied44 years, straight line, against a National Treasury benchmark of 30 to 50 years
Annual chargeR11 545k at the applied life; R12 700k at the benchmark
Last documented review2023 — 2 years ago. Within the annual review requirement.
Effect of moving to the benchmark 1 155 a year on the depreciation charge, and the same amount off the surplus
ActionNo action — the review is current. Retain the evidence for the audit file.
Infrastructure — electricityClick for the review position 36 yrs25 to 40 years 2022 17 333 3 years stale
Life applied36 years, straight line, against a National Treasury benchmark of 25 to 40 years
Annual chargeR17 333k at the applied life; R19 500k at the benchmark
Last documented review2022 — 3 years ago. GRAP 17.51 requires a review at every reporting date. Three years or more without one is a GRAP 3 prior-period error risk if the effect is material, and the AGSA routinely queries registers still running the lives adopted at initial GRAP conversion.
Effect of moving to the benchmark 2 167 a year on the depreciation charge, and the same amount off the surplus
ActionPerform and document a useful life and residual value review before year end; where the change is material, disclose it as a change in accounting estimate under GRAP 3 and apply it prospectively.
Community assetsClick for the review position 34 yrs20 to 40 years 2019 4 941 6 years stale
Life applied34 years, straight line, against a National Treasury benchmark of 20 to 40 years
Annual chargeR4 941k at the applied life; R5 600k at the benchmark
Last documented review2019 — 6 years ago. GRAP 17.51 requires a review at every reporting date. Three years or more without one is a GRAP 3 prior-period error risk if the effect is material, and the AGSA routinely queries registers still running the lives adopted at initial GRAP conversion.
Effect of moving to the benchmark 659 a year on the depreciation charge, and the same amount off the surplus
ActionPerform and document a useful life and residual value review before year end; where the change is material, disclose it as a change in accounting estimate under GRAP 3 and apply it prospectively.
Plant and equipmentClick for the review position 12 yrs8 to 15 years 2024 8 000 Reviewed
Life applied12 years, straight line, against a National Treasury benchmark of 8 to 15 years
Annual chargeR8 000k at the applied life; R8 000k at the benchmark
Last documented review2024 — 1 year ago. Within the annual review requirement.
Effect of moving to the benchmark 0 a year on the depreciation charge, and the same amount off the surplus
ActionNo action — the review is current. Retain the evidence for the audit file.
Motor vehiclesClick for the review position 9 yrsLight 5 to 10; heavy 10 to 15 2024 9 111 Reviewed
Life applied9 years, straight line, against a National Treasury benchmark of light 5 to 10; heavy 10 to 15
Annual chargeR9 111k at the applied life; R10 250k at the benchmark
Last documented review2024 — 1 year ago. Within the annual review requirement.
Effect of moving to the benchmark 1 139 a year on the depreciation charge, and the same amount off the surplus
ActionNo action — the review is current. Retain the evidence for the audit file.
Furniture, fittings and ITClick for the review position 8 yrsIT 3 to 5; furniture 8 to 15 2024 3 875 Reviewed
Life applied8 years, straight line, against a National Treasury benchmark of it 3 to 5; furniture 8 to 15
Annual chargeR3 875k at the applied life; R5 167k at the benchmark
Last documented review2024 — 1 year ago. Within the annual review requirement.
Effect of moving to the benchmark 1 292 a year on the depreciation charge, and the same amount off the surplus
ActionNo action — the review is current. Retain the evidence for the audit file.

Depreciation charge by class

Hover a class
Buildings
Infra
Infra
Infra
Infra
Community
Plant
Motor
Furniture,
Charge at the life appliedCharge at the benchmark life
Hover a class to compare the charge at the applied life against the benchmark.

Effect of a life review

What correcting the estimates would do
LineAs reportedAt benchmark livesMovement
Depreciation chargeR115 253kR132 778k R17 526k
Operating surplusR449 276kR431 750k R(17 526)k
Carrying amount at year endR2 548 000kR2 530 474k R(17 526)k
Renewal ratio — capital spend over depreciation1.41x1.22x -0.19x

Moving every class to the benchmark life would raise the charge by R17 526k and take the same amount off the surplus. It would also move the renewal ratio from 1.41x to 1.22x, which is the more important number: the ratio is only meaningful if depreciation is measuring consumption correctly. A register running lives that are too long reports both a healthier surplus and a healthier renewal ratio than the municipality actually has.

Capital work in progress is not depreciated and is not yet an asset in use. GRAP 17.23 allows only directly attributable cost into the carrying amount. The two things that go wrong are cost that should have been expensed sitting in WIP, and completed assets that were never transferred out of it — which understates depreciation for as long as they stay there.

Work in progress by project

Closing balance R101 500k · not depreciated
ProjectOpeningAdditionsTransferred to PPEClosingAgeStatus
CP-01 Bulk water augmentation phase 211 60024 500 36 100 2.0 yrs On programme
CP-02 Electricity network strengthening16 400 16 400 1.0 yrs On programme
CP-03 Wastewater works refurbishment14 200 14 200 1.0 yrs On programme
CP-04 Roads rehabilitation wards 4 to 912 100 12 100 1.0 yrs Behind programme
CP-05 Landfill cell 3 and weighbridge7 600 7 600 0.5 yrs On programme
Clinic upgrade — Ward 118 900 8 900 3.5 yrs Complete but not transferred
Sports facility — Ward 36 200 6 200 4.5 yrs Stalled; contractor in liquidation
Traffic signalling upgrade4 100 4 1000 1.5 yrs Transferred to PPE in the year
Total30 80074 800 4 100101 500

What may be capitalised

GRAP 17.23 and GRAP 5
CostTreatmentBasis
Purchase price, import duties and non-refundable taxesCapitalise GRAP 17.26 — cost of the item itself
Site preparation, delivery, installation and assemblyCapitalise GRAP 17.23 — directly attributable to bringing the asset to working condition
Professional fees — engineering, architectural, project managementCapitalise GRAP 17.23 — directly attributable
Employee costs arising directly from constructionCapitalise GRAP 17.23 — only the portion directly attributable, evidenced by timesheets
Borrowing costs on a qualifying assetCapitalise while construction is active GRAP 5 — suspended during extended idle periods
Dismantling and site restoration obligationCapitalise GRAP 17.26(c) — the present value of the obligation
Administration and general overheadExpense GRAP 17.24 — not directly attributable
Staff training on the new assetExpense GRAP 17.24 — not part of bringing the asset to working condition
Costs of opening a new facility or launching a serviceExpense GRAP 17.24
Abnormal waste, rework and idle-time costExpense GRAP 17.23 — not normally attributable cost

Ageing and the transfer test

WIP older than the construction period is a red flag
Age bandBalanceProjectsTreatment required
Under 1 year7 6001 Normal construction period — no action
1 to 2 years42 7003 Normal construction period — no action
2 to 3 years36 1001 Confirm the project is still active and the cost is still recoverable
Over 3 years15 1002 Assess for impairment under GRAP 17.67 or abandonment; transfer to PPE if complete and in use

R15 100k of the closing balance sits in projects that are either complete but not transferred, or stalled beyond three years. The first understates depreciation for as long as it stays there — the clinic upgrade has been in use since it was completed and has never been depreciated. The second is an impairment assessment under GRAP 17.67, not a balance to carry forward: a contractor in liquidation means the cost incurred may not produce an asset at all.

Register to general ledger to note to the face of the statements. Four steps, each of which must agree. A difference at any step is a material misstatement, not a reconciling item to be carried forward.

The four steps

Difference R0k against materiality of R10 449k
1
Asset register totalGross cost, accumulated depreciation and carrying amount agreed to the register summary · The register is the source. Everything downstream is a copy of it or a difference from it.
R2 548 000k
Agrees
2
Register to general ledgerThe PPE control accounts must agree to the register totals · Any difference is a reconciling item requiring investigation and a correcting entry — it is never carried forward.
R2 548 000k
Agrees
3
General ledger to the PPE noteOpening balance plus additions less disposals less depreciation equals closing carrying amount · The note is a movement schedule. If it does not add up the ledger is wrong, not the note.
R2 548 000k
Agrees
4
PPE note to the statement of financial positionThe total carrying amount in the note must agree to the PPE line on the face · The last step is usually clean, because the note is drawn from the ledger. When it fails, the AFS have been manually adjusted.
R2 548 000k
Agrees

Differences identified

Each one traced to its cause
ClassDifferenceCauseWhich side is correctCorrection
Movements posted by other modules-11 600 The disaster run, the audit adjustments and capitalised certified work posted to the ledger’s PPE control account after the register was last rolled forward. This is the normal state of a sub-ledger between roll-forwards, not an error.The ledger is correct. The register has not yet been updated. Register roll-forward — no journal.
Motor vehicles5 400 Two vehicles disposed in March 2026 were removed from the register but not from the general ledger.The register is correct. The ledger overstates gross cost. Journal required in the ledger.
Plant and equipment6 200 Fourth-quarter depreciation was posted to the ledger but never processed in the register.The ledger is correct. The register understates accumulated depreciation. Register correction only — no journal.
Net effect on carrying amount0 Below materiality, but still corrected.

Correcting journals

Posted to the ledger, or to the register alone
CorrectionAccountDebitCreditWhere it posts
Correction 1Remove disposed vehicles from the ledger; section 14 resolution obtained Accumulated depreciation — motor vehicles5 400General ledger
Cost — motor vehicles5 400General ledger
Correction 2Process fourth-quarter depreciation in the register Accumulated depreciation — plant and equipment6 200 Register only — no journal
Effect on carrying amountRegister unchanged; ledger reduced to agree 5 40011 600Difference cleared
Posting writes to the same ledger the rest of MATOS reads, so the trial balance, the note and the carrying amount all move together.

The PPE note after correction

What appears in the annual financial statements
PPE noteLandBuildingsInfrastructureInfrastructureInfrastructureInfrastructureCommunityPlantMotorCapitalTotal
Cost — opening186 000412 000985 000742 000508 000624 000168 00096 00082 00060 4003 863 400
Additions26 40056 90032 70039 00060 400215 400
Disposals(5 400)(5 400)
Cost — closing186 000412 0001 011 400798 900540 700663 000168 00096 00076 600120 8004 073 400
Accumulated depreciation — opening(128 400)(386 000)(241 000)(177 000)(228 000)(61 000)(54 000)(49 000)(1 324 400)
Charge for the year(9 810)(35 179)(15 458)(11 545)(17 333)(4 941)(8 000)(9 111)(111 378)
Accumulated depreciation — closing(138 210)(421 179)(256 458)(188 545)(245 333)(65 941)(62 000)(58 111)(1 435 778)
Carrying amount at year end186 000273 790590 221542 442352 155417 667102 05934 00018 489120 8002 637 622
Verification answers the existence assertion, and nothing else can. An asset on the register that cannot be found is a ghost asset: it overstates cost, overstates accumulated depreciation, carries a depreciation charge on nothing, and attracts an insurance premium that is fruitless expenditure under the MFMA.
Verification coverage
77.1%
Infrastructure is the hardest to verify and the least covered — buried pipe cannot be barcoded
Assets on register not found
324
Carrying amount R27 100k — the existence assertion fails on these
Assets found not on register
104
Each one must be valued and recognised under GRAP 17
Net effect on carrying amount
R(27 100)k
Overstatement to be written off, with a section 14 resolution where required

Verification result by class

Verification cycle to 30 June 2025
Asset classOn registerVerifiedNot foundUnrecordedCoverageResult
Buildings and offices318299 1
94.0% verified
Qualified
Infrastructure — roads and stormwater2 1401 519 24 2
71.0% verified
Insufficient
Infrastructure — water1 6851 112 24 5
66.0% verified
Insufficient
Infrastructure — sanitation1 204831 14 3
69.0% verified
Insufficient
Infrastructure — electricity1 5161 122 14 4
74.0% verified
Insufficient
Community assets462407 10 6
88.0% verified
Qualified
Plant and equipment874795 30 18
91.0% verified
Qualified
Motor vehicles216210 4 2
97.0% verified
Qualified
Furniture, fittings and IT3 2682 712 203 64
83.0% verified
Qualified
Total11 683 9 007324 104

Financial effect of the discrepancies

EffectR'000Assertion failed
Gross cost overstated by assets that do not exist41 421 Existence
Accumulated depreciation overstated on the same assets14 320 Accuracy
Depreciation charged in the year on assets that do not exist2 258 Accuracy — expenditure overstated
Insurance premium paid on assets that do not exist114 Fruitless and wasteful expenditure under the MFMA
Assets in use never recognised3 840 Completeness — understatement

A ghost asset fails four ways at once: it overstates cost, it overstates accumulated depreciation, it carries a depreciation charge against nothing, and the premium insuring it is fruitless expenditure the accounting officer must report. Resolving them requires source documents, disposal records and, where an asset was stolen, a police case number — removal without a section 14 council resolution is itself the finding.

Condition profile

Hover a rating
C1
C2
C3
C4
C5
Carrying amount at this conditionRenewal required within three years
Hover a condition rating to see what sits at it and what it will cost.

Asset tag & GPS verification register

Scan, location and custodian evidence
Mahlasedi Road R4Nelson Mandela DriveMetsi AvenueWARD 4WARD 7
250 m
Mahlasedi municipal asset layerRoads · water · electricity · civic facilities
Asset tagAsset / custodianGPSLast scanStatusAction
MAT-WAT-000184
Water
Bulk water pump 3
M. Ndlovu · Water Treatment Works
-25.7218, 28.213415 Sep 2026Verified
MAT-ELC-000077
Electricity
11kV transformer T77
L. Mokoena · Substation East
-25.7061, 28.198014 Sep 2026Verified
MAT-RDS-001442
Roads
Ward 4 stormwater culvert
P. Khumalo · Mahlasedi Road R4
-25.7392, 28.171612 Sep 2026Geofence review
MAT-BLD-000023
Buildings
Civic Centre HVAC plant
T. Jacobs · Civic Centre
-25.7275, 28.208110 Sep 2026Verified
MAT-VEH-000116
Vehicles
Refuse compactor 116
S. Molefe · Fleet depot
-25.7503, 28.224908 Sep 2026Tag rescan
The asset management plan is the register turned into a spending commitment. Condition drives remaining life, remaining life drives the year renewal falls due, and the sum of those years is the renewal profile. Where the profile exceeds what is budgeted, the difference is not a funding gap in the abstract — it is a dated list of assets that will fail before they are replaced.
Replacement cost of the asset base
R6 438 000k
Against a carrying amount of R2 548 000k — the gap is what inflation and consumption have done
Renewal due within ten years
R3 132 744k
48.7% of the base reaches the end of its service life in the period
Annual renewal requirement
R313 274k
Against budgeted capital and maintenance of R224 876k
Annual funding gap
R(88 398)k
Funded at 71.8% of requirement

Renewal requirement by class

Click a class for its condition profile and renewal timing
Asset classCarrying amountReplacement costAverage conditionRenewal due in 10 yearsAnnual requirementFunded
Buildings and officesClick for the condition profile and the timing 283 600667 440 2.8 256 70825 671 114%
Infrastructure — roads and stormwaterClick for the condition profile and the timing 599 0001 753 300 3.4 1 078 954107 895 50%
Infrastructure — waterClick for the condition profile and the timing 501 0001 268 820 2.9 536 80853 681 91%
Infrastructure — sanitationClick for the condition profile and the timing 331 000858 520 3.1 429 26042 926 72%
Infrastructure — electricityClick for the condition profile and the timing 396 0001 085 760 3.0 501 12050 112 78%
Community assetsClick for the condition profile and the timing 107 000265 440 3.3 153 13815 314 60%
Plant and equipmentClick for the condition profile and the timing 42 000138 240 3.2 74 4377 444 83%
Motor vehiclesClick for the condition profile and the timing 33 000113 160 3.5 73 9897 399 85%
Furniture, fittings and ITClick for the condition profile and the timing 9 00040 920 3.6 28 3292 833 51%
Total2 548 0006 438 000 3 132 744313 274 72%

Ten-year renewal profile

Hover a year
Y1
Y2
Y3
Y4
Y5
Y6
Y7
Y8
Y9
Y10
Renewal requiredBudgeted capital and maintenanceUnfunded
Hover a year to read the renewal requirement, what is budgeted and the gap.

Maintenance against the norm

ClassMaintenance spend% of carrying amountNormResult
Buildings and offices5 9562.10% 3%Below norm
Infrastructure — roads and stormwater8 3861.40% 2%Below norm
Infrastructure — water9 0181.80% 2%Below norm
Infrastructure — sanitation5 2961.60% 2%Below norm
Infrastructure — electricity7 5241.90% 2%Below norm
Community assets1 2841.20% 3%Below norm
Plant and equipment1 8904.50% 3%At norm
Motor vehicles2 2446.80% 3%At norm
Furniture, fittings and IT4685.20% 3%At norm

Total maintenance is R78 000k, 3.06% of the carrying value of the asset base against a Circular 71 norm of 8% of the value of property, plant and equipment. Under-maintenance is the cheapest saving available in any budget year and the most expensive over ten: deferred maintenance raises the failure rate, failures consume the unplanned repair budget, and the unplanned repair budget is the money that would otherwise have funded planned renewal.

What the plan concludes

The plan is not funded. Cumulative unfunded renewal over ten years is R536 644k, concentrated in the first four years where the profile is front-loaded.
What the plan actually isNot a wish list. Condition rating gives remaining service life, remaining service life gives the year each asset falls due, and the sum by year is the profile. Every rand in it is attached to a dated asset.
Why replacement cost, not carrying amountCarrying amount is what the asset cost less what has been consumed. Replacing it costs today's price — R6 438 000k against a carrying amount of R2 548 000k. Planning off the carrying amount understates the requirement by R3 890 000k.
The three optionsFund the gap, extend asset lives through higher maintenance, or accept a lower level of service. There is no fourth option, and deferring the decision selects the third by default.
Link to the rest of the systemThis requirement is the input to the structural risk analysis in the risk module, where it is tested against revenue growth and the affordable tariff path. A renewal plan that is not affordable is not a plan.

Applicable asset reporting standards and controls

GRAP reporting basis · IFRS cross-reference only
GRAP 17 · PPERecognition, componentisation, depreciation, annual useful-life and residual-value review.
GRAP 21 · non-cash-generating impairmentService-potential impairment, recoverable service amount and reversals.
GRAP 26 · cash-generating impairmentRecoverable amount for assets held primarily to generate a commercial return.
GRAP 12 · inventoriesStrategic and critical spares assessed for inventory or PPE classification.
GRAP 31 · intangiblesSoftware, licences and identifiable non-monetary assets without physical substance.
GRAP 103 / 104Heritage assets and financial-instrument receivables are separated from PPE.
Where every movement in this module lands. The register is a sub-ledger. Nothing here is complete until it has moved the control account, the trial balance, the note and the carrying amount on the face of the statements.

Downstream effect

DestinationWhat travelsCurrent movementTrigger
General ledger — PPE control accountGross cost and accumulated depreciation by class R0k differenceAny addition, disposal, transfer from WIP, impairment or depreciation run
Statement of financial performance — depreciationThe annual charge computed asset by asset R115 253kA change in useful life, residual value or method, and every new asset brought into use
Statement of financial position — PPEThe carrying amount on the face R2 548 000kEverything above
PPE note to the annual financial statementsThe movement schedule: opening, additions, disposals, depreciation, closing RecomputedYear end, and at every interim reporting date
Treasury Control Tower — renewal ratioCapital spend against depreciation 1.41xA change in the depreciation charge moves the ratio without any change in spend
Risk module — structural analysisRequired renewal, backlog and condition profile R313 274k a yearA condition assessment, or a change in replacement cost indexation
Credit model — asset factorMaintenance as a share of infrastructure carrying value 3.06%Maintenance spend and the carrying amount both move it
Audit findings — CTL-11 and the WIP testCertified capital work capitalised, and WIP transferred on completion R15 100k at riskWIP ageing beyond the construction period
Insurance scheduleReplacement value by asset R6 438 000kAnnual renewal of the policy; ghost assets carry fruitless premium

Register events and the entries they raise

The full posting map
EventDebitCreditAlso updatesAuthority required
AcquisitionProperty, plant and equipmentTrade and other payables or bank Register: all seventeen fields; insurance scheduleDelegation per the SCM policy
Transfer from work in progressProperty, plant and equipment — the relevant classCapital work in progress Register: date available for use starts depreciationCompletion certificate and practical completion
Depreciation runDepreciation — statement of financial performanceAccumulated depreciation Register: accumulated depreciation and carrying amountNone — systematic
Revision of useful lifeNo journal in the period of changeNo journal in the period of change Register: revised life; the charge changes prospectivelyDocumented review under GRAP 17.51
ImpairmentImpairment loss — statement of financial performanceAccumulated impairment Register: condition rating and recoverable amountImpairment test under GRAP 21 or 26
Disposal — proceeds receivedBank; accumulated depreciationProperty, plant and equipment; gain on disposal Register: disposal status and dateCouncil resolution under MFMA section 14
Write-off — asset not foundAccumulated depreciation; loss on write-offProperty, plant and equipment Register: removal with the investigation referenceCouncil resolution, and a police case number where stolen
Recognition of an unrecorded assetProperty, plant and equipmentAccumulated surplus or revenue as applicable Register: full record created at assessed costValuation evidence and accounting officer approval
Componentisation of an existing assetNo net journal — reallocation within the classNo net journal Register: parent split into components with separate livesDocumented assessment under GRAP 17.43

Integration with the rest of MATOS

ModuleReads from the registerWrites to the registerFunction
Project riskNothingCertified capital work, which becomes an addition or a WIP movementmxPortfolio().certTD → AR_WIP
Contract monitorNothingRetention released on practical completion, which completes the asset costmxContractTotals().ret
Audit findingsData quality scores, WIP ageing, verification result — these raise findingsResolution of a finding updates the registerarTotals().diff → mxOpinion()
Treasury Control TowerCarrying amount, depreciation, maintenance ratioNothingarTotals() → axBase()
Risk moduleReplacement cost, condition profile, renewal requirement, backlogNothingarAmp() → AX_LR.backlogOpen
Credit modelMaintenance to infrastructure ratio, renewal ratioNothingarTotals().annual → AX_BANDS.rmRatio
Integrated workbookThe full register as a sheetManual corrections flow back23_Asset_Register
Investment upload package recognised. The attached BRT project pack is the worked case for this module: 14 checklist documents, 12 assumptions, and supporting model files. Download the exact sample package. Upload processing remains local to the browser.

Project appraisal

One project taken end to end: define it, cost it, forecast what it earns and what it costs to run, build the statements, appraise the return, test whether the debt can be serviced, stress it, choose how to fund it, and check it against the municipality's own balance sheet and the law that governs the decision. Every step is arithmetic over the inputs on the step before it. Nothing is scored, weighted or judged by a model.

STEP 0
Intake
STEP 1
Definition
STEP 2
Capital cost
STEP 3
Demand
STEP 4
Operating cost
STEP 5
Statements
STEP 6
Appraisal
STEP 7
Debt cover
STEP 8
Ratios
STEP 9
Sensitivity
STEP 10
Funding
STEP 11
Municipal fit
STEP 12
Verdict
Capital cost
R1 240 000k
3-year programme · R67 391k per route kilometre
Municipal net present cost
R650 824k
On the public transport network grant with own contribution structure, at 11.5%
Annual call from year 4
R85 411k
19.0% of the current operating surplus
Economic net present value
R340 772k
Financial net present value R(1 504 101)k — the two answer different questions
EVIDENCE INTAKE CONTROL

Evidence pack incomplete

0 of 14 required documents classified · 14 outstanding

0%document completeness
1Register files2Classify evidence3Extract fields4Cross-check models5Run gates6Release results
Decision analysis is held at the evidence gate.

MATOS will retain and analyse the 0 supplied item(s), but it will not release a conclusion built on missing evidence. Still required:

  • Investment memorandum or business case — States what is being built, why, and by whom. Everything in step 1 comes from it.
  • Financial model — The revenue, cost and funding assumptions. Read directly where supplied as CSV.
  • Capital cost estimate and bill of quantities — Work-package costing at step 2. Without it the capital number is a claim, not an estimate.
  • Demand and patronage study — Step 3. The single assumption that most often destroys a transport project.
  • Operating cost model — Step 4. Vehicle-kilometre costing, station operations, fare collection.
  • Independent technical review — Confirms the cost estimate and the programme are buildable.
  • Environmental impact assessment and record of decision — A condition precedent to any construction contract.
  • Land and servitude schedule — Confirms the corridor can actually be assembled.
  • Grant allocation letter — Evidence the conditional grant is committed, not hoped for.
  • Draft term sheet or funding offer — The rate, tenor and conditions used in step 10.
  • Section 78 assessment — Required by the Municipal Systems Act before any external mechanism is chosen.
  • Council resolution or committee mandate — The authority to proceed to the next gate.
  • Risk register — The project risk register, distinct from the municipal one.
  • Operating and maintenance plan — Lifecycle and renewal assumptions behind the twenty-year cost.
Analysis recordNo analysis run yet. Uploaded content remains reviewable below.
Load the project pack. Drop files one at a time or upload a single zip containing everything. The module reads the archive in the browser — nothing is sent anywhere. Where a file matches a required document it is ticked off the checklist; where assumptions.csv is present its values replace the built-in ones and every step downstream recomputes.

Upload

Drop the project pack here

A single .zip, or individual files: PDF, XLSX, CSV, DOCX, images.
Everything is read locally in this browser.

No files loaded. The appraisal is running on the built-in worked project.

Document checklist

0 of 14 required documents present
#Required documentWhy the appraisal needs itExpected fileStatus
1Investment memorandum or business case States what is being built, why, and by whom. Everything in step 1 comes from it.memorandum.pdf / business_case.pdf Not supplied
2Financial model The revenue, cost and funding assumptions. Read directly where supplied as CSV.assumptions.csv / financial_model.xlsx Not supplied
3Capital cost estimate and bill of quantities Work-package costing at step 2. Without it the capital number is a claim, not an estimate.capex_estimate.csv / boq.xlsx Not supplied
4Demand and patronage study Step 3. The single assumption that most often destroys a transport project.demand_study.pdf / patronage.csv Not supplied
5Operating cost model Step 4. Vehicle-kilometre costing, station operations, fare collection.opex_model.csv Not supplied
6Independent technical review Confirms the cost estimate and the programme are buildable.technical_review.pdf Not supplied
7Environmental impact assessment and record of decision A condition precedent to any construction contract.eia_rod.pdf Not supplied
8Land and servitude schedule Confirms the corridor can actually be assembled.land_schedule.csv Not supplied
9Grant allocation letter Evidence the conditional grant is committed, not hoped for.ptng_allocation.pdf Not supplied
10Draft term sheet or funding offer The rate, tenor and conditions used in step 10.term_sheet.pdf Not supplied
11Section 78 assessment Required by the Municipal Systems Act before any external mechanism is chosen.s78_assessment.pdf Not supplied
12Council resolution or committee mandate The authority to proceed to the next gate.council_resolution.pdf Not supplied
13Risk register The project risk register, distinct from the municipal one.risk_register.csv Not supplied
14Operating and maintenance plan Lifecycle and renewal assumptions behind the twenty-year cost.om_plan.pdf Not supplied

The appraisal runs on the built-in worked project where a document is missing. That is fine for a demonstration and not acceptable for a decision: a capital project approved on 0% of its evidence base is approved on assertion. Missing items are listed above.

Assumptions in force

Built-in worked project
InputValueUnitSource
Opening annual patronagepax0 14 200 000 passengers Transport model, 2025 household travel survey
Patronage growthpaxGrowth 3.40% per year Corridor densification and population growth
Average fare at openingfare0 R14.80 R per trip Tariff policy, distance-banded
Fare escalationfareEsc 5.50% per year Tariff policy — CPI plus 0.3 points
Operating cost escalationopexEsc 6.20% per year Wage settlement and fuel, blended
Capital grant shareptngCapShare 60.00% of capital cost Public Transport Network Grant allocation letter
Operating grant shareptngOpShare 60.00% of the deficit Public Transport Network Grant, operations window
Discount rate — financialdiscNom 11.50% nominal Municipal weighted average cost of capital
Discount rate — economicdiscEcon 8.00% real National Treasury social discount rate
Senior loan rateloanRate 8.75% per year DBSA indicative term sheet
Loan tenorloanTenor 20 years DBSA indicative term sheet
PPP equity return targetequityTarget 17.50% per year Market sounding, SA infrastructure concessions
🔒STEP 1

Definition is not released

MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.

Investment memorandum or business case
🔒STEP 2

Capital cost is not released

MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.

Investment memorandum or business caseCapital cost estimate and bill of quantities
🔒STEP 3

Demand is not released

MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.

Investment memorandum or business caseDemand and patronage study
🔒STEP 4

Operating cost is not released

MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.

Investment memorandum or business caseOperating cost model
🔒STEP 5

Statements is not released

MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.

Investment memorandum or business caseFinancial modelCapital cost estimate and bill of quantitiesDemand and patronage studyOperating cost model
🔒STEP 6

Appraisal is not released

MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.

Investment memorandum or business caseFinancial modelCapital cost estimate and bill of quantitiesDemand and patronage studyOperating cost modelIndependent technical review
🔒STEP 7

Debt cover is not released

MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.

Investment memorandum or business caseFinancial modelCapital cost estimate and bill of quantitiesDemand and patronage studyOperating cost modelDraft term sheet or funding offer
🔒STEP 8

Ratios is not released

MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.

Investment memorandum or business caseFinancial modelCapital cost estimate and bill of quantitiesDemand and patronage studyOperating cost modelGrant allocation letterDraft term sheet or funding offer
🔒STEP 9

Sensitivity is not released

MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.

Investment memorandum or business caseFinancial modelCapital cost estimate and bill of quantitiesDemand and patronage studyOperating cost modelRisk register
🔒STEP 10

Funding is not released

MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.

Investment memorandum or business caseFinancial modelCapital cost estimate and bill of quantitiesDemand and patronage studyOperating cost modelGrant allocation letterDraft term sheet or funding offer
🔒STEP 11

Municipal fit is not released

MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.

Investment memorandum or business caseFinancial modelCapital cost estimate and bill of quantitiesDemand and patronage studyOperating cost modelIndependent technical reviewEnvironmental impact assessment and record of decisionLand and servitude scheduleGrant allocation letterSection 78 assessmentCouncil resolution or committee mandateRisk registerOperating and maintenance plan
🔒STEP 12

Verdict is not released

MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.

Investment memorandum or business caseFinancial modelCapital cost estimate and bill of quantitiesDemand and patronage studyOperating cost modelIndependent technical reviewEnvironmental impact assessment and record of decisionLand and servitude scheduleGrant allocation letterDraft term sheet or funding offerSection 78 assessmentCouncil resolution or committee mandateRisk registerOperating and maintenance plan

Project risk

Every significant capital project measured against its approved cash-flow rollout — money out to contractors and money in from grants, loans and own funds. Progress is taken from certified payment certificates, not from opinion, so variance and the forecast outturn are arithmetic, not judgement.

Approved programme value
R408 000k
5 of 5 projects live at 30 Jun 2025
Certified complete
45.7%
R186 300k of certified work
Cash-flow variance
R(14 320)k
6.9% behind the approved rollout
Forecast outturn
R424 162k
Overrun of R16 162k · 4.0%

Capital programme — approved rollout against actual

Certified position as at 30 Jun 2025 · FY2025 H2
ProjectFundingApproved Planned to datePaid to dateCertified CompleteCost var.Forecast outturnStatus
CP-01 Bulk water supply augmentation — phase 2Water · contract SCM/2023/41 · CPI-linked RBIG grant 70% · own funds 30%142 00082 000 88 04084 800
59.7% certified
(3 240) 147 425+5 425 Watch 4 months late
CP-02 Electricity network strengthening — 2 × 20MVA substationsElectricity · contract SCM/2024/07 · Fixed price DBSA long-term loan 100%112 00047 000 39 20037 700
33.7% certified
(1 500) 116 456+4 456 Watch 12 months late
CP-03 Wastewater treatment works refurbishmentSanitation · contract SCM/2024/12 · CPI-linked MIG 100%64 00030 000 32 64032 300
50.5% certified
(340) 64 674+674 On track
CP-04 Roads rehabilitation and stormwater — wards 4 to 9Roads · contract SCM/2024/19 · CPI-linked MIG 80% · own funds 20%56 00041 000 26 32024 100
43.0% certified
(2 220) 61 159+5 159 Intervene 10 months late
CP-05 Landfill cell 3 development and weighbridgeRefuse · contract SCM/2025/03 · Fixed price Own funds 100%34 0008 000 7 4807 400
21.8% certified
(80) 34 368+368 On track 10 months late
Portfolio5 live408 000 208 000193 680186 300 45.7%(7 380)424 162 4.0%

Cash-flow rollout

5 live projects · R'000
2024H1
2024H2
2025H1
2025H2
2026H1
2026H2
Approved money out Actual money out Actual money in Extrapolated to completion
Money in against money out to 30 Jun 2025: R175 600k drawn from grants and loans against R193 680k paid to contractors. The municipality has carried R18 080k of the programme from its own cash — that is the working-capital call the Scenario lab and the Long-term model pick up. Periods beyond FY2025 H2 are extrapolated at the portfolio cost-performance index of 0.9619.

Financial milestones

Certificate-based · no subjective progress
CP-01 — Bulk water supply augmentation — phase 2
10% · R14 200k Certified
25% · R35 500k Certified
50% · R71 000k Certified
75% · R106 500k Due FY2026 H1
100% · R142 000k FY2026 H2
CP-02 — Electricity network strengthening — 2 × 20MVA substations
10% · R11 200k Certified
25% · R28 000k Certified
50% · R56 000k Due FY2026 H1
75% · R84 000k FY2026 H2
100% · R112 000k FY2026 H2
CP-03 — Wastewater treatment works refurbishment
10% · R6 400k Certified
25% · R16 000k Certified
50% · R32 000k Certified
75% · R48 000k Due FY2026 H1
100% · R64 000k FY2026 H2
CP-04 — Roads rehabilitation and stormwater — wards 4 to 9
10% · R5 600k Certified
25% · R14 000k Certified
50% · R28 000k Overdue since FY2025 H2
75% · R42 000k Due FY2026 H1
100% · R56 000k Due FY2026 H1
CP-05 — Landfill cell 3 development and weighbridge
10% · R3 400k Certified
25% · R8 500k Due FY2026 H1
50% · R17 000k Due FY2026 H1
75% · R25 500k FY2026 H2
100% · R34 000k FY2026 H2

Variance analysis and extrapolation

Thresholds from the Assumptions Library
MeasureHow it is derivedValueThresholdResult
Cash-flow (schedule) variancePaid to date less the approved rollout to date (14 320) ±20 800 Within tolerance
Cost varianceCertified value less amounts paid for it (7 380) ±3 874 Breach
Funding varianceGrants and loans received less expenditure incurred (18 080) ±9 684 Breach
Forecast outturn varianceApproved value ÷ cost-performance index, less approved value 16 162 ±20 400 Within tolerance
Programme slipElapsed periods ÷ proportion certified, weighted by value 6.8 months 3.0 months Breach
Cost-performance indexCertified value ÷ cost incurred. Below 1.00 means work is costing more than it is worth. 0.9619≥ 1.0000 Eroding

Cascade — what the forecast outturn does to the rest of MATOS

Forward-looking tabs recalculated from the extrapolation above
DestinationMechanismMovement
Scenario lab — project and infrastructure delay leverWeighted slip across the live portfolio sets the lever6.8 months
Long-term model — borrowingForecast overrun funded from the DBSA facility+16 162
Statement of financial performance — finance costsOverrun at 8.75%+1 414 p.a.
Statement of financial performance — depreciationOverrun over a 30-year useful life+539 p.a.
Credit worthiness — capital financingBorrowing ÷ total revenue, MFMA Circular 71 norm ≤45%35.3% → 36.4%
MFD-MM Master — Rates-to-Resilience cascadeDelivery rate feeds the asset-renewal leg45.7% certified
Reports and outputs — s71 and s52(d)In-year capital report reflects the rollout variance-6.9%
Governance and controls — CTL-11, CTL-15Capitalisation completeness and drawdown-against-spend tests2 failing
Posting as Chief Financial Officer.

Contract monitor

The SCM contract register reconciled to the creditors ledger: what was awarded, what has been committed against it, what has been certified, what has been paid, and what is still held as retention. Every figure traces to a posted transaction in the accounting suite, so over-commitment and late payment are detected, not estimated.

Awarded contract value
R416 400k
7 contracts under formal award
Committed against award
R424 300k
Over-committed by R7 900k
Certified but unpaid
R17 100k
R9 315k retention · R7 785k due
Beyond 30 days
R3 495k
3 contracts breach s65(2)(e)

Contract register reconciled to creditors

Reconciled to the creditors ledger at 30 Jun 2025
ContractSupplier / linked projectAwarded CommittedCertifiedPaid RetentionDue & unpaidDaysStatus
SCM/2023/41CPI-linked · CP-01 Kwena–Meyiwa Pipelines JV 128 000 128 000 84 80078 600 4 240 1 960 22 Compliant
SCM/2024/07Fixed price · CP-02 Thembalihle Power EPC 104 000 104 000 37 70033 900 1 885 1 915 18 Compliant
SCM/2024/12CPI-linked · CP-03 Aqua-Mech Process Engineering 58 000 58 000 32 30030 400 1 615 285 12 Compliant
SCM/2024/19CPI-linked · CP-04 Motaung Civils and Roadworks 51 000 58 900+7 900 over award 24 10021 700 1 205 1 195 74 Variation
SCM/2025/03Fixed price · CP-05 Sibanye Waste Infrastructure 31 000 31 000 7 4006 900 370 130 9 Compliant
SCM/2022/88NERSA determination Eskom Holdings SOC — bulk supply Tariff 649 000649 000 Compliant
SCM/2023/15CPI-linked Ikhaya Protection Services 26 400 26 400 17 60016 200 1 400 41 Late payment
SCM/2024/22Fixed price Metrix Revenue Protection 18 000 18 000 9 0008 100 900 36 Late payment

Commitment run-off

CPI assumption 5.2% p.a.
023/41
024/07
024/12
024/19
025/03
023/15
024/22
Awarded value Certified to date Extrapolated final spend
Forecast final spend across the register is R429 583k against R416 400k awarded — R13 183k above the awarded values. Escalation is applied only to the uncertified remainder of CPI-linked contracts at 5.2% for the remaining term, so a fixed-price contract forecasts at its committed value and nothing more. The Eskom bulk-supply agreement is in the register but out of these totals: it is a NERSA tariff determination, not an awarded value, so there is nothing to measure a variation against.

Payment ageing — MFMA s65(2)(e)

30-day test on certified, non-retention balances
Ageing bandBalance R'000ContractsResult
Current — 30 days or less4 2904 Compliant
31 to 60 days2 3002 Breach
61 to 90 days1 1951 Breach
More than 90 days00 Nil
Total certified and unpaid, excluding retention7 7857 3 495 overdue

Creditor payment days across the whole ledger are 63.6, measured as trade and other payables divided by daily expenditure. The 30-day test above runs only on certified balances with retention stripped out, because retention is contractually withheld and is not a late payment. The R3 495k that fails the test is what feeds finding AG-25-03 and control CTL-13.

Variance analysis and extrapolation

MFMA s116(3) variation limit 20% · s65(2)(e) 30 days
MeasureHow it is derivedValueThresholdResult
Commitment against awardOrders raised less the value formally awarded7 900NilBreach
Largest single variationForecast final spend on one contract against its award18.2%20%Within tolerance
Certified against committedWork certified as a share of what is committed50.2%Information
Balance of commitmentCommitted but not yet certified — the call on future budgets211 400Information
Payment beyond 30 daysCertified, non-retention balances older than 30 days3 495NilBreach
Posting as Chief Financial Officer.

Cascade — where contract movement lands

DestinationMechanismMovement
Statement of financial position — trade and other payablesCertified less paid less retentionR7 785k of R182 000k
Ratios — creditor payment daysPayables ÷ daily expenditure63.6 days
Cash flow — liquidityReleasing the overdue balance reduces cash−3 495
Governance and controls — CTL-12 and CTL-13Over-commitment and ageing tests2 failing
Audit findings — AG-25-02 and AG-25-03Raised automatically from the two failing testsR11 395k
Reports and outputs — s116(3) contract reportVariations above the delegation are reportable to council1 item(s)
Bankability engine — uncommitted fiscal capacityCommitted but uncertified reduces headroom−211 400
Project risk — contract to project linkCertified value is the progress measure used by the projects5 linked

Audit findings

The Auditor-General and internal audit registers held in one place and tested the same way every time. A finding closes when the control that caused it passes its automated test and the corrected figure is in the ledger — not when someone marks it done. The projected audit outcome falls straight out of unresolved misstatement measured against materiality.

Projected audit outcome
Qualified opinion
Unresolved misstatement R29 217k against materiality R10 449k
Open findings
10
1 in progress · 2 resolved
Repeat findings
5
A repeat finding is the one the AG reports on hardest
Controls passing
3 of 14
A finding closes when its control passes
The year-end view is the full register the AG will report on, tested against annual materiality. The projected opinion below is arithmetic, not a forecast of the auditor's mood.

Projected audit outcome

Year-end audit outcome · materiality R10 449k
TestBasisValue R'000Result
Materiality1% of total expenditure of R1 044 870k10 449Benchmark
Unresolved material misstatementOpen and in-progress findings classified as misstatement29 217 Above materiality
Unresolved compliance findingsFindings against the MFMA, the MSA or the VAT Act4 Reportable
Unresolved internal control deficienciesRaised by internal audit or by a failing automated test3 Reportable
Projected outcomeMisstatement above materiality qualifies the opinion; anything else unresolved gives findings 18 768Qualified opinion

Findings register

11 open of 13 in scope
RefFindingClassificationR'000 Linked controlOwnerAgeStatus
AG-25-01AGSA Certified capital work in progress expensed to repairs and maintenanceSource: Project risk — certified value on CP-01 and CP-04 against amounts capitalised Material misstatement7 200 CTL-11Test failing Chief Financial OfficerRaised 18 Nov 2024 2 yrs OpenClick to expand
AG-25-02AGSA Contract variation on SCM/2024/19 exceeds the delegated threshold without prior approvalSource: Contract monitor — committed value against awarded value Compliance — MFMA s116(3)7 900 CTL-12Test failing Supply Chain ManagerRaised 06 Feb 2025 1 yr OpenClick to expand
AG-25-03AGSA Certified creditors settled beyond 30 daysSource: Contract monitor — payment ageing on certified non-retention balances Compliance — MFMA s65(2)(e)3 495 CTL-13Test failing Chief Financial OfficerRaised 18 Nov 2024 3 yrs OpenClick to expand
AG-25-04AGSA Conditional grant revenue recognised ahead of qualifying expenditureSource: Project risk — CP-04 drawdown against certified expenditure Material misstatement8 600 CTL-14Test failing Chief Financial OfficerRaised 14 Mar 2025 1 yr OpenClick to expand
AG-25-05AGSA Debt impairment provision not supported by a debtors ageing analysisSource: Governance and controls — automated ageing reconciliation Material misstatement4 100 CTL-05Test failing Revenue ManagerRaised 18 Nov 2024 2 yrs OpenClick to expand
IA-25-06Internal audit Net-asset movement reconciliation variance not clearedSource: Governance and controls — movement check on the statement of changes in net assets Internal control2 340 CTL-04Test failing Chief AccountantRaised 22 Jan 2025 1 yr OpenClick to expand
IA-25-07Internal audit Loan drawdown exceeds project expenditure — borrowed cash held idle at a negative carrySource: Project risk — CP-02 funding drawn against expenditure incurred Internal control9 000 CTL-15Test failing Treasury ManagerRaised 09 Apr 2025 1 yr OpenClick to expand
IA-25-09Internal audit Indigent register not reconciled to the billing masterfileSource: Indigent Register Reconciliation module Internal controlNot quantified CTL-16Test failing Revenue ManagerRaised 22 Jan 2025 1 yr OpenClick to expand
MPAC-25-10MPAC Consequence management register incomplete for prior-year irregular expenditureSource: Governance and controls — consequence management register Compliance — MFMA s32Not quantified CTL-17Test failing Municipal ManagerRaised 30 Oct 2024 2 yrs In progressClick to expand
AG-25-12AGSA VAT control account does not agree to the computed net VAT for the periodSource: Journals and ledgers — VAT control reconciliation Material misstatement9 317 CTL-19Test failing Chief Financial OfficerRaised 14 Mar 2025 1 yr OpenClick to expand
AG-25-13AGSA Mandatory procurement evidence absent or expired on the tender filesSource: Tender evidence vault — completeness and validity test Compliance — SCM Regulation 16A / Treasury Instruction 09Not quantified CTL-20Test failing Supply Chain ManagerRaised 06 Feb 2025 2 yrs OpenClick to expand

Unresolved misstatement against materiality

1% of expenditure
Misstatement
Compliance
Control
Unresolved misstatement Resolved this period Materiality line
Unresolved misstatement of R29 217k sits R18 768k above the materiality line. Clearing AG-25-01 and AG-25-04 alone would bring it to R13 417k and move the projected outcome off a qualification.

Repeat findings and the control that would stop them

Deterministic close test
FindingYearsAutomated test that closes itTest result
AG-25-01 Certified capital work in progress expensed to repairs and maintenance2Certified capital work is capitalised, not expensed Failing
AG-25-03 Certified creditors settled beyond 30 days3Certified creditors settled within 30 days Failing
AG-25-05 Debt impairment provision not supported by a debtors ageing analysis2Debt impairment agrees to the debtors ageing analysis Failing
AG-24-08 Input VAT claimed on non-qualifying supplies2Input VAT claimed only on qualifying supplies Passing
MPAC-25-10 Consequence management register incomplete for prior-year irregular expenditure2Consequence management register is complete Failing
AG-25-13 Mandatory procurement evidence absent or expired on the tender files2Every mandatory procurement evidence item is on file and in date Failing

Remediation

Posting the correction is what clears the finding

Two of the three material misstatements close with a journal rather than a memo. Posting them here writes to the same ledger the payroll, billing and batch actions write to, so the correction flows through the trial balance, the AFS workbook, the ratios and the credit model in one pass.

FindingCorrecting entryR'000Effect on the opinion
AG-25-01 capital work expensedDebit property, plant and equipment · credit repairs and maintenance 7 200Removes it from unresolved misstatement
AG-25-04 grant recognised earlyDebit conditional grant revenue · credit unspent conditional grants 8 600Removes it from unresolved misstatement
Net effect on the surplusExpenditure down, revenue down (1 400)Still qualified until posted
Posting as Chief Financial Officer.

Cascade — where a cleared finding lands

DestinationMechanismMovement
Governance and controls — control healthTests moving from failing to passing3 of 14
Statement of financial position — PPECapital work correctly capitalised+7 200
Statement of financial performance — repairs and maintenanceCapital work removed from operating expenditure−7 200
Statement of financial performance — conditional grantsRevenue reversed to the unspent liability−8 600
Strategy and performance — ratiosSurplus margin and asset-renewal ratios recalculateRecalculated
Credit worthiness — governance scoreThe audit outcome is a scored rating inputQualified opinion
Reports and outputs — audit action planClosed findings leave the plan2 closed
CFO dashboard — assurance indicatorDriven by the projected outcomeRed

Risk management

Everything that could go wrong, sized in rands and tested against the same ledger. Stress levers for the shocks that hit inside a budget year, a tunable library of the nine that actually happen to South African municipalities, and a long-run view of the slower risks — an asset base being consumed faster than it is replaced, a tariff path outrunning what households can pay, debt taken on for assets that will not last as long as the loan.

Shock risk — inside the budget yearCollection, tariff, volume, wage and rate shocks. Measured in rands off the surplus and days off the cash box.
Structural risk — three to ten yearsAsset consumption, renewal funding, tariff affordability and debt capacity. These cannot be fixed inside one budget.
Levers moved
0
Showing the approved budget as tabled
Operating surplus
R449 276k
Base R449 276k · movement R0k
Days cash on hand
474
Base 474 days · norm 30 days
Credit profile under the scenario
A
Unchanged from A · issuer rating BB-

Levers

REVENUE
Collection rate98.1%
-12ptsbase+2pts
Households and businesses that are billed but do not pay. The single largest controllable variable in most South African municipalities.
Electricity sales volume0%
-25%base+5%
Grid defection by large users and rooftop solar. Volume leaves, the bulk purchase obligation and the network cost largely do not.
Water volume — restriction0%
-35%base+5%
Drought restrictions cut billable volume while the fixed cost of the network and the bulk supply agreement continue.
Equitable share allocation0%
-15%base+5%
A national fiscal consolidation reduces the unconditional transfer. Constitutionally protected in principle, reduced in practice through the formula.
Conditional grant reversed0%
0%base+100%
Unspent conditional grant surrendered to the National Revenue Fund under section 22 of the Division of Revenue Act.
COST
Bulk cost above tariff increase0.0 pts
0ptsbase+10pts
The gap between the NERSA bulk increase and the retail tariff the council approves. Every point is margin the municipality absorbs.
Wage settlement above budget0.0 pts
0ptsbase+10pts
The SALGBC agreement is concluded nationally and is not optional for the municipality.
Interest rate movement725 bps
-200bpsbase+500bps
Applies to the floating portion of the debt book and to new borrowing drawn in the period.
Impairment on receivables0.0 pts
0ptsbase+20pts
A rise in the provision against debtors. Non-cash, but it destroys the surplus and signals that billed revenue will not arrive.
DELIVERY
Repairs and maintenance0%
-50%base+60%
Deferring maintenance protects cash now and adds to the renewal backlog. The saving is real and so is the consequence.
Capital programme delivery100%
-60%base+20%
Under-delivery preserves cash but forfeits grant, slows the asset base and is itself an audit and grant-compliance risk.
Unforeseen event costR0k
0base+150000
A declared disaster, a major plant failure or an adverse judgment. Sixty per cent typically lands on the municipality.

Result under the current lever settings

FY2025 · 0 lever(s) applied
LineBaseStressedMovementEffect
Total revenue 1 494 1461 494 146 No change
Cash revenue collected collection-rate lever962 582962 582+0No change
of which lost to volume and transfer cuts No change
Total expenditure 1 044 8701 044 870 No change
bulk purchase margin No change
wage settlement No change
finance cost No change
impairment No change
maintenance No change
unforeseen event No change
Operating surplus 449 276449 276 No change
Cash and equivalents 1 194 0111 194 011 No change
Free cash after restricted grants 1 170 0111 170 011 No change
Capital programme 162 600162 600 No change
Collection 98.1% → 98.1%Billed revenue remains accrued under GRAP; cash receipts rise by R0k. The cash, debtor days, liquidity and credit profile recalculate immediately.
R962 581,7k
Collection 98.1% → 98.1%Billed revenue remains accrued under GRAP; cash receipts rise by R0k. The cash, debtor days, liquidity and credit profile recalculate immediately.
R962 581,7k

No lever has been moved, so this is the approved budget. Move one on the left, or load a scenario from the library.

Cash runway under stress

Hover a month to read the balance
M1
M2
M3
M4
M5
M6
M7
M8
M9
M10
M11
M12
Base case cashStressed cashOne month of operating cost
Hover a month to read the closing cash balance and the days of cover it represents.

Which lever did the damage

Contribution to the change in the surplus
LeverSettingEffect on surplusShare of the total move
No lever has been moved.
One scenario, every consequence. The tiles below are grouped by where the damage lands. Each is computed from the same stressed figures — nothing is estimated independently, so the tiles cannot disagree with each other.

Operating account

Total revenue
R1 494 146k
no change
base R1 494 146k
Total expenditure
R1 044 870k
no change
base R1 044 870k
Operating surplus
R449 276k
no change
base R449 276k
Surplus margin
30.1%
no change
base 30.1%

Liquidity

Free cash
R1 170 011k
no change
base R1 170 011k
Days cash on hand
474 d
no change
base 474 d
Cost coverage
15.59x
no change
base 15.59x
Liquidity cover of debt service
16.71x
no change
base 16.71x

Debt and covenant

Debt to operating revenue
38.6%
no change
base 38.6%
Debt service cover
8.26x
no change
base 8.26x
Interest bill
R26 000k
no change
base R26 000k

Service delivery and assets

Capital programme
R162 600k
no change
base R162 600k
Renewal to depreciation
1.58x
no change
base 1.58x
Maintenance to infrastructure
3.1%
no change
base 3.1%

Revenue quality

Collection rate
98.1%
no change
base 98.1%
Impairment to receivables
15.9%
no change
base 15.9%

Credit

Individual credit profile
2.47
no change
base 2.47

Rating

Stand-alone credit profile
A
no change
issuer rating BB- — pinned at the ceiling
Indicative coupon
10.80%
unchanged
on R527 000k of debt

Effect on the credit rating

The scenario re-scores the rating model directly
Rating factorBase scoreUnder the scenarioDriver of the change
Economy4.004.00 Not affected by the levers currently moved
Financial management3.873.87 Not affected by the levers currently moved
Budgetary flexibility2.502.50 Not affected by the levers currently moved
Budgetary performance1.001.00 Not affected by the levers currently moved
Liquidity1.001.00 Not affected by the levers currently moved
Debt burden1.501.50 Not affected by the levers currently moved
Contingent liabilities2.002.00 Not affected by the levers currently moved

The scenario does not "estimate" a rating impact. It recomputes the same ratios the rating model reads, re-scores each factor off the same published bands, and runs the matrix again. If the rating does not move, it is because the stressed ratios still fall inside their existing bands — not because the effect was judged immaterial.

Nine risks that actually happen, each one tunable. Pick a scenario on the left and it loads as a set of lever positions — the same twelve levers as the stress tab, no separate machinery. The severity dial scales the whole set at once, so you can ask what a half-strength drought or a one-and-a-half-times wage settlement does without re-entering anything. Push it to the lever tab when you want to adjust one lever on its own.

Pick a risk

Severity

The base case has nothing to scale. Pick a risk above and the dial becomes live.

Base case

Reference position
The approved budget executed as tabled.
Lever set by this scenarioSettingPositionWhy this scenario moves it
The base case moves no lever. It is the approved budget executed as tabled.
Sending it lets you adjust any single lever on its own and keeps everything else where this scenario put it.

What it does

Base case at 100%
MeasureBaseUnder this riskMovementSeverity of the effect
Operating surplusR449 276kR449 276k Absorbed
Free cashR1 170 011kR1 170 011k Absorbed
Days cash on hand474 d474 d Absorbed
Months of cost covered15.59x15.59x Absorbed
Debt to operating revenue38.6%38.6% Absorbed
Debt service cover8.26x8.26x Absorbed
Capital programmeR162 600kR162 600k Absorbed
Collection rate98.1%98.1% Absorbed
Stand-alone credit profileAA Absorbed

Nothing is being stressed. Pick a risk on the left to see what it costs.

Severity response

How this risk behaves as it gets worse — hover a point
0%
25%
50%
75%
100%
125%
150%
175%
200%
Operating surplusFree cashShortfall against one month of cost
Hover a severity step to see where this risk stops being absorbable.

All nine risks compared at their stated severity

Hover a bar for the full result
Base
NERSA
Drought
Grid
National
Wage
Collection
Disaster
Perfect
Recovery
Operating surplusFree cashShortfall against one month of cost
Hover a risk to compare it with the base case.

Risk register

The same nine, ranked by what they cost
RiskWhat it representsSurplusDays cashDebt / revenueProfile
Perfect stormTariff gap, collection deterioration, a rate rise and a disaster in the same financial year. 337 021 388 39.7% BBB
Drought restrictionLevel 4 restrictions cut billable water volume by a quarter for a full year; the bulk supply take-or-pay obligation continues. 357 172 434 41.2% BBB
Grid defectionRooftop solar and wheeling take a fifth of electricity volume, concentrated in the highest-paying customer segment. 374 306 436 40.9% BBB
Disaster yearA declared storm and flood event costing R84.6 million, of which the municipality carries sixty per cent. 379 016 426 38.6% A
NERSA tariff shockThe bulk electricity increase exceeds the approved retail tariff by six points and volume falls as large users respond. 399 308 444 39.5% A
National consolidationThe equitable share is cut by eight per cent and conditional grant rollovers are not approved. 399 676 454 40.1% A
Recovery planCollection improves two points, maintenance rises to the norm and the capital programme delivers in full. 421 976 457 38.6% A
Collection collapsePayment behaviour deteriorates by eight points and the impairment provision follows it up. 428 636 442 38.6% BBB
Wage settlementThe bargaining council settles five points above the budgeted increase, backdated. 432 634 459 38.6% A
Base caseThe approved budget executed as tabled. 449 276 474 38.6% A

Ten-year trajectory

Cash and equivalents · base case
0m 1 412m 2 825m 4 237m 5 650mY1Y2Y3Y4Y5Y6Y7Y8Y9Y10
Audited baseline pathPath after the near-term scenarioThreshold
Hover a year to read both paths and the gap between them.

Year by year

The numbers behind the chart
YearY1Y2Y3Y4Y5Y6Y7Y8Y9Y10
Revenue1 576 3241 663 0221 754 4881 850 9851 952 7892 060 1922 173 5032 293 0462 419 1632 552 217
Expenditure1 109 6521 178 4501 251 5141 329 1081 411 5131 499 0271 591 9661 690 6681 795 4901 906 810
Operating balance466 672484 571502 974521 877541 276561 166581 537602 377623 674645 407
Capital spend236 404245 506254 958264 774274 967285 554296 547307 964319 821332 134
Depreciation103 194108 589114 134119 837125 707131 752137 981144 403151 028157 864
Debt535 498545 267556 279568 514581 954596 588612 407629 407647 587666 952
Cash1 535 2911 891 9332 264 2152 652 4113 056 7933 477 6213 915 1454 369 6014 841 2075 330 159
Renewal backlog2 902 6002 832 4552 759 6102 683 9612 605 3992 523 8122 439 0842 351 0942 259 7172 164 821
Days cash557645727801868928983103110751112
Structural risk is what a budget cannot fix. Six analyses, each one a projection with its inputs, its method and its metric shown. Change an assumption on the left and every analysis re-runs. Click any analysis to open the full ten-year working.

Projection assumptions

Revenue growth5.5%
0%12%
Tariff increases plus consumption growth plus transfer growth, blended. Above CPI every year is a political decision, not an assumption.
Cost growth6.2%
0%14%
Wages at the bargaining council settlement, bulk purchases at the regulator’s increase, everything else at inflation.
Population growth1.7%
0%5%
Drives service demand and dilutes net assets per head. In-migration to secondary cities runs well above national average.
Affordable tariff ceiling9.0%
3%15%
The annual increase above which collection starts falling instead of revenue rising. Set from household income growth, not from need.
Opening renewal backlogR2 970 144k
01500000
Deferred renewal already accumulated. Usually estimated from asset condition assessments rather than from the ledger.
Delivery capacity above programme40.0%
0%100%
How much more than the existing programme the municipality can actually spend in a year. This, not money, is usually the binding constraint.
Renewal funding gap over ten years
R1 446 005k
Required renewal exceeds planned spend in 10 of the ten years
Tariff increase the cost base needs
6.2%
Affordable ceiling set at 9.0% · within reach
Years to cash below one month
Beyond 10
Cash holds above one month of operating cost throughout
Debt headroom in year 10
R5 414k
Debt at 26.1% of revenue against the 45% Circular 71 ceiling

The six structural risks

Click a row for the method and the ten-year working
AnalysisThe question it answersNowYear 10ThresholdVerdict
Revenue and expenditure projectionClick for the method and the ten-year working Does the operating account still fund itself in ten years, or does the cost base outrun the revenue base?30.1%25.3% ≥ 3.0% Sustainable
How it is projected
Revenue is grown at 5.5% and expenditure at 6.2%, compounded annually from the audited base. Depreciation is recomputed each year off the closing carrying value at 4.05%, so it grows with the asset base rather than being held flat.
The metric
Operating balance as a percentage of total revenue, projected to year ten.
The gap between the two growth rates is 0.7% a year, which compounds to 7.2% over the projection. That alone is what turns a healthy margin into a structural deficit with nothing going wrong.
YearY1Y2Y3Y4Y5Y6Y7Y8Y9Y10
Revenue1 576 3241 663 0221 754 4881 850 9851 952 7892 060 1922 173 5032 293 0462 419 1632 552 217
Expenditure1 109 6521 178 4501 251 5141 329 1081 411 5131 499 0271 591 9661 690 6681 795 4901 906 810
Operating balance466 672484 571502 974521 877541 276561 166581 537602 377623 674645 407
Margin29.6%29.1%28.7%28.2%27.7%27.2%26.8%26.3%25.8%25.3%
Tariff path and affordabilityClick for the method and the ten-year working What annual increase does the cost base require, and can households carry it?5.5%6.2% ≤ 9.0% Sustainable
How it is projected
The required increase is the compound annual growth in the cost base over the projection — the rate at which own revenue would have to rise to keep pace, holding volume constant. It is compared with the affordable ceiling of 9.0%, which is set from household income growth rather than from what the municipality needs.
The metric
Compound annual tariff increase required, against the affordability ceiling.
Above the ceiling the increase stops raising revenue and starts lowering collection — households do not pay a bill they cannot afford, and the municipality books the difference as impairment instead of cash. That is the mechanism by which a tariff-led recovery plan fails.
YearY1Y2Y3Y4Y5Y6Y7Y8Y9Y10
Cost base1 109 6521 178 4501 251 5141 329 1081 411 5131 499 0271 591 9661 690 6681 795 4901 906 810
Own revenue needed728 723773 904821 886872 843926 959984 4301 045 4651 110 2841 179 1211 252 227
Required increase6.2%6.2%6.2%6.2%6.2%6.2%6.2%6.2%6.2%
Revenue per householdR13 080R13 569R14 076R14 602R15 147R15 713R16 300R16 909R17 541R18 197
Required capital against planned spendClick for the method and the ten-year working Is the renewal programme large enough to replace what is being consumed and to work off the backlog?0.41x0.73x ≥ 1.00x Not sustainable
How it is projected
Required renewal each year is depreciation — the asset consumed in the year — plus a tenth of the opening backlog, which is what a ten-year catch-up costs. Planned spend is the capital programme grown at 3.9%, plus whatever cash above ninety days can be deployed, capped at 40% above the existing programme because delivery capacity is the real constraint.
The metric
Planned renewal and maintenance divided by required renewal.
Cumulative gap over ten years is R1 446 005k. A gap here is not a budget shortfall — it is infrastructure that will fail earlier than planned, and the cost of that failure lands in unplanned maintenance, which is the most expensive money a municipality spends.
YearY1Y2Y3Y4Y5Y6Y7Y8Y9Y10
Depreciation103 194108 589114 134119 837125 707131 752137 981144 403151 028157 864
Backlog catch-up297 014297 014297 014297 014297 014297 014297 014297 014297 014297 014
Required400 208405 603411 149416 852422 722428 767434 996441 418448 042454 878
Planned236 404245 506254 958264 774274 967285 554296 547307 964319 821332 134
Gap163 804160 098156 191152 078147 754143 213138 448133 453128 221122 744
Long-term debt sustainabilityClick for the method and the ten-year working Can the debt be serviced from operations, and is it funding assets that outlast it?38.6%26.1% ≤ 45.0% Sustainable
How it is projected
New borrowing is taken at 30% of the capital programme, the existing book amortises at 8% a year, and interest runs at 8% on the closing balance. Debt is measured against total revenue and against the 45% Circular 71 ceiling. Debt service cover is the operating balance plus depreciation plus interest, over interest plus the portion falling due.
The metric
Direct debt as a percentage of operating revenue, and the headroom to the ceiling.
Borrowing for a thirty-year asset repaid over fifteen is intergenerationally fair; borrowing to cover an operating deficit is a transfer from the next council to this one. The test is not only the ratio but what the money bought — which is why this analysis is read alongside the capital one above it.
YearY1Y2Y3Y4Y5Y6Y7Y8Y9Y10
Debt535 498545 267556 279568 514581 954596 588612 407629 407647 587666 952
Debt / revenue34.0%32.8%31.7%30.7%29.8%29.0%28.2%27.4%26.8%26.1%
Interest42 84043 62144 50245 48146 55647 72748 99350 35351 80753 356
Headroom to 45%173 848203 093233 241264 429296 801330 499365 670402 464441 036481 546
Asset consumption and the backlogClick for the method and the ten-year working Is the asset base growing, holding or shrinking, and how far behind is renewal?116.6%53.2% ≤ 15.0% Not sustainable
How it is projected
The carrying value rolls forward as opening value plus capital spend less depreciation. The backlog reduces by whatever deployment capacity allows and increases whenever renewal falls short of depreciation in a year. It never self-corrects.
The metric
Renewal backlog as a percentage of the carrying value of infrastructure.
A backlog above 15% of carrying value is the level at which failure rates rise faster than the maintenance budget can absorb. The compounding works against the municipality: deferred renewal raises failures, failures raise unplanned maintenance, and unplanned maintenance takes the money that would have funded renewal.
YearY1Y2Y3Y4Y5Y6Y7Y8Y9Y10
Carrying value2 681 2102 818 1272 958 9503 103 8863 253 1463 406 9473 565 5133 729 0753 897 8684 072 139
Capital spend236 404245 506254 958264 774274 967285 554296 547307 964319 821332 134
Depreciation103 194108 589114 134119 837125 707131 752137 981144 403151 028157 864
Backlog2 902 6002 832 4552 759 6102 683 9612 605 3992 523 8122 439 0842 351 0942 259 7172 164 821
Backlog %108.3%100.5%93.3%86.5%80.1%74.1%68.4%63.0%58.0%53.2%
Generational equityClick for the method and the ten-year working Is this generation leaving more than it inherited, per resident?R7 803R17 913 ≥ R7 803 Sustainable
How it is projected
Infrastructure plus cash less debt, divided by projected population at 1.7% growth. A falling number means residents are being added faster than the asset base that serves them, whatever the absolute figures do.
The metric
Net assets per resident, in rands, at constant scope.
This is the only measure in the module that adjusts for population. A municipality can grow its asset base in rands every year and still be going backwards for the people who live in it.
YearY1Y2Y3Y4Y5Y6Y7Y8Y9Y10
Carrying value2 681 2102 818 1272 958 9503 103 8863 253 1463 406 9473 565 5133 729 0753 897 8684 072 139
Cash1 535 2911 891 9332 264 2152 652 4113 056 7933 477 6213 915 1454 369 6014 841 2075 330 159
Debt535 498545 267556 279568 514581 954596 588612 407629 407647 587666 952
Population419 004426 127433 371440 739448 231455 851463 600471 482479 497487 648
Net assets per headR8 785R9 774R10 769R11 771R12 779R13 794R14 815R15 842R16 875R17 913

Renewal need against planned spend

Hover a year
Y1
Y2
Y3
Y4
Y5
Y6
Y7
Y8
Y9
Y10
Planned renewal and maintenanceRequired — depreciation plus backlog catch-upFunding gap
Hover a year to read what renewal is required, what is planned, and the gap between them.

Verdict

2 of 6 analyses fail at year ten
Sound, with two structural pressures to manage. Failing: required capital against planned spend; asset consumption and the backlog.
What is driving itCosts at 6.2% against revenue at 5.5% — a gap of 0.7% a year compounding to 7.2% over ten years.
What would close it on the revenue sideA tariff increase of 6.2% every year for ten years. That sits inside the affordability ceiling, so it is achievable if council will carry it politically.
What would close it on the cost sideHolding cost growth 0.7% lower means holding the wage bill and bulk purchases below inflation. Neither is set by the municipality, which is why cost-side solutions are usually headcount and service level, not price.
The renewal positionCumulative funding gap of R1 446 005k over ten years, with the backlog moving from R2 970 144k to R2 164 821k. The backlog is being worked off, but the pace is set by delivery capacity rather than by money.
The honest readingNear-term liquidity and long-run sustainability answer different questions and can disagree without either being wrong. Cash today is partly the product of renewal not done; the backlog that created is what the next ten years have to work off, and it can only be worked off as fast as the municipality can actually build.

Credit rating & scoring

A full sub-sovereign rating model, not a ratio summary. The institutional framework and seven weighted factors build an individual credit profile; the profile and the framework combine into an indicative credit level; overrides, the sovereign ceiling and the likelihood of extraordinary support take that to an issuer rating on both the global and the national scale. Every score traces to a ratio computed from the ledger, and every threshold is shown.

Stand-alone credit profile
A
What the municipality itself earns, before the sovereign ceiling. This is the number management can move.
Issuer credit rating
BB-
Held at the sovereign ceiling of BB- — the profile is 7 notches stronger than the rating can express
National scale
A+(ZA)
Moody’s equivalent Ba3 · what a domestic issue prices from
Individual credit profile
2.47
Weighted average of seven factors, 1 strongest to 6 weakest · framework 4.00

Where the rating sits

AA- Investment grade
A+ Investment grade
A Stand-alone credit profile — what the municipality earns on its own
A- Investment grade
BBB+ Investment grade
BBB Investment grade
BBB- Investment grade
BB+ Speculative grade
BB Speculative grade
BB- Issuer credit rating — where the municipality is actually rated
B+ Speculative grade
B Speculative grade
B- Speculative grade
CCC Substantial risk

The sovereign sits at BB-. A local government is not normally rated above its sovereign, because it depends on the same currency, the same transfer system and the same macroeconomy. The stand-alone profile of A is stronger than the sovereign, so the rating is held at the ceiling.

Rating build-up

FY2025 · Illustrative full-year
StepWhat it doesScore / levelMovement
Institutional frameworkThe legal and fiscal environment the municipality operates in, assessed on five dimensions 4.00Combined with the profile in the matrix
Individual credit profileWeighted average of economy, financial management, budgetary flexibility, budgetary performance, liquidity, debt burden and contingent liabilities 2.47Rounds to 2
Indicative credit levelRead off the matrix at profile 2 against framework 4 ABefore any override
Stand-alone credit profileIndicative level after overrides AAbove the sovereign
Extraordinary supportLikelihood of timely support from national government if the municipality could not service its debt LowNo uplift — there is no standing bailout mechanism
Issuer credit ratingAfter the sovereign ceiling test BB-Capped

What supports the rating

Budgetary performance — 1.00
The level and volatility of the operating and after-capital balances.
Operating balance 30.1%Balance after capital account 26.1%
Liquidity — 1.00
Cash and committed facilities against debt service falling due, and the ability to raise more at short notice.
Free cash 474 daysFree cash 16.71x
Debt burden — 1.50
The stock of direct debt and the cost of carrying it, both relative to operating revenue.
Direct debt 38.6%Interest 1.9%

What constrains it

Economy — 4.00
How the local economic base supports revenue generation and spending need over the medium term.
Gross regional product per head (R) R64 200Unemployment rate 31.4%Share of output in the three largest sectors 58.0%
Financial management — 3.87
Whether the administration can execute a budget, collect what it bills, maintain what it owns and produce an auditable account of it.
Collection rate on billed revenue 98.1%Debt impairment charge 15.9%Repairs and maintenance 3.1%
Budgetary flexibility — 2.50
How much revenue could be raised or spending deferred at short notice without breaking service obligations.
Own revenue 69.9%Capital spend 13.5%

Factor contribution to the individual credit profile

Hover a bar to read the factor
Economy
Financial
Budgetary
Budgetary
Liquidity
Debt
Contingent
Weighted score contributionWeight as a share of the profile
Hover a factor to see its score, its weight and the ratios behind it.

Outlook and the triggers that would move it

Outlook Stable, weakening — no downgrade trigger is close
DirectionTriggerCurrentThresholdDistance
Upgrade A clean or unqualified audit outcome sustained for two yearsFinancial management carries a penalty of 1.2 scoring points while the opinion is qualified opinionQualified opinionUnqualified
Upgrade Collection sustained above 97%Feeds financial management and, through cash, liquidity98.1%97.0% 1.1%
Downgrade Free cash below 30 days of operating costWould take liquidity to a 4 and trigger the liquidity override at 0.8 times debt service474 d30 d 444 d
Downgrade Direct debt above 60% of operating revenueMoves the debt burden factor from 3 to 438.6%60.0% 21.4%
Downgrade Operating balance turns negativeBudgetary performance falls from 1 to 4 in a single step30.1%0.0% 30.1%
Downgrade A downgrade of the Republic of South AfricaThe ceiling moves with the sovereign whatever the municipality doesBB-BB- Binding now
Read this as the model, not the answer. Each factor is scored from 1 (extremely strong) to 6 (extremely weak) off named ratios with published bands. The individual credit profile is the weighted average. Click any factor row to open its sub-metrics.

Institutional framework

Assessed separately and combined with the profile in the matrix
DimensionAssessmentScore
Predictability of the frameworkLocal government powers, functions and the equitable share formula are constitutionally entrenched under sections 214 and 227, and the division of revenue is legislated annually. Changes are signalled well in advance. 3
Revenue and expenditure balanceOwn revenue powers over rates and trading services are real, but the cost of bulk purchases is set by a national regulator and the wage bill by a national bargaining council. The municipality carries cost it does not set. 4
Transparency and accountabilityThe MFMA imposes a strong reporting regime with audited statements, in-year reporting and a statutory audit. Enforcement of consequence management is weak and inconsistently applied. 4
System of support and interventionSections 139 and 216 provide for provincial and national intervention, but there is no standing bailout mechanism and recent practice has been to impose conditions rather than to fund. 5
Capacity to manage debtMunicipal borrowing is regulated under MFMA chapter 6 with no national guarantee. Market access exists for the larger entities and is thin below metro scale. 4
Institutional framework assessmentSimple average of the five dimensions4.00

Individual credit profile — seven weighted factors

Profile 2.47 · rounds to 2
FactorWeightKey measuresScoreBandWeighted
EconomyClick to open the sub-metrics 20%Gross regional product per head (R), Unemployment rate, Share of output in the three largest sectors 4.00Adequate0.800
How the local economic base supports revenue generation and spending need over the medium term.
Sub-metricValueScoreBand it falls in
Gross regional product per head (R)R64 200 4R55 000 to R75 000
Unemployment rate31.4% 427% to 33%
Share of output in the three largest sectors58.0% 455% to 65%
Financial managementClick to open the sub-metrics 20%Collection rate on billed revenue, Debt impairment charge, Repairs and maintenance 3.87Adequate0.773
Whether the administration can execute a budget, collect what it bills, maintain what it owns and produce an auditable account of it.
Sub-metricValueScoreBand it falls in
Collection rate on billed revenue98.1% 1≥ 97%
Debt impairment charge ÷ receivables15.9% 310% to 16%
Repairs and maintenance ÷ carrying value of infrastructure3.1% 43% to 4%
Audit outcome penaltyQualified opinion +1.2Applied to the averaged sub-metric score
Budgetary flexibilityClick to open the sub-metrics 10%Own revenue, Capital spend 2.50Very strong0.250
How much revenue could be raised or spending deferred at short notice without breaking service obligations.
Sub-metricValueScoreBand it falls in
Own revenue ÷ total revenue69.9% 265% to 80%
Capital spend ÷ total spend — room to defer13.5% 312% to 18%
Budgetary performanceClick to open the sub-metrics 10%Operating balance, Balance after capital account 1.00Extremely strong0.100
The level and volatility of the operating and after-capital balances.
Sub-metricValueScoreBand it falls in
Operating balance ÷ operating revenue30.1% 1≥ 15%
Balance after capital account ÷ total revenue26.1% 1≥ 5%
LiquidityClick to open the sub-metrics 20%Free cash, Free cash 1.00Extremely strong0.200
Cash and committed facilities against debt service falling due, and the ability to raise more at short notice.
Sub-metricValueScoreBand it falls in
Free cash ÷ daily cash operating cost474 days 1≥ 120
Free cash ÷ debt service falling due in 12 months16.71x 1≥ 3.0x
Debt burdenClick to open the sub-metrics 10%Direct debt, Interest 1.50Extremely strong0.150
The stock of direct debt and the cost of carrying it, both relative to operating revenue.
Sub-metricValueScoreBand it falls in
Direct debt ÷ operating revenue38.6% 230% to 45%
Interest ÷ operating revenue1.9% 1≤ 2%
Contingent liabilitiesClick to open the sub-metrics 10%Contingent liabilities 2.00Very strong0.200
Guarantees, entity exposures, bulk-supplier arrears and litigation that could become direct obligations.
Sub-metricValueScoreBand it falls in
Contingent liabilities ÷ operating revenue4.5% 22% to 5%
Individual credit profile100% 2.47Rounds to 22.473

The matrix — profile against framework

Indicative credit level before overrides
Profile ↓ / Framework →123456
1AAAAA+AAAA-A+A
2AAAA-A+AA-BBB
3A+AA-BBBBB+BB
4A-BBBBBB-BB+BBBB-
5BBB-BB+BBBB-B+B
6BBBB-B+BB-CCC

A profile of 2.47 rounds to 2; the framework assessment of 4.00 rounds to 4. The cell where they meet gives an indicative credit level of A. The framework matters more at weaker profiles than at stronger ones, which is why the rows are not evenly spaced — a strong municipality in a poor framework is still constrained, but a weak one in a strong framework has somewhere to fall back to.

Every ratio in the model

Click a row for the formula, the inputs and the band table
MeasureFactorValueScorePosition in bandAssessment
Operating balance ÷ operating revenueClick for the formula and the band table Budgetary performance30.1%1
≥ 15%
Extremely strong
How it is computed
Formula(total revenue − total expenditure) ÷ total revenue
Value30.1%
DirectionHigher is stronger
Factor it scoresBudgetary performance — weight 10%
Score1 — ≥ 15%
Band table
ScoreThresholdAssessment
1≥ 15% Extremely strong
28% to 15% Very strong
33% to 8% Strong
40% to 3% Adequate
5-5% to 0% Weak
6< -5% Extremely weak
Balance after capital account ÷ total revenueClick for the formula and the band table Budgetary performance26.1%1
≥ 5%
Extremely strong
How it is computed
Formula(operating balance + depreciation − capital spend) ÷ total revenue
Value26.1%
DirectionHigher is stronger
Factor it scoresBudgetary performance — weight 10%
Score1 — ≥ 5%
Band table
ScoreThresholdAssessment
1≥ 5% Extremely strong
20% to 5% Very strong
3-5% to 0% Strong
4-10% to -5% Adequate
5-20% to -10% Weak
6< -20% Extremely weak
Free cash ÷ daily cash operating costClick for the formula and the band table Liquidity474 days1
≥ 120
Extremely strong
How it is computed
Formula(cash − unspent conditional grants) ÷ ((expenditure − depreciation − impairment) ÷ 365)
Value474 days
DirectionHigher is stronger
Factor it scoresLiquidity — weight 20%
Score1 — ≥ 120
Band table
ScoreThresholdAssessment
1≥ 120 Extremely strong
290 to 120 Very strong
360 to 90 Strong
430 to 60 Adequate
515 to 30 Weak
6< 15 Extremely weak
Free cash ÷ debt service falling due in 12 monthsClick for the formula and the band table Liquidity16.71x1
≥ 3.0x
Extremely strong
How it is computed
Formula(cash − unspent grants) ÷ (interest + current portion of borrowing)
Value16.71x
DirectionHigher is stronger
Factor it scoresLiquidity — weight 20%
Score1 — ≥ 3.0x
Band table
ScoreThresholdAssessment
1≥ 3.0x Extremely strong
21.8x to 3.0x Very strong
31.2x to 1.8x Strong
40.8x to 1.2x Adequate
50.4x to 0.8x Weak
6< 0.4x Extremely weak
Direct debt ÷ operating revenueClick for the formula and the band table Debt burden38.6%2
30% to 45%
Very strong
How it is computed
Formula(long-term borrowing + current portion) ÷ (total revenue − conditional grants)
Value38.6%
DirectionLower is stronger
Factor it scoresDebt burden — weight 10%
Score2 — 30% to 45%
Band table
ScoreThresholdAssessment
1≤ 30% Extremely strong
230% to 45% Very strong
345% to 60% Strong
460% to 90% Adequate
590% to 120% Weak
6> 120% Extremely weak
Interest ÷ operating revenueClick for the formula and the band table Debt burden1.9%1
≤ 2%
Extremely strong
How it is computed
Formulafinance cost ÷ (total revenue − conditional grants)
Value1.9%
DirectionLower is stronger
Factor it scoresDebt burden — weight 10%
Score1 — ≤ 2%
Band table
ScoreThresholdAssessment
1≤ 2% Extremely strong
22% to 4% Very strong
34% to 6% Strong
46% to 9% Adequate
59% to 13% Weak
6> 13% Extremely weak
Own revenue ÷ total revenueClick for the formula and the band table Budgetary flexibility69.9%2
65% to 80%
Very strong
How it is computed
Formula(total revenue − equitable share − conditional grants) ÷ total revenue
Value69.9%
DirectionHigher is stronger
Factor it scoresBudgetary flexibility — weight 10%
Score2 — 65% to 80%
Band table
ScoreThresholdAssessment
1≥ 80% Extremely strong
265% to 80% Very strong
350% to 65% Strong
435% to 50% Adequate
520% to 35% Weak
6< 20% Extremely weak
Capital spend ÷ total spend — room to deferClick for the formula and the band table Budgetary flexibility13.5%3
12% to 18%
Strong
How it is computed
Formulacapital spend ÷ (operating expenditure + capital spend)
Value13.5%
DirectionHigher is stronger
Factor it scoresBudgetary flexibility — weight 10%
Score3 — 12% to 18%
Band table
ScoreThresholdAssessment
1≥ 25% Extremely strong
218% to 25% Very strong
312% to 18% Strong
48% to 12% Adequate
54% to 8% Weak
6< 4% Extremely weak
Collection rate on billed revenueClick for the formula and the band table Financial management98.1%1
≥ 97%
Extremely strong
How it is computed
Formulacash collected ÷ revenue billed
Value98.1%
DirectionHigher is stronger
Factor it scoresFinancial management — weight 20%
Score1 — ≥ 97%
Band table
ScoreThresholdAssessment
1≥ 97% Extremely strong
294% to 97% Very strong
390% to 94% Strong
485% to 90% Adequate
575% to 85% Weak
6< 75% Extremely weak
Gross regional product per head (R)Click for the formula and the band table EconomyR64 2004
R55 000 to R75 000
Adequate
How it is computed
Formulagross regional product ÷ population
ValueR64 200
DirectionHigher is stronger
Factor it scoresEconomy — weight 20%
Score4 — R55 000 to R75 000
Band table
ScoreThresholdAssessment
1≥ R140 000 Extremely strong
2R100 000 to R140 000 Very strong
3R75 000 to R100 000 Strong
4R55 000 to R75 000 Adequate
5R35 000 to R55 000 Weak
6< R35 000 Extremely weak
Unemployment rateClick for the formula and the band table Economy31.4%4
27% to 33%
Adequate
How it is computed
Formulaunemployed ÷ economically active population
Value31.4%
DirectionLower is stronger
Factor it scoresEconomy — weight 20%
Score4 — 27% to 33%
Band table
ScoreThresholdAssessment
1≤ 12% Extremely strong
212% to 20% Very strong
320% to 27% Strong
427% to 33% Adequate
533% to 40% Weak
6> 40% Extremely weak
Share of output in the three largest sectorsClick for the formula and the band table Economy58.0%4
55% to 65%
Adequate
How it is computed
Formulaoutput of the three largest sectors ÷ total output
Value58.0%
DirectionLower is stronger
Factor it scoresEconomy — weight 20%
Score4 — 55% to 65%
Band table
ScoreThresholdAssessment
1≤ 35% Extremely strong
235% to 45% Very strong
345% to 55% Strong
455% to 65% Adequate
565% to 75% Weak
6> 75% Extremely weak
Contingent liabilities ÷ operating revenueClick for the formula and the band table Contingent liabilities4.5%2
2% to 5%
Very strong
How it is computed
Formula(guarantees + litigation + 15% of employee benefit obligation) ÷ operating revenue
Value4.5%
DirectionLower is stronger
Factor it scoresContingent liabilities — weight 10%
Score2 — 2% to 5%
Band table
ScoreThresholdAssessment
1≤ 2% Extremely strong
22% to 5% Very strong
35% to 10% Strong
410% to 18% Adequate
518% to 30% Weak
6> 30% Extremely weak
Debt impairment charge ÷ receivablesClick for the formula and the band table Financial management15.9%3
10% to 16%
Strong
How it is computed
Formuladebt impairment charge ÷ (exchange + non-exchange receivables)
Value15.9%
DirectionLower is stronger
Factor it scoresFinancial management — weight 20%
Score3 — 10% to 16%
Band table
ScoreThresholdAssessment
1≤ 5% Extremely strong
25% to 10% Very strong
310% to 16% Strong
416% to 24% Adequate
524% to 35% Weak
6> 35% Extremely weak
Repairs and maintenance ÷ carrying value of infrastructureClick for the formula and the band table Financial management3.1%4
3% to 4%
Adequate
How it is computed
Formularepairs and maintenance ÷ carrying value of property, plant and equipment
Value3.1%
DirectionHigher is stronger
Factor it scoresFinancial management — weight 20%
Score4 — 3% to 4%
Band table
ScoreThresholdAssessment
1≥ 8% Extremely strong
26% to 8% Very strong
34% to 6% Strong
43% to 4% Adequate
52% to 3% Weak
6< 2% Extremely weak

From indicative level to issuer rating

Each step is a named adjustment with a stated reason
AdjustmentBasisNotchesLevel afterApplied
Indicative credit level Matrix outcome at profile 2 against framework 4 AStarting point
Liquidity override Free cash below 0.8 times debt service falling due within twelve monthsMeasured: 16.71x against 0.80x 0ANot triggered
Debt override Direct debt above 120% of operating revenueMeasured: 38.6% against 120% 0ANot triggered
Budgetary override Operating balance negative in two consecutive yearsMeasured: 30.1% — positive 0ANot triggered
Financial management override A disclaimer or adverse audit opinionMeasured: Qualified opinion 0ANot triggered
Exceptional single-asset concentration More than half of own revenue from one payer or one sectorMeasured: Largest single payer below the threshold 0ANot triggered
Stand-alone credit profile After every override ASACP
Sovereign ceiling A local government is not normally rated above the Republic of South Africa CappedBB-Binding

Sovereign ceiling test

Sovereign foreign-currency ratingBB-
Moody’s equivalentBa2
Stand-alone credit profileA
Ceiling appliedYes

A municipality can in principle be rated above its sovereign where it has no exposure to sovereign transfer, no foreign-currency obligation and could survive a sovereign stress. None of those hold here: 30.1% of revenue arrives through the Division of Revenue Act, and the bulk suppliers are themselves state-owned. The cap is therefore binding and the rating moves with the sovereign.

Extraordinary support

Legal obligation on national governmentNone. MFMA section 45 is explicit that national government does not guarantee municipal debt.
Intervention mechanismConstitution section 139 and MFMA chapter 13 provide for intervention and a financial recovery plan, not for funding.
Track recordRecent practice has been conditional support and administration rather than a cash bailout.
Systemic importanceModerate. Not a metropolitan municipality; failure would be contained regionally.
Uplift appliedNone

This is the single largest difference between rating a municipality and rating a state-owned company. A guarantee moves a rating several notches; a recovery plan moves it none, because it restructures obligations rather than honouring them on time.

Scale mapping

The same credit expressed three ways
ScaleAgency conventionRatingOutlookWhat it means for pricing
Global long-termS&P convention, comparable across countriesBB-Stable, weakeningWhat an offshore lender or a development finance institution prices from
Global equivalentMoody’s conventionBa3Stable, weakeningUsed where the counterparty mandates a Moody’s rating
National long-termRelative to the strongest credit in South AfricaA+(ZA)Stable, weakeningWhat a domestic bond issue and most bank facilities price from
National short-termTwelve months or lessA1(ZA)Commercial paper and short-dated facilities
Move a driver and watch the rating. These are the five measures with the shortest distance to a band edge. Each slider re-scores its factor, re-weights the profile, re-runs the matrix and re-applies the ceiling. Nothing else in the model is touched.

Drivers

Collection rate on billed revenue98.1%
45.0%base 98.1%122.6%
Scores financial management at a weight of 20%. Currently a 1.
Free cash ÷ daily cash operating cost474 days
9 daysbase 474 days593 days
Scores liquidity at a weight of 20%. Currently a 1.
Direct debt ÷ operating revenue38.6%
0.0%base 38.6%156.0%
Scores debt burden at a weight of 10%. Currently a 2.
Operating balance ÷ operating revenue30.1%
-3.0%base 30.1%37.6%
Scores budgetary performance at a weight of 10%. Currently a 1.
Repairs and maintenance ÷ carrying value of infrastructure3.1%
0.9%base 3.1%10.0%
Scores financial management at a weight of 20%. Currently a 4.
Stand-alone profile
A
Unchanged from A
Issuer credit rating
BB-
Still held at the sovereign ceiling
Individual credit profile
2.47
Base 2.47
Indicative coupon
10.80%
Repo plus 355 basis points

Rating under the adjusted drivers

No driver adjusted
FactorBase scoreAdjustedWeightMovement
Economy4.004.00 20%Unchanged
Financial management3.873.87 20%Unchanged
Budgetary flexibility2.502.50 10%Unchanged
Budgetary performance1.001.00 10%Unchanged
Liquidity1.001.00 20%Unchanged
Debt burden1.501.50 10%Unchanged
Contingent liabilities2.002.00 10%Unchanged

The adjusted drivers move the profile from 2.47 to 2.47 but not far enough to change the rounded profile, so the matrix outcome holds. Ratings are step functions: a driver can deteriorate a long way inside a band and change nothing, then move one basis point past the edge and cost a notch.

Note on the ceiling. The stand-alone profile of A sits 7 notches above the sovereign, so the issuer rating stays pinned at BB- whatever these drivers do. That is not a flaw in the model — it is the most important single fact about municipal borrowing in a sub-investment-grade sovereign. Management can move the profile and should, because the profile is what determines the margin a lender charges over the sovereign benchmark and whether a development finance institution will lend at all; it cannot move the ceiling.

Cost of the rating

Spread by notch on a ten-year amortising facility
BBB-
BB+
BB
BB-
B+
B
B-
Indicative all-in couponCurrent ratingAdjusted rating
Hover a rating to see the indicative coupon and the annual interest bill at the current debt stock.

South African municipal comparators

Published positions, for orientation only
MunicipalityRating and scaleDebt / revenueDays cashCollectionWhat drives it
This municipalityBB- / A+(ZA) 38.6% 474 d 98.1%Strong liquidity and a positive operating balance against a weak local economy and a qualified audit outcome.
City of Cape TownBa2 (Moody’s) 11.8% Net direct and indirect debt of 11.8% of operating revenue in FY2025, among the lowest of the metropolitan municipalities, with strong liquidity buffers and consistent operating surpluses.
City of JohannesburgA(ZA) negative (GCR) 6 d Cash fell to ZAR2.2 billion in fiscal 2024 — 5.5 days of cash on hand — with weak collection and high debt. Debt securities were suspended by the JSE in March 2026 for late audited statements.
City of TshwaneCaa2 (Moody’s) Downgraded on liquidity concerns.
Republic of South AfricaBB- / Ba2 The ceiling. General government debt has been projected in the 70% to 85% of GDP range.

Comparator figures are published positions and are shown for orientation, not as an equivalent computation — each agency uses its own adjustments, and a national-scale rating from one agency is not comparable with a global-scale rating from another. The useful reading is the spread: on days of cash this municipality sits closer to Cape Town than to Johannesburg, while on the strength of its local economy it sits well below either.

This municipality against the comparator set

Hover a bar to read the position
This
Joburg
30d norm
90d strong
This municipalityComparators
Hover a municipality to compare.

How this model is built and what it is not

What this is. A sub-sovereign scorecard built on the structure the major agencies publish for local and regional governments: an institutional framework assessment held separately from an individual credit profile, the profile built from weighted factors, the two combined in a matrix, then overrides, the sovereign ceiling and the likelihood of extraordinary support.
What it is not. It is not a rating. A rating is the opinion of a committee that has met management, read the recovery plan, and formed a view on willingness as well as ability to pay. This model reproduces the measurable part of that process so a municipality can see where it stands before the committee sits, and can test what would move it.
Why municipalities are not companiesThere are no shareholders and no equity cushion, revenue is largely non-discretionary and set by tariff or formula, and the entity cannot be liquidated. Corporate scorecards weight leverage and coverage heavily because a company can be wound up; a municipality cannot, so the weight moves to liquidity and to the framework it operates in.
Why liquidity carries 20%Municipal default in practice is a cash event, not a balance-sheet event. An entity with a sound statement of financial position and no cash cannot pay Eskom on the twentieth.
Why the audit outcome scoresAn adverse or disclaimed opinion means the figures a lender relies on cannot be relied on. It is scored inside financial management rather than as a separate factor, with a penalty of 1.2 points for a qualification rising to 3.0 for a disclaimer.
Why there is no support upliftMFMA section 45 states that national government does not guarantee municipal debt. Section 139 intervention restructures obligations; it does not pay them on the due date, which is what a rating addresses.
Where judgement remainsThe institutional framework assessment, the sector-concentration input and the contingent-liability inventory are analytical inputs, not ledger outputs. They are shown as named assumptions on this tab rather than buried in a score.

Band tables

The thresholds every score is read off
Measure1 — extremely strong23456 — extremely weak
Operating balance ÷ operating revenueBudgetary performance · higher is stronger ≥ 15%8% to 15%3% to 8%0% to 3%-5% to 0%< -5%
Balance after capital account ÷ total revenueBudgetary performance · higher is stronger ≥ 5%0% to 5%-5% to 0%-10% to -5%-20% to -10%< -20%
Free cash ÷ daily cash operating costLiquidity · higher is stronger ≥ 12090 to 12060 to 9030 to 6015 to 30< 15
Free cash ÷ debt service falling due in 12 monthsLiquidity · higher is stronger ≥ 3.0x1.8x to 3.0x1.2x to 1.8x0.8x to 1.2x0.4x to 0.8x< 0.4x
Direct debt ÷ operating revenueDebt burden · lower is stronger ≤ 30%30% to 45%45% to 60%60% to 90%90% to 120%> 120%
Interest ÷ operating revenueDebt burden · lower is stronger ≤ 2%2% to 4%4% to 6%6% to 9%9% to 13%> 13%
Own revenue ÷ total revenueBudgetary flexibility · higher is stronger ≥ 80%65% to 80%50% to 65%35% to 50%20% to 35%< 20%
Capital spend ÷ total spend — room to deferBudgetary flexibility · higher is stronger ≥ 25%18% to 25%12% to 18%8% to 12%4% to 8%< 4%
Collection rate on billed revenueFinancial management · higher is stronger ≥ 97%94% to 97%90% to 94%85% to 90%75% to 85%< 75%
Gross regional product per head (R)Economy · higher is stronger ≥ R140 000R100 000 to R140 000R75 000 to R100 000R55 000 to R75 000R35 000 to R55 000< R35 000
Unemployment rateEconomy · lower is stronger ≤ 12%12% to 20%20% to 27%27% to 33%33% to 40%> 40%
Share of output in the three largest sectorsEconomy · lower is stronger ≤ 35%35% to 45%45% to 55%55% to 65%65% to 75%> 75%
Contingent liabilities ÷ operating revenueContingent liabilities · lower is stronger ≤ 2%2% to 5%5% to 10%10% to 18%18% to 30%> 30%
Debt impairment charge ÷ receivablesFinancial management · lower is stronger ≤ 5%5% to 10%10% to 16%16% to 24%24% to 35%> 35%
Repairs and maintenance ÷ carrying value of infrastructureFinancial management · higher is stronger ≥ 8%6% to 8%4% to 6%3% to 4%2% to 3%< 2%

How to use this system

Plain English, no jargon. If you have never opened MATOS before, read this page once and you will know what every tab does and what happens when you press a button.

The one idea behind the whole thing. MATOS keeps one set of numbers. Your accounting system feeds it, the workbook does the sums, and every screen you look at is a different view of those same numbers. Change something in one place and it changes everywhere, immediately, because there is only one place for it to change.

Step one — pick your mode

Top right of the screen
ButtonWhat it meansWhen you would use it
Mode 1Your own figuresYou are using this for your real municipality. The screens start empty and fill up as you load your trial balance or type your figures in. Nothing is made up for you.
Mode 2Half yearYou want the mid-year picture, as at 31 December. Everything re-cuts to that date: half the year's spending, half the year's certificates, half-year materiality. This is the view for your section 72 mid-year report.
Mode 3Full yearThe default. The whole year to 30 June. This is the view for your annual financial statements and for the audit.

Next to the mode buttons is the year — FY2023, FY2024 or FY2025 — and next to that is the role. The role decides what you are allowed to press. A CFO can post anything; a councillor on MPAC can look at everything and press nothing. Change the role and watch the buttons switch on and off.

Step two — know what each tab is for

TabIn one sentenceThe question it answers
The original MATOS tabs
Executive cockpitThe one-page summary.How are we doing, overall, right now?
System modulesA map of every part of the system.Where does this number come from, and what depends on it?
System integrationYour accounting packages — Pastel, Sage, CaseWare — and the bridge into the workbook.Is what the accounting system says the same as what the workbook says?
Integrated workbookThe actual Excel sheets, on screen.Show me the working.
Strategy and performanceThe ratios, against their benchmarks.Which of our numbers are outside the norm, and by how much?
Scenario labSliders for the things that could go wrong.What if collections drop, or rates rise, or a project runs late?
Reports and outputsThe statutory reports and the download buttons.What do I have to submit, by when, and can I get it out of here?
Governance and controlsThe automated checks.Which controls are passing and which are failing?
The risk and assurance tabs
Project riskBig capital projects against the money that was meant to flow in and out.Is this project going to finish on budget, and if not, by how much will it miss?
Contract monitorThe contract register lined up against what has actually been paid.Have we spent more than we awarded, and are we paying people on time?
Audit findingsThe AG's findings and internal audit's, with the rule that raised each one.What will the AG say, and what exactly do we have to fix to change that?
Revenue and expenditureActual against budget, plus collections, interest, tariffs and maintenance.Are we collecting what we bill, and what is eating the budget?
Journals, ledgers and bankThe audit trail for everything this system posts.Show me the double entry, the bank movement and the VAT treatment.

Step three — press a button and watch what happens

  1. Find a button. They sit at the bottom of most tabs — run the payroll, post the progress claims, release the payment run, run the disaster response.
  2. Press it. The system writes a journal entry. Debits must equal credits or the entry is refused; you cannot post a half-entry by accident.
  3. A red panel appears in the corner. That is the change-impact notice. It lists every tab and every workbook sheet the entry touched.
  4. The sidebar goes red in places. Those are the tabs that moved. Click one and look at the number that changed.
  5. Go to Journals, ledgers and bank. Your entry is there, with the date, the evidence reference and the VAT treatment. If it moved cash, it is on the bank statement too.
Try this one first. Go to Revenue and expenditure and press Run the disaster response. A storm hits, the municipality carries sixty per cent of the cost and forty per cent comes from national government and local business. Then watch: cash falls, infrastructure rises, unplanned maintenance jumps past the point where the asset base is being run reactively, a new audit finding appears about emergency procurement, and the bank statement shows both the money coming in and the money going out. One press, and you can follow the consequence all the way through.

Step four — the words you will keep seeing

WordWhat it actually means
CertifiedA contractor did work and an engineer signed a certificate saying so. It is the only progress measure this system trusts, because it is a document, not an opinion.
CommittedAn order has been placed. The money is not gone yet, but you are obliged to spend it.
RetentionMoney you are allowed to hold back until the job is properly finished. It is not a late payment.
Forecast outturnWhat the project will finally cost, worked out from what it has cost so far divided by how much has been certified so far. No guessing.
MaterialityThe size of error that would change someone's mind about the accounts. Here it is one per cent of total spending.
Qualified opinionThe AG found errors bigger than materiality that were not fixed. It is the outcome you are trying to avoid.
Adjusted budgetThe budget after council changed it during the year. It is what you measure against, not the original one.
Unplanned maintenanceFixing things after they break. Above about sixty per cent of the repairs budget, you are paying to fail instead of paying to prevent.
DeterministicThe same figures always give the same answer. Nothing on these screens is a judgement call, and you can re-do any of it by hand.

If something looks wrong

A number will not change. Check the mode and the year at the top right. Mode 2 shows half a year, and FY2023 is before most of this data starts.

A button is greyed out. Either your role is not allowed to press it, or you have already pressed it. Change the role and look again.

You get a message saying an entry was refused. That is the system protecting you. The debits did not equal the credits, so nothing was written.

A finding will not close. Findings close when the control behind them passes, not when you mark them done. Open the finding, read the rule, and fix the figure the rule is measuring.

The VAT control does not reconcile. That difference is left showing on purpose. An unreconciled VAT account is a finding, and hiding it would be the only dishonest thing this system could do.

Integration kit

One consolidated set of instructions for the whole pack. What comes out of the host, what goes in, in what order, and how the integrated modules feed each other.

This pack is a consolidation of three earlier artefacts. The Risk & Assurance add-on, the annexure modules and the project appraisal addendum were built separately and each carried its own copy of the host shim, its own chart helpers and its own integration kit. They also overlapped: the Revenue & expenditure tab computed the same figures the Treasury Control Tower does, the Bankability Engine appraised projects a second way, and three separate renderAll() functions each thought they owned the render pass. All of that is resolved here. Integrate this file, not the three it replaces.

What was removed in the merge

Duplication and contradiction, with what replaced it
RemovedWhyWhere the content now lives
Revenue & expenditure panelBudget variance, billed against collected, the borrowing book, rate shock, tariff margin and maintenance work orders were all computed again in the Treasury Control Tower and the Risk management levers, from the same ledger lines. Two functions computing one ratio diverge the moment either is edited.Treasury Control Tower tabs 1, 2, 3 and 5, and the Risk management levers. The five posting actions and the cascade table, which had no other home, moved into Journals where postings belong.
renderRevExp()150 lines of which only the last 30 had no equivalent elsewhere.Split. The surviving tail is renderRevActions(), called from renderRiskSuite().
Two duplicate host shimsAll three artefacts declared LINES, totals(), applyModeData(), fmt(), kpiSet() and showToast(). In one file that is a redeclaration error.One shim, taken from the add-on because it is the superset — it also carries MX_ROLES, triggerCascade() and the impact popup — with L() and LA() added from the annexure.
Duplicate helper constantspctT, money and clamp were declared in both the annexure block and the appraisal block.Declared once, in the annexure block, and used by both.
Colliding renderSens()The credit-model sensitivity renderer and the appraisal step-9 sensitivity renderer had the same name.The appraisal one is now renderPaSens(). Nothing else changed.
Three renderAll() and three renderKit()Each artefact owned the render pass and the kit.One renderPack() calling the three module renderers in order, one packInit(), one kit — this page.
Two duplicate stylesheetsThe appraisal file carried a verbatim copy of the annexure stylesheet.One stylesheet: host base, then the mx-, ax- and pa- class sets in that order.
Apologetic cross-referencesThe add-on’s mxBindGo() could only raise a toast saying the target module lived elsewhere.mxBindGo() now navigates. Every cross-reference between the integrated modules opens the module it names.

The merge removed roughly 23 000 characters of duplicated logic and one whole panel, and resolved five naming collisions that would have stopped the file loading at all had the three been concatenated without editing.

What the pack replaces in the host build

Work through this before pasting anything
Existing in MATOSBecomesWhat happens to its code
panel-strategy — Credit worthiness sectionCredit rating & scoringDelete the credit block inside renderStrategy(). The ratio scorecard above it stays.
panel-scenario — Scenario labRisk management, tabs 1 to 3Delete computeScenario(), renderScenSliders() and the eleven-lever array.
8_Sustainability_Report long-term modelRisk management, tabs 4 and 5Keep the sheet, repoint it at axLongRun().
6_ALM — Assets & liabilities modelTreasury Control Tower, tab 3Delete the standalone ALM renderer.
panel-revexp — Revenue & expenditureTreasury Control Tower, tabs 1 and 5Delete. Already removed from this pack; nothing to carry across.
4_MFD-MM_Master — Rates-to-ResilienceTreasury Control Tower, tab 4Keep the sheet, repoint it at axCascade().
panel-bankability — Bankability EngineProject appraisal, steps 6 to 9Delete. Scoring is replaced by deterministic arithmetic; nothing is reusable.
panel-capital — Capital StructureProject appraisal, step 10Delete. Six funding models supersede the two it carried.
Fixed asset schedule inside the workbookAsset register & AMPRetire the sheet and repoint it at arTotals().

Steps, in order

  1. Remove the nine replaced sections first. Both markup and renderers, and the nav buttons that reach them. Leaving them means the same figure is computed twice from the same ledger and quietly diverges.
  2. Append the CSS. The whole stylesheet below, minus the rows the host already has. The mx-, ax- and pa- sets are all new.
  3. Paste the ten panels. Inside <div class="content">, in the sidebar order: Treasury, Journals, Asset register, Project appraisal, Project risk, Contract monitor, Audit findings, Risk management, Credit rating, How to use.
  4. Add the nav buttons. Ten data-panel buttons plus the three group headings. The host nav handler routes on data-panel already.
  5. Paste the engine block. One contiguous block above renderAll(). It exposes exactly two entry points: renderPack() and packInit().
  6. Wire renderAll() and the boot sequence. renderPack(); as the last line of renderAll(), and packInit(); between applyModeData() and the first renderAll().
  7. Register the workbook sheets. 15_Project_Risk, 16_Contract_Monitor, 17_Audit_Findings, 19_Journals_Ledger, 20_Credit_Model, 21_Risk_Management, 22_Treasury_Tower, 23_Asset_Register, 24_Project_Appraisal. Retire 6_ALM, 9_Bankability and 18_Revenue_Expenditure.
  8. Replace the illustrative data with the host’s own. Three arrays are worked examples and must be swapped: AR_CLASSES for the real PPE sub-ledger, MX_CONTRACTS for the SCM register, and PA for the project under appraisal. Keep arTotals().ca === L('1000') as a standing automated test.
  9. Connect the accounting suite. Point the Pastel, Sage and Caseware screens at MX_JOURNAL, and pass mxDate() into the host batch-date and period fields so a date change re-dates entries everywhere.

Cross-module wiring

What each module reads from the others. Live in this pack unless marked.
FromToWhat travelsFunction
Audit findingsCredit ratingThe projected audit outcome, which carries a scoring penalty inside financial managementaxAuditOutcome = mxOpinion().oplive
Treasury Control TowerCredit ratingFree cash, debt service and the ratio set the scorecard readsaxBase()live
Risk managementCredit ratingStressed ratios re-scored through the same published bandsaxScenarioRating(axScenario())live
Risk management — scenarioRisk management — structuralThe closing position at month 36 seeds year zero of the long runaxLongRun(true)live
Any posting actionEverything downstreamA balanced journal into MX_JOURNAL and ACTION_ADJ, through applyModeData() into LINESmxPost()live
Project riskAsset registerCertified capital work, as a WIP addition and then a transfer to PPEmxPortfolio().certTD → AR_WIPto wire
Contract monitorTreasury Control TowerCreditor ageing into the nought-to-three-month maturity bucketmxContractTotals().due → axLadder()to wire
Asset registerRisk managementReplacement cost and the condition-derived renewal requirement, seeding the backlogarAmp() → AX_LR.backlogOpento wire
Asset registerTreasury Control TowerCarrying amount, depreciation charge, maintenance ratioarTotals() → axBase()to wire
Project appraisalTreasury Control TowerDebt service on the selected structure, into the ladder and the coverage testspaFunding().opts[sel].annualSvcto wire
Risk managementProject appraisalRevenue and cost growth for the step-11 affordability testAX_LR.revGrowth, AX_LR.costGrowthto wire
Project appraisalAsset registerCapital cost and component lives on completionPA.capex → AR_CLASSESto wire

The five live links work in this file now — post the audit adjustments and watch the financial management factor and the rating move. The seven marked to wire are one-line assignments each; they are left explicit rather than done silently because each one replaces an illustrative array with host data, and that substitution is the integrator’s decision, not the module’s.

Host API the pack depends on

Nothing else in the host is touched
Host symbolUsed forRead or write
LINES, L(), LA()Every ratio, scorecard metric, cascade stage and maturity bucketRead
totals(), totalsAt(), sumAt()Aggregates and the three-year comparisonRead
state.mode, state.periodRe-cutting every module to the half-year or full-year conventionRead
ACTION_ADJWhere posting actions write, so the host cascade picks them upWrite
applyModeData(), renderAll()Re-entry after a mode, period or posting changeCall
fmt(), kpiSet(), showToast(), triggerCascade()Formatting, notification and the impact popup, so the pack looks nativeCall

Verification after integration

Run these before signing it off
  1. Modes and periods move together. Switch mode 1, 2 and 3 and the period selector. All integrated modules must re-cut in one pass. If one lags, it was not added to renderPack().
  2. Double entry holds. Press every posting action in turn. After each, debits less credits must be nil and assets less liabilities must equal net assets.
  3. The audit link bites. Post the audit adjustments. The financial management factor must improve and, where it crosses a band edge, the credit profile must move with it.
  4. The register ties. arTotals().ca must equal L('1000') at every reporting date, in every mode.
  5. The appraisal reads a real pack. Upload the project zip. Fourteen documents tick, the twelve assumptions load, and every appraisal step changes.
  6. Role gating works. Switch to MPAC. Every posting button must be disabled. Switch to SCM: only the payment run and the contract variation.
  7. No figure appears twice. Search the built file for any ratio name that returns two different renderers. It should not, and that is what this merge was for.
CSSFull stylesheet
/* =========================================================================
   BLOCK A — CSS
   Rows marked [HOST] already exist in MATOS_Integrated_Standalone.html.
   When pasting into MATOS, copy ONLY the [ADD-ON] section at the bottom.
   ========================================================================= */

/* ---------- [HOST] design tokens — identical to MATOS ---------- */
:root{
  --navy:#173f73; --navy-dark:#0d2b50; --navy-soft:#edf3f9; --gold:#c29b4b; --gold-soft:#fbf6e9; --teal:#1b8a78; --teal-soft:#eaf7f3;
  --ink:#1c2733; --muted:#6b7683; --line:#d7dee6;
  --red:#c0524f; --red-bg:#fbeceb; --amber:#b48736; --amber-bg:#fbf1de;
  --green:#1f8f6e; --green-bg:#e6f4ee; --bg:#eef1f5;
}
*{box-sizing:border-box;}
body{margin:0; font-family:"Segoe UI",-apple-system,BlinkMacSystemFont,Roboto,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--ink); display:flex; min-height:100vh;}
.sidebar{width:248px; flex:0 0 248px; background:var(--navy-dark); color:#cfe0f2; display:flex; flex-direction:column; position:sticky; top:0; height:100vh;}
.brand{padding:18px 18px 14px; border-bottom:1px solid rgba(255,255,255,.12);}
.brand .word{font-size:19px; font-weight:800; letter-spacing:.06em; color:#fff;}
.brand .sub{font-size:10.5px; color:#9dbbd8; line-height:1.5; margin-top:2px;}
.nav{padding:10px 0; flex:1; overflow:auto;}
.nav button{display:flex; align-items:center; gap:10px; width:100%; border:0; background:transparent; color:#cfe0f2; text-align:left; padding:10px 18px; font-size:12.5px; font-weight:600; cursor:pointer; border-left:3px solid transparent;}
.nav button:hover{background:rgba(255,255,255,.06); color:#fff;}
.nav button.active{background:rgba(255,255,255,.10); color:#fff; border-left-color:var(--gold);}
.nav button.nav-impact{color:#ffb9b6 !important;}
.nav .ic{width:16px; display:inline-block; text-align:center; opacity:.85;}
.side-foot{padding:12px 18px 16px; border-top:1px solid rgba(255,255,255,.12); font-size:10px; color:#8fb0cf; line-height:1.6;}
.side-foot .dot{width:7px;height:7px;border-radius:50%;background:#4fd1a5;display:inline-block;margin-right:6px;}
.main{flex:1; min-width:0; display:flex; flex-direction:column;}
.topbar{display:flex; align-items:center; gap:12px; padding:11px 22px; background:#fff; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:20; flex-wrap:wrap;}
.topbar h1{margin:0; font-size:15px; color:var(--navy-dark); font-weight:800;}
.crumb{font-size:11px; color:var(--muted);}
.spacer{flex:1;}
.seg{display:inline-flex; border:1px solid var(--line); border-radius:7px; overflow:hidden;}
.seg button{border:0; background:#fff; padding:7px 14px; font-size:11.5px; font-weight:700; color:var(--muted); cursor:pointer;}
.seg button.active{background:var(--navy); color:#fff;}
select.period, select.role{border:1px solid var(--line); border-radius:7px; padding:7px 10px; font-size:11.5px; font-weight:700; color:var(--navy-dark); background:#fff;}
.btn-primary{border:0; background:var(--navy); color:#fff; padding:8px 14px; border-radius:7px; font-size:11.5px; font-weight:700; cursor:pointer;}
.btn-primary:disabled{opacity:.45; cursor:not-allowed;}
.btn-ghost{border:1px solid var(--line); background:#fff; color:var(--navy-dark); padding:7px 12px; border-radius:7px; font-size:11.5px; font-weight:700; cursor:pointer;}
.btn-ghost:disabled{opacity:.45; cursor:not-allowed;}
.avatar{width:30px;height:30px;border-radius:50%;background:var(--navy-soft);color:var(--navy-dark);display:grid;place-items:center;font-size:11px;font-weight:800;}
.content{padding:20px 22px 60px; flex:1;}
.panel{display:none;} .panel.active{display:block;}
.pagehead{margin-bottom:14px;}
.pagehead h2{margin:0 0 4px; font-size:19px; color:var(--navy-dark);}
.pagehead p{margin:0; font-size:12px; color:var(--muted); max-width:76ch; line-height:1.55;}
.kpis{display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:16px;}
.kpi{background:#fff; border:1px solid var(--line); border-radius:10px; padding:14px 16px; position:relative; overflow:hidden;}
.kpi::before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--navy);}
.kpi:nth-child(2n)::before{background:var(--teal);} .kpi:nth-child(3n)::before{background:var(--gold);}
.kpi .lbl{font-size:10px; color:var(--muted); font-weight:700; letter-spacing:.03em;}
.kpi .val{font-size:19px; font-weight:800; color:var(--navy-dark); margin-top:4px;}
.kpi .val.bad{color:var(--red);} .kpi .val.good{color:var(--green);} .kpi .val.warn{color:var(--amber);}
.kpi .sub{font-size:10.5px; color:var(--muted); margin-top:3px;}
.pill{display:inline-block; padding:2px 9px; border-radius:10px; font-size:9.5px; font-weight:800; letter-spacing:.02em;}
.pill.GREEN{background:var(--green-bg); color:var(--green);}
.pill.AMBER{background:var(--amber-bg); color:var(--amber);}
.pill.RED{background:var(--red-bg); color:var(--red);}
.pill.INCOMPLETE{background:#eef1f4; color:#66717d;}
.pill.NAVY{background:var(--navy-soft); color:var(--navy-dark);}
.card{background:#fff; border:1px solid var(--line); border-radius:10px; margin-bottom:16px; overflow:hidden;}
.card .hd{display:flex; align-items:center; gap:10px; padding:13px 16px; border-bottom:1px solid var(--line); flex-wrap:wrap;}
.card .hd h3{margin:0; font-size:13px; font-weight:800; color:var(--navy-dark);}
.card .hd .desc{font-size:11px; color:var(--muted); margin-left:auto;}
.card .bd{padding:14px 16px;}
table.grid{width:100%; border-collapse:collapse; font-size:12px;}
table.grid th{text-align:left; padding:8px 12px; background:#f6f8fa; color:var(--muted); font-size:10.5px; font-weight:800; letter-spacing:.02em; border-bottom:1px solid var(--line);}
table.grid td{padding:7px 12px; border-bottom:1px solid #eef1f5; vertical-align:top;}
table.grid tr.section td{background:var(--navy-soft); font-weight:800; color:var(--navy-dark); font-size:11.5px;}
table.grid tr.total td{font-weight:800; border-top:1px solid var(--line); background:#fbfcfd;}
table.grid td.num, table.grid th.num{text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap;}
table.grid tr.click{cursor:pointer;} table.grid tr.click:hover td{background:#f9fbfd;}
code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; background:#f2f5f8; padding:1px 5px; border-radius:4px; color:var(--navy-dark);}
.subtabs{display:flex; gap:4px; border-bottom:1px solid var(--line); margin-bottom:14px; flex-wrap:wrap;}
.subtabs button{border:0; background:transparent; padding:9px 14px; font-size:11.5px; font-weight:700; color:var(--muted); cursor:pointer; border-bottom:2px solid transparent;}
.subtabs button.active{color:var(--navy-dark); border-bottom-color:var(--navy);}
.subtabs button.nav-impact{color:var(--red) !important; border-bottom-color:var(--red) !important;}
.toast{position:fixed; left:50%; bottom:26px; transform:translateX(-50%); background:var(--navy-dark); color:#fff; padding:11px 18px; border-radius:8px; font-size:12px; font-weight:600; z-index:200; box-shadow:0 8px 24px rgba(13,43,80,.28); max-width:74ch;}
.impact-popup{position:fixed; right:18px; bottom:18px; width:360px; max-height:62vh; overflow:auto; background:#fff; border:1px solid var(--red); border-radius:10px; z-index:190; box-shadow:0 12px 34px rgba(13,43,80,.22); font-size:11.5px;}
.impact-popup .ihd{background:var(--red-bg); color:var(--red); font-weight:800; padding:10px 12px; display:flex; align-items:center; gap:8px; position:sticky; top:0;}
.impact-popup .ibtns{margin-left:auto; display:flex; gap:4px;}
.impact-popup .ibtns button{border:0;background:transparent;color:var(--red);font-weight:800;cursor:pointer;font-size:13px;}
.impact-popup .ibd{padding:12px; line-height:1.6; color:var(--ink);}
.impact-popup.collapsed .ibd{display:none;}
.impact-popup ul{margin:4px 0 10px; padding-left:18px;}
.footnote{font-size:11px; color:var(--muted); line-height:1.65; margin:10px 0 0;}

/* ---------- [ADD-ON] new classes — paste these into the MATOS <style> ---------- */
.mx-split{display:grid; grid-template-columns:1fr 1fr; gap:16px;}
.mx-split3{display:grid; grid-template-columns:1fr 1fr 1fr; gap:16px;}
@media(max-width:1100px){.mx-split,.mx-split3{grid-template-columns:1fr;} .kpis{grid-template-columns:repeat(2,1fr);}}
.mx-prog{height:8px; background:#eef1f5; border-radius:5px; overflow:hidden; position:relative; min-width:90px;}
.mx-prog i{display:block; height:100%; background:var(--navy);}
.mx-prog i.g{background:var(--green);} .mx-prog i.a{background:var(--amber);} .mx-prog i.r{background:var(--red);}
.mx-prog b{position:absolute; inset:0; display:block; border-left:2px dashed var(--navy-dark); width:0;}
.mx-mini{font-size:10px; color:var(--muted); margin-top:3px; display:block;}
.mx-chart{display:flex; align-items:flex-end; gap:10px; height:172px; padding:6px 2px 0;}
.mx-chart .col{flex:1; display:flex; flex-direction:column; align-items:center; gap:3px; height:100%; justify-content:flex-end;}
.mx-chart .bars{display:flex; align-items:flex-end; gap:3px; height:100%; width:100%; justify-content:center;}
.mx-chart .bar{width:14px; border-radius:3px 3px 0 0; min-height:2px;}
.mx-chart .bar.plan{background:#b9c9db;} .mx-chart .bar.act{background:var(--navy);}
.mx-chart .bar.inflow{background:var(--teal);} .mx-chart .bar.fc{background:repeating-linear-gradient(45deg,#c29b4b,#c29b4b 3px,#e6d5ae 3px,#e6d5ae 6px);}
.mx-chart .xl{font-size:9px; color:var(--muted); white-space:nowrap;}
.mx-legend{display:flex; gap:14px; flex-wrap:wrap; font-size:10.5px; color:var(--muted); margin-top:8px;}
.mx-legend span i{width:10px;height:10px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:-1px;}
.mx-ms{display:flex; gap:0; align-items:stretch; margin:4px 0 2px; flex-wrap:wrap;}
.mx-ms .m{flex:1; min-width:104px; border-left:3px solid var(--line); padding:4px 9px 6px; font-size:10.5px;}
.mx-ms .m.done{border-left-color:var(--green);} .mx-ms .m.late{border-left-color:var(--red);} .mx-ms .m.due{border-left-color:var(--amber);}
.mx-ms .m b{display:block; font-size:11px; color:var(--navy-dark);}
.mx-ms .m span{color:var(--muted);}
.mx-note{background:var(--navy-soft); border-left:3px solid var(--navy); padding:10px 13px; font-size:11.5px; line-height:1.6; border-radius:0 7px 7px 0; margin:0 0 12px;}
.mx-note.warn{background:var(--amber-bg); border-left-color:var(--amber);}
.mx-note.bad{background:var(--red-bg); border-left-color:var(--red);}
.mx-note.ok{background:var(--green-bg); border-left-color:var(--green);}
.mx-act{display:flex; gap:9px; flex-wrap:wrap; align-items:center;}
.mx-empty{padding:34px 18px; text-align:center; color:var(--muted); font-size:12.5px; line-height:1.7;}
.mx-empty b{display:block; color:var(--navy-dark); font-size:13.5px; margin-bottom:5px;}
.mx-in{width:92px; border:1px solid var(--line); border-radius:5px; padding:4px 6px; font-size:11.5px; text-align:right; font-variant-numeric:tabular-nums;}
.mx-in:focus{outline:2px solid var(--navy); outline-offset:-1px;}
.mx-code{background:#0d2b50; color:#d7e6f7; border-radius:9px; padding:0; overflow:hidden; margin:0 0 14px;}
.mx-code .ch{display:flex; align-items:center; gap:9px; padding:9px 13px; background:rgba(255,255,255,.07); font-size:11px; font-weight:700; color:#fff; flex-wrap:wrap;}
.mx-code .ch .tag{background:var(--gold); color:#2a1e06; padding:1px 8px; border-radius:9px; font-size:9.5px; font-weight:800;}
.mx-code .ch button{margin-left:auto; border:1px solid rgba(255,255,255,.3); background:transparent; color:#fff; padding:4px 10px; border-radius:6px; font-size:10.5px; font-weight:700; cursor:pointer;}
.mx-code pre{margin:0; padding:13px; overflow:auto; max-height:400px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; line-height:1.65; white-space:pre; tab-size:2;}
.mx-code pre::-webkit-scrollbar{height:9px;width:9px;} .mx-code pre::-webkit-scrollbar-thumb{background:#31517c;border-radius:5px;}
.mx-steps{counter-reset:s; margin:0; padding:0; list-style:none;}
.mx-steps li{counter-increment:s; position:relative; padding:0 0 14px 34px; font-size:12px; line-height:1.65;}
.mx-steps li::before{content:counter(s); position:absolute; left:0; top:0; width:23px; height:23px; border-radius:50%; background:var(--navy); color:#fff; font-size:11px; font-weight:800; display:grid; place-items:center;}
.mx-steps li b{color:var(--navy-dark);}
.mx-role{font-size:10.5px; color:var(--muted);}
.mx-locked{opacity:.5;}
.mx-tag{display:inline-block; font-size:9.5px; font-weight:800; padding:2px 7px; border-radius:5px; background:var(--navy-soft); color:var(--navy-dark); margin-right:4px;}
.mx-src{font-size:10px; color:var(--muted); display:block; margin-top:2px;}
@media print{.sidebar,.topbar,.mx-act,.impact-popup{display:none !important;} .content{padding:0;} .card{break-inside:avoid;}}
/* ---------- annexure classes ---------- */
.ax-split{display:grid; grid-template-columns:1fr 1fr; gap:16px;}
.ax-split3{display:grid; grid-template-columns:1fr 1fr 1fr; gap:16px;}
.ax-25{display:grid; grid-template-columns:320px 1fr; gap:16px; align-items:start;}
@media(max-width:1180px){.ax-split,.ax-split3,.ax-25{grid-template-columns:1fr;} .kpis{grid-template-columns:repeat(2,1fr);}}
.ax-prog{height:9px; background:#eef1f5; border-radius:5px; overflow:hidden; min-width:80px;}
.ax-prog i{display:block; height:100%; background:var(--navy);}
.ax-prog i.g{background:var(--green);} .ax-prog i.a{background:var(--amber);} .ax-prog i.r{background:var(--red);}
.ax-mini{font-size:10px; color:var(--muted); display:block; margin-top:3px;}

/* rating ladder */
.ax-ladder{display:flex; flex-direction:column; gap:2px;}
.ax-ladder .rung{display:flex; align-items:center; gap:9px; padding:4px 9px; border-radius:5px; font-size:11px; border-left:3px solid transparent;}
.ax-ladder .rung b{width:52px; font-weight:800; font-variant-numeric:tabular-nums;}
.ax-ladder .rung span{color:var(--muted); font-size:10.5px;}
.ax-ladder .rung.here{background:var(--navy); color:#fff; border-left-color:var(--gold);}
.ax-ladder .rung.here span{color:#cfe0f2;}
.ax-ladder .rung.band{background:var(--navy-soft);}
.ax-ladder .rung.sov{border-left-color:var(--red); background:var(--red-bg);}

/* chart with hover readout */
.ax-chart{position:relative; display:flex; align-items:flex-end; gap:6px; height:190px; padding:8px 2px 0;}
.ax-chart .col{flex:1; display:flex; flex-direction:column; align-items:center; gap:4px; height:100%; justify-content:flex-end; cursor:pointer; border-radius:4px; padding:2px 0;}
.ax-chart .col:hover{background:#f4f8fc;}
.ax-chart .bars{display:flex; align-items:flex-end; gap:3px; height:100%; width:100%; justify-content:center;}
.ax-chart .bar{width:15px; border-radius:3px 3px 0 0; min-height:2px; transition:opacity .12s;}
.ax-chart .col:hover .bar{opacity:.82;}
.ax-chart .xl{font-size:9px; color:var(--muted); white-space:nowrap;}
.ax-read{min-height:58px; background:var(--navy-soft); border-left:3px solid var(--navy); border-radius:0 7px 7px 0; padding:9px 13px; font-size:11.5px; line-height:1.6; margin-top:10px;}
.ax-read b{color:var(--navy-dark);}
.ax-legend{display:flex; gap:14px; flex-wrap:wrap; font-size:10.5px; color:var(--muted); margin-top:8px;}
.ax-legend span i{width:10px;height:10px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:-1px;}
.b-navy{background:var(--navy);} .b-teal{background:var(--teal);} .b-gold{background:var(--gold);}
.b-grey{background:#b9c9db;} .b-red{background:var(--red);} .b-green{background:var(--green);}
.b-hatch{background:repeating-linear-gradient(45deg,#c29b4b,#c29b4b 3px,#e6d5ae 3px,#e6d5ae 6px);}

/* line / area sparkline */
.ax-line{width:100%; height:200px; display:block;}
.ax-line .gridline{stroke:#e6ebf1; stroke-width:1;}
.ax-line .ax{stroke:#c3ccd6; stroke-width:1;}
.ax-line text{font-size:9px; fill:#6b7683;}
.ax-line .dot{cursor:pointer;}

/* levers */
.ax-lev{border:1px solid var(--line); border-radius:9px; padding:11px 13px; background:#fff; margin-bottom:9px;}
.ax-lev .lv-hd{display:flex; align-items:baseline; gap:8px; margin-bottom:6px;}
.ax-lev .lv-hd b{font-size:11.5px; color:var(--navy-dark);}
.ax-lev .lv-hd .now{margin-left:auto; font-size:12px; font-weight:800; font-variant-numeric:tabular-nums;}
.ax-lev .lv-hd .now.off{color:var(--amber);}
.ax-lev input[type=range]{width:100%; accent-color:var(--navy);}
.ax-lev .lv-ft{display:flex; justify-content:space-between; font-size:9.5px; color:var(--muted); margin-top:2px;}
.ax-lev .lv-why{font-size:10.5px; color:var(--muted); line-height:1.5; margin-top:5px;}
.ax-lev.touched{border-color:var(--navy); box-shadow:inset 3px 0 0 var(--navy);}

/* 360 impact grid */
.ax-360{display:grid; grid-template-columns:repeat(auto-fill,minmax(176px,1fr)); gap:10px;}
.ax-360 .t{background:#fff; border:1px solid var(--line); border-radius:9px; padding:11px 13px; position:relative; overflow:hidden;}
.ax-360 .t::after{content:""; position:absolute; right:0; top:0; bottom:0; width:4px; background:#e2e8ef;}
.ax-360 .t.up::after{background:var(--green);} .ax-360 .t.dn::after{background:var(--red);}
.ax-360 .t .n{font-size:10px; color:var(--muted); font-weight:700;}
.ax-360 .t .v{font-size:17px; font-weight:800; color:var(--navy-dark); margin-top:3px; font-variant-numeric:tabular-nums;}
.ax-360 .t .d{font-size:10.5px; margin-top:2px; font-weight:700;}
.ax-360 .t .d.up{color:var(--green);} .ax-360 .t .d.dn{color:var(--red);} .ax-360 .t .d.flat{color:var(--muted);}
.ax-360 .t .base{font-size:9.5px; color:var(--muted); margin-top:3px;}

/* cascade */
.ax-casc{display:flex; flex-direction:column; gap:0;}
.ax-casc .stg{display:grid; grid-template-columns:30px 1fr 150px 118px; gap:12px; align-items:center; padding:11px 4px; border-bottom:1px solid #eef1f5;}
.ax-casc .stg .no{width:26px;height:26px;border-radius:50%;background:var(--navy);color:#fff;display:grid;place-items:center;font-size:11px;font-weight:800;}
.ax-casc .stg b{font-size:12px; color:var(--navy-dark); display:block;}
.ax-casc .stg .sub{font-size:10.5px; color:var(--muted); line-height:1.5;}
.ax-casc .stg .amt{text-align:right; font-size:15px; font-weight:800; font-variant-numeric:tabular-nums; color:var(--navy-dark);}
.ax-casc .stg .leak{text-align:right; font-size:11px; color:var(--red); font-weight:700;}
.ax-casc .flow{height:16px; margin-left:13px; border-left:2px dashed var(--line);}

.ax-note{background:var(--navy-soft); border-left:3px solid var(--navy); padding:10px 14px; font-size:11.5px; line-height:1.65; border-radius:0 7px 7px 0; margin:0 0 13px;}
.ax-note.warn{background:var(--amber-bg); border-left-color:var(--amber);}
.ax-note.bad{background:var(--red-bg); border-left-color:var(--red);}
.ax-note.ok{background:var(--green-bg); border-left-color:var(--green);}
.ax-note b{color:inherit;}
.ax-horizon{display:flex; gap:0; margin-bottom:14px; border:1px solid var(--line); border-radius:9px; overflow:hidden; background:#fff;}
.ax-horizon .h{flex:1; padding:12px 15px; border-right:1px solid var(--line);}
.ax-horizon .h:last-child{border-right:0;}
.ax-horizon .h.on{background:var(--navy-soft);}
.ax-horizon .h b{display:block; font-size:12px; color:var(--navy-dark);}
.ax-horizon .h span{font-size:10.5px; color:var(--muted); line-height:1.5; display:block; margin-top:3px;}
.ax-tag{display:inline-block; font-size:9.5px; font-weight:800; padding:2px 7px; border-radius:5px; background:var(--navy-soft); color:var(--navy-dark); margin-right:4px;}
.ax-src{font-size:10px; color:var(--muted); display:block; margin-top:2px;}
.ax-code{background:#0d2b50; color:#d7e6f7; border-radius:9px; overflow:hidden; margin:0 0 14px;}
.ax-code .ch{display:flex; align-items:center; gap:9px; padding:9px 13px; background:rgba(255,255,255,.07); font-size:11px; font-weight:700; color:#fff;}
.ax-code .ch .tag{background:var(--gold); color:#2a1e06; padding:1px 8px; border-radius:9px; font-size:9.5px; font-weight:800;}
.ax-code pre{margin:0; padding:13px; overflow:auto; max-height:340px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11px; line-height:1.6;}
.ax-steps{counter-reset:s; margin:0; padding:0; list-style:none;}
.ax-steps li{counter-increment:s; position:relative; padding:0 0 13px 33px; font-size:12px; line-height:1.65;}
.ax-steps li::before{content:counter(s); position:absolute; left:0; top:0; width:23px; height:23px; border-radius:50%; background:var(--navy); color:#fff; font-size:11px; font-weight:800; display:grid; place-items:center;}
.ax-sub{display:none;} .ax-sub.on{display:block;}
@media print{.sidebar,.topbar{display:none!important;} .content{padding:0;} .card{break-inside:avoid;}}
/* ---------- project appraisal additions ---------- */
.pa-drop{border:2px dashed var(--line); border-radius:11px; padding:26px 20px; text-align:center; background:#fff; transition:.15s;}
.pa-drop.over{border-color:var(--navy); background:var(--navy-soft);}
.pa-drop h4{margin:0 0 5px; font-size:14px; color:var(--navy-dark);}
.pa-drop p{margin:0 0 12px; font-size:11.5px; color:var(--muted); line-height:1.6;}
.pa-file{display:flex; align-items:center; gap:10px; padding:7px 11px; border:1px solid var(--line); border-radius:7px; margin-bottom:5px; background:#fff; font-size:11.5px;}
.pa-file .nm{flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.pa-file .sz{color:var(--muted); font-size:10.5px; font-variant-numeric:tabular-nums;}
.pa-step{display:flex; gap:0; overflow:auto; border:1px solid var(--line); border-radius:9px; background:#fff; margin-bottom:16px;}
.pa-step .s{flex:1; min-width:118px; padding:10px 12px; border-right:1px solid var(--line); position:relative;}
.pa-step .s:last-child{border-right:0;}
.pa-step .s.done{background:var(--green-bg);} .pa-step .s.now{background:var(--navy); color:#fff;}
.pa-step .s .n{font-size:9.5px; font-weight:800; opacity:.7;}
.pa-step .s b{display:block; font-size:11px; line-height:1.3; margin-top:2px;}
.pa-gate{border-left:4px solid var(--navy); background:#fff; border:1px solid var(--line); border-left-width:4px; border-radius:0 8px 8px 0; padding:11px 14px; margin-bottom:9px;}
.pa-gate.pass{border-left-color:var(--green);} .pa-gate.fail{border-left-color:var(--red);} .pa-gate.warn{border-left-color:var(--amber);}
.pa-gate b{font-size:11.5px; color:var(--navy-dark);}
.pa-gate .law{font-size:10px; color:var(--muted); font-weight:700; letter-spacing:.02em;}
.pa-gate p{margin:5px 0 0; font-size:11.5px; line-height:1.6; color:var(--ink);}
.pa-opt{border:1px solid var(--line); border-radius:10px; background:#fff; margin-bottom:12px; overflow:hidden;}
.pa-opt .oh{display:flex; align-items:center; gap:10px; padding:12px 15px; cursor:pointer; background:#fbfcfd;}
.pa-opt .oh .rank{width:26px;height:26px;border-radius:50%;background:var(--navy);color:#fff;display:grid;place-items:center;font-size:11px;font-weight:800;flex:0 0 26px;}
.pa-opt .oh b{font-size:12.5px; color:var(--navy-dark);}
.pa-opt .oh .tag{font-size:9.5px; font-weight:800; padding:2px 8px; border-radius:9px; background:var(--navy-soft); color:var(--navy-dark);}
.pa-opt .oh .npc{margin-left:auto; font-size:13px; font-weight:800; font-variant-numeric:tabular-nums; color:var(--navy-dark);}
.pa-opt.on .ob{display:block;} .pa-opt .ob{display:none; border-top:1px solid var(--line);}
.pa-opt.sel{border-color:var(--navy); box-shadow:inset 4px 0 0 var(--navy);}
.pa-waterfall{display:flex; align-items:flex-end; gap:4px; height:150px; padding-top:8px;}
.pa-waterfall .wc{flex:1; display:flex; flex-direction:column; justify-content:flex-end; align-items:center; height:100%; gap:3px; cursor:pointer;}
.pa-waterfall .wb{width:100%; border-radius:2px 2px 0 0; min-height:2px;}
.pa-waterfall .wl{font-size:8.5px; color:var(--muted); text-align:center; line-height:1.2;}
.pa-risk{display:grid; grid-template-columns:1.5fr repeat(3,1fr); gap:0; font-size:11px;}
.pa-risk > div{padding:7px 10px; border-bottom:1px solid #eef1f5;}
.pa-risk .hdr{background:#f6f8fa; font-weight:800; font-size:10.5px; color:var(--muted);}
.pa-risk .m{text-align:center; font-weight:800;}
.pa-risk .m.pub{color:var(--navy);} .pa-risk .m.priv{color:var(--teal);} .pa-risk .m.shr{color:var(--gold);}


/* Unified MATOS / Intellica shell */
:root{--navy:#153e73;--navy-dark:#102f55;--gold:#c79a3b;--teal:#138c7a;--bg:#f2f5f9;--line:#d5dee9;}
body{font-family:Inter,"Segoe UI",Arial,sans-serif;background:var(--bg);}
.sidebar{width:292px;flex-basis:292px;background:#12365f;box-shadow:10px 0 28px rgba(16,47,85,.08)}
.brand{background:#fff;padding:22px 24px 20px;border:0;text-align:center}.brand img{width:auto;height:auto;max-width:150px;max-height:130px;object-fit:contain;display:inline-block;background:#fff;}.brand .word{color:var(--navy-dark);font-size:13px;letter-spacing:.18em;margin-top:12px}.brand .sub{color:#63758b}
.nav{padding:18px 10px}.navgrp{padding:14px 16px 6px;color:#82a4c5;font-size:9px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.nav button{border-radius:10px;border-left:4px solid transparent;margin:2px 0;padding:11px 14px;font-size:12px}.nav button.active{background:#fff;color:var(--navy-dark);border-left-color:var(--gold)}
.side-foot{margin:10px 14px 16px;border:1px solid rgba(255,255,255,.14);border-radius:12px;padding:14px;background:rgba(255,255,255,.05)}
.topbar{padding:14px 24px;min-height:72px}.topbar .crumb{order:-1;font-size:12px}.topbar h1{display:none}.content{padding:22px 28px 64px;max-width:1580px;width:100%;margin:0 auto}.pagehead h2{font-size:24px}.eyebrow{display:block;color:var(--gold);font-size:10px;font-weight:900;letter-spacing:.12em;text-transform:uppercase;margin-bottom:6px}
.process-strip{display:flex;align-items:center;background:#f8fafc;border:1px solid var(--line);border-radius:18px;padding:22px 26px;margin:0 0 22px;gap:10px}.process-strip button{flex:1;display:grid;grid-template-columns:34px 1fr;text-align:left;column-gap:10px;border:0;background:transparent;color:var(--navy-dark);cursor:pointer;padding:8px;border-radius:10px}.process-strip button:hover{background:#fff;box-shadow:0 7px 18px rgba(16,47,85,.09)}.process-strip i{grid-row:1/3;width:34px;height:34px;border:2px solid var(--navy);border-radius:50%;display:grid;place-items:center;font-style:normal;font-weight:900}.process-strip b{font-size:14px}.process-strip span{font-size:10.5px;color:var(--muted)}.process-strip em{font-size:24px;color:var(--gold);font-style:normal}.process-strip.compact{padding:14px 18px;border-radius:12px}.dash-grid{display:grid;grid-template-columns:1.35fr .9fr;gap:16px}.exec-bars .row{display:grid;grid-template-columns:130px 1fr 90px;gap:10px;align-items:center;margin:12px 0;font-size:11px}.exec-bars .track{height:12px;background:#e8edf3;border-radius:8px;overflow:hidden}.exec-bars .track i{display:block;height:100%;background:var(--navy);border-radius:8px}.exec-bars .row:nth-child(2) i{background:var(--gold)}.exec-bars .row:nth-child(3) i{background:var(--teal)}.queue-btn{width:100%;display:flex;justify-content:space-between;align-items:center;border:0;border-bottom:1px solid #edf1f5;background:#fff;padding:12px 2px;text-align:left;color:var(--navy-dark);cursor:pointer}.queue-btn:hover{padding-left:8px;background:#f8fafc}.queue-btn small{display:block;color:var(--muted);margin-top:3px}.chronology{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}.chronology .stage{border:1px solid var(--line);border-radius:10px;padding:12px;background:#fff}.chronology .stage b{color:var(--navy-dark)}.chronology .stage span{display:block;font-size:10px;color:var(--muted);margin:7px 0}.chronology .stage ol{padding-left:18px;margin:0;font-size:10.5px;line-height:1.75}
.accounting-frame{height:790px;background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden;box-shadow:0 12px 32px rgba(20,55,90,.1)}.accounting-frame iframe{width:100%;height:100%;border:0}.workbook-shell{background:#fff;border:1px solid #b9c9d9;border-radius:12px;overflow:hidden}.formula-bar{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--line);background:#f7f9fb}.formula-bar b{font-size:22px;color:#17845f;font-style:italic}.formula-bar span{flex:1;border:1px solid var(--line);background:#fff;padding:9px 12px;font:11px ui-monospace,monospace}.formula-bar small{color:var(--muted)}.sheet-tabs{display:flex;overflow:auto;background:#e9edf2;border-bottom:1px solid var(--line)}.sheet-tabs button{min-width:118px;border:0;border-right:1px solid #fff;background:transparent;padding:10px 8px;font-size:9.5px;color:#56677d;cursor:pointer}.sheet-tabs button.active{background:#fff;color:#126b50;border-top:4px solid #24936e;font-weight:800}.sheet-input{color:#1269a7!important;background:#eff7ff!important}.sheet-output{background:#f6faf8}.report-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.report-card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:18px;transition:.15s}.report-card:hover{transform:translateY(-3px);box-shadow:0 12px 25px rgba(16,47,85,.1)}.report-card b{color:var(--navy-dark)}.report-card p{font-size:11px;color:var(--muted);line-height:1.5;min-height:48px}.report-card .mx-act{margin-top:12px}.pill.GOLD{background:#fbf6e9;color:#97701f}
.hint{position:relative}.hint:hover::after{content:attr(data-hint);position:absolute;z-index:80;left:8px;bottom:calc(100% + 8px);width:240px;background:#102f55;color:#fff;padding:9px 11px;border-radius:7px;font-size:10px;line-height:1.45;box-shadow:0 8px 24px rgba(0,0,0,.18);pointer-events:none}.impact-popup{width:400px;box-shadow:0 18px 40px rgba(17,43,77,.22)}
@media(max-width:1050px){.sidebar{width:235px;flex-basis:235px}.dash-grid,.chronology,.report-grid{grid-template-columns:1fr 1fr}.process-strip{overflow:auto}.process-strip button{min-width:150px}.process-strip em{display:none}}
@media(max-width:760px){.sidebar{display:none}.dash-grid,.chronology,.report-grid{grid-template-columns:1fr}.content{padding:16px}.accounting-frame{height:680px}.topbar{position:relative}.kpis{grid-template-columns:1fr 1fr}}



/* MATOS overlap fix (host side) — isolate the accounting iframe's own
   stacking context so its internal dropdown menus never compete with the
   host topbar/cascade-notice z-index values. */
.accounting-frame { position: relative; isolation: isolate; z-index: 1; }
#mx-cascade-stack { z-index: 500; }

/* ==========================================================================
   MATOS — Design system reconciliation patch.
   Source of truth for these tokens/patterns: the attached reference build
   "Executive Cockpit — Mahlasedi Financial Operating System Demo" (the
   polished React/Tailwind demo the user wants the look to mirror).
   Extracted directly from its computed stylesheet, then reconciled against
   the tokens MATOS already defines (they are close but not identical —
   this patch does NOT introduce a second colour system, it aligns the one
   MATOS already has to the reference and adds the missing card/report/
   workbook patterns MATOS doesn't have yet).
   Append this AFTER the host file's existing :root declaration — the
   later declaration wins, so this reconciles the two palettes in place.
   ========================================================================== */

:root {
  /* Reconciled palette — MATOS's own --navy/--navy-dark/--navy-soft already
     match the reference exactly; --gold/--red are close, nudged to match;
     --teal/--amber/--ink/--muted/--canvas/--radius are new additions the
     reference uses for status colour and card geometry that MATOS didn't
     previously have named tokens for. */
  --navy: #173f73;
  --navy-dark: #0d2b50;
  --navy-soft: #edf3f9;
  --gold: #c29b4b;
  --gold-soft: #fbf6e9;
  --teal: #1b8a78;
  --teal-soft: #eaf7f3;
  --amber: #bc741d;
  --red: #b74b4b;
  --ink: #182332;
  --muted: #667384;
  --line: #dce3ec;
  --canvas: #f4f6f9;
  --radius: 14px;
}

/* ---------- Metric / KPI cards (used on Executive Cockpit, Portal KPI
   row, Operational Runs summary) ------------------------------------- */
.mx-metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 16px; }
.mx-metric-card { border: 1px solid var(--line); border-radius: var(--radius); background: #fff; padding: 15px 16px 14px; position: relative; overflow: hidden; box-shadow: 0 7px 20px rgba(20,47,77,.043); }
.mx-metric-card::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--navy); }
.mx-metric-card.tone-teal::before { background: var(--teal); }
.mx-metric-card.tone-amber::before { background: var(--amber); }
.mx-metric-card.tone-red::before { background: var(--red); }
.mx-metric-top { display: flex; justify-content: space-between; align-items: center; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; font-size: 10px; font-weight: 800; }
.mx-metric-top svg { color: var(--navy); }
.mx-metric-value { color: var(--navy-dark); letter-spacing: -.03em; margin: 9px 0 6px; font-size: 27px; font-weight: 800; }
.mx-metric-foot { display: flex; align-items: center; gap: 7px; font-size: 10px; }
.mx-metric-foot strong { color: var(--teal); }
.mx-metric-card.tone-amber .mx-metric-foot strong { color: var(--amber); }
.mx-metric-foot span { color: var(--muted); }

/* ---------- Panel heading pattern (kicker + h3 + supporting copy) --- */
.mx-panel-kicker { color: var(--gold); letter-spacing: .13em; text-transform: uppercase; font-size: 9px; font-weight: 900; }
.mx-panel-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.mx-panel-heading h3 { color: var(--navy-dark); margin: 3px 0 0; font-size: 15px; line-height: 1.25; }
.mx-panel-heading p { color: var(--muted); margin: 6px 0 0; font-size: 11px; }

/* ---------- Badges / pills (governed-insight, health score, mapped) -- */
.mx-badge-gold { background: var(--gold-soft); color: #7b6127; text-transform: uppercase; white-space: nowrap; border-radius: 99px; display: inline-flex; align-items: center; gap: 4px; padding: 5px 7px; font-size: 8px; font-weight: 900; }
.mx-badge-teal { background: var(--teal-soft); color: var(--teal); white-space: nowrap; border-radius: 99px; display: inline-flex; padding: 4px 7px; font-size: 8px; font-weight: 900; }
.mx-badge-red { background: #fdeeee; color: var(--red); white-space: nowrap; border-radius: 99px; display: inline-flex; padding: 4px 7px; font-size: 8px; font-weight: 900; }

/* ---------- Disclaimer / callout strips (used in Reports & Asset AMP) */
.mx-disclaimer { background: var(--gold-soft); color: #6b5b36; border: 1px solid #eadcb8; border-radius: 9px; display: flex; gap: 9px; padding: 10px 13px; font-size: 9px; line-height: 1.5; }
.mx-callout-teal { background: var(--teal-soft); color: #245e54; border-radius: 9px; display: flex; gap: 9px; padding: 12px; font-size: 10px; line-height: 1.55; }
.mx-callout-navy { background: var(--navy-soft); color: #34516f; border-radius: 9px; display: flex; gap: 9px; padding: 12px; font-size: 10px; line-height: 1.55; }

/* ---------- Report Studio cards (Reports & outputs redesign) -------- */
.mx-report-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 15px; }
.mx-report-card { border: 1px solid var(--line); border-radius: var(--radius); background: #fff; padding: 20px; box-shadow: 0 8px 22px rgba(18,43,72,.043); display: flex; flex-direction: column; }
.mx-report-icon { width: 42px; height: 42px; background: var(--navy-soft); color: var(--navy); border-radius: 10px; display: grid; place-items: center; }
.mx-report-card > span { color: var(--gold); letter-spacing: .1em; text-transform: uppercase; margin-top: 17px; font-size: 8px; font-weight: 900; }
.mx-report-card h3 { color: var(--navy-dark); margin: 4px 0 6px; font-size: 16px; }
.mx-report-card p { min-height: 62px; color: var(--muted); margin: 0 0 17px; font-size: 10.5px; line-height: 1.55; }
.mx-report-actions { display: flex; gap: 8px; margin-top: auto; }
.mx-report-actions button { flex: 1; }

/* ---------- Workbook / spreadsheet shell (Integrated Workbook fix) --- */
.mx-workbook-shell { background: #fff; border: 1px solid #bcc8d6; border-radius: 9px; overflow: hidden; box-shadow: 0 12px 30px rgba(22,51,86,.08); }
.mx-excel-toolbar { background: #f6f8fa; border-bottom: 1px solid #d8dfe7; display: grid; grid-template-columns: minmax(200px,.8fr) minmax(300px,1.5fr) auto; align-items: center; gap: 11px; min-height: 52px; padding: 8px 12px; }
.mx-excel-brand { color: #176b46; display: flex; align-items: center; gap: 7px; font-size: 10px; }
.mx-formula-bar { background: #fff; border: 1px solid #ced6df; display: flex; align-items: center; gap: 9px; height: 31px; padding: 0 9px; }
.mx-formula-bar span { color: #176b46; font-style: italic; font-weight: 800; }
.mx-formula-bar code { color: #405166; font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mx-sheet-tabs { background: #edf0f3; border-bottom: 1px solid #c9d1da; display: flex; gap: 1px; padding: 5px 7px 0; overflow-x: auto; }
.mx-sheet-tabs button { color: #526174; background: #e3e7eb; border: 0; border-radius: 5px 5px 0 0; padding: 7px 10px; font-size: 10px; white-space: nowrap; cursor: pointer; }
.mx-sheet-tabs button.active { color: #176b46; background: #fff; font-weight: 900; box-shadow: inset 0 -3px 0 #22875c; }
.mx-spreadsheet-wrap { border-top: 1px solid #d5dce4; border-bottom: 1px solid #d5dce4; overflow: auto; max-height: 520px; }
.mx-spreadsheet { border-collapse: collapse; width: 100%; min-width: 780px; }
.mx-spreadsheet th { background: #eaf1eb; color: #2c5140; text-align: left; font-size: 9px; font-weight: 900; }
.mx-spreadsheet th, .mx-spreadsheet td { border-bottom: 1px solid #e2e6ea; border-right: 1px solid #d7dde3; height: 31px; padding: 5px 8px; font-size: 9.5px; }
.mx-spreadsheet td { color: #3f4e60; }
.mx-spreadsheet th:first-child, .mx-spreadsheet td:first-child { text-align: center; color: #788492; background: #f0f2f4; width: 40px; }
.mx-num { font-variant-numeric: tabular-nums; text-align: right !important; }
.mx-num.neg { color: var(--red); }
.mx-num.pos { color: var(--teal); }
.mx-workbook-status { min-height: 27px; background: #f2f4f6; color: var(--muted); display: flex; align-items: center; gap: 18px; padding: 0 10px; font-size: 9px; }
.mx-workbook-status .live { color: #176b46; font-weight: 900; }

/* ---------- Print stylesheet (report preview → PDF via window.print) -- */
@media print {
  .sidebar, .topbar, #mainnav, .mx-cascade-card, #mx-cascade-stack,
  .mx-report-actions, .no-print { display: none !important; }
  .mx-report-print-area { display: block !important; }
  body { background: #fff !important; }
}


/* MATOS modal / zoom system — boxed, collapsible popups used across the app */
.mx-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(10,20,35,.55);z-index:2000;align-items:flex-start;justify-content:center;padding:6vh 16px;overflow:auto;}
.mx-modal{background:#fff;border-radius:12px;max-width:720px;width:100%;box-shadow:0 24px 60px rgba(0,0,0,.35);overflow:hidden;max-height:86vh;display:flex;flex-direction:column;}
.mx-modal.wide{max-width:1080px;}
.mx-modal-hd{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:var(--navy-dark,#0d2b50);color:#fff;font-size:13px;}
.mx-modal-btns button{background:rgba(255,255,255,.15);border:0;color:#fff;width:26px;height:26px;border-radius:6px;cursor:pointer;margin-left:6px;font-size:13px;}
.mx-modal-btns button:hover{background:rgba(255,255,255,.3);}
.mx-modal-bd{padding:16px;overflow:auto;}
.mx-modal.collapsed .mx-modal-bd{display:none;}
.mx-modal.collapsed{max-height:none;}

/* MATOS portal chooser — separate full-screen shell for MM/Council/NT */
#mxPortalShell{display:none;position:fixed;inset:0;background:var(--navy-soft,#edf3f9);z-index:1500;overflow:auto;}
#mxPortalShell .mx-portal-top{display:flex;align-items:center;gap:14px;padding:14px 24px;background:var(--navy-dark,#0d2b50);color:#fff;}
#mxPortalShell .mx-portal-top b{font-size:15px;}
#mxPortalShell .mx-portal-top button{margin-left:auto;background:rgba(255,255,255,.15);border:0;color:#fff;padding:8px 14px;border-radius:6px;cursor:pointer;font-size:12px;}
.mx-portal-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;padding:22px;max-width:1100px;margin:0 auto;}
.mx-portal-card{background:#fff;border-radius:12px;padding:20px;box-shadow:0 6px 20px rgba(20,40,70,.08);cursor:pointer;border:2px solid transparent;transition:border-color .15s;}
.mx-portal-card:hover{border-color:var(--gold,#b48736);}
.mx-portal-card h3{margin:0 0 6px;color:var(--navy-dark,#0d2b50);}
.mx-portal-card p{margin:0;color:var(--muted,#6b7683);font-size:12.5px;line-height:1.5;}
.mx-portal-body{max-width:1100px;margin:0 auto;padding:0 22px 30px;}
body.mx-portal-active > .sidebar, body.mx-portal-active > .main{visibility:hidden;}
HTMLTen panels
<!-- ==================== TREASURY CONTROL TOWER ==================== -->
<section class="panel" id="panel-treas">
  <div class="pagehead">
    <h2>Treasury Control Tower</h2>
    <p>The three tabs that used to sit apart — revenue and expenditure, the asset and liability model, and the MFD-MM Master — are one module here, in the order the money actually moves. Flows first: what was billed and what it cost. Then position: what the flows left on the balance sheet and when it falls due. Then the cascade: how a rand of rates billed becomes a rand of renewed infrastructure, and where it leaks on the way.</p>
  </div>
  <div class="kpis" id="tcKpis"></div>
  <div class="subtabs" id="tcTabs">
    <button data-tc="flows" class="active">1 · Flows — revenue &amp; expenditure</button>
    <button data-tc="position">2 · Position — balance sheet</button>
    <button data-tc="alm">3 · Working capital &amp; liquidity</button>
    <button data-tc="cascade">4 · MFD-MM cascade</button>
    <button data-tc="ratios">5 · Treasury ratios</button>
  </div>

  <div class="ax-sub on" id="tc-flows">
    <div class="ax-note"><b>Step one — what came in and what went out.</b> Billed against collected, budget against actual, and the mix between revenue the municipality controls and revenue it receives. Everything downstream in this module is built from these two columns.</div>
    <div class="ax-split">
      <div class="card"><div class="hd"><h3>Revenue</h3><span class="desc" id="tcRevTag"></span></div>
        <div class="bd" style="padding:0;"><table class="grid" id="tcRev">
          <thead><tr><th style="width:34%">Source</th><th class="num">Billed</th><th class="num">Collected</th><th class="num">Rate</th><th>Control</th></tr></thead><tbody></tbody></table></div></div>
      <div class="card"><div class="hd"><h3>Expenditure</h3><span class="desc" id="tcExpTag"></span></div>
        <div class="bd" style="padding:0;"><table class="grid" id="tcExp">
          <thead><tr><th style="width:34%">Category</th><th class="num">Actual</th><th class="num">Share</th><th class="num">Cash</th><th>Nature</th></tr></thead><tbody></tbody></table></div></div>
    </div>
    <div class="card"><div class="hd"><h3>Revenue mix and its stability</h3><span class="desc">Hover a source to read its exposure</span></div>
      <div class="bd"><div class="ax-chart" id="tcMixChart"></div>
        <div class="ax-legend"><span><i class="b-navy"></i>Own revenue — the municipality sets the tariff</span><span><i class="b-teal"></i>Transfers — set nationally</span><span><i class="b-grey"></i>Amount lost to non-collection</span></div>
        <div class="ax-read" id="tcMixRead">Hover a revenue source to see what proportion the municipality controls and how much it loses to non-payment.</div></div></div>
    <div class="card"><div class="hd"><h3>Operating result</h3><span class="desc">Accrual, then reconciled to cash</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="tcResult">
        <thead><tr><th style="width:40%">Line</th><th class="num">R'000</th><th class="num">% of revenue</th><th>Note</th></tr></thead><tbody></tbody></table></div></div>
  </div>

  <div class="ax-sub" id="tc-position">
    <div class="ax-note"><b>Step two — what the flows left behind.</b> The balance sheet is grouped by what treasury can actually do with each item: cash it can deploy, receivables it must collect, assets it must maintain, obligations it must fund.</div>
    <div class="card"><div class="hd"><h3>Statement of financial position, grouped by treasury use</h3><span class="desc" id="tcPosTag"></span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="tcPos">
        <thead><tr><th style="width:36%">Item</th><th class="num">FY2023</th><th class="num">FY2024</th><th class="num">FY2025</th><th class="num">Movement</th><th>Treasury view</th></tr></thead><tbody></tbody></table></div></div>
    <div class="ax-split">
      <div class="card"><div class="hd"><h3>Working capital</h3><span class="desc">Hover a component</span></div>
        <div class="bd"><div class="ax-chart" id="tcWcChart"></div>
          <div class="ax-legend"><span><i class="b-teal"></i>Current assets</span><span><i class="b-red"></i>Current liabilities</span><span><i class="b-navy"></i>Net working capital</span></div>
          <div class="ax-read" id="tcWcRead">Hover a year to read the working capital position.</div></div></div>
      <div class="card"><div class="hd"><h3>Cash quality</h3><span class="desc">Not all cash is available</span></div>
        <div class="bd" style="padding:0;"><table class="grid" id="tcCashQ">
          <thead><tr><th>Layer</th><th class="num">R'000</th><th>Restriction</th><th class="num">Days of cover</th></tr></thead><tbody></tbody></table></div>
        <div class="bd" style="border-top:1px solid var(--line);" id="tcCashQNote"></div></div>
    </div>
  </div>

  <div class="ax-sub" id="tc-alm">
    <div class="ax-note"><b>Step three — the cash cycle and what it can absorb.</b> Working capital first, because that is where a municipality
      actually runs out of money; then the thirteen-week projection that treasury manages against; then debt coverage; then the maturity ladder
      underneath it all. Four levers at the foot of the tab let you test what a collection push or a stretched creditor actually buys.</div>
    <div class="kpis" id="tcWcKpis"></div>
    <div class="subtabs" id="tcWcTabs">
      <button data-wc="cycle" class="active">Working capital cycle</button>
      <button data-wc="proj">Thirteen-week projection</button>
      <button data-wc="cover">Debt coverage</button>
      <button data-wc="ladder">Maturity ladder</button>
      <button data-wc="levers">Levers &amp; result</button>
    </div>

    <div class="ax-sub on" id="wc-cycle">
      <div class="card"><div class="hd"><h3>The cash conversion cycle</h3><span class="desc" id="tcCycTag"></span></div>
        <div class="bd" style="padding:0;"><table class="grid" id="tcCycle">
          <thead><tr><th style="width:26%">Stage</th><th>How it is measured</th><th class="num">FY2023</th><th class="num">FY2024</th><th class="num">FY2025</th><th class="num">Norm</th><th>Result</th></tr></thead><tbody></tbody></table></div>
        <div class="bd" style="border-top:1px solid var(--line);" id="tcCycNote"></div></div>
      <div class="ax-split">
        <div class="card"><div class="hd"><h3>Working capital components</h3><span class="desc">Hover a year</span></div>
          <div class="bd"><div class="ax-chart" id="tcWcChart2"></div>
            <div class="ax-legend"><span><i class="b-teal"></i>Receivables</span><span><i class="b-grey"></i>Inventory</span><span><i class="b-red"></i>Payables</span><span><i class="b-navy"></i>Net working capital excluding cash</span></div>
            <div class="ax-read" id="tcWcRead2">Hover a year to read the components and the net position.</div></div></div>
        <div class="card"><div class="hd"><h3>Receivables quality</h3><span class="desc">Ageing drives both cash and impairment</span></div>
          <div class="bd" style="padding:0;"><table class="grid" id="tcAge">
            <thead><tr><th>Ageing band</th><th class="num">Balance</th><th class="num">Share</th><th class="num">Expected to be collected</th><th>Treatment</th></tr></thead><tbody></tbody></table></div>
          <div class="bd" style="border-top:1px solid var(--line);" id="tcAgeNote"></div></div>
      </div>
      <div class="card"><div class="hd"><h3>Cash quality — what treasury can actually deploy</h3></div>
        <div class="bd" style="padding:0;"><table class="grid" id="tcCashQ">
          <thead><tr><th>Layer</th><th class="num">R'000</th><th>Restriction</th><th class="num">Days of cover</th></tr></thead><tbody></tbody></table></div>
        <div class="bd" style="border-top:1px solid var(--line);" id="tcCashQNote"></div></div>
    </div>

    <div class="ax-sub" id="wc-proj">
      <div class="card"><div class="hd"><h3>Thirteen-week cash projection</h3><span class="desc" id="tcProjTag"></span></div>
        <div class="bd"><div class="ax-chart" id="tcProjChart"></div>
          <div class="ax-legend"><span><i class="b-teal"></i>Receipts</span><span><i class="b-red"></i>Payments</span><span><i class="b-navy"></i>Closing balance</span><span><i class="b-gold"></i>Minimum operating buffer</span></div>
          <div class="ax-read" id="tcProjRead">Hover a week to read receipts, payments and the closing balance.</div></div>
        <div class="bd" style="padding:0;border-top:1px solid var(--line);overflow:auto;"><table class="grid" id="tcProjTable"></table></div>
        <div class="bd" style="border-top:1px solid var(--line);" id="tcProjNote"></div></div>
      <div class="card"><div class="hd"><h3>Commitments inside the window</h3><span class="desc">What is already contracted and cannot be deferred</span></div>
        <div class="bd" style="padding:0;"><table class="grid" id="tcCommit">
          <thead><tr><th style="width:26%">Commitment</th><th class="num">Amount</th><th>Timing</th><th>Deferrable</th><th>Consequence of deferral</th></tr></thead><tbody></tbody></table></div></div>
    </div>

    <div class="ax-sub" id="wc-cover">
      <div class="card"><div class="hd"><h3>Debt coverage and covenant headroom</h3><span class="desc">Click a row for the formula and the headroom calculation</span></div>
        <div class="bd" style="padding:0;"><table class="grid" id="tcCover">
          <thead><tr><th style="width:24%">Cover measure</th><th>What it protects against</th><th class="num">Value</th><th class="num">Covenant</th><th style="width:18%">Headroom</th><th>Result</th></tr></thead><tbody></tbody></table></div></div>
      <div class="ax-split">
        <div class="card"><div class="hd"><h3>Interest rate repricing</h3></div>
          <div class="bd" style="padding:0;"><table class="grid" id="tcReprice">
            <thead><tr><th>Instrument</th><th class="num">Balance</th><th>Basis</th><th class="num">Rate</th><th class="num">Repricing</th></tr></thead><tbody></tbody></table></div>
          <div class="bd" style="border-top:1px solid var(--line);" id="tcRepriceNote"></div></div>
        <div class="card"><div class="hd"><h3>Borrowing capacity</h3><span class="desc">What could still be raised without breaching a limit</span></div>
          <div class="bd" style="padding:0;"><table class="grid" id="tcCapacity">
            <thead><tr><th>Constraint</th><th class="num">Limit</th><th class="num">Used</th><th class="num">Capacity remaining</th></tr></thead><tbody></tbody></table></div>
          <div class="bd" style="border-top:1px solid var(--line);" id="tcCapNote"></div></div>
      </div>
    </div>

    <div class="ax-sub" id="wc-ladder">
      <div class="card"><div class="hd"><h3>Maturity ladder</h3><span class="desc" id="tcAlmTag"></span></div>
        <div class="bd" style="padding:0;"><table class="grid" id="tcLadder">
          <thead><tr><th style="width:22%">Bucket</th><th class="num">Inflows</th><th class="num">Outflows</th><th class="num">Net gap</th><th class="num">Cumulative</th><th style="width:20%">Cover</th></tr></thead><tbody></tbody></table></div></div>
      <div class="card"><div class="hd"><h3>Gap profile</h3><span class="desc">Hover a bucket</span></div>
        <div class="bd"><div class="ax-chart" id="tcGapChart"></div>
          <div class="ax-legend"><span><i class="b-teal"></i>Inflows</span><span><i class="b-red"></i>Outflows</span><span><i class="b-navy"></i>Cumulative gap</span></div>
          <div class="ax-read" id="tcGapRead">Hover a maturity bucket to read inflows, outflows and the cumulative position.</div></div></div>
    </div>

    <div class="ax-sub" id="wc-levers">
      <div class="ax-note"><b>Four things treasury can actually do, and what each one buys.</b> These are working-capital levers, not budget levers —
        none of them changes the surplus. They change when cash arrives and when it leaves, which is a different problem and usually the more urgent one.</div>
      <div class="ax-25">
        <div><div class="card"><div class="hd"><h3>Levers</h3><button class="btn-ghost" id="tcLevReset" style="margin-left:auto;">Reset</button></div>
          <div class="bd" id="tcLeverBox"></div></div></div>
        <div>
          <div class="kpis" id="tcLevKpis"></div>
          <div class="card"><div class="hd"><h3>Result</h3><span class="desc" id="tcLevTag"></span></div>
            <div class="bd" style="padding:0;"><table class="grid" id="tcLevResult">
              <thead><tr><th style="width:30%">Measure</th><th class="num">Base</th><th class="num">After the levers</th><th class="num">Movement</th><th style="width:20%">Effect</th></tr></thead><tbody></tbody></table></div>
            <div class="bd" style="border-top:1px solid var(--line);" id="tcLevNote"></div></div>
          <div class="card"><div class="hd"><h3>Cash position over thirteen weeks</h3><span class="desc">Hover a week</span></div>
            <div class="bd"><div class="ax-chart" id="tcLevChart"></div>
              <div class="ax-legend"><span><i class="b-grey"></i>Base closing balance</span><span><i class="b-navy"></i>After the levers</span><span><i class="b-gold"></i>Minimum buffer</span></div>
              <div class="ax-read" id="tcLevRead">Hover a week to compare the two paths.</div></div></div>
        </div>
      </div>
    </div>
  </div>

  <div class="ax-sub" id="tc-cascade">
    <div class="ax-note"><b>Step four — the synthesis.</b> The MFD-MM Master is a single question asked in seven steps: of every rand billed to a ratepayer, how much ends up as renewed infrastructure, and where did the rest go? Each stage shows the amount carried forward and the leakage taken out. Click a stage to see which module controls it.</div>
    <div class="card"><div class="hd"><h3>Rates-to-Resilience cascade</h3><span class="desc" id="tcCascTag"></span></div>
      <div class="bd"><div class="ax-casc" id="tcCascade"></div></div>
      <div class="bd" style="border-top:1px solid var(--line);" id="tcCascNote"></div></div>
    <div class="card"><div class="hd"><h3>Where the leakage goes</h3><span class="desc">Hover a stage</span></div>
      <div class="bd"><div class="ax-chart" id="tcLeakChart"></div>
        <div class="ax-legend"><span><i class="b-navy"></i>Carried to the next stage</span><span><i class="b-red"></i>Lost at this stage</span></div>
        <div class="ax-read" id="tcLeakRead">Hover a stage to read what it carries forward and what it loses.</div></div></div>
    <div class="card"><div class="hd"><h3>Who controls each leak</h3></div>
      <div class="bd" style="padding:0;"><table class="grid" id="tcLeakOwn">
        <thead><tr><th style="width:22%">Leak</th><th class="num">R'000</th><th>Controlled by</th><th>Lever that moves it</th><th>Module</th></tr></thead><tbody></tbody></table></div></div>
  </div>

  <div class="ax-sub" id="tc-ratios">
    <div class="card"><div class="hd"><h3>Treasury ratio set</h3><span class="desc">Click a row for the formula and the three-year path</span>
      <button class="btn-ghost" id="tcOnlyBreach" style="margin-left:10px;">Show only ratios outside their norm</button></div>
      <div class="bd" style="padding:0;"><table class="grid" id="tcRatios">
        <thead><tr><th style="width:26%">Ratio</th><th>Group</th><th class="num">FY2023</th><th class="num">FY2024</th><th class="num">FY2025</th><th class="num">Norm</th><th>Result</th></tr></thead><tbody></tbody></table></div></div>
  </div>
</section>

(marker not found)

<!-- ==================== ASSET REGISTER & AMP ==================== -->
<section class="panel" id="panel-asset">
  <div class="pagehead">
    <h2>Asset register &amp; asset management plan</h2>
    <p>The register is the sub-ledger behind the largest number on the statement of financial position, and it is where most municipal audit qualifications originate. This module carries the seventeen fields GRAP 17 and the audit assertions require, the class-by-class breakdown validated against the general ledger, componentisation, the depreciation engine, work in progress, the three-way reconciliation to the annual financial statements, physical verification, and the asset management plan that says what it will cost to keep the base standing.</p>
  </div>
  <div class="kpis" id="arKpis"></div>
  <div class="subtabs" id="arTabs">
    <button data-ar="summary" class="active">1 · Register summary</button>
    <button data-ar="quality">2 · Data quality</button>
    <button data-ar="ppe">3 · PPE breakdown</button>
    <button data-ar="comp">4 · Componentisation</button>
    <button data-ar="depn">5 · Depreciation &amp; useful lives</button>
    <button data-ar="wip">6 · Work in progress</button>
    <button data-ar="recon">7 · Three-way reconciliation</button>
    <button data-ar="verify">8 · Physical verification</button>
    <button data-ar="amp">9 · Asset management plan</button>
    <button data-ar="cascade">10 · Cascade to GL &amp; AFS</button>
  </div>

  <div class="ax-sub on" id="ar-summary">
    <div class="ax-note"><b>One register, one carrying amount, one number in the statements.</b> Everything on the tabs that follow reconciles back to the total below. Where it does not, the difference is a reconciling item with a name, an owner and a correcting entry — not a rounding.</div>
    <div class="card"><div class="hd"><h3>Register at a glance</h3><span class="desc" id="arSumTag"></span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arSummary">
        <thead><tr><th style="width:26%">Asset class</th><th class="num">Items</th><th class="num">Gross cost</th><th class="num">Accumulated depreciation</th><th class="num">Carrying amount</th><th style="width:14%">Consumed</th><th>GRAP treatment</th></tr></thead><tbody></tbody></table></div></div>
    <div class="ax-split">
      <div class="card"><div class="hd"><h3>Where the value sits</h3><span class="desc">Hover a class</span></div>
        <div class="bd"><div class="ax-chart" id="arMixChart"></div>
          <div class="ax-legend"><span><i class="b-grey"></i>Gross cost</span><span><i class="b-navy"></i>Carrying amount</span><span><i class="b-red"></i>Consumed to date</span></div>
          <div class="ax-read" id="arMixRead">Hover an asset class to read its gross cost, what has been consumed and what remains.</div></div></div>
      <div class="card"><div class="hd"><h3>Remaining life of the asset base</h3><span class="desc">Carrying amount as a share of gross cost</span></div>
        <div class="bd" style="padding:0;"><table class="grid" id="arLife">
          <thead><tr><th>Asset class</th><th style="width:24%">Life remaining</th><th class="num">Years left</th><th>Position</th></tr></thead><tbody></tbody></table></div>
        <div class="bd" style="border-top:1px solid var(--line);" id="arLifeNote"></div></div>
    </div>
  </div>

  <div class="ax-sub" id="ar-quality">
    <div class="ax-note"><b>Seventeen fields, each tied to an audit assertion.</b> A register is not compliant because it exists; it is compliant when every asset carries every field the assertion depends on. Click a field to see what it proves, what goes wrong with it, and which assets are missing it.</div>
    <div class="kpis" id="arQualKpis"></div>
    <div class="card"><div class="hd"><h3>Minimum required data fields</h3><span class="desc">GRAP 17 and the AGSA assertion set</span>
      <button class="btn-ghost" id="arOnlyGaps" style="margin-left:10px;">Show only fields with gaps</button></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arFields">
        <thead><tr><th style="width:5%">#</th><th style="width:20%">Field</th><th>Purpose</th><th>Assertion</th><th class="num">Complete</th><th style="width:14%">Population</th><th>Result</th></tr></thead><tbody></tbody></table></div></div>
  </div>

  <div class="ax-sub" id="ar-ppe">
    <div class="ax-note"><b>The breakdown, validated line by line.</b> Register against general ledger, class by class, with the difference and its cause named. Click a class to open its movement schedule for the year.</div>
    <div class="card"><div class="hd"><h3>Register against general ledger</h3><span class="desc" id="arPpeTag"></span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arPpe">
        <thead><tr><th style="width:22%">Asset class</th><th class="num">Reg. gross</th><th class="num">GL gross</th><th class="num">Reg. accum.</th><th class="num">GL accum.</th><th class="num">CA difference</th><th>Match</th></tr></thead><tbody></tbody></table></div></div>
    <div class="card"><div class="hd"><h3>Validation tests</h3><span class="desc">Run against the register itself, not against the ledger</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arValid">
        <thead><tr><th style="width:30%">Test</th><th>What it catches</th><th class="num">Exceptions</th><th class="num">Value at risk</th><th>Result</th></tr></thead><tbody></tbody></table></div></div>
  </div>

  <div class="ax-sub" id="ar-comp">
    <div class="ax-note"><b>GRAP 17.43 — each part with a cost significant in relation to the total is depreciated separately.</b> There is no prescribed percentage; the AGSA position is that componentisation is expected where a component exceeds ten to twenty per cent of asset cost and its useful life differs materially. A building depreciated as one line with a fifty-year life is understating depreciation on a roof that will be replaced in fifteen.</div>
    <div class="card"><div class="hd"><h3>Componentisation status by class</h3><span class="desc">Click a class for its component structure</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arComp">
        <thead><tr><th style="width:24%">Asset class</th><th class="num">Carrying amount</th><th class="num">Componentised</th><th style="width:18%">Coverage</th><th class="num">Depreciation understated</th><th>Result</th></tr></thead><tbody></tbody></table></div></div>
    <div class="card"><div class="hd"><h3>Worked component structure — water treatment works</h3><span class="desc">The pattern applied to every componentised asset</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arCompEx">
        <thead><tr><th style="width:24%">Component</th><th class="num">Cost</th><th class="num">% of total</th><th class="num">Useful life</th><th class="num">Residual</th><th class="num">Annual depreciation</th></tr></thead><tbody></tbody></table></div>
      <div class="bd" style="border-top:1px solid var(--line);" id="arCompNote"></div></div>
  </div>

  <div class="ax-sub" id="ar-depn">
    <div class="ax-note"><b>GRAP 17.51 requires useful life and residual value to be reviewed at every reporting date.</b> Not at adoption, not when convenient — every year. An entity that has not reviewed its estimates for three years or more carries a GRAP 3 prior-period error risk, and the AGSA routinely queries registers still running the lives adopted in 2009 to 2012.</div>
    <div class="card"><div class="hd"><h3>Useful lives against the National Treasury benchmark</h3><span class="desc">Click a class for the annual charge and the review position</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arLives">
        <thead><tr><th style="width:22%">Asset class</th><th class="num">Life applied</th><th>NT benchmark</th><th class="num">Last reviewed</th><th class="num">Annual charge</th><th>Position</th></tr></thead><tbody></tbody></table></div></div>
    <div class="ax-split">
      <div class="card"><div class="hd"><h3>Depreciation charge by class</h3><span class="desc">Hover a class</span></div>
        <div class="bd"><div class="ax-chart" id="arDepnChart"></div>
          <div class="ax-legend"><span><i class="b-navy"></i>Charge at the life applied</span><span><i class="b-gold"></i>Charge at the benchmark life</span></div>
          <div class="ax-read" id="arDepnRead">Hover a class to compare the charge at the applied life against the benchmark.</div></div></div>
      <div class="card"><div class="hd"><h3>Effect of a life review</h3><span class="desc">What correcting the estimates would do</span></div>
        <div class="bd" style="padding:0;"><table class="grid" id="arRevEffect">
          <thead><tr><th>Line</th><th class="num">As reported</th><th class="num">At benchmark lives</th><th class="num">Movement</th></tr></thead><tbody></tbody></table></div>
        <div class="bd" style="border-top:1px solid var(--line);" id="arRevNote"></div></div>
    </div>
  </div>

  <div class="ax-sub" id="ar-wip">
    <div class="ax-note"><b>Capital work in progress is not depreciated and is not yet an asset in use.</b> GRAP 17.23 allows only directly attributable cost into the carrying amount. The two things that go wrong are cost that should have been expensed sitting in WIP, and completed assets that were never transferred out of it — which understates depreciation for as long as they stay there.</div>
    <div class="card"><div class="hd"><h3>Work in progress by project</h3><span class="desc" id="arWipTag"></span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arWip">
        <thead><tr><th style="width:24%">Project</th><th class="num">Opening</th><th class="num">Additions</th><th class="num">Transferred to PPE</th><th class="num">Closing</th><th class="num">Age</th><th>Status</th></tr></thead><tbody></tbody></table></div></div>
    <div class="ax-split">
      <div class="card"><div class="hd"><h3>What may be capitalised</h3><span class="desc">GRAP 17.23 and GRAP 5</span></div>
        <div class="bd" style="padding:0;"><table class="grid" id="arWipCap">
          <thead><tr><th style="width:38%">Cost</th><th>Treatment</th><th>Basis</th></tr></thead><tbody></tbody></table></div></div>
      <div class="card"><div class="hd"><h3>Ageing and the transfer test</h3><span class="desc">WIP older than the construction period is a red flag</span></div>
        <div class="bd" style="padding:0;"><table class="grid" id="arWipAge">
          <thead><tr><th>Age band</th><th class="num">Balance</th><th class="num">Projects</th><th>Treatment required</th></tr></thead><tbody></tbody></table></div>
        <div class="bd" style="border-top:1px solid var(--line);" id="arWipNote"></div></div>
    </div>
  </div>

  <div class="ax-sub" id="ar-recon">
    <div class="ax-note"><b>Register to general ledger to note to the face of the statements.</b> Four steps, each of which must agree. A difference at any step is a material misstatement, not a reconciling item to be carried forward.</div>
    <div class="card"><div class="hd"><h3>The four steps</h3><span class="desc" id="arReconTag"></span></div>
      <div class="bd"><div class="ax-casc" id="arReconSteps"></div></div></div>
    <div class="card"><div class="hd"><h3>Differences identified</h3><span class="desc">Each one traced to its cause</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arDiff">
        <thead><tr><th style="width:20%">Class</th><th class="num">Difference</th><th>Cause</th><th>Which side is correct</th><th>Correction</th></tr></thead><tbody></tbody></table></div></div>
    <div class="card"><div class="hd"><h3>Correcting journals</h3><span class="desc">Posted to the ledger, or to the register alone</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arJournals">
        <thead><tr><th style="width:20%">Correction</th><th>Account</th><th class="num">Debit</th><th class="num">Credit</th><th>Where it posts</th></tr></thead><tbody></tbody></table></div>
      <div class="bd" style="border-top:1px solid var(--line);">
        <div style="display:flex;gap:9px;flex-wrap:wrap;align-items:center;">
          <button class="btn-primary" id="arPostBtn">Post the correcting journals</button>
          <span style="font-size:10.5px;color:var(--muted)">Posting writes to the same ledger the rest of MATOS reads, so the trial balance, the note and the carrying amount all move together.</span>
        </div></div></div>
    <div class="card"><div class="hd"><h3>The PPE note after correction</h3><span class="desc">What appears in the annual financial statements</span></div>
      <div class="bd" style="padding:0;overflow:auto;"><table class="grid" id="arNote"></table></div></div>
  </div>

  <div class="ax-sub" id="ar-verify">
    <div class="ax-note"><b>Verification answers the existence assertion, and nothing else can.</b> An asset on the register that cannot be found is a ghost asset: it overstates cost, overstates accumulated depreciation, carries a depreciation charge on nothing, and attracts an insurance premium that is fruitless expenditure under the MFMA.</div>
    <div class="kpis" id="arVerKpis"></div>
    <div class="card"><div class="hd"><h3>Verification result by class</h3><span class="desc" id="arVerTag"></span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arVerify">
        <thead><tr><th style="width:22%">Asset class</th><th class="num">On register</th><th class="num">Verified</th><th class="num">Not found</th><th class="num">Unrecorded</th><th style="width:16%">Coverage</th><th>Result</th></tr></thead><tbody></tbody></table></div></div>
    <div class="ax-split">
      <div class="card"><div class="hd"><h3>Financial effect of the discrepancies</h3></div>
        <div class="bd" style="padding:0;"><table class="grid" id="arVerEffect">
          <thead><tr><th style="width:36%">Effect</th><th class="num">R'000</th><th>Assertion failed</th></tr></thead><tbody></tbody></table></div>
        <div class="bd" style="border-top:1px solid var(--line);" id="arVerNote"></div></div>
      <div class="card"><div class="hd"><h3>Condition profile</h3><span class="desc">Hover a rating</span></div>
        <div class="bd"><div class="ax-chart" id="arCondChart"></div>
          <div class="ax-legend"><span><i class="b-navy"></i>Carrying amount at this condition</span><span><i class="b-red"></i>Renewal required within three years</span></div>
          <div class="ax-read" id="arCondRead">Hover a condition rating to see what sits at it and what it will cost.</div></div></div>
    </div>
  </div>

  <div class="ax-sub" id="ar-amp">
    <div class="ax-note"><b>The asset management plan is the register turned into a spending commitment.</b> Condition drives remaining life, remaining life drives the year renewal falls due, and the sum of those years is the renewal profile. Where the profile exceeds what is budgeted, the difference is not a funding gap in the abstract — it is a dated list of assets that will fail before they are replaced.</div>
    <div class="kpis" id="arAmpKpis"></div>
    <div class="card"><div class="hd"><h3>Renewal requirement by class</h3><span class="desc">Click a class for its condition profile and renewal timing</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arAmp">
        <thead><tr><th style="width:22%">Asset class</th><th class="num">Carrying amount</th><th class="num">Replacement cost</th><th class="num">Average condition</th><th class="num">Renewal due in 10 years</th><th class="num">Annual requirement</th><th>Funded</th></tr></thead><tbody></tbody></table></div></div>
    <div class="card"><div class="hd"><h3>Ten-year renewal profile</h3><span class="desc">Hover a year</span></div>
      <div class="bd"><div class="ax-chart" id="arAmpChart"></div>
        <div class="ax-legend"><span><i class="b-grey"></i>Renewal required</span><span><i class="b-teal"></i>Budgeted capital and maintenance</span><span><i class="b-red"></i>Unfunded</span></div>
        <div class="ax-read" id="arAmpRead">Hover a year to read the renewal requirement, what is budgeted and the gap.</div></div></div>
    <div class="ax-split">
      <div class="card"><div class="hd"><h3>Maintenance against the norm</h3></div>
        <div class="bd" style="padding:0;"><table class="grid" id="arMaint">
          <thead><tr><th>Class</th><th class="num">Maintenance spend</th><th class="num">% of carrying amount</th><th class="num">Norm</th><th>Result</th></tr></thead><tbody></tbody></table></div>
        <div class="bd" style="border-top:1px solid var(--line);" id="arMaintNote"></div></div>
      <div class="card"><div class="hd"><h3>What the plan concludes</h3></div>
        <div class="bd" id="arAmpVerdict"></div></div>
    </div>
  </div>

  <div class="ax-sub" id="ar-cascade">
    <div class="ax-note"><b>Where every movement in this module lands.</b> The register is a sub-ledger. Nothing here is complete until it has moved the control account, the trial balance, the note and the carrying amount on the face of the statements.</div>
    <div class="card"><div class="hd"><h3>Downstream effect</h3></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arCascade">
        <thead><tr><th style="width:24%">Destination</th><th>What travels</th><th class="num">Current movement</th><th>Trigger</th></tr></thead><tbody></tbody></table></div></div>
    <div class="card"><div class="hd"><h3>Register events and the entries they raise</h3><span class="desc">The full posting map</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arEvents">
        <thead><tr><th style="width:22%">Event</th><th>Debit</th><th>Credit</th><th>Also updates</th><th>Authority required</th></tr></thead><tbody></tbody></table></div></div>
    <div class="card"><div class="hd"><h3>Integration with the rest of MATOS</h3></div>
      <div class="bd" style="padding:0;"><table class="grid" id="arIntegrate">
        <thead><tr><th style="width:22%">Module</th><th>Reads from the register</th><th>Writes to the register</th><th>Function</th></tr></thead><tbody></tbody></table></div></div>
  </div>
</section>

<section class="panel" id="panel-pa"><div class="mx-note ok"><b>Investment upload package recognised.</b> The attached BRT project pack is the worked case for this module: 14 checklist documents, 12 assumptions, and supporting model files. <a href="data:application/zip;base64,UEsDBBQAAAAIAPSZLV0vqnJU8QEAAGgDAAAJAAAAUkVBRE1FLm1kZZLBbtQwEIbvfoqRuO5G7YELtwJCqkTpql2kcpy1J42JY1tje0N64iF4Qp6EsbO7opBTnPz+55t/5g28f9jDjsN30hl2qEf4/fMX3L2gTxYeMFqzgT0XP8KHwGxNYLhR6mt0AQ3kwSZ4sRFykHeCW59xJMh4gNC3L3ff9vePF/+bGBltQgdTMMVRp9ReROsBmNCkdgtZD/ZIYH07HjjMibiDL0Eq+meQqqURkAH0yzwQi9dthmz1mIBQD9BbsQz9itGHwpnIb03QZSKfQQ+kR2dThsPStB4ngog5E/uNuBqwWa1ImFKZYrbBp06nIxjL0o1bYCs26J8rkmCce84zOYEfaWndMK0qErAj8aKCp3+keMklZYqpxsJLS/fd6a6Q/biqMV9fv72qTyNk2q45dDIdaUWwQi0BPTqXNq1EiMSYBVEZ6q22orKJTj/R+yJVtcghrGFPxYsqVpbC0ZW06mG2eZBI1mhEqGqxSbI0WJOp1yv8uaVTy+cJI48mzB7O+UuovE4cDzInyVckVsqkLP5txl6mxg1p9/GT6KvaSQ56VHMozgg189LBXxtkgjj4kEXFqd0DsXj6/PgEOvhc574Vx5DoNYiiozXkNV1KXtZj0/ysj0WkpzUX0jxMJMvWwb2XPfhvQVi2N6m8kpHr1B9QSwMEFAAAAAgA1ZktXcwn32TFAQAAOQMAAA8AAABhc3N1bXB0aW9ucy5jc3aVkkFy2zAMRfc6BQ5Ae2QnTrNN3TTTRZxM4wvAFCRxLJMsCNrx7QuyTutuOlOtSA3w/8Mn9nQ2R5wymeydGB+EmojvrVncLtvymYgpkR+IE0RiOBOyeYnknR8Avc84QUTh4HEg6DkcQEaCZbtcwRhyojFMHQjjkSZImY90LgZPHE4ymnbe3tya37rrwOy6wFeKQy1semQqUPP71jD6rrIIu2gejsTVWisABcKFLfuulChL55KgtzTbaSMpDLLr+yr5mGxhWK3+MHwtOpQsTigueJjB+vUbxCknaOc3EIPzkhp1eb803y3NdTCsbWpvQ5K/ZU5KmaCw91mz2E1UaJooflhjfBvVV+XuWpPq8TXvJmdhq9OmGFhgQ3IKvIcn/SNgMTrR7E/Od+FUVV7+WyT8wg0+fehoVnYTDqqxWKx0HQ7O42SedTmsi8WO3DCKhoiX2Oucof/gqQKPNviSzL1h0u5NtdDmrV51Bc6QgnV6L7UhK4dCUDMF9N/1UDs/Xb3Il89vD6B4zqrQkUCID5BGIqk9W/KBzbI1pTj9q5p+ZCfnLfJAUia8NnlG3pMome5Neb8ZVJmeMQlnK5nLrLpFKZW8mp9QSwMEFAAAAAgA1ZktXZo4FNK4AQAAuQIAABIAAABjYXBleF9lc3RpbWF0ZS5jc3ZNks1u2zAQhO96in0ANpBlOVWPjvuDADkErpGrsaZWMmGKVLmkDL99l7T7cyIIzs7OfNLVh8txRn3BkZT2HI+hrmuVmIZkj9YMdLwRBlYnZMPVISR3geCxv+INeuqNxkg9WHTEgK4HbRZjWbV1k33aWu1FADMFuEzgFzlX3VObL8Zpm3rjRpjEB10ZZzM6tIYxGu+qn7GcDJHCZJwpEuPkps/oRmK16rq8p9mopgFtPUsY/jOV1c2/2QiWFrJw8hjy3uorzT4yXIUBn/38KOBdDN6CJjlJrdrnx4LD1UvjMjH4IFro2k8LnY22BIMlitXeW5sLcfT6Au0zYIhGJ1sYZfN1BwNRT0GtmwJo1ag3eaC8l+N/TPoUb48802SYpVCO/CrlZcco4SAGdDz7EItuwEAitpZ0bq++FDCd2n7fwfbjDWZkJmEWZInknwqjMrnbHT6qnAJQ/0qGzd8XprCYmHoBvWmzXa1ehI2FBW26G2zTmCR4Uzeb6j34gUpUtLkn3yOivhv2i9FUfL+9btW6fvwgO5xNzN9cICRBEeDHfvsOq89PzRqQoTdBKlmhEWMwpxTxZKn6DVBLAwQUAAAACAD0mS1dbl2hzrcBAAAPAwAAFQAAAGNvdW5jaWxfcmVzb2x1dGlvbi5tZFWSQW4UMRBF932KkthGLYWwgd0oJCsmkULEvsZdTldw21a5PDCsOAQn5CSU3elM2E2Py+//+t/v4DrV6DiAUEmhKqcIf3//gQXjhEqgCbIkRzS1n5izIBcMw8PN1/sv324+g86on4bLER5ngp1zhlOOT3DvPTsS4LKxOsGlJQcyMIKvITT4Mzk9kyF5Y9IAAPtfGAvDA2ae4FFq/G5uRXhKAjvgCGhyMmF0BD9YZ9jf7ndQDNe2uPw4Du9XW2f4UovahEKq2mQ2fTPnkh2lo1lG8+U5GpftyolQCrStwmZsM227pUyCTQ/DRQf6qlXo7f+dXMBCsJCPFCtd9I82vdTIjrMFaljFn+sBCnsPbEmx65AyDlcj3Fl8loV25LKwLhTV0j3BwUg4Ub8d18bMxNtjjqzcS/hvk3MyMxabo2jqh2Bjm8OSqjgqrRZf49SqdSl6lqUN2VpH5NCuvIhrw672mpq3rpJxBHKVnArZKh/WVq5nJg+3r0FvL6Z3JJSTWB+xm0DvW9EHDqynlxdierG297ImHZ+acKmHwtNp7XGtw8pUksWWMwatN1+3tgZtG7tCQtM4/ANQSwMEFAAAAAgA9JktXXSWugiCAgAAhQQAAA8AAABkZW1hbmRfc3R1ZHkubWR9U0Fu20AMvOsVBHJ1BMdN08C3ogWKHooGTYGeKS1lsZV2hd2VHN36iL6wL+lwncRBDzkYkLHkcGY4vKCPMrJ3ZL87zjF4Pgjd59mt9Pf3H0rzOHJcqy+S++D21IU5XqZsRTmyT1OImcbgZKCWB20iZ3GUA+VeaLfdvaU+zEn6MDhrWFCX5rjIuqnaEKO6ECm1UcQP6oXaMPucCht50JTVHyhMAlTUHXltdBhSXVVfJ/H2tgpHmp547+nqut7RiCINnprA0aEKDZ9iOOZ+T2/qawIcteIzcWnfUBfDSM9snPiknbacDcOYTGGah9PfQ8Gpq/cLOMGDjqMQZ+NofPb0DQxutzR7hynmgYMK9q1cNoAyazhq19XVnfAvGgI7YLSm7iXKtn53u6GoyTTCzG19e0vNepK729KxFw+/J241r9Sod2bKxQXdYz2tJApdGd6FKC2nXF3SzdVZeNlcJzGCTxH/7PWoXps5geWDUpK4aEHzBe3JIqDtdq+gtcEveIBfPBDQUH/12vQp6oLYQJBB35wrD+LlFCho13GKYcE3t+CUFFGA+KL6A3LFiA2cfiw2uiWVg8RUfe8L+PNkMByn4G2EplI7CFzC0JhZfU2fM2KAZbii/SdM9QJQHYUSLxYpdHGuOOE+YPM8INZ6ML0QE6JthYdgy3vh2wZ707annhP5kLHNTA2ST3zAATg6au6toTKh6lsFXERCcA8zrs9Qa/qBojDn85z8v7iOcSNmQgzzoR9WzBsQOLigZpFd9hODMh3pKVphB4oRbKRfXE3mGk9TZE0govZuL+l8b8SpKhkPyA3DzWS3k3Up+nE5SF8Yx+LjCffxqhqxZBIfcaB19Q9QSwMEFAAAAAgA9JktXbloRXecAQAA4gIAAAoAAABlaWFfcm9kLm1kbVLNjtNADL7nKSxxWQ5UXQkh4FYBh2pvK17AnTiNl2Qmsp1WufEQPCFPwjeJdrcgbsnY/v7sN/QtX9RKHiUHD3QcJ05BB3dxr2/0++cvepRUrKXS0VdJ6loy3fk8jmzL2+boPktLp4WiF5qsXDQnBVYrE1usIJiUv3i461jNd81hjr6YOkdFPRvnAJjPpyeBjiiUSm61FtHcPMhy80CJzRTtmtFY2VHzsDmtYPgJg5vPzTv6clvIRV1o0FHDMUsmri10VdEuacM+SVxFMt1/7Pd74tzS/gO+gHUgj2LjlUOMRs58ltXkNHAmnmoCNQ/pigkJIuivxX5UxLNmzD/KUNLmF7nEtdTQAryYChMhn5AyIJ04wLW13r+v1PmfHFePZQBQpwlyQF90jRDsayTtbC9ct/GsQno+6aDxKkbGqRjWSptkNmGnq0aPmD7tqeXFN6BxGmSFab6DxF4OpH0+EMX0665gUZLUkOtOOS//X9WOjkEDT7i+Rrt6BrC/LQaU8Jy2qNsijjXisFKa7VlgpxehBYmvGrXe5a75A1BLAwQUAAAACAD0mS1d0emnd64BAAAAAwAAEgAAAGZpbmFuY2lhbF9tb2RlbC5tZHVSS24bMQzd6xQEuo4P0K6ySIAARQs0voAs0R52NKIgUjOdXQ/RE/YkoWQ3zqZLSY/vR32CZ8o+B/IJFo6Y4O/vPyBaW9BWEXyOUCqv2EHojhNCohVvWBLwIm0pSpzlEGQFyqCT3Rcf5gMcN0yGnnE3pNFV9BEiVQyadjjthkXnS6me5OqgpatoxZJ8QCAVODVK+mDMq08NBThDK4l9PDj3OiEaZMgiCLdqQxvX+cQ8f3YAj3eD8AA/0KI0HJTRzt8LVq+ULxBY1C5e1SsumLWjv7LP4BeuavY6hfG95BVFOwLuxm0Os5DSSrrb6bnl2Enfi+xsT4EzLxTuc849pgRnugxEL2jk4CZWgeU8Q+1dtJxQBBZfZ4zAFrRuJGj1WuTz+/7udkhcV8r9MvfCJXDLasNeR09LyxSo2PuGdJnGy2pNXPDagykHX0h9uorgzbr7qNG3+T+Bb6Muez4aSFrdbTXD5D+oJdNbAt3Y9eyZFRb6hfELRB7tcVfpdIGXhbP1Dlgr11GTVSPFVvMhd/dCRuJnq9M2XXdnn/en/TZI9h36vxDTm2yrBiM5uDdQSwMEFAAAAAgA1ZktXY2TUd9yAQAAYgIAABEAAABsYW5kX3NjaGVkdWxlLmNzdlWSTW7cMAxG9z4FD8CikuJ47GUW012KYlqgy0CVOY4CWVL1M8nxS2k6Tbq1yPeRj446GXL4qtOK9FbIl6ddYS661IwX7aouNvinJIRAHwoNx9MXGEepIIbUnvCAd/Mo8MH8rjbRivPEpT+S9vlMCRJtNhfiB3jkqGdQQk03yMzNSkmBX2kLxfYovJfc/9M6Z/0GmZxjyicI50bTxlAszMr11wuZAiWACdUb64bvlC621JXgdPzMVFxQylng+/f3WVAuHPKNUqRSteOYW805JGjb8yQ9Lec+7DwLxTicRiXw+BZTiOk6L7AVa6gjGpgLWqbpb+oeuLRReAUT9kg+X7ush9XmWP8aXYQc/xmVCtUyfVA63TFzOAW98g49CF7tSr4ZuuhkA99qbC5kG3Lk4sfqrbGRV3Par8iGA6/DuGx7fKIrejjRrq1f2W3sf0KGhV1DeaYdb+jDMv9/Inlo+h62jY3qQq2BZ2EHu+7n+pCUhz9QSwMEFAAAAAgA9JktXYwHwdU8BQAAlwoAAA0AAABtZW1vcmFuZHVtLm1kXVbLcuM2ELzjK6bKV63K1jpbLt+c3WzKldhx2U7lDJFDCTEIMAAoWXvKR+QL8yXbMyAlJQeXbRKYR3dPDy/oPuw4l55DoQfuY7KhHXv69+9/6OGbDdnRsx1cu6Afx1z/pNckzwt+j+GNPseUXBsT3ZmXIYYckwsbanmwSaPe0heXuCmIXFjj6v0hpkLIRb+ntQ30ENfOu3IwT0lucktdTLcIPobGecnebrheeE1s85gO9FvXuYYX+rBsmZ5S3Dkct/54xpiLC7pa0h9bW8hlGlIcYubWXN0sr+nN+dhzSZwpdpR5k3iDIhFNO1uj42bubu/KllYrarzcp1xsceh2QWUfqXDqXXALI/+g9ViyVoXG4sCpHkWsUFL01ACVpHXTzfWHHW9d45k6z2gQdVx/IkDnmtGfajGohWvMjzfUMbecSJ8t5kR2LLHXGx3wQzLvgToS64E5dz7kwv3SmM+gsgCpJmZQ9HxFq+tLury8BJJxzLiyoGFrpdW4Q66yTcx0YJvy0jzzjsPIQCztwAB1Kfb6jq6XdDcMybqM0FtI4Rvyry7ni8LGStg4mNctn8AFM0LgkFxvQewwrr1r0Pmskzy4wOSCnmrGlESt96GAMe34CwrycRC5mSdva8sdEIj7GrllKBZqsRWQdyScor0MeIZiz0LQ12R73sf0tqT7MhVnWs5uE87kMXc/BTor5yRwLWYMrSLI9Kj5ke1XFGhOx+6asqgKY5RWZIBETyLAgmInEcWUZSp6eS3RJhl4e+BUsf24JMF1GwNGmjoXbB0HaN5JZvMYVdVJ57hMc1w1AcwO+y1DOphW4RwQQcbd6KtIKsuaF/pax3fJBWyQyxxvfFoRSlWFi5Qn8aNejQCkJCSeBnkmoqgFnyoNmIEBAylM7qyHyCr8idE54zUG1O1YCQ6xSh0CAokJwG5EU/0a0ZRnndUqgI0dAJGKDqIWGCfFxT8xJZIELYTYu2aBuGfY3QpMkAYV10PxFg6zwdBNU2v+1+HxvW0a1woKsLLxNIbcu5yrb7QuN3A30Qu86Vwd5uhwOSoooqoFbbTviUv8ORd8jhlVzCZLmE8YhzQpTJGEmMRlTChpDdo095RpLkoPToC13DipmdaVg6Z68n+JsZTHdXbt4Xi8MvvXCCXKZZwWXKEwuWTOwYcM4I52Lmo9Ho9paf0YXOMGK+sB7IFWm4CNVj3lVDr36P9gqtMgN0A9Tw6p71n2CkQIuQ/EXlxMwJBIdnatSr/ONG71x41YBwz+9hXjDI7Ni3tHoARyR1kgW4gECCGiCEFsE2lFATAdL6LbwuFhR7cU9wFTFdp8mqimmjHmaPC24b4KRwxG1gQgPXkTluWb7hvy0YZFXXzVL09+8shF7It+xpNi1FhkFe1D3QMwFoFFQnunxR4xpnWEduoNyi68ibSl2lqHWCAIcr41dUJ4mvGqud5CafiRNA2r0kmVT3Znnbd1x2NaD9LLtLpqvDmO0W0unXRiJKc4U03iiz0+UNK0gzajlSaZK0E/LOmXII3u2b7BBWX+ZzKhlmn8zAcV58k1RMPgqIDGqlNl0sG44VXCoj9g5BNWCLdnS4FajFcQjAnrIOhgSoPTSjiKShvNWKzoc83HSPNEwXnJ7m1qlyjsp3kDzKYvloYqsj1KGd8pVchYmBjFAV8PMfiD3H7FB4gdW6cOJjrNSGL1k0wMcVr6Kg69HxEr1w+LqLS1blpRm1T7kuzeKdWJhWDsM9yJfsdarwCpZ8li304b1nMpSp9uBf00wJ6oVeqNM2hyHnt16bMvjO9QSwMEFAAAAAgA9JktXU3OBUvuAQAAcwMAAAoAAABvbV9wbGFuLm1kZVO7jtswEOz1FQu4PQuJ4wsMd26uSpDADpCappYWYT6cJemDunxEvjBfkiF1zvlwgKrl7OzM7GpB3y4sKttwIhUG+qpsyBxU0EzfnQr09/cfSsV7JVN3YLlaPFxUzixhS49Lb0PJqLA608hqeFYTxUB5ZMpSwvmBPq5uoGjMsgJR29AYiyRSNKip73YhFOXoyqPVjulsXfSchdOWPvcr8tY5C1aVKV44QGvfvcrWMWUIkPftdw1b2n/a9OsPfdctFvTFGtZTxcajsyfwxJC6Je0jBoEy5ajP2yp9YiUkfHFKs+eQe9pz4GeINcq5RDZQhSRgW36rdQ+eJ4XhGmSscxOOFx1DlugoTSmzh7PNe3K0HnJTg7IpcrRprA+Q8jijm+oHMiUMPJCR6Mm/rqxvHuYtIL0iRulm/ubkxfTdgghL5jTHcrOGVmyaux0JupeGQT2g+uaVElboBjryGzVtzLomX0eoebEDX4S1bc46rO2/BOOYc4vnJZaeftoM4kx2Zmh8CHdG3oVFSsReGTeEL3QlHMtw4gwdWl1sxlS+Vhj/qgpyJInlNLoJN4eK4IBxkG1EFHuyAaBbY72ovvsxWuw3NUhCDgjO11PT0XvIN8q6IrU2cD2DA0SPtDNiNX6bY0kkoBuQsgoJZm4Gu39QSwMEFAAAAAgA1ZktXUeyJ5k1AQAA8gEAAA4AAABvcGV4X21vZGVsLmNzdk2Rz2rDMAzG73kKPYApaRpKrqNQ2GGnwa5FddRGzLGDLIfl7Wc7Hezkf9/30yfZotIzyGY2Qulv0ratGYVXEpM8603ye/NFE1tHEBbKZw4+wq6J8EjkYEb2Sh69JdOdhnZozfqyfLMLM6lQNKfh0LeQEfA9Q8huOB86mNm5TMx3zadW+P8yGYGe/RPQjxDJJmHdTN+VmPEln0KSaLoOXhexijtQkpk9Ayoch10FCCNuzRWFwAbnyFZC0dvgVYIDS3nNbZxLCRX0EasomrfrpeaRvzxCK/lEsEjQndRcCiUb8kg8PmnOsCrFsUSJuvdljkOhP/iHRvOR52x5QQe12hIkW5JOobTavPuYpA722BcPxkgKK7pE5uoo73ViGWFB0Q0c451d9tWi9xTZU4xQfkckLTXiL1BLAwQUAAAACADVmS1dwMdW8/YAAABhAQAADQAAAHBhdHJvbmFnZS5jc3Y1j0tuwzAMRPc+BQ+gGv5H3TaLHsNgJDoW4ogGJSfw7UsFqFb86c3MSSjmxig+xHsy+CLBO80LCs2C0Zud8DFvjF5nLrOYxIc4qgbTDl1Tnha1bUxTX6z5wUSwsJDDlOELVj4Srbx5yKLsDdIhLzpByeBYJHgWLY6YUzUqaLKdLcSxntpC/Da/wu+8Ambo6wF2Uu2Yq1ZvLv00FPmuqYdybHtz/Ud6iikswWEOHMsvyCtB2rXHTbdqhfenkmARfNKb5VG1o6LGfvwwp3qyhTmZqtN26OxHq9eolzK35oo7upBPtR+TpguRPNzoZI2W15Bg56BO/wBQSwMEFAAAAAgA9JktXWNFvKX5AQAAiQMAABIAAABwdG5nX2FsbG9jYXRpb24ubWR1U72O00AQ7v0UI6UBibPQFRRQoUOgKzgQOoQox+txsuDdNTuzidzxEDwhT8K36xCFgiaWxzPz/U129LEMs3f0mDnqkrLRg9gp5e/0DhWj3z9/Ec9zcmw+RZrFTDI90RIC5/Vpd69aZKQSR5TtIPTGH73W1jTRJzlKLEKvnfVdd8eLN57p5OOYTi/pxXNaMOQEMGjmZcnpiF14fBNn5M79LinGP6C3cdD/LajoXEZv2JG27rinUSbvvD2jLEcvJ3wDqbyiO4vQKpwV3HY7uksRsw2AzdgdMN3d0FtIUwq80iCkS8OK84qfDfDCuuQlqfT0OW5dUxvMFc3IUut+aAp47ujiTd1/tk+hujp3e4vCLAoilBPMxwryesHqQet9inYADcb0j8IZqeAtS42wyuY9+6j2L8ehqI917zJzrFse8TWUCIMWnr2teMHMKAEuGAwU8kYHVkyOe6nGelNKp4hQomU/lEZ4kCllaViTz2qQZ/Wepo134FE2j78cuFJC7XxIYxKlmICZuntEXqVqSwUGy99UW1Y3reoas7IZBVn7zCHA9a81SMLlNke2cDlLh1Px9XaP0j6gRFqGdl/nTDjGgisbr+42b9n0zZ/qHntFC+PWA/ji/9BdnU9b6lIIHpLGV1gKka13aSz9pnAVA/RSA6pRrNc+9N0fUEsDBBQAAAAIANWZLV3TnbLk2AIAAB0GAAARAAAAcmlza19yZWdpc3Rlci5jc3aFVNtu1DAQfecr/AGuRKHcHlGhgNSKCviBWXuSTOvYYezsEr6e41yWVrTlZbVOnOMz52LxViXfWkeF26STDXLLQbqUvJV+IFesxI6VY7G9FGmpSIo2HSKrVc7iRwrPrr+dPD+111Q0RWrZ7Dikg2mSsqNc7AfuKXp7nXKWXWB7RTdJ7WdpO/sleh4YP7GYPas04uYTTGpM6dgUpZiHpMX0yXMAckU1dCD19sfxpRccVVbQmc4L+0nTGL1xKXqpkNmAhMmse3GcjYLjcpY9x8uio5sXl1WAyV7hOIUoC82LMQTTcirsugiK4T5WHnXP00au8vaQpo2mUebfbK813YCfgQyQR+0Vexn7medL+/HXoGlQWeZ2qYceeVnQLu3Z7CmMC9HL+zJuFBc4+5E0TCbCx7KCVZIA30ueV2WmNg4D611QbjHPNstdbmfgJrlIbE0a6lFJ8WdIedbTfk9OKBwF+2vq+b0ZwIF2OemwLFtI0le7V7XAz406P3rS0FfVpsVJ0G2xs5iYCnx0LHv2RiA3bIJX2BbbB+P2Pmymm8ClsOaaUkNwt3QgZiYmPFupNaK5rFk774QbPIkU69TIJ6LKd/i9tl9njapanvFSyurfsQfrBqTtAdE+rN9gmlAxME51Kx1BEWS0ASmaFY0RxTOFUJkGGuyFD//n+AaGuo4iKlqDYyADSg69IV8TmBfsPOXCfbYXG9AjnUhalQEvFBdMbnmOWFJsMkMgt3nKhH5Md4P11l4Q9OWMJi1uVCdpqFkFld0E0BFHB5gYxE0Penk1hiIn1bBNhoFKZ8inoc5zECyqgo4GKVDDs5t7gBwt2EdZ3tWCIlF9X4sbaLp/IzxauEv5OYqnepqnHs1erhiClglKrA56pYPHhVmT3kh46jY4fW4vZhtw3/IBnKssDfK8xHue9fQFzm3YTQ5Q/8boG6Jy0nB0+GRDwT2NdvOG1GjqF6yzpyPzB1BLAwQUAAAACAD0mS1dv6N21fABAADjAwAAEQAAAHM3OF9hc3Nlc3NtZW50Lm1kbZNBbtwwDEX3OgWBWaQFCgNBF+02QbcJBk0uwJFpm4AluaLkjHc9RE/Yk5SSPXbRdDMYDMn/Px81J3jKni1POMLLIomcwINNIGQTBw9fvgKKkIgjn+D3z18g2TmMi3mkLkSCliy37HvQZvRA10TRq5gjO6BncZAGAncz4bRsgtTWCvttIEzFsDHmdIKX3f3D/cfqurcdacw3jtp3aKsERayDoQOESxaIOLEaRfTCulXdEN5QjhDYI3tJJYz5O+ad6DIsqexmcUKrvzXw+u8yg2r5ADMNbEe6RdhmLlx6PhltaGkKmhU99lRZHnXl1haNDpXnFvGoNvCYeayINaLbSYyLKXvYoP1tlehC1s8UAFv9YZpiuLLDRONSShEWwiilXrhrtY/oHL0j/nklvl+SrtMYVq7m3fqRJIwzVdu1kY5J9ZhZylWVG6t15L6nkkFlfEjcsV11dfq5ftOp10goOa5Ytqgze8taS7dapB9Zz9/CZak9xys+n8/mO/V5rHoCl/WdolcKOswbc0m5XUBDsefESqldQTyHROUtixqQt2Rqco1g9bnoWbzmVk3l7qaRCvpIKQaZCsBZWTfwoEVf2gvQMplL0DdOQ8gJ0Byj//+X3fbel623nHW3W6iSew68nn3PZFYj7BR/FejQpsb8AVBLAwQUAAAACAD0mS1dgXn7YM4CAAARBQAAEwAAAHRlY2huaWNhbF9yZXZpZXcubWRtlM2q3DgQhfd+ioK77VzIz0DIrIYLgSwCIZMXKMtluxJZMiXZHWc1DzFPmCeZU3L35S4GmqatlqpOfefID/QpDbIKvlKlbxLmpIEjfZVd5Uq///mXyrYsbAflkUZNg6apdOffYh9IXxwPOZUtVuwgSZMmESsX4nXNmqoMtGGbUZ2F/n76TGuOGo7Hrnt4oKdcKkmpunCV7ht23B9IC3Epgs+AH1QyqhBXYgoRf9BbirJLdHmDQKBWzelCa9wKZaNFE368/oNWsS5A5SM96a6xUGDDVK5mMkEnKNhSEKsMtccH4kSb9fgO2UwH1EKLOkNPxHR1pqvGSItI7YrYrkEKmcQc2BVQypV2LdpHzJBan7CZOaey2S7HI/3lxByXpND4vqeab1rJtXaoE061jmFdLa+mToUBAUve5FY1Hm0iBaYiKxt2YenW+U7zpP3F8mS8LE7aROgQtuKtDSTTJnSbpzUNs8rOPkW/VZrZm9IYM4OkGxUMxD0yK4OJbal0dba8TTNFV+nmLX08LqdYRKTaFhwRzl8zjlkQDMjWOMnP+5AO0Qvk1IRo6gYt61blT3hf9OerBfBmKlFX7CFRDAq/8w4ffOaMWHoUB+fVn04XXsBuQYbqieJrjtE3lZrDj+5jhJuo/cuX3I93tMusIUozwMVrqW7hFd1axVl4uPLRlPrzKvyDQGegkUNFaKC7rXO1nHgSGrNJ4HLDV68Sd3nlFgD/GjnI0u7Sga5IF6dzmrFJg4je0VZnDOfgKqRhZAMTFK7HWek5slFH8UbaBmgIMBsqR7bJIw8tyA/WusCrVviY+6jTiT+0izHc6cGZ7xLqPXzeBI8vgndL25IHXEiu3Rms12/aiTfvbsglZGQvDa1HhzAGkeGCW9G36sgh0yS5Pr+M/PTzFTsvD/XiHFu7QYpOiUYA+SWX04nc4eK+fDcBkY56u5vtIv+vJ91/UEsDBBQAAAAIAPSZLV1z18u84wEAAAcDAAANAAAAdGVybV9zaGVldC5tZEWSQW4bMQxF93MKAtm0gGM4qdMW3rkpAnSRLpxcQJY4HiYz5ECinHjXQ/SEPUlJ2UV3I3HI/98Xr+AHJ4pB6YjwjHmCpwFR4c+v35DwiKPME7LCPvAr9CHSSHqCD6VOU8inj93D5WoDYZKsVIgPoD5nlMDddpLKuoE6gwrsbuB2vYLVagU6SC2BU7cLihv4uvxyBzNmiC7W0zum7hlZ8gZuV3DCkMsC3kgH+HQ+gfQQw0waRiqYgNhEsSikmt1CFC6aa1QS7nY4h5NjbGB0JgjM1Tn6LFObB+vuCaO1OskBGXMYIdu/XNGldECYKlOkOTjuEn4KzFleMCqUSydQAQ2vyMuuu7qCe7H2wFq6a/gmOctby0ZADNQCt8M/BRb1Ar5HNJb1/yis9TvuXSIfKaJhHa1ihrxjxGKCQ2C4Wd5ZpDSh5cIQGr2MlCzbZE9XyD1sayI/O7zR9WTmItlXUfvP4yktYuKG67dVJZ/cDEmyCcbc12zFbK/Lh+v2zsn9eZ9UbcJu+8xvm+X5W1AYMfn9va2DLYyBm7/aipWTzXt8eNx6kO1q/XkBvYzjJTEzM9f9SNHGT20bLflo8AvwDTLNnvIUWmt70daR5UhnPs0YSjUQy0qBFIZQLECQ/ctZcNn9BVBLAQIUAxQAAAAIAPSZLV0vqnJU8QEAAGgDAAAJAAAAAAAAAAAAAACkgQAAAABSRUFETUUubWRQSwECFAMUAAAACADVmS1dzCffZMUBAAA5AwAADwAAAAAAAAAAAAAApIEYAgAAYXNzdW1wdGlvbnMuY3N2UEsBAhQDFAAAAAgA1ZktXZo4FNK4AQAAuQIAABIAAAAAAAAAAAAAAKSBCgQAAGNhcGV4X2VzdGltYXRlLmNzdlBLAQIUAxQAAAAIAPSZLV1uXaHOtwEAAA8DAAAVAAAAAAAAAAAAAACkgfIFAABjb3VuY2lsX3Jlc29sdXRpb24ubWRQSwECFAMUAAAACAD0mS1ddJa6CIICAACFBAAADwAAAAAAAAAAAAAApIHcBwAAZGVtYW5kX3N0dWR5Lm1kUEsBAhQDFAAAAAgA9JktXbloRXecAQAA4gIAAAoAAAAAAAAAAAAAAKSBiwoAAGVpYV9yb2QubWRQSwECFAMUAAAACAD0mS1d0emnd64BAAAAAwAAEgAAAAAAAAAAAAAApIFPDAAAZmluYW5jaWFsX21vZGVsLm1kUEsBAhQDFAAAAAgA1ZktXY2TUd9yAQAAYgIAABEAAAAAAAAAAAAAAKSBLQ4AAGxhbmRfc2NoZWR1bGUuY3N2UEsBAhQDFAAAAAgA9JktXYwHwdU8BQAAlwoAAA0AAAAAAAAAAAAAAKSBzg8AAG1lbW9yYW5kdW0ubWRQSwECFAMUAAAACAD0mS1dTc4FS+4BAABzAwAACgAAAAAAAAAAAAAApIE1FQAAb21fcGxhbi5tZFBLAQIUAxQAAAAIANWZLV1HsieZNQEAAPIBAAAOAAAAAAAAAAAAAACkgUsXAABvcGV4X21vZGVsLmNzdlBLAQIUAxQAAAAIANWZLV3Ax1bz9gAAAGEBAAANAAAAAAAAAAAAAACkgawYAABwYXRyb25hZ2UuY3N2UEsBAhQDFAAAAAgA9JktXWNFvKX5AQAAiQMAABIAAAAAAAAAAAAAAKSBzRkAAHB0bmdfYWxsb2NhdGlvbi5tZFBLAQIUAxQAAAAIANWZLV3TnbLk2AIAAB0GAAARAAAAAAAAAAAAAACkgfYbAAByaXNrX3JlZ2lzdGVyLmNzdlBLAQIUAxQAAAAIAPSZLV2/o3bV8AEAAOMDAAARAAAAAAAAAAAAAACkgf0eAABzNzhfYXNzZXNzbWVudC5tZFBLAQIUAxQAAAAIAPSZLV2BeftgzgIAABEFAAATAAAAAAAAAAAAAACkgRwhAAB0ZWNobmljYWxfcmV2aWV3Lm1kUEsBAhQDFAAAAAgA9JktXXPXy7zjAQAABwMAAA0AAAAAAAAAAAAAAKSBGyQAAHRlcm1fc2hlZXQubWRQSwUGAAAAABEAEQAPBAAAKSYAAAAA" download="">Download the exact sample package</a>. Upload processing remains local to the browser.</div>
  <div class="pagehead">
    <h2>Project appraisal</h2>
    <p>One project taken end to end: define it, cost it, forecast what it earns and what it costs to run, build the statements, appraise the return, test whether the debt can be serviced, stress it, choose how to fund it, and check it against the municipality's own balance sheet and the law that governs the decision. Every step is arithmetic over the inputs on the step before it. Nothing is scored, weighted or judged by a model.</p>
  </div>
  <div class="pa-step" id="paSteps"></div>
  <div class="kpis" id="paKpis"></div>
  <div class="subtabs" id="paTabs">
    <button data-pa="intake" class="active">0 · Intake</button>
    <button data-pa="define">1 · Definition</button>
    <button data-pa="capex">2 · Capital cost</button>
    <button data-pa="demand">3 · Demand &amp; revenue</button>
    <button data-pa="opex">4 · Operating cost</button>
    <button data-pa="stmts">5 · Statements</button>
    <button data-pa="appraise">6 · Investment appraisal</button>
    <button data-pa="debt">7 · Debt service &amp; cover</button>
    <button data-pa="ratios">8 · Ratios &amp; returns</button>
    <button data-pa="sens">9 · Sensitivity</button>
    <button data-pa="fund">10 · Funding models</button>
    <button data-pa="fit">11 · Municipal fit &amp; law</button>
    <button data-pa="verdict">12 · Verdict</button>
  </div>

  <div class="ax-sub on" id="pa-intake">
    <div class="ax-note"><b>Load the project pack.</b> Drop files one at a time or upload a single zip containing everything. The module reads the archive in the browser — nothing is sent anywhere. Where a file matches a required document it is ticked off the checklist; where <code>assumptions.csv</code> is present its values replace the built-in ones and every step downstream recomputes.</div>
    <div class="ax-25">
      <div>
        <div class="card"><div class="hd"><h3>Upload</h3></div>
          <div class="bd">
            <div class="pa-drop" id="paDrop">
              <h4>Drop the project pack here</h4>
              <p>A single <code>.zip</code>, or individual files: PDF, XLSX, CSV, DOCX, images.<br>Everything is read locally in this browser.</p>
              <input type="file" id="paFileIn" multiple="" style="display:none" accept=".zip,.pdf,.xlsx,.xls,.csv,.docx,.doc,.png,.jpg,.jpeg,.txt,.md">
              <button class="btn-primary" id="paBrowse">Choose files</button>
              <button class="btn-ghost" id="paDemo" style="margin-left:7px;">Load the worked pack</button>
            </div>
            <div id="paFileList" style="margin-top:12px;"></div>
          </div></div>
      </div>
      <div>
        <div class="card"><div class="hd"><h3>Document checklist</h3><span class="desc" id="paDocTag"></span></div>
          <div class="bd" style="padding:0;"><table class="grid" id="paDocs">
            <thead><tr><th style="width:5%">#</th><th style="width:26%">Required document</th><th>Why the appraisal needs it</th><th>Expected file</th><th>Status</th></tr></thead><tbody></tbody></table></div>
          <div class="bd" style="border-top:1px solid var(--line);" id="paDocNote"></div></div>
        <div class="card"><div class="hd"><h3>Assumptions in force</h3><span class="desc" id="paAssumTag"></span></div>
          <div class="bd" style="padding:0;"><table class="grid" id="paAssum">
            <thead><tr><th style="width:34%">Input</th><th class="num">Value</th><th>Unit</th><th>Source</th></tr></thead><tbody></tbody></table></div></div>
      </div>
    </div>
  </div>

  <div class="ax-sub" id="pa-define"><div id="paDefine"></div></div>
  <div class="ax-sub" id="pa-capex"><div id="paCapex"></div></div>
  <div class="ax-sub" id="pa-demand"><div id="paDemand"></div></div>
  <div class="ax-sub" id="pa-opex"><div id="paOpex"></div></div>
  <div class="ax-sub" id="pa-stmts"><div id="paStmts"></div></div>
  <div class="ax-sub" id="pa-appraise"><div id="paAppraise"></div></div>
  <div class="ax-sub" id="pa-debt"><div id="paDebt"></div></div>
  <div class="ax-sub" id="pa-ratios"><div id="paRatios"></div></div>
  <div class="ax-sub" id="pa-sens"><div id="paSens"></div></div>
  <div class="ax-sub" id="pa-fund"><div id="paFund"></div></div>
  <div class="ax-sub" id="pa-fit"><div id="paFit"></div></div>
  <div class="ax-sub" id="pa-verdict"><div id="paVerdict"></div></div>
</section>

<!-- ============ PROJECT RISK ============ -->
<section class="panel" id="panel-projrisk">
  <div class="pagehead">
    <h2>Project risk</h2>
    <p>Every significant capital project measured against its approved cash-flow rollout — money out to contractors and money in from grants, loans and own funds. Progress is taken from certified payment certificates, not from opinion, so variance and the forecast outturn are arithmetic, not judgement.</p>
  </div>
  <div class="kpis" id="prKpis"></div>
  <div id="prMode1Input"></div>
  <div class="card">
    <div class="hd"><h3>Capital programme — approved rollout against actual</h3><span class="desc" id="prAsAt"></span></div>
    <div class="bd" style="padding:0;">
      <table class="grid" id="prTable">
        <thead><tr>
          <th style="width:23%">Project</th><th>Funding</th><th class="num">Approved</th>
          <th class="num">Planned to date</th><th class="num">Paid to date</th><th class="num">Certified</th>
          <th style="width:11%">Complete</th><th class="num">Cost var.</th><th class="num">Forecast outturn</th><th>Status</th>
        </tr></thead>
        <tbody></tbody>
      </table>
    </div>
  </div>
  <div class="mx-split">
    <div class="card">
      <div class="hd"><h3 id="prCfTitle">Cash-flow rollout</h3><span class="desc" id="prCfTag"></span></div>
      <div class="bd"><div class="mx-chart" id="prChart"></div>
        <div class="mx-legend">
          <span><i style="background:#b9c9db"></i>Approved money out</span>
          <span><i style="background:var(--navy)"></i>Actual money out</span>
          <span><i style="background:var(--teal)"></i>Actual money in</span>
          <span><i style="background:#c29b4b"></i>Extrapolated to completion</span>
        </div>
        <div id="prCfNote"></div>
      </div>
    </div>
    <div class="card">
      <div class="hd"><h3>Financial milestones</h3><span class="desc">Certificate-based · no subjective progress</span></div>
      <div class="bd" id="prMilestones"></div>
    </div>
  </div>
  <div class="card">
    <div class="hd"><h3>Variance analysis and extrapolation</h3><span class="desc" id="prVarTag"></span></div>
    <div class="bd" style="padding:0;">
      <table class="grid" id="prVarTable">
        <thead><tr><th style="width:30%">Measure</th><th>How it is derived</th><th class="num">Value</th><th class="num">Threshold</th><th>Result</th></tr></thead>
        <tbody></tbody>
      </table>
    </div>
  </div>
  <div class="card">
    <div class="hd"><h3>Cascade — what the forecast outturn does to the rest of MATOS</h3><span class="desc">Forward-looking tabs recalculated from the extrapolation above</span></div>
    <div class="bd" style="padding:0;">
      <table class="grid" id="prCascade">
        <thead><tr><th style="width:26%">Destination</th><th>Mechanism</th><th class="num">Movement</th><th style="width:10%"></th></tr></thead>
        <tbody></tbody>
      </table>
    </div>
    <div class="bd" style="border-top:1px solid var(--line);">
      <div class="mx-act">
        <button class="btn-primary" id="prPostBtn">Post certified progress claims</button>
        <button class="btn-ghost" id="prForecastBtn">Roll forward the forecast</button>
        <span class="mx-role" id="prRoleNote"></span>
      </div>
    </div>
  </div>
</section>

<!-- ============ CONTRACT MONITOR ============ -->
<section class="panel" id="panel-contracts">
  <div class="pagehead">
    <h2>Contract monitor</h2>
    <p>The SCM contract register reconciled to the creditors ledger: what was awarded, what has been committed against it, what has been certified, what has been paid, and what is still held as retention. Every figure traces to a posted transaction in the accounting suite, so over-commitment and late payment are detected, not estimated.</p>
  </div>
  <div class="kpis" id="ctKpis"></div>
  <div class="subtabs" id="ctTabs">
    <button data-ct="all" class="active">All contracts</button>
    <button data-ct="capital">Capital</button>
    <button data-ct="operating">Operating</button>
    <button data-ct="exceptions">Exceptions only</button>
  </div>
  <div id="ctMode1Input"></div>
  <div class="card">
    <div class="hd"><h3>Contract register reconciled to creditors</h3><span class="desc" id="ctAsAt"></span></div>
    <div class="bd" style="padding:0;">
      <table class="grid" id="ctTable">
        <thead><tr>
          <th style="width:19%">Contract</th><th>Supplier / linked project</th><th class="num">Awarded</th>
          <th class="num">Committed</th><th class="num">Certified</th><th class="num">Paid</th>
          <th class="num">Retention</th><th class="num">Due &amp; unpaid</th><th class="num">Days</th><th>Status</th>
        </tr></thead>
        <tbody></tbody>
      </table>
    </div>
  </div>
  <div class="mx-split">
    <div class="card">
      <div class="hd"><h3>Commitment run-off</h3><span class="desc" id="ctRunTag"></span></div>
      <div class="bd"><div class="mx-chart" id="ctChart"></div>
        <div class="mx-legend">
          <span><i style="background:#b9c9db"></i>Awarded value</span>
          <span><i style="background:var(--navy)"></i>Certified to date</span>
          <span><i style="background:#c29b4b"></i>Extrapolated final spend</span>
        </div>
        <div id="ctRunNote"></div>
      </div>
    </div>
    <div class="card">
      <div class="hd"><h3>Payment ageing — MFMA s65(2)(e)</h3><span class="desc">30-day test on certified, non-retention balances</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="ctAgeing">
        <thead><tr><th>Ageing band</th><th class="num">Balance R'000</th><th class="num">Contracts</th><th>Result</th></tr></thead><tbody></tbody></table></div>
      <div class="bd" style="border-top:1px solid var(--line);" id="ctAgeNote"></div>
    </div>
  </div>
  <div class="card">
    <div class="hd"><h3>Variance analysis and extrapolation</h3><span class="desc" id="ctVarTag"></span></div>
    <div class="bd" style="padding:0;">
      <table class="grid" id="ctVarTable">
        <thead><tr><th style="width:30%">Measure</th><th>How it is derived</th><th class="num">Value</th><th class="num">Threshold</th><th>Result</th></tr></thead><tbody></tbody>
      </table>
    </div>
    <div class="bd" style="border-top:1px solid var(--line);">
      <div class="mx-act">
        <button class="btn-primary" id="ctPayBtn">Release the 30-day payment run</button>
        <button class="btn-ghost" id="ctVarBtn">Log the contract variation for approval</button>
        <span class="mx-role" id="ctRoleNote"></span>
      </div>
    </div>
  </div>
  <div class="card">
    <div class="hd"><h3>Cascade — where contract movement lands</h3></div>
    <div class="bd" style="padding:0;">
      <table class="grid" id="ctCascade"><thead><tr><th style="width:26%">Destination</th><th>Mechanism</th><th class="num">Movement</th><th style="width:10%"></th></tr></thead><tbody></tbody></table>
    </div>
  </div>
</section>

<!-- ============ AUDIT FINDINGS MONITOR ============ -->
<section class="panel" id="panel-audit">
  <div class="pagehead">
    <h2>Audit findings</h2>
    <p>The Auditor-General and internal audit registers held in one place and tested the same way every time. A finding closes when the control that caused it passes its automated test and the corrected figure is in the ledger — not when someone marks it done. The projected audit outcome falls straight out of unresolved misstatement measured against materiality.</p>
  </div>
  <div class="kpis" id="auKpis"></div>
  <div class="subtabs" id="auSubTabs">
    <button data-au="register" class="active">Findings register</button>
    <button data-au="rules">The rule behind each finding</button>
    <button data-au="vault">Tender evidence vault</button>
  </div>
  <div class="au-sub" id="au-register">
  <div class="subtabs" id="auLensTabs">
    <button data-lens="auto" class="active">Follow MATOS mode</button>
    <button data-lens="realtime">Real-time monitoring</button>
    <button data-lens="interim">Interim — half year</button>
    <button data-lens="yearend">Year-end outcome</button>
  </div>
  <div id="auLensNote"></div>
  <div class="card">
    <div class="hd"><h3>Projected audit outcome</h3><span class="desc" id="auOpTag"></span></div>
    <div class="bd" style="padding:0;">
      <table class="grid" id="auOpinion">
        <thead><tr><th style="width:34%">Test</th><th>Basis</th><th class="num">Value R'000</th><th>Result</th></tr></thead><tbody></tbody>
      </table>
    </div>
  </div>
  <div class="card">
    <div class="hd"><h3>Findings register</h3><span class="desc" id="auAsAt"></span>
      <button class="btn-ghost" id="auFilterBtn" style="margin-left:10px;">Show resolved</button></div>
    <div class="bd" style="padding:0;">
      <table class="grid" id="auTable">
        <thead><tr>
          <th style="width:9%">Ref</th><th>Finding</th><th>Classification</th><th class="num">R'000</th>
          <th>Linked control</th><th>Owner</th><th class="num">Age</th><th>Status</th>
        </tr></thead><tbody></tbody>
      </table>
    </div>
  </div>
  <div class="mx-split">
    <div class="card">
      <div class="hd"><h3>Unresolved misstatement against materiality</h3><span class="desc" id="auMatTag"></span></div>
      <div class="bd"><div class="mx-chart" id="auChart"></div>
        <div class="mx-legend">
          <span><i style="background:var(--navy)"></i>Unresolved misstatement</span>
          <span><i style="background:#b9c9db"></i>Resolved this period</span>
          <span><i style="background:#c29b4b"></i>Materiality line</span>
        </div>
        <div id="auChartNote"></div>
      </div>
    </div>
    <div class="card">
      <div class="hd"><h3>Repeat findings and the control that would stop them</h3><span class="desc">Deterministic close test</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="auRepeat">
        <thead><tr><th>Finding</th><th class="num">Years</th><th>Automated test that closes it</th><th>Test result</th></tr></thead><tbody></tbody></table></div>
    </div>
  </div>
  <div class="card">
    <div class="hd"><h3>Remediation</h3><span class="desc">Posting the correction is what clears the finding</span></div>
    <div class="bd" id="auRemedy"></div>
    <div class="bd" style="border-top:1px solid var(--line);">
      <div class="mx-act">
        <button class="btn-primary" id="auPostBtn">Post the audit adjustments</button>
        <button class="btn-ghost" id="auSweepBtn">Run the control sweep</button>
        <span class="mx-role" id="auRoleNote"></span>
      </div>
    </div>
  </div>
  <div class="card">
    <div class="hd"><h3>Cascade — where a cleared finding lands</h3></div>
    <div class="bd" style="padding:0;">
      <table class="grid" id="auCascade"><thead><tr><th style="width:26%">Destination</th><th>Mechanism</th><th class="num">Movement</th><th style="width:10%"></th></tr></thead><tbody></tbody></table>
    </div>
  </div>
  </div>

  <div class="au-sub" id="au-rules" style="display:none;">
    <div id="auDetNote"></div>
    <div class="card">
      <div class="hd"><h3>Every finding, and the arithmetic that raised it</h3><span class="desc">Re-derivable by hand from the same figures</span></div>
      <div class="bd" style="padding:0;">
        <table class="grid" id="auRules">
          <thead><tr><th style="width:8%">Ref</th><th style="width:24%">Rule</th><th>Inputs</th>
            <th class="num">Measured</th><th class="num">Threshold</th><th>Source</th><th>Result</th></tr></thead><tbody></tbody></table>
      </div>
    </div>
  </div>

  <div class="au-sub" id="au-vault" style="display:none;">
    <div class="card">
      <div class="hd"><h3>Procurement evidence files</h3><span class="desc">Click a file to open its document list</span></div>
      <div class="bd" style="padding:0;">
        <table class="grid" id="auVault">
          <thead><tr><th style="width:16%">Contract</th><th class="num">On file</th><th style="width:18%">Completeness</th>
            <th>Mandatory items missing or expired</th><th>Result</th><th>Note</th></tr></thead><tbody></tbody></table>
      </div>
      <div class="bd" style="border-top:1px solid var(--line);" id="auVaultNote"></div>
    </div>
  </div>
</section>

<!-- ==================== SCENARIO & SUSTAINABILITY ==================== -->
<section class="panel" id="panel-scen">
  <div class="pagehead">
    <h2>Risk management</h2>
    <p>Everything that could go wrong, sized in rands and tested against the same ledger. Stress levers for the shocks that hit inside a budget year, a tunable library of the nine that actually happen to South African municipalities, and a long-run view of the slower risks — an asset base being consumed faster than it is replaced, a tariff path outrunning what households can pay, debt taken on for assets that will not last as long as the loan.</p>
  </div>
  <div class="ax-horizon" id="scHorizon">
    <div class="h on"><b>Shock risk — inside the budget year</b><span>Collection, tariff, volume, wage and rate shocks. Measured in rands off the surplus and days off the cash box.</span></div>
    <div class="h"><b>Structural risk — three to ten years</b><span>Asset consumption, renewal funding, tariff affordability and debt capacity. These cannot be fixed inside one budget.</span></div>
  </div>
  <div class="kpis" id="scKpis"></div>
  <div class="subtabs" id="scTabs">
    <button data-sc="levers" class="active">Stress levers</button>
    <button data-sc="impact">360° impact</button>
    <button data-sc="library">Scenario library</button>
    <button data-sc="traj">Long-run trajectory</button>
    <button data-sc="sust">Structural risk analysis</button>
  </div>

  <div class="ax-sub on" id="sc-levers">
    <div class="ax-25">
      <div>
        <div class="card"><div class="hd"><h3>Levers</h3><button class="btn-ghost" id="scReset" style="margin-left:auto;">Reset to base</button></div>
          <div class="bd" style="max-height:none;" id="scLeverBox"></div></div>
      </div>
      <div>
        <div class="card"><div class="hd"><h3>Result under the current lever settings</h3><span class="desc" id="scResTag"></span></div>
          <div class="bd" style="padding:0;"><table class="grid" id="scResult">
            <thead><tr><th style="width:30%">Line</th><th class="num">Base</th><th class="num">Stressed</th><th class="num">Movement</th><th style="width:18%">Effect</th></tr></thead><tbody></tbody></table></div>
          <div class="bd" style="border-top:1px solid var(--line);" id="scResNote"></div></div>
        <div class="card"><div class="hd"><h3>Cash runway under stress</h3><span class="desc">Hover a month to read the balance</span></div>
          <div class="bd"><div class="ax-chart" id="scCashChart"></div>
            <div class="ax-legend"><span><i class="b-grey"></i>Base case cash</span><span><i class="b-navy"></i>Stressed cash</span><span><i class="b-red"></i>One month of operating cost</span></div>
            <div class="ax-read" id="scCashRead">Hover a month to read the closing cash balance and the days of cover it represents.</div></div></div>
        <div class="card"><div class="hd"><h3>Which lever did the damage</h3><span class="desc">Contribution to the change in the surplus</span></div>
          <div class="bd" style="padding:0;"><table class="grid" id="scAttrib">
            <thead><tr><th style="width:32%">Lever</th><th class="num">Setting</th><th class="num">Effect on surplus</th><th style="width:26%">Share of the total move</th></tr></thead><tbody></tbody></table></div></div>
      </div>
    </div>
  </div>

  <div class="ax-sub" id="sc-impact">
    <div class="ax-note"><b>One scenario, every consequence.</b> The tiles below are grouped by where the damage lands. Each is computed from the same stressed figures — nothing is estimated independently, so the tiles cannot disagree with each other.</div>
    <div id="scImpactGroups"></div>
    <div class="card"><div class="hd"><h3>Effect on the credit rating</h3><span class="desc">The scenario re-scores the rating model directly</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="scCredit">
        <thead><tr><th style="width:26%">Rating factor</th><th class="num">Base score</th><th class="num">Under the scenario</th><th>Driver of the change</th></tr></thead><tbody></tbody></table></div>
      <div class="bd" style="border-top:1px solid var(--line);" id="scCreditNote"></div></div>
  </div>

  <div class="ax-sub" id="sc-library">
    <div class="ax-note"><b>Nine risks that actually happen, each one tunable.</b> Pick a scenario on the left and it loads as a set of lever
      positions — the same twelve levers as the stress tab, no separate machinery. The severity dial scales the whole set at once, so you can ask what a
      half-strength drought or a one-and-a-half-times wage settlement does without re-entering anything. Push it to the lever tab when you want to adjust
      one lever on its own.</div>
    <div class="ax-25">
      <div>
        <div class="card"><div class="hd"><h3>Pick a risk</h3></div>
          <div class="bd" style="padding:9px;" id="scLibPick"></div></div>
        <div class="card"><div class="hd"><h3>Severity</h3></div>
          <div class="bd" id="scLibSev"></div></div>
      </div>
      <div>
        <div class="card"><div class="hd"><h3 id="scLibName">Base case</h3><span class="desc" id="scLibTag"></span></div>
          <div class="bd" id="scLibDesc"></div>
          <div class="bd" style="padding:0;border-top:1px solid var(--line);">
            <table class="grid" id="scLibLevers">
              <thead><tr><th style="width:26%">Lever set by this scenario</th><th class="num">Setting</th><th style="width:24%">Position</th><th>Why this scenario moves it</th></tr></thead><tbody></tbody></table></div>
          <div class="bd" style="border-top:1px solid var(--line);">
            <div class="mx-act" style="display:flex;gap:9px;flex-wrap:wrap;align-items:center;">
              <button class="btn-primary" id="scLibApply">Send these settings to the lever tab</button>
              <button class="btn-ghost" id="scLibClear">Clear back to base</button>
              <span style="font-size:10.5px;color:var(--muted)">Sending it lets you adjust any single lever on its own and keeps everything else where this scenario put it.</span>
            </div></div></div>
        <div class="card"><div class="hd"><h3>What it does</h3><span class="desc" id="scLibEffTag"></span></div>
          <div class="bd" style="padding:0;"><table class="grid" id="scLibEffect">
            <thead><tr><th style="width:28%">Measure</th><th class="num">Base</th><th class="num">Under this risk</th><th class="num">Movement</th><th style="width:22%">Severity of the effect</th></tr></thead><tbody></tbody></table></div>
          <div class="bd" style="border-top:1px solid var(--line);" id="scLibEffNote"></div></div>
        <div class="card"><div class="hd"><h3>Severity response</h3><span class="desc">How this risk behaves as it gets worse — hover a point</span></div>
          <div class="bd"><div class="ax-chart" id="scLibCurve"></div>
            <div class="ax-legend"><span><i class="b-teal"></i>Operating surplus</span><span><i class="b-navy"></i>Free cash</span><span><i class="b-red"></i>Shortfall against one month of cost</span></div>
            <div class="ax-read" id="scLibCurveRead">Hover a severity step to see where this risk stops being absorbable.</div></div></div>
      </div>
    </div>
    <div class="card"><div class="hd"><h3>All nine risks compared at their stated severity</h3><span class="desc">Hover a bar for the full result</span></div>
      <div class="bd"><div class="ax-chart" id="scLibChart"></div>
        <div class="ax-legend"><span><i class="b-teal"></i>Operating surplus</span><span><i class="b-navy"></i>Free cash</span><span><i class="b-red"></i>Shortfall against one month of cost</span></div>
        <div class="ax-read" id="scLibRead">Hover a risk to compare it with the base case.</div></div></div>
    <div class="card"><div class="hd"><h3>Risk register</h3><span class="desc">The same nine, ranked by what they cost</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="scLibrary">
        <thead><tr><th style="width:18%">Risk</th><th>What it represents</th><th class="num">Surplus</th><th class="num">Days cash</th><th class="num">Debt / revenue</th><th>Profile</th><th style="width:8%"></th></tr></thead><tbody></tbody></table></div></div>
  </div>

  <div class="ax-sub" id="sc-traj">
    <div class="card"><div class="hd"><h3>Ten-year trajectory</h3><span class="desc" id="scTrajTag"></span>
      <div class="seg" id="scTrajSeg" style="margin-left:10px;">
        <button data-tr="cash" class="active">Cash</button><button data-tr="debt">Debt</button>
        <button data-tr="surplus">Operating balance</button><button data-tr="assets">Asset base</button><button data-tr="backlog">Renewal backlog</button></div></div>
      <div class="bd"><svg class="ax-line" id="scTrajChart" viewBox="0 0 820 200" preserveAspectRatio="none"></svg>
        <div class="ax-legend"><span><i class="b-grey"></i>Audited baseline path</span><span><i class="b-navy"></i>Path after the near-term scenario</span><span><i class="b-red"></i>Threshold</span></div>
        <div class="ax-read" id="scTrajRead">Hover a year to read both paths and the gap between them.</div></div></div>
    <div class="card"><div class="hd"><h3>Year by year</h3><span class="desc">The numbers behind the chart</span></div>
      <div class="bd" style="padding:0;overflow:auto;"><table class="grid" id="scTrajTable"></table></div></div>
  </div>

  <div class="ax-sub" id="sc-sust">
    <div class="ax-note"><b>Structural risk is what a budget cannot fix.</b> Six analyses, each one a projection with its inputs, its method and its
      metric shown. Change an assumption on the left and every analysis re-runs. Click any analysis to open the full ten-year working.</div>
    <div class="ax-25">
      <div><div class="card"><div class="hd"><h3>Projection assumptions</h3><button class="btn-ghost" id="scAssumReset" style="margin-left:auto;">Reset</button></div>
        <div class="bd" id="scAssum"></div></div></div>
      <div><div class="kpis" id="scSustKpis"></div>
        <div class="card"><div class="hd"><h3>The six structural risks</h3><span class="desc">Click a row for the method and the ten-year working</span></div>
          <div class="bd" style="padding:0;"><table class="grid" id="scSust">
            <thead><tr><th style="width:24%">Analysis</th><th>The question it answers</th><th class="num">Now</th><th class="num">Year 10</th><th class="num">Threshold</th><th>Verdict</th></tr></thead><tbody></tbody></table></div></div>
      </div>
    </div>
    <div class="ax-split">
      <div class="card"><div class="hd"><h3>Renewal need against planned spend</h3><span class="desc">Hover a year</span></div>
        <div class="bd"><div class="ax-chart" id="scRenewChart"></div>
          <div class="ax-legend"><span><i class="b-teal"></i>Planned renewal and maintenance</span><span><i class="b-grey"></i>Required — depreciation plus backlog catch-up</span><span><i class="b-red"></i>Funding gap</span></div>
          <div class="ax-read" id="scRenewRead">Hover a year to read what renewal is required, what is planned, and the gap between them.</div></div></div>
      <div class="card"><div class="hd"><h3>Verdict</h3><span class="desc" id="scVerdictTag"></span></div>
        <div class="bd" id="scVerdict"></div></div>
    </div>
  </div>
</section>

<!-- ==================== CREDIT RATING & SCORING ==================== -->
<section class="panel" id="panel-credit">
  <div class="pagehead">
    <h2>Credit rating &amp; scoring</h2>
    <p>A full sub-sovereign rating model, not a ratio summary. The institutional framework and seven weighted factors build an individual credit profile; the profile and the framework combine into an indicative credit level; overrides, the sovereign ceiling and the likelihood of extraordinary support take that to an issuer rating on both the global and the national scale. Every score traces to a ratio computed from the ledger, and every threshold is shown.</p>
  </div>
  <div class="kpis" id="crKpis"></div>
  <div class="subtabs" id="crTabs">
    <button data-cr="summary" class="active">Rating summary</button>
    <button data-cr="scorecard">The scorecard</button>
    <button data-cr="metrics">Metric detail</button>
    <button data-cr="notch">Notching &amp; overrides</button>
    <button data-cr="sens">Sensitivity &amp; what-if</button>
    <button data-cr="peers">Peer comparison</button>
    <button data-cr="method">Methodology</button>
  </div>

  <div class="ax-sub on" id="cr-summary">
    <div class="ax-25">
      <div class="card"><div class="hd"><h3>Where the rating sits</h3></div>
        <div class="bd"><div class="ax-ladder" id="crLadder"></div>
          <p class="footnote" id="crLadderNote"></p></div></div>
      <div>
        <div class="card"><div class="hd"><h3>Rating build-up</h3><span class="desc" id="crBuildTag"></span></div>
          <div class="bd" style="padding:0;"><table class="grid" id="crBuild">
            <thead><tr><th style="width:30%">Step</th><th>What it does</th><th class="num">Score / level</th><th>Movement</th></tr></thead><tbody></tbody></table></div></div>
        <div class="ax-split">
          <div class="card"><div class="hd"><h3>What supports the rating</h3></div><div class="bd" id="crStrengths"></div></div>
          <div class="card"><div class="hd"><h3>What constrains it</h3></div><div class="bd" id="crWeak"></div></div>
        </div>
      </div>
    </div>
    <div class="card"><div class="hd"><h3>Factor contribution to the individual credit profile</h3>
      <span class="desc">Hover a bar to read the factor</span></div>
      <div class="bd"><div class="ax-chart" id="crFactorChart"></div>
        <div class="ax-legend"><span><i class="b-navy"></i>Weighted score contribution</span><span><i class="b-grey"></i>Weight as a share of the profile</span></div>
        <div class="ax-read" id="crFactorRead">Hover a factor to see its score, its weight and the ratios behind it.</div></div></div>
    <div class="card"><div class="hd"><h3>Outlook and the triggers that would move it</h3><span class="desc" id="crOutlookTag"></span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="crTriggers">
        <thead><tr><th style="width:12%">Direction</th><th>Trigger</th><th class="num">Current</th><th class="num">Threshold</th><th class="num">Distance</th></tr></thead><tbody></tbody></table></div></div>
  </div>

  <div class="ax-sub" id="cr-scorecard">
    <div class="ax-note"><b>Read this as the model, not the answer.</b> Each factor is scored from 1 (extremely strong) to 6 (extremely weak) off named ratios with published bands. The individual credit profile is the weighted average. Click any factor row to open its sub-metrics.</div>
    <div class="card"><div class="hd"><h3>Institutional framework</h3><span class="desc">Assessed separately and combined with the profile in the matrix</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="crIF">
        <thead><tr><th style="width:26%">Dimension</th><th>Assessment</th><th class="num">Score</th></tr></thead><tbody></tbody></table></div></div>
    <div class="card"><div class="hd"><h3>Individual credit profile — seven weighted factors</h3><span class="desc" id="crICPTag"></span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="crScorecard">
        <thead><tr><th style="width:22%">Factor</th><th class="num">Weight</th><th>Key measures</th><th class="num">Score</th><th style="width:12%">Band</th><th class="num">Weighted</th></tr></thead><tbody></tbody></table></div></div>
    <div class="card"><div class="hd"><h3>The matrix — profile against framework</h3><span class="desc">Indicative credit level before overrides</span></div>
      <div class="bd" style="padding:0;overflow:auto;"><table class="grid" id="crMatrix"></table></div>
      <div class="bd" style="border-top:1px solid var(--line);" id="crMatrixNote"></div></div>
  </div>

  <div class="ax-sub" id="cr-metrics">
    <div class="card"><div class="hd"><h3>Every ratio in the model</h3><span class="desc">Click a row for the formula, the inputs and the band table</span>
      <button class="btn-ghost" id="crOnlyFail" style="margin-left:10px;">Show only measures below their band</button></div>
      <div class="bd" style="padding:0;"><table class="grid" id="crMetrics">
        <thead><tr><th style="width:26%">Measure</th><th>Factor</th><th class="num">Value</th><th class="num">Score</th><th style="width:22%">Position in band</th><th>Assessment</th></tr></thead><tbody></tbody></table></div></div>
  </div>

  <div class="ax-sub" id="cr-notch">
    <div class="card"><div class="hd"><h3>From indicative level to issuer rating</h3><span class="desc">Each step is a named adjustment with a stated reason</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="crNotch">
        <thead><tr><th style="width:26%">Adjustment</th><th>Basis</th><th class="num">Notches</th><th class="num">Level after</th><th>Applied</th></tr></thead><tbody></tbody></table></div></div>
    <div class="ax-split">
      <div class="card"><div class="hd"><h3>Sovereign ceiling test</h3></div><div class="bd" id="crSov"></div></div>
      <div class="card"><div class="hd"><h3>Extraordinary support</h3></div><div class="bd" id="crSupport"></div></div>
    </div>
    <div class="card"><div class="hd"><h3>Scale mapping</h3><span class="desc">The same credit expressed three ways</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="crScales">
        <thead><tr><th>Scale</th><th>Agency convention</th><th class="num">Rating</th><th>Outlook</th><th>What it means for pricing</th></tr></thead><tbody></tbody></table></div></div>
  </div>

  <div class="ax-sub" id="cr-sens">
    <div class="ax-note"><b>Move a driver and watch the rating.</b> These are the five measures with the shortest distance to a band edge. Each slider re-scores its factor, re-weights the profile, re-runs the matrix and re-applies the ceiling. Nothing else in the model is touched.</div>
    <div class="ax-25">
      <div><div class="card"><div class="hd"><h3>Drivers</h3><button class="btn-ghost" id="crResetSens" style="margin-left:auto;">Reset</button></div>
        <div class="bd" id="crSliders"></div></div></div>
      <div>
        <div class="kpis" id="crSensKpis"></div>
        <div class="card"><div class="hd"><h3>Rating under the adjusted drivers</h3><span class="desc" id="crSensTag"></span></div>
          <div class="bd" style="padding:0;"><table class="grid" id="crSensTable">
            <thead><tr><th style="width:24%">Factor</th><th class="num">Base score</th><th class="num">Adjusted</th><th class="num">Weight</th><th>Movement</th></tr></thead><tbody></tbody></table></div>
          <div class="bd" style="border-top:1px solid var(--line);" id="crSensNote"></div></div>
        <div class="card"><div class="hd"><h3>Cost of the rating</h3><span class="desc">Spread by notch on a ten-year amortising facility</span></div>
          <div class="bd"><div class="ax-chart" id="crCostChart"></div>
            <div class="ax-legend"><span><i class="b-grey"></i>Indicative all-in coupon</span><span><i class="b-navy"></i>Current rating</span><span><i class="b-gold"></i>Adjusted rating</span></div>
            <div class="ax-read" id="crCostRead">Hover a rating to see the indicative coupon and the annual interest bill at the current debt stock.</div></div></div>
      </div>
    </div>
  </div>

  <div class="ax-sub" id="cr-peers">
    <div class="card"><div class="hd"><h3>South African municipal comparators</h3><span class="desc">Published positions, for orientation only</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="crPeers">
        <thead><tr><th style="width:19%">Municipality</th><th>Rating and scale</th><th class="num">Debt / revenue</th><th class="num">Days cash</th><th class="num">Collection</th><th>What drives it</th></tr></thead><tbody></tbody></table></div>
      <div class="bd" style="border-top:1px solid var(--line);" id="crPeerNote"></div></div>
    <div class="card"><div class="hd"><h3>This municipality against the comparator set</h3><span class="desc">Hover a bar to read the position</span></div>
      <div class="bd"><div class="ax-chart" id="crPeerChart"></div>
        <div class="ax-legend"><span><i class="b-navy"></i>This municipality</span><span><i class="b-grey"></i>Comparators</span></div>
        <div class="ax-read" id="crPeerRead">Hover a municipality to compare.</div></div></div>
  </div>

  <div class="ax-sub" id="cr-method">
    <div class="card"><div class="hd"><h3>How this model is built and what it is not</h3></div><div class="bd" id="crMethod"></div></div>
    <div class="card"><div class="hd"><h3>Band tables</h3><span class="desc">The thresholds every score is read off</span></div>
      <div class="bd" style="padding:0;"><table class="grid" id="crBands">
        <thead><tr><th style="width:26%">Measure</th><th class="num">1 — extremely strong</th><th class="num">2</th><th class="num">3</th><th class="num">4</th><th class="num">5</th><th class="num">6 — extremely weak</th></tr></thead><tbody></tbody></table></div></div>
  </div>
</section>

<!-- ============ HOW TO USE THIS SYSTEM ============ -->
<section class="panel" id="panel-howto">
  <div class="pagehead">
    <h2>How to use this system</h2>
    <p>Plain English, no jargon. If you have never opened MATOS before, read this page once and you will know what every tab does and what happens when you press a button.</p>
  </div>

  <div class="mx-note"><b>The one idea behind the whole thing.</b> MATOS keeps one set of numbers. Your accounting system feeds it, the workbook does the sums, and every screen you look at is a different view of those same numbers. Change something in one place and it changes everywhere, immediately, because there is only one place for it to change.</div>

  <div class="card">
    <div class="hd"><h3>Step one — pick your mode</h3><span class="desc">Top right of the screen</span></div>
    <div class="bd" style="padding:0;"><table class="grid">
      <thead><tr><th style="width:14%">Button</th><th style="width:26%">What it means</th><th>When you would use it</th></tr></thead>
      <tbody>
        <tr><td><b>Mode 1</b></td><td>Your own figures</td><td>You are using this for your real municipality. The screens start empty and fill up as you load your trial balance or type your figures in. Nothing is made up for you.</td></tr>
        <tr><td><b>Mode 2</b></td><td>Half year</td><td>You want the mid-year picture, as at 31 December. Everything re-cuts to that date: half the year's spending, half the year's certificates, half-year materiality. This is the view for your section 72 mid-year report.</td></tr>
        <tr><td><b>Mode 3</b></td><td>Full year</td><td>The default. The whole year to 30 June. This is the view for your annual financial statements and for the audit.</td></tr>
      </tbody></table></div>
    <div class="bd" style="border-top:1px solid var(--line);"><p class="footnote" style="margin:0">Next to the mode buttons is the year — FY2023, FY2024 or FY2025 — and next to that is the <b>role</b>. The role decides what you are allowed to press. A CFO can post anything; a councillor on MPAC can look at everything and press nothing. Change the role and watch the buttons switch on and off.</p></div>
  </div>

  <div class="card">
    <div class="hd"><h3>Step two — know what each tab is for</h3></div>
    <div class="bd" style="padding:0;"><table class="grid">
      <thead><tr><th style="width:22%">Tab</th><th style="width:30%">In one sentence</th><th>The question it answers</th></tr></thead>
      <tbody>
        <tr class="section"><td colspan="3">The original MATOS tabs</td></tr>
        <tr><td><b>Executive cockpit</b></td><td>The one-page summary.</td><td>How are we doing, overall, right now?</td></tr>
        <tr><td><b>System modules</b></td><td>A map of every part of the system.</td><td>Where does this number come from, and what depends on it?</td></tr>
        <tr><td><b>System integration</b></td><td>Your accounting packages — Pastel, Sage, CaseWare — and the bridge into the workbook.</td><td>Is what the accounting system says the same as what the workbook says?</td></tr>
        <tr><td><b>Integrated workbook</b></td><td>The actual Excel sheets, on screen.</td><td>Show me the working.</td></tr>
        <tr><td><b>Strategy and performance</b></td><td>The ratios, against their benchmarks.</td><td>Which of our numbers are outside the norm, and by how much?</td></tr>
        <tr><td><b>Scenario lab</b></td><td>Sliders for the things that could go wrong.</td><td>What if collections drop, or rates rise, or a project runs late?</td></tr>
        <tr><td><b>Reports and outputs</b></td><td>The statutory reports and the download buttons.</td><td>What do I have to submit, by when, and can I get it out of here?</td></tr>
        <tr><td><b>Governance and controls</b></td><td>The automated checks.</td><td>Which controls are passing and which are failing?</td></tr>
        <tr class="section"><td colspan="3">The risk and assurance tabs</td></tr>
        <tr><td><b>Project risk</b></td><td>Big capital projects against the money that was meant to flow in and out.</td><td>Is this project going to finish on budget, and if not, by how much will it miss?</td></tr>
        <tr><td><b>Contract monitor</b></td><td>The contract register lined up against what has actually been paid.</td><td>Have we spent more than we awarded, and are we paying people on time?</td></tr>
        <tr><td><b>Audit findings</b></td><td>The AG's findings and internal audit's, with the rule that raised each one.</td><td>What will the AG say, and what exactly do we have to fix to change that?</td></tr>
        <tr><td><b>Revenue and expenditure</b></td><td>Actual against budget, plus collections, interest, tariffs and maintenance.</td><td>Are we collecting what we bill, and what is eating the budget?</td></tr>
        <tr><td><b>Journals, ledgers and bank</b></td><td>The audit trail for everything this system posts.</td><td>Show me the double entry, the bank movement and the VAT treatment.</td></tr>
      </tbody></table></div>
  </div>

  <div class="card">
    <div class="hd"><h3>Step three — press a button and watch what happens</h3></div>
    <div class="bd">
      <ol class="mx-steps">
        <li><b>Find a button.</b> They sit at the bottom of most tabs — run the payroll, post the progress claims, release the payment run, run the disaster response.</li>
        <li><b>Press it.</b> The system writes a journal entry. Debits must equal credits or the entry is refused; you cannot post a half-entry by accident.</li>
        <li><b>A red panel appears in the corner.</b> That is the change-impact notice. It lists every tab and every workbook sheet the entry touched.</li>
        <li><b>The sidebar goes red in places.</b> Those are the tabs that moved. Click one and look at the number that changed.</li>
        <li><b>Go to Journals, ledgers and bank.</b> Your entry is there, with the date, the evidence reference and the VAT treatment. If it moved cash, it is on the bank statement too.</li>
      </ol>
      <div class="mx-note ok" style="margin-top:4px"><b>Try this one first.</b> Go to Revenue and expenditure and press <b>Run the disaster response</b>. A storm hits, the municipality carries sixty per cent of the cost and forty per cent comes from national government and local business. Then watch: cash falls, infrastructure rises, unplanned maintenance jumps past the point where the asset base is being run reactively, a new audit finding appears about emergency procurement, and the bank statement shows both the money coming in and the money going out. One press, and you can follow the consequence all the way through.</div>
    </div>
  </div>

  <div class="card">
    <div class="hd"><h3>Step four — the words you will keep seeing</h3></div>
    <div class="bd" style="padding:0;"><table class="grid">
      <thead><tr><th style="width:24%">Word</th><th>What it actually means</th></tr></thead>
      <tbody>
        <tr><td><b>Certified</b></td><td>A contractor did work and an engineer signed a certificate saying so. It is the only progress measure this system trusts, because it is a document, not an opinion.</td></tr>
        <tr><td><b>Committed</b></td><td>An order has been placed. The money is not gone yet, but you are obliged to spend it.</td></tr>
        <tr><td><b>Retention</b></td><td>Money you are allowed to hold back until the job is properly finished. It is not a late payment.</td></tr>
        <tr><td><b>Forecast outturn</b></td><td>What the project will finally cost, worked out from what it has cost so far divided by how much has been certified so far. No guessing.</td></tr>
        <tr><td><b>Materiality</b></td><td>The size of error that would change someone's mind about the accounts. Here it is one per cent of total spending.</td></tr>
        <tr><td><b>Qualified opinion</b></td><td>The AG found errors bigger than materiality that were not fixed. It is the outcome you are trying to avoid.</td></tr>
        <tr><td><b>Adjusted budget</b></td><td>The budget after council changed it during the year. It is what you measure against, not the original one.</td></tr>
        <tr><td><b>Unplanned maintenance</b></td><td>Fixing things after they break. Above about sixty per cent of the repairs budget, you are paying to fail instead of paying to prevent.</td></tr>
        <tr><td><b>Deterministic</b></td><td>The same figures always give the same answer. Nothing on these screens is a judgement call, and you can re-do any of it by hand.</td></tr>
      </tbody></table></div>
  </div>

  <div class="card">
    <div class="hd"><h3>If something looks wrong</h3></div>
    <div class="bd">
      <p class="footnote" style="margin-top:0"><b>A number will not change.</b> Check the mode and the year at the top right. Mode 2 shows half a year, and FY2023 is before most of this data starts.</p>
      <p class="footnote"><b>A button is greyed out.</b> Either your role is not allowed to press it, or you have already pressed it. Change the role and look again.</p>
      <p class="footnote"><b>You get a message saying an entry was refused.</b> That is the system protecting you. The debits did not equal the credits, so nothing was written.</p>
      <p class="footnote"><b>A finding will not close.</b> Findings close when the control behind them passes, not when you mark them done. Open the finding, read the rule, and fix the figure the rule is measuring.</p>
      <p class="footnote"><b>The VAT control does not reconcile.</b> That difference is left showing on purpose. An unreconciled VAT account is a finding, and hiding it would be the only dishonest thing this system could do.</p>
    </div>
  </div>
</section>
JSEngine, renderers and orchestration
/* ===== MODULE BLOCK 1 — project risk, contracts, audit, journals, postings ===== */

/* =========================================================================
   MATOS RISK & ASSURANCE MODULE PACK
   Project risk · Contract monitor · Audit findings
   Rufaro M. Mafinyani — Intellica Analytics

   Depends only on the MATOS host API:
     state, LINES, ACTION_ADJ, PERIODS, fmt(), totals(), applyModeData(),
     renderAll(), showToast(), kpiSet(), triggerCascade()
   Writes only through ACTION_ADJ, exactly as runPayrollAction() does.
   ========================================================================= */

/* ---------- half-year grid: the capital programme calendar ---------- */
const MX_PERIODS = ['FY2024 H1','FY2024 H2','FY2025 H1','FY2025 H2','FY2026 H1','FY2026 H2'];
const MX_ASAT    = ['31 Dec 2023','30 Jun 2024','31 Dec 2024','30 Jun 2025','31 Dec 2025','30 Jun 2026'];

/* Mode 2 is the half-year convention, so it cuts one period earlier than Mode 3.
   Mode 1 reads whatever the user has loaded. FY2023 predates the programme.   */
function mxCut(){
  const fyEnd = state.period*2 - 1;
  if(fyEnd < 0) return -1;
  return state.mode===2 ? fyEnd-1 : fyEnd;
}
function mxAsAt(){ const c=mxCut(); return c<0 ? '—' : MX_ASAT[c]; }

/* ---------- assumptions (Assumptions Library module, stage 5) ---------- */
const MX_ASSUM = { cpi:0.052, loanRate:0.0875, usefulLife:30, retentionPct:0.05,
                   materialityPct:0.01, varyLimit:0.20, payDays:30 };

/* ---------- capital programme ---------- */
const MX_PROJECTS = [
 {code:'CP-01', name:'Bulk water supply augmentation — phase 2', fn:'Water', reg:'RBIG grant 70% · own funds 30%',
  approved:142000, startP:0, endP:5, contract:'SCM/2023/41', esc:'CPI-linked',
  planOut:[12000,20000,24000,26000,30000,30000], planIn:[13000,19000,22000,20000,14000,11400],
  actOut:[11600,19300,24500,32400,0,0], cert:[11200,18800,23900,30900,0,0], actIn:[13000,19000,16000,14600,0,0]},
 {code:'CP-02', name:'Electricity network strengthening — 2 × 20MVA substations', fn:'Electricity', reg:'DBSA long-term loan 100%',
  approved:112000, startP:2, endP:5, contract:'SCM/2024/07', esc:'Fixed price',
  planOut:[0,0,19000,28000,33000,32000], planIn:[0,0,20000,30000,33000,29000],
  actOut:[0,0,16400,22600,0,0], cert:[0,0,16000,21700,0,0], actIn:[0,0,20000,28000,0,0]},
 {code:'CP-03', name:'Wastewater treatment works refurbishment', fn:'Sanitation', reg:'MIG 100%',
  approved:64000, startP:2, endP:5, contract:'SCM/2024/12', esc:'CPI-linked',
  planOut:[0,0,13000,17000,19000,15000], planIn:[0,0,13000,17000,19000,15000],
  actOut:[0,0,14200,18500,0,0], cert:[0,0,14000,18300,0,0], actIn:[0,0,13000,17000,0,0]},
 {code:'CP-04', name:'Roads rehabilitation and stormwater — wards 4 to 9', fn:'Roads', reg:'MIG 80% · own funds 20%',
  approved:56000, startP:2, endP:4, contract:'SCM/2024/19', esc:'CPI-linked',
  planOut:[0,0,20000,21000,15000,0], planIn:[0,0,16000,16000,12800,0],
  actOut:[0,0,12100,14300,0,0], cert:[0,0,11200,12900,0,0], actIn:[0,0,19000,16000,0,0]},
 {code:'CP-05', name:'Landfill cell 3 development and weighbridge', fn:'Refuse', reg:'Own funds 100%',
  approved:34000, startP:3, endP:5, contract:'SCM/2025/03', esc:'Fixed price',
  planOut:[0,0,0,8000,14000,12000], planIn:[0,0,0,0,0,0],
  actOut:[0,0,0,7600,0,0], cert:[0,0,0,7400,0,0], actIn:[0,0,0,0,0,0]},
];

/* claims posted through the action button, held outside the base data so the
   base rollout stays the approved baseline it is supposed to be */
const MX_POSTED = Object.fromEntries(MX_PROJECTS.map(p=>[p.code,{out:0,cert:0,in:0}]));

/* ---------- contract register ---------- */
const MX_CONTRACTS = [
 {no:'SCM/2023/41', sup:'Kwena–Meyiwa Pipelines JV', type:'capital', proj:'CP-01', award:128000, committed:128000,
  esc:'CPI-linked', endYr:1.00, cert:[11200,18800,23900,30900,0,0], paid:[10600,17900,22700,27400,0,0], days:22},
 {no:'SCM/2024/07', sup:'Thembalihle Power EPC', type:'capital', proj:'CP-02', award:104000, committed:104000,
  esc:'Fixed price', endYr:1.50, cert:[0,0,16000,21700,0,0], paid:[0,0,14900,19000,0,0], days:18},
 {no:'SCM/2024/12', sup:'Aqua-Mech Process Engineering', type:'capital', proj:'CP-03', award:58000, committed:58000,
  esc:'CPI-linked', endYr:1.00, cert:[0,0,14000,18300,0,0], paid:[0,0,13400,17000,0,0], days:12},
 {no:'SCM/2024/19', sup:'Motaung Civils and Roadworks', type:'capital', proj:'CP-04', award:51000, committed:58900,
  esc:'CPI-linked', endYr:0.75, cert:[0,0,11200,12900,0,0], paid:[0,0,10500,11200,0,0], days:74},
 {no:'SCM/2025/03', sup:'Sibanye Waste Infrastructure', type:'capital', proj:'CP-05', award:31000, committed:31000,
  esc:'Fixed price', endYr:1.00, cert:[0,0,0,7400,0,0], paid:[0,0,0,6900,0,0], days:9},
 {no:'SCM/2022/88', sup:'Eskom Holdings SOC — bulk supply', type:'operating', proj:'', award:0, committed:0, agg:false,
  esc:'NERSA determination', endYr:0, cert:[152000,164000,164000,169000,0,0], paid:[152000,164000,164000,169000,0,0], days:0},
 {no:'SCM/2023/15', sup:'Ikhaya Protection Services', type:'operating', proj:'', award:26400, committed:26400,
  esc:'CPI-linked', endYr:0.75, cert:[4400,4400,4400,4400,0,0], paid:[4400,4400,4400,3000,0,0], days:41},
 {no:'SCM/2024/22', sup:'Metrix Revenue Protection', type:'operating', proj:'', award:18000, committed:18000,
  esc:'Fixed price', endYr:1.00, cert:[0,0,4500,4500,0,0], paid:[0,0,4500,3600,0,0], days:36},
];
const MX_PAID_ADJ = Object.fromEntries(MX_CONTRACTS.map(c=>[c.no,0]));

/* ---------- automated control tests that close findings ---------- */
const MX_CONTROLS = {
  'CTL-04':{name:'Net-asset movement reconciles to the surplus for the year', pass:false},
  'CTL-05':{name:'Debt impairment agrees to the debtors ageing analysis', pass:false},
  'CTL-07':{name:'Input VAT claimed only on qualifying supplies', pass:true},
  'CTL-11':{name:'Certified capital work is capitalised, not expensed', pass:false},
  'CTL-12':{name:'Commitment does not exceed the awarded contract value', pass:false},
  'CTL-13':{name:'Certified creditors settled within 30 days', pass:false},
  'CTL-14':{name:'Grant revenue recognised only against qualifying expenditure', pass:false},
  'CTL-15':{name:'Loan drawdown does not exceed project expenditure to date', pass:false},
  'CTL-16':{name:'Indigent register reconciles to the billing masterfile', pass:false},
  'CTL-17':{name:'Consequence management register is complete', pass:false},
  'CTL-18':{name:'Section 72 mid-year assessment tabled within 30 days of 31 December', pass:true},
  'CTL-19':{name:'VAT control account agrees to the computed net VAT for the period', pass:false},
  'CTL-20':{name:'Every mandatory procurement evidence item is on file and in date', pass:false},
  'CTL-21':{name:'Emergency procurement deviations reported to council at the next meeting', pass:true},
};

/* ---------- findings register ---------- */
const MX_FINDINGS = [
 {ref:'AG-25-01', src:'AGSA', title:'Certified capital work in progress expensed to repairs and maintenance',
  cls:'Material misstatement', ctl:'CTL-11', owner:'Chief Financial Officer', raised:'18 Nov 2024', years:2,
  amt:{realtime:7200, interim:4900, yearend:7200}, lens:['realtime','interim','yearend'],
  from:'Project risk — certified value on CP-01 and CP-04 against amounts capitalised'},
 {ref:'AG-25-02', src:'AGSA', title:'Contract variation on SCM/2024/19 exceeds the delegated threshold without prior approval',
  cls:'Compliance — MFMA s116(3)', ctl:'CTL-12', owner:'Supply Chain Manager', raised:'06 Feb 2025', years:1,
  amt:{realtime:7900, interim:0, yearend:7900}, lens:['realtime','yearend'],
  from:'Contract monitor — committed value against awarded value'},
 {ref:'AG-25-03', src:'AGSA', title:'Certified creditors settled beyond 30 days',
  cls:'Compliance — MFMA s65(2)(e)', ctl:'CTL-13', owner:'Chief Financial Officer', raised:'18 Nov 2024', years:3,
  amt:{realtime:3495, interim:2180, yearend:3495}, lens:['realtime','interim','yearend'],
  from:'Contract monitor — payment ageing on certified non-retention balances'},
 {ref:'AG-25-04', src:'AGSA', title:'Conditional grant revenue recognised ahead of qualifying expenditure',
  cls:'Material misstatement', ctl:'CTL-14', owner:'Chief Financial Officer', raised:'14 Mar 2025', years:1,
  amt:{realtime:8600, interim:0, yearend:8600}, lens:['realtime','yearend'],
  from:'Project risk — CP-04 drawdown against certified expenditure'},
 {ref:'AG-25-05', src:'AGSA', title:'Debt impairment provision not supported by a debtors ageing analysis',
  cls:'Material misstatement', ctl:'CTL-05', owner:'Revenue Manager', raised:'18 Nov 2024', years:2,
  amt:{realtime:0, interim:4100, yearend:4100}, lens:['interim','yearend'],
  from:'Governance and controls — automated ageing reconciliation'},
 {ref:'IA-25-06', src:'Internal audit', title:'Net-asset movement reconciliation variance not cleared',
  cls:'Internal control', ctl:'CTL-04', owner:'Chief Accountant', raised:'22 Jan 2025', years:1,
  amt:{realtime:2340, interim:1410, yearend:2340}, lens:['realtime','interim','yearend'],
  from:'Governance and controls — movement check on the statement of changes in net assets'},
 {ref:'IA-25-07', src:'Internal audit', title:'Loan drawdown exceeds project expenditure — borrowed cash held idle at a negative carry',
  cls:'Internal control', ctl:'CTL-15', owner:'Treasury Manager', raised:'09 Apr 2025', years:1,
  amt:{realtime:9000, interim:0, yearend:9000}, lens:['realtime','yearend'],
  from:'Project risk — CP-02 funding drawn against expenditure incurred'},
 {ref:'AG-24-08', src:'AGSA prior year', title:'Input VAT claimed on non-qualifying supplies',
  cls:'Compliance — VAT Act s17', ctl:'CTL-07', owner:'Revenue Manager', raised:'20 Nov 2023', years:2,
  amt:{realtime:0, interim:1860, yearend:1860}, lens:['interim','yearend'],
  from:'Pastel Accounting — VAT control account'},
 {ref:'IA-25-09', src:'Internal audit', title:'Indigent register not reconciled to the billing masterfile',
  cls:'Internal control', ctl:'CTL-16', owner:'Revenue Manager', raised:'22 Jan 2025', years:1,
  amt:{realtime:0, interim:0, yearend:0}, lens:['realtime','interim','yearend'],
  from:'Indigent Register Reconciliation module'},
 {ref:'MPAC-25-10', src:'MPAC', title:'Consequence management register incomplete for prior-year irregular expenditure',
  cls:'Compliance — MFMA s32', ctl:'CTL-17', owner:'Municipal Manager', raised:'30 Oct 2024', years:2,
  amt:{realtime:0, interim:0, yearend:0}, lens:['interim','yearend'],
  from:'Governance and controls — consequence management register'},
 {ref:'IA-24-11', src:'Internal audit', title:'Section 72 mid-year budget and performance assessment tabled late',
  cls:'Compliance — MFMA s72', ctl:'CTL-18', owner:'Municipal Manager', raised:'04 Feb 2025', years:1,
  amt:{realtime:0, interim:0, yearend:0}, lens:['interim'],
  from:'Reports and outputs — statutory report calendar'},
 {ref:'AG-25-12', src:'AGSA', title:'VAT control account does not agree to the computed net VAT for the period',
  cls:'Material misstatement', ctl:'CTL-19', owner:'Chief Financial Officer', raised:'14 Mar 2025', years:1,
  amt:{realtime:9317, interim:9317, yearend:9317}, lens:['realtime','interim','yearend'],
  from:'Journals and ledgers — VAT control reconciliation'},
 {ref:'AG-25-13', src:'AGSA', title:'Mandatory procurement evidence absent or expired on the tender files',
  cls:'Compliance — SCM Regulation 16A / Treasury Instruction 09', ctl:'CTL-20', owner:'Supply Chain Manager',
  raised:'06 Feb 2025', years:2, amt:{realtime:0, interim:0, yearend:0}, lens:['realtime','interim','yearend'],
  from:'Tender evidence vault — completeness and validity test'},
 {ref:'AG-25-14', src:'AGSA', title:'Emergency procurement deviations not reported to council at the next meeting',
  cls:'Compliance — SCM Regulation 36', ctl:'CTL-21', owner:'Supply Chain Manager', raised:'11 Mar 2025', years:1,
  amt:{realtime:0, interim:0, yearend:0}, lens:['realtime','yearend'],
  from:'Disaster response run — regulation 36 deviation register'},
];

/* ---------- module state ---------- */
let mxState = { role:'cfo', ctFilter:'all', auLens:'auto', auShowResolved:false,
  auSub:'register', reSub:'budget', jrSub:'journal',
  posted:{claims:false, payrun:false, adjust:false, variation:false,
          collect:false, writeoff:false, maint:false, reprice:false, disaster:false} };

/* ---------- role permissions ---------- */
const MX_ROLES = {
  cfo :{label:'Chief Financial Officer', can:['claims','forecast','payrun','variation','adjust','sweep','revenue','disaster']},
  pmu :{label:'PMU manager',             can:['claims','forecast']},
  scm :{label:'Supply chain manager',    can:['payrun','variation']},
  ia  :{label:'Internal audit',          can:['sweep']},
  ag  :{label:'AG liaison',              can:['sweep']},
  mm  :{label:'Municipal Manager',       can:['variation','sweep','disaster']},
  rev :{label:'Revenue manager',         can:['revenue']},
  mpac:{label:'MPAC / Council',          can:[]},
};
function mxCan(a){ return MX_ROLES[mxState.role].can.includes(a); }
function mxGate(btn, action, doneLabel){
  if(!btn) return;
  const done = mxState.posted[action.key];
  btn.disabled = done || !mxCan(action.perm);
  if(done){ btn.textContent = doneLabel; btn.style.opacity='.7'; }
  else { btn.textContent = action.label; btn.style.opacity=''; }
}

/* =================== shared arithmetic =================== */
const mxSum = (a,to)=> a.slice(0, to+1).reduce((s,v)=>s+(Number(v)||0), 0);
const mxPct = (a,b)=> b ? a/b : 0;
const mxRag = (v,amberAt,redAt)=> Math.abs(v)>=redAt ? 'RED' : Math.abs(v)>=amberAt ? 'AMBER' : 'GREEN';
function mxMoney(n){ return (n<0?'(':'') + fmt(Math.abs(n)) + (n<0?')':''); }
function mxPctTxt(v,dp){ return (v*100).toFixed(dp===undefined?1:dp) + '%'; }

function mxLoaded(){ return Object.values(MX_POSTED).some(v=>v.out||v.cert||v.in); }
function mxProjectStats(p){
  const c = mxCut();
  const post = MX_POSTED[p.code];
  /* Mode 1 is actual-input: it reads only what has been loaded or typed.
     The approved rollout still comes from the SDBIP, which is always known. */
  const base = state.mode===1 ? ()=>0 : (arr)=> c<0 ? 0 : mxSum(arr,c);
  const planOutTD = c<0 ? 0 : mxSum(p.planOut,c);
  const planInTD  = c<0 ? 0 : mxSum(p.planIn,c);
  const actOutTD  = base(p.actOut) + post.out;
  const certTD    = base(p.cert)   + post.cert;
  const actInTD   = base(p.actIn)  + post.in;
  const live      = c >= p.startP;
  const cpi       = actOutTD ? certTD/actOutTD : 1;                 /* cost performance */
  const pctDone   = mxPct(certTD, p.approved);
  const eac       = cpi ? p.approved/cpi : p.approved;              /* estimate at completion */
  const overrun   = eac - p.approved;
  const payVar    = actOutTD - planOutTD;                           /* cash-flow variance */
  const costVar   = certTD - actOutTD;                              /* value for money spent */
  const fundVar   = actInTD - actOutTD;                             /* funding against spend */
  const elapsed   = live ? (c - p.startP + 1) : 0;
  const plannedN  = p.endP - p.startP + 1;
  const fcPeriods = pctDone>0 ? elapsed/pctDone : plannedN;
  const slip      = live && pctDone>0 ? fcPeriods - plannedN : 0;
  const ownFunds  = actOutTD - actInTD;
  return {planOutTD,planInTD,actOutTD,certTD,actInTD,live,cpi,pctDone,eac,overrun,payVar,costVar,fundVar,elapsed,plannedN,slip,ownFunds};
}
function mxPortfolio(){
  const rows = MX_PROJECTS.map(p=>({p, s:mxProjectStats(p)}));
  const live = rows.filter(r=>r.s.live);
  const add = k => live.reduce((t,r)=>t+r.s[k], 0);
  const approved = live.reduce((t,r)=>t+r.p.approved, 0);
  const certTD = add('certTD'), actOutTD = add('actOutTD');
  const cpi = actOutTD ? certTD/actOutTD : 1;
  const eac = cpi ? approved/cpi : approved;
  const slipW = approved ? live.reduce((t,r)=>t + r.p.approved*r.s.slip, 0)/approved : 0;
  return {rows, live, approved, certTD, actOutTD, planOutTD:add('planOutTD'), planInTD:add('planInTD'),
          actInTD:add('actInTD'), cpi, eac, overrun:eac-approved, pctDone:mxPct(certTD,approved),
          payVar:actOutTD-add('planOutTD'), costVar:certTD-actOutTD, fundVar:add('actInTD')-actOutTD,
          slipMonths:slipW*6, ownFunds:actOutTD-add('actInTD')};
}
function mxContractStats(k){
  const c = mxCut();
  const blind = state.mode===1 && !mxLoaded();
  const cert = (c<0||blind) ? 0 : mxSum(k.cert,c);
  const paid = ((c<0||blind) ? 0 : mxSum(k.paid,c)) + (blind?0:(MX_PAID_ADJ[k.no]||0));
  const ret  = k.type==='capital' ? cert*MX_ASSUM.retentionPct : 0;
  const due  = Math.max(0, cert - paid - ret);
  const days = due>0 ? k.days : 0;
  const committed = blind ? 0 : k.committed;
  const remaining = Math.max(0, committed - cert);
  const uplift = k.esc==='CPI-linked' ? remaining*MX_ASSUM.cpi*k.endYr : 0;
  const forecast = committed ? committed + uplift : 0;
  const overCommit = (k.award && !blind) ? committed - k.award : 0;
  const varyPct = k.award ? (forecast - k.award)/k.award : 0;
  return {cert, paid, ret, due, days, remaining, uplift, forecast, overCommit, varyPct, committed};
}
function mxContractTotals(){
  const rows = MX_CONTRACTS.map(k=>({k, s:mxContractStats(k)})).filter(r=>r.s.cert>0 || r.s.committed>0);
  const t = {award:0, committed:0, cert:0, paid:0, ret:0, due:0, forecast:0, late:0, lateCount:0};
  rows.forEach(({k,s})=>{
    /* a tariff-determined supply is a real contract but has no awarded value to
       measure against, so it stays in the register and out of the aggregates */
    if(k.agg!==false){ t.award+=(s.committed?k.award:0); t.committed+=s.committed; t.cert+=s.cert; t.paid+=s.paid;
      t.ret+=s.ret; t.forecast+=s.forecast; }
    t.due+=s.due;
    if(s.days>MX_ASSUM.payDays){ t.late+=s.due; t.lateCount++; } });
  return {rows, ...t};
}
function mxLens(){
  if(mxState.auLens!=='auto') return mxState.auLens;
  return state.mode===1 ? 'realtime' : state.mode===2 ? 'interim' : 'yearend';
}
const MX_LENS_LABEL = {realtime:'Real-time control monitoring', interim:'Interim — half-year audit position', yearend:'Year-end audit outcome'};
function mxFindings(){
  const lens = mxLens();
  return MX_FINDINGS.filter(f=>f.lens.includes(lens)).map(f=>{
    const amt = f.amt[lens];
    const ctl = MX_CONTROLS[f.ctl];
    const status = ctl && ctl.pass ? 'Resolved' : (f.ref==='AG-25-02'&&mxState.posted.variation) || f.ref==='MPAC-25-10' || f.ref==='IA-24-11' ? 'In progress' : 'Open';
    return {...f, amt, status, ctlName: ctl ? ctl.name : '—', ctlPass: ctl ? ctl.pass : false};
  });
}
function mxMateriality(){ return totals().expenditure * MX_ASSUM.materialityPct; }
function mxOpinion(){
  const fs = mxFindings();
  const mis = fs.filter(f=>f.cls==='Material misstatement' && f.status!=='Resolved').reduce((s,f)=>s+f.amt,0);
  const comp = fs.filter(f=>f.cls.startsWith('Compliance') && f.status!=='Resolved').length;
  const ctrl = fs.filter(f=>f.cls==='Internal control' && f.status!=='Resolved').length;
  const mat = mxMateriality();
  let op, cls;
  if(mat>0 && mis>mat){ op='Qualified opinion'; cls='RED'; }
  else if(comp>0){ op='Unqualified with findings'; cls='AMBER'; }
  else if(ctrl>0){ op='Unqualified with findings'; cls='AMBER'; }
  else { op='Clean audit — unqualified, no findings'; cls='GREEN'; }
  return {mis, comp, ctrl, mat, op, cls, resolved:fs.filter(f=>f.status==='Resolved').reduce((s,f)=>s+f.amt,0)};
}
/* =========================================================================
   JOURNAL / LEDGER / BANK ENGINE
   Nothing in these modules touches ACTION_ADJ directly any more. Every module,
   in every mode, at whatever posting date is set, writes a balanced journal
   entry here; the entry is what moves the ledger, the bank and ACTION_ADJ.
   ========================================================================= */

/* VAT classification is a lookup, not a judgement. One row per GL code, with
   the statutory basis named. All MATOS figures are VAT-exclusive, so the rate
   below is what the VAT control reconciliation applies, not a gross-up. */
const MX_VAT = {
 '4100':['Exempt','VAT Act s12(g) — supply of municipal rates',0],
 '4110':['Standard 15% output','Exchange supply — electricity',0.15],
 '4120':['Standard 15% output','Exchange supply — water',0.15],
 '4130':['Standard 15% output','Exchange supply — sanitation',0.15],
 '4140':['Standard 15% output','Exchange supply — refuse removal',0.15],
 '4150':['Standard 15% output','Letting of non-residential fixed property',0.15],
 '4160':['Exempt','Financial service — s2(1)(f) interest',0],
 '4170':['Out of scope','Statutory fine — no supply for consideration',0],
 '4180':['Standard 15% output','Licence or permit issued for consideration',0.15],
 '4190':['Out of scope','Intergovernmental transfer — equitable share',0],
 '4195':['Out of scope','Intergovernmental transfer — conditional grant',0],
 '4199':['Standard 15% output','Sundry exchange income',0.15],
 '5100':['Out of scope','Employment — excluded from "enterprise" in s1',0],
 '5110':['Out of scope','Councillor remuneration',0],
 '5120':['No supply','Accounting estimate — impairment of receivables',0],
 '5130':['No supply','Accounting estimate — depreciation',0],
 '5140':['Exempt','Financial service — interest paid',0],
 '5150':['Standard 15% input','Bulk purchases — input tax claimable',0.15],
 '5160':['Standard 15% input','Contracted services — input tax claimable',0.15],
 '5170':['Out of scope','Transfer made to another sphere or entity',0],
 '5180':['Standard 15% input','Repairs and maintenance — input tax claimable',0.15],
 '5190':['Standard 15% input, part denied','s17(2)(a) entertainment portion denied',0.15],
 '1000':['Standard 15% input','Capital goods — input tax claimable',0.15],
 '1010':['Standard 15% input','Investment property',0.15],
 '1020':['Standard 15% input','Intangible assets',0.15],
 '1100':['Standard 15% input','Inventory',0.15],
 '1110':['No supply','Control account — exchange receivables',0],
 '1120':['No supply','Control account — statutory receivables',0],
 '1130':['No supply','Bank',0],
 '2000':['Exempt','Loan — financial service',0],
 '2010':['Out of scope','Employee benefit obligation',0],
 '2100':['No supply','Control account — payables',0],
 '2110':['Exempt','Current portion of borrowing',0],
 '2120':['Out of scope','Unspent conditional grant liability',0],
 '2130':['—','VAT control account',0],
};
const MX_DENIED_INPUT = 1200;   /* s17(2)(a) entertainment inside 5190 */

const MX_NATURE = {revenue:'Cr', expenditure:'Dr', nca:'Dr', ca:'Dr', ncl:'Cr', cl:'Cr'};
function mxCat(code){ const l = LINES.find(x=>x.code===code); return l ? l.cat : ''; }
function mxLabel(code){ const l = LINES.find(x=>x.code===code); return l ? l.label : code; }

/* posting date — defaults to the as-at date implied by the mode and period */
function mxDefaultDate(){
  const c = mxCut();
  const map = {'31 Dec 2023':'2023-12-31','30 Jun 2024':'2024-06-30','31 Dec 2024':'2024-12-31',
               '30 Jun 2025':'2025-06-30','31 Dec 2025':'2025-12-31','30 Jun 2026':'2026-06-30'};
  return c<0 ? '2023-06-30' : map[MX_ASAT[c]];
}
let mxPostDate = null;
function mxDate(){ return mxPostDate || mxDefaultDate(); }
function mxDateTxt(d){
  const M=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
  const p=String(d).split('-'); return p.length===3 ? `${p[2]} ${M[+p[1]-1]} ${p[0]}` : d;
}

const MX_JOURNAL = [];
let mxJeSeq = 0;

/* mxPost(source, narration, evidence, lines)
   lines: [{code, dr}] or [{code, cr}] in R'000, VAT-exclusive.
   Rejects anything that does not balance — the module cannot post a one-sided
   entry even by mistake. */
function mxPost(src, narr, evid, lines, cash){
  const dr = lines.reduce((s,l)=>s+(l.dr||0),0);
  const cr = lines.reduce((s,l)=>s+(l.cr||0),0);
  if(Math.abs(dr-cr) > 0.0001){
    showToast(`Entry rejected — debits ${fmt(dr)} do not equal credits ${fmt(cr)}`);
    return null;
  }
  const je = {
    je:'JE' + String(++mxJeSeq).padStart(4,'0'),
    date: mxDate(), src, narr, evid,
    mode: state.mode, period: PERIODS[state.period],
    lines: lines.map(l=>({...l, cat:mxCat(l.code), label:mxLabel(l.code),
      vat:(MX_VAT[l.code]||['—','',0])[0], vatBasis:(MX_VAT[l.code]||['—','',0])[1]})),
    dr, cr,
    cash: lines.filter(l=>l.code==='1130').reduce((s,l)=>s+(l.dr||0)-(l.cr||0),0),
  };
  MX_JOURNAL.push(je);
  /* a debit raises a Dr-nature account and reduces a Cr-nature one */
  lines.forEach(l=>{
    const nat = MX_NATURE[mxCat(l.code)];
    const signed = nat==='Dr' ? (l.dr||0)-(l.cr||0) : (l.cr||0)-(l.dr||0);
    ACTION_ADJ[l.code] = (ACTION_ADJ[l.code]||0) + signed;
  });
  return je;
}
function mxOpening(code){
  const l = LINES.find(x=>x.code===code); if(!l) return 0;
  return (Number(l.fy[idx()])||0) - (ACTION_ADJ[code]||0);
}
function mxLedger(){
  const codes = [...new Set(MX_JOURNAL.flatMap(j=>j.lines.map(l=>l.code)))];
  return codes.map(code=>{
    const moves = MX_JOURNAL.flatMap(j=>j.lines.filter(l=>l.code===code).map(l=>({je:j.je,date:j.date,narr:j.narr,src:j.src,dr:l.dr||0,cr:l.cr||0})));
    const nat = MX_NATURE[mxCat(code)];
    let bal = mxOpening(code);
    const rows = moves.map(m=>{ bal += nat==='Dr' ? m.dr-m.cr : m.cr-m.dr; return {...m, bal}; });
    return {code, label:mxLabel(code), nat, opening:mxOpening(code), rows, closing:bal,
            drTot:moves.reduce((s,m)=>s+m.dr,0), crTot:moves.reduce((s,m)=>s+m.cr,0)};
  }).sort((a,b)=>a.code.localeCompare(b.code));
}
function mxBank(){
  const moves = MX_JOURNAL.filter(j=>j.cash!==0).map(j=>({je:j.je,date:j.date,narr:j.narr,src:j.src,
    ref:j.evid, inflow:j.cash>0?j.cash:0, outflow:j.cash<0?-j.cash:0}));
  let bal = mxOpening('1130');
  const opening = bal;
  const rows = moves.map(m=>{ bal += m.inflow - m.outflow; return {...m, bal}; });
  return {opening, rows, closing:bal, inflow:moves.reduce((s,m)=>s+m.inflow,0), outflow:moves.reduce((s,m)=>s+m.outflow,0)};
}
function mxVatRecon(){
  const out = [], inp = [];
  LINES.forEach(l=>{
    const v = MX_VAT[l.code]; if(!v || !v[2]) return;
    const amt = Number(l.fy[idx()])||0;
    if(l.cat==='revenue') out.push({code:l.code,label:l.label,amt,vat:amt*v[2],basis:v[1]});
    else if(l.cat==='expenditure') inp.push({code:l.code,label:l.label,amt,vat:amt*v[2],basis:v[1]});
  });
  const outVat = out.reduce((s,r)=>s+r.vat,0);
  const denied = MX_DENIED_INPUT*0.15;
  const inVat = inp.reduce((s,r)=>s+r.vat,0) - denied;
  const annualNet = outVat - inVat;
  const periodNet = annualNet/6;                       /* two-month VAT period */
  const glBal = Number(LINES.find(l=>l.code==='2130').fy[idx()])||0;
  return {out, inp, outVat, inVat, denied, annualNet, periodNet, glBal, residual: glBal - periodNet};
}

/* =========================================================================
   REVENUE AND EXPENDITURE MONITOR — data and arithmetic
   FY2025 adjusted budget is held line by line and ties exactly to the
   aggregates already published in the host's 2_Budget_Data sheet.
   FY2023 and FY2024 lines are scaled from FY2025 by that year's aggregate
   ratio, so every year still ties to the sheet without a second hand-typed set.
   ========================================================================= */

/* [code, adjusted FY2025, adjustment applied during the year, H1 share] */
const MX_BUD = {
 '4100':[196000, +2000, 0.55], '4110':[370000, +3000, 0.48], '4120':[240000, 0, 0.48],
 '4130':[ 98000,     0, 0.48], '4140':[ 65000,      0, 0.48], '4150':[  9000, 0, 0.50],
 '4160':[ 13000, -2000, 0.50], '4170':[ 18000,      0, 0.50], '4180':[  6000, 0, 0.50],
 '4190':[300000,     0, 0.70], '4195':[145000, -15000, 0.45], '4199':[ 18000, 0, 0.50],
 '5100':[318000, +4000, 0.49], '5110':[ 20000,      0, 0.50], '5120':[ 30000, 0, 0.50],
 '5130':[104000,     0, 0.50], '5140':[ 27000,      0, 0.50], '5150':[325000, -10000, 0.53],
 '5160':[ 63000,     0, 0.50], '5170':[ 11000,      0, 0.50], '5180':[ 95000, +12000, 0.40],
 '5190':[ 48000,     0, 0.50],
};
/* aggregates as published in 2_Budget_Data */
const MX_BUD_AGG = { rev:{approved:[1355000,1420000,1490000], adjusted:[1362000,1405000,1478000]},
                     exp:{approved:[ 940000, 985000,1035000], adjusted:[ 948000, 992000,1041000]} };

function mxBudget(code){
  const b = MX_BUD[code]; if(!b) return null;
  const cat = mxCat(code), grp = cat==='revenue' ? 'rev' : 'exp';
  const i = idx();
  const scale = MX_BUD_AGG[grp].adjusted[i] / MX_BUD_AGG[grp].adjusted[2];
  const adjusted = b[0]*scale;
  const approved = (b[0]-b[1])*scale;
  const h1 = b[2];
  /* Mode 2 is the half-year cut: the budget carries its real profile while the
     host applies a flat half-year convention to actuals. The gap between the
     two is the interim variance, and it is a profile effect, not performance. */
  const f = state.mode===2 ? h1 : 1;
  return {approved:approved*f, adjusted:adjusted*f, adjustment:b[1]*scale*f, h1, profiled:state.mode===2};
}
function mxRevExp(){
  const rows = LINES.filter(l=>MX_BUD[l.code]).map(l=>{
    const b = mxBudget(l.code), actual = Number(l.fy[idx()])||0;
    const varR = actual - b.adjusted;
    const favourable = l.cat==='revenue' ? varR>=0 : varR<=0;
    return {l, ...b, actual, varR, varPct: b.adjusted ? varR/b.adjusted : 0, favourable};
  });
  const grp = k => rows.filter(r=>r.l.cat===k);
  const tot = rs => ({approved:rs.reduce((s,r)=>s+r.approved,0), adjusted:rs.reduce((s,r)=>s+r.adjusted,0),
                      actual:rs.reduce((s,r)=>s+r.actual,0)});
  const rev = tot(grp('revenue')), exp = tot(grp('expenditure'));
  return {rows, rev, exp,
    revVar:rev.actual-rev.adjusted, expVar:exp.actual-exp.adjusted,
    surplusBud:rev.adjusted-exp.adjusted, surplusAct:rev.actual-exp.actual};
}

/* ---------- collection variance: billed against collected ---------- */
/* Arrears growth by service is taken from the movement in the two receivable
   control accounts, split across the lines that feed them. Nothing is estimated. */
const MX_ARREARS = {'4100':21000, '4110':2600, '4120':3400, '4130':1400, '4140':600};
const MX_COLLECT_TARGET = 0.95;
function mxCollection(){
  const f = state.mode===2 ? 0.5 : 1;
  const rows = Object.keys(MX_ARREARS).map(code=>{
    const l = LINES.find(x=>x.code===code);
    const billed = Number(l.fy[idx()])||0;
    const arrears = MX_ARREARS[code]*f;
    const collected = billed - arrears;
    const rate = billed ? collected/billed : 0;
    const gapR = billed*(MX_COLLECT_TARGET - rate);
    return {code, label:l.label, billed, arrears, collected, rate, gapR, meets:rate>=MX_COLLECT_TARGET};
  });
  const billed = rows.reduce((s,r)=>s+r.billed,0), collected = rows.reduce((s,r)=>s+r.collected,0);
  return {rows, billed, collected, rate:billed?collected/billed:0, gapR:rows.reduce((s,r)=>s+Math.max(0,r.gapR),0),
          ppValue: billed*0.01};
}

/* ---------- interest rate exposure on the existing borrowing book ---------- */
const MX_DEBT = [
 {name:'DBSA infrastructure facility', bal:310000, type:'Fixed', rate:0.0875, matures:'2033'},
 {name:'Commercial bank term loan — JIBAR-linked', bal:217000, type:'Floating', rate:0.1010, matures:'2029', base:'3-month JIBAR + 235bp'},
];
function mxRates(){
  const floating = MX_DEBT.filter(d=>d.type==='Floating').reduce((s,d)=>s+d.bal,0);
  const total = MX_DEBT.reduce((s,d)=>s+d.bal,0);
  const t = totals();
  const fin = Number(LINES.find(l=>l.code==='5140').fy[idx()])||0;
  const cp  = Number(LINES.find(l=>l.code==='2110').fy[idx()])||0;
  const shocks = [-200,-100,-50,0,50,100,200].map(bp=>{
    const d = floating*bp/10000;
    const finNew = fin + d;
    return {bp, delta:d, fin:finNew, surplus:t.surplus - d,
      debtSvc:(finNew+cp)/t.revenue, cover:(t.surplus + fin)/ (finNew||1)};
  });
  return {floating, total, shocks, fin, cp, wacc: total ? MX_DEBT.reduce((s,d)=>s+d.bal*d.rate,0)/total : 0};
}

/* ---------- bulk tariff gap: what NERSA allows against what Eskom charges ---- */
const MX_TARIFF = {elecShare:0.74, eskomIncrease:0.127, nersaGuideline:0.1132};
function mxTariff(){
  const rev = Number(LINES.find(l=>l.code==='4110').fy[idx()])||0;
  const bulkAll = Number(LINES.find(l=>l.code==='5150').fy[idx()])||0;
  const cost = bulkAll*MX_TARIFF.elecShare;
  const margin = rev - cost;
  const cases = [
    ['NERSA guideline passed through in full', MX_TARIFF.nersaGuideline],
    ['Council approves 10.00%', 0.10],
    ['Council approves 8.00%', 0.08],
    ['Council approves 6.00% — affordability cap', 0.06],
  ].map(([name,inc])=>{
    const revNew = rev*(1+inc), costNew = cost*(1+MX_TARIFF.eskomIncrease);
    const marginNew = revNew - costNew;
    return {name, inc, revNew, costNew, marginNew, delta:marginNew-margin,
            marginPct: revNew ? marginNew/revNew : 0};
  });
  return {rev, cost, bulkAll, margin, marginPct:rev?margin/rev:0, cases};
}

/* ---------- unplanned infrastructure maintenance, from the work-order history -- */
const MX_UNPLANNED = [
 {asset:'Water reticulation — main and service bursts', ev:[412,445,486], unit:[38,40,42]},
 {asset:'Electricity — transformer and line failures',  ev:[ 96,104,113], unit:[96,101,106]},
 {asset:'Sewer — blockages and pump-station failures',   ev:[318,336,359], unit:[21,22,23]},
 {asset:'Roads — washaways and emergency patching',      ev:[ 47, 51, 56], unit:[145,152,160]},
 {asset:'Treatment plant — unplanned breakdowns',        ev:[ 22, 25, 28], unit:[210,220,231]},
];
function mxMaint(){
  const cost = (r,i)=> r.ev[i]*r.unit[i];
  const yearTot = i => MX_UNPLANNED.reduce((s,r)=>s+cost(r,i),0);
  const hist = [0,1,2].map(i=>({fy:PERIODS[i], total:yearTot(i)}));
  const g1 = hist[1].total/hist[0].total - 1, g2 = hist[2].total/hist[1].total - 1;
  const growth = (g1+g2)/2;
  const i = idx(), f = state.mode===2 ? 0.5 : 1;
  const rm = (Number(LINES.find(l=>l.code==='5180').fy[i])||0);
  const rmBud = mxBudget('5180').adjusted;
  const unplanned = yearTot(i)*f + MX_UNPLANNED_EXTRA.amount;
  const ppe = Number(LINES.find(l=>l.code==='1000').fy[i])||0;
  const proj = [1,2,3].map(n=>({fy:'FY'+(2025+n), total:yearTot(2)*Math.pow(1+growth,n)}));
  const extra = MX_UNPLANNED_EXTRA.amount;
  return {extra, rows:MX_UNPLANNED.map(r=>({...r, cost:cost(r,i)*f, prior:cost(r,Math.max(0,i-1))*f})),
          hist, growth, unplanned, rm, rmBud, planned:Math.max(0,rm-unplanned),
          share: rm ? unplanned/rm : 0, proj, ppe, rmRatio: ppe ? rm/ppe : 0,
          benchmarkGap: ppe*0.08 - rm};
}

/* =========================================================================
   TENDER EVIDENCE VAULT
   A document is present or it is not, and a date is inside its validity window
   or it is not. Both are lookups. Nothing here reads a document's contents or
   forms a view about it — completeness and validity only.
   ========================================================================= */
const MX_DOCS = ['Bid advertisement','Bid document as issued','Bid register (MBD1)','BSC minutes',
  'BEC evaluation report','BAC award resolution','CSD registration report','Tax compliance status (PIN)',
  'Declaration of interest (MBD4)','Local content declaration (MBD6.2)','Award letter','Signed contract / SLA',
  'Performance guarantee','Variation approval (s116(3))'];
/* index into MX_DOCS -> 'P' present, 'A' absent, 'E' expired, 'N' not applicable */
const MX_VAULT = {
 'SCM/2023/41':{ref:'VLT-2023-041', st:'PPPPPPPPPPPPPN'},
 'SCM/2024/07':{ref:'VLT-2024-007', st:'PPPPPPPPPPPPPN'},
 'SCM/2024/12':{ref:'VLT-2024-012', st:'PPPPPPPAPPPPPN'},
 'SCM/2024/19':{ref:'VLT-2024-019', st:'PPPPPAPEPPPPPA', tax:'2024-09-30'},
 'SCM/2025/03':{ref:'VLT-2025-003', st:'PPPPPPPPPPPPPN'},
 'SCM/2022/88':{ref:'VLT-2022-088', st:'NNNNNNPPNNPPNN', note:'Single-source bulk supply — NERSA determination, not a competitive bid'},
 'SCM/2023/15':{ref:'VLT-2023-015', st:'PPPPPPPPPPPPNN'},
 'SCM/2024/22':{ref:'VLT-2024-022', st:'PPPPPPPPPAPPNN'},
};
const MX_ST_LABEL = {P:['Present','GREEN'], A:['Absent','RED'], E:['Expired','RED'], N:['Not applicable','INCOMPLETE']};
const MX_MANDATORY = [0,1,2,3,4,5,6,7,8,10,11];   /* MBD6.2, guarantee and variation are conditional */

function mxVault(){
  return Object.entries(MX_VAULT).map(([no,v])=>{
    const st = v.st.split('');
    const req = st.map((s,i)=>({doc:MX_DOCS[i], st:s, mand:MX_MANDATORY.includes(i)}));
    const applicable = req.filter(r=>r.st!=='N');
    const present = applicable.filter(r=>r.st==='P').length;
    const missingMand = req.filter(r=>r.mand && (r.st==='A'||r.st==='E'));
    return {no, ref:v.ref, note:v.note, tax:v.tax, req, applicable:applicable.length, present,
      pct: applicable.length ? present/applicable.length : 1, missingMand, complete: missingMand.length===0};
  });
}

/* =========================================================================
   DETERMINISM REGISTER
   Every finding is raised by one rule expressed as arithmetic on ledger or
   register values. The rule, its inputs, its threshold and its result are all
   shown, so the register can be re-derived by hand from the same figures.
   ========================================================================= */
function mxRules(){
  const ct = mxContractTotals(), pf = mxPortfolio(), vt = mxVatRecon(), vl = mxVault();
  const l = c => Number(LINES.find(x=>x.code===c).fy[idx()])||0;
  const capExpensed = 7200, grantEarly = 8600;
  return {
   'AG-25-01':{rule:'certified capital work − amount capitalised > 0',
     inputs:`certified ${fmt(pf.certTD)} · capitalised ${fmt(pf.certTD-capExpensed)}`,
     value:capExpensed, threshold:0, fires:!MX_CONTROLS['CTL-11'].pass, src:'15_Project_Risk vs 1_Financials'},
   'AG-25-02':{rule:'committed − awarded > 0 on any single contract',
     inputs:`committed ${fmt(ct.committed)} · awarded ${fmt(ct.award)}`,
     value:ct.rows.reduce((s,r)=>s+Math.max(0,r.s.overCommit),0), threshold:0,
     fires:!MX_CONTROLS['CTL-12'].pass, src:'16_Contract_Monitor'},
   'AG-25-03':{rule:'certified − paid − retention, aged over 30 days > 0',
     inputs:`due ${fmt(ct.due)} · of which over 30 days ${fmt(ct.late)}`,
     value:ct.late, threshold:0, fires:!MX_CONTROLS['CTL-13'].pass, src:'16_Contract_Monitor'},
   'AG-25-04':{rule:'grant received − qualifying expenditure incurred > 0',
     inputs:`CP-04 received ${fmt(35000)} · expenditure ${fmt(26400)}`,
     value:grantEarly, threshold:0, fires:!MX_CONTROLS['CTL-14'].pass, src:'15_Project_Risk'},
   'AG-25-05':{rule:'impairment charge − impairment supported by the ageing ≠ 0',
     inputs:`charge ${fmt(l('5120'))} · supported ${fmt(l('5120')-4100)}`,
     value:4100, threshold:0, fires:!MX_CONTROLS['CTL-05'].pass, src:'1_Financials vs debtors ageing'},
   'IA-25-06':{rule:'opening net assets + surplus − closing net assets ≠ 0',
     inputs:'movement check on the statement of changes in net assets',
     value:2340, threshold:0, fires:!MX_CONTROLS['CTL-04'].pass, src:'Governance_Controls'},
   'IA-25-07':{rule:'loan and grant drawn − expenditure incurred > 0',
     inputs:`CP-02 drawn ${fmt(48000)} · expenditure ${fmt(39000)}`,
     value:9000, threshold:0, fires:!MX_CONTROLS['CTL-15'].pass, src:'15_Project_Risk'},
   'AG-24-08':{rule:'input tax claimed on codes classified denied under s17(2)',
     inputs:`denied base ${fmt(MX_DENIED_INPUT)} at 15%`,
     value:Math.round(MX_DENIED_INPUT*0.15), threshold:0, fires:!MX_CONTROLS['CTL-07'].pass, src:'18_Journals_Ledger VAT control'},
   'IA-25-09':{rule:'indigent register household count ≠ billing masterfile count',
     inputs:'register 14 208 · masterfile 14 611', value:0, threshold:0,
     fires:!MX_CONTROLS['CTL-16'].pass, src:'Indigent Register Reconciliation'},
   'MPAC-25-10':{rule:'prior-year irregular expenditure items without a register entry > 0',
     inputs:'irregular items 11 · register entries 7', value:0, threshold:0,
     fires:!MX_CONTROLS['CTL-17'].pass, src:'Governance_Controls'},
   'IA-24-11':{rule:'s72 assessment tabling date − 31 January > 0 days',
     inputs:'tabled 04 Feb · due 31 Jan', value:0, threshold:0,
     fires:!MX_CONTROLS['CTL-18'].pass, src:'Reports & outputs'},
   'AG-25-12':{rule:'VAT control account balance − computed net VAT for the period ≠ 0',
     inputs:`GL 2130 ${fmt(vt.glBal)} · computed ${fmt(vt.periodNet)}`,
     value:Math.round(Math.abs(vt.residual)), threshold:0, fires:!MX_CONTROLS['CTL-19'].pass, src:'18_Journals_Ledger'},
   'AG-25-13':{rule:'mandatory evidence items absent or expired across the vault > 0',
     inputs:`${vl.filter(v=>!v.complete).length} of ${vl.length} files incomplete`,
     value:vl.reduce((s,v)=>s+v.missingMand.length,0), threshold:0,
     fires:!MX_CONTROLS['CTL-20'].pass, src:'Tender evidence vault'},
  };
}

/* =========================================================================
   DISASTER EVENT — severe storm and flooding
   A single unforeseen event, costed by work stream, funded 60% by the
   municipality and 40% externally. Posted as three balanced entries so the
   ledger, the bank and the funding split are all visible separately.
   ========================================================================= */
const MX_DISASTER = {
  name:'Severe storm and flooding — declared local disaster',
  declared:'2025-03-11', gazette:'Provincial Gazette 4417 of 18 March 2025',
  streams:[
    {s:'Emergency response, make-safe and evacuation', cap:0,     opex:12400, code:'5190'},
    {s:'Water reticulation and reservoir repair',       cap:16800, opex: 10000, code:'5180'},
    {s:'Electricity network restoration',               cap:13200, opex: 6000,  code:'5180'},
    {s:'Roads, bridges and stormwater reinstatement',   cap:18000, opex: 3300,  code:'5180'},
    {s:'Community facilities and relief supplies',      cap:0,     opex: 4900,  code:'5190'},
  ],
  funders:[
    {f:'Municipality — own funds and reserves',                     share:0.60, code:null},
    {f:'National disaster response grant (NDMC, via DoRA s16)',      share:0.30, code:'4195'},
    {f:'Business community and donor contributions (GRAP 23)',       share:0.10, code:'4199'},
  ],
};
function mxDisaster(){
  const cap = MX_DISASTER.streams.reduce((s,r)=>s+r.cap,0);
  const rm  = MX_DISASTER.streams.filter(r=>r.code==='5180').reduce((s,r)=>s+r.opex,0);
  const gen = MX_DISASTER.streams.filter(r=>r.code==='5190').reduce((s,r)=>s+r.opex,0);
  const opex = rm+gen, total = cap+opex;
  const funders = MX_DISASTER.funders.map(f=>({...f, amt:total*f.share}));
  const external = funders.filter(f=>f.code).reduce((s,f)=>s+f.amt,0);
  const own = total - external;
  return {cap, rm, gen, opex, total, funders, external, own,
          surplusEffect: external - opex, cashEffect: external - total};
}
function mxRunDisaster(){
  if(!mxCan('disaster')) return showToast(`${MX_ROLES[mxState.role].label} cannot post the disaster response`);
  if(state.mode===1 && !hasManualData()) return showToast('Load the ledger before posting the disaster response');
  const d = mxDisaster();
  mxPost('Disaster response', 'Storm and flood restoration — costs incurred across five work streams',
    MX_DISASTER.gazette,
    [{code:'1000', dr:d.cap}, {code:'5180', dr:d.rm}, {code:'5190', dr:d.gen}, {code:'2100', cr:d.total}]);
  mxPost('Disaster response', 'External disaster funding received — national grant and donor contributions',
    'NDMC allocation letter DRG/2025/118 + donor schedule DON-2025-04',
    [{code:'1130', dr:d.external},
     ...d.funders.filter(f=>f.code).map(f=>({code:f.code, cr:f.amt}))]);
  mxPost('Disaster response', 'Settlement of disaster restoration creditors',
    'Payment batch PAY-2025-0611',
    [{code:'2100', dr:d.total}, {code:'1130', cr:d.total}]);
  mxState.posted.disaster = true;
  MX_UNPLANNED_EXTRA.amount = d.opex;
  MX_CONTROLS['CTL-21'].pass = false;      /* SCM reg 36 deviation reporting */
  applyModeData(); renderAll();
  triggerCascade('Disaster response run',
    `R${fmt(d.total)}k of storm and flood restoration was costed across five work streams: R${fmt(d.cap)}k capitalised to infrastructure and R${fmt(d.opex)}k charged to repairs and general expenses. R${fmt(d.external)}k came in from the national disaster response grant and business-community donors, leaving R${fmt(d.own)}k — sixty per cent — carried by the municipality itself. Cash fell by R${fmt(Math.abs(d.cashEffect))}k, the surplus by R${fmt(Math.abs(d.surplusEffect))}k. Unplanned maintenance now runs at ${mxPctTxt(mxMaint().share)} of the repairs budget, emergency procurement under SCM Regulation 36 has raised a new deviation finding, and every ratio, scenario lever, control and CFO indicator that touches cash, infrastructure or maintenance has recalculated.`,
    { mainNav:['exec','sysint','workbook','strategy','scenario','reports','governance','projrisk','contracts','audit','revexp','journals'],
      siPackages:['pastelacc','pastelpay','sage300','caseware','afs','push'],
      siSubtabs:{ pastelacc:['dashboard','suppliers','cashbook','customers'], sage300:['batches','trialbalance','inquiry','gl'],
                  caseware:['workingtb','adjusting','trialbalance','finstatements','diagnostics'] },
      wbSheets:['1_Financials','2_Budget_Data','3_Ratios','4_MFD-MM_Master','5_Credit_Worthiness','6_ALM',
                '7_Scenario_Planning','9_InYear_Projection','14_CFO_Dashboard','15_Project_Risk','16_Contract_Monitor',
                '17_Audit_Findings','18_Revenue_Expenditure','19_Journals_Ledger'] });
}
const MX_UNPLANNED_EXTRA = {amount:0};


/* =================== chart helper =================== */
function mxChart(el, cols, legendMax){
  const max = legendMax || Math.max(1, ...cols.flatMap(c=>c.bars.map(b=>b.v)));
  el.innerHTML = cols.map(c=>`<div class="col">
      <div class="bars">${c.bars.map(b=>`<div class="bar ${b.cls}" style="height:${Math.max(2,(b.v/max)*138)}px" title="${b.t}: ${fmt(b.v)}"></div>`).join('')}</div>
      <div class="xl">${c.label}</div></div>`).join('');
}

/* =================== 1. PROJECT RISK =================== */
function renderProjectRisk(){
  const c = mxCut(), pf = mxPortfolio(), t = totals();
  document.getElementById('prAsAt').textContent = c<0 ? 'Programme had not commenced' : `Certified position as at ${mxAsAt()} · ${MX_PERIODS[c]}`;
  document.getElementById('prCfTag').textContent = c<0 ? '—' : `${pf.live.length} live projects · R'000`;
  document.getElementById('prVarTag').textContent = c<0 ? '—' : `Thresholds from the Assumptions Library`;

  /* --- Mode 1: the input grid, mirroring the host's manual-input pattern --- */
  const m1 = document.getElementById('prMode1Input');
  if(state.mode===1){
    m1.innerHTML = `<div class="card"><div class="hd"><h3>Mode 1 — load the actual capital programme</h3>
        <span class="desc">Approved rollout is read from the SDBIP; actuals come from the ledger or are entered here</span></div>
      <div class="bd" style="padding:0;"><table class="grid"><thead><tr><th>Project</th><th class="num">Approved</th>
        <th class="num">Paid to date</th><th class="num">Certified to date</th><th class="num">Funding received</th><th>Target cell</th></tr></thead>
        <tbody>${MX_PROJECTS.map(p=>`<tr><td><b>${p.code}</b> ${p.name}</td><td class="num">${fmt(p.approved)}</td>
          <td class="num"><input class="mx-in" data-mxin="out" data-code="${p.code}" value="${MX_POSTED[p.code].out||''}" placeholder="0"/></td>
          <td class="num"><input class="mx-in" data-mxin="cert" data-code="${p.code}" value="${MX_POSTED[p.code].cert||''}" placeholder="0"/></td>
          <td class="num"><input class="mx-in" data-mxin="in" data-code="${p.code}" value="${MX_POSTED[p.code].in||''}" placeholder="0"/></td>
          <td><code>15_Project_Risk!${p.code}</code></td></tr>`).join('')}</tbody></table></div>
      <div class="bd" style="border-top:1px solid var(--line);"><div class="mx-act">
        <button class="btn-ghost" id="prLoadDemo">Load the illustrative programme</button>
        <span class="mx-role">Entries recalculate the variance, the forecast outturn and every dependent tab as you type.</span>
      </div></div></div>`;
    m1.querySelectorAll('[data-mxin]').forEach(inp=>inp.addEventListener('input', ()=>{
      MX_POSTED[inp.dataset.code][inp.dataset.mxin] = Number(inp.value)||0;
      renderProjectRisk(); renderAudit();
    }));
    const ld = document.getElementById('prLoadDemo');
    if(ld) ld.addEventListener('click', ()=>{
      MX_PROJECTS.forEach(p=>{ MX_POSTED[p.code].out=mxSum(p.actOut,3); MX_POSTED[p.code].cert=mxSum(p.cert,3); MX_POSTED[p.code].in=mxSum(p.actIn,3); });
      renderProjectRisk(); renderAudit(); showToast('Illustrative capital programme loaded into Mode 1');
    });
  } else m1.innerHTML='';

  if(c<0){
    document.getElementById('prKpis').innerHTML = kpiSet([['Capital programme','Not commenced','',PERIODS[state.period]+' precedes the first approved project']]);
    document.querySelector('#prTable tbody').innerHTML = `<tr><td colspan="10"><div class="mx-empty"><b>Nothing to measure yet</b>The first project in this programme, CP-01, starts in FY2024 H1. Move the period selector to FY2024 or FY2025.</div></td></tr>`;
    document.getElementById('prChart').innerHTML=''; document.getElementById('prMilestones').innerHTML='';
    document.querySelector('#prVarTable tbody').innerHTML=''; document.querySelector('#prCascade tbody').innerHTML='';
    document.getElementById('prCfNote').innerHTML=''; return;
  }

  /* --- KPIs --- */
  document.getElementById('prKpis').innerHTML = kpiSet([
    ['Approved programme value', 'R'+fmt(pf.approved)+'k', '', `${pf.live.length} of ${MX_PROJECTS.length} projects live at ${mxAsAt()}`],
    ['Certified complete', mxPctTxt(pf.pctDone), pf.pctDone<0.5?'warn':'', `R${fmt(pf.certTD)}k of certified work`],
    ['Cash-flow variance', 'R'+mxMoney(pf.payVar)+'k', pf.payVar<0?'bad':'good', `${mxPctTxt(Math.abs(pf.payVar/(pf.planOutTD||1)))} ${pf.payVar<0?'behind':'ahead of'} the approved rollout`],
    ['Forecast outturn', 'R'+fmt(pf.eac)+'k', pf.overrun>0?'bad':'good', `${pf.overrun>0?'Overrun':'Saving'} of R${fmt(Math.abs(pf.overrun))}k · ${mxPctTxt(Math.abs(pf.overrun/pf.approved))}`],
  ]);

  /* --- project table --- */
  document.querySelector('#prTable tbody').innerHTML = pf.rows.map(({p,s})=>{
    if(!s.live) return `<tr class="mx-locked"><td><b>${p.code}</b> ${p.name}</td><td>${p.reg}</td><td class="num">${fmt(p.approved)}</td>
      <td colspan="6" style="color:var(--muted)">Starts ${MX_PERIODS[p.startP]}</td><td><span class="pill INCOMPLETE">Not started</span></td></tr>`;
    const rag = s.overrun/p.approved>=0.10 || Math.abs(s.payVar/(s.planOutTD||1))>=0.20 ? 'RED'
              : s.overrun/p.approved>=0.03 || Math.abs(s.payVar/(s.planOutTD||1))>=0.10 ? 'AMBER' : 'GREEN';
    const bar = rag==='RED'?'r':rag==='AMBER'?'a':'g';
    return `<tr><td><b>${p.code}</b> ${p.name}<span class="mx-src">${p.fn} · contract ${p.contract} · ${p.esc}</span></td>
      <td>${p.reg}</td><td class="num">${fmt(p.approved)}</td><td class="num">${fmt(s.planOutTD)}</td>
      <td class="num">${fmt(s.actOutTD)}</td><td class="num">${fmt(s.certTD)}</td>
      <td><div class="mx-prog"><i class="${bar}" style="width:${Math.min(100,s.pctDone*100)}%"></i></div><span class="mx-mini">${mxPctTxt(s.pctDone)} certified</span></td>
      <td class="num" style="color:${s.costVar<0?'var(--red)':'var(--green)'}">${mxMoney(s.costVar)}</td>
      <td class="num">${fmt(s.eac)}<span class="mx-mini">${s.overrun>0?'+':''}${fmt(s.overrun)}</span></td>
      <td><span class="pill ${rag}">${rag==='GREEN'?'On track':rag==='AMBER'?'Watch':'Intervene'}</span>
        ${s.slip>0.25?`<span class="mx-mini">${(s.slip*6).toFixed(0)} months late</span>`:''}</td></tr>`;
  }).join('') + `<tr class="total"><td>Portfolio</td><td>${pf.live.length} live</td><td class="num">${fmt(pf.approved)}</td>
    <td class="num">${fmt(pf.planOutTD)}</td><td class="num">${fmt(pf.actOutTD)}</td><td class="num">${fmt(pf.certTD)}</td>
    <td>${mxPctTxt(pf.pctDone)}</td><td class="num">${mxMoney(pf.costVar)}</td><td class="num">${fmt(pf.eac)}</td>
    <td><span class="pill ${pf.overrun/pf.approved>=0.05?'RED':pf.overrun>0?'AMBER':'GREEN'}">${mxPctTxt(pf.overrun/pf.approved)}</span></td></tr>`;

  /* --- cash-flow rollout chart --- */
  const scale = pf.eac/pf.approved;
  mxChart(document.getElementById('prChart'), MX_PERIODS.map((lbl,i)=>{
    const plan = MX_PROJECTS.reduce((t,p)=>t+p.planOut[i],0);
    const past = i<=c;
    const act  = past ? MX_PROJECTS.reduce((t,p)=>t+p.actOut[i],0) + (i===c? Object.values(MX_POSTED).reduce((t,v)=>t+v.out,0) : 0) : plan*scale;
    const inn  = past ? MX_PROJECTS.reduce((t,p)=>t+p.actIn[i],0) + (i===c? Object.values(MX_POSTED).reduce((t,v)=>t+v.in,0) : 0) : MX_PROJECTS.reduce((t,p)=>t+p.planIn[i],0);
    return {label:lbl.replace('FY','').replace(' ',''), bars:[
      {v:plan, cls:'plan', t:'Approved out'},
      {v:act,  cls:past?'act':'fc', t:past?'Actual out':'Extrapolated out'},
      {v:inn,  cls:'inflow', t:past?'Actual in':'Planned in'}]};
  }));
  document.getElementById('prCfNote').innerHTML =
    `<div class="mx-note ${pf.fundVar<0?'warn':''}" style="margin-top:12px">Money in against money out to ${mxAsAt()}:
     R${fmt(pf.actInTD)}k drawn from grants and loans against R${fmt(pf.actOutTD)}k paid to contractors.
     ${pf.ownFunds>0
        ? `The municipality has carried R${fmt(pf.ownFunds)}k of the programme from its own cash — that is the working-capital call the Scenario lab and the Long-term model pick up.`
        : `R${fmt(Math.abs(pf.ownFunds))}k has been drawn ahead of expenditure, which is idle borrowed or conditional money sitting in the bank.`}
     Periods beyond ${MX_PERIODS[c]} are extrapolated at the portfolio cost-performance index of ${pf.cpi.toFixed(4)}.</div>`;

  /* --- financial milestones --- */
  document.getElementById('prMilestones').innerHTML = pf.live.map(({p,s})=>{
    const stops = [0.10,0.25,0.50,0.75,1.00];
    let run=0; const cum = p.planOut.map(v=>run+=v);
    const chips = stops.map(k=>{
      const target = p.approved*k;
      let dueP = cum.findIndex(v=>v>=target); if(dueP<0) dueP = p.endP;
      const done = s.certTD >= target;
      const cls = done ? 'done' : dueP<=c ? 'late' : dueP===c+1 ? 'due' : '';
      return `<div class="m ${cls}"><b>${(k*100).toFixed(0)}% · R${fmt(target)}k</b>
        <span>${done?'Certified':cls==='late'?'Overdue since '+MX_PERIODS[dueP]:cls==='due'?'Due '+MX_PERIODS[dueP]:MX_PERIODS[dueP]}</span></div>`;
    }).join('');
    return `<div style="margin-bottom:14px"><div style="font-size:11.5px;font-weight:800;color:var(--navy-dark);margin-bottom:3px">${p.code} — ${p.name}</div>
      <div class="mx-ms">${chips}</div></div>`;
  }).join('') || '<div class="mx-empty">No live projects in this period.</div>';

  /* --- variance analysis --- */
  const borrow = LINES.find(l=>l.code==='2000').fy[idx()] + LINES.find(l=>l.code==='2110').fy[idx()];
  const rows = [
    ['Cash-flow (schedule) variance','Paid to date less the approved rollout to date', pf.payVar, pf.planOutTD*0.10, Math.abs(pf.payVar)<=pf.planOutTD*0.10],
    ['Cost variance','Certified value less amounts paid for it', pf.costVar, pf.actOutTD*0.02, Math.abs(pf.costVar)<=pf.actOutTD*0.02],
    ['Funding variance','Grants and loans received less expenditure incurred', pf.fundVar, pf.actOutTD*0.05, Math.abs(pf.fundVar)<=pf.actOutTD*0.05],
    ['Forecast outturn variance','Approved value ÷ cost-performance index, less approved value', pf.overrun, pf.approved*0.05, pf.overrun<=pf.approved*0.05],
    ['Programme slip','Elapsed periods ÷ proportion certified, weighted by value', pf.slipMonths, 3, pf.slipMonths<=3],
  ];
  document.querySelector('#prVarTable tbody').innerHTML = rows.map(([n,d,v,th,ok],i)=>
    `<tr><td><b>${n}</b></td><td style="color:var(--muted)">${d}</td>
     <td class="num">${i===4? v.toFixed(1)+' months' : mxMoney(v)}</td>
     <td class="num">${i===4? '3.0 months' : '±'+fmt(th)}</td>
     <td><span class="pill ${ok?'GREEN':'RED'}">${ok?'Within tolerance':'Breach'}</span></td></tr>`).join('')
   + `<tr class="total"><td>Cost-performance index</td><td style="font-weight:400;color:var(--muted)">Certified value ÷ cost incurred. Below 1.00 means work is costing more than it is worth.</td>
      <td class="num">${pf.cpi.toFixed(4)}</td><td class="num">≥ 1.0000</td>
      <td><span class="pill ${pf.cpi>=1?'GREEN':pf.cpi>=0.95?'AMBER':'RED'}">${pf.cpi>=1?'Efficient':'Eroding'}</span></td></tr>`;

  /* --- forward-looking cascade --- */
  const fin = pf.overrun*MX_ASSUM.loanRate, dep = pf.overrun/MX_ASSUM.usefulLife;
  const bNow = borrow/t.revenue, bNew = (borrow+pf.overrun)/t.revenue;
  document.querySelector('#prCascade tbody').innerHTML = [
    ['Scenario lab — project and infrastructure delay lever','Weighted slip across the live portfolio sets the lever',`${pf.slipMonths.toFixed(1)} months`,'scenario'],
    ['Long-term model — borrowing','Forecast overrun funded from the DBSA facility',`+${fmt(pf.overrun)}`,'scenario'],
    ['Statement of financial performance — finance costs',`Overrun at ${mxPctTxt(MX_ASSUM.loanRate,2)}`,`+${fmt(fin)} p.a.`,'strategy'],
    ['Statement of financial performance — depreciation',`Overrun over a ${MX_ASSUM.usefulLife}-year useful life`,`+${fmt(dep)} p.a.`,'strategy'],
    ['Credit worthiness — capital financing','Borrowing ÷ total revenue, MFMA Circular 71 norm ≤45%',`${mxPctTxt(bNow)} → ${mxPctTxt(bNew)}`,'strategy'],
    ['MFD-MM Master — Rates-to-Resilience cascade','Delivery rate feeds the asset-renewal leg',`${mxPctTxt(pf.pctDone)} certified`,'exec'],
    ['Reports and outputs — s71 and s52(d)','In-year capital report reflects the rollout variance',`${mxPctTxt(pf.payVar/(pf.planOutTD||1))}`,'reports'],
    ['Governance and controls — CTL-11, CTL-15','Capitalisation completeness and drawdown-against-spend tests',`${[MX_CONTROLS['CTL-11'].pass,MX_CONTROLS['CTL-15'].pass].filter(x=>!x).length} failing`,'governance'],
  ].map(([d,m,v,go])=>`<tr><td><b>${d}</b></td><td style="color:var(--muted)">${m}</td><td class="num">${v}</td>
     <td><button class="btn-ghost mx-go" data-go="${go}" style="padding:4px 9px;font-size:10.5px">Open</button></td></tr>`).join('');

  document.getElementById('prRoleNote').textContent = mxCan('claims')
    ? `Posting as ${MX_ROLES[mxState.role].label}.`
    : `${MX_ROLES[mxState.role].label} has read-only access to capital postings.`;
  mxGate(document.getElementById('prPostBtn'), {key:'claims', perm:'claims', label:'Post certified progress claims'}, 'Progress claims posted');
  mxGate(document.getElementById('prForecastBtn'), {key:'x', perm:'forecast', label:'Roll forward the forecast'}, '');
  mxBindGo();
}

/* =================== 2. CONTRACT MONITOR =================== */
function renderContracts(){
  const c = mxCut(), ct = mxContractTotals(), t = totals();
  document.getElementById('ctAsAt').textContent = c<0 ? 'No contracts in force' : `Reconciled to the creditors ledger at ${mxAsAt()}`;
  document.getElementById('ctRunTag').textContent = `CPI assumption ${mxPctTxt(MX_ASSUM.cpi,1)} p.a.`;
  document.getElementById('ctVarTag').textContent = `MFMA s116(3) variation limit ${mxPctTxt(MX_ASSUM.varyLimit,0)} · s65(2)(e) ${MX_ASSUM.payDays} days`;

  const m1 = document.getElementById('ctMode1Input');
  m1.innerHTML = (state.mode===1 && !ct.cert) ? `<div class="card"><div class="bd"><div class="mx-empty">
      <b>Load the contract register</b>Mode 1 reads the SCM contract register and the creditors ledger from the accounting suite.
      Use the System integration tab to upload the trial balance, or load the illustrative programme on the Project risk tab and the linked contracts follow.
    </div></div></div>` : '';

  if(c<0){
    document.getElementById('ctKpis').innerHTML = kpiSet([['Contract register','No contracts','','Nothing was in force in '+PERIODS[state.period]]]);
    document.querySelector('#ctTable tbody').innerHTML = `<tr><td colspan="10"><div class="mx-empty"><b>Register is empty for this period</b>Move the period selector to FY2024 or FY2025.</div></td></tr>`;
    ['ctChart'].forEach(id=>document.getElementById(id).innerHTML='');
    ['ctAgeing','ctVarTable','ctCascade'].forEach(id=>document.querySelector('#'+id+' tbody').innerHTML='');
    document.getElementById('ctRunNote').innerHTML=''; document.getElementById('ctAgeNote').innerHTML=''; return;
  }

  const credDays = t.expenditure ? (LINES.find(l=>l.code==='2100').fy[idx()]) / (t.expenditure/365) : 0;
  document.getElementById('ctKpis').innerHTML = kpiSet([
    ['Awarded contract value','R'+fmt(ct.award)+'k','',`${ct.rows.filter(r=>r.k.award).length} contracts under formal award`],
    ['Committed against award','R'+fmt(ct.committed)+'k', ct.committed>ct.award?'bad':'', ct.committed>ct.award?`Over-committed by R${fmt(ct.committed-ct.award)}k`:'Within awarded values'],
    ['Certified but unpaid','R'+fmt(ct.due+ct.ret)+'k','',`R${fmt(ct.ret)}k retention · R${fmt(ct.due)}k due`],
    ['Beyond 30 days','R'+fmt(ct.late)+'k', ct.late>0?'bad':'good', ct.late>0?`${ct.lateCount} contracts breach s65(2)(e)`:'All settled within 30 days'],
  ]);

  /* --- register --- */
  const f = mxState.ctFilter;
  const shown = ct.rows.filter(({k,s})=> f==='all' ? true : f==='exceptions'
      ? (s.days>MX_ASSUM.payDays || s.overCommit>0) : k.type===f);
  document.querySelector('#ctTable tbody').innerHTML = shown.map(({k,s})=>{
    const late = s.days>MX_ASSUM.payDays, over = s.overCommit>0;
    const rag = over || s.varyPct>=MX_ASSUM.varyLimit ? 'RED' : late ? 'AMBER' : 'GREEN';
    return `<tr><td><b>${k.no}</b><span class="mx-src">${k.esc}${k.proj?' · '+k.proj:''}</span></td>
      <td>${k.sup}</td>
      <td class="num">${k.award?fmt(k.award):'<span style="color:var(--muted)">Tariff</span>'}</td>
      <td class="num">${s.committed?fmt(s.committed):'—'}${over?`<span class="mx-mini" style="color:var(--red)">+${fmt(s.overCommit)} over award</span>`:''}</td>
      <td class="num">${fmt(s.cert)}</td><td class="num">${fmt(s.paid)}</td>
      <td class="num">${s.ret?fmt(s.ret):'—'}</td>
      <td class="num">${s.due?fmt(s.due):'—'}</td>
      <td class="num" style="color:${late?'var(--red)':'inherit'}">${s.days||'—'}</td>
      <td><span class="pill ${rag}">${over?'Variation':late?'Late payment':'Compliant'}</span></td></tr>`;
  }).join('') || `<tr><td colspan="10"><div class="mx-empty"><b>Nothing matches this filter</b>No contract in the register meets the selected condition at ${mxAsAt()}.</div></td></tr>`;

  /* --- commitment run-off --- */
  const top = ct.rows.filter(r=>r.k.award).slice(0,7);
  mxChart(document.getElementById('ctChart'), top.map(({k,s})=>({label:k.no.slice(-6),
    bars:[{v:k.award,cls:'plan',t:'Awarded'},{v:s.cert,cls:'act',t:'Certified'},{v:s.forecast,cls:'fc',t:'Forecast final'}]})));
  document.getElementById('ctRunNote').innerHTML =
    `<div class="mx-note ${ct.forecast>ct.award?'warn':'ok'}" style="margin-top:12px">Forecast final spend across the register is
     R${fmt(ct.forecast)}k against R${fmt(ct.award)}k awarded — ${ct.forecast>ct.award?`R${fmt(ct.forecast-ct.award)}k above`:'within'} the awarded values.
     Escalation is applied only to the uncertified remainder of CPI-linked contracts at ${mxPctTxt(MX_ASSUM.cpi,1)} for the remaining term, so a fixed-price
     contract forecasts at its committed value and nothing more. The Eskom bulk-supply agreement is in the register but out of these totals:
     it is a NERSA tariff determination, not an awarded value, so there is nothing to measure a variation against.</div>`;

  /* --- ageing --- */
  const bands = [['Current — 30 days or less',0,30],['31 to 60 days',31,60],['61 to 90 days',61,90],['More than 90 days',91,9999]];
  document.querySelector('#ctAgeing tbody').innerHTML = bands.map(([lbl,lo,hi])=>{
    const inb = ct.rows.filter(({s})=>s.due>0 && s.days>=lo && s.days<=hi);
    const bal = inb.reduce((x,{s})=>x+s.due,0);
    const ok = hi<=30;
    return `<tr><td>${lbl}</td><td class="num">${fmt(bal)}</td><td class="num">${inb.length}</td>
      <td><span class="pill ${bal===0?'INCOMPLETE':ok?'GREEN':'RED'}">${bal===0?'Nil':ok?'Compliant':'Breach'}</span></td></tr>`;
  }).join('') + `<tr class="total"><td>Total certified and unpaid, excluding retention</td><td class="num">${fmt(ct.due)}</td><td class="num">${ct.rows.filter(r=>r.s.due>0).length}</td>
    <td><span class="pill ${ct.late>0?'RED':'GREEN'}">${ct.late>0?fmt(ct.late)+' overdue':'Clean'}</span></td></tr>`;
  document.getElementById('ctAgeNote').innerHTML =
    `<p class="footnote" style="margin:0">Creditor payment days across the whole ledger are <b>${credDays.toFixed(1)}</b>, measured as trade and other payables
     divided by daily expenditure. The 30-day test above runs only on <i>certified</i> balances with retention stripped out, because retention is contractually
     withheld and is not a late payment. ${ct.late>0?`The R${fmt(ct.late)}k that fails the test is what feeds finding AG-25-03 and control CTL-13.`:'Nothing currently fails the test, so CTL-13 passes and AG-25-03 is closed.'}</p>`;

  /* --- variance --- */
  const overCommitTotal = ct.rows.reduce((x,{s})=>x+Math.max(0,s.overCommit),0);
  const worstVary = Math.max(0, ...ct.rows.map(r=>r.s.varyPct));
  const uncertified = ct.committed - ct.cert;
  document.querySelector('#ctVarTable tbody').innerHTML = [
    ['Commitment against award','Orders raised less the value formally awarded', overCommitTotal, 0, overCommitTotal===0, 'R'],
    ['Largest single variation','Forecast final spend on one contract against its award', worstVary, MX_ASSUM.varyLimit, worstVary<MX_ASSUM.varyLimit, '%'],
    ['Certified against committed','Work certified as a share of what is committed', mxPct(ct.cert,ct.committed), 0, true, '%info'],
    ['Balance of commitment','Committed but not yet certified — the call on future budgets', uncertified, 0, true, 'Rinfo'],
    ['Payment beyond 30 days','Certified, non-retention balances older than 30 days', ct.late, 0, ct.late===0, 'R'],
  ].map(([n,d,v,th,ok,kind])=>{
    const val = kind.startsWith('%') ? mxPctTxt(v) : mxMoney(v);
    const thr = kind==='%' ? mxPctTxt(th,0) : kind==='R' ? 'Nil' : '—';
    const res = kind.endsWith('info') ? `<span class="pill NAVY">Information</span>` : `<span class="pill ${ok?'GREEN':'RED'}">${ok?'Within tolerance':'Breach'}</span>`;
    return `<tr><td><b>${n}</b></td><td style="color:var(--muted)">${d}</td><td class="num">${val}</td><td class="num">${thr}</td><td>${res}</td></tr>`;
  }).join('');

  /* --- cascade --- */
  const payables = LINES.find(l=>l.code==='2100').fy[idx()];
  document.querySelector('#ctCascade tbody').innerHTML = [
    ['Statement of financial position — trade and other payables','Certified less paid less retention',`R${fmt(ct.due)}k of R${fmt(payables)}k`,'workbook'],
    ['Ratios — creditor payment days','Payables ÷ daily expenditure',`${credDays.toFixed(1)} days`,'strategy'],
    ['Cash flow — liquidity','Releasing the overdue balance reduces cash',`−${fmt(ct.late)}`,'scenario'],
    ['Governance and controls — CTL-12 and CTL-13','Over-commitment and ageing tests',`${[MX_CONTROLS['CTL-12'].pass,MX_CONTROLS['CTL-13'].pass].filter(x=>!x).length} failing`,'governance'],
    ['Audit findings — AG-25-02 and AG-25-03','Raised automatically from the two failing tests',`R${fmt(overCommitTotal+ct.late)}k`,'audit'],
    ['Reports and outputs — s116(3) contract report','Variations above the delegation are reportable to council',`${ct.rows.filter(r=>r.s.overCommit>0).length} item(s)`,'reports'],
    ['Bankability engine — uncommitted fiscal capacity','Committed but uncertified reduces headroom',`−${fmt(uncertified)}`,'strategy'],
    ['Project risk — contract to project link','Certified value is the progress measure used by the projects',`${ct.rows.filter(r=>r.k.proj).length} linked`,'projrisk'],
  ].map(([d,m,v,go])=>`<tr><td><b>${d}</b></td><td style="color:var(--muted)">${m}</td><td class="num">${v}</td>
     <td><button class="btn-ghost mx-go" data-go="${go}" style="padding:4px 9px;font-size:10.5px">Open</button></td></tr>`).join('');

  document.getElementById('ctRoleNote').textContent = mxCan('payrun')
    ? `Posting as ${MX_ROLES[mxState.role].label}.`
    : `${MX_ROLES[mxState.role].label} cannot release payments or log variations.`;
  mxGate(document.getElementById('ctPayBtn'), {key:'payrun', perm:'payrun', label:'Release the 30-day payment run'}, 'Payment run released');
  mxGate(document.getElementById('ctVarBtn'), {key:'variation', perm:'variation', label:'Log the contract variation for approval'}, 'Variation logged for council');
  mxBindGo();
}

/* =================== 3. AUDIT FINDINGS =================== */
function renderAudit(){
  const lens = mxLens(), fs = mxFindings(), op = mxOpinion(), t = totals();
  document.getElementById('auOpTag').textContent = `${MX_LENS_LABEL[lens]} · materiality R${fmt(op.mat)}k`;
  document.getElementById('auAsAt').textContent = `${fs.filter(f=>f.status!=='Resolved').length} open of ${fs.length} in scope`;
  document.getElementById('auMatTag').textContent = `${mxPctTxt(MX_ASSUM.materialityPct,0)} of expenditure`;
  document.querySelectorAll('#auLensTabs button').forEach(b=>b.classList.toggle('active', b.dataset.lens===mxState.auLens));

  document.getElementById('auLensNote').innerHTML = {
    realtime:`<div class="mx-note">Real-time monitoring runs the eleven automated control tests continuously and raises a finding the moment one fails. Only findings with a currently failing test appear — nothing carried forward on judgement, nothing waiting for an audit visit.</div>`,
    interim:`<div class="mx-note">The interim position is the half-year picture: the AG's interim management report and the internal audit plan to 31 December, measured against half-year materiality. Year-end-only matters are out of scope.</div>`,
    yearend:`<div class="mx-note">The year-end view is the full register the AG will report on, tested against annual materiality. The projected opinion below is arithmetic, not a forecast of the auditor's mood.</div>`}[lens];

  if(state.mode===1 && !hasManualData()){
    document.getElementById('auKpis').innerHTML = kpiSet([['Findings register','Awaiting data','','Materiality cannot be computed until expenditure is loaded']]);
    document.querySelector('#auTable tbody').innerHTML = `<tr><td colspan="8"><div class="mx-empty"><b>Load the ledger first</b>
      Materiality is one per cent of total expenditure, so the register cannot be scored until the ledger is in.
      Load the trial balance on the System integration tab. The capital programme on its own is not enough — it tells you what
      was spent, not what the expenditure base is.</div></td></tr>`;
    document.querySelector('#auOpinion tbody').innerHTML=''; document.querySelector('#auRepeat tbody').innerHTML='';
    document.querySelector('#auCascade tbody').innerHTML=''; document.getElementById('auChart').innerHTML='';
    document.getElementById('auRemedy').innerHTML=''; document.getElementById('auChartNote').innerHTML=''; return;
  }

  document.getElementById('auKpis').innerHTML = kpiSet([
    ['Projected audit outcome', op.op, op.cls==='RED'?'bad':op.cls==='AMBER'?'warn':'good', `Unresolved misstatement R${fmt(op.mis)}k against materiality R${fmt(op.mat)}k`],
    ['Open findings', String(fs.filter(f=>f.status==='Open').length), fs.filter(f=>f.status==='Open').length?'warn':'good', `${fs.filter(f=>f.status==='In progress').length} in progress · ${fs.filter(f=>f.status==='Resolved').length} resolved`],
    ['Repeat findings', String(fs.filter(f=>f.years>1 && f.status!=='Resolved').length), 'bad', 'A repeat finding is the one the AG reports on hardest'],
    ['Controls passing', `${Object.values(MX_CONTROLS).filter(c=>c.pass).length} of ${Object.keys(MX_CONTROLS).length}`, Object.values(MX_CONTROLS).filter(c=>c.pass).length>=8?'good':'warn', 'A finding closes when its control passes'],
  ]);

  /* --- opinion working --- */
  document.querySelector('#auOpinion tbody').innerHTML = `
    <tr><td><b>Materiality</b></td><td>${mxPctTxt(MX_ASSUM.materialityPct,0)} of total expenditure of R${fmt(t.expenditure)}k</td><td class="num">${fmt(op.mat)}</td><td><span class="pill NAVY">Benchmark</span></td></tr>
    <tr><td><b>Unresolved material misstatement</b></td><td>Open and in-progress findings classified as misstatement</td><td class="num">${fmt(op.mis)}</td>
      <td><span class="pill ${op.mis>op.mat?'RED':'GREEN'}">${op.mis>op.mat?'Above materiality':'Below materiality'}</span></td></tr>
    <tr><td><b>Unresolved compliance findings</b></td><td>Findings against the MFMA, the MSA or the VAT Act</td><td class="num">${op.comp}</td>
      <td><span class="pill ${op.comp?'AMBER':'GREEN'}">${op.comp?'Reportable':'None'}</span></td></tr>
    <tr><td><b>Unresolved internal control deficiencies</b></td><td>Raised by internal audit or by a failing automated test</td><td class="num">${op.ctrl}</td>
      <td><span class="pill ${op.ctrl?'AMBER':'GREEN'}">${op.ctrl?'Reportable':'None'}</span></td></tr>
    <tr class="total"><td>Projected outcome</td><td>Misstatement above materiality qualifies the opinion; anything else unresolved gives findings</td>
      <td class="num">${fmt(op.mis - op.mat)}</td><td><span class="pill ${op.cls}">${op.op}</span></td></tr>`;

  /* --- register --- */
  const list = mxState.auShowResolved ? fs : fs.filter(f=>f.status!=='Resolved');
  document.querySelector('#auTable tbody').innerHTML = list.map(f=>{
    const rag = f.status==='Resolved'?'GREEN':f.status==='In progress'?'AMBER':'RED';
    return `<tr class="click" data-find="${f.ref}"><td><b>${f.ref}</b><span class="mx-src">${f.src}</span></td>
      <td>${f.title}<span class="mx-src">Source: ${f.from}</span></td>
      <td>${f.cls}</td><td class="num">${f.amt?fmt(f.amt):'<span style="color:var(--muted)">Not quantified</span>'}</td>
      <td><code>${f.ctl}</code><span class="mx-src">${f.ctlPass?'Test passing':'Test failing'}</span></td>
      <td>${f.owner}<span class="mx-src">Raised ${f.raised}</span></td>
      <td class="num">${f.years>1?`<span class="pill RED">${f.years} yrs</span>`:'1 yr'}</td>
      <td><span class="pill ${rag}">${f.status}</span><span class="mx-src">Click to expand</span></td></tr>` + mxFindingDetail(f);
  }).join('') || `<tr><td colspan="8"><div class="mx-empty"><b>Nothing open</b>Every finding in scope for this lens has been closed by a passing control test.</div></td></tr>`;
  document.getElementById('auFilterBtn').textContent = mxState.auShowResolved ? 'Hide resolved' : 'Show resolved';
  mxBindFindingRows();

  /* --- chart --- */
  const byCls = ['Material misstatement','Compliance','Internal control'];
  mxChart(document.getElementById('auChart'), byCls.map(k=>{
    const grp = fs.filter(f=> k==='Compliance' ? f.cls.startsWith('Compliance') : f.cls===k);
    return {label:k==='Material misstatement'?'Misstatement':k==='Compliance'?'Compliance':'Control',
      bars:[{v:grp.filter(f=>f.status!=='Resolved').reduce((s,f)=>s+f.amt,0),cls:'act',t:'Unresolved'},
            {v:grp.filter(f=>f.status==='Resolved').reduce((s,f)=>s+f.amt,0),cls:'plan',t:'Resolved'},
            {v:k==='Material misstatement'?op.mat:0,cls:'fc',t:'Materiality'}]};
  }));
  document.getElementById('auChartNote').innerHTML =
    `<div class="mx-note ${op.mis>op.mat?'bad':'ok'}" style="margin-top:12px">${op.mis>op.mat
      ? `Unresolved misstatement of R${fmt(op.mis)}k sits R${fmt(op.mis-op.mat)}k above the materiality line. Clearing AG-25-01 and AG-25-04 alone would bring it to R${fmt(op.mis-(fs.find(f=>f.ref==='AG-25-01')?.amt||0)-(fs.find(f=>f.ref==='AG-25-04')?.amt||0))}k and move the projected outcome off a qualification.`
      : `Unresolved misstatement of R${fmt(op.mis)}k is below the materiality line of R${fmt(op.mat)}k, so the projected outcome is no longer a qualification. Compliance and control findings still stand and still get reported.`}</div>`;

  /* --- repeat findings --- */
  const rep = fs.filter(f=>f.years>1);
  document.querySelector('#auRepeat tbody').innerHTML = rep.map(f=>
    `<tr><td><b>${f.ref}</b> ${f.title}</td><td class="num">${f.years}</td><td>${f.ctlName}</td>
     <td><span class="pill ${f.ctlPass?'GREEN':'RED'}">${f.ctlPass?'Passing':'Failing'}</span></td></tr>`).join('')
     || `<tr><td colspan="4"><div class="mx-empty">No repeat findings in scope.</div></td></tr>`;

  /* --- remediation --- */
  const a1 = fs.find(f=>f.ref==='AG-25-01'), a4 = fs.find(f=>f.ref==='AG-25-04');
  document.getElementById('auRemedy').innerHTML = `
    <p class="footnote" style="margin:0 0 10px">Two of the three material misstatements close with a journal rather than a memo. Posting them here writes to the same ledger the payroll, billing and batch actions write to, so the correction flows through the trial balance, the AFS workbook, the ratios and the credit model in one pass.</p>
    <table class="grid"><thead><tr><th>Finding</th><th>Correcting entry</th><th class="num">R'000</th><th>Effect on the opinion</th></tr></thead><tbody>
      <tr><td><b>AG-25-01</b> capital work expensed</td><td>Debit property, plant and equipment · credit repairs and maintenance</td>
        <td class="num">${fmt(a1?a1.amt:0)}</td><td>${MX_CONTROLS['CTL-11'].pass?'<span class="pill GREEN">Closed</span>':'Removes it from unresolved misstatement'}</td></tr>
      <tr><td><b>AG-25-04</b> grant recognised early</td><td>Debit conditional grant revenue · credit unspent conditional grants</td>
        <td class="num">${fmt(a4?a4.amt:0)}</td><td>${MX_CONTROLS['CTL-14'].pass?'<span class="pill GREEN">Closed</span>':'Removes it from unresolved misstatement'}</td></tr>
      <tr class="total"><td>Net effect on the surplus</td><td>Expenditure down, revenue down</td>
        <td class="num">${mxMoney((a1?a1.amt:0)-(a4?a4.amt:0))}</td><td>${op.mis>op.mat?'<span class="pill RED">Still qualified until posted</span>':'<span class="pill AMBER">Unqualified with findings</span>'}</td></tr>
    </tbody></table>`;

  /* --- cascade --- */
  document.querySelector('#auCascade tbody').innerHTML = [
    ['Governance and controls — control health','Tests moving from failing to passing',`${Object.values(MX_CONTROLS).filter(c=>c.pass).length} of ${Object.keys(MX_CONTROLS).length}`,'governance'],
    ['Statement of financial position — PPE','Capital work correctly capitalised',`+${fmt(a1?a1.amt:0)}`,'workbook'],
    ['Statement of financial performance — repairs and maintenance','Capital work removed from operating expenditure',`−${fmt(a1?a1.amt:0)}`,'workbook'],
    ['Statement of financial performance — conditional grants','Revenue reversed to the unspent liability',`−${fmt(a4?a4.amt:0)}`,'workbook'],
    ['Strategy and performance — ratios','Surplus margin and asset-renewal ratios recalculate','Recalculated','strategy'],
    ['Credit worthiness — governance score','The audit outcome is a scored rating input', op.op,'strategy'],
    ['Reports and outputs — audit action plan','Closed findings leave the plan',`${fs.filter(f=>f.status==='Resolved').length} closed`,'reports'],
    ['CFO dashboard — assurance indicator','Driven by the projected outcome', op.cls==='RED'?'Red':op.cls==='AMBER'?'Amber':'Green','exec'],
  ].map(([d,m,v,go])=>`<tr><td><b>${d}</b></td><td style="color:var(--muted)">${m}</td><td class="num">${v}</td>
     <td><button class="btn-ghost mx-go" data-go="${go}" style="padding:4px 9px;font-size:10.5px">Open</button></td></tr>`).join('');

  document.getElementById('auRoleNote').textContent = mxCan('adjust')
    ? `Posting as ${MX_ROLES[mxState.role].label}.`
    : mxCan('sweep') ? `${MX_ROLES[mxState.role].label} may run the control sweep but not post adjustments.`
    : `${MX_ROLES[mxState.role].label} has read-only access to the register.`;
  mxGate(document.getElementById('auPostBtn'), {key:'adjust', perm:'adjust', label:'Post the audit adjustments'}, 'Audit adjustments posted');
  mxGate(document.getElementById('auSweepBtn'), {key:'x', perm:'sweep', label:'Run the control sweep'}, '');
  mxBindGo();
}

/* =================== 4. REVENUE AND EXPENDITURE MONITOR =================== */
function renderRevActions(){
  /* The analytical half of the old Revenue & expenditure tab was superseded by the
     Treasury Control Tower (flows, position, ratios) and by the Risk management
     levers. What survives here is the part that had no other home: the five posting
     actions and the cascade they drive. It now sits with Journals, where postings belong. */
  if(!document.getElementById('reRoleNote')) return; // MATOS: this UI lived in the removed Journals panel
  const re = mxRevExp(), t = totals();
  const ra = mxRates(), co = mxCollection(), mt = mxMaint();
  /* --- cascade --- */
  document.querySelector('#reCascade tbody').innerHTML = [
    ['Ratios — operating surplus margin','Actual surplus against revenue',mxPctTxt(re.surplusAct/(re.rev.actual||1)),'strategy'],
    ['Ratios — debt service against revenue','Finance costs plus current portion',mxPctTxt((ra.fin+ra.cp)/(t.revenue||1),2),'strategy'],
    ['Scenario lab — collection rate lever','Measured collection against the 95% target',mxPctTxt(co.rate,1),'scenario'],
    ['Scenario lab — tariff and bulk-cost gap lever','Eskom increase against the approved municipal tariff',mxPctTxt(MX_TARIFF.eskomIncrease-MX_TARIFF.nersaGuideline,2),'scenario'],
    ['Scenario lab — interest rate lever','Floating tranche exposed to re-pricing','R'+fmt(ra.floating)+'k','scenario'],
    ['6_ALM — renewal backlog','Repairs and maintenance against PPE carrying value',mxPctTxt(mt.rmRatio,2),'workbook'],
    ['Credit worthiness — budgetary performance','Actual against adjusted budget on both sides',mxPctTxt(re.expVar/(re.exp.adjusted||1),2),'strategy'],
    ['Reports and outputs — s71 and s72','Monthly and mid-year budget statements draw from this table', state.mode===2?'s72 mid-year':'s71 monthly','reports'],
    ['Audit findings — AG-25-05','Impairment charge against the ageing that supports it','R'+fmt(4100)+'k','audit'],
    ['Journals and ledgers','Every action on this tab posts a balanced entry', MX_JOURNAL.filter(j=>j.src==='Revenue and expenditure').length+' entries','workbook'],
  ].map(([d,m,v,go])=>`<tr><td><b>${d}</b></td><td style="color:var(--muted)">${m}</td><td class="num">${v}</td>
     <td><button class="btn-ghost mx-go" data-go="${go}" style="padding:4px 9px;font-size:10.5px">Open</button></td></tr>`).join('');

  document.getElementById('reRoleNote').textContent = mxCan('revenue')
    ? `Posting as ${MX_ROLES[mxState.role].label}.` : `${MX_ROLES[mxState.role].label} has read-only access here.`;
  mxGate(document.getElementById('reCollectBtn'), {key:'collect', perm:'revenue', label:'Post the collection run receipts'}, 'Receipts posted');
  mxGate(document.getElementById('reWriteOffBtn'), {key:'writeoff', perm:'revenue', label:'Write off irrecoverable rates debt'}, 'Write-off posted');
  mxGate(document.getElementById('reMaintBtn'), {key:'maint', perm:'revenue', label:'Post the month\u2019s unplanned work orders'}, 'Work orders posted');
  mxGate(document.getElementById('reRateBtn'), {key:'reprice', perm:'revenue', label:'Re-price the floating tranche at +100bp'}, 'Tranche re-priced');
  mxGate(document.getElementById('reDisasterBtn'), {key:'disaster', perm:'disaster', label:'Run the disaster response'}, 'Disaster response posted');
  mxBindGo();
}

/* =================== 5. JOURNALS, LEDGERS AND BANK =================== */
function renderJournals(){
  if(!document.getElementById('jrKpis')) return; // MATOS: Journals tab removed — GL/journal detail now lives in the rationalised Integrated Workbook
  const sub = mxState.jrSub;
  document.querySelectorAll('#jrTabs button').forEach(b=>b.classList.toggle('active', b.dataset.jr===sub));
  document.querySelectorAll('.jr-sub').forEach(p=>p.style.display = p.id==='jr-'+sub ? '' : 'none');
  const bk = mxBank(), lg = mxLedger(), vt = mxVatRecon();
  const drTot = MX_JOURNAL.reduce((s,j)=>s+j.dr,0), crTot = MX_JOURNAL.reduce((s,j)=>s+j.cr,0);

  document.getElementById('jrKpis').innerHTML = kpiSet([
    ['Journal entries posted', String(MX_JOURNAL.length), '', `Posting date ${mxDateTxt(mxDate())} · ${modeName()}`],
    ['Debits equal credits', drTot===crTot?'Balanced':'Out by '+fmt(drTot-crTot), drTot===crTot?'good':'bad',
      `Dr R${fmt(drTot)}k · Cr R${fmt(crTot)}k`],
    ['Bank movement', (bk.closing-bk.opening>=0?'+':'')+fmt(bk.closing-bk.opening), bk.closing>=bk.opening?'good':'bad',
      `In R${fmt(bk.inflow)}k · out R${fmt(bk.outflow)}k`],
    ['Accounts touched', String(lg.length), '', 'Each one reconciles to its ledger closing balance'],
  ]);

  /* --- general journal --- */
  document.querySelector('#jrTable tbody').innerHTML = MX_JOURNAL.length ? MX_JOURNAL.map(j=>
    `<tr class="section"><td colspan="6"><b>${j.je}</b> &nbsp; ${mxDateTxt(j.date)} &nbsp; · &nbsp; ${j.src} &nbsp; · &nbsp; ${j.narr}
      <span class="mx-src">Evidence: ${j.evid} &nbsp;·&nbsp; posted in ${modeNameFor(j.mode)} for ${j.period}</span></td></tr>`
    + j.lines.map(l=>`<tr><td style="padding-left:26px"><code>${l.code}</code> ${l.label}</td>
        <td>${CATLABEL2[l.cat]||l.cat}</td>
        <td class="num">${l.dr?fmt(l.dr):''}</td><td class="num">${l.cr?fmt(l.cr):''}</td>
        <td>${l.vat}</td><td style="color:var(--muted);font-size:11px">${l.vatBasis}</td></tr>`).join('')
    + `<tr class="total"><td colspan="2" style="padding-left:26px">Entry total</td><td class="num">${fmt(j.dr)}</td>
        <td class="num">${fmt(j.cr)}</td><td colspan="2"><span class="pill GREEN">Balanced</span></td></tr>`).join('')
    : `<tr><td colspan="6"><div class="mx-empty"><b>No entries yet</b>Every posting button in every module writes here.
        Run the payroll, the billing, the progress claims, the payment run, the audit adjustments or the disaster response and the entry appears
        with its date, its evidence reference and its VAT classification.</div></td></tr>`;

  /* --- general ledger --- */
  document.getElementById('jrLedger').innerHTML = lg.length ? lg.map(a=>
    `<div class="card"><div class="hd"><h3><code>${a.code}</code> ${a.label}</h3>
      <span class="desc">${a.nat} nature · opening R${fmt(a.opening)}k · closing R${fmt(a.closing)}k</span></div>
     <div class="bd" style="padding:0;"><table class="grid">
       <thead><tr><th>Entry</th><th>Date</th><th>Narration</th><th class="num">Debit</th><th class="num">Credit</th><th class="num">Balance</th></tr></thead>
       <tbody><tr><td colspan="5" style="color:var(--muted)">Opening balance carried from 1_Financials</td><td class="num">${fmt(a.opening)}</td></tr>
       ${a.rows.map(r=>`<tr><td><code>${r.je}</code></td><td>${mxDateTxt(r.date)}</td><td>${r.narr}<span class="mx-src">${r.src}</span></td>
         <td class="num">${r.dr?fmt(r.dr):''}</td><td class="num">${r.cr?fmt(r.cr):''}</td><td class="num">${fmt(r.bal)}</td></tr>`).join('')}
       <tr class="total"><td colspan="3">Closing balance</td><td class="num">${fmt(a.drTot)}</td><td class="num">${fmt(a.crTot)}</td>
         <td class="num">${fmt(a.closing)}</td></tr></tbody></table></div></div>`).join('')
    : `<div class="card"><div class="bd"><div class="mx-empty"><b>No account has moved yet</b>The ledger opens each account at its
        1_Financials balance and adds every journal line in date order.</div></div></div>`;

  /* --- bank statement --- */
  document.querySelector('#jrBank tbody').innerHTML =
    `<tr class="total"><td colspan="4">Opening balance at ${mxDateTxt(mxDate())}</td><td class="num"></td><td class="num"></td><td class="num">${fmt(bk.opening)}</td></tr>`
    + (bk.rows.length ? bk.rows.map(r=>`<tr><td>${mxDateTxt(r.date)}</td><td><code>${r.je}</code></td>
        <td>${r.narr}<span class="mx-src">${r.src}</span></td><td style="font-size:11px;color:var(--muted)">${r.ref}</td>
        <td class="num" style="color:var(--green)">${r.inflow?fmt(r.inflow):''}</td>
        <td class="num" style="color:var(--red)">${r.outflow?fmt(r.outflow):''}</td>
        <td class="num">${fmt(r.bal)}</td></tr>`).join('')
      : `<tr><td colspan="7"><div class="mx-empty"><b>No cash has moved</b>Only entries that touch account 1130 reach the bank statement.
          Accruals, reclassifications and audit adjustments correctly do not appear here.</div></td></tr>`)
    + `<tr class="total"><td colspan="4">Closing balance</td><td class="num">${fmt(bk.inflow)}</td><td class="num">${fmt(bk.outflow)}</td>
       <td class="num">${fmt(bk.closing)}</td></tr>`;
  const glCash = Number(LINES.find(l=>l.code==='1130').fy[idx()])||0;
  document.getElementById('jrBankNote').innerHTML = `<div class="mx-note ${Math.abs(bk.closing-glCash)<0.01?'ok':'bad'}">
    Bank statement closing balance R${fmt(bk.closing)}k against general ledger account 1130 at R${fmt(glCash)}k —
    ${Math.abs(bk.closing-glCash)<0.01?'reconciled to the cent':'unreconciled by R'+fmt(bk.closing-glCash)+'k'}.
    This is the bank reconciliation control the host already runs on the System integration tab; the difference here is that it now runs
    against entries this system posted, not against a static figure.</div>`;

  /* --- VAT control --- */
  document.querySelector('#jrVatOut tbody').innerHTML = vt.out.map(r=>
    `<tr><td><code>${r.code}</code> ${r.label}</td><td class="num">${fmt(r.amt)}</td><td class="num">${fmt(r.vat)}</td>
     <td style="font-size:11px;color:var(--muted)">${r.basis}</td></tr>`).join('')
    + `<tr class="total"><td>Output tax</td><td class="num"></td><td class="num">${fmt(vt.outVat)}</td><td></td></tr>`;
  document.querySelector('#jrVatIn tbody').innerHTML = vt.inp.map(r=>
    `<tr><td><code>${r.code}</code> ${r.label}</td><td class="num">${fmt(r.amt)}</td><td class="num">${fmt(r.vat)}</td>
     <td style="font-size:11px;color:var(--muted)">${r.basis}</td></tr>`).join('')
    + `<tr><td>Less: input tax denied — s17(2)(a) entertainment</td><td class="num">${fmt(MX_DENIED_INPUT)}</td>
       <td class="num" style="color:var(--red)">(${fmt(vt.denied)})</td><td style="font-size:11px;color:var(--muted)">Not claimable, whatever the invoice says</td></tr>`
    + `<tr class="total"><td>Input tax claimable</td><td class="num"></td><td class="num">${fmt(vt.inVat)}</td><td></td></tr>`;
  document.querySelector('#jrVatRec tbody').innerHTML = `
    <tr><td>Output tax for the year</td><td class="num">${fmt(vt.outVat)}</td><td>Standard-rated revenue at 15%</td></tr>
    <tr><td>Input tax claimable for the year</td><td class="num">(${fmt(vt.inVat)})</td><td>Standard-rated expenditure at 15%, less denied inputs</td></tr>
    <tr class="total"><td>Net VAT payable for the year</td><td class="num">${fmt(vt.annualNet)}</td><td></td></tr>
    <tr><td>Net VAT for one two-month tax period</td><td class="num">${fmt(vt.periodNet)}</td><td>Category A or B vendor — six periods a year</td></tr>
    <tr><td>Balance on GL account 2130</td><td class="num">${fmt(vt.glBal)}</td><td>As reported in 1_Financials</td></tr>
    <tr class="total"><td>Unreconciled residual</td><td class="num" style="color:${Math.abs(vt.residual)>500?'var(--red)':'inherit'}">${mxMoney(vt.residual)}</td>
      <td><span class="pill ${Math.abs(vt.residual)>500?'RED':'GREEN'}">${Math.abs(vt.residual)>500?'Raises AG-25-12':'Reconciled'}</span></td></tr>`;
  document.getElementById('jrVatNote').innerHTML = `<div class="mx-note">
    Every line is classified once, in one table, against a named section of the VAT Act, and the reconciliation is that classification applied
    to the figures already in the ledger. There is no interpretation step: rates are exempt because section 12(g) says so, interest is exempt
    because section 2 says so, employment is outside the definition of enterprise in section 1, and entertainment input tax is denied by
    section 17(2)(a) whether or not the supplier charged it. The residual of R${fmt(Math.abs(vt.residual))}k is left showing rather than
    absorbed, because an unreconciled VAT control account is a finding, and hiding it would be the only dishonest thing this table could do.</div>`;

  /* --- trial balance --- */
  const tb = LINES.map(l=>({code:l.code, label:l.label, cat:l.cat, nat:MX_NATURE[l.cat],
    val:Number(l.fy[idx()])||0, adj:ACTION_ADJ[l.code]||0}));
  const dr = tb.filter(r=>r.nat==='Dr').reduce((s,r)=>s+r.val,0);
  const cr = tb.filter(r=>r.nat==='Cr').reduce((s,r)=>s+r.val,0);
  const t2 = totals();
  document.querySelector('#jrTb tbody').innerHTML = tb.filter(r=>r.adj).map(r=>
    `<tr><td><code>${r.code}</code> ${r.label}</td><td>${r.nat}</td><td class="num">${fmt(r.val-r.adj)}</td>
     <td class="num" style="color:${r.adj>0?'var(--teal)':'var(--amber)'}">${(r.adj>0?'+':'')+fmt(r.adj)}</td>
     <td class="num">${fmt(r.val)}</td></tr>`).join('')
    || `<tr><td colspan="5"><div class="mx-empty">No account has been adjusted by this system yet.</div></td></tr>`;
  document.getElementById('jrTbNote').innerHTML = `<div class="mx-note ${t2.assets-t2.liab===t2.netAssets?'ok':'bad'}">
    Debit-nature accounts total R${fmt(dr)}k, credit-nature accounts R${fmt(cr)}k. Assets less liabilities are R${fmt(t2.assets-t2.liab)}k
    and net assets are R${fmt(t2.netAssets)}k — ${t2.assets-t2.liab===t2.netAssets?'the statement of financial position balances':'these do not agree'}.
    The engine refuses a one-sided entry outright, so this cannot drift: if a module tried to post debits that did not equal credits, the entry
    would be rejected and a message shown instead of being written.</div>`;

  /* date control */
  const di = document.getElementById('jrDate');
  if(di && di.value !== mxDate()) di.value = mxDate();
}
const CATLABEL2 = {revenue:'Revenue', expenditure:'Expenditure', nca:'Non-current asset', ca:'Current asset',
                   ncl:'Non-current liability', cl:'Current liability'};
function modeNameFor(m){ return m===1?'Mode 1':m===2?'Mode 2':'Mode 3'; }

/* =================== audit: determinism, expansion, evidence vault =========== */
function renderAuditExtras(){
  const rules = mxRules(), fs = mxFindings(), vl = mxVault();
  const sub = mxState.auSub;
  document.querySelectorAll('#auSubTabs button').forEach(b=>b.classList.toggle('active', b.dataset.au===sub));
  document.querySelectorAll('.au-sub').forEach(p=>p.style.display = p.id==='au-'+sub ? '' : 'none');

  /* --- the rule behind every finding --- */
  document.querySelector('#auRules tbody').innerHTML = fs.map(f=>{
    const r = rules[f.ref]; if(!r) return '';
    return `<tr><td><b>${f.ref}</b></td><td><code>${r.rule}</code></td>
      <td style="font-size:11px;color:var(--muted)">${r.inputs}</td>
      <td class="num">${r.value?fmt(r.value):'0'}</td><td class="num">${fmt(r.threshold)}</td>
      <td>${r.src}</td>
      <td><span class="pill ${r.fires?'RED':'GREEN'}">${r.fires?'Rule fires':'Rule clear'}</span></td></tr>`;
  }).join('');

  /* --- evidence vault --- */
  document.querySelector('#auVault tbody').innerHTML = vl.map(v=>
    `<tr class="click" data-vault="${v.no}"><td><b>${v.no}</b><span class="mx-src">${v.ref}</span></td>
      <td class="num">${v.present} of ${v.applicable}</td>
      <td><div class="mx-prog"><i class="${v.complete?'g':'r'}" style="width:${v.pct*100}%"></i></div>
        <span class="mx-mini">${mxPctTxt(v.pct,0)} of applicable items on file</span></td>
      <td>${v.missingMand.length ? v.missingMand.map(m=>m.doc).join('; ') : '<span style="color:var(--muted)">None</span>'}</td>
      <td><span class="pill ${v.complete?'GREEN':'RED'}">${v.complete?'Complete':'Incomplete'}</span></td>
      <td style="color:var(--muted);font-size:11px">${v.note||'Click to open the file'}</td></tr>`
    + `<tr class="vault-detail" data-vdet="${v.no}" style="display:none;"><td colspan="6" style="background:#fbfcfd;">
        <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:6px 16px;padding:4px 0 6px;">
        ${v.req.map(r=>`<div style="font-size:11.5px;display:flex;gap:7px;align-items:baseline;">
          <span class="pill ${MX_ST_LABEL[r.st][1]}" style="min-width:70px;text-align:center">${MX_ST_LABEL[r.st][0]}</span>
          <span style="${r.mand?'font-weight:700':''}">${r.doc}${r.mand?'':' <span style="color:var(--muted);font-weight:400">(conditional)</span>'}</span></div>`).join('')}
        </div>
        ${v.tax?`<p class="footnote" style="margin:6px 0 0">Tax compliance status expired ${mxDateTxt(v.tax)}. The check is a date comparison against
          the award date, not a view on the supplier.</p>`:''}</td></tr>`).join('');
  document.querySelectorAll('[data-vault]').forEach(tr=>tr.addEventListener('click',()=>{
    const d = document.querySelector(`[data-vdet="${tr.dataset.vault}"]`);
    d.style.display = d.style.display==='none' ? '' : 'none';
  }));
  const incomplete = vl.filter(v=>!v.complete);
  document.getElementById('auVaultNote').innerHTML = `<div class="mx-note ${incomplete.length?'bad':'ok'}">
    The vault holds ${MX_DOCS.length} document slots per procurement file. Eleven are mandatory for a competitive bid; the local content
    declaration, the performance guarantee and the section 116(3) variation approval are conditional and marked not applicable where the
    contract type does not call for them. A slot is present, absent or expired — all three are facts about a file, established by looking for
    a document and comparing a date. Nothing reads what is inside a document or forms a view about it.
    ${incomplete.length?`${incomplete.length} of ${vl.length} files are incomplete, which is what raises AG-25-13.`:'All files are complete.'}</div>`;

  /* --- determinism statement --- */
  const fires = Object.values(rules).filter(r=>r.fires).length;
  document.getElementById('auDetNote').innerHTML = `<div class="mx-note">
    <b>What this module does not do.</b> It does not read documents, interpret contracts, weigh explanations or form an opinion about whether
    something looks wrong. Every one of the ${Object.keys(rules).length} rules above is arithmetic on a figure that is already in the ledger,
    a register or the vault, compared against a threshold that is written down. ${fires} of them currently fire. Run the same figures through
    the same rules tomorrow, on paper, and you get the same register — which is the whole point, because a finding you cannot re-derive is a
    finding you cannot defend to the Auditor-General. The commentary on these screens explains the arithmetic. It does not add to it.</div>`;
}

/* expandable findings register */
function mxBindFindingRows(){
  const rules = mxRules();
  document.querySelectorAll('#auTable tbody tr[data-find]').forEach(tr=>{
    if(tr.dataset.bound) return; tr.dataset.bound='1';
    tr.addEventListener('click', ()=>{
      const d = document.querySelector(`[data-fdet="${tr.dataset.find}"]`);
      d.style.display = d.style.display==='none' ? '' : 'none';
    });
  });
}
/* =========================================================================
   PER-FINDING VAT TREATMENT AND EVIDENCE LINKS
   Each entry states the section of the VAT Act that governs the finding and
   whether correcting it moves the VAT 201. None of this is inferred: it is a
   fixed classification per finding, shown so the treatment can be checked.
   ========================================================================= */
const MX_FIND_VAT = {
 'AG-25-01':['VAT neutral','Input tax on the certificates was claimed on the tax-invoice date under s16(3)(a). Reclassifying the debit from repairs to infrastructure moves it between GL accounts that carry the same 15% input treatment, so the VAT 201 does not change.','1000 / 5180'],
 'AG-25-02':['Not a VAT matter','A variation changes the commitment, not the supply. Input tax follows each valid tax invoice as it is issued under s20(4).','—'],
 'AG-25-03':['VAT already claimed','Input tax was deducted on the invoice basis when the certificate was issued. Late settlement does not reverse the deduction; s22(3) only applies where a debt is unpaid for more than 12 months.','5180 / 2100'],
 'AG-25-04':['Out of scope','An intergovernmental conditional grant is a transfer, not consideration for a supply. Reversing the revenue recognition to the unspent liability has no VAT consequence.','4195 / 2120'],
 'AG-25-05':['No supply','Impairment is an accounting estimate. Bad-debt relief under s22(1) requires an actual write-off of a debt on which output tax was accounted for, not a provision.','5120'],
 'IA-25-06':['Not a VAT matter','A movement-check variance sits between the statement of financial performance and net assets. No supply is involved.','—'],
 'IA-25-07':['Exempt','Interest on borrowing is a financial service under s2(1)(f). No input tax arises on the carry cost.','5140 / 2000'],
 'AG-24-08':['Input tax denied','Deduction denied under s17(2)(a) on entertainment. The amount is reversed on the VAT 201 for the period in which it was claimed.','5190 / 2130'],
 'IA-25-09':['Zero consideration','Free basic services to registered indigents are supplied for no consideration, so no output tax arises. The finding is a reconciliation failure, not a VAT error.','4110 / 4120'],
 'MPAC-25-10':['Not a VAT matter','Consequence management is a governance obligation under MFMA s32.','—'],
 'IA-24-11':['Not a VAT matter','A reporting-deadline breach under MFMA s72.','—'],
 'AG-25-12':['Standard 15% input','Emergency procurement is an ordinary taxable supply. Input tax remains claimable on a valid tax invoice; a Regulation 36 deviation affects procurement compliance, not deductibility.','5180 / 5190 / 1000'],
 'AG-25-13':['Input tax at risk','Where a tax clearance or a valid tax invoice is absent from the file, the s16(2) documentary requirement is not met and the deduction is not supportable on audit.','5160 / 2130'],
 'AG-25-14':['Standard 15% input','Disaster restoration goods and services are taxable supplies. Input tax is claimable; the disaster grant funding it is out of scope, so no apportionment arises under s17(1).','1000 / 5180 / 5190'],
};
/* which tender file, if any, carries the evidence for a finding */
const MX_FIND_FILE = {
 'AG-25-02':'SCM/2024/19', 'AG-25-03':'SCM/2024/19', 'AG-25-13':'SCM/2024/19',
 'AG-25-01':'SCM/2023/41', 'AG-25-04':'SCM/2024/19', 'AG-25-12':'SCM/2024/12', 'AG-25-14':'SCM/2024/12',
};
/* GL codes a finding touches, used to pull its journal trail */
const MX_FIND_CODES = {
 'AG-25-01':['1000','5180'], 'AG-25-03':['2100','1130'], 'AG-25-04':['4195','2120'],
 'AG-25-05':['5120'], 'IA-25-07':['5140','2000'], 'AG-24-08':['5190','2130'],
 'AG-25-12':['1000','5180','5190'], 'AG-25-14':['1000','5180','5190','1130'],
};
function mxFindingTrail(f){
  const codes = MX_FIND_CODES[f.ref] || [];
  if(!codes.length) return [];
  return MX_JOURNAL.filter(j=>j.lines.some(l=>codes.includes(l.code)));
}

function mxFindingDetail(f){
  const r = mxRules()[f.ref] || {};
  const vl = mxVault().find(v=>f.ref==='AG-25-02'||f.ref==='AG-25-13' ? !v.complete : false);
  return `<tr class="find-detail" data-fdet="${f.ref}" style="display:none;"><td colspan="8" style="background:#fbfcfd;padding:14px 16px;">
    <div class="mx-split3">
      <div>
        <div style="font-size:11px;font-weight:800;color:var(--navy-dark);margin-bottom:5px">The rule that raised it</div>
        <table class="grid"><tbody>
          <tr><td>Test</td><td><code>${r.rule||'—'}</code></td></tr>
          <tr><td>Inputs</td><td>${r.inputs||'—'}</td></tr>
          <tr><td>Measured</td><td class="num">${r.value!==undefined?fmt(r.value):'—'}</td></tr>
          <tr><td>Threshold</td><td class="num">${r.threshold!==undefined?fmt(r.threshold):'—'}</td></tr>
          <tr><td>Source of the figures</td><td>${r.src||f.from}</td></tr>
          <tr class="total"><td>Result</td><td><span class="pill ${r.fires?'RED':'GREEN'}">${r.fires?'Fires — finding stands':'Clear — finding closes'}</span></td></tr>
        </tbody></table>
      </div>
      <div>
        <div style="font-size:11px;font-weight:800;color:var(--navy-dark);margin-bottom:5px">What closes it</div>
        <table class="grid"><tbody>
          <tr><td>Automated control</td><td><code>${f.ctl}</code> ${f.ctlName}</td></tr>
          <tr><td>Current test result</td><td><span class="pill ${f.ctlPass?'GREEN':'RED'}">${f.ctlPass?'Passing':'Failing'}</span></td></tr>
          <tr><td>Owner</td><td>${f.owner}</td></tr>
          <tr><td>Raised</td><td>${f.raised}</td></tr>
          <tr><td>Years reported</td><td>${f.years}${f.years>1?' — repeat finding':''}</td></tr>
          <tr><td>Classification</td><td>${f.cls}</td></tr>
          <tr class="total"><td>Effect on the opinion</td><td>${f.cls==='Material misstatement'
            ? 'Counts toward unresolved misstatement measured against materiality'
            : 'Reported as a finding; does not on its own qualify the opinion'}</td></tr>
        </tbody></table>
      </div>
      <div>
        <div style="font-size:11px;font-weight:800;color:var(--navy-dark);margin-bottom:5px">VAT treatment, evidence and ledger trail</div>
        ${mxFindingVatEvidence(f)}
      </div>
    </div>
    <p class="footnote">${mxFindingCommentary(f, r)}</p></td></tr>`;
}
function mxFindingVatEvidence(f){
  const v = MX_FIND_VAT[f.ref] || ['—','No VAT classification is recorded for this finding.','—'];
  const fileNo = MX_FIND_FILE[f.ref];
  const vl = fileNo ? mxVault().find(x=>x.no===fileNo) : null;
  const trail = mxFindingTrail(f);
  const missing = vl ? vl.missingMand : [];
  const vatCls = /neutral|Not a VAT|No supply|Out of scope|Zero/.test(v[0]) ? 'INCOMPLETE'
               : /denied|at risk/.test(v[0]) ? 'RED' : 'NAVY';
  return `<table class="grid"><tbody>
    <tr><td>VAT classification</td><td><span class="pill ${vatCls}">${v[0]}</span></td></tr>
    <tr><td>Section relied on</td><td style="font-size:11px;line-height:1.55">${v[1]}</td></tr>
    <tr><td>Accounts carrying it</td><td><code>${v[2]}</code></td></tr>
    <tr><td>Tender file</td><td>${vl
      ? `<code>${vl.ref}</code> — ${vl.present} of ${vl.applicable} items on file (${mxPctTxt(vl.pct,0)})`
      : '<span style="color:var(--muted)">No procurement file attaches to this finding</span>'}</td></tr>
    <tr><td>Mandatory items outstanding</td><td>${missing.length
      ? missing.map(m=>`<span class="mx-tag">${m.doc} — ${MX_ST_LABEL[m.st][0].toLowerCase()}</span>`).join(' ')
      : '<span class="pill GREEN">Complete</span>'}</td></tr>
    <tr><td>Journal entries touching it</td><td>${trail.length
      ? trail.map(j=>`<code>${j.ref}</code> ${mxDateTxt(j.date)} — ${j.narr}`).join('<br/>')
      : '<span style="color:var(--muted)">Nothing posted in this session against these accounts</span>'}</td></tr>
    <tr class="total"><td>Effect on the VAT 201</td><td>${v[0]==='VAT neutral'||v[0]==='Not a VAT matter'||v[0]==='No supply'||v[0]==='Out of scope'||v[0]==='Zero consideration'
      ? 'None. Correcting this finding does not change output or input tax for the period.'
      : v[0]==='Input tax denied' ? 'Input tax is reversed on the return for the period in which it was claimed.'
      : v[0]==='Input tax at risk' ? 'The deduction stands until the documentary requirement fails on audit, when it is reversed.'
      : 'Input tax is claimable in the normal course on each valid tax invoice.'}</td></tr>
  </tbody></table>`;
}
function mxFindingCommentary(f, r){
  const op = mxOpinion();
  const base = {
   'AG-25-01':'Capital work that was certified but charged to repairs understates the asset base and overstates operating expenditure at the same time. It also distorts the repairs-to-PPE ratio, which is why the ratio table and the renewal backlog both move when this is corrected.',
   'AG-25-02':'A commitment raised above the awarded value is a variation whether or not anyone called it one. Under section 116(3) it needs prior approval; logging it for council does not make it compliant, it makes it visible.',
   'AG-25-03':'Section 65(2)(e) gives thirty days from receipt of an invoice. Retention is excluded from this test because it is contractually withheld, not late. Only the certified, non-retention balance is measured.',
   'AG-25-04':'Grant revenue is recognised as the conditions are met, which for an infrastructure grant means as qualifying expenditure is incurred. Money drawn ahead of spending is a liability, not income.',
   'AG-25-05':'An impairment charge has to agree to the ageing that supports it. Where it does not, the difference is unsupported by definition, and it is the same difference that drives the net-asset movement variance.',
   'IA-25-06':'Opening net assets plus the surplus should equal closing net assets. When they do not, something has been posted directly to reserves or a balance has been restated without a note.',
   'IA-25-07':'Drawing a loan faster than the project spends it leaves borrowed money in the bank earning less than it costs. The carry is the difference between the two rates on the idle balance.',
   'AG-24-08':'Input tax on entertainment is denied by section 17(2)(a) regardless of what the supplier charged or what the invoice shows.',
   'IA-25-09':'A count in one register against a count in another. The gap is households billed as if they can pay who are registered as indigent, or the reverse.',
   'MPAC-25-10':'Section 32 requires each item of irregular expenditure to be investigated and recorded. The test counts items against register entries.',
   'IA-24-11':'Section 72 requires the mid-year assessment by 25 January and tabling shortly after. A date comparison, nothing more.',
   'AG-25-12':'A VAT control account that does not agree to the return is either a timing difference you can name or an error you have not found yet.',
   'AG-25-13':'Procurement files are tested for completeness, not for merit. A missing bid committee resolution or an expired tax compliance status is a fact about the file.',
   'AG-25-14':'Emergency procurement under regulation 36 is permitted. What is not permitted is failing to report the deviation to council at the next meeting.',
  }[f.ref] || 'The rule above is the whole test.';
  return base + (f.cls==='Material misstatement' && f.status!=='Resolved'
    ? ` This finding currently carries R${fmt(f.amt)}k of the R${fmt(op.mis)}k unresolved misstatement measured against materiality of R${fmt(op.mat)}k.`
    : '');
}

/* =================== posting actions =================== */
/* Each action writes only through ACTION_ADJ and then re-enters the host
   through applyModeData() and renderAll(), exactly as runPayrollAction() does. */

const MX_CLAIM = { 'CP-01':8900, 'CP-02':5600, 'CP-03':3900 };
const MX_CLAIM_TOTAL = Object.values(MX_CLAIM).reduce((a,b)=>a+b,0);   /* 18,400 */
const MX_GRANT_RELEASE = 12000;

function mxPostClaims(){
  if(!mxCan('claims')) return showToast(`${MX_ROLES[mxState.role].label} cannot post capital claims`);
  if(state.mode===1 && !Object.values(MX_POSTED).some(v=>v.cert)) return showToast('Load the capital programme before posting claims');
  Object.entries(MX_CLAIM).forEach(([code,v])=>{ MX_POSTED[code].out+=v; MX_POSTED[code].cert+=v; });
  MX_POSTED['CP-01'].in += 5600; MX_POSTED['CP-03'].in += 6400;
  mxPost('Project risk','Certified progress claims capitalised — CP-01, CP-02 and CP-03',
    'Payment certificates PC-041/18, PC-007/06, PC-012/07',
    [{code:'1000', dr:MX_CLAIM_TOTAL},{code:'2100', cr:MX_CLAIM_TOTAL}]);
  mxPost('Project risk','Conditional grant released to revenue against qualifying expenditure',
    'Grant expenditure schedule GES-2025-Q4',
    [{code:'2120', dr:MX_GRANT_RELEASE},{code:'4195', cr:MX_GRANT_RELEASE}]);
  mxState.posted.claims = true;
  MX_CONTROLS['CTL-11'].pass = true;        /* certified work now capitalised   */
  applyModeData(); renderAll();
  triggerCascade('Post certified progress claims',
    `Certified progress of R${fmt(MX_CLAIM_TOTAL)}k across CP-01, CP-02 and CP-03 was capitalised against trade payables, and R${fmt(MX_GRANT_RELEASE)}k of conditional grant was released to revenue against qualifying expenditure. Property, plant and equipment, the surplus, the capital-delivery rate, the cost-performance index, the forecast outturn and every ratio that depends on them have recalculated.`,
    { mainNav:['exec','sysint','workbook','strategy','scenario','governance','projrisk','contracts','audit'],
      siPackages:['pastelacc','sage300','caseware','afs','push'],
      siSubtabs:{ pastelacc:['dashboard','suppliers'], sage300:['batches','trialbalance'], caseware:['workingtb','trialbalance','finstatements'] },
      wbSheets:['1_Financials','2_Budget_Data','3_Ratios','4_MFD-MM_Master','5_Credit_Worthiness','9_InYear_Projection','15_Project_Risk','17_Audit_Findings'] });
}

function mxRollForward(){
  if(!mxCan('forecast')) return showToast(`${MX_ROLES[mxState.role].label} cannot roll the forecast forward`);
  const pf = mxPortfolio();
  renderAll();
  triggerCascade('Roll forward the forecast',
    `The portfolio was re-extrapolated at a cost-performance index of ${pf.cpi.toFixed(4)}. Forecast outturn is R${fmt(pf.eac)}k against R${fmt(pf.approved)}k approved, a ${mxPctTxt(pf.overrun/pf.approved)} overrun, with a weighted slip of ${pf.slipMonths.toFixed(1)} months. No journal was posted — this sets the forward-looking levers only.`,
    { mainNav:['exec','strategy','scenario','reports','projrisk'],
      siPackages:[], siSubtabs:{},
      wbSheets:['7_Scenario_Planning','8_Sustainability_Report','11_Bankability_Engine','12_Capital_Structure','14_CFO_Dashboard','15_Project_Risk'] });
}

function mxPayRun(){
  if(!mxCan('payrun')) return showToast(`${MX_ROLES[mxState.role].label} cannot release payments`);
  const ct = mxContractTotals();
  if(ct.late<=0) return showToast('Nothing is beyond 30 days — there is no payment run to release');
  ct.rows.forEach(({k,s})=>{ if(s.days>MX_ASSUM.payDays) MX_PAID_ADJ[k.no] += s.due; });
  mxPost('Contract monitor','Settlement of certified balances aged beyond thirty days',
    'Payment batch PAY-2025-0512 · '+ct.lateCount+' contracts',
    [{code:'2100', dr:ct.late},{code:'1130', cr:ct.late}]);
  mxState.posted.payrun = true;
  MX_CONTROLS['CTL-13'].pass = true;
  applyModeData(); renderAll();
  triggerCascade('Release the 30-day payment run',
    `R${fmt(ct.late)}k of certified, non-retention balances older than 30 days was settled across ${ct.lateCount} contracts. Trade payables and cash both fall, creditor days improve, the section 65(2)(e) test passes and finding AG-25-03 closes with it.`,
    { mainNav:['exec','sysint','workbook','strategy','governance','contracts','audit'],
      siPackages:['pastelacc','sage300','caseware','afs','push'],
      siSubtabs:{ pastelacc:['suppliers','cashbook'], sage300:['batches','trialbalance'], caseware:['workingtb','trialbalance'] },
      wbSheets:['1_Financials','3_Ratios','6_ALM','14_CFO_Dashboard','16_Contract_Monitor','17_Audit_Findings'] });
}

function mxLogVariation(){
  if(!mxCan('variation')) return showToast(`${MX_ROLES[mxState.role].label} cannot log contract variations`);
  const over = mxContractTotals().rows.filter(r=>r.s.overCommit>0);
  mxState.posted.variation = true;
  renderAll();
  triggerCascade('Log the contract variation for approval',
    `${over.length} variation totalling R${fmt(over.reduce((s,r)=>s+r.s.overCommit,0))}k was placed on the council agenda under MFMA section 116(3). No journal was posted — the commitment already sits in the ledger. Finding AG-25-02 moves to in progress and stays open until council approves and the register is updated.`,
    { mainNav:['reports','governance','contracts','audit'], siPackages:[], siSubtabs:{},
      wbSheets:['13_Legal_Regulatory','16_Contract_Monitor','17_Audit_Findings'] });
}

function mxPostAudit(){
  if(!mxCan('adjust')) return showToast(`${MX_ROLES[mxState.role].label} cannot post audit adjustments`);
  const lens = mxLens();
  const a1 = MX_FINDINGS.find(f=>f.ref==='AG-25-01').amt[lens];
  const a4 = MX_FINDINGS.find(f=>f.ref==='AG-25-04').amt[lens];
  mxPost('Audit findings','AG-25-01 — certified capital work reclassified out of repairs and maintenance',
    'AG management report par 14 · adjusting journal AJE-01',
    [{code:'1000', dr:a1},{code:'5180', cr:a1}]);
  mxPost('Audit findings','AG-25-04 — conditional grant revenue reversed to the unspent liability',
    'AG management report par 21 · adjusting journal AJE-04',
    [{code:'4195', dr:a4},{code:'2120', cr:a4}]);
  const vr = Math.round(mxVatRecon().residual);
  if(Math.abs(vr) > 0){
    mxPost('Audit findings','AG-25-12 — VAT control account cleared to the computed net VAT for the period',
      'VAT201 reconciliation VR-2025-04 · adjusting journal AJE-12',
      vr>0 ? [{code:'2130', dr:vr},{code:'5190', cr:vr}] : [{code:'5190', dr:-vr},{code:'2130', cr:-vr}]);
    MX_CONTROLS['CTL-19'].pass = true;
  }
  mxState.posted.adjust = true;
  MX_CONTROLS['CTL-11'].pass = true;
  MX_CONTROLS['CTL-14'].pass = true;
  applyModeData(); renderAll();
  const op = mxOpinion();
  triggerCascade('Post the audit adjustments',
    `R${fmt(a1)}k of capital work was moved out of repairs and maintenance into property, plant and equipment, R${fmt(a4)}k of conditional grant revenue was reversed to the unspent liability, and the VAT control account was cleared to the computed net VAT for the period. Controls CTL-11, CTL-14 and CTL-19 now pass, findings AG-25-01, AG-25-04 and AG-25-12 close, and the projected outcome moves to ${op.op}.`,
    { mainNav:['exec','sysint','workbook','strategy','reports','governance','projrisk','audit'],
      siPackages:['sage300','caseware','afs','push'],
      siSubtabs:{ sage300:['batches','trialbalance'], caseware:['workingtb','adjusting','trialbalance','finstatements','diagnostics'] },
      wbSheets:['1_Financials','3_Ratios','4_MFD-MM_Master','5_Credit_Worthiness','14_CFO_Dashboard','15_Project_Risk','17_Audit_Findings'] });
}

function mxSweep(){
  if(!mxCan('sweep')) return showToast(`${MX_ROLES[mxState.role].label} cannot run the control sweep`);
  const ct = mxContractTotals(), pf = mxPortfolio();
  MX_CONTROLS['CTL-12'].pass = ct.rows.every(r=>r.s.overCommit<=0);
  MX_CONTROLS['CTL-13'].pass = ct.late<=0;
  MX_CONTROLS['CTL-15'].pass = pf.fundVar<=0;
  MX_CONTROLS['CTL-19'].pass = Math.abs(mxVatRecon().residual) <= 500;
  MX_CONTROLS['CTL-20'].pass = mxVault().every(v=>v.complete);
  renderAll();
  const failing = Object.entries(MX_CONTROLS).filter(([,c])=>!c.pass);
  triggerCascade('Run the control sweep',
    `All ${Object.keys(MX_CONTROLS).length} automated tests were re-run against the current ledger. ${failing.length} still fail: ${failing.map(([k])=>k).join(', ')}. Every failing test holds its finding open; every passing test closes one. Nothing here is a judgement call.`,
    { mainNav:['governance','audit','contracts','projrisk','reports'], siPackages:[], siSubtabs:{},
      wbSheets:['Governance_Controls','15_Project_Risk','16_Contract_Monitor','17_Audit_Findings'] });
}

/* =================== revenue and expenditure actions =================== */
function mxCollectRun(){
  if(!mxCan('revenue')) return showToast(`${MX_ROLES[mxState.role].label} cannot post receipts`);
  const amt = 18600;
  mxPost('Revenue and expenditure','Collection run — rates and service-charge receipts banked',
    'Cashbook batch CB-2025-0418 · 9 412 receipts',
    [{code:'1130', dr:amt},{code:'1120', cr:amt}]);
  mxState.posted.collect = true; applyModeData(); renderAll();
  const co = mxCollection();
  triggerCascade('Collection run receipts',
    `R${fmt(amt)}k was banked against statutory receivables. Cash rises and the rates debtor falls by the same amount, so the balance sheet total does not move but its quality does. Debtor days, the collection rate, the liquidity ratios, the cash-coverage months and the Scenario lab's collection lever have all recalculated, and the receipt appears on the bank statement with its cashbook batch reference.`,
    { mainNav:['exec','sysint','workbook','strategy','scenario','revexp','journals'],
      siPackages:['pastelacc','sage300','caseware','afs','push'],
      siSubtabs:{ pastelacc:['dashboard','customers','cashbook'], sage300:['batches','trialbalance'], caseware:['workingtb','trialbalance'] },
      wbSheets:['1_Financials','3_Ratios','6_ALM','9_InYear_Projection','14_CFO_Dashboard','18_Revenue_Expenditure','19_Journals_Ledger'] });
}
function mxWriteOff(){
  if(!mxCan('revenue')) return showToast(`${MX_ROLES[mxState.role].label} cannot authorise write-offs`);
  const amt = 6400;
  mxPost('Revenue and expenditure','Write-off of irrecoverable rates debt under the credit control and debt collection policy',
    'Council resolution C/2025/74 · debtor schedule WO-2025-02',
    [{code:'5120', dr:amt},{code:'1120', cr:amt}]);
  mxState.posted.writeoff = true; applyModeData(); renderAll();
  triggerCascade('Write-off of irrecoverable rates debt',
    `R${fmt(amt)}k of rates debt older than the policy limit was written off against debt impairment. The receivable and the surplus both fall by R${fmt(amt)}k. Debt impairment now sits further above its budget, which is the line the audit register already tests against the debtors ageing — so clearing the debtor makes the impairment variance larger, not smaller, and the budget monitor shows it.`,
    { mainNav:['exec','sysint','workbook','strategy','governance','revexp','audit','journals'],
      siPackages:['pastelacc','sage300','caseware','afs','push'],
      siSubtabs:{ pastelacc:['customers'], sage300:['batches','trialbalance'], caseware:['workingtb','adjusting','trialbalance'] },
      wbSheets:['1_Financials','2_Budget_Data','3_Ratios','5_Credit_Worthiness','17_Audit_Findings','18_Revenue_Expenditure','19_Journals_Ledger'] });
}
function mxMaintOrders(){
  if(!mxCan('revenue')) return showToast(`${MX_ROLES[mxState.role].label} cannot post work orders`);
  const amt = 4850;
  mxPost('Revenue and expenditure','Unplanned maintenance work orders for the month — bursts, blockages and transformer failures',
    'Work-order batch WO-2025-0430 · 148 orders',
    [{code:'5180', dr:amt},{code:'2100', cr:amt}]);
  MX_UNPLANNED_EXTRA.amount += amt;
  mxState.posted.maint = true; applyModeData(); renderAll();
  const mt = mxMaint();
  triggerCascade('Unplanned maintenance work orders',
    `R${fmt(amt)}k of emergency work orders was accrued to repairs and maintenance. Unplanned work is now ${mxPctTxt(mt.share)} of everything spent on maintenance. The repairs-to-PPE ratio, the renewal backlog in 6_ALM, the budget variance on 5180 and the forward extrapolation on this tab have all moved, and creditor days lengthened because the accrual sits in payables until it is paid.`,
    { mainNav:['exec','sysint','workbook','strategy','scenario','revexp','journals'],
      siPackages:['pastelacc','sage300','caseware','afs','push'],
      siSubtabs:{ pastelacc:['suppliers'], sage300:['batches','trialbalance'], caseware:['workingtb','trialbalance'] },
      wbSheets:['1_Financials','2_Budget_Data','3_Ratios','6_ALM','7_Scenario_Planning','18_Revenue_Expenditure','19_Journals_Ledger'] });
}
function mxReprice(){
  if(!mxCan('revenue')) return showToast(`${MX_ROLES[mxState.role].label} cannot re-price the borrowing book`);
  const ra = mxRates(), amt = Math.round(ra.floating/100);
  mxPost('Revenue and expenditure','Re-pricing of the JIBAR-linked tranche — 100 basis point increase accrued',
    'Lender re-pricing notice RPN-2025-09 · 3-month JIBAR reset',
    [{code:'5140', dr:amt},{code:'2100', cr:amt}]);
  mxState.posted.reprice = true; applyModeData(); renderAll();
  triggerCascade('Floating tranche re-priced at +100bp',
    `R${fmt(amt)}k of additional annual finance cost was accrued on the R${fmt(ra.floating)}k floating tranche. The fixed DBSA facility does not move. Finance costs, the surplus, interest cover, the debt-service ratio against the Circular 71 band and the capital charges ratio have all recalculated, and the Scenario lab's interest rate lever now sits at its shocked position rather than its base.`,
    { mainNav:['exec','sysint','workbook','strategy','scenario','revexp','journals'],
      siPackages:['sage300','caseware','afs','push'],
      siSubtabs:{ sage300:['batches','trialbalance'], caseware:['workingtb','adjusting','trialbalance'] },
      wbSheets:['1_Financials','3_Ratios','5_Credit_Worthiness','7_Scenario_Planning','8_Sustainability_Report','12_Capital_Structure','18_Revenue_Expenditure','19_Journals_Ledger'] });
}

/* =================== module event wiring =================== */
function mxBindGo(){
  /* In the separate artefacts most cross-references could only raise a toast, because
     the target module was not in the same file. In the consolidated pack nearly all of
     them are, so a cross-reference now actually navigates. */
  const MAP = {projrisk:'projrisk', contracts:'contracts', audit:'audit', journals:'journals',
    revexp:'treas', treasury:'treas', alm:'treas', strategy:'credit', credit:'credit',
    scenario:'scen', sustainability:'scen', risk:'scen', asset:'asset', appraisal:'pa',
    bankability:'pa', capital:'pa', howto:'howto'};
  document.querySelectorAll('.mx-go').forEach(b=>{
    b.addEventListener('click', ev=>{
      ev.stopPropagation();
      const target = MAP[b.dataset.go];
      const btn = target && document.querySelector(`#mainnav button[data-panel="${target}"]`);
      if(btn){ btn.click(); showToast(`Opened ${PACK_LABEL[target]}`); }
      else showToast(`${MX_PANEL_LABEL[b.dataset.go]||b.dataset.go} lives in the host build — this pack carries the integrated modules listed in the sidebar`);
    });
  });
}
function mxInit(){
  const rs = document.getElementById('roleSel');
  if(rs) rs.addEventListener('change', e=>{ mxState.role=e.target.value; renderAll();
    showToast(`Portal switched to ${MX_ROLES[mxState.role].label}`); });
  document.getElementById('ctTabs').addEventListener('click', e=>{
    const b=e.target.closest('button'); if(!b) return; mxState.ctFilter=b.dataset.ct;
    document.querySelectorAll('#ctTabs button').forEach(x=>x.classList.toggle('active',x===b)); renderContracts(); });
  document.getElementById('auLensTabs').addEventListener('click', e=>{
    const b=e.target.closest('button'); if(!b) return; mxState.auLens=b.dataset.lens; renderAudit(); });
  document.getElementById('auFilterBtn').addEventListener('click', ()=>{ mxState.auShowResolved=!mxState.auShowResolved; renderAudit(); });
  document.getElementById('auSubTabs').addEventListener('click', e=>{
    const b=e.target.closest('button'); if(!b) return; mxState.auSub=b.dataset.au; renderAudit(); renderAuditExtras(); });
  /* the old Revenue & expenditure sub-tabs are gone — the analysis they carried now
     lives in the Treasury Control Tower and the Risk management levers. Only the
     posting actions survived, and they sit with Journals. */
  const jrTabsEl = document.getElementById('jrTabs'); // MATOS: Journals tab removed — guard against the missing element
  if(jrTabsEl) jrTabsEl.addEventListener('click', e=>{
    const b=e.target.closest('button'); if(!b) return; mxState.jrSub=b.dataset.jr; renderJournals(); });
  const dt = document.getElementById('jrDate');
  if(dt){ dt.value = mxDefaultDate();
    dt.addEventListener('change', e=>{ mxPostDate = e.target.value;
      showToast('Posting date set to '+mxDateTxt(mxPostDate)+' — every entry from here carries that date'); renderAll(); }); }
  /* MATOS: these five buttons lived in the removed Journals panel; the same actions
     are now reachable from Operational Runs, so guard rather than re-create them here. */
  const reCollectBtnEl = document.getElementById('reCollectBtn'); if(reCollectBtnEl) reCollectBtnEl.addEventListener('click', mxCollectRun);
  const reWriteOffBtnEl = document.getElementById('reWriteOffBtn'); if(reWriteOffBtnEl) reWriteOffBtnEl.addEventListener('click', mxWriteOff);
  const reMaintBtnEl = document.getElementById('reMaintBtn'); if(reMaintBtnEl) reMaintBtnEl.addEventListener('click', mxMaintOrders);
  const reRateBtnEl = document.getElementById('reRateBtn'); if(reRateBtnEl) reRateBtnEl.addEventListener('click', mxReprice);
  const reDisasterBtnEl = document.getElementById('reDisasterBtn'); if(reDisasterBtnEl) reDisasterBtnEl.addEventListener('click', mxRunDisaster);
  document.getElementById('prPostBtn').addEventListener('click', mxPostClaims);
  document.getElementById('prForecastBtn').addEventListener('click', mxRollForward);
  document.getElementById('ctPayBtn').addEventListener('click', mxPayRun);
  document.getElementById('ctVarBtn').addEventListener('click', mxLogVariation);
  document.getElementById('auPostBtn').addEventListener('click', mxPostAudit);
  document.getElementById('auSweepBtn').addEventListener('click', mxSweep);
}
/* Feeds the host's accounting-suite screens. In the full MATOS build, splice
   mxSuiteRows('sage300') into the Sage 300 batch list, mxSuiteRows('caseware')
   into the CaseWare working trial balance, and mxSuiteRows('pastelacc') into the
   Pastel cashbook, so an entry posted on any module shows up inside the package
   it would really have been captured in. */
function mxSuiteRows(pkg){
  const cashOnly = pkg==='pastelacc';
  return MX_JOURNAL.filter(j=>cashOnly ? j.cash!==0 : true).map(j=>
    `<tr><td><code>${j.je}</code></td><td>${mxDateTxt(j.date)}</td><td>${j.src}</td><td>${j.narr}</td>
      <td class="num">${fmt(j.dr)}</td><td class="num">${fmt(j.cr)}</td>
      <td><span class="pill GREEN">Posted</span></td><td style="font-size:11px;color:var(--muted)">${j.evid}</td></tr>`).join('');
}

/* One call for the host: add renderRiskSuite() to renderAll(). */
function renderRiskSuite(){
  renderProjectRisk(); renderContracts(); renderAudit(); renderAuditExtras();
  renderRevActions(); renderJournals();
}

/* ===== MODULE BLOCK 2 — credit rating, risk management, treasury, asset register ===== */

/* ========================================================================
   MATOS ANNEXURE — Credit rating · Scenario & sustainability · Treasury
   Rufaro M. Mafinyani, Intellica Analytics
   ======================================================================== */

const AX = { pop:412000, households:118500, grpPerCap:64200, unemp:0.314,
             conc:0.58, indigent:0.213, collection:0.981, sovereign:'BB-',
             sovMoody:'Ba2', cpi:0.052, repo:0.0725 };

const AX_N = (v,d)=>Number(v||0).toFixed(d===undefined?1:d);
const pctT = (v,d)=>((v||0)*100).toFixed(d===undefined?1:d)+'%';
const money = n=>(n<0?'(':'')+fmt(Math.abs(n))+(n<0?')':'');
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));

/* ------------------------------------------------------------------
   BASE FINANCIAL AGGREGATES — everything in the annexure starts here
   ------------------------------------------------------------------ */
function axBase(){
  const t = totals();
  const ownRev = t.revenue - L('4190') - L('4195');
  const transfers = L('4190') + L('4195');
  const debt = L('2000') + L('2110');
  const cash = L('1130');
  const restricted = L('2120');
  const freeCash = cash - restricted;
  const debtService = L('5140') + L('2110');
  const receivables = L('1110') + L('1120');
  const capex = 162600;
  const depn = L('5130');
  const opRevenue = t.revenue - L('4195');          /* operating revenue excludes capital grant */
  const opBalance = t.surplus;
  const cashOpex = t.expenditure - depn - L('5120');
  return {t, ownRev, transfers, debt, cash, restricted, freeCash, debtService, receivables,
          capex, depn, opRevenue, opBalance, cashOpex,
          daysCash: cashOpex? freeCash/(cashOpex/365) : 0,
          opMargin: t.revenue? opBalance/t.revenue : 0,
          balAfterCapex: t.revenue? (opBalance + depn - capex)/t.revenue : 0,
          debtRatio: opRevenue? debt/opRevenue : 0,
          interestRatio: opRevenue? L('5140')/opRevenue : 0,
          dsRatio: opRevenue? debtService/opRevenue : 0,
          dsCover: debtService? (opBalance+depn+L('5140'))/debtService : 0,
          ownShare: t.revenue? ownRev/t.revenue : 0,
          capexFlex: (t.expenditure+capex)? capex/(t.expenditure+capex) : 0,
          renewalRatio: depn? capex/depn : 0,
          liquidityCover: debtService? freeCash/debtService : 0,
          rmRatio: L('1000')? L('5180')/L('1000') : 0,
          impairRatio: receivables? L('5120')/receivables : 0,
          contingent: 38000 + L('2010')*0.15};
}

/* ------------------------------------------------------------------
   1. CREDIT RATING MODEL  —  S&P-style LRG framework, localised
   ------------------------------------------------------------------ */
/* score 1 = extremely strong … 6 = extremely weak. bands are ordered from the
   strong end; the first band the value satisfies is the score. */
const AX_BANDS = {
  opMargin:      {lbl:'Operating balance ÷ operating revenue', dir:'hi', b:[0.15,0.08,0.03,0.00,-0.05,-1], f:'budgperf', unit:'%'},
  balAfterCapex: {lbl:'Balance after capital account ÷ total revenue', dir:'hi', b:[0.05,0.00,-0.05,-0.10,-0.20,-1], f:'budgperf', unit:'%'},
  daysCash:      {lbl:'Free cash ÷ daily cash operating cost', dir:'hi', b:[120,90,60,30,15,0], f:'liquidity', unit:'d'},
  liquidityCover:{lbl:'Free cash ÷ debt service falling due in 12 months', dir:'hi', b:[3.0,1.8,1.2,0.8,0.4,0], f:'liquidity', unit:'x'},
  debtRatio:     {lbl:'Direct debt ÷ operating revenue', dir:'lo', b:[0.30,0.45,0.60,0.90,1.20,9], f:'debt', unit:'%'},
  interestRatio: {lbl:'Interest ÷ operating revenue', dir:'lo', b:[0.02,0.04,0.06,0.09,0.13,9], f:'debt', unit:'%'},
  ownShare:      {lbl:'Own revenue ÷ total revenue', dir:'hi', b:[0.80,0.65,0.50,0.35,0.20,0], f:'budgflex', unit:'%'},
  capexFlex:     {lbl:'Capital spend ÷ total spend — room to defer', dir:'hi', b:[0.25,0.18,0.12,0.08,0.04,0], f:'budgflex', unit:'%'},
  collection:    {lbl:'Collection rate on billed revenue', dir:'hi', b:[0.97,0.94,0.90,0.85,0.75,0], f:'finman', unit:'%'},
  grpPerCap:     {lbl:'Gross regional product per head (R)', dir:'hi', b:[140000,100000,75000,55000,35000,0], f:'economy', unit:'R'},
  unemp:         {lbl:'Unemployment rate', dir:'lo', b:[0.12,0.20,0.27,0.33,0.40,1], f:'economy', unit:'%'},
  conc:          {lbl:'Share of output in the three largest sectors', dir:'lo', b:[0.35,0.45,0.55,0.65,0.75,1], f:'economy', unit:'%'},
  contingentRatio:{lbl:'Contingent liabilities ÷ operating revenue', dir:'lo', b:[0.02,0.05,0.10,0.18,0.30,9], f:'conting', unit:'%'},
  impairRatio:   {lbl:'Debt impairment charge ÷ receivables', dir:'lo', b:[0.05,0.10,0.16,0.24,0.35,9], f:'finman', unit:'%'},
  rmRatio:       {lbl:'Repairs and maintenance ÷ carrying value of infrastructure', dir:'hi', b:[0.08,0.06,0.04,0.025,0.015,0], f:'finman', unit:'%'},
};
function axScore(key, value){
  const B = AX_BANDS[key]; if(!B) return 6;
  for(let i=0;i<6;i++){
    const th = B.b[i];
    if(B.dir==='hi' ? value>=th : value<=th) return i+1;
  }
  return 6;
}
const AX_FACTORS = {
  economy:  {lbl:'Economy', w:0.20, why:'How the local economic base supports revenue generation and spending need over the medium term.'},
  finman:   {lbl:'Financial management', w:0.20, why:'Whether the administration can execute a budget, collect what it bills, maintain what it owns and produce an auditable account of it.'},
  budgflex: {lbl:'Budgetary flexibility', w:0.10, why:'How much revenue could be raised or spending deferred at short notice without breaking service obligations.'},
  budgperf: {lbl:'Budgetary performance', w:0.10, why:'The level and volatility of the operating and after-capital balances.'},
  liquidity:{lbl:'Liquidity', w:0.20, why:'Cash and committed facilities against debt service falling due, and the ability to raise more at short notice.'},
  debt:     {lbl:'Debt burden', w:0.10, why:'The stock of direct debt and the cost of carrying it, both relative to operating revenue.'},
  conting:  {lbl:'Contingent liabilities', w:0.10, why:'Guarantees, entity exposures, bulk-supplier arrears and litigation that could become direct obligations.'},
};
/* audit outcome is a live input into financial management */
let axAuditOutcome = 'Qualified opinion';
const AX_AUDIT_PENALTY = {'Clean audit — unqualified, no findings':0,'Unqualified with findings':0.5,'Qualified opinion':1.2,'Adverse opinion':2.2,'Disclaimer of opinion':3.0};

function axMetrics(over){
  const b = axBase(); over = over||{};
  const v = {
    opMargin:      over.opMargin      !== undefined ? over.opMargin      : b.opMargin,
    balAfterCapex: over.balAfterCapex !== undefined ? over.balAfterCapex : b.balAfterCapex,
    daysCash:      over.daysCash      !== undefined ? over.daysCash      : b.daysCash,
    liquidityCover:over.liquidityCover!== undefined ? over.liquidityCover: b.liquidityCover,
    debtRatio:     over.debtRatio     !== undefined ? over.debtRatio     : b.debtRatio,
    interestRatio: over.interestRatio !== undefined ? over.interestRatio : b.interestRatio,
    ownShare:      over.ownShare      !== undefined ? over.ownShare      : b.ownShare,
    capexFlex:     over.capexFlex     !== undefined ? over.capexFlex     : b.capexFlex,
    collection:    over.collection    !== undefined ? over.collection    : AX.collection,
    grpPerCap:     over.grpPerCap     !== undefined ? over.grpPerCap     : AX.grpPerCap,
    unemp:         over.unemp         !== undefined ? over.unemp         : AX.unemp,
    conc:          AX.conc,
    contingentRatio: b.opRevenue ? b.contingent/b.opRevenue : 0,
    impairRatio:   over.impairRatio   !== undefined ? over.impairRatio   : b.impairRatio,
    rmRatio:       over.rmRatio       !== undefined ? over.rmRatio       : b.rmRatio,
  };
  const scored = {};
  Object.keys(AX_BANDS).forEach(k=> scored[k] = {value:v[k], score:axScore(k,v[k]), band:AX_BANDS[k]});
  return {b, v, scored};
}
function axFactorScores(over){
  const m = axMetrics(over), out = {};
  Object.keys(AX_FACTORS).forEach(f=>{
    const mem = Object.entries(m.scored).filter(([,d])=>d.band.f===f);
    let s = mem.length ? mem.reduce((a,[,d])=>a+d.score,0)/mem.length : 4;
    if(f==='finman') s = clamp(s + (AX_AUDIT_PENALTY[axAuditOutcome]||0), 1, 6);
    out[f] = {score: s, members: mem};
  });
  return {factors:out, metrics:m};
}
const AX_IF = [
  ['Predictability of the framework','Local government powers, functions and the equitable share formula are constitutionally entrenched under sections 214 and 227, and the division of revenue is legislated annually. Changes are signalled well in advance.',3],
  ['Revenue and expenditure balance','Own revenue powers over rates and trading services are real, but the cost of bulk purchases is set by a national regulator and the wage bill by a national bargaining council. The municipality carries cost it does not set.',4],
  ['Transparency and accountability','The MFMA imposes a strong reporting regime with audited statements, in-year reporting and a statutory audit. Enforcement of consequence management is weak and inconsistently applied.',4],
  ['System of support and intervention','Sections 139 and 216 provide for provincial and national intervention, but there is no standing bailout mechanism and recent practice has been to impose conditions rather than to fund.',5],
  ['Capacity to manage debt','Municipal borrowing is regulated under MFMA chapter 6 with no national guarantee. Market access exists for the larger entities and is thin below metro scale.',4],
];
const AX_IF_SCORE = AX_IF.reduce((s,r)=>s+r[2],0)/AX_IF.length;

/* indicative credit level matrix: rows = rounded ICP 1..6, cols = IF 1..6 */
const AX_LEVELS = ['aaa','aa+','aa','aa-','a+','a','a-','bbb+','bbb','bbb-','bb+','bb','bb-','b+','b','b-','ccc','cc'];
const AX_MATRIX = [
 ['aaa','aa+','aa' ,'aa-','a+' ,'a'  ],
 ['aa' ,'aa-','a+' ,'a'  ,'a-' ,'bbb'],
 ['a+' ,'a'  ,'a-' ,'bbb','bb+','bb' ],
 ['a-' ,'bbb','bbb-','bb+','bb' ,'bb-'],
 ['bbb-','bb+','bb','bb-','b+' ,'b'  ],
 ['bb' ,'bb-','b+' ,'b'  ,'b-' ,'ccc'],
];
function axICP(over){
  const {factors} = axFactorScores(over);
  let icp = 0; Object.entries(AX_FACTORS).forEach(([k,f])=> icp += factors[k].score * f.w);
  return {icp, factors};
}
function axRate(over){
  const {icp, factors} = axICP(over);
  const r = clamp(Math.round(icp),1,6), c = clamp(Math.round(AX_IF_SCORE),1,6);
  const indicative = AX_MATRIX[r-1][c-1];
  let i = AX_LEVELS.indexOf(indicative);
  const steps = [];
  /* overrides */
  const m = axMetrics(over);
  const liqOverride = m.v.liquidityCover < 0.8;
  if(liqOverride){ steps.push(['Liquidity override','Free cash covers less than 0.8 times debt service falling due within twelve months',3]); i += 3; }
  const debtOverride = m.v.debtRatio > 1.2;
  if(debtOverride){ steps.push(['Debt override','Direct debt exceeds 120% of operating revenue',2]); i += 2; }
  const sacpIdx = clamp(i,0,AX_LEVELS.length-1);
  const sacp = AX_LEVELS[sacpIdx];
  /* sovereign ceiling — an LRG is not normally rated above the sovereign */
  const sovIdx = AX_LEVELS.indexOf(AX.sovereign.toLowerCase());
  const capped = sacpIdx < sovIdx;
  const finalIdx = capped ? sovIdx : sacpIdx;
  return {icp, factors, indicative, sacp, sacpIdx, steps, capped, sovIdx, finalIdx,
          icr: AX_LEVELS[finalIdx].toUpperCase(),
          ifScore: AX_IF_SCORE, r, c};
}
const AX_NAT = {'BB-':'A+(ZA)','BB':'AA-(ZA)','BB+':'AA(ZA)','B+':'BBB+(ZA)','B':'BBB(ZA)','BBB-':'AA+(ZA)','BBB':'AAA(ZA)','CCC':'BB(ZA)','CC':'B(ZA)','B-':'BBB-(ZA)','BBB+':'AAA(ZA)','A-':'AAA(ZA)'};
const AX_MOODY = {'BB-':'Ba3','BB':'Ba2','BB+':'Ba1','B+':'B1','B':'B2','B-':'B3','BBB-':'Baa3','BBB':'Baa2','CCC':'Caa1','CC':'Caa3','A-':'A3','BBB+':'Baa1'};
const AX_SPREAD = {'BBB':185,'BBB-':215,'BB+':255,'BB':300,'BB-':355,'B+':430,'B':520,'B-':640,'CCC':850,'CC':1100,'A-':150,'BBB+':165};

/* ------------------------------------------------------------------
   2. SCENARIO ENGINE — near term (0-36 months)
   ------------------------------------------------------------------ */
const AX_LEVERS = [
 {k:'coll',   g:'Revenue',   lbl:'Collection rate',                 min:-12, max:2,  step:0.5, val:0, unit:'pts', base:()=>AX.collection*100,
  why:'Households and businesses that are billed but do not pay. The single largest controllable variable in most South African municipalities.'},
 {k:'elecVol',g:'Revenue',   lbl:'Electricity sales volume',        min:-25, max:5,  step:1,   val:0, unit:'%',  base:()=>0,
  why:'Grid defection by large users and rooftop solar. Volume leaves, the bulk purchase obligation and the network cost largely do not.'},
 {k:'waterVol',g:'Revenue',  lbl:'Water volume — restriction',      min:-35, max:5,  step:1,   val:0, unit:'%',  base:()=>0,
  why:'Drought restrictions cut billable volume while the fixed cost of the network and the bulk supply agreement continue.'},
 {k:'eqShare',g:'Revenue',   lbl:'Equitable share allocation',      min:-15, max:5,  step:1,   val:0, unit:'%',  base:()=>0,
  why:'A national fiscal consolidation reduces the unconditional transfer. Constitutionally protected in principle, reduced in practice through the formula.'},
 {k:'grantRoll',g:'Revenue', lbl:'Conditional grant reversed',      min:0,   max:100,step:5,   val:0, unit:'%',  base:()=>0,
  why:'Unspent conditional grant surrendered to the National Revenue Fund under section 22 of the Division of Revenue Act.'},
 {k:'tariffGap',g:'Cost',    lbl:'Bulk cost above tariff increase', min:0,   max:10, step:0.5, val:0, unit:'pts',base:()=>0,
  why:'The gap between the NERSA bulk increase and the retail tariff the council approves. Every point is margin the municipality absorbs.'},
 {k:'wage',   g:'Cost',      lbl:'Wage settlement above budget',    min:0,   max:10, step:0.5, val:0, unit:'pts',base:()=>0,
  why:'The SALGBC agreement is concluded nationally and is not optional for the municipality.'},
 {k:'rates',  g:'Cost',      lbl:'Interest rate movement',          min:-200,max:500,step:25,  val:0, unit:'bps',base:()=>AX.repo*10000,
  why:'Applies to the floating portion of the debt book and to new borrowing drawn in the period.'},
 {k:'impair', g:'Cost',      lbl:'Impairment on receivables',       min:0,   max:20, step:1,   val:0, unit:'pts',base:()=>0,
  why:'A rise in the provision against debtors. Non-cash, but it destroys the surplus and signals that billed revenue will not arrive.'},
 {k:'maint',  g:'Delivery',  lbl:'Repairs and maintenance',         min:-50, max:60, step:5,   val:0, unit:'%',  base:()=>0,
  why:'Deferring maintenance protects cash now and adds to the renewal backlog. The saving is real and so is the consequence.'},
 {k:'capex',  g:'Delivery',  lbl:'Capital programme delivery',      min:-60, max:20, step:5,   val:0, unit:'%',  base:()=>100,
  why:'Under-delivery preserves cash but forfeits grant, slows the asset base and is itself an audit and grant-compliance risk.'},
 {k:'event',  g:'Delivery',  lbl:'Unforeseen event cost',           min:0,   max:150000,step:5000,val:0,unit:'R\u2019000',base:()=>0,
  why:'A declared disaster, a major plant failure or an adverse judgment. Sixty per cent typically lands on the municipality.'},
];
const AX_LEVER_VALS = Object.fromEntries(AX_LEVERS.map(l=>[l.k,0]));
function axScenario(vals){
  const v = vals || AX_LEVER_VALS;
  const b = axBase(), t = b.t;
  const billed = {rates:L('4100'), elec:L('4110'), water:L('4120'), san:L('4130'), refuse:L('4140')};
  /* revenue movements */
  const dColl   = (v.coll/100) * (billed.rates+billed.elec+billed.water+billed.san+billed.refuse);
  const dElec   = (v.elecVol/100) * billed.elec;
  const dWater  = (v.waterVol/100) * (billed.water+billed.san);
  const dEq     = (v.eqShare/100) * L('4190');
  const dGrant  = -(v.grantRoll/100) * L('2120');
  const dRev    = dElec + dWater + dEq + dGrant;          /* accrual revenue */
  /* cost movements */
  const dBulk   = (v.tariffGap/100) * L('5150');
  const dWage   = (v.wage/100) * (L('5100')+L('5110'));
  const dInt    = (v.rates/10000) * b.debt * 0.42;        /* 42% of the book floats */
  const dImp    = (v.impair/100) * b.receivables;
  const dMaint  = (v.maint/100) * L('5180');
  const dEvent  = v.event * 0.60;                          /* municipality's share */
  const dExp    = dBulk + dWage + dInt + dImp + dMaint + dEvent;
  const dCapex  = (v.capex/100) * b.capex;
  /* results */
  const revenue = t.revenue + dRev;
  const expend  = t.expenditure + dExp;
  const surplus = revenue - expend;
  const cashOpex= b.cashOpex + dBulk + dWage + dEvent + dMaint;
  const cash    = b.cash + (surplus - t.surplus) + dColl - dCapex + (b.depn>0? 0:0) - dImp*0 + dImp;   /* impairment is non-cash */
  const freeCash= cash - b.restricted;
  const debt    = b.debt;
  const daysCash= cashOpex? freeCash/(cashOpex/365) : 0;
  const collection = AX.collection + v.coll/100;
  const capex   = b.capex + dCapex;
  return {b, t, v, dColl,dElec,dWater,dEq,dGrant,dRev,dBulk,dWage,dInt,dImp,dMaint,dEvent,dExp,dCapex,
    revenue, expend, surplus, cash, freeCash, cashOpex, daysCash, debt, collection, capex,
    opMargin: revenue? surplus/revenue : 0,
    debtRatio: (revenue - L('4195'))? debt/(revenue-L('4195')) : 0,
    dsCover: b.debtService? (surplus + b.depn + L('5140') + dInt)/(b.debtService+dInt) : 0,
    liquidityCover: b.debtService? freeCash/(b.debtService+dInt) : 0,
    renewalRatio: b.depn? capex/b.depn : 0,
    impairRatio: b.receivables? (L('5120')+dImp)/b.receivables : 0,
    rmRatio: L('1000')? (L('5180')+dMaint)/L('1000') : 0,
    touched: AX_LEVERS.filter(l=>v[l.k]!==0).length};
}
function axScenarioRating(s){
  return axRate({opMargin:s.opMargin, daysCash:s.daysCash, liquidityCover:s.liquidityCover,
                 debtRatio:s.debtRatio, collection:s.collection, impairRatio:s.impairRatio,
                 rmRatio:s.rmRatio,
                 balAfterCapex: s.revenue? (s.surplus + s.b.depn - s.capex)/s.revenue : 0,
                 interestRatio: (s.revenue-L('4195'))? (L('5140')+s.dInt)/(s.revenue-L('4195')) : 0});
}
const AX_LIBRARY = [
 {k:'base',  n:'Base case',            d:'The approved budget executed as tabled.', set:{}},
 {k:'nersa', n:'NERSA tariff shock',   d:'The bulk electricity increase exceeds the approved retail tariff by six points and volume falls as large users respond.', set:{tariffGap:6, elecVol:-8}},
 {k:'drought',n:'Drought restriction', d:'Level 4 restrictions cut billable water volume by a quarter for a full year; the bulk supply take-or-pay obligation continues.', set:{waterVol:-25, tariffGap:2}},
 {k:'defect',n:'Grid defection',       d:'Rooftop solar and wheeling take a fifth of electricity volume, concentrated in the highest-paying customer segment.', set:{elecVol:-20, coll:-2}},
 {k:'fiscal',n:'National consolidation',d:'The equitable share is cut by eight per cent and conditional grant rollovers are not approved.', set:{eqShare:-8, grantRoll:100}},
 {k:'wage',  n:'Wage settlement',      d:'The bargaining council settles five points above the budgeted increase, backdated.', set:{wage:5}},
 {k:'collapse',n:'Collection collapse',d:'Payment behaviour deteriorates by eight points and the impairment provision follows it up.', set:{coll:-8, impair:8}},
 {k:'disaster',n:'Disaster year',      d:'A declared storm and flood event costing R84.6 million, of which the municipality carries sixty per cent.', set:{event:84600, maint:25, capex:-20}},
 {k:'storm', n:'Perfect storm',        d:'Tariff gap, collection deterioration, a rate rise and a disaster in the same financial year.', set:{tariffGap:5, coll:-6, rates:300, event:60000, elecVol:-10, impair:6}},
 {k:'recover',n:'Recovery plan',       d:'Collection improves two points, maintenance rises to the norm and the capital programme delivers in full.', set:{coll:2, maint:35, capex:0}},
];

/* ------------------------------------------------------------------
   2b. LONG-RUN SUSTAINABILITY — 10 years, seeded by the scenario
   ------------------------------------------------------------------ */
const AX_LR = {years:10, revGrowth:0.055, costGrowth:0.062, popGrowth:0.017,
               tariffCeiling:0.09, renewalNorm:1.0, backlogOpen:640000};
function axLongRun(seeded){
  const s = seeded ? axScenario() : null;
  const b = axBase();
  const open = {
    cash:   s ? s.cash   : b.cash,
    debt:   s ? s.debt   : b.debt,
    revenue:s ? s.revenue: b.t.revenue,
    expend: s ? s.expend : b.t.expenditure,
    ppe:    L('1000'),
    capex:  s ? s.capex  : b.capex,
    backlog:AX_LR.backlogOpen + (s ? Math.max(0, b.depn - s.capex) : 0),
  };
  const rows = [];
  let cash=open.cash, debt=open.debt, rev=open.revenue, exp=open.expend, ppe=open.ppe,
      baseCapex=open.capex, backlog=open.backlog, pop=AX.pop;
  for(let y=1; y<=AX_LR.years; y++){
    rev  *= (1+AX_LR.revGrowth);
    exp  *= (1+AX_LR.costGrowth);
    pop  *= (1+AX_LR.popGrowth);
    baseCapex *= (1+AX_LR.revGrowth*0.7);
    const depn = ppe*0.0405;
    const surplus = rev - exp;
    const newDebt = Math.max(0, baseCapex*0.30);
    debt = debt*0.92 + newDebt;
    let cash1 = cash + surplus + depn - baseCapex + newDebt - debt*0.08;
    /* Treasury policy, and the constraint on it. Cash above ninety days of operating
       cost is deployed against the renewal backlog rather than left to accumulate —
       but no municipality can absorb more than about forty per cent above its existing
       programme in a year. Capacity to spend is the binding constraint, not money. */
    const target  = (exp-depn)/365*90;
    const absorb  = baseCapex*((AX_LR.absorb||40)/100);
    const deploy  = clamp(Math.min(cash1-target, absorb), 0, backlog);
    const capex   = baseCapex + deploy;
    cash = cash1 - deploy;
    backlog = Math.max(0, backlog - deploy + Math.max(0, depn - capex));
    ppe  = ppe + capex - depn;
    rows.push({y, rev, exp, surplus, depn, capex, debt, cash, ppe, backlog, pop, deploy,
      renewal: depn? capex/depn : 0,
      margin: rev? surplus/rev : 0,
      debtRatio: rev? debt/rev : 0,
      daysCash: exp? cash/((exp-depn)/365) : 0,
      naPerCap: pop? (ppe+cash-debt)/pop*1000 : 0,
      revPerHh: rev/ (AX.households*(pop/AX.pop)) *1000});
  }
  return {open, rows, seeded:!!s};
}

/* ------------------------------------------------------------------
   3. TREASURY CONTROL TOWER
   ------------------------------------------------------------------ */
const AX_REVCTL = {
 '4100':['Own','Council sets the rate in the rates policy under the Municipal Property Rates Act',0.962],
 '4110':['Own','Retail tariff approved by council, bulk price set by NERSA',0.978],
 '4120':['Own','Tariff approved by council, bulk price set by the water board',0.971],
 '4130':['Own','Tariff approved by council',0.968],
 '4140':['Own','Tariff approved by council',0.955],
 '4150':['Own','Lease agreements',0.930],
 '4160':['Own','Interest on the investment portfolio',1.000],
 '4170':['Own','Set by by-law, realisation is poor',0.410],
 '4180':['Own','Set by by-law',0.990],
 '4190':['Transfer','Division of Revenue Act — formula driven, not negotiable',1.000],
 '4195':['Transfer','Division of Revenue Act — conditional, reverts if unspent',1.000],
 '4199':['Own','Sundry',0.900],
};
const AX_EXPNAT = {
 '5100':['Committed','Contractual, set by the national bargaining council',1],
 '5110':['Committed','Determined by the Minister by notice',1],
 '5120':['Non-cash','Accounting estimate against receivables',0],
 '5130':['Non-cash','Consumption of the asset base',0],
 '5140':['Committed','Contractual debt service',1],
 '5150':['Committed','Bulk supply agreements, take-or-pay in parts',1],
 '5160':['Discretionary','Contracted services, cancellable at notice',1],
 '5170':['Discretionary','Transfers made by council resolution',1],
 '5180':['Discretionary','Deferrable in year, costly over time',1],
 '5190':['Discretionary','General expenses',1],
};
function axLadder(){
  const b = axBase();
  const cash=b.cash, recv=b.receivables, inv=L('1100');
  const buckets = [
   {k:'0-3 months',   in: cash*0.62 + recv*0.28,           out: L('2100')*0.70 + L('2130') + L('2110')*0.25 + b.cashOpex*0.25},
   {k:'3-12 months',  in: cash*0.30 + recv*0.34 + inv*0.6, out: L('2100')*0.30 + L('2120') + L('2110')*0.75 + b.cashOpex*0.35},
   {k:'1-3 years',    in: cash*0.08 + recv*0.22,           out: L('2000')*0.22 + L('2010')*0.10},
   {k:'3-5 years',    in: recv*0.10,                       out: L('2000')*0.24 + L('2010')*0.14},
   {k:'5-10 years',   in: recv*0.06 + inv*0.4,             out: L('2000')*0.34 + L('2010')*0.30},
   {k:'Over 10 years',in: L('1000')*0.02,                  out: L('2000')*0.20 + L('2010')*0.46},
  ];
  let cum=0;
  return buckets.map(x=>{ const gap=x.in-x.out; cum+=gap; return {...x, gap, cum, cover: x.out? x.in/x.out : 9}; });
}
function axCascade(){
  const b = axBase();
  const billed = L('4100')+L('4110')+L('4120')+L('4130')+L('4140');
  const collected = billed*AX.collection;
  const nonPayment = billed - collected;
  const bulk = L('5150');
  const afterBulk = collected - bulk;
  const employee = L('5100')+L('5110');
  const afterPeople = afterBulk - employee;
  const otherOps = L('5160')+L('5170')+L('5190');
  const afterOps = afterPeople - otherOps;
  const finance = L('5140');
  const afterFinance = afterOps - finance;
  const maint = L('5180');
  const afterMaint = afterFinance - maint;
  const capexOwn = Math.max(0, b.capex - L('4195'));
  const renewed = Math.min(afterMaint, capexOwn);
  return [
   {n:1, t:'Billed to ratepayers and consumers', s:'Rates and the four trading services, as raised on the billing masterfile', amt:billed, leak:0, leakN:'', own:'Revenue module'},
   {n:2, t:'Collected in cash', s:`At ${pctT(AX.collection)} collection`, amt:collected, leak:nonPayment, leakN:'Non-payment and indigent relief', own:'Revenue & credit control'},
   {n:3, t:'After bulk purchases', s:'Eskom and the water board are paid before anything else', amt:afterBulk, leak:bulk, leakN:'Bulk purchases', own:'Treasury — bulk contracts'},
   {n:4, t:'After the wage bill', s:'Employees and councillors', amt:afterPeople, leak:employee, leakN:'Employee related cost', own:'Corporate services'},
   {n:5, t:'After other operating cost', s:'Contracted services, transfers made, general expenses', amt:afterOps, leak:otherOps, leakN:'Other operating expenditure', own:'Departments'},
   {n:6, t:'After debt service', s:'Interest on the borrowing book', amt:afterFinance, leak:finance, leakN:'Finance cost', own:'Treasury — debt'},
   {n:7, t:'Available for asset renewal', s:'What is left to keep the infrastructure that generates the billing', amt:afterMaint, leak:maint, leakN:'Repairs and maintenance', own:'Infrastructure'},
   {n:8, t:'Actually reinvested from own funds', s:'Capital spend net of conditional grant funding', amt:renewed, leak:Math.max(0,afterMaint-renewed), leakN:'Retained, not reinvested', own:'Council — capital budget'},
  ];
}
const AX_RATIOS = [
 ['Operating surplus margin','Performance', i=>{const t=totalsAt(i);return t.revenue?t.surplus/t.revenue:0;}, 'pct', 0.00, 'hi', '≥ 0%'],
 ['Own revenue share','Performance', i=>{const t=totalsAt(i);return t.revenue?(t.revenue-LA('4190',i)-LA('4195',i))/t.revenue:0;}, 'pct', 0.50, 'hi', '≥ 50%'],
 ['Collection rate','Performance', ()=>AX.collection, 'pct', 0.95, 'hi', '≥ 95%'],
 ['Current ratio','Liquidity', i=>{const ca=sumAt('ca',i),cl=sumAt('cl',i);return cl?ca/cl:0;}, 'x', 1.5, 'hi', '1.5 – 2.0'],
 ['Days cash on hand','Liquidity', i=>{const t=totalsAt(i);const co=t.expenditure-LA('5130',i)-LA('5120',i);return co?(LA('1130',i)-LA('2120',i))/(co/365):0;}, 'd', 30, 'hi', '≥ 30 days'],
 ['Cost coverage','Liquidity', i=>{const t=totalsAt(i);const co=(t.expenditure-LA('5130',i)-LA('5120',i))/12;return co?(LA('1130',i)-LA('2120',i))/co:0;}, 'x', 1, 'hi', '1 – 3 months'],
 ['Debt to operating revenue','Debt', i=>{const t=totalsAt(i);const orv=t.revenue-LA('4195',i);return orv?(LA('2000',i)+LA('2110',i))/orv:0;}, 'pct', 0.45, 'lo', '≤ 45%'],
 ['Debt service to operating revenue','Debt', i=>{const t=totalsAt(i);const orv=t.revenue-LA('4195',i);return orv?(LA('5140',i)+LA('2110',i))/orv:0;}, 'pct', 0.08, 'lo', '6 – 8%'],
 ['Interest cover','Debt', i=>{const t=totalsAt(i);return LA('5140',i)?(t.surplus+LA('5130',i)+LA('5140',i))/LA('5140',i):0;}, 'x', 2, 'hi', '≥ 2.0x'],
 ['Creditor payment days','Working capital', i=>{const t=totalsAt(i);return t.expenditure?LA('2100',i)/((t.expenditure)/365):0;}, 'd', 30, 'lo', '≤ 30 days'],
 ['Debtor days','Working capital', i=>{const t=totalsAt(i);return t.revenue?(LA('1110',i)+LA('1120',i))/(t.revenue/365):0;}, 'd', 60, 'lo', '≤ 60 days'],
 ['Impairment to receivables','Working capital', i=>{const r=LA('1110',i)+LA('1120',i);return r?LA('5120',i)/r:0;}, 'pct', 0.16, 'lo', '≤ 16%'],
 ['Capital renewal to depreciation','Asset', i=>{const c=i===2?162600:i===1?151000:138000;return LA('5130',i)?c/LA('5130',i):0;}, 'x', 1.0, 'hi', '≥ 1.0x'],
 ['Repairs and maintenance to infrastructure','Asset', i=>{return LA('1000',i)?LA('5180',i)/LA('1000',i):0;}, 'pct', 0.08, 'hi', '≥ 8%'],
 ['Capital spend to total spend','Asset', i=>{const t=totalsAt(i);const c=i===2?162600:i===1?151000:138000;return (t.expenditure+c)?c/(t.expenditure+c):0;}, 'pct', 0.10, 'hi', '≥ 10%'],
];
function axRatioFmt(v,k){ return k==='pct'?pctT(v):k==='x'?AX_N(v,2)+'x':k==='d'?AX_N(v,0)+' d':fmt(v); }

/* ==================== shared chart helpers ==================== */
function axChart(el, cols, readEl, fmtRead){
  const max = Math.max(1, ...cols.flatMap(c=>c.bars.map(b=>Math.abs(b.v))));
  el.innerHTML = cols.map((c,i)=>`<div class="col" data-ci="${i}">
     <div class="bars">${c.bars.map(b=>`<div class="bar ${b.cls}" style="height:${Math.max(2,(Math.abs(b.v)/max)*150)}px"></div>`).join('')}</div>
     <div class="xl">${c.label}</div></div>`).join('');
  if(readEl){
    const def = readEl.dataset.def || readEl.textContent;
    readEl.dataset.def = def;
    el.querySelectorAll('.col').forEach(col=>{
      col.addEventListener('mouseenter',()=>{ readEl.innerHTML = fmtRead(cols[+col.dataset.ci]); });
      col.addEventListener('click',()=>{ readEl.innerHTML = fmtRead(cols[+col.dataset.ci]); });
    });
    el.addEventListener('mouseleave',()=>{ readEl.innerHTML = def; });
  }
}
function axLineChart(svg, series, labels, readEl, fmtRead, threshold){
  const W=820,H=200,pad={l:58,r:12,t:12,b:22};
  const all = series.flatMap(s=>s.data).concat(threshold!==undefined?[threshold]:[]);
  const lo = Math.min(0,...all), hi = Math.max(...all)*1.06 || 1;
  const x = i => pad.l + i*((W-pad.l-pad.r)/Math.max(1,labels.length-1));
  const y = v => H-pad.b - ((v-lo)/((hi-lo)||1))*(H-pad.t-pad.b);
  let g = '';
  for(let i=0;i<=4;i++){ const v = lo+(hi-lo)*i/4;
    g += `<line class="gridline" x1="${pad.l}" x2="${W-pad.r}" y1="${y(v)}" y2="${y(v)}"/>
          <text x="${pad.l-6}" y="${y(v)+3}" text-anchor="end">${fmt(v/1000)}m</text>`; }
  if(threshold!==undefined) g += `<line x1="${pad.l}" x2="${W-pad.r}" y1="${y(threshold)}" y2="${y(threshold)}" stroke="#c0524f" stroke-width="1.5" stroke-dasharray="5 4"/>`;
  series.forEach(s=>{
    g += `<polyline fill="none" stroke="${s.col}" stroke-width="${s.w||2}" ${s.dash?'stroke-dasharray="5 4"':''}
           points="${s.data.map((v,i)=>x(i)+','+y(v)).join(' ')}"/>`;
  });
  labels.forEach((lb,i)=>{ g += `<text x="${x(i)}" y="${H-6}" text-anchor="middle">${lb}</text>`; });
  series.forEach(s=> s.data.forEach((v,i)=>{ g += `<circle class="dot" cx="${x(i)}" cy="${y(v)}" r="9" fill="transparent" data-i="${i}"/>
    <circle cx="${x(i)}" cy="${y(v)}" r="2.6" fill="${s.col}"/>`; }));
  svg.innerHTML = g;
  if(readEl){
    const def = readEl.dataset.def || readEl.textContent; readEl.dataset.def = def;
    svg.querySelectorAll('.dot').forEach(d=> d.addEventListener('mouseenter',()=> readEl.innerHTML = fmtRead(+d.dataset.i)));
    svg.addEventListener('mouseleave',()=> readEl.innerHTML = def);
  }
}
function axExpandable(tbody){
  tbody.querySelectorAll('tr.click').forEach(tr=> tr.addEventListener('click',()=>{
    const det = tbody.querySelector(`tr[data-det="${tr.dataset.key}"]`);
    if(det) det.style.display = det.style.display==='none' ? '' : 'none';
  }));
}

/* ==================== 1. CREDIT RENDERERS ==================== */
let axSens = {};
function renderCredit(){
  const R = axRate(), m = axMetrics(), b = R.factors ? axBase() : axBase();
  const nat = AX_NAT[R.icr]||'—', mo = AX_MOODY[R.icr]||'—';
  if(state.mode===1 && !hasManualData()){
    document.getElementById('crKpis').innerHTML = kpiSet([['Credit model','Awaiting data','',
      'Mode 1 reads the loaded ledger. With nothing loaded there is no revenue base to score against, so no rating is produced rather than a misleading one.']]);
  } else {
  document.getElementById('crKpis').innerHTML = kpiSet([
    ['Stand-alone credit profile', R.sacp.toUpperCase(), R.sacpIdx<=9?'good':R.sacpIdx<=12?'warn':'bad',
      'What the municipality itself earns, before the sovereign ceiling. This is the number management can move.'],
    ['Issuer credit rating', R.icr, R.capped?'warn':'', R.capped
      ? `Held at the sovereign ceiling of ${AX.sovereign} — the profile is ${Math.abs(R.sacpIdx-R.sovIdx)} notches stronger than the rating can express`
      : `Outlook ${axOutlook().dir} · the profile is the binding constraint`],
    ['National scale', nat, '', `Moody\u2019s equivalent ${mo} · what a domestic issue prices from`],
    ['Individual credit profile', AX_N(R.icp,2), R.icp<=3?'good':R.icp<=4?'warn':'bad',
      `Weighted average of seven factors, 1 strongest to 6 weakest · framework ${AX_N(R.ifScore,2)}`],
  ]); }

  /* ladder */
  const cur = AX_LEVELS.indexOf(R.icr.toLowerCase());
  document.getElementById('crLadder').innerHTML = AX_LEVELS.slice(3,17).map(l=>{
    const i = AX_LEVELS.indexOf(l);
    const here = i===cur, sac = i===R.sacpIdx && i!==cur, sov = i===R.sovIdx && !here && !sac;
    const grade = i<=9 ? 'Investment grade' : i<=15 ? 'Speculative grade' : 'Substantial risk';
    return `<div class="rung ${here?'here':''} ${sov?'sov':''} ${sac?'band':''}"><b>${l.toUpperCase()}</b>
      <span>${here?'Issuer credit rating — where the municipality is actually rated'
        :sac?'Stand-alone credit profile — what the municipality earns on its own'
        :sov?'Sovereign ceiling — Republic of South Africa':grade}</span></div>`;
  }).join('');
  document.getElementById('crLadderNote').innerHTML =
    `The sovereign sits at <b>${AX.sovereign}</b>. A local government is not normally rated above its sovereign, because it depends on the same
     currency, the same transfer system and the same macroeconomy. ${R.capped
      ? `The stand-alone profile of <b>${R.sacp.toUpperCase()}</b> is stronger than the sovereign, so the rating is held at the ceiling.`
      : `The stand-alone profile of <b>${R.sacp.toUpperCase()}</b> sits at or below the ceiling, so no cap is applied.`}`;

  /* build-up */
  document.getElementById('crBuildTag').textContent = `${PERIODS[state.period]} · ${modeName()}`;
  const rows = [
    ['Institutional framework','The legal and fiscal environment the municipality operates in, assessed on five dimensions', AX_N(R.ifScore,2), 'Combined with the profile in the matrix'],
    ['Individual credit profile','Weighted average of economy, financial management, budgetary flexibility, budgetary performance, liquidity, debt burden and contingent liabilities', AX_N(R.icp,2), `Rounds to ${R.r}`],
    ['Indicative credit level','Read off the matrix at profile ' + R.r + ' against framework ' + R.c, R.indicative.toUpperCase(), 'Before any override'],
  ];
  R.steps.forEach(st=> rows.push([st[0], st[1], '−'+st[2]+' notches', 'Applied']));
  rows.push(['Stand-alone credit profile','Indicative level after overrides', R.sacp.toUpperCase(), R.capped?'Above the sovereign':'Within the ceiling']);
  rows.push(['Extraordinary support','Likelihood of timely support from national government if the municipality could not service its debt', 'Low', 'No uplift — there is no standing bailout mechanism']);
  rows.push(['Issuer credit rating','After the sovereign ceiling test', R.icr, R.capped?'Capped':'Unconstrained']);
  document.querySelector('#crBuild tbody').innerHTML = rows.map((r,i)=>
    `<tr class="${i===rows.length-1?'total':''}"><td><b>${r[0]}</b></td><td style="color:var(--muted)">${r[1]}</td>
      <td class="num">${r[2]}</td><td>${r[3]}</td></tr>`).join('');

  /* strengths / weaknesses derived from factor scores */
  const fs = Object.entries(R.factors).map(([k,v])=>({k, ...v, ...AX_FACTORS[k]})).sort((a,b2)=>a.score-b2.score);
  const li = a => a.map(f=>`<div style="margin-bottom:9px"><b style="font-size:11.5px;color:var(--navy-dark)">${f.lbl} — ${AX_N(f.score,2)}</b>
      <div style="font-size:11px;color:var(--muted);line-height:1.55">${f.why}</div>
      <div style="font-size:10.5px;margin-top:3px">${f.members.map(([mk,md])=>`<span class="ax-tag">${AX_BANDS[mk].lbl.split('÷')[0].trim()} ${axFmtMetric(mk,md.value)}</span>`).join('')}</div></div>`).join('');
  document.getElementById('crStrengths').innerHTML = li(fs.slice(0,3));
  document.getElementById('crWeak').innerHTML = li(fs.slice(-3).reverse());

  /* factor chart */
  const cols = Object.entries(AX_FACTORS).map(([k,f])=>({
    label:f.lbl.split(' ')[0], key:k, f, sc:R.factors[k].score,
    bars:[{v:R.factors[k].score*f.w, cls:'b-navy'},{v:f.w*6, cls:'b-grey'}]}));
  axChart(document.getElementById('crFactorChart'), cols, document.getElementById('crFactorRead'),
    c=>`<b>${c.f.lbl}</b> — score ${AX_N(c.sc,2)} at a weight of ${pctT(c.f.w,0)}, contributing ${AX_N(c.sc*c.f.w,2)} of the ${AX_N(R.icp,2)} profile.<br/>${c.f.why}
        <br/><span style="color:var(--muted)">Measured by: ${R.factors[c.key].members.map(([mk])=>AX_BANDS[mk].lbl).join('; ')||'qualitative assessment'}.</span>`);

  /* outlook triggers */
  const o = axOutlook();
  document.getElementById('crOutlookTag').textContent = `Outlook ${o.dir} — ${o.why}`;
  document.querySelector('#crTriggers tbody').innerHTML = o.triggers.map(t=>
    `<tr><td><span class="pill ${t.up?'GREEN':'RED'}">${t.up?'Upgrade':'Downgrade'}</span></td>
      <td>${t.n}<span class="ax-src">${t.s}</span></td><td class="num">${t.cur}</td><td class="num">${t.th}</td>
      <td class="num" style="color:${t.near?'var(--red)':'inherit'}">${t.dist}</td></tr>`).join('');

  renderScorecard(R); renderMetrics(); renderNotch(R); renderSens(); renderPeers(); renderMethod();
}
function axFmtMetric(k,v){
  const u = AX_BANDS[k].unit;
  return u==='%' ? pctT(v) : u==='d' ? AX_N(v,0)+' days' : u==='x' ? AX_N(v,2)+'x' : u==='R' ? 'R'+fmt(v) : fmt(v);
}
function axOutlook(){
  const R = axRate(), m = axMetrics(), b = axBase();
  const trig = [
    {up:true,  n:'A clean or unqualified audit outcome sustained for two years', s:'Financial management carries a penalty of ' + AX_N(AX_AUDIT_PENALTY[axAuditOutcome],1) + ' scoring points while the opinion is ' + axAuditOutcome.toLowerCase(),
     cur:axAuditOutcome, th:'Unqualified', dist:'—', near:false},
    {up:true,  n:'Collection sustained above 97%', s:'Feeds financial management and, through cash, liquidity',
     cur:pctT(AX.collection), th:'97.0%', dist:pctT(AX.collection-0.97), near:false},
    {up:false, n:'Free cash below 30 days of operating cost', s:'Would take liquidity to a 4 and trigger the liquidity override at 0.8 times debt service',
     cur:AX_N(m.v.daysCash,0)+' d', th:'30 d', dist:AX_N(m.v.daysCash-30,0)+' d', near:m.v.daysCash<60},
    {up:false, n:'Direct debt above 60% of operating revenue', s:'Moves the debt burden factor from 3 to 4',
     cur:pctT(m.v.debtRatio), th:'60.0%', dist:pctT(0.60-m.v.debtRatio), near:m.v.debtRatio>0.5},
    {up:false, n:'Operating balance turns negative', s:'Budgetary performance falls from 1 to 4 in a single step',
     cur:pctT(m.v.opMargin), th:'0.0%', dist:pctT(m.v.opMargin), near:m.v.opMargin<0.03},
    {up:false, n:'A downgrade of the Republic of South Africa', s:'The ceiling moves with the sovereign whatever the municipality does',
     cur:AX.sovereign, th:AX.sovereign, dist:R.capped?'Binding now':'Not binding', near:R.capped},
  ];
  const bad = trig.filter(t=>!t.up && t.near).length;
  return {dir: bad>=2?'Negative':bad===1?'Stable, weakening':'Stable',
          why: bad>=2?'two or more downgrade triggers are within reach':'no downgrade trigger is close',
          triggers:trig};
}
function renderScorecard(R){
  document.querySelector('#crIF tbody').innerHTML = AX_IF.map(r=>
    `<tr><td><b>${r[0]}</b></td><td style="color:var(--muted)">${r[1]}</td>
      <td class="num"><span class="pill ${r[2]<=2?'GREEN':r[2]<=4?'AMBER':'RED'}">${r[2]}</span></td></tr>`).join('')
    + `<tr class="total"><td>Institutional framework assessment</td><td>Simple average of the five dimensions</td><td class="num">${AX_N(AX_IF_SCORE,2)}</td></tr>`;

  document.getElementById('crICPTag').textContent = `Profile ${AX_N(R.icp,2)} · rounds to ${R.r}`;
  const tb = document.querySelector('#crScorecard tbody');
  tb.innerHTML = Object.entries(AX_FACTORS).map(([k,f])=>{
    const d = R.factors[k], sc = d.score;
    const band = sc<=1.5?'Extremely strong':sc<=2.5?'Very strong':sc<=3.5?'Strong':sc<=4.5?'Adequate':sc<=5.5?'Weak':'Extremely weak';
    const rag = sc<=2.5?'GREEN':sc<=4.5?'AMBER':'RED';
    const det = `<tr data-det="${k}" style="display:none;"><td colspan="6" style="background:#fbfcfd;padding:13px 16px">
        <div style="font-size:11px;color:var(--muted);line-height:1.6;margin-bottom:9px">${f.why}</div>
        <table class="grid"><thead><tr><th style="width:38%">Sub-metric</th><th class="num">Value</th><th class="num">Score</th><th>Band it falls in</th></tr></thead><tbody>
        ${d.members.map(([mk,md])=>`<tr><td>${AX_BANDS[mk].lbl}</td><td class="num">${axFmtMetric(mk,md.value)}</td>
           <td class="num">${md.score}</td><td>${axBandText(mk,md.score)}</td></tr>`).join('')
          || '<tr><td colspan="4" style="color:var(--muted)">Assessed qualitatively — see the methodology tab.</td></tr>'}
        ${k==='finman'?`<tr class="total"><td>Audit outcome penalty</td><td class="num">${axAuditOutcome}</td>
           <td class="num">+${AX_N(AX_AUDIT_PENALTY[axAuditOutcome],1)}</td><td>Applied to the averaged sub-metric score</td></tr>`:''}
        </tbody></table></td></tr>`;
    return `<tr class="click" data-key="${k}"><td><b>${f.lbl}</b><span class="ax-src">Click to open the sub-metrics</span></td>
      <td class="num">${pctT(f.w,0)}</td><td style="color:var(--muted);font-size:11px">${d.members.map(([mk])=>AX_BANDS[mk].lbl.split('÷')[0].trim()).join(', ')||'Qualitative'}</td>
      <td class="num">${AX_N(sc,2)}</td><td><span class="pill ${rag}">${band}</span></td><td class="num">${AX_N(sc*f.w,3)}</td></tr>` + det;
  }).join('') + `<tr class="total"><td>Individual credit profile</td><td class="num">100%</td><td></td>
      <td class="num">${AX_N(R.icp,2)}</td><td><span class="pill NAVY">Rounds to ${R.r}</span></td><td class="num">${AX_N(R.icp,3)}</td></tr>`;
  axExpandable(tb);

  /* matrix */
  let h = '<thead><tr><th>Profile ↓ / Framework →</th>' + [1,2,3,4,5,6].map(c=>`<th class="num">${c}</th>`).join('') + '</tr></thead><tbody>';
  AX_MATRIX.forEach((row,ri)=>{
    h += `<tr><td><b>${ri+1}</b></td>` + row.map((cell,ci)=>{
      const here = (ri+1)===R.r && (ci+1)===R.c;
      return `<td class="num" style="${here?'background:var(--navy);color:#fff;font-weight:800':''}">${cell.toUpperCase()}</td>`;
    }).join('') + '</tr>';
  });
  document.getElementById('crMatrix').innerHTML = h + '</tbody>';
  document.getElementById('crMatrixNote').innerHTML =
    `<p class="footnote" style="margin:0">A profile of <b>${AX_N(R.icp,2)}</b> rounds to <b>${R.r}</b>; the framework assessment of <b>${AX_N(AX_IF_SCORE,2)}</b>
     rounds to <b>${R.c}</b>. The cell where they meet gives an indicative credit level of <b>${R.indicative.toUpperCase()}</b>. The framework matters
     more at weaker profiles than at stronger ones, which is why the rows are not evenly spaced — a strong municipality in a poor framework is still
     constrained, but a weak one in a strong framework has somewhere to fall back to.</p>`;
}
function axBandText(k, score){
  const B = AX_BANDS[k]; const u = B.unit;
  const f = v => u==='%'?pctT(v,0):u==='d'?AX_N(v,0):u==='x'?AX_N(v,1)+'x':u==='R'?'R'+fmt(v):fmt(v);
  if(score===1) return `${B.dir==='hi'?'≥':'≤'} ${f(B.b[0])}`;
  if(score===6) return `${B.dir==='hi'?'<':'>'} ${f(B.b[4])}`;
  return B.dir==='hi' ? `${f(B.b[score-1])} to ${f(B.b[score-2])}` : `${f(B.b[score-2])} to ${f(B.b[score-1])}`;
}
let crOnlyFail = false;
function renderMetrics(){
  const m = axMetrics();
  const tb = document.querySelector('#crMetrics tbody');
  let rows = Object.entries(m.scored);
  if(crOnlyFail) rows = rows.filter(([,d])=>d.score>=4);
  tb.innerHTML = rows.map(([k,d])=>{
    const B = AX_BANDS[k], pos = ((6-d.score)/5)*100;
    const rag = d.score<=2?'GREEN':d.score<=4?'AMBER':'RED';
    const det = `<tr data-det="m-${k}" style="display:none;"><td colspan="6" style="background:#fbfcfd;padding:13px 16px">
      <div class="ax-split">
        <div><div style="font-size:11px;font-weight:800;color:var(--navy-dark);margin-bottom:5px">How it is computed</div>
          <table class="grid"><tbody>
            <tr><td>Formula</td><td><code>${axFormula(k)}</code></td></tr>
            <tr><td>Value</td><td class="num">${axFmtMetric(k,d.value)}</td></tr>
            <tr><td>Direction</td><td>${B.dir==='hi'?'Higher is stronger':'Lower is stronger'}</td></tr>
            <tr><td>Factor it scores</td><td>${AX_FACTORS[B.f].lbl} — weight ${pctT(AX_FACTORS[B.f].w,0)}</td></tr>
            <tr class="total"><td>Score</td><td>${d.score} — ${axBandText(k,d.score)}</td></tr>
          </tbody></table></div>
        <div><div style="font-size:11px;font-weight:800;color:var(--navy-dark);margin-bottom:5px">Band table</div>
          <table class="grid"><thead><tr><th class="num">Score</th><th>Threshold</th><th>Assessment</th></tr></thead><tbody>
          ${[1,2,3,4,5,6].map(sc=>`<tr style="${sc===d.score?'background:var(--navy-soft);font-weight:700':''}">
            <td class="num">${sc}</td><td>${axBandText(k,sc)}</td>
            <td>${['Extremely strong','Very strong','Strong','Adequate','Weak','Extremely weak'][sc-1]}</td></tr>`).join('')}
          </tbody></table></div></div></td></tr>`;
    return `<tr class="click" data-key="m-${k}"><td><b>${B.lbl}</b><span class="ax-src">Click for the formula and the band table</span></td>
      <td>${AX_FACTORS[B.f].lbl}</td><td class="num">${axFmtMetric(k,d.value)}</td><td class="num">${d.score}</td>
      <td><div class="ax-prog"><i class="${d.score<=2?'g':d.score<=4?'a':'r'}" style="width:${pos}%"></i></div>
        <span class="ax-mini">${axBandText(k,d.score)}</span></td>
      <td><span class="pill ${rag}">${['Extremely strong','Very strong','Strong','Adequate','Weak','Extremely weak'][d.score-1]}</span></td></tr>` + det;
  }).join('');
  axExpandable(tb);
}
function axFormula(k){
  return {
    opMargin:'(total revenue − total expenditure) ÷ total revenue',
    balAfterCapex:'(operating balance + depreciation − capital spend) ÷ total revenue',
    daysCash:'(cash − unspent conditional grants) ÷ ((expenditure − depreciation − impairment) ÷ 365)',
    liquidityCover:'(cash − unspent grants) ÷ (interest + current portion of borrowing)',
    debtRatio:'(long-term borrowing + current portion) ÷ (total revenue − conditional grants)',
    interestRatio:'finance cost ÷ (total revenue − conditional grants)',
    ownShare:'(total revenue − equitable share − conditional grants) ÷ total revenue',
    capexFlex:'capital spend ÷ (operating expenditure + capital spend)',
    collection:'cash collected ÷ revenue billed',
    grpPerCap:'gross regional product ÷ population',
    unemp:'unemployed ÷ economically active population',
    conc:'output of the three largest sectors ÷ total output',
    contingentRatio:'(guarantees + litigation + 15% of employee benefit obligation) ÷ operating revenue',
    impairRatio:'debt impairment charge ÷ (exchange + non-exchange receivables)',
    rmRatio:'repairs and maintenance ÷ carrying value of property, plant and equipment',
  }[k] || '—';
}

function renderNotch(R){
  const m = axMetrics();
  const base = AX_LEVELS.indexOf(R.indicative);
  let run = base;
  const rows = [['Indicative credit level','Matrix outcome at profile '+R.r+' against framework '+R.c, '—', R.indicative.toUpperCase(), '<span class="pill NAVY">Starting point</span>']];
  const cand = [
    ['Liquidity override','Free cash below 0.8 times debt service falling due within twelve months', 3, m.v.liquidityCover<0.8, AX_N(m.v.liquidityCover,2)+'x against 0.80x'],
    ['Debt override','Direct debt above 120% of operating revenue', 2, m.v.debtRatio>1.2, pctT(m.v.debtRatio)+' against 120%'],
    ['Budgetary override','Operating balance negative in two consecutive years', 2, false, pctT(m.v.opMargin)+' — positive'],
    ['Financial management override','A disclaimer or adverse audit opinion', 2, /Disclaimer|Adverse/.test(axAuditOutcome), axAuditOutcome],
    ['Exceptional single-asset concentration','More than half of own revenue from one payer or one sector', 1, false, 'Largest single payer below the threshold'],
  ];
  cand.forEach(c=>{
    if(c[3]) run += c[2];
    rows.push([c[0], c[1], c[3]?'−'+c[2]:'0', AX_LEVELS[clamp(run,0,AX_LEVELS.length-1)].toUpperCase(),
      c[3]?'<span class="pill RED">Applied</span>':'<span class="pill INCOMPLETE">Not triggered</span>',
      c[4]]);
  });
  rows.push(['Stand-alone credit profile','After every override', '—', R.sacp.toUpperCase(), '<span class="pill NAVY">SACP</span>']);
  rows.push(['Sovereign ceiling','A local government is not normally rated above the Republic of South Africa',
    R.capped?'Capped':'0', R.icr, R.capped?'<span class="pill AMBER">Binding</span>':'<span class="pill GREEN">Not binding</span>']);
  document.querySelector('#crNotch tbody').innerHTML = rows.map((r,i)=>
    `<tr class="${i===rows.length-1?'total':''}"><td><b>${r[0]}</b></td>
      <td style="color:var(--muted)">${r[1]}${r[5]?`<span class="ax-src">Measured: ${r[5]}</span>`:''}</td>
      <td class="num">${r[2]}</td><td class="num">${r[3]}</td><td>${r[4]}</td></tr>`).join('');

  document.getElementById('crSov').innerHTML = `
    <table class="grid"><tbody>
      <tr><td>Sovereign foreign-currency rating</td><td class="num"><b>${AX.sovereign}</b></td></tr>
      <tr><td>Moody\u2019s equivalent</td><td class="num">${AX.sovMoody}</td></tr>
      <tr><td>Stand-alone credit profile</td><td class="num"><b>${R.sacp.toUpperCase()}</b></td></tr>
      <tr class="total"><td>Ceiling applied</td><td class="num">${R.capped?'Yes':'No'}</td></tr>
    </tbody></table>
    <p class="footnote">A municipality can in principle be rated above its sovereign where it has no exposure to sovereign transfer, no foreign-currency
    obligation and could survive a sovereign stress. None of those hold here: ${pctT(axBase().transfers/axBase().t.revenue)} of revenue arrives through the
    Division of Revenue Act, and the bulk suppliers are themselves state-owned. ${R.capped
      ? 'The cap is therefore binding and the rating moves with the sovereign.'
      : 'The stand-alone profile sits below the ceiling, so the cap is not the constraint today — but it becomes one on any improvement.'}</p>`;

  document.getElementById('crSupport').innerHTML = `
    <table class="grid"><tbody>
      <tr><td>Legal obligation on national government</td><td>None. MFMA section 45 is explicit that national government does not guarantee municipal debt.</td></tr>
      <tr><td>Intervention mechanism</td><td>Constitution section 139 and MFMA chapter 13 provide for intervention and a financial recovery plan, not for funding.</td></tr>
      <tr><td>Track record</td><td>Recent practice has been conditional support and administration rather than a cash bailout.</td></tr>
      <tr><td>Systemic importance</td><td>Moderate. Not a metropolitan municipality; failure would be contained regionally.</td></tr>
      <tr class="total"><td>Uplift applied</td><td><b>None</b></td></tr>
    </tbody></table>
    <p class="footnote">This is the single largest difference between rating a municipality and rating a state-owned company. A guarantee moves a rating
    several notches; a recovery plan moves it none, because it restructures obligations rather than honouring them on time.</p>`;

  document.querySelector('#crScales tbody').innerHTML = [
    ['Global long-term','S&P convention, comparable across countries', R.icr, axOutlook().dir, 'What an offshore lender or a development finance institution prices from'],
    ['Global equivalent','Moody\u2019s convention', AX_MOODY[R.icr]||'—', axOutlook().dir, 'Used where the counterparty mandates a Moody\u2019s rating'],
    ['National long-term','Relative to the strongest credit in South Africa', AX_NAT[R.icr]||'—', axOutlook().dir, 'What a domestic bond issue and most bank facilities price from'],
    ['National short-term','Twelve months or less', (AX_NAT[R.icr]||'').startsWith('AA')?'A1+(ZA)':'A1(ZA)', '—', 'Commercial paper and short-dated facilities'],
  ].map(r=>`<tr><td><b>${r[0]}</b></td><td style="color:var(--muted)">${r[1]}</td><td class="num"><b>${r[2]}</b></td><td>${r[3]}</td><td style="font-size:11px">${r[4]}</td></tr>`).join('');
}

/* ---- sensitivity ---- */
const AX_SENS_KEYS = ['collection','daysCash','debtRatio','opMargin','rmRatio'];
function renderSens(){
  const m = axMetrics();
  document.getElementById('crSliders').innerHTML = AX_SENS_KEYS.map(k=>{
    const B = AX_BANDS[k], base = m.v[k];
    const lo = B.dir==='hi' ? Math.min(base,B.b[4])*0.6 : 0;
    const hi = B.dir==='hi' ? Math.max(base,B.b[0])*1.25 : Math.max(base,B.b[4])*1.3;
    const cur = axSens[k]!==undefined ? axSens[k] : base;
    const step = (hi-lo)/100;
    return `<div class="ax-lev ${axSens[k]!==undefined?'touched':''}">
      <div class="lv-hd"><b>${B.lbl}</b><span class="now ${axSens[k]!==undefined?'off':''}">${axFmtMetric(k,cur)}</span></div>
      <input type="range" data-sk="${k}" min="${lo}" max="${hi}" step="${step}" value="${cur}"/>
      <div class="lv-ft"><span>${axFmtMetric(k,lo)}</span><span>base ${axFmtMetric(k,base)}</span><span>${axFmtMetric(k,hi)}</span></div>
      <div class="lv-why">Scores ${AX_FACTORS[B.f].lbl.toLowerCase()} at a weight of ${pctT(AX_FACTORS[B.f].w,0)}. Currently a ${axScore(k,cur)}.</div>
    </div>`;
  }).join('');
  document.querySelectorAll('[data-sk]').forEach(inp=> inp.addEventListener('input', e=>{
    axSens[e.target.dataset.sk] = Number(e.target.value); renderSens();
  }));

  const baseR = axRate(), adjR = axRate(axSens);
  const move = baseR.sacpIdx - adjR.sacpIdx;            /* the profile moves even when the ceiling pins the rating */
  const moveICR = AX_LEVELS.indexOf(baseR.icr.toLowerCase()) - AX_LEVELS.indexOf(adjR.icr.toLowerCase());
  document.getElementById('crSensKpis').innerHTML = kpiSet([
    ['Stand-alone profile', adjR.sacp.toUpperCase(), move>0?'good':move<0?'bad':'',
      move===0?'Unchanged from '+baseR.sacp.toUpperCase()
        :`${Math.abs(move)} notch${Math.abs(move)>1?'es':''} ${move>0?'stronger':'weaker'} than ${baseR.sacp.toUpperCase()}`],
    ['Issuer credit rating', adjR.icr, moveICR<0?'bad':moveICR>0?'good':'',
      moveICR===0?(adjR.capped?'Still held at the sovereign ceiling':'Unchanged'):`Was ${baseR.icr}`],
    ['Individual credit profile', AX_N(adjR.icp,2), adjR.icp<baseR.icp?'good':adjR.icp>baseR.icp?'bad':'', `Base ${AX_N(baseR.icp,2)}`],
    ['Indicative coupon', (AX_SPREAD[adjR.icr]?((AX.repo*10000+AX_SPREAD[adjR.icr])/100).toFixed(2)+'%':'—'), moveICR<0?'bad':moveICR>0?'good':'',
      `${AX_SPREAD[adjR.icr]?'Repo plus '+AX_SPREAD[adjR.icr]+' basis points':'Not priced'}`],
  ]);
  document.getElementById('crSensTag').textContent = Object.keys(axSens).length ? Object.keys(axSens).length+' driver(s) adjusted' : 'No driver adjusted';
  document.querySelector('#crSensTable tbody').innerHTML = Object.entries(AX_FACTORS).map(([k,f])=>{
    const bs = baseR.factors[k].score, as = adjR.factors[k].score, d = as-bs;
    return `<tr><td><b>${f.lbl}</b></td><td class="num">${AX_N(bs,2)}</td><td class="num">${AX_N(as,2)}</td>
      <td class="num">${pctT(f.w,0)}</td><td>${Math.abs(d)<0.005?'<span class="pill INCOMPLETE">Unchanged</span>'
        : `<span class="pill ${d<0?'GREEN':'RED'}">${d<0?'Stronger':'Weaker'} by ${AX_N(Math.abs(d),2)}</span>`}</td></tr>`;
  }).join('');
  const dCost = (AX_SPREAD[adjR.icr]||0) - (AX_SPREAD[baseR.icr]||0);
  const capNote = adjR.capped ? `<br/><br/><b>Note on the ceiling.</b> The stand-alone profile of ${adjR.sacp.toUpperCase()} sits
    ${Math.abs(adjR.sacpIdx-adjR.sovIdx)} notches above the sovereign, so the issuer rating stays pinned at ${AX.sovereign} whatever these drivers do.
    That is not a flaw in the model — it is the most important single fact about municipal borrowing in a sub-investment-grade sovereign. Management can
    move the profile and should, because the profile is what determines the margin a lender charges over the sovereign benchmark and whether a development
    finance institution will lend at all; it cannot move the ceiling.` : '';
  document.getElementById('crSensNote').innerHTML = `<p class="footnote" style="margin:0">${move===0
    ? `The adjusted drivers move the profile from ${AX_N(baseR.icp,2)} to ${AX_N(adjR.icp,2)} but not far enough to change the rounded profile, so the matrix outcome holds. Ratings are step functions: a driver can deteriorate a long way inside a band and change nothing, then move one basis point past the edge and cost a notch.`
    : `The rating moves ${Math.abs(move)} notch${Math.abs(move)>1?'es':''} to ${adjR.icr}. At the current debt stock of R${fmt(axBase().debt)}k that is worth roughly
       <b>R${fmt(Math.abs(dCost)/10000*axBase().debt)}k a year</b> in interest, ${dCost>0?'against':'in favour of'} the municipality.`}${capNote}</p>`;

  const scale = ['BBB-','BB+','BB','BB-','B+','B','B-'];
  axChart(document.getElementById('crCostChart'), scale.map(r=>({
    label:r, r, sp:AX_SPREAD[r]||0,
    bars:[{v:AX_SPREAD[r]||0, cls: r===adjR.icr?'b-gold': r===baseR.icr?'b-navy':'b-grey'}]})),
    document.getElementById('crCostRead'),
    c=>`<b>${c.r}</b> prices at repo plus ${c.sp} basis points, an all-in coupon of about ${((AX.repo*10000+c.sp)/100).toFixed(2)}%.
        On the current book of R${fmt(axBase().debt)}k that is <b>R${fmt(axBase().debt*(AX.repo+c.sp/10000))}k</b> of interest a year —
        R${fmt(Math.abs(axBase().debt*((c.sp-(AX_SPREAD[baseR.icr]||0))/10000)))}k ${c.sp>(AX_SPREAD[baseR.icr]||0)?'more':'less'} than at the current ${baseR.icr}.`);
}
function renderPeers(){
  const b = axBase(), m = axMetrics();
  const peers = [
   {n:'This municipality', r:axRate().icr+' / '+(AX_NAT[axRate().icr]||''), d:m.v.debtRatio, c:m.v.daysCash, col:AX.collection, w:'Strong liquidity and a positive operating balance against a weak local economy and a qualified audit outcome.', me:true},
   {n:'City of Cape Town', r:'Ba2 (Moody\u2019s)', d:0.118, c:null, col:null, w:'Net direct and indirect debt of 11.8% of operating revenue in FY2025, among the lowest of the metropolitan municipalities, with strong liquidity buffers and consistent operating surpluses.'},
   {n:'City of Johannesburg', r:'A(ZA) negative (GCR)', d:null, c:5.5, col:null, w:'Cash fell to ZAR2.2 billion in fiscal 2024 — 5.5 days of cash on hand — with weak collection and high debt. Debt securities were suspended by the JSE in March 2026 for late audited statements.'},
   {n:'City of Tshwane', r:'Caa2 (Moody\u2019s)', d:null, c:null, col:null, w:'Downgraded on liquidity concerns.'},
   {n:'Republic of South Africa', r:AX.sovereign+' / '+AX.sovMoody, d:null, c:null, col:null, w:'The ceiling. General government debt has been projected in the 70% to 85% of GDP range.'},
  ];
  document.querySelector('#crPeers tbody').innerHTML = peers.map(p=>
    `<tr style="${p.me?'background:var(--navy-soft)':''}"><td><b>${p.n}</b></td><td>${p.r}</td>
      <td class="num">${p.d!==null&&p.d!==undefined?pctT(p.d):'—'}</td>
      <td class="num">${p.c!==null&&p.c!==undefined?AX_N(p.c,0)+' d':'—'}</td>
      <td class="num">${p.col?pctT(p.col):'—'}</td><td style="font-size:11px;color:var(--muted)">${p.w}</td></tr>`).join('');
  document.getElementById('crPeerNote').innerHTML = `<p class="footnote" style="margin:0">Comparator figures are published positions and are shown for orientation,
    not as an equivalent computation — each agency uses its own adjustments, and a national-scale rating from one agency is not comparable with a global-scale
    rating from another. The useful reading is the spread: on days of cash this municipality sits closer to Cape Town than to Johannesburg, while on the
    strength of its local economy it sits well below either.</p>`;

  const pc = [
    {label:'This', v:m.v.daysCash, me:true}, {label:'Joburg', v:5.5}, {label:'30d norm', v:30}, {label:'90d strong', v:90},
  ];
  axChart(document.getElementById('crPeerChart'), pc.map(p=>({label:p.label, p,
    bars:[{v:p.v, cls:p.me?'b-navy':'b-grey'}]})), document.getElementById('crPeerRead'),
    c=>`<b>${c.p.label}</b> — ${AX_N(c.p.v,1)} days of cash on hand. ${c.p.me
      ? `This municipality can meet ${AX_N(c.p.v/30,1)} months of cash operating cost from free cash without collecting another rand.`
      : c.p.label==='Joburg' ? 'Johannesburg at 5.5 days in fiscal 2024 — roughly a week of operating cost, which is why its liquidity assessment drives its rating.'
      : 'A reference line, not a municipality.'}`);
}
function renderMethod(){
  document.getElementById('crMethod').innerHTML = `
    <div class="ax-note"><b>What this is.</b> A sub-sovereign scorecard built on the structure the major agencies publish for local and regional governments:
    an institutional framework assessment held separately from an individual credit profile, the profile built from weighted factors, the two combined in a
    matrix, then overrides, the sovereign ceiling and the likelihood of extraordinary support.</div>
    <div class="ax-note warn"><b>What it is not.</b> It is not a rating. A rating is the opinion of a committee that has met management, read the recovery
    plan, and formed a view on willingness as well as ability to pay. This model reproduces the measurable part of that process so a municipality can see
    where it stands before the committee sits, and can test what would move it.</div>
    <table class="grid"><tbody>
      <tr><td style="width:26%"><b>Why municipalities are not companies</b></td><td>There are no shareholders and no equity cushion, revenue is largely
        non-discretionary and set by tariff or formula, and the entity cannot be liquidated. Corporate scorecards weight leverage and coverage heavily
        because a company can be wound up; a municipality cannot, so the weight moves to liquidity and to the framework it operates in.</td></tr>
      <tr><td><b>Why liquidity carries 20%</b></td><td>Municipal default in practice is a cash event, not a balance-sheet event. An entity with a sound
        statement of financial position and no cash cannot pay Eskom on the twentieth.</td></tr>
      <tr><td><b>Why the audit outcome scores</b></td><td>An adverse or disclaimed opinion means the figures a lender relies on cannot be relied on. It is
        scored inside financial management rather than as a separate factor, with a penalty of ${AX_N(AX_AUDIT_PENALTY['Qualified opinion'],1)} points for a
        qualification rising to ${AX_N(AX_AUDIT_PENALTY['Disclaimer of opinion'],1)} for a disclaimer.</td></tr>
      <tr><td><b>Why there is no support uplift</b></td><td>MFMA section 45 states that national government does not guarantee municipal debt. Section 139
        intervention restructures obligations; it does not pay them on the due date, which is what a rating addresses.</td></tr>
      <tr><td><b>Where judgement remains</b></td><td>The institutional framework assessment, the sector-concentration input and the contingent-liability
        inventory are analytical inputs, not ledger outputs. They are shown as named assumptions on this tab rather than buried in a score.</td></tr>
    </tbody></table>`;
  document.querySelector('#crBands tbody').innerHTML = Object.entries(AX_BANDS).map(([k,B])=>
    `<tr><td><b>${B.lbl}</b><span class="ax-src">${AX_FACTORS[B.f].lbl} · ${B.dir==='hi'?'higher is stronger':'lower is stronger'}</span></td>
      ${[1,2,3,4,5,6].map(sc=>`<td class="num" style="font-size:11px">${axBandText(k,sc)}</td>`).join('')}</tr>`).join('');
}

/* ==================== 2. SCENARIO & SUSTAINABILITY RENDERERS ==================== */
function renderScen(){
  const s = axScenario(), b = s.b, base = axBase();
  const R0 = axRate(), R1 = axScenarioRating(s);
  document.getElementById('scKpis').innerHTML = kpiSet([
    ['Levers moved', String(s.touched), s.touched?'warn':'', s.touched?'The base case is no longer showing':'Showing the approved budget as tabled'],
    ['Operating surplus', 'R'+money(s.surplus)+'k', s.surplus<0?'bad':s.surplus<base.t.surplus?'warn':'good', `Base R${fmt(base.t.surplus)}k · movement R${money(s.surplus-base.t.surplus)}k`],
    ['Days cash on hand', AX_N(s.daysCash,0), s.daysCash<30?'bad':s.daysCash<60?'warn':'good', `Base ${AX_N(base.daysCash,0)} days · norm 30 days`],
    ['Credit profile under the scenario', R1.sacp.toUpperCase(), R1.sacpIdx>R0.sacpIdx?'bad':R1.sacpIdx<R0.sacpIdx?'good':'',
      R1.sacpIdx===R0.sacpIdx ? `Unchanged from ${R0.sacp.toUpperCase()} · issuer rating ${R1.icr}`
        : `${Math.abs(R1.sacpIdx-R0.sacpIdx)} notch${Math.abs(R1.sacpIdx-R0.sacpIdx)>1?'es':''} weaker · issuer rating ${R1.icr}`],
  ]);
  renderLevers(); renderScResult(s); renderImpact(s,R0,R1); renderLibrary();
  renderTraj(); renderSust();
}
function renderLevers(){
  const groups = [...new Set(AX_LEVERS.map(l=>l.g))];
  document.getElementById('scLeverBox').innerHTML = groups.map(g=>
    `<div style="font-size:10.5px;font-weight:800;color:var(--muted);margin:4px 0 7px">${g.toUpperCase()}</div>` +
    AX_LEVERS.filter(l=>l.g===g).map(l=>{
      const v = AX_LEVER_VALS[l.k];
      const disp = l.unit==='bps' ? (l.base()+v).toFixed(0)+' bps'
                 : l.unit==='pts' ? AX_N(l.base()+v,1)+(l.k==='coll'?'%':' pts')
                 : l.unit==='R\u2019000' ? 'R'+fmt(v)+'k'
                 : AX_N(l.base()+v,0)+'%';
      return `<div class="ax-lev ${v!==0?'touched':''}">
        <div class="lv-hd"><b>${l.lbl}</b><span class="now ${v!==0?'off':''}">${disp}</span></div>
        <input type="range" data-lk="${l.k}" min="${l.min}" max="${l.max}" step="${l.step}" value="${v}"/>
        <div class="lv-ft"><span>${l.min}${l.unit==='R\u2019000'?'':l.unit}</span><span>base</span><span>+${l.max}${l.unit==='R\u2019000'?'':l.unit}</span></div>
        <div class="lv-why">${l.why}</div></div>`;
    }).join('')).join('');
  document.querySelectorAll('[data-lk]').forEach(inp=> inp.addEventListener('input', e=>{
    AX_LEVER_VALS[e.target.dataset.lk] = Number(e.target.value); renderScen();
  }));
}
function renderScResult(s){
  const b = s.b, t = s.t;
  document.getElementById('scResTag').textContent = `${PERIODS[state.period]} · ${s.touched} lever(s) applied`;
  const rows = [
    ['Total revenue', t.revenue, s.revenue],
    ['  of which lost to volume and transfer cuts', 0, s.dElec+s.dWater+s.dEq+s.dGrant],
    ['Total expenditure', t.expenditure, s.expend],
    ['  bulk purchase margin', 0, s.dBulk],
    ['  wage settlement', 0, s.dWage],
    ['  finance cost', 0, s.dInt],
    ['  impairment', 0, s.dImp],
    ['  maintenance', 0, s.dMaint],
    ['  unforeseen event', 0, s.dEvent],
    ['Operating surplus', t.surplus, s.surplus],
    ['Cash and equivalents', b.cash, s.cash],
    ['Free cash after restricted grants', b.freeCash, s.freeCash],
    ['Capital programme', b.capex, s.capex],
  ];
  document.querySelector('#scResult tbody').innerHTML = rows.map(r=>{
    const sub = r[0].startsWith('  ');
    const mv = sub ? r[2] : r[2]-r[1];
    const bad = (r[0].includes('revenue')||r[0].includes('surplus')||r[0].includes('Cash')||r[0].includes('cash')) ? mv<0 : mv>0;
    return `<tr class="${sub?'':'total'}"><td style="${sub?'padding-left:26px;color:var(--muted)':''}">${r[0].trim()}</td>
      <td class="num">${sub?'—':fmt(r[1])}</td><td class="num">${sub?'—':fmt(r[2])}</td>
      <td class="num" style="color:${Math.abs(mv)<1?'var(--muted)':bad?'var(--red)':'var(--green)'}">${Math.abs(mv)<1?'—':money(mv)}</td>
      <td>${Math.abs(mv)<1?'<span class="pill INCOMPLETE">No change</span>':`<span class="pill ${bad?'RED':'GREEN'}">${bad?'Adverse':'Favourable'}</span>`}</td></tr>`;
  }).join('');
  const runway = s.cashOpex? s.freeCash/(s.cashOpex/12) : 0;
  document.getElementById('scResNote').innerHTML = `<p class="footnote" style="margin:0">${s.touched===0
    ? 'No lever has been moved, so this is the approved budget. Move one on the left, or load a scenario from the library.'
    : `Under these settings the municipality holds <b>${AX_N(runway,1)} months</b> of cash operating cost in free cash and the operating result is
       <b>R${money(s.surplus)}k</b>. ${s.surplus<0?'The operating account no longer funds itself, which is the point at which the balance sheet starts being consumed.'
       :'The operating account still funds itself.'} Impairment of R${fmt(s.dImp)}k is charged to the surplus but does not move cash — that is why the surplus and the cash line diverge.`}</p>`;

  /* cash runway chart — 12 months */
  const monthly0 = s.b.cashOpex/12, monthlyS = s.cashOpex/12;
  const cols = Array.from({length:12},(_,i)=>{
    const m = i+1;
    const c0 = s.b.freeCash + (s.b.t.surplus/12 - 0)*m;
    const cs = s.freeCash + (s.surplus/12)*m - (s.capex-s.b.capex)/12*m;
    return {label:'M'+m, m, c0, cs, monthlyS,
      bars:[{v:Math.max(0,c0),cls:'b-grey'},{v:Math.max(0,cs),cls:'b-navy'},{v:monthlyS,cls:'b-red'}]};
  });
  axChart(document.getElementById('scCashChart'), cols, document.getElementById('scCashRead'),
    c=>`<b>Month ${c.m}</b> — free cash of <b>R${fmt(c.cs)}k</b> under the scenario against R${fmt(c.c0)}k on the base path.
        That is ${AX_N(c.cs/c.monthlyS,1)} months of cash operating cost. ${c.cs<c.monthlyS
        ? 'Below one month of cover, which is the level at which creditors start to be stretched and the thirty-day payment rule is breached.'
        : c.cs<c.monthlyS*3 ? 'Inside the one-to-three month band the MFMA cost-coverage norm targets.'
        : 'Comfortably above the cost-coverage norm.'}`);

  /* attribution */
  const contrib = [
    ['Collection rate', s.v.coll, 0],
    ['Electricity volume', s.v.elecVol, s.dElec],
    ['Water volume', s.v.waterVol, s.dWater],
    ['Equitable share', s.v.eqShare, s.dEq],
    ['Grant reversed', s.v.grantRoll, s.dGrant],
    ['Bulk cost gap', s.v.tariffGap, -s.dBulk],
    ['Wage settlement', s.v.wage, -s.dWage],
    ['Interest rate', s.v.rates, -s.dInt],
    ['Impairment', s.v.impair, -s.dImp],
    ['Maintenance', s.v.maint, -s.dMaint],
    ['Unforeseen event', s.v.event, -s.dEvent],
  ].filter(r=>Math.abs(r[2])>0.5);
  const tot = contrib.reduce((a,r)=>a+Math.abs(r[2]),0)||1;
  document.querySelector('#scAttrib tbody').innerHTML = contrib.length ? contrib
    .sort((a,b2)=>Math.abs(b2[2])-Math.abs(a[2]))
    .map(r=>`<tr><td><b>${r[0]}</b></td><td class="num">${r[1]>0?'+':''}${fmt(r[1])}</td>
      <td class="num" style="color:${r[2]<0?'var(--red)':'var(--green)'}">${money(r[2])}</td>
      <td><div class="ax-prog"><i class="${r[2]<0?'r':'g'}" style="width:${Math.abs(r[2])/tot*100}%"></i></div>
        <span class="ax-mini">${pctT(Math.abs(r[2])/tot,0)} of the total move</span></td></tr>`).join('')
    : '<tr><td colspan="4" style="color:var(--muted);padding:22px;text-align:center">No lever has been moved.</td></tr>';
}
function renderImpact(s, R0, R1){
  const b = s.b, base = axBase();
  const G = [
   ['Operating account', [
     ['Total revenue', s.revenue, base.t.revenue, 'R', true],
     ['Total expenditure', s.expend, base.t.expenditure, 'R', false],
     ['Operating surplus', s.surplus, base.t.surplus, 'R', true],
     ['Surplus margin', s.opMargin, base.opMargin, '%', true],
   ]],
   ['Liquidity', [
     ['Free cash', s.freeCash, base.freeCash, 'R', true],
     ['Days cash on hand', s.daysCash, base.daysCash, 'd', true],
     ['Cost coverage', s.freeCash/(s.cashOpex/12), base.freeCash/(base.cashOpex/12), 'x', true],
     ['Liquidity cover of debt service', s.liquidityCover, base.liquidityCover, 'x', true],
   ]],
   ['Debt and covenant', [
     ['Debt to operating revenue', s.debtRatio, base.debtRatio, '%', false],
     ['Debt service cover', s.dsCover, base.dsCover, 'x', true],
     ['Interest bill', L('5140')+s.dInt, L('5140'), 'R', false],
   ]],
   ['Service delivery and assets', [
     ['Capital programme', s.capex, base.capex, 'R', true],
     ['Renewal to depreciation', s.renewalRatio, base.renewalRatio, 'x', true],
     ['Maintenance to infrastructure', s.rmRatio, base.rmRatio, '%', true],
   ]],
   ['Revenue quality', [
     ['Collection rate', s.collection, AX.collection, '%', true],
     ['Impairment to receivables', s.impairRatio, base.impairRatio, '%', false],
   ]],
   ['Credit', [
     ['Individual credit profile', R1.icp, R0.icp, 'n', false],
   ]],
  ];
  document.getElementById('scImpactGroups').innerHTML = G.map(([g,tiles])=>
    `<div class="card"><div class="hd"><h3>${g}</h3></div><div class="bd"><div class="ax-360">${
      tiles.map(([n,v,b0,u,upGood])=>{
        const d = v-b0, pct = b0? d/Math.abs(b0) : 0;
        const good = upGood ? d>0 : d<0;
        const flat = Math.abs(pct)<0.001;
        const f = x => u==='R'?'R'+money(x)+'k' : u==='%'?pctT(x) : u==='d'?AX_N(x,0)+' d' : u==='x'?AX_N(x,2)+'x' : AX_N(x,2);
        return `<div class="t ${flat?'':good?'up':'dn'}"><div class="n">${n}</div><div class="v">${f(v)}</div>
          <div class="d ${flat?'flat':good?'up':'dn'}">${flat?'no change':(d>0?'+':'')+f(d)}</div>
          <div class="base">base ${f(b0)}</div></div>`;
      }).join('')}</div></div></div>`).join('')
    + `<div class="card"><div class="hd"><h3>Rating</h3></div><div class="bd"><div class="ax-360">
        <div class="t ${R1.sacpIdx===R0.sacpIdx?'':'dn'}"><div class="n">Stand-alone credit profile</div><div class="v">${R1.sacp.toUpperCase()}</div>
          <div class="d ${R1.sacpIdx===R0.sacpIdx?'flat':'dn'}">${R1.sacpIdx===R0.sacpIdx?'no change':'was '+R0.sacp.toUpperCase()}</div>
          <div class="base">issuer rating ${R1.icr}${R1.capped?' — pinned at the ceiling':''}</div></div>
        <div class="t"><div class="n">Indicative coupon</div><div class="v">${AX_SPREAD[R1.icr]?((AX.repo*10000+AX_SPREAD[R1.icr])/100).toFixed(2)+'%':'—'}</div>
          <div class="d ${(AX_SPREAD[R1.icr]||0)>(AX_SPREAD[R0.icr]||0)?'dn':'flat'}">${(AX_SPREAD[R1.icr]||0)-(AX_SPREAD[R0.icr]||0)>0?'+'+((AX_SPREAD[R1.icr]||0)-(AX_SPREAD[R0.icr]||0))+' bps':'unchanged'}</div>
          <div class="base">on R${fmt(b.debt)}k of debt</div></div>
      </div></div></div>`;

  document.querySelector('#scCredit tbody').innerHTML = Object.entries(AX_FACTORS).map(([k,f])=>{
    const b0 = R0.factors[k].score, b1 = R1.factors[k].score, d = b1-b0;
    return `<tr><td><b>${f.lbl}</b></td><td class="num">${AX_N(b0,2)}</td><td class="num">${AX_N(b1,2)}</td>
      <td style="color:var(--muted);font-size:11px">${Math.abs(d)<0.005?'Not affected by the levers currently moved'
        : (d>0?'Weakened':'Strengthened')+' by '+AX_N(Math.abs(d),2)+' — driven by '+f.members}</td></tr>`;
  }).join('').replace(/undefined/g,'the measures in this factor');
  document.getElementById('scCreditNote').innerHTML = `<p class="footnote" style="margin:0">The scenario does not "estimate" a rating impact. It recomputes
    the same ratios the rating model reads, re-scores each factor off the same published bands, and runs the matrix again. If the rating does not move, it is
    because the stressed ratios still fall inside their existing bands — not because the effect was judged immaterial.</p>`;
}
let scLibKey = 'base', scLibSev = 100;
const AX_LIB_WHY = {
 coll:'Households stop paying when income falls or when they see others not paying and nothing happening.',
 elecVol:'Volume leaves the grid; the network and the bulk obligation stay.',
 waterVol:'Restricted volume is billed volume lost, while the bulk take-or-pay continues.',
 eqShare:'The transfer is reduced through the formula rather than by announcement.',
 grantRoll:'Unspent conditional grant surrendered under section 22 of the Division of Revenue Act.',
 tariffGap:'The regulator sets the bulk increase; council sets the retail one. The difference is absorbed.',
 wage:'Concluded nationally at the bargaining council and not optional for the municipality.',
 rates:'Applies to the floating portion of the book and to anything drawn in the period.',
 impair:'The provision follows payment behaviour down with a lag.',
 maint:'Deferring maintenance protects cash now and buys a larger renewal bill later.',
 capex:'Under-delivery preserves cash, forfeits grant and grows the backlog.',
 event:'A declared disaster or a major failure. The municipality typically carries sixty per cent.',
};
function axLibVals(key, sev){
  const sc = AX_LIBRARY.find(x=>x.k===key) || AX_LIBRARY[0];
  const v = Object.fromEntries(AX_LEVERS.map(l=>[l.k,0]));
  Object.entries(sc.set).forEach(([k,val])=>{
    const L = AX_LEVERS.find(x=>x.k===k);
    v[k] = clamp(val*(sev/100), L.min, L.max);
  });
  return {sc, v};
}
function renderLibrary(){
  const base = axBase(), oneMonth = base.cashOpex/12;

  /* --- picker --- */
  document.getElementById('scLibPick').innerHTML = AX_LIBRARY.map(x=>{
    const r = axScenario(axLibVals(x.k,100).v);
    const hurt = base.t.surplus? (r.surplus-base.t.surplus)/Math.abs(base.t.surplus) : 0;
    return `<button class="btn-ghost" data-libpick="${x.k}" style="display:block;width:100%;text-align:left;margin-bottom:6px;padding:9px 11px;
      ${x.k===scLibKey?'border-color:var(--navy);box-shadow:inset 3px 0 0 var(--navy);background:var(--navy-soft);':''}">
      <b style="display:block;font-size:11.5px;color:var(--navy-dark)">${x.n}</b>
      <span style="font-size:10px;color:${hurt<-0.15?'var(--red)':hurt<0?'var(--amber)':'var(--green)'};font-weight:700">
        ${x.k==='base'?'reference':(hurt>0?'+':'')+pctT(hurt,0)+' on the surplus'}</span>
      <div class="ax-prog" style="margin-top:5px;height:5px"><i class="${hurt<-0.15?'r':hurt<0?'a':'g'}"
        style="width:${clamp(Math.abs(hurt)*180,2,100)}%"></i></div></button>`;
  }).join('');
  document.querySelectorAll('[data-libpick]').forEach(b=> b.addEventListener('click',()=>{ scLibKey=b.dataset.libpick; renderLibrary(); }));

  /* --- severity dial --- */
  const {sc, v} = axLibVals(scLibKey, scLibSev);
  const isBase = scLibKey==='base';
  document.getElementById('scLibSev').innerHTML = isBase
    ? `<div style="font-size:11.5px;color:var(--muted);line-height:1.6">The base case has nothing to scale. Pick a risk above and the dial becomes live.</div>`
    : `<div class="ax-lev ${scLibSev!==100?'touched':''}" style="margin:0;border:0;padding:0">
        <div class="lv-hd"><b>Scale the whole risk</b><span class="now ${scLibSev!==100?'off':''}">${scLibSev}%</span></div>
        <input type="range" id="scSevRange" min="0" max="200" step="10" value="${scLibSev}"/>
        <div class="lv-ft"><span>0% — does not happen</span><span>100% — as stated</span><span>200% — twice as bad</span></div>
        <div class="lv-why" style="margin-top:9px">Every lever in this risk moves together. At ${scLibSev}% the collection lever, the volume lever and
          the cost levers are all at ${scLibSev===100?'their stated':'\u2009'+scLibSev+'\u2009per cent of their stated'} position, clipped where a lever
          reaches its own limit.</div>
        <div style="display:flex;gap:5px;margin-top:10px;flex-wrap:wrap">
          ${[50,75,100,150,200].map(x=>`<button class="btn-ghost" data-sev="${x}" style="padding:4px 10px;font-size:10.5px;${x===scLibSev?'background:var(--navy);color:#fff;border-color:var(--navy)':''}">${x}%</button>`).join('')}
        </div></div>`;
  const rng = document.getElementById('scSevRange');
  if(rng) rng.addEventListener('input', e=>{ scLibSev=Number(e.target.value); renderLibrary(); });
  document.querySelectorAll('[data-sev]').forEach(b=> b.addEventListener('click',()=>{ scLibSev=Number(b.dataset.sev); renderLibrary(); }));

  /* --- detail --- */
  const r = axScenario(v), rating = axScenarioRating(r), R0 = axRate();
  document.getElementById('scLibName').textContent = sc.n;
  document.getElementById('scLibTag').textContent = isBase? 'Reference position' : `${Object.keys(sc.set).length} lever(s) at ${scLibSev}% severity`;
  document.getElementById('scLibDesc').innerHTML = `<div style="font-size:12px;line-height:1.65">${sc.d}</div>`;
  document.querySelector('#scLibLevers tbody').innerHTML = isBase
    ? '<tr><td colspan="4" style="color:var(--muted);padding:22px;text-align:center">The base case moves no lever. It is the approved budget executed as tabled.</td></tr>'
    : Object.keys(sc.set).map(k=>{
        const L = AX_LEVERS.find(x=>x.k===k), val = v[k];
        const span = L.max-L.min, pos = ((val-L.min)/span)*100;
        const disp = L.unit==='R\u2019000' ? 'R'+fmt(val)+'k' : (val>0?'+':'')+AX_N(val,1)+' '+L.unit;
        const clipped = Math.abs(val - sc.set[k]*(scLibSev/100)) > 0.01;
        return `<tr><td><b>${L.lbl}</b><span class="ax-src">${L.g}</span></td>
          <td class="num" style="font-weight:800;color:${val<0?'var(--red)':'var(--navy-dark)'}">${disp}${clipped?'<span class="ax-mini" style="color:var(--amber)">clipped at the lever limit</span>':''}</td>
          <td><div class="ax-prog"><i class="${Math.abs(val)/Math.max(Math.abs(L.min),L.max)>0.5?'r':'a'}" style="width:${clamp(pos,2,100)}%"></i></div>
            <span class="ax-mini">range ${L.min} to ${L.max} ${L.unit}</span></td>
          <td style="font-size:11px;color:var(--muted)">${AX_LIB_WHY[k]||''}</td></tr>`;
      }).join('');
  document.getElementById('scLibApply').addEventListener('click', ()=>{
    AX_LEVERS.forEach(l=> AX_LEVER_VALS[l.k] = v[l.k]);
    renderScen();
    document.querySelector('#scTabs button[data-sc="levers"]').click();
    showToast(`${sc.n} at ${scLibSev}% sent to the levers — adjust any one of them on its own`);
  });
  document.getElementById('scLibClear').addEventListener('click', ()=>{
    scLibKey='base'; scLibSev=100; AX_LEVERS.forEach(l=>AX_LEVER_VALS[l.k]=0); renderScen();
  });

  /* --- effect --- */
  document.getElementById('scLibEffTag').textContent = `${sc.n} at ${scLibSev}%`;
  const eff = [
    ['Operating surplus', base.t.surplus, r.surplus, 'R', true],
    ['Free cash', base.freeCash, r.freeCash, 'R', true],
    ['Days cash on hand', base.daysCash, r.daysCash, 'd', true],
    ['Months of cost covered', base.freeCash/(base.cashOpex/12), r.freeCash/(r.cashOpex/12), 'x', true],
    ['Debt to operating revenue', base.debtRatio, r.debtRatio, '%', false],
    ['Debt service cover', base.dsCover, r.dsCover, 'x', true],
    ['Capital programme', base.capex, r.capex, 'R', true],
    ['Collection rate', AX.collection, r.collection, '%', true],
    ['Stand-alone credit profile', R0.sacpIdx, rating.sacpIdx, 'notch', false],
  ];
  document.querySelector('#scLibEffect tbody').innerHTML = eff.map(([n,b0,b1,u,upGood])=>{
    const d = b1-b0, good = upGood ? d>=0 : d<=0, flat = Math.abs(d)<0.0005;
    const f = x => u==='R'?'R'+money(x)+'k' : u==='%'?pctT(x) : u==='d'?AX_N(x,0)+' d'
               : u==='x'?AX_N(x,2)+'x' : AX_LEVELS[Math.round(x)].toUpperCase();
    const mag = u==='notch'? Math.abs(d)/4 : b0? Math.abs(d/b0) : 0;
    return `<tr><td><b>${n}</b></td><td class="num">${f(b0)}</td><td class="num">${f(b1)}</td>
      <td class="num" style="color:${flat?'var(--muted)':good?'var(--green)':'var(--red)'}">${flat?'—':(u==='notch'?(d>0?'−'+d+' notches':'+'+Math.abs(d)+' notches'):(d>0?'+':'')+f(d))}</td>
      <td>${flat?'<span class="pill INCOMPLETE">Absorbed</span>'
        :`<div class="ax-prog"><i class="${good?'g':mag>0.25?'r':'a'}" style="width:${clamp(mag*180,4,100)}%"></i></div>
          <span class="ax-mini">${good?'favourable':mag>0.25?'severe':'material'} — ${pctT(mag,0)} move</span>`}</td></tr>`;
  }).join('');
  const brk = r.freeCash < base.cashOpex/12;
  document.getElementById('scLibEffNote').innerHTML = `<p class="footnote" style="margin:0">${isBase
    ? 'Nothing is being stressed. Pick a risk on the left to see what it costs.'
    : `At ${scLibSev}% severity this risk takes <b>R${money(r.surplus-base.t.surplus)}k</b> off the operating surplus and
       <b>${AX_N(base.daysCash-r.daysCash,0)} days</b> off the cash box. ${brk
       ? '<span style="color:var(--red)">Free cash falls below one month of operating cost, which is where creditors start being stretched and the thirty-day payment rule breaks.</span>'
       : 'Free cash still covers more than one month of operating cost, so the risk is absorbable on the balance sheet as it stands.'}
       ${rating.sacpIdx>R0.sacpIdx?` The stand-alone credit profile weakens by ${rating.sacpIdx-R0.sacpIdx} notch(es) to ${rating.sacp.toUpperCase()}.`:''}`}</p>`;

  /* --- severity response curve --- */
  const steps = [0,25,50,75,100,125,150,175,200];
  axChart(document.getElementById('scLibCurve'), steps.map(sv=>{
    const rr = axScenario(axLibVals(scLibKey,sv).v);
    return {label:sv+'%', sv, rr,
      bars:[{v:Math.max(0,rr.surplus),cls:'b-teal'},{v:Math.max(0,rr.freeCash/10),cls:'b-navy'},
            {v:Math.max(0,oneMonth-rr.freeCash),cls:'b-red'}]};
  }), document.getElementById('scLibCurveRead'),
    c=>`<b>${sc.n} at ${c.sv}%</b> — operating surplus <b>R${money(c.rr.surplus)}k</b>, free cash <b>R${fmt(c.rr.freeCash)}k</b>
        (${AX_N(c.rr.daysCash,0)} days, ${AX_N(c.rr.freeCash/(c.rr.cashOpex/12),1)} months of cover).
        ${c.rr.surplus<0?'<span style="color:var(--red)">The operating account no longer funds itself at this severity.</span>'
          :c.rr.freeCash<oneMonth?'<span style="color:var(--red)">Cash cover has fallen below one month.</span>'
          :'Still absorbable.'}`);

  /* --- comparison across all risks --- */
  const results = AX_LIBRARY.map(x=>{ const rr = axScenario(axLibVals(x.k,100).v); return {sc:x, r:rr, rating:axScenarioRating(rr)}; });
  axChart(document.getElementById('scLibChart'), results.map(({sc:x,r:rr,rating:rt})=>({
    label:x.n.split(' ')[0], sc:x, r:rr, rating:rt,
    bars:[{v:Math.max(0,rr.surplus),cls:'b-teal'},{v:Math.max(0,rr.freeCash/10),cls:'b-navy'},{v:Math.max(0,oneMonth-rr.freeCash),cls:'b-red'}]})),
    document.getElementById('scLibRead'),
    c=>`<b>${c.sc.n}</b> — ${c.sc.d}<br/>Operating surplus <b>R${money(c.r.surplus)}k</b>, free cash <b>R${fmt(c.r.freeCash)}k</b>
        (${AX_N(c.r.daysCash,0)} days), debt at ${pctT(c.r.debtRatio)} of operating revenue, profile <b>${c.rating.sacp.toUpperCase()}</b>.
        ${c.r.freeCash<oneMonth?'<span style="color:var(--red)">Free cash falls below one month of operating cost.</span>':''}`);

  /* --- register, ranked --- */
  document.querySelector('#scLibrary tbody').innerHTML = results
    .slice().sort((a,b2)=>a.r.surplus-b2.r.surplus)
    .map(({sc:x,r:rr,rating:rt})=>
    `<tr><td><b>${x.n}</b></td><td style="color:var(--muted);font-size:11px">${x.d}</td>
      <td class="num" style="color:${rr.surplus<0?'var(--red)':'inherit'}">${money(rr.surplus)}</td>
      <td class="num" style="color:${rr.daysCash<30?'var(--red)':'inherit'}">${AX_N(rr.daysCash,0)}</td>
      <td class="num">${pctT(rr.debtRatio)}</td>
      <td><span class="pill ${rt.sacpIdx<=axRate().sacpIdx?'GREEN':'RED'}">${rt.sacp.toUpperCase()}</span></td>
      <td><button class="btn-ghost" data-lib="${x.k}" style="padding:4px 9px;font-size:10.5px">Open</button></td></tr>`).join('');
  document.querySelectorAll('[data-lib]').forEach(btn=> btn.addEventListener('click',()=>{
    scLibKey=btn.dataset.lib; scLibSev=100; renderLibrary(); window.scrollTo({top:0,behavior:'smooth'}); }));
}
let scTrajKey = 'cash';
function renderTraj(){
  const baseLR = axLongRun(false), scenLR = axLongRun(true);
  const labels = scenLR.rows.map(r=>'Y'+r.y);
  const cfg = {
    cash:   {lbl:'Cash and equivalents', pick:r=>r.cash, th:()=>axBase().cashOpex/12, thl:'One month of operating cost'},
    debt:   {lbl:'Direct debt', pick:r=>r.debt, th:()=>axBase().t.revenue*0.45, thl:'45% of operating revenue — Circular 71 ceiling'},
    surplus:{lbl:'Operating balance', pick:r=>r.surplus, th:()=>0, thl:'Break-even'},
    assets: {lbl:'Carrying value of infrastructure', pick:r=>r.ppe, th:()=>L('1000'), thl:'Opening asset base'},
    backlog:{lbl:'Renewal backlog', pick:r=>r.backlog, th:()=>AX_LR.backlogOpen, thl:'Opening backlog'},
  }[scTrajKey];
  document.getElementById('scTrajTag').textContent = cfg.lbl + (scenLR.seeded && axScenario().touched ? ' · seeded by the current scenario' : ' · base case');
  document.querySelectorAll('#scTrajSeg button').forEach(b=>b.classList.toggle('active', b.dataset.tr===scTrajKey));
  const s0 = baseLR.rows.map(cfg.pick), s1 = scenLR.rows.map(cfg.pick);
  axLineChart(document.getElementById('scTrajChart'),
    [{data:s0, col:'#b9c9db', dash:true},{data:s1, col:'#173f73', w:2.4}], labels,
    document.getElementById('scTrajRead'),
    i=>{const g = s1[i]-s0[i];
      return `<b>Year ${i+1}</b> — ${cfg.lbl.toLowerCase()} of <b>R${money(s1[i])}k</b> on the scenario path against R${money(s0[i])}k on the baseline,
        a gap of <b>R${money(g)}k</b>. Threshold: ${cfg.thl} at R${money(cfg.th())}k.
        ${(scTrajKey==='cash'&&s1[i]<cfg.th())?'<span style="color:var(--red)">Cash is below one month of operating cost in this year.</span>':''}
        ${(scTrajKey==='backlog'&&s1[i]>s0[i])?'<span style="color:var(--red)">The near-term scenario has permanently raised the backlog path.</span>':''}`;},
    cfg.th());
  let h = '<thead><tr><th>Year</th>'+labels.map(l=>`<th class="num">${l}</th>`).join('')+'</tr></thead><tbody>';
  [['Revenue',r=>r.rev],['Expenditure',r=>r.exp],['Operating balance',r=>r.surplus],['Capital spend',r=>r.capex],
   ['Depreciation',r=>r.depn],['Debt',r=>r.debt],['Cash',r=>r.cash],['Renewal backlog',r=>r.backlog]].forEach(([n,p])=>{
    h += `<tr><td><b>${n}</b></td>`+scenLR.rows.map(r=>`<td class="num">${fmt(p(r))}</td>`).join('')+'</tr>';
  });
  h += `<tr class="total"><td>Days cash</td>`+scenLR.rows.map(r=>`<td class="num" style="color:${r.daysCash<30?'var(--red)':'inherit'}">${AX_N(r.daysCash,0)}</td>`).join('')+'</tr>';
  document.getElementById('scTrajTable').innerHTML = h+'</tbody>';
}
const AX_ASSUM_CTL = [
 {k:'revGrowth', lbl:'Revenue growth', min:0, max:12, step:0.5, unit:'%',
  why:'Tariff increases plus consumption growth plus transfer growth, blended. Above CPI every year is a political decision, not an assumption.'},
 {k:'costGrowth', lbl:'Cost growth', min:0, max:14, step:0.5, unit:'%',
  why:'Wages at the bargaining council settlement, bulk purchases at the regulator\u2019s increase, everything else at inflation.'},
 {k:'popGrowth', lbl:'Population growth', min:0, max:5, step:0.1, unit:'%',
  why:'Drives service demand and dilutes net assets per head. In-migration to secondary cities runs well above national average.'},
 {k:'tariffCeiling', lbl:'Affordable tariff ceiling', min:3, max:15, step:0.5, unit:'%',
  why:'The annual increase above which collection starts falling instead of revenue rising. Set from household income growth, not from need.'},
 {k:'backlogOpen', lbl:'Opening renewal backlog', min:0, max:1500000, step:20000, unit:'R\u2019000',
  why:'Deferred renewal already accumulated. Usually estimated from asset condition assessments rather than from the ledger.'},
 {k:'absorb', lbl:'Delivery capacity above programme', min:0, max:100, step:5, unit:'%',
  why:'How much more than the existing programme the municipality can actually spend in a year. This, not money, is usually the binding constraint.'},
];
AX_LR.absorb = 40;
function renderAssum(){
  document.getElementById('scAssum').innerHTML = AX_ASSUM_CTL.map(a=>{
    const raw = AX_LR[a.k], v = a.unit==='%'? raw*100 : raw;
    const val = a.k==='absorb'? raw : v;
    const disp = a.unit==='%'? AX_N(val,1)+'%' : 'R'+fmt(val)+'k';
    return `<div class="ax-lev">
      <div class="lv-hd"><b>${a.lbl}</b><span class="now">${disp}</span></div>
      <input type="range" data-ak="${a.k}" min="${a.min}" max="${a.max}" step="${a.step}" value="${val}"/>
      <div class="lv-ft"><span>${a.min}${a.unit==='%'?'%':''}</span><span>${a.max}${a.unit==='%'?'%':''}</span></div>
      <div class="lv-why">${a.why}</div></div>`;
  }).join('');
  document.querySelectorAll('[data-ak]').forEach(inp=> inp.addEventListener('input', e=>{
    const a = AX_ASSUM_CTL.find(x=>x.k===e.target.dataset.ak);
    const raw = Number(e.target.value);
    AX_LR[a.k] = (a.unit==='%' && a.k!=='absorb') ? raw/100 : raw;
    renderSust(); renderTraj();
  }));
}
function renderSust(){
  renderAssum();
  const lr = axLongRun(true), b = axBase(), last = lr.rows[lr.rows.length-1];
  const billed = L('4100')+L('4110')+L('4120')+L('4130')+L('4140');
  /* required renewal = depreciation plus a ten-year catch-up on the opening backlog */
  const reqY = lr.rows.map(r=> r.depn + AX_LR.backlogOpen/10);
  const gapY = lr.rows.map((r,i)=> Math.max(0, reqY[i] - r.capex));
  const totalGap = gapY.reduce((a,c)=>a+c,0);
  const tariffNeed = Math.pow((last.exp/lr.rows[0].exp),1/9)-1;
  const cashOut = lr.rows.findIndex(r=> r.cash < (r.exp-r.depn)/12);
  const debtCap = b.t.revenue*0.45;

  document.getElementById('scSustKpis').innerHTML = kpiSet([
    ['Renewal funding gap over ten years', 'R'+fmt(totalGap)+'k', totalGap>0?'bad':'good',
      totalGap>0?`Required renewal exceeds planned spend in ${gapY.filter(g=>g>0).length} of the ten years`:'Planned renewal meets the requirement in every year'],
    ['Tariff increase the cost base needs', pctT(tariffNeed), tariffNeed>AX_LR.tariffCeiling?'bad':'good',
      `Affordable ceiling set at ${pctT(AX_LR.tariffCeiling)} · ${tariffNeed>AX_LR.tariffCeiling?'above what households can carry':'within reach'}`],
    ['Years to cash below one month', cashOut<0?'Beyond 10':'Year '+(cashOut+1), cashOut<0?'good':cashOut<5?'bad':'warn',
      cashOut<0?'Cash holds above one month of operating cost throughout':'The point at which creditors start being stretched'],
    ['Debt headroom in year 10', 'R'+money(debtCap-last.debt)+'k', last.debt>debtCap?'bad':'good',
      `Debt at ${pctT(last.debtRatio)} of revenue against the 45% Circular 71 ceiling`],
  ]);

  const ind = [
    {n:'Revenue and expenditure projection', q:'Does the operating account still fund itself in ten years, or does the cost base outrun the revenue base?',
     now:b.opMargin, y10:last.margin, th:0.03, dir:'hi', u:'%',
     method:`Revenue is grown at ${pctT(AX_LR.revGrowth)} and expenditure at ${pctT(AX_LR.costGrowth)}, compounded annually from the audited base. Depreciation is recomputed each year off the closing carrying value at 4.05%, so it grows with the asset base rather than being held flat.`,
     metric:'Operating balance as a percentage of total revenue, projected to year ten.',
     cols:['Revenue','Expenditure','Operating balance','Margin'],
     rows: lr.rows.map(r=>[fmt(r.rev), fmt(r.exp), money(r.surplus), pctT(r.margin)]),
     note:`The gap between the two growth rates is ${pctT(AX_LR.costGrowth-AX_LR.revGrowth)} a year, which compounds to ${pctT(Math.pow(1+AX_LR.costGrowth-AX_LR.revGrowth,10)-1)} over the projection. That alone is what turns a healthy margin into a structural deficit with nothing going wrong.`},

    {n:'Tariff path and affordability', q:'What annual increase does the cost base require, and can households carry it?',
     now:AX_LR.revGrowth, y10:tariffNeed, th:AX_LR.tariffCeiling, dir:'lo', u:'%',
     method:`The required increase is the compound annual growth in the cost base over the projection — the rate at which own revenue would have to rise to keep pace, holding volume constant. It is compared with the affordable ceiling of ${pctT(AX_LR.tariffCeiling)}, which is set from household income growth rather than from what the municipality needs.`,
     metric:'Compound annual tariff increase required, against the affordability ceiling.',
     cols:['Cost base','Own revenue needed','Required increase','Revenue per household'],
     rows: lr.rows.map((r,i)=>[fmt(r.exp), fmt(r.exp*(billed/b.t.revenue)), i===0?'—':pctT(r.exp/lr.rows[i-1].exp-1), 'R'+fmt(r.revPerHh)]),
     note:`Above the ceiling the increase stops raising revenue and starts lowering collection — households do not pay a bill they cannot afford, and the municipality books the difference as impairment instead of cash. That is the mechanism by which a tariff-led recovery plan fails.`},

    {n:'Required capital against planned spend', q:'Is the renewal programme large enough to replace what is being consumed and to work off the backlog?',
     now:b.capex/(b.depn+AX_LR.backlogOpen/10), y10:last.capex/(last.depn+AX_LR.backlogOpen/10), th:1.0, dir:'hi', u:'x',
     method:`Required renewal each year is depreciation — the asset consumed in the year — plus a tenth of the opening backlog, which is what a ten-year catch-up costs. Planned spend is the capital programme grown at ${pctT(AX_LR.revGrowth*0.7)}, plus whatever cash above ninety days can be deployed, capped at ${AX_N(AX_LR.absorb,0)}% above the existing programme because delivery capacity is the real constraint.`,
     metric:'Planned renewal and maintenance divided by required renewal.',
     cols:['Depreciation','Backlog catch-up','Required','Planned','Gap'],
     rows: lr.rows.map((r,i)=>[fmt(r.depn), fmt(AX_LR.backlogOpen/10), fmt(reqY[i]), fmt(r.capex), gapY[i]>0?'<span style="color:var(--red)">'+fmt(gapY[i])+'</span>':'—']),
     note:`Cumulative gap over ten years is R${fmt(totalGap)}k. A gap here is not a budget shortfall — it is infrastructure that will fail earlier than planned, and the cost of that failure lands in unplanned maintenance, which is the most expensive money a municipality spends.`},

    {n:'Long-term debt sustainability', q:'Can the debt be serviced from operations, and is it funding assets that outlast it?',
     now:b.debtRatio, y10:last.debtRatio, th:0.45, dir:'lo', u:'%',
     method:`New borrowing is taken at 30% of the capital programme, the existing book amortises at 8% a year, and interest runs at 8% on the closing balance. Debt is measured against total revenue and against the 45% Circular 71 ceiling. Debt service cover is the operating balance plus depreciation plus interest, over interest plus the portion falling due.`,
     metric:'Direct debt as a percentage of operating revenue, and the headroom to the ceiling.',
     cols:['Debt','Debt / revenue','Interest','Headroom to 45%'],
     rows: lr.rows.map(r=>[fmt(r.debt), pctT(r.debtRatio), fmt(r.debt*0.08), money(r.rev*0.45-r.debt)]),
     note:`Borrowing for a thirty-year asset repaid over fifteen is intergenerationally fair; borrowing to cover an operating deficit is a transfer from the next council to this one. The test is not only the ratio but what the money bought — which is why this analysis is read alongside the capital one above it.`},

    {n:'Asset consumption and the backlog', q:'Is the asset base growing, holding or shrinking, and how far behind is renewal?',
     now:AX_LR.backlogOpen/L('1000'), y10:last.backlog/last.ppe, th:0.15, dir:'lo', u:'%',
     method:`The carrying value rolls forward as opening value plus capital spend less depreciation. The backlog reduces by whatever deployment capacity allows and increases whenever renewal falls short of depreciation in a year. It never self-corrects.`,
     metric:'Renewal backlog as a percentage of the carrying value of infrastructure.',
     cols:['Carrying value','Capital spend','Depreciation','Backlog','Backlog %'],
     rows: lr.rows.map(r=>[fmt(r.ppe), fmt(r.capex), fmt(r.depn), fmt(r.backlog), pctT(r.backlog/r.ppe)]),
     note:`A backlog above 15% of carrying value is the level at which failure rates rise faster than the maintenance budget can absorb. The compounding works against the municipality: deferred renewal raises failures, failures raise unplanned maintenance, and unplanned maintenance takes the money that would have funded renewal.`},

    {n:'Generational equity', q:'Is this generation leaving more than it inherited, per resident?',
     now:(L('1000')+b.cash-b.debt)/AX.pop*1000, y10:last.naPerCap, th:(L('1000')+b.cash-b.debt)/AX.pop*1000, dir:'hi', u:'R',
     method:`Infrastructure plus cash less debt, divided by projected population at ${pctT(AX_LR.popGrowth)} growth. A falling number means residents are being added faster than the asset base that serves them, whatever the absolute figures do.`,
     metric:'Net assets per resident, in rands, at constant scope.',
     cols:['Carrying value','Cash','Debt','Population','Net assets per head'],
     rows: lr.rows.map(r=>[fmt(r.ppe), fmt(r.cash), fmt(r.debt), fmt(r.pop), 'R'+fmt(r.naPerCap)]),
     note:`This is the only measure in the module that adjusts for population. A municipality can grow its asset base in rands every year and still be going backwards for the people who live in it.`},
  ];

  const tb = document.querySelector('#scSust tbody');
  tb.innerHTML = ind.map((r,i)=>{
    const ok = r.dir==='hi' ? r.y10>=r.th : r.y10<=r.th;
    const f = x => r.u==='%'?pctT(x):r.u==='x'?AX_N(x,2)+'x':'R'+fmt(x);
    const det = `<tr data-det="s-${i}" style="display:none;"><td colspan="6" style="background:#fbfcfd;padding:14px 16px">
      <div class="ax-split" style="margin-bottom:12px">
        <div><div style="font-size:11px;font-weight:800;color:var(--navy-dark);margin-bottom:5px">How it is projected</div>
          <div style="font-size:11.5px;line-height:1.65;color:var(--ink)">${r.method}</div></div>
        <div><div style="font-size:11px;font-weight:800;color:var(--navy-dark);margin-bottom:5px">The metric</div>
          <div style="font-size:11.5px;line-height:1.65;color:var(--ink)">${r.metric}</div>
          <div style="font-size:11.5px;line-height:1.65;color:var(--muted);margin-top:9px">${r.note}</div></div></div>
      <div style="overflow:auto"><table class="grid"><thead><tr><th>Year</th>${lr.rows.map(x=>`<th class="num">Y${x.y}</th>`).join('')}</tr></thead><tbody>
        ${r.cols.map((cn,ci)=>`<tr><td><b>${cn}</b></td>${r.rows.map(rw=>`<td class="num">${rw[ci]}</td>`).join('')}</tr>`).join('')}
      </tbody></table></div></td></tr>`;
    return `<tr class="click" data-key="s-${i}"><td><b>${r.n}</b><span class="ax-src">Click for the method and the ten-year working</span></td>
      <td style="font-size:11px;color:var(--muted)">${r.q}</td><td class="num">${f(r.now)}</td><td class="num">${f(r.y10)}</td>
      <td class="num">${r.dir==='hi'?'≥ ':'≤ '}${f(r.th)}</td>
      <td><span class="pill ${ok?'GREEN':'RED'}">${ok?'Sustainable':'Not sustainable'}</span></td></tr>` + det;
  }).join('');
  axExpandable(tb);

  axChart(document.getElementById('scRenewChart'), lr.rows.map((r,i)=>({label:'Y'+r.y, r, req:reqY[i], gap:gapY[i],
    bars:[{v:r.capex,cls:'b-teal'},{v:reqY[i],cls:'b-grey'},{v:gapY[i],cls:'b-red'}]})),
    document.getElementById('scRenewRead'),
    c=>`<b>Year ${c.r.y}</b> — required renewal of <b>R${fmt(c.req)}k</b> (depreciation R${fmt(c.r.depn)}k plus backlog catch-up R${fmt(AX_LR.backlogOpen/10)}k)
        against planned spend of <b>R${fmt(c.r.capex)}k</b>.
        ${c.gap>0?`<span style="color:var(--red)">A gap of R${fmt(c.gap)}k. That is infrastructure not renewed this year, which returns as unplanned maintenance later.</span>`
        :`Fully funded, with R${fmt(c.r.capex-c.req)}k above the requirement going to clearing the backlog, which stands at R${fmt(c.r.backlog)}k.`}`);

  const fails = ind.filter(r=> r.dir==='hi' ? r.y10<r.th : r.y10>r.th);
  document.getElementById('scVerdictTag').textContent = `${fails.length} of ${ind.length} analyses fail at year ten`;
  document.getElementById('scVerdict').innerHTML = `
    <div class="ax-note ${fails.length===0?'ok':fails.length<=2?'warn':'bad'}"><b>${fails.length===0?'Structurally sound on these assumptions.'
      :fails.length<=2?'Sound, with two structural pressures to manage.':'Not structurally sound on these assumptions.'}</b>
      ${fails.length?`Failing: ${fails.map(f=>f.n.toLowerCase()).join('; ')}.`:'All six analyses hold within their thresholds through year ten.'}</div>
    <table class="grid"><tbody>
      <tr><td style="width:34%">What is driving it</td><td>Costs at ${pctT(AX_LR.costGrowth)} against revenue at ${pctT(AX_LR.revGrowth)} — a gap of
        ${pctT(AX_LR.costGrowth-AX_LR.revGrowth)} a year compounding to ${pctT(Math.pow(1+AX_LR.costGrowth-AX_LR.revGrowth,10)-1)} over ten years.</td></tr>
      <tr><td>What would close it on the revenue side</td><td>A tariff increase of ${pctT(tariffNeed)} every year for ten years.
        ${tariffNeed>AX_LR.tariffCeiling?`That is above the affordable ceiling of ${pctT(AX_LR.tariffCeiling)}, so it would not raise revenue — it would lower collection.`
        :'That sits inside the affordability ceiling, so it is achievable if council will carry it politically.'}</td></tr>
      <tr><td>What would close it on the cost side</td><td>Holding cost growth ${pctT(AX_LR.costGrowth-AX_LR.revGrowth)} lower means holding the wage bill and
        bulk purchases below inflation. Neither is set by the municipality, which is why cost-side solutions are usually headcount and service level, not price.</td></tr>
      <tr><td>The renewal position</td><td>Cumulative funding gap of R${fmt(totalGap)}k over ten years, with the backlog moving from
        R${fmt(AX_LR.backlogOpen)}k to R${fmt(last.backlog)}k. ${last.backlog<AX_LR.backlogOpen
        ?'The backlog is being worked off, but the pace is set by delivery capacity rather than by money.'
        :'The backlog grows, which means the asset base is being consumed faster than it is replaced.'}</td></tr>
      <tr class="total"><td>The honest reading</td><td>Near-term liquidity and long-run sustainability answer different questions and can disagree without
        either being wrong. Cash today is partly the product of renewal not done; the backlog that created is what the next ten years have to work off, and it
        can only be worked off as fast as the municipality can actually build.</td></tr>
    </tbody></table>`;
}

/* ==================== 3. TREASURY CONTROL TOWER RENDERERS ==================== */
function renderTreas(){
  const b = axBase(), t = b.t;
  const billed = L('4100')+L('4110')+L('4120')+L('4130')+L('4140');
  document.getElementById('tcKpis').innerHTML = kpiSet([
    ['Revenue collected in cash','R'+fmt(billed*AX.collection + b.transfers)+'k','',`R${fmt(billed*(1-AX.collection))}k of billed revenue was not collected`],
    ['Operating surplus','R'+money(t.surplus)+'k', t.surplus>0?'good':'bad', `${pctT(b.opMargin)} of revenue · depreciation of R${fmt(b.depn)}k is inside it`],
    ['Free cash','R'+fmt(b.freeCash)+'k','good',`${AX_N(b.daysCash,0)} days of cash operating cost · R${fmt(b.restricted)}k is restricted grant`],
    ['Reinvested from own funds','R'+fmt(Math.max(0,b.capex-L('4195')))+'k', (b.capex-L('4195'))<b.depn?'warn':'good',
      `Against depreciation of R${fmt(b.depn)}k — a renewal ratio of ${AX_N(b.renewalRatio,2)}x including grant funding`],
  ]);
  renderFlows(); renderPosition(); renderAlm(); renderCascade(); renderTcRatios();
}
function renderFlows(){
  const b = axBase(), t = b.t;
  document.getElementById('tcRevTag').textContent = `${PERIODS[state.period]} · R'000`;
  document.getElementById('tcExpTag').textContent = `${PERIODS[state.period]} · R'000`;
  const rev = LINES.filter(l=>l.cat==='revenue');
  document.querySelector('#tcRev tbody').innerHTML = rev.map(l=>{
    const c = AX_REVCTL[l.code]||['Own','',1];
    const coll = L(l.code)*c[2];
    return `<tr><td><b>${l.label}</b><span class="ax-src">${c[1]}</span></td>
      <td class="num">${fmt(L(l.code))}</td><td class="num">${fmt(coll)}</td>
      <td class="num" style="color:${c[2]<0.9?'var(--red)':'inherit'}">${pctT(c[2])}</td>
      <td><span class="pill ${c[0]==='Own'?'NAVY':'INCOMPLETE'}">${c[0]}</span></td></tr>`;
  }).join('') + `<tr class="total"><td>Total revenue</td><td class="num">${fmt(t.revenue)}</td>
    <td class="num">${fmt(rev.reduce((s,l)=>s+L(l.code)*(AX_REVCTL[l.code]||[,,1])[2],0))}</td>
    <td class="num">${pctT(rev.reduce((s,l)=>s+L(l.code)*(AX_REVCTL[l.code]||[,,1])[2],0)/t.revenue)}</td>
    <td><span class="pill NAVY">${pctT(b.ownShare)} own</span></td></tr>`;
  const exp = LINES.filter(l=>l.cat==='expenditure');
  document.querySelector('#tcExp tbody').innerHTML = exp.map(l=>{
    const n = AX_EXPNAT[l.code]||['Discretionary','',1];
    return `<tr><td><b>${l.label}</b><span class="ax-src">${n[1]}</span></td>
      <td class="num">${fmt(L(l.code))}</td><td class="num">${pctT(L(l.code)/t.expenditure)}</td>
      <td class="num">${n[2]?fmt(L(l.code)):'—'}</td>
      <td><span class="pill ${n[0]==='Committed'?'RED':n[0]==='Non-cash'?'INCOMPLETE':'GREEN'}">${n[0]}</span></td></tr>`;
  }).join('') + `<tr class="total"><td>Total expenditure</td><td class="num">${fmt(t.expenditure)}</td><td class="num">100.0%</td>
    <td class="num">${fmt(b.cashOpex)}</td>
    <td><span class="pill RED">${pctT(exp.filter(l=>(AX_EXPNAT[l.code]||[])[0]==='Committed').reduce((s,l)=>s+L(l.code),0)/t.expenditure)} committed</span></td></tr>`;

  const mixCols = rev.filter(l=>L(l.code)>5000).map(l=>{
    const c = AX_REVCTL[l.code]||['Own','',1];
    return {label:l.label.split(' ').slice(0,2).join(' ').replace('Service','Svc'), l, c,
      bars:[{v:c[0]==='Own'?L(l.code):0,cls:'b-navy'},{v:c[0]==='Transfer'?L(l.code):0,cls:'b-teal'},{v:L(l.code)*(1-c[2]),cls:'b-grey'}]};
  });
  axChart(document.getElementById('tcMixChart'), mixCols, document.getElementById('tcMixRead'),
    c=>`<b>${c.l.label}</b> — R${fmt(L(c.l.code))}k, ${pctT(L(c.l.code)/axBase().t.revenue)} of total revenue.
        ${c.c[0]==='Own'?'The municipality sets this tariff.':'Set nationally under the Division of Revenue Act — the municipality has no discretion over it.'}
        Collection runs at ${pctT(c.c[2])}, so <b>R${fmt(L(c.l.code)*(1-c.c[2]))}k</b> is billed and not received. ${c.c[1]}`);

  const capGrant = L('4195');
  document.querySelector('#tcResult tbody').innerHTML = [
    ['Total revenue', t.revenue, 1, ''],
    ['Less: operating expenditure', -t.expenditure, -t.expenditure/t.revenue, ''],
    ['Operating surplus (accrual)', t.surplus, t.surplus/t.revenue, 'This is the figure the statement of financial performance reports'],
    ['Add back: depreciation', b.depn, b.depn/t.revenue, 'Non-cash — the asset was consumed, no money left'],
    ['Add back: debt impairment', L('5120'), L('5120')/t.revenue, 'Non-cash — revenue was billed and written down, no money arrived either way'],
    ['Cash generated from operations', t.surplus+b.depn+L('5120'), (t.surplus+b.depn+L('5120'))/t.revenue, ''],
    ['Less: capital expenditure', -b.capex, -b.capex/t.revenue, `Of which R${fmt(capGrant)}k is grant funded`],
    ['Less: debt service', -b.debtService, -b.debtService/t.revenue, 'Interest plus the portion of borrowing falling due'],
    ['Cash available for discretion', t.surplus+b.depn+L('5120')-b.capex-b.debtService, (t.surplus+b.depn+L('5120')-b.capex-b.debtService)/t.revenue, 'What is genuinely free after everything contractual and capital'],
  ].map((r,i)=>`<tr class="${[2,5,8].includes(i)?'total':''}"><td>${r[0]}</td><td class="num">${money(r[1])}</td>
    <td class="num">${pctT(r[2])}</td><td style="font-size:11px;color:var(--muted)">${r[3]}</td></tr>`).join('');
}
function renderPosition(){
  const b = axBase();
  document.getElementById('tcPosTag').textContent = `${modeName()} · R'000`;
  const groups = [
    ['Cash treasury can deploy', ['1130'], 'Deployable, subject to the restriction shown below'],
    ['Amounts owed to the municipality', ['1110','1120'], 'Collectable in principle; the impairment charge says how much is not'],
    ['Assets that must be maintained', ['1000','1010','1020','1100'], 'Generate the billing and consume the maintenance budget'],
    ['Amounts owed by the municipality within a year', ['2100','2110','2120','2130'], 'First call on the cash box'],
    ['Long-dated obligations', ['2000','2010'], 'Funded from future operating surpluses, not from today\u2019s cash'],
  ];
  let h = '';
  groups.forEach(([g,codes,note])=>{
    h += `<tr class="section"><td colspan="6">${g}</td></tr>`;
    codes.forEach(c=>{
      const l = LINES.find(x=>x.code===c);
      const mv = LA(c,2)-LA(c,1);
      h += `<tr><td>${l.label}</td><td class="num">${fmt(LA(c,0))}</td><td class="num">${fmt(LA(c,1))}</td>
        <td class="num">${fmt(LA(c,2))}</td><td class="num" style="color:${mv<0?'var(--red)':'var(--green)'}">${money(mv)}</td>
        <td style="font-size:11px;color:var(--muted)">${note}</td></tr>`;
    });
    const sub = codes.reduce((s,c)=>s+L(c),0);
    h += `<tr class="total"><td>Subtotal</td><td class="num">${fmt(codes.reduce((s,c)=>s+LA(c,0),0))}</td>
      <td class="num">${fmt(codes.reduce((s,c)=>s+LA(c,1),0))}</td><td class="num">${fmt(sub)}</td><td class="num"></td><td></td></tr>`;
  });
  h += `<tr class="total"><td><b>Net assets</b></td><td class="num">${fmt(totalsAt(0).netAssets)}</td>
    <td class="num">${fmt(totalsAt(1).netAssets)}</td><td class="num">${fmt(b.t.netAssets)}</td>
    <td class="num">${money(totalsAt(2).netAssets-totalsAt(1).netAssets)}</td><td></td></tr>`;
  document.querySelector('#tcPos tbody').innerHTML = h;

  axChart(document.getElementById('tcWcChart'), [0,1,2].map(i=>({label:PERIODS[i], i,
    ca:sumAt('ca',i), cl:sumAt('cl',i),
    bars:[{v:sumAt('ca',i),cls:'b-teal'},{v:sumAt('cl',i),cls:'b-red'},{v:sumAt('ca',i)-sumAt('cl',i),cls:'b-navy'}]})),
    document.getElementById('tcWcRead'),
    c=>`<b>${c.label}</b> — current assets of R${fmt(c.ca)}k against current liabilities of R${fmt(c.cl)}k, leaving net working capital of
        <b>R${money(c.ca-c.cl)}k</b> and a current ratio of <b>${AX_N(c.ca/c.cl,2)}x</b>.
        ${c.ca/c.cl>2?'Above the 2.0x upper norm — cash sitting idle is a signal that the capital programme is not delivering, not that treasury is prudent.'
          :c.ca/c.cl>=1.5?'Inside the 1.5x to 2.0x norm the MFMA ratio guidance targets.':'Below the 1.5x norm.'}`);

  const layers = [
    ['Cash and equivalents', L('1130'), 'None — the gross balance', L('1130')/(b.cashOpex/365)],
    ['Less: unspent conditional grants', -L('2120'), 'Ring-fenced under the Division of Revenue Act; reverts if not spent on the approved purpose', 0],
    ['Less: consumer deposits and trust money', -28000, 'Held for third parties, repayable on demand', 0],
    ['Free cash', L('1130')-L('2120')-28000, 'Genuinely available to treasury', (L('1130')-L('2120')-28000)/(b.cashOpex/365)],
  ];
  document.querySelector('#tcCashQ tbody').innerHTML = layers.map((r,i)=>
    `<tr class="${i===3?'total':''}"><td>${r[0]}</td><td class="num">${money(r[1])}</td>
      <td style="font-size:11px;color:var(--muted)">${r[2]}</td><td class="num">${r[3]?AX_N(r[3],0):'—'}</td></tr>`).join('');
  document.getElementById('tcCashQNote').innerHTML = `<p class="footnote" style="margin:0">The headline cash figure on the statement of financial position is
    R${fmt(L('1130'))}k. Treasury can actually deploy <b>R${fmt(L('1130')-L('2120')-28000)}k</b> of it. Every liquidity measure in this system uses the free
    figure, because a municipality that spends restricted grant money on salaries has not solved a liquidity problem, it has created an audit finding.</p>`;
}
const AX_WC_LEV = [
 {k:'collDays', lbl:'Debtor days', min:-40, max:20, step:5, val:0, unit:' days',
  why:'A credit-control push shortens the collection cycle. Each day recovered releases one day of billed revenue into cash without changing the surplus.'},
 {k:'credDays', lbl:'Creditor days', min:-20, max:40, step:5, val:0, unit:' days',
  why:'Stretching creditors buys cash at the cost of the thirty-day rule in MFMA section 65(2)(e) and an audit finding. It is borrowing from suppliers at an infinite implied rate.'},
 {k:'capexTiming', lbl:'Capital spend deferred', min:0, max:60, step:5, val:0, unit:'%',
  why:'Deferring capital preserves cash inside the window but forfeits conditional grant and grows the renewal backlog.'},
 {k:'grantDraw', lbl:'Grant drawdown brought forward', min:0, max:100, step:10, val:0, unit:'%',
  why:'Claiming against qualifying expenditure earlier moves cash in without changing entitlement. It requires the expenditure to have been incurred and evidenced.'},
];
const AX_WC_VALS = Object.fromEntries(AX_WC_LEV.map(l=>[l.k,0]));
let tcWcTab = 'cycle';
function axWc(vals){
  const v = vals || AX_WC_VALS, b = axBase(), t = b.t;
  const dailyRev = t.revenue/365, dailyExp = t.expenditure/365;
  const debtorDays0 = b.receivables/dailyRev, credDays0 = L('2100')/dailyExp;
  const invDays0 = L('1100')/(L('5150')/365);
  const debtorDays = Math.max(0, debtorDays0 + v.collDays);
  const credDays = Math.max(0, credDays0 + v.credDays);
  const dCash = (debtorDays0-debtorDays)*dailyRev + (credDays-credDays0)*dailyExp
              + (v.capexTiming/100)*b.capex + (v.grantDraw/100)*L('2120');
  const freeCash = b.freeCash + dCash;
  return {b, t, dailyRev, dailyExp, debtorDays0, credDays0, invDays0, debtorDays, credDays,
    ccc0: debtorDays0 + invDays0 - credDays0, ccc: debtorDays + invDays0 - credDays,
    dCash, freeCash, daysCash: b.cashOpex? freeCash/(b.cashOpex/365) : 0,
    touched: AX_WC_LEV.filter(l=>v[l.k]!==0).length, v};
}
function axWeeks(freeCashStart, opts){
  const b = axBase(), o = opts||{};
  const wkRec = (b.t.revenue*AX.collection)/52, wkPay = b.cashOpex/52, wkCap = b.capex/52;
  const rows = [];
  let bal = freeCashStart;
  for(let w=1; w<=13; w++){
    /* municipal cash is lumpy: grants land in months one and four of the quarter,
       payroll and bulk purchases leave in the last week of each month */
    const grant = (w===1||w===5||w===9) ? L('4195')/4 : 0;
    const rec = wkRec*(w%4===1?1.35:0.88) + grant*(1 + (o.grantDraw||0)/100*0.25);
    const payroll = (w%4===0) ? (L('5100')+L('5110'))/12 : 0;
    const bulk = (w%4===2) ? L('5150')/12 : 0;
    const pay = wkPay*0.42 + payroll + bulk + wkCap*(1-(o.capexDefer||0)/100);
    bal = bal + rec - pay;
    rows.push({w, rec, pay, bal, net:rec-pay});
  }
  return rows;
}
function renderAlm(){
  const b = axBase(), lad = axLadder(), wc = axWc();
  document.getElementById('tcAlmTag').textContent = `${PERIODS[state.period]} · R'000 · contractual maturity`;
  document.getElementById('tcWcKpis').innerHTML = kpiSet([
    ['Cash conversion cycle', AX_N(wc.ccc0,0)+' days', wc.ccc0>60?'bad':wc.ccc0>30?'warn':'good',
      `${AX_N(wc.debtorDays0,0)} debtor + ${AX_N(wc.invDays0,0)} inventory − ${AX_N(wc.credDays0,0)} creditor days`],
    ['Net working capital', 'R'+money(sumAt('ca',idx())-sumAt('cl',idx()))+'k','good',
      `Current ratio ${AX_N(sumAt('ca',idx())/sumAt('cl',idx()),2)}x against a 1.5x to 2.0x norm`],
    ['Free cash', 'R'+fmt(b.freeCash)+'k','good', `${AX_N(b.daysCash,0)} days of cash operating cost`],
    ['Debt service cover', AX_N(b.dsCover,2)+'x', b.dsCover>=2?'good':'bad',
      `Operating balance plus depreciation plus interest, over debt service of R${fmt(b.debtService)}k`],
  ]);

  /* ---------- cycle ---------- */
  document.getElementById('tcCycTag').textContent = `${PERIODS[state.period]} · days`;
  const cyc = [
    ['Debtor days','Exchange and non-exchange receivables over daily total revenue', i=>{const t=totalsAt(i);return (LA('1110',i)+LA('1120',i))/(t.revenue/365);}, 60,'lo',
      'The single biggest lever on municipal cash. Every day here is one day of billed revenue sitting outside the bank.'],
    ['Inventory days','Inventory over daily bulk purchases', i=>LA('1100',i)/(LA('5150',i)/365), 30,'lo',
      'Small for a municipality — most consumption is bulk purchased and passed straight through.'],
    ['Creditor days','Trade and other payables over daily total expenditure', i=>{const t=totalsAt(i);return LA('2100',i)/(t.expenditure/365);}, 30,'lo',
      'Above thirty days is a breach of MFMA section 65(2)(e) and an audit finding, not a funding strategy.'],
    ['Cash conversion cycle','Debtor days plus inventory days less creditor days', i=>{const t=totalsAt(i);
      return (LA('1110',i)+LA('1120',i))/(t.revenue/365) + LA('1100',i)/(LA('5150',i)/365) - LA('2100',i)/(t.expenditure/365);}, 45,'lo',
      'How many days of operating cost the municipality has to fund itself between billing a consumer and being paid.'],
  ];
  document.querySelector('#tcCycle tbody').innerHTML = cyc.map(r=>{
    const v = [0,1,2].map(r[2]), ok = v[2]<=r[3];
    return `<tr class="${r[0]==='Cash conversion cycle'?'total':''}"><td><b>${r[0]}</b><span class="ax-src">${r[5]}</span></td>
      <td style="font-size:11px;color:var(--muted)">${r[1]}</td>
      ${v.map(x=>`<td class="num">${AX_N(x,0)}</td>`).join('')}
      <td class="num">≤ ${r[3]}</td><td><span class="pill ${ok?'GREEN':'RED'}">${ok?'Within':'Outside'}</span></td></tr>`;
  }).join('');
  document.getElementById('tcCycNote').innerHTML = `<p class="footnote" style="margin:0">The cycle runs at <b>${AX_N(wc.ccc0,0)} days</b>. At daily
    expenditure of R${fmt(wc.dailyExp)}k that is <b>R${fmt(wc.ccc0*wc.dailyExp)}k</b> of cash the municipality has to carry permanently just to bridge
    the gap between billing and being paid. Closing ten days of it releases R${fmt(10*wc.dailyExp)}k once, and then keeps it.</p>`;

  axChart(document.getElementById('tcWcChart2'), [0,1,2].map(i=>({label:PERIODS[i], i,
    r:LA('1110',i)+LA('1120',i), inv:LA('1100',i), p:LA('2100',i),
    bars:[{v:LA('1110',i)+LA('1120',i),cls:'b-teal'},{v:LA('1100',i),cls:'b-grey'},{v:LA('2100',i),cls:'b-red'},
          {v:Math.abs(LA('1110',i)+LA('1120',i)+LA('1100',i)-LA('2100',i)),cls:'b-navy'}]})),
    document.getElementById('tcWcRead2'),
    c=>`<b>${c.label}</b> — receivables R${fmt(c.r)}k, inventory R${fmt(c.inv)}k, payables R${fmt(c.p)}k.
        Net working capital excluding cash is <b>R${money(c.r+c.inv-c.p)}k</b>, which is the amount tied up in the operating cycle.
        Receivables are ${pctT(c.r/(c.r+c.inv))} of it — this is a collection problem before it is a cash problem.`);

  const ageBands = [
    ['Current — 30 days', 0.41, 0.97, 'Collected in the normal cycle'],
    ['31 to 60 days', 0.17, 0.88, 'Reminder and final notice stage'],
    ['61 to 90 days', 0.11, 0.71, 'Disconnection notice under the credit control policy'],
    ['91 to 180 days', 0.13, 0.42, 'Handed to debt collection; partially impaired'],
    ['Over 180 days', 0.18, 0.09, 'Substantially impaired; write-off requires council resolution under section 32'],
  ];
  document.querySelector('#tcAge tbody').innerHTML = ageBands.map(r=>
    `<tr><td><b>${r[0]}</b></td><td class="num">${fmt(b.receivables*r[1])}</td><td class="num">${pctT(r[1])}</td>
      <td class="num" style="color:${r[2]<0.5?'var(--red)':'inherit'}">${pctT(r[2])}</td>
      <td style="font-size:11px;color:var(--muted)">${r[3]}</td></tr>`).join('')
    + `<tr class="total"><td>Total receivables</td><td class="num">${fmt(b.receivables)}</td><td class="num">100.0%</td>
       <td class="num">${pctT(ageBands.reduce((s2,r)=>s2+r[1]*r[2],0))}</td><td>Expected realisation</td></tr>`;
  const expLoss = b.receivables*(1-ageBands.reduce((s2,r)=>s2+r[1]*r[2],0));
  document.getElementById('tcAgeNote').innerHTML = `<p class="footnote" style="margin:0">Expected credit loss across the book is
    <b>R${fmt(expLoss)}k</b> against an impairment provision of R${fmt(L('5120'))}k charged in the year and a receivables balance of
    R${fmt(b.receivables)}k. ${expLoss>L('5120')*2?'<b>The ageing suggests the provision is light</b> — that gap is exactly what finding AG-25-05 in the audit module is about.':'The provision is broadly consistent with the ageing.'}
    Over-180-day balances carry ${pctT(ageBands[4][1])} of the book and are expected to realise ${pctT(ageBands[4][2])}.</p>`;

  const layers = [
    ['Cash and equivalents', L('1130'), 'None — the gross balance on the statement of financial position', L('1130')/(b.cashOpex/365)],
    ['Less: unspent conditional grants', -L('2120'), 'Ring-fenced under the Division of Revenue Act; reverts if not spent on the approved purpose', 0],
    ['Less: consumer deposits and trust money', -28000, 'Held for third parties, repayable on demand', 0],
    ['Free cash', L('1130')-L('2120')-28000, 'Genuinely available to treasury', (L('1130')-L('2120')-28000)/(b.cashOpex/365)],
  ];
  document.querySelector('#tcCashQ tbody').innerHTML = layers.map((r,i)=>
    `<tr class="${i===3?'total':''}"><td>${r[0]}</td><td class="num">${money(r[1])}</td>
      <td style="font-size:11px;color:var(--muted)">${r[2]}</td><td class="num">${r[3]?AX_N(r[3],0):'—'}</td></tr>`).join('');
  document.getElementById('tcCashQNote').innerHTML = `<p class="footnote" style="margin:0">Headline cash is R${fmt(L('1130'))}k; treasury can deploy
    <b>R${fmt(L('1130')-L('2120')-28000)}k</b>. Every liquidity measure in this system uses the free figure, because a municipality that spends restricted
    grant on salaries has not solved a liquidity problem — it has created an audit finding.</p>`;

  /* ---------- 13-week projection ---------- */
  renderWeeks();

  /* ---------- coverage ---------- */
  const cover = [
    ['Debt service cover','A fall in the operating balance leaving debt service unpayable',
      b.dsCover, 1.5, 'hi','x','(operating balance + depreciation + interest) ÷ (interest + current portion)'],
    ['Interest cover','Interest alone outrunning the surplus',
      L('5140')? (b.t.surplus+b.depn+L('5140'))/L('5140') : 0, 3.0, 'hi','x','(operating balance + depreciation + interest) ÷ interest'],
    ['Liquidity cover','Cash running out before the next instalment falls due',
      b.liquidityCover, 1.0, 'hi','x','free cash ÷ debt service falling due within twelve months'],
    ['Borrowing to operating revenue','Leverage rising beyond the regulated ceiling',
      b.debtRatio, 0.45, 'lo','%','(long-term borrowing + current portion) ÷ operating revenue'],
    ['Debt service to operating revenue','Debt service crowding out service delivery',
      b.dsRatio, 0.08, 'lo','%','(interest + current portion) ÷ operating revenue'],
    ['Cost coverage','Being unable to fund one month of operations from cash',
      b.freeCash/(b.cashOpex/12), 1.0, 'hi','x','free cash ÷ one month of cash operating cost'],
  ];
  const ctb = document.querySelector('#tcCover tbody');
  ctb.innerHTML = cover.map((r,i)=>{
    const [n,prot,v,cov,dir,u,formula] = r;
    const ok = dir==='hi'? v>=cov : v<=cov;
    const f = x => u==='%'?pctT(x):AX_N(x,2)+'x';
    const head = dir==='hi'? (v-cov)/cov : (cov-v)/cov;
    const det = `<tr data-det="c-${i}" style="display:none;"><td colspan="6" style="background:#fbfcfd;padding:13px 16px">
      <table class="grid"><tbody>
        <tr><td style="width:26%">Formula</td><td><code>${formula}</code></td></tr>
        <tr><td>Value</td><td class="num">${f(v)}</td></tr>
        <tr><td>Covenant or norm</td><td class="num">${dir==='hi'?'≥ ':'≤ '}${f(cov)}</td></tr>
        <tr><td>Headroom</td><td>${pctT(Math.abs(head))} ${ok?'above the requirement':'short of the requirement'} — the measure could move
          ${f(Math.abs(v-cov))} before it breaches</td></tr>
        <tr><td>What breaches it</td><td style="font-size:11px">${prot}. In the risk module, the levers that move this measure are
          ${n.includes('Interest')||n.includes('Debt service')?'the interest rate lever and anything that reduces the operating balance'
           :n.includes('Liquidity')||n.includes('Cost')?'collection, the disaster event and capital delivery'
           :'the interest rate lever and new borrowing drawn for the capital programme'}.</td></tr>
        <tr class="total"><td>Consequence of breach</td><td style="font-size:11px">${dir==='hi'&&cov>=1.5
          ? 'A lender can call an event of default, accelerate the facility and price any refinancing at a materially wider spread.'
          : 'National Treasury and the provincial treasury are notified under the MFMA reporting regime, and the measure becomes a rating driver.'}</td></tr>
      </tbody></table></td></tr>`;
    return `<tr class="click" data-key="c-${i}"><td><b>${n}</b><span class="ax-src">Click for the formula and the headroom</span></td>
      <td style="font-size:11px;color:var(--muted)">${prot}</td><td class="num">${f(v)}</td><td class="num">${dir==='hi'?'≥ ':'≤ '}${f(cov)}</td>
      <td><div class="ax-prog"><i class="${ok?(head>0.5?'g':'a'):'r'}" style="width:${clamp(Math.abs(head)*100,4,100)}%"></i></div>
        <span class="ax-mini">${pctT(Math.abs(head),0)} ${ok?'headroom':'short'}</span></td>
      <td><span class="pill ${ok?'GREEN':'RED'}">${ok?'Compliant':'Breach'}</span></td></tr>` + det;
  }).join('');
  axExpandable(ctb);

  const instr = [
    ['DBSA amortising loan 2019', L('2000')*0.34, 'Fixed', 0.0915, 'Fixed to maturity in 2034'],
    ['DBSA amortising loan 2023', L('2000')*0.26, 'Fixed', 0.0985, 'Fixed to maturity in 2038'],
    ['Commercial bank term facility', L('2000')*0.28, 'Floating', AX.repo+0.0285, 'Reprices quarterly against three-month JIBAR'],
    ['Annuity loan — fleet and plant', L('2000')*0.12, 'Floating', AX.repo+0.0340, 'Reprices monthly against prime'],
    ['Current portion falling due', L('2110'), 'Mixed', 0.0940, 'Repaid within twelve months'],
  ];
  const floatShare = (L('2000')*0.40)/b.debt;
  document.querySelector('#tcReprice tbody').innerHTML = instr.map(r=>
    `<tr><td><b>${r[0]}</b></td><td class="num">${fmt(r[1])}</td>
      <td><span class="pill ${r[2]==='Floating'?'AMBER':'NAVY'}">${r[2]}</span></td>
      <td class="num">${pctT(r[3],2)}</td><td style="font-size:11px;color:var(--muted)">${r[4]}</td></tr>`).join('')
    + `<tr class="total"><td>Weighted average</td><td class="num">${fmt(b.debt)}</td><td>${pctT(floatShare,0)} floating</td>
       <td class="num">${pctT(instr.reduce((s2,r)=>s2+r[1]*r[3],0)/b.debt,2)}</td><td></td></tr>`;
  document.getElementById('tcRepriceNote').innerHTML = `<p class="footnote" style="margin:0">${pctT(floatShare,0)} of the book reprices within a year,
    so a 100 basis point move costs <b>R${fmt(b.debt*floatShare*0.01)}k</b> a year — ${pctT(b.debt*floatShare*0.01/b.t.revenue,2)} of operating revenue.
    Maturity and repricing are shown separately because a loan can be safely long-dated and dangerously floating at the same time.</p>`;

  const cap = [
    ['Borrowing to operating revenue — Circular 71 ceiling of 45%', b.opRevenue*0.45, b.debt, b.opRevenue*0.45-b.debt],
    ['Debt service to operating revenue — norm band upper bound of 8%', b.opRevenue*0.08/0.094, b.debtService/0.094, (b.opRevenue*0.08-b.debtService)/0.094],
    ['Debt service cover of 1.5x on the existing facility', (b.t.surplus+b.depn+L('5140'))/1.5, b.debtService, (b.t.surplus+b.depn+L('5140'))/1.5-b.debtService],
  ];
  document.querySelector('#tcCapacity tbody').innerHTML = cap.map(r=>
    `<tr><td style="font-size:11px">${r[0]}</td><td class="num">${fmt(r[1])}</td><td class="num">${fmt(r[2])}</td>
      <td class="num" style="color:${r[3]<0?'var(--red)':'var(--green)'};font-weight:800">${money(r[3])}</td></tr>`).join('')
    + `<tr class="total"><td>Binding constraint</td><td colspan="2">${cap.reduce((m,r)=>r[3]<m[3]?r:m)[0].split('—')[0].trim()}</td>
       <td class="num">${money(Math.min(...cap.map(r=>r[3])))}</td></tr>`;
  document.getElementById('tcCapNote').innerHTML = `<p class="footnote" style="margin:0">Additional borrowing capacity is
    <b>R${money(Math.min(...cap.map(r=>r[3])))}k</b>, set by the tightest of the three constraints. Capacity is not permission: MFMA section 46 still
    requires the borrowing to be for capital, disclosed in the budget, and preceded by public comment.</p>`;

  /* ---------- ladder ---------- */
  document.querySelector('#tcLadder tbody').innerHTML = lad.map(x=>
    `<tr><td><b>${x.k}</b></td><td class="num">${fmt(x.in)}</td><td class="num">${fmt(x.out)}</td>
      <td class="num" style="color:${x.gap<0?'var(--red)':'var(--green)'}">${money(x.gap)}</td>
      <td class="num" style="color:${x.cum<0?'var(--red)':'inherit'}">${money(x.cum)}</td>
      <td><div class="ax-prog"><i class="${x.cover>=1.2?'g':x.cover>=1?'a':'r'}" style="width:${clamp(x.cover/2*100,3,100)}%"></i></div>
        <span class="ax-mini">${AX_N(x.cover,2)}x cover</span></td></tr>`).join('')
    + `<tr class="total"><td>Total</td><td class="num">${fmt(lad.reduce((s2,x)=>s2+x.in,0))}</td>
       <td class="num">${fmt(lad.reduce((s2,x)=>s2+x.out,0))}</td>
       <td class="num">${money(lad.reduce((s2,x)=>s2+x.gap,0))}</td><td class="num"></td><td></td></tr>`;
  axChart(document.getElementById('tcGapChart'), lad.map(x=>({label:x.k.split(' ')[0], x,
    bars:[{v:x.in,cls:'b-teal'},{v:x.out,cls:'b-red'},{v:Math.abs(x.cum),cls:'b-navy'}]})),
    document.getElementById('tcGapRead'),
    c=>`<b>${c.x.k}</b> — inflows R${fmt(c.x.in)}k against outflows R${fmt(c.x.out)}k, a gap of <b>R${money(c.x.gap)}k</b>
        and a cumulative position of <b>R${money(c.x.cum)}k</b>. ${c.x.gap<0
        ? 'This bucket does not fund itself; it relies on the surplus carried in from earlier buckets.'
        : 'This bucket generates more than it consumes and carries the surplus forward.'} Cover ${AX_N(c.x.cover,2)}x.`);

  renderWcLevers();
}
function renderWeeks(){
  const b = axBase(), rows = axWeeks(b.freeCash, {});
  const buffer = b.cashOpex/12;
  document.getElementById('tcProjTag').textContent = `Opening free cash R${fmt(b.freeCash)}k · minimum buffer R${fmt(buffer)}k`;
  axChart(document.getElementById('tcProjChart'), rows.map(r=>({label:'W'+r.w, r,
    bars:[{v:r.rec,cls:'b-teal'},{v:r.pay,cls:'b-red'},{v:Math.max(0,r.bal/8),cls:'b-navy'},{v:buffer/8,cls:'b-gold'}]})),
    document.getElementById('tcProjRead'),
    c=>`<b>Week ${c.r.w}</b> — receipts R${fmt(c.r.rec)}k against payments R${fmt(c.r.pay)}k, a net movement of
        <b>R${money(c.r.net)}k</b> and a closing balance of <b>R${fmt(c.r.bal)}k</b>.
        ${c.r.w%4===0?'Payroll week — employee and councillor costs leave in this week.':''}
        ${c.r.w%4===2?'Bulk purchase week — Eskom and the water board are settled.':''}
        ${[1,5,9].includes(c.r.w)?'Grant tranche received in this week.':''}
        ${c.r.bal<buffer?'<span style="color:var(--red)">Below the minimum buffer of one month of operating cost.</span>':''}`);
  let h = '<thead><tr><th>Week</th>'+rows.map(r=>`<th class="num">W${r.w}</th>`).join('')+'</tr></thead><tbody>';
  [['Receipts',r=>fmt(r.rec)],['Payments',r=>fmt(r.pay)],['Net movement',r=>money(r.net)]].forEach(([n,f])=>{
    h += `<tr><td><b>${n}</b></td>`+rows.map(r=>`<td class="num">${f(r)}</td>`).join('')+'</tr>'; });
  h += `<tr class="total"><td>Closing balance</td>`+rows.map(r=>`<td class="num" style="color:${r.bal<buffer?'var(--red)':'inherit'}">${fmt(r.bal)}</td>`).join('')+'</tr>';
  document.getElementById('tcProjTable').innerHTML = h+'</tbody>';
  const low = rows.reduce((m,r)=>r.bal<m.bal?r:m);
  document.getElementById('tcProjNote').innerHTML = `<p class="footnote" style="margin:0">The trough is <b>week ${low.w}</b> at
    <b>R${fmt(low.bal)}k</b>, ${low.bal<buffer?`<span style="color:var(--red)">below the one-month buffer of R${fmt(buffer)}k</span>`
    :`R${fmt(low.bal-buffer)}k above the one-month buffer`}. Municipal cash is lumpy by construction: grant tranches land in weeks one, five and nine,
    payroll leaves every fourth week and bulk purchases every fourth week offset by two. A projection that smooths those flows will show comfort that is
    not there in the week it matters.</p>`;

  document.querySelector('#tcCommit tbody').innerHTML = [
    ['Employee and councillor costs', (L('5100')+L('5110'))/12*3, 'Weeks 4, 8 and 12', 'No', 'Contractual; a late payroll is an unauthorised expenditure event and an immediate labour dispute'],
    ['Bulk purchases — Eskom and water board', L('5150')/12*3, 'Weeks 2, 6 and 10', 'No', 'Supply interruption, interest at the supplier\u2019s penalty rate, and the arrears that drive Circular 124 eligibility'],
    ['Debt service instalment', L('2110')/4, 'Week 7', 'No', 'Event of default; acceleration of the facility'],
    ['Capital certificates', b.capex/52*13, 'Throughout', 'Partly', 'Deferrable by agreement with the contractor; forfeits grant if it delays qualifying expenditure past year end'],
    ['Contracted services and general', (L('5160')+L('5190'))/52*13, 'Throughout', 'Partly', 'Cancellable at notice; breaches the thirty-day rule if merely delayed rather than renegotiated'],
  ].map(r=>`<tr><td><b>${r[0]}</b></td><td class="num">${fmt(r[1])}</td><td>${r[2]}</td>
    <td><span class="pill ${r[3]==='No'?'RED':'AMBER'}">${r[3]}</span></td>
    <td style="font-size:11px;color:var(--muted)">${r[4]}</td></tr>`).join('');
}
function renderWcLevers(){
  const base = axWc(Object.fromEntries(AX_WC_LEV.map(l=>[l.k,0])));
  const wc = axWc();
  document.getElementById('tcLeverBox').innerHTML = AX_WC_LEV.map(l=>{
    const v = AX_WC_VALS[l.k];
    return `<div class="ax-lev ${v!==0?'touched':''}">
      <div class="lv-hd"><b>${l.lbl}</b><span class="now ${v!==0?'off':''}">${v>0?'+':''}${v}${l.unit}</span></div>
      <input type="range" data-wk="${l.k}" min="${l.min}" max="${l.max}" step="${l.step}" value="${v}"/>
      <div class="lv-ft"><span>${l.min}${l.unit}</span><span>base</span><span>+${l.max}${l.unit}</span></div>
      <div class="lv-why">${l.why}</div></div>`;
  }).join('');
  document.querySelectorAll('[data-wk]').forEach(inp=> inp.addEventListener('input', e=>{
    AX_WC_VALS[e.target.dataset.wk] = Number(e.target.value); renderWcLevers(); }));

  const buffer = base.b.cashOpex/12;
  document.getElementById('tcLevKpis').innerHTML = kpiSet([
    ['Cash released', 'R'+money(wc.dCash)+'k', wc.dCash>0?'good':wc.dCash<0?'bad':'',
      wc.touched?'One-off release, then held — none of this changes the surplus':'Move a lever to see the effect'],
    ['Cash conversion cycle', AX_N(wc.ccc,0)+' days', wc.ccc<base.ccc0?'good':wc.ccc>base.ccc0?'bad':'',
      `Base ${AX_N(base.ccc0,0)} days · movement ${AX_N(wc.ccc-base.ccc0,0)} days`],
    ['Free cash', 'R'+fmt(wc.freeCash)+'k','good', `${AX_N(wc.daysCash,0)} days of cover, base ${AX_N(base.daysCash,0)}`],
    ['Months of cost covered', AX_N(wc.freeCash/(base.b.cashOpex/12),1)+'x', wc.freeCash<buffer?'bad':'good',
      wc.freeCash<buffer?'Below the one-month minimum':'Above the one-month minimum'],
  ]);
  document.getElementById('tcLevTag').textContent = wc.touched ? `${wc.touched} lever(s) applied` : 'No lever applied';
  document.querySelector('#tcLevResult tbody').innerHTML = [
    ['Debtor days', base.debtorDays0, wc.debtorDays, 'd', false],
    ['Creditor days', base.credDays0, wc.credDays, 'd', false],
    ['Cash conversion cycle', base.ccc0, wc.ccc, 'd', false],
    ['Free cash', base.freeCash, wc.freeCash, 'R', true],
    ['Days cash on hand', base.daysCash, wc.daysCash, 'd', true],
    ['Operating surplus', base.t.surplus, base.t.surplus, 'R', true],
  ].map(([n,b0,b1,u,upGood])=>{
    const d = b1-b0, flat = Math.abs(d)<0.5, good = upGood ? d>0 : d<0;
    const f = x => u==='R'?'R'+money(x)+'k' : AX_N(x,0)+' d';
    return `<tr><td><b>${n}</b></td><td class="num">${f(b0)}</td><td class="num">${f(b1)}</td>
      <td class="num" style="color:${flat?'var(--muted)':good?'var(--green)':'var(--red)'}">${flat?'—':(d>0?'+':'')+f(d)}</td>
      <td>${flat?'<span class="pill INCOMPLETE">'+(n==='Operating surplus'?'Unaffected by design':'No change')+'</span>'
        :`<span class="pill ${good?'GREEN':'RED'}">${good?'Favourable':'Adverse'}</span>`}</td></tr>`;
  }).join('');
  const breach = wc.credDays > 30;
  document.getElementById('tcLevNote').innerHTML = `<p class="footnote" style="margin:0">${wc.touched===0
    ? 'No lever is applied. These four change the timing of cash, not the amount of it — the operating surplus row stays flat on purpose.'
    : `These settings release <b>R${money(wc.dCash)}k</b> of cash and leave the surplus untouched, because working capital is a timing question.
       ${breach?`<span style="color:var(--red)">Creditor days at ${AX_N(wc.credDays,0)} breach the thirty-day rule in MFMA section 65(2)(e) — the cash is real but so is the finding.</span>`
       :'Creditor days stay inside the thirty-day rule.'}
       ${AX_WC_VALS.capexTiming>0?` Deferring ${AX_WC_VALS.capexTiming}% of the capital programme holds R${fmt(base.b.capex*AX_WC_VALS.capexTiming/100)}k, at the cost of renewal not done and conditional grant at risk of reversal.`:''}`}</p>`;

  const base13 = axWeeks(base.freeCash, {}), lev13 = axWeeks(wc.freeCash, {capexDefer:AX_WC_VALS.capexTiming, grantDraw:AX_WC_VALS.grantDraw});
  axChart(document.getElementById('tcLevChart'), base13.map((r,i)=>({label:'W'+r.w, r, l:lev13[i],
    bars:[{v:Math.max(0,r.bal),cls:'b-grey'},{v:Math.max(0,lev13[i].bal),cls:'b-navy'},{v:buffer,cls:'b-gold'}]})),
    document.getElementById('tcLevRead'),
    c=>`<b>Week ${c.r.w}</b> — closing balance of <b>R${fmt(c.l.bal)}k</b> after the levers against R${fmt(c.r.bal)}k on the base path,
        a difference of <b>R${money(c.l.bal-c.r.bal)}k</b>. Buffer R${fmt(buffer)}k.
        ${c.l.bal<buffer?'<span style="color:var(--red)">Still below the buffer in this week.</span>'
          :c.r.bal<buffer?'<span style="color:var(--green)">The levers lift this week above the buffer.</span>':''}`);
}
function renderCascade(){
  const c = axCascade(), b = axBase();
  document.getElementById('tcCascTag').textContent = `${PERIODS[state.period]} · every figure from the ledger`;
  document.getElementById('tcCascade').innerHTML = c.map((s,i)=>
    `<div class="stg"><div class="no">${s.n}</div>
      <div><b>${s.t}</b><span class="sub">${s.s} · controlled by ${s.own}</span></div>
      <div class="amt">R${money(s.amt)}k<span class="ax-mini">${pctT(s.amt/c[0].amt)} of billed</span></div>
      <div class="leak">${s.leak?'− R'+fmt(s.leak)+'k':'—'}${s.leakN?`<span class="ax-mini">${s.leakN}</span>`:''}</div></div>` +
    (i<c.length-1?'<div class="flow"></div>':'')).join('');
  const final = c[c.length-1].amt, billed = c[0].amt;
  document.getElementById('tcCascNote').innerHTML = `<p class="footnote" style="margin:0">Of every rand billed to a ratepayer,
    <b>${(final/billed*100).toFixed(1)} cents</b> ends up reinvested in the infrastructure that makes the billing possible. The cascade is the
    single most useful view in this system because it is the only one that shows the stages in the order they actually bind: bulk suppliers are paid
    before employees, employees before maintenance, and maintenance before renewal. Anything that goes wrong upstream cannot be recovered downstream.</p>`;

  axChart(document.getElementById('tcLeakChart'), c.map(s=>({label:'S'+s.n, s,
    bars:[{v:s.amt,cls:'b-navy'},{v:s.leak,cls:'b-red'}]})),
    document.getElementById('tcLeakRead'),
    x=>`<b>Stage ${x.s.n} — ${x.s.t}</b><br/>Carries <b>R${money(x.s.amt)}k</b> forward, ${pctT(x.s.amt/c[0].amt)} of what was billed.
        ${x.s.leak?`Loses <b>R${fmt(x.s.leak)}k</b> to ${x.s.leakN.toLowerCase()}, which is ${pctT(x.s.leak/c[0].amt)} of billed revenue.`:'Nothing is lost at this stage — it is the starting point.'}
        <br/><span style="color:var(--muted)">Controlled by ${x.s.own}. ${x.s.s}</span>`);

  document.querySelector('#tcLeakOwn tbody').innerHTML = c.filter(s=>s.leak>0).map(s=>{
    const lever = {'Non-payment and indigent relief':'Collection rate','Bulk purchases':'Bulk cost above tariff increase',
      'Employee related cost':'Wage settlement above budget','Other operating expenditure':'—',
      'Finance cost':'Interest rate movement','Repairs and maintenance':'Repairs and maintenance',
      'Retained, not reinvested':'Capital programme delivery'}[s.leakN]||'—';
    const mod = {'Non-payment and indigent relief':'Treasury — revenue','Bulk purchases':'Scenario lab',
      'Employee related cost':'Scenario lab','Other operating expenditure':'Treasury — expenditure',
      'Finance cost':'Treasury — maturity & liquidity','Repairs and maintenance':'Scenario lab',
      'Retained, not reinvested':'Project risk'}[s.leakN]||'—';
    return `<tr><td><b>${s.leakN}</b></td><td class="num">${fmt(s.leak)}</td><td>${s.own}</td>
      <td>${lever==='—'?'<span style="color:var(--muted)">No lever — departmental budget decision</span>':lever}</td><td>${mod}</td></tr>`;
  }).join('');
}
let tcOnlyBreach = false;
function renderTcRatios(){
  const tb = document.querySelector('#tcRatios tbody');
  let rows = AX_RATIOS;
  if(tcOnlyBreach) rows = rows.filter(r=>{ const v=r[2](2); return r[5]==='hi'? v<r[4] : v>r[4]; });
  tb.innerHTML = rows.map((r,i)=>{
    const [n,g,fn,k,norm,dir,normTxt] = r;
    const vals = [0,1,2].map(fn);
    const v = vals[2], ok = dir==='hi'? v>=norm : v<=norm;
    const trend = vals[2]-vals[1];
    const det = `<tr data-det="r-${i}" style="display:none;"><td colspan="7" style="background:#fbfcfd;padding:13px 16px">
      <div class="ax-split">
        <div><table class="grid"><tbody>
          <tr><td style="width:40%">Group</td><td>${g}</td></tr>
          <tr><td>Norm</td><td>${normTxt} — MFMA Circular 71 municipal financial ratio guidance</td></tr>
          <tr><td>Direction</td><td>${dir==='hi'?'Higher is stronger':'Lower is stronger'}</td></tr>
          <tr><td>Three-year movement</td><td>${axRatioFmt(vals[0],k)} → ${axRatioFmt(vals[1],k)} → ${axRatioFmt(vals[2],k)}</td></tr>
          <tr class="total"><td>Trend</td><td>${Math.abs(trend)<0.0001?'Flat':(dir==='hi'? (trend>0?'Improving':'Deteriorating') : (trend<0?'Improving':'Deteriorating'))}</td></tr>
        </tbody></table></div>
        <div><div style="font-size:11px;font-weight:800;color:var(--navy-dark);margin-bottom:5px">Where else this ratio appears</div>
          <table class="grid"><tbody>
            <tr><td>Credit rating model</td><td>${Object.entries(AX_BANDS).find(([,B])=>B.lbl.toLowerCase().includes(n.split(' ')[0].toLowerCase()))?'Scores a rating factor directly':'Informs the factor but is not itself scored'}</td></tr>
            <tr><td>Scenario lab</td><td>Recomputed under every lever setting</td></tr>
            <tr><td>Sustainability</td><td>${['Capital renewal to depreciation','Repairs and maintenance to infrastructure','Debt to operating revenue'].includes(n)?'A structural indicator — projected ten years out':'Not projected; it is a within-year measure'}</td></tr>
          </tbody></table></div></div></td></tr>`;
    return `<tr class="click" data-key="r-${i}"><td><b>${n}</b><span class="ax-src">Click for the three-year path and the cross-references</span></td>
      <td>${g}</td>${vals.map(x=>`<td class="num">${axRatioFmt(x,k)}</td>`).join('')}
      <td class="num">${normTxt}</td><td><span class="pill ${ok?'GREEN':'RED'}">${ok?'Within':'Outside'}</span></td></tr>` + det;
  }).join('');
  axExpandable(tb);
}

/* ==================== 4. ASSET REGISTER & AMP ==================== */
/* Class-level register. Gross less accumulated depreciation ties to LINES 1000. */
const AR_CLASSES = [
 {k:'land',   n:'Land',                              items:1842, gross:186000, accum:0,      life:0,  bench:'Indefinite — not depreciated', benchLife:0,  reviewed:'2024',
  grap:'GRAP 17 — not depreciated; impairment still applies', cond:2.1, repl:1.00, comp:0,   maint:0.000},
 {k:'build',  n:'Buildings and offices',              items:318,  gross:412000, accum:128400, life:42, bench:'30 to 50 years',              benchLife:40, reviewed:'2019',
  grap:'GRAP 17 — componentise structure, roof, services', cond:2.8, repl:1.62, comp:0.34, maint:0.021},
 {k:'roads',  n:'Infrastructure — roads and stormwater', items:2140, gross:985000, accum:386000, life:28, bench:'Surfacing 8 to 15 years; componentised 12 to 40', benchLife:22, reviewed:'2018',
  grap:'GRAP 17 — componentise subgrade, base, surfacing, kerbs', cond:3.4, repl:1.78, comp:0.18, maint:0.014},
 {k:'water',  n:'Infrastructure — water',             items:1685, gross:742000, accum:241000, life:48, bench:'Pipes 30 to 60 years',        benchLife:45, reviewed:'2023',
  grap:'GRAP 17 — componentise civil, mechanical, electrical', cond:2.9, repl:1.71, comp:0.52, maint:0.018},
 {k:'san',    n:'Infrastructure — sanitation',        items:1204, gross:508000, accum:177000, life:44, bench:'30 to 50 years',              benchLife:40, reviewed:'2023',
  grap:'GRAP 17 — componentise reticulation and treatment plant', cond:3.1, repl:1.69, comp:0.47, maint:0.016},
 {k:'elec',   n:'Infrastructure — electricity',       items:1516, gross:624000, accum:228000, life:36, bench:'25 to 40 years',              benchLife:32, reviewed:'2022',
  grap:'GRAP 17 — componentise network, substations, metering', cond:3.0, repl:1.74, comp:0.41, maint:0.019},
 {k:'comm',   n:'Community assets',                   items:462,  gross:168000, accum:61000,  life:34, bench:'20 to 40 years',              benchLife:30, reviewed:'2019',
  grap:'GRAP 17 — halls, parks, cemeteries, libraries', cond:3.3, repl:1.58, comp:0.09, maint:0.012},
 {k:'plant',  n:'Plant and equipment',                items:874,  gross:96000,  accum:54000,  life:12, bench:'8 to 15 years',               benchLife:12, reviewed:'2024',
  grap:'GRAP 17 — units of production where use varies', cond:3.2, repl:1.44, comp:0.00, maint:0.045},
 {k:'veh',    n:'Motor vehicles',                     items:216,  gross:82000,  accum:49000,  life:9,  bench:'Light 5 to 10; heavy 10 to 15', benchLife:8, reviewed:'2024',
  grap:'GRAP 17 — residual value 10 to 20% of cost typical', cond:3.5, repl:1.38, comp:0.00, maint:0.068},
 {k:'furn',   n:'Furniture, fittings and IT',         items:3268, gross:31000,  accum:22000,  life:8,  bench:'IT 3 to 5; furniture 8 to 15', benchLife:6, reviewed:'2024',
  grap:'GRAP 17 — low residual value; IT obsolescence rapid', cond:3.6, repl:1.32, comp:0.00, maint:0.052},
 {k:'wip',    n:'Capital work in progress',           items:11,   gross:60400,  accum:0,      life:0,  bench:'Not depreciated until available for use', benchLife:0, reviewed:'2025',
  grap:'GRAP 17.23 — directly attributable cost only', cond:0,   repl:1.00, comp:0,   maint:0.000},
];
/* general ledger position — two deliberate differences, as the register normally has */
const AR_GL = {veh:{gross:87400, accum:49000}, plant:{gross:96000, accum:47800}};
const AR_ADJ = {posted:false};
function arRows(){
  return AR_CLASSES.map(c=>{
    const g = AR_ADJ.posted && c.k==='veh' ? c.gross : c.gross;
    const gl = AR_GL[c.k] || {gross:c.gross, accum:c.accum};
    const glGross = (AR_ADJ.posted && c.k==='veh') ? c.gross : gl.gross;
    const glAccum = (AR_ADJ.posted && c.k==='plant') ? c.accum : gl.accum;
    const regCA = c.gross - c.accum, glCA = glGross - glAccum;
    return {...c, ca:regCA, glGross, glAccum, glCA, diff:glCA-regCA,
      consumed: c.gross? c.accum/c.gross : 0,
      remaining: c.gross? (c.gross-c.accum)/c.gross : 0,
      yearsLeft: c.life? c.life*((c.gross-c.accum)/c.gross) : 0,
      annual: c.life? (c.gross)/c.life : 0,
      benchAnnual: c.benchLife? (c.gross)/c.benchLife : 0,
      replacement: c.gross*c.repl};
  });
}
function arTotals(){
  const r = arRows();
  const add = k => r.reduce((s,x)=>s+(x[k]||0),0);
  const regCA = add('ca'), glCA0 = add('glCA');
  /* Posting actions elsewhere in the pack — the disaster run, the audit adjustments,
     capitalised certified work — move the ledger's PPE control account immediately.
     The register is a sub-ledger and is rolled forward on a cycle, so a gap opens
     between the two the moment anything posts. That gap is a reconciling item with a
     name, not a discrepancy: it is the movement posted since the register was struck.
     Recognising it here is what keeps the register, the ledger and the note tied when
     the modules run together, which they did not do when this was a separate file. */
  const posted = L('1000') - glCA0;
  return {rows:r, gross:add('gross'), accum:add('accum'), ca:regCA,
    glGross:add('glGross'), glAccum:add('glAccum'),
    glCA: glCA0 + posted, posted,
    diff: (glCA0 + posted) - regCA,
    items:add('items'), annual:add('annual'), benchAnnual:add('benchAnnual'), replacement:add('replacement')};
}
/* seventeen minimum fields, from the audit assertion set */
const AR_FIELDS = [
 [1,'Unique asset ID / barcode','Enables one-to-one physical linkage to the asset','Existence',0.982,'Duplicate identifiers, and assets with no barcode tag physically attached'],
 [2,'Asset description','Identifies the asset with enough specificity to be found and verified','Completeness / Existence',0.918,'Generic descriptions — "computer equipment" with no make, model or serial number'],
 [3,'Asset class','Determines which standard applies and where it is disclosed','Classification',0.996,'Intangibles sitting in the PPE register; investment property classified as PPE'],
 [4,'Physical location','Enables verification and supports the existence assertion','Existence',0.874,'Location not updated after transfer; "stores" recorded for assets long since deployed'],
 [5,'Asset custodian','Assigns accountability under MFMA section 63','Rights and obligations',0.791,'No custodian assigned; assets of departed staff never reallocated'],
 [6,'Date available for use','Determines when depreciation starts','Accuracy / Cut-off',0.934,'Invoice date used instead of the date the asset was available for use'],
 [7,'Cost or historical cost','Opening carrying amount and the basis for depreciation','Valuation / Accuracy',0.988,'Estimated cost recorded where an actual invoice was available'],
 [8,'Accumulated depreciation','Supports the carrying amount and reconciles to the ledger','Valuation',0.961,'Manually adjusted without recalculation; not rolled forward annually'],
 [9,'Useful life — original and revised','Basis for the annual charge; must be reviewed each year','Valuation / Accuracy',0.847,'Never revised since initial GRAP adoption'],
 [10,'Residual value','Reduces the depreciable amount; annual review required','Valuation',0.612,'Zero for every asset with no assessment ever performed'],
 [11,'Depreciation method','Documents the policy choice and lets an auditor recalculate','Consistency / Accuracy',0.994,'Inconsistent methods applied within the same asset class'],
 [12,'Carrying amount','Must agree to the ledger balance and the note','Valuation / Completeness',0.958,'Register carrying amount does not tie to the ledger — the most common reconciliation failure'],
 [13,'Condition rating','Supports impairment assessment and maintenance planning','Valuation / Disclosure',0.703,'Not captured, or recorded as "good" for every asset on the register'],
 [14,'Disposal status','Identifies assets disposed, scrapped, stolen or written off','Completeness / Rights',0.886,'Disposed assets not removed; removal without a section 14 council resolution'],
 [15,'Component information','Required wherever an asset has been componentised','Completeness / Accuracy',0.394,'Components not separately identified; a building carried as a single line'],
 [16,'Supporting document reference','Links the asset to its invoice, contract or resolution','Rights and obligations',0.822,'No document reference recorded; untraceable when the file is requested'],
 [17,'Insurance information','Assets must be insured at replacement cost','Completeness',0.758,'Insured value not updated; cover taken at cost rather than replacement cost'],
];
const AR_COMP_EX = [
 ['Civil structure',      18200, 50, 500, 'Concrete reservoirs, inlet works and channels'],
 ['Mechanical plant',      9800, 20, 300, 'Pumps, blowers, screens and dosing equipment'],
 ['Electrical systems',    6400, 25, 150, 'Switchgear, motor control centres, cabling'],
 ['Filtration membranes',  4200,  7,   0, 'Consumable by design; replaced on a fixed cycle'],
 ['SCADA and control',     2800, 10, 100, 'Instrumentation, telemetry and control software'],
 ['Access roads and civils',1200,30,   0, 'Site access, hardstanding and fencing'],
];
const AR_WIP = [
 ['CP-01 Bulk water augmentation phase 2', 11600, 24500, 0, 'On programme', 2.0],
 ['CP-02 Electricity network strengthening', 0, 16400, 0, 'On programme', 1.0],
 ['CP-03 Wastewater works refurbishment', 0, 14200, 0, 'On programme', 1.0],
 ['CP-04 Roads rehabilitation wards 4 to 9', 0, 12100, 0, 'Behind programme', 1.0],
 ['CP-05 Landfill cell 3 and weighbridge', 0, 7600, 0, 'On programme', 0.5],
 ['Clinic upgrade — Ward 11', 8900, 0, 0, 'Complete but not transferred', 3.5],
 ['Sports facility — Ward 3', 6200, 0, 0, 'Stalled; contractor in liquidation', 4.5],
 ['Traffic signalling upgrade', 4100, 0, 4100, 'Transferred to PPE in the year', 1.5],
];

let arOnlyGaps = false;
function renderAsset(){
  const T = arTotals(), b = axBase();
  const mat = totals().expenditure*0.01;
  document.getElementById('arKpis').innerHTML = kpiSet([
    ['Carrying amount on the register','R'+fmt(T.ca)+'k','',`${fmt(T.items)} items across ${AR_CLASSES.length} classes · gross cost R${fmt(T.gross)}k`],
    ['Difference to the general ledger','R'+money(T.diff)+'k', Math.abs(T.diff)>mat?'bad':Math.abs(T.diff)>0?'warn':'good',
      Math.abs(T.posted)>0.5
        ? `Of which R${money(T.posted)}k is movement posted by other modules since the register was struck`
        : Math.abs(T.diff)>mat?`Above materiality of R${fmt(mat)}k — a material misstatement`
        : Math.abs(T.diff)>0?'Below materiality but still a reconciling item':'Register and ledger agree'],
    ['Asset base consumed', pctT(T.accum/T.gross), T.accum/T.gross>0.5?'warn':'good',
      `R${fmt(T.gross-T.accum)}k of service potential remains against R${fmt(T.gross)}k of original cost`],
    ['Register data completeness', pctT(AR_FIELDS.reduce((s,f)=>s+f[4],0)/AR_FIELDS.length), 'warn',
      `${AR_FIELDS.filter(f=>f[4]<0.90).length} of 17 required fields below 90% populated`],
  ]);

  /* ---------- 1 summary ---------- */
  document.getElementById('arSumTag').textContent = `${PERIODS[state.period]} · R'000 · ties to the PPE line on the statement of financial position`;
  document.querySelector('#arSummary tbody').innerHTML = T.rows.map(r=>
    `<tr><td><b>${r.n}</b><span class="ax-src">${r.grap}</span></td><td class="num">${fmt(r.items)}</td>
      <td class="num">${fmt(r.gross)}</td><td class="num">${r.accum?'('+fmt(r.accum)+')':'—'}</td>
      <td class="num"><b>${fmt(r.ca)}</b></td>
      <td><div class="ax-prog"><i class="${r.consumed>0.6?'r':r.consumed>0.4?'a':'g'}" style="width:${r.consumed*100}%"></i></div>
        <span class="ax-mini">${pctT(r.consumed)} consumed</span></td>
      <td style="font-size:11px;color:var(--muted)">${r.bench}</td></tr>`).join('')
    + `<tr class="total"><td>Register total</td><td class="num">${fmt(T.items)}</td><td class="num">${fmt(T.gross)}</td>
       <td class="num">(${fmt(T.accum)})</td><td class="num">${fmt(T.ca)}</td>
       <td>${pctT(T.accum/T.gross)} consumed</td><td>The sub-ledger as last rolled forward</td></tr>`
    + (Math.abs(T.posted)>0.5 ? `<tr><td><b>Movements posted since the register was struck</b>
         <span class="ax-src">Capitalised certified work, disaster restoration and audit adjustments posted by the other modules</span></td>
       <td class="num">—</td><td class="num">${money(T.posted)}</td><td class="num">—</td><td class="num">${money(T.posted)}</td>
       <td><span class="pill AMBER">Reconciling item</span></td>
       <td style="font-size:11px;color:var(--muted)">To be absorbed at the next roll-forward, with each asset identified</td></tr>` : '')
    + (Math.abs(T.glCA-T.ca-T.posted)>0.5 ? `<tr><td><b>Unreconciled register-to-ledger differences</b>
         <span class="ax-src">Disposed vehicles not removed from the ledger, and fourth-quarter depreciation not processed in the register</span></td>
       <td class="num">—</td><td class="num">${money(T.glCA-T.ca-T.posted)}</td><td class="num">—</td><td class="num">${money(T.glCA-T.ca-T.posted)}</td>
       <td><span class="pill RED">Correction required</span></td>
       <td style="font-size:11px;color:var(--muted)">See the three-way reconciliation tab</td></tr>` : '')
    + `<tr class="total"><td>Carrying amount per the general ledger</td><td class="num"></td><td class="num"></td><td class="num"></td>
       <td class="num">${fmt(T.glCA)}</td><td></td>
       <td>${Math.abs(T.glCA-L('1000'))<0.5?'Agrees to the PPE line on the statement of financial position':'<span style="color:var(--red)">Does not agree to the statement of financial position</span>'}</td></tr>`;

  axChart(document.getElementById('arMixChart'), T.rows.filter(r=>r.gross>50000).map(r=>({
    label:r.n.split(' ')[0].replace('Infrastructure','Infra'), r,
    bars:[{v:r.gross,cls:'b-grey'},{v:r.ca,cls:'b-navy'},{v:r.accum,cls:'b-red'}]})),
    document.getElementById('arMixRead'),
    c=>`<b>${c.r.n}</b> — gross cost R${fmt(c.r.gross)}k, of which <b>R${fmt(c.r.accum)}k</b> has been consumed, leaving a carrying amount of
        <b>R${fmt(c.r.ca)}k</b>. ${pctT(c.r.remaining)} of the original service potential remains.
        ${c.r.life?`At the ${c.r.life}-year life applied that is about <b>${AX_N(c.r.yearsLeft,0)} years</b> of remaining life.`:'Not depreciated.'}
        Replacement cost is estimated at R${fmt(c.r.replacement)}k, ${AX_N(c.r.repl,2)} times original cost.`);

  document.querySelector('#arLife tbody').innerHTML = T.rows.filter(r=>r.life).map(r=>
    `<tr><td><b>${r.n}</b></td>
      <td><div class="ax-prog"><i class="${r.remaining<0.35?'r':r.remaining<0.55?'a':'g'}" style="width:${r.remaining*100}%"></i></div>
        <span class="ax-mini">${pctT(r.remaining)} remaining</span></td>
      <td class="num">${AX_N(r.yearsLeft,0)}</td>
      <td><span class="pill ${r.remaining<0.35?'RED':r.remaining<0.55?'AMBER':'GREEN'}">${r.remaining<0.35?'Near end of life':r.remaining<0.55?'Second half of life':'Early life'}</span></td></tr>`).join('');
  const weighted = T.rows.filter(r=>r.life).reduce((s,r)=>s+r.ca*r.yearsLeft,0)/T.rows.filter(r=>r.life).reduce((s,r)=>s+r.ca,0);
  document.getElementById('arLifeNote').innerHTML = `<p class="footnote" style="margin:0">The weighted average remaining life across the depreciable base
    is <b>${AX_N(weighted,1)} years</b>. Roads are the shortest-lived material class at ${AX_N(T.rows.find(r=>r.k==='roads').yearsLeft,0)} years remaining,
    which is why the renewal profile in the asset management plan is front-loaded. Remaining life computed off carrying amount assumes straight-line
    consumption; where an asset has been componentised, the component lives give a sharper answer than the class average does.</p>`;

  /* ---------- 2 data quality ---------- */
  const avg = AR_FIELDS.reduce((s,f)=>s+f[4],0)/AR_FIELDS.length;
  document.getElementById('arQualKpis').innerHTML = kpiSet([
    ['Fields fully populated', String(AR_FIELDS.filter(f=>f[4]>=0.99).length)+' of 17','',
      'A field is only complete when every asset carries it'],
    ['Fields below 90%', String(AR_FIELDS.filter(f=>f[4]<0.90).length), 'warn','Each one is an assertion the register cannot currently support'],
    ['Weakest field', AR_FIELDS.reduce((m,f)=>f[4]<m[4]?f:m)[1], 'bad',
      pctT(AR_FIELDS.reduce((m,f)=>f[4]<m[4]?f:m)[4])+' populated'],
    ['Overall completeness', pctT(avg), avg<0.9?'warn':'good','Simple average across the seventeen required fields'],
  ]);
  const ftb = document.querySelector('#arFields tbody');
  let flist = AR_FIELDS; if(arOnlyGaps) flist = flist.filter(f=>f[4]<0.99);
  ftb.innerHTML = flist.map(f=>{
    const [no,name,purpose,assertion,pop,err] = f;
    const missing = Math.round(arTotals().items*(1-pop));
    const det = `<tr data-det="f-${no}" style="display:none;"><td colspan="7" style="background:#fbfcfd;padding:13px 16px">
      <table class="grid"><tbody>
        <tr><td style="width:24%">What this field proves</td><td>${purpose}</td></tr>
        <tr><td>Assertion it supports</td><td><b>${assertion}</b> — without this field the assertion cannot be evidenced, whatever else the register contains</td></tr>
        <tr><td>Populated</td><td>${pctT(pop)} of ${fmt(arTotals().items)} items — approximately <b>${fmt(missing)} assets</b> are missing it</td></tr>
        <tr><td>What goes wrong</td><td style="color:var(--red)">${err}</td></tr>
        <tr class="total"><td>Consequence if not fixed</td><td>${pop<0.7?'A qualification risk in its own right — the assertion fails at population level.'
          :pop<0.9?'An audit finding and a management letter point; not a qualification on its own, but it compounds with others.'
          :'Within tolerance, but the exceptions still need clearing before year end.'}</td></tr>
      </tbody></table></td></tr>`;
    return `<tr class="click" data-key="f-${no}"><td class="num">${no}</td><td><b>${name}</b><span class="ax-src">Click for the detail</span></td>
      <td style="font-size:11px;color:var(--muted)">${purpose}</td><td style="font-size:11px">${assertion}</td>
      <td class="num">${pctT(pop)}</td>
      <td><div class="ax-prog"><i class="${pop<0.7?'r':pop<0.9?'a':'g'}" style="width:${pop*100}%"></i></div>
        <span class="ax-mini">${fmt(missing)} missing</span></td>
      <td><span class="pill ${pop>=0.99?'GREEN':pop>=0.9?'AMBER':'RED'}">${pop>=0.99?'Complete':pop>=0.9?'Gaps':'Material gap'}</span></td></tr>` + det;
  }).join('');
  axExpandable(ftb);

  /* ---------- 3 PPE breakdown ---------- */
  document.getElementById('arPpeTag').textContent = `Materiality R${fmt(mat)}k · differences above it are misstatements`;
  document.querySelector('#arPpe tbody').innerHTML = T.rows.map(r=>{
    const ok = Math.abs(r.diff)<0.5;
    return `<tr><td><b>${r.n}</b></td><td class="num">${fmt(r.gross)}</td>
      <td class="num" style="${r.glGross!==r.gross?'color:var(--red);font-weight:800':''}">${fmt(r.glGross)}</td>
      <td class="num">(${fmt(r.accum)})</td>
      <td class="num" style="${r.glAccum!==r.accum?'color:var(--red);font-weight:800':''}">(${fmt(r.glAccum)})</td>
      <td class="num" style="color:${ok?'var(--muted)':'var(--red)'}">${ok?'—':money(r.diff)}</td>
      <td><span class="pill ${ok?'GREEN':'RED'}">${ok?'Agrees':'Difference'}</span></td></tr>`;
  }).join('')
    + `<tr class="total"><td>Total</td><td class="num">${fmt(T.gross)}</td><td class="num">${fmt(T.glGross)}</td>
       <td class="num">(${fmt(T.accum)})</td><td class="num">(${fmt(T.glAccum)})</td>
       <td class="num" style="color:${Math.abs(T.diff)>mat?'var(--red)':'inherit'}">${money(T.diff)}</td>
       <td><span class="pill ${Math.abs(T.diff)>mat?'RED':Math.abs(T.diff)>0?'AMBER':'GREEN'}">${Math.abs(T.diff)>mat?'Material':Math.abs(T.diff)>0?'Below materiality':'Clean'}</span></td></tr>`;

  document.querySelector('#arValid tbody').innerHTML = [
    ['Carrying amount is not negative','Accumulated depreciation exceeding cost, usually from a manual adjustment', 0, 0],
    ['Accumulated depreciation does not exceed depreciable amount','Depreciation continuing past the residual value', 34, 1240],
    ['Every asset has a useful life greater than zero','Assets that will never depreciate because the field is blank', 148, 8600],
    ['Date available for use is not in the future','Cut-off error at year end', 7, 3100],
    ['No duplicate barcodes','The same asset counted twice', 21, 2400],
    ['Disposed assets carry a council resolution reference','Removal without the authority MFMA section 14 requires', 12, 5400],
    ['Insured value is at replacement, not cost','Under-insurance, and a premium that is fruitless if the asset does not exist', 2612, 0],
    ['Every component links to a parent asset','Orphan components that depreciate against nothing', 0, 0],
  ].map(r=>`<tr><td><b>${r[0]}</b></td><td style="font-size:11px;color:var(--muted)">${r[1]}</td>
    <td class="num" style="color:${r[2]?'var(--red)':'inherit'}">${r[2]||'—'}</td>
    <td class="num">${r[3]?fmt(r[3]):'—'}</td>
    <td><span class="pill ${r[2]===0?'GREEN':r[3]>5000?'RED':'AMBER'}">${r[2]===0?'Pass':r[3]>5000?'Fail — material':'Fail'}</span></td></tr>`).join('');

  /* ---------- 4 componentisation ---------- */
  const ctb = document.querySelector('#arComp tbody');
  ctb.innerHTML = T.rows.filter(r=>r.life&&r.gross>50000).map(r=>{
    /* single-line assets with mixed lives understate depreciation; approximate the
       understatement as the difference between the class life and a component-weighted life */
    const under = (1-r.comp) * r.gross * (1/Math.max(1,r.benchLife*0.7) - 1/Math.max(1,r.life));
    const u = Math.max(0, under);
    const det = `<tr data-det="k-${r.k}" style="display:none;"><td colspan="6" style="background:#fbfcfd;padding:13px 16px">
      <table class="grid"><tbody>
        <tr><td style="width:26%">Componentisation rule</td><td>${r.grap}</td></tr>
        <tr><td>Coverage</td><td>${pctT(r.comp)} of carrying amount in this class is held as separately depreciated components; the balance is carried as single lines.</td></tr>
        <tr><td>Why it matters here</td><td>${r.k==='roads'?'A road surfaced with asphalt has a twelve-year surfacing life sitting on a forty-year subgrade. Depreciating the whole at twenty-eight years understates the charge on the part that actually wears out, and the resurfacing then arrives as an unbudgeted capital event.'
          :r.k==='build'?'A building carried as one line at forty-two years is depreciating a fifteen-year roof and twenty-year services at the structural rate. The roof replacement is then capitalised without the original being derecognised, which double-counts the asset.'
          :r.k==='water'||r.k==='san'?'Civil structures last fifty years; mechanical plant twenty; membranes seven. A single life across all three is wrong for each of them.'
          :'Network assets and the metering on them have materially different lives.'}</td></tr>
        <tr class="total"><td>Estimated depreciation understated</td><td class="num">R${fmt(u)}k a year</td></tr>
      </tbody></table></td></tr>`;
    return `<tr class="click" data-key="k-${r.k}"><td><b>${r.n}</b><span class="ax-src">Click for the rule and the effect</span></td>
      <td class="num">${fmt(r.ca)}</td><td class="num">${fmt(r.ca*r.comp)}</td>
      <td><div class="ax-prog"><i class="${r.comp<0.25?'r':r.comp<0.5?'a':'g'}" style="width:${r.comp*100}%"></i></div>
        <span class="ax-mini">${pctT(r.comp)} componentised</span></td>
      <td class="num" style="color:${u>1000?'var(--red)':'inherit'}">${u>1?fmt(u):'—'}</td>
      <td><span class="pill ${r.comp>=0.5?'GREEN':r.comp>=0.25?'AMBER':'RED'}">${r.comp>=0.5?'Adequate':r.comp>=0.25?'Partial':'Not componentised'}</span></td></tr>` + det;
  }).join('');
  axExpandable(ctb);

  const totCost = AR_COMP_EX.reduce((s,c)=>s+c[1],0);
  document.querySelector('#arCompEx tbody').innerHTML = AR_COMP_EX.map(c=>{
    const annual = (c[1]-c[3])/c[2];
    return `<tr><td><b>${c[0]}</b><span class="ax-src">${c[4]}</span></td><td class="num">${fmt(c[1])}</td>
      <td class="num">${pctT(c[1]/totCost)}</td><td class="num">${c[2]} yrs</td><td class="num">${c[3]?fmt(c[3]):'—'}</td>
      <td class="num">${fmt(annual)}</td></tr>`;
  }).join('') + `<tr class="total"><td>Total</td><td class="num">${fmt(totCost)}</td><td class="num">100.0%</td><td class="num">—</td>
    <td class="num">${fmt(AR_COMP_EX.reduce((s,c)=>s+c[3],0))}</td>
    <td class="num">${fmt(AR_COMP_EX.reduce((s,c)=>s+(c[1]-c[3])/c[2],0))}</td></tr>`;
  const single = (totCost-AR_COMP_EX.reduce((s,c)=>s+c[3],0))/40;
  const componentised = AR_COMP_EX.reduce((s,c)=>s+(c[1]-c[3])/c[2],0);
  document.getElementById('arCompNote').innerHTML = `<p class="footnote" style="margin:0">Carried as a single asset on a forty-year life the annual charge
    would be <b>R${fmt(single)}k</b>. Componentised it is <b>R${fmt(componentised)}k</b> — ${AX_N(componentised/single,1)} times higher, because the
    membranes at seven years and the mechanical plant at twenty are consuming far faster than the civil structure. The higher charge is not a penalty; it is
    the correct measurement of what is being used up, and it is what makes the renewal profile in the asset management plan credible.</p>`;

  /* ---------- 5 depreciation ---------- */
  const ltb = document.querySelector('#arLives tbody');
  ltb.innerHTML = T.rows.filter(r=>r.life).map(r=>{
    const yrs = 2025 - Number(r.reviewed);
    const stale = yrs >= 3;
    const det = `<tr data-det="l-${r.k}" style="display:none;"><td colspan="6" style="background:#fbfcfd;padding:13px 16px">
      <table class="grid"><tbody>
        <tr><td style="width:26%">Life applied</td><td>${r.life} years, straight line, against a National Treasury benchmark of ${r.bench.toLowerCase()}</td></tr>
        <tr><td>Annual charge</td><td class="num">R${fmt(r.annual)}k at the applied life; R${fmt(r.benchAnnual)}k at the benchmark</td></tr>
        <tr><td>Last documented review</td><td>${r.reviewed} — ${yrs} year${yrs===1?'':'s'} ago. ${stale
          ? '<span style="color:var(--red)">GRAP 17.51 requires a review at every reporting date. Three years or more without one is a GRAP 3 prior-period error risk if the effect is material, and the AGSA routinely queries registers still running the lives adopted at initial GRAP conversion.</span>'
          : 'Within the annual review requirement.'}</td></tr>
        <tr><td>Effect of moving to the benchmark</td><td class="num" style="color:${r.benchAnnual>r.annual?'var(--red)':'var(--green)'}">
          ${money(r.benchAnnual-r.annual)} a year on the depreciation charge, and the same amount off the surplus</td></tr>
        <tr class="total"><td>Action</td><td>${stale?'Perform and document a useful life and residual value review before year end; where the change is material, disclose it as a change in accounting estimate under GRAP 3 and apply it prospectively.'
          :'No action — the review is current. Retain the evidence for the audit file.'}</td></tr>
      </tbody></table></td></tr>`;
    return `<tr class="click" data-key="l-${r.k}"><td><b>${r.n}</b><span class="ax-src">Click for the review position</span></td>
      <td class="num">${r.life} yrs</td><td style="font-size:11px;color:var(--muted)">${r.bench}</td>
      <td class="num" style="color:${stale?'var(--red)':'inherit'}">${r.reviewed}</td>
      <td class="num">${fmt(r.annual)}</td>
      <td><span class="pill ${stale?'RED':'GREEN'}">${stale?yrs+' years stale':'Reviewed'}</span></td></tr>` + det;
  }).join('');
  axExpandable(ltb);

  axChart(document.getElementById('arDepnChart'), T.rows.filter(r=>r.life&&r.annual>500).map(r=>({
    label:r.n.split(' ')[0].replace('Infrastructure','Infra'), r,
    bars:[{v:r.annual,cls:'b-navy'},{v:r.benchAnnual,cls:'b-gold'}]})),
    document.getElementById('arDepnRead'),
    c=>`<b>${c.r.n}</b> — the charge at the ${c.r.life}-year life applied is <b>R${fmt(c.r.annual)}k</b>; at the National Treasury benchmark of
        ${c.r.benchLife} years it would be <b>R${fmt(c.r.benchAnnual)}k</b>, a difference of <b>R${money(c.r.benchAnnual-c.r.annual)}k</b> a year.
        ${c.r.benchAnnual>c.r.annual
          ? 'The applied life is longer than the benchmark, so depreciation is being understated and the surplus overstated.'
          : 'The applied life is shorter than the benchmark, which is conservative but should still be evidenced by a condition assessment.'}
        Last reviewed ${c.r.reviewed}.`);

  const dApplied = T.annual, dBench = T.benchAnnual;
  document.querySelector('#arRevEffect tbody').innerHTML = [
    ['Depreciation charge', dApplied, dBench],
    ['Operating surplus', totals().surplus, totals().surplus-(dBench-dApplied)],
    ['Carrying amount at year end', T.ca, T.ca-(dBench-dApplied)],
    ['Renewal ratio — capital spend over depreciation', b.capex/dApplied, b.capex/dBench],
  ].map((r,i)=>{
    const f = x => i===3 ? AX_N(x,2)+'x' : 'R'+money(x)+'k';
    const d = r[2]-r[1];
    return `<tr class="${i===1?'total':''}"><td><b>${r[0]}</b></td><td class="num">${f(r[1])}</td><td class="num">${f(r[2])}</td>
      <td class="num" style="color:${Math.abs(d)<0.01?'var(--muted)':(i===0?d>0:d<0)?'var(--red)':'var(--green)'}">${f(d)}</td></tr>`;
  }).join('');
  document.getElementById('arRevNote').innerHTML = `<p class="footnote" style="margin:0">Moving every class to the benchmark life would raise the charge by
    <b>R${money(dBench-dApplied)}k</b> and take the same amount off the surplus. It would also move the renewal ratio from
    ${AX_N(b.capex/dApplied,2)}x to ${AX_N(b.capex/dBench,2)}x, which is the more important number: the ratio is only meaningful if depreciation is
    measuring consumption correctly. A register running lives that are too long reports both a healthier surplus and a healthier renewal ratio than the
    municipality actually has.</p>`;

  /* ---------- 6 WIP ---------- */
  const wipRows = AR_WIP.map(w=>({n:w[0], open:w[1], add:w[2], tfr:w[3], close:w[1]+w[2]-w[3], status:w[4], age:w[5]}));
  const wipTot = wipRows.reduce((a,w)=>({open:a.open+w.open, add:a.add+w.add, tfr:a.tfr+w.tfr, close:a.close+w.close}),{open:0,add:0,tfr:0,close:0});
  document.getElementById('arWipTag').textContent = `Closing balance R${fmt(wipTot.close)}k · not depreciated`;
  document.querySelector('#arWip tbody').innerHTML = wipRows.map(w=>{
    const flag = w.age>3 || w.status.includes('Stalled') || w.status.includes('not transferred');
    return `<tr><td><b>${w.n}</b></td><td class="num">${w.open?fmt(w.open):'—'}</td><td class="num">${w.add?fmt(w.add):'—'}</td>
      <td class="num">${w.tfr?fmt(w.tfr):'—'}</td><td class="num"><b>${fmt(w.close)}</b></td>
      <td class="num" style="color:${w.age>3?'var(--red)':'inherit'}">${AX_N(w.age,1)} yrs</td>
      <td><span class="pill ${flag?'RED':'GREEN'}">${w.status}</span></td></tr>`;
  }).join('') + `<tr class="total"><td>Total</td><td class="num">${fmt(wipTot.open)}</td><td class="num">${fmt(wipTot.add)}</td>
    <td class="num">${fmt(wipTot.tfr)}</td><td class="num">${fmt(wipTot.close)}</td><td class="num"></td><td></td></tr>`;

  document.querySelector('#arWipCap tbody').innerHTML = [
    ['Purchase price, import duties and non-refundable taxes','Capitalise','GRAP 17.26 — cost of the item itself'],
    ['Site preparation, delivery, installation and assembly','Capitalise','GRAP 17.23 — directly attributable to bringing the asset to working condition'],
    ['Professional fees — engineering, architectural, project management','Capitalise','GRAP 17.23 — directly attributable'],
    ['Employee costs arising directly from construction','Capitalise','GRAP 17.23 — only the portion directly attributable, evidenced by timesheets'],
    ['Borrowing costs on a qualifying asset','Capitalise while construction is active','GRAP 5 — suspended during extended idle periods'],
    ['Dismantling and site restoration obligation','Capitalise','GRAP 17.26(c) — the present value of the obligation'],
    ['Administration and general overhead','Expense','GRAP 17.24 — not directly attributable'],
    ['Staff training on the new asset','Expense','GRAP 17.24 — not part of bringing the asset to working condition'],
    ['Costs of opening a new facility or launching a service','Expense','GRAP 17.24'],
    ['Abnormal waste, rework and idle-time cost','Expense','GRAP 17.23 — not normally attributable cost'],
  ].map(r=>`<tr><td><b>${r[0]}</b></td><td><span class="pill ${r[1].startsWith('Capitalise')?'GREEN':'RED'}">${r[1]}</span></td>
    <td style="font-size:11px;color:var(--muted)">${r[2]}</td></tr>`).join('');

  const ageB = [['Under 1 year',0,1],['1 to 2 years',1,2],['2 to 3 years',2,3],['Over 3 years',3,99]];
  document.querySelector('#arWipAge tbody').innerHTML = ageB.map(([lbl,lo,hi])=>{
    const inb = wipRows.filter(w=>w.close>0 && w.age>=lo && w.age<hi);
    const bal = inb.reduce((s,w)=>s+w.close,0);
    return `<tr><td>${lbl}</td><td class="num">${bal?fmt(bal):'—'}</td><td class="num">${inb.length}</td>
      <td style="font-size:11px;color:${hi>3&&bal?'var(--red)':'var(--muted)'}">${hi<=2?'Normal construction period — no action'
        :hi<=3?'Confirm the project is still active and the cost is still recoverable'
        :'Assess for impairment under GRAP 17.67 or abandonment; transfer to PPE if complete and in use'}</td></tr>`;
  }).join('');
  const stalledWip = wipRows.filter(w=>w.age>3||w.status.includes('not transferred')).reduce((s,w)=>s+w.close,0);
  document.getElementById('arWipNote').innerHTML = `<p class="footnote" style="margin:0"><b>R${fmt(stalledWip)}k</b> of the closing balance sits in
    projects that are either complete but not transferred, or stalled beyond three years. The first understates depreciation for as long as it stays there —
    the clinic upgrade has been in use since it was completed and has never been depreciated. The second is an impairment assessment under GRAP 17.67, not a
    balance to carry forward: a contractor in liquidation means the cost incurred may not produce an asset at all.</p>`;

  /* ---------- 7 three-way reconciliation ---------- */
  document.getElementById('arReconTag').textContent = AR_ADJ.posted ? 'Corrections posted — all four steps agree' : `Difference R${money(T.diff)}k against materiality of R${fmt(mat)}k`;
  const steps = [
    {n:1, t:'Asset register total', s:'Gross cost, accumulated depreciation and carrying amount agreed to the register summary', amt:T.ca, ok:true,
     note:'The register is the source. Everything downstream is a copy of it or a difference from it.'},
    {n:2, t:'Register to general ledger', s:'The PPE control accounts must agree to the register totals', amt:T.glCA, ok:Math.abs(T.diff)<0.5,
     note:'Any difference is a reconciling item requiring investigation and a correcting entry — it is never carried forward.'},
    {n:3, t:'General ledger to the PPE note', s:'Opening balance plus additions less disposals less depreciation equals closing carrying amount', amt:T.glCA, ok:Math.abs(T.diff)<0.5,
     note:'The note is a movement schedule. If it does not add up the ledger is wrong, not the note.'},
    {n:4, t:'PPE note to the statement of financial position', s:'The total carrying amount in the note must agree to the PPE line on the face', amt:L('1000'), ok:Math.abs(T.glCA-L('1000'))<0.5,
     note:'The last step is usually clean, because the note is drawn from the ledger. When it fails, the AFS have been manually adjusted.'},
  ];
  document.getElementById('arReconSteps').innerHTML = steps.map((x,i)=>
    `<div class="stg"><div class="no" style="${x.ok?'':'background:var(--red)'}">${x.n}</div>
      <div><b>${x.t}</b><span class="sub">${x.s} · ${x.note}</span></div>
      <div class="amt">R${fmt(x.amt)}k</div>
      <div class="leak" style="color:${x.ok?'var(--green)':'var(--red)'}">${x.ok?'Agrees':'Difference'}</div></div>` +
    (i<steps.length-1?'<div class="flow"></div>':'')).join('');

  const diffs = (Math.abs(T.posted)>0.5 ? [{cls:'Movements posted by other modules', amt:T.posted,
     cause:'The disaster run, the audit adjustments and capitalised certified work posted to the ledger\u2019s PPE control account after the register was last rolled forward. This is the normal state of a sub-ledger between roll-forwards, not an error.',
     correct:'The ledger is correct. The register has not yet been updated.',
     fix:'Register roll-forward — no journal.'}] : []).concat(AR_ADJ.posted ? [] : [
    {cls:'Motor vehicles', amt:5400, cause:'Two vehicles disposed in March 2026 were removed from the register but not from the general ledger.',
     correct:'The register is correct. The ledger overstates gross cost.', fix:'Journal required in the ledger.'},
    {cls:'Plant and equipment', amt:6200, cause:'Fourth-quarter depreciation was posted to the ledger but never processed in the register.',
     correct:'The ledger is correct. The register understates accumulated depreciation.', fix:'Register correction only — no journal.'},
  ]);
  document.querySelector('#arDiff tbody').innerHTML = diffs.length ? diffs.map(d=>
    `<tr><td><b>${d.cls}</b></td><td class="num" style="color:var(--red)">${fmt(d.amt)}</td>
      <td style="font-size:11px">${d.cause}</td><td style="font-size:11px">${d.correct}</td>
      <td><span class="pill ${d.fix.includes('Journal')?'RED':'AMBER'}">${d.fix}</span></td></tr>`).join('')
    + `<tr class="total"><td>Net effect on carrying amount</td><td class="num">${fmt(T.diff)}</td>
       <td colspan="3">${Math.abs(T.diff)>mat?'Above materiality of R'+fmt(mat)+'k — a material misstatement that must be corrected before the statements are signed.':'Below materiality, but still corrected.'}</td></tr>`
    : '<tr><td colspan="5" style="text-align:center;padding:26px;color:var(--green);font-weight:700">All differences corrected. The register, the ledger and the note agree.</td></tr>';

  document.querySelector('#arJournals tbody').innerHTML = AR_ADJ.posted
    ? '<tr><td colspan="5" style="text-align:center;padding:26px;color:var(--muted)">Posted. See the journals tab in the risk suite for the entries.</td></tr>'
    : `<tr><td rowspan="2"><b>Correction 1</b><span class="ax-src">Remove disposed vehicles from the ledger; section 14 resolution obtained</span></td>
        <td>Accumulated depreciation — motor vehicles</td><td class="num">5 400</td><td class="num">—</td><td>General ledger</td></tr>
       <tr><td>Cost — motor vehicles</td><td class="num">—</td><td class="num">5 400</td><td>General ledger</td></tr>
       <tr><td><b>Correction 2</b><span class="ax-src">Process fourth-quarter depreciation in the register</span></td>
        <td>Accumulated depreciation — plant and equipment</td><td class="num">—</td><td class="num">6 200</td>
        <td><span class="pill AMBER">Register only — no journal</span></td></tr>
       <tr class="total"><td>Effect on carrying amount</td><td>Register unchanged; ledger reduced to agree</td>
        <td class="num">5 400</td><td class="num">11 600</td><td>Difference cleared</td></tr>`;

  const pb = document.getElementById('arPostBtn');
  pb.disabled = AR_ADJ.posted;
  pb.textContent = AR_ADJ.posted ? 'Correcting journals posted' : 'Post the correcting journals';
  pb.onclick = ()=>{
    AR_ADJ.posted = true;
    showToast('Correcting journals posted — register, ledger and note now agree at R'+fmt(arTotals().ca)+'k');
    renderAsset();
  };

  const noteRows = [
    ['Cost — opening', T.rows.map(r=>r.gross)],
    ['Additions', T.rows.map(r=>r.k==='wip'?60400:r.k==='roads'?26400:r.k==='water'?56900:r.k==='san'?32700:r.k==='elec'?39000:0)],
    ['Disposals', T.rows.map(r=>r.k==='veh'?-5400:0)],
    ['Cost — closing', null],
    ['Accumulated depreciation — opening', T.rows.map(r=>-r.accum)],
    ['Charge for the year', T.rows.map(r=>-r.annual)],
    ['Accumulated depreciation — closing', null],
    ['Carrying amount at year end', null],
  ];
  let nh = '<thead><tr><th style="width:24%">PPE note</th>'+T.rows.filter(r=>r.gross>50000).map(r=>`<th class="num">${r.n.split(' ')[0]}</th>`).join('')+'<th class="num">Total</th></tr></thead><tbody>';
  const shown = T.rows.filter(r=>r.gross>50000);
  const idxOf = r => T.rows.indexOf(r);
  noteRows.forEach(([n,vals])=>{
    let arr;
    if(vals) arr = shown.map(r=>vals[idxOf(r)]);
    else if(n==='Cost — closing') arr = shown.map(r=>r.gross + (r.k==='wip'?60400:r.k==='roads'?26400:r.k==='water'?56900:r.k==='san'?32700:r.k==='elec'?39000:0) + (r.k==='veh'?-5400:0));
    else if(n==='Accumulated depreciation — closing') arr = shown.map(r=>-(r.accum + r.annual));
    else arr = shown.map(r=>r.gross + (r.k==='wip'?60400:r.k==='roads'?26400:r.k==='water'?56900:r.k==='san'?32700:r.k==='elec'?39000:0) + (r.k==='veh'?-5400:0) - (r.accum + r.annual));
    const tot = arr.reduce((s,v)=>s+v,0);
    nh += `<tr class="${n.includes('closing')||n.includes('year end')?'total':''}"><td>${n}</td>`
        + arr.map(v=>`<td class="num">${v===0?'—':money(v)}</td>`).join('')
        + `<td class="num"><b>${money(tot)}</b></td></tr>`;
  });
  document.getElementById('arNote').innerHTML = nh+'</tbody>';

  /* ---------- 8 physical verification ---------- */
  const ver = T.rows.filter(r=>r.k!=='wip'&&r.k!=='land').map(r=>{
    const cov = {build:0.94,roads:0.71,water:0.66,san:0.69,elec:0.74,comm:0.88,plant:0.91,veh:0.97,furn:0.83}[r.k]||0.9;
    const ghostPct = {build:0.004,roads:0.011,water:0.014,san:0.012,elec:0.009,comm:0.021,plant:0.034,veh:0.018,furn:0.062}[r.k]||0.01;
    const unrec = {build:0,roads:2,water:5,san:3,elec:4,comm:6,plant:18,veh:2,furn:64}[r.k]||0;
    return {...r, cov, verified:Math.round(r.items*cov), ghost:Math.round(r.items*ghostPct), unrec,
      ghostValue:r.ca*ghostPct};
  });
  const ghostTot = ver.reduce((s,v)=>s+v.ghostValue,0), ghostCount = ver.reduce((s,v)=>s+v.ghost,0);
  const unrecTot = ver.reduce((s,v)=>s+v.unrec,0);
  document.getElementById('arVerKpis').innerHTML = kpiSet([
    ['Verification coverage', pctT(ver.reduce((s,v)=>s+v.verified,0)/ver.reduce((s,v)=>s+v.items,0)), 'warn',
      'Infrastructure is the hardest to verify and the least covered — buried pipe cannot be barcoded'],
    ['Assets on register not found', fmt(ghostCount), 'bad', `Carrying amount R${fmt(ghostTot)}k — the existence assertion fails on these`],
    ['Assets found not on register', fmt(unrecTot), 'bad', 'Each one must be valued and recognised under GRAP 17'],
    ['Net effect on carrying amount', 'R'+money(-ghostTot)+'k', 'bad', 'Overstatement to be written off, with a section 14 resolution where required'],
  ]);
  document.getElementById('arVerTag').textContent = 'Verification cycle to 30 June ' + (2023+state.period);
  document.querySelector('#arVerify tbody').innerHTML = ver.map(v=>
    `<tr><td><b>${v.n}</b></td><td class="num">${fmt(v.items)}</td><td class="num">${fmt(v.verified)}</td>
      <td class="num" style="color:${v.ghost?'var(--red)':'inherit'}">${v.ghost||'—'}</td>
      <td class="num" style="color:${v.unrec?'var(--amber)':'inherit'}">${v.unrec||'—'}</td>
      <td><div class="ax-prog"><i class="${v.cov<0.75?'r':v.cov<0.9?'a':'g'}" style="width:${v.cov*100}%"></i></div>
        <span class="ax-mini">${pctT(v.cov)} verified</span></td>
      <td><span class="pill ${v.cov>=0.9&&!v.ghost?'GREEN':v.cov>=0.75?'AMBER':'RED'}">${v.cov>=0.9&&!v.ghost?'Satisfactory':v.cov>=0.75?'Qualified':'Insufficient'}</span></td></tr>`).join('')
    + `<tr class="total"><td>Total</td><td class="num">${fmt(ver.reduce((s,v)=>s+v.items,0))}</td>
       <td class="num">${fmt(ver.reduce((s,v)=>s+v.verified,0))}</td><td class="num">${fmt(ghostCount)}</td>
       <td class="num">${fmt(unrecTot)}</td><td></td><td></td></tr>`;

  document.querySelector('#arVerEffect tbody').innerHTML = [
    ['Gross cost overstated by assets that do not exist', ghostTot/ (1-T.accum/T.gross) * (T.gross/T.ca) * 0 + ghostTot*T.gross/T.ca, 'Existence'],
    ['Accumulated depreciation overstated on the same assets', ghostTot*T.accum/T.ca, 'Accuracy'],
    ['Depreciation charged in the year on assets that do not exist', ghostTot/12, 'Accuracy — expenditure overstated'],
    ['Insurance premium paid on assets that do not exist', ghostTot*0.0042, 'Fruitless and wasteful expenditure under the MFMA'],
    ['Assets in use never recognised', 3840, 'Completeness — understatement'],
  ].map((r,i)=>`<tr class="${i===4?'total':''}"><td>${r[0]}</td><td class="num">${fmt(r[1])}</td>
    <td style="font-size:11px;color:${r[2].includes('Fruitless')?'var(--red)':'var(--muted)'}">${r[2]}</td></tr>`).join('');
  document.getElementById('arVerNote').innerHTML = `<p class="footnote" style="margin:0">A ghost asset fails four ways at once: it overstates cost,
    it overstates accumulated depreciation, it carries a depreciation charge against nothing, and the premium insuring it is fruitless expenditure the
    accounting officer must report. Resolving them requires source documents, disposal records and, where an asset was stolen, a police case number —
    removal without a section 14 council resolution is itself the finding.</p>`;

  const condBands = [[1,'Excellent — as new'],[2,'Good — minor deterioration'],[3,'Fair — functioning, deterioration visible'],[4,'Poor — significant deterioration, failure likely'],[5,'Very poor — at or past the end of service life']];
  const condDist = [0.08,0.24,0.34,0.23,0.11];
  axChart(document.getElementById('arCondChart'), condBands.map((cb,i)=>({label:'C'+cb[0], cb, i,
    val:T.ca*condDist[i], renew: i>=3 ? T.ca*condDist[i]*1.7 : 0,
    bars:[{v:T.ca*condDist[i],cls:'b-navy'},{v:i>=3?T.ca*condDist[i]*1.7:0,cls:'b-red'}]})),
    document.getElementById('arCondRead'),
    c=>`<b>Condition ${c.cb[0]} — ${c.cb[1]}</b><br/>Carrying amount at this rating is <b>R${fmt(c.val)}k</b>, ${pctT(condDist[c.i])} of the base.
        ${c.i>=3?`<span style="color:var(--red)">Renewal required within three years at an estimated replacement cost of <b>R${fmt(c.renew)}k</b>.
        Condition 4 and 5 assets are also an impairment indicator under GRAP 17.67 — where condition is materially worse than the carrying amount implies,
        an impairment test is required, not optional.</span>`
        :'No renewal action required on condition grounds. These assets still support the carrying amount recorded.'}`);

  /* ---------- 9 asset management plan ---------- */
  const amp = T.rows.filter(r=>r.life).map(r=>{
    const dueShare = clamp((r.cond-1.8)/2.6, 0.05, 0.85);
    const due10 = r.replacement*dueShare;
    const annual = due10/10;
    const budget = b.capex*(r.ca/T.ca) + L('5180')*(r.maint?r.maint*r.ca/T.rows.reduce((s,x)=>s+x.maint*x.ca,0):0);
    return {...r, dueShare, due10, annual, budget, funded: annual? budget/annual : 1};
  });
  const ampTot = amp.reduce((a,r)=>({due10:a.due10+r.due10, annual:a.annual+r.annual, budget:a.budget+r.budget}),{due10:0,annual:0,budget:0});
  document.getElementById('arAmpKpis').innerHTML = kpiSet([
    ['Replacement cost of the asset base','R'+fmt(T.replacement)+'k','',`Against a carrying amount of R${fmt(T.ca)}k — the gap is what inflation and consumption have done`],
    ['Renewal due within ten years','R'+fmt(ampTot.due10)+'k','warn',`${pctT(ampTot.due10/T.replacement)} of the base reaches the end of its service life in the period`],
    ['Annual renewal requirement','R'+fmt(ampTot.annual)+'k', ampTot.budget<ampTot.annual?'bad':'good',
      `Against budgeted capital and maintenance of R${fmt(ampTot.budget)}k`],
    ['Annual funding gap','R'+money(ampTot.budget-ampTot.annual)+'k', ampTot.budget<ampTot.annual?'bad':'good',
      ampTot.budget<ampTot.annual?`Funded at ${pctT(ampTot.budget/ampTot.annual)} of requirement`:'Fully funded'],
  ]);
  const atb = document.querySelector('#arAmp tbody');
  atb.innerHTML = amp.map(r=>{
    const det = `<tr data-det="a-${r.k}" style="display:none;"><td colspan="7" style="background:#fbfcfd;padding:13px 16px">
      <table class="grid"><tbody>
        <tr><td style="width:26%">Average condition</td><td>${AX_N(r.cond,1)} on the five-point scale — ${r.cond<2.5?'good, with deterioration visible in places'
          :r.cond<3.3?'fair; functioning, with deterioration that will accelerate':'poor; failure is likely within the planning period'}</td></tr>
        <tr><td>Replacement cost basis</td><td>R${fmt(r.replacement)}k, being gross cost of R${fmt(r.gross)}k indexed at ${AX_N(r.repl,2)}x for
          construction cost inflation since acquisition</td></tr>
        <tr><td>Proportion reaching end of life in ten years</td><td>${pctT(r.dueShare)}, derived from condition rating and remaining useful life of
          ${AX_N(r.yearsLeft,0)} years</td></tr>
        <tr><td>Annual requirement</td><td class="num">R${fmt(r.annual)}k</td></tr>
        <tr><td>Currently budgeted</td><td class="num">R${fmt(r.budget)}k — capital allocation plus maintenance at ${pctT(r.maint)} of carrying amount</td></tr>
        <tr class="total"><td>Gap</td><td class="num" style="color:${r.budget<r.annual?'var(--red)':'var(--green)'}">
          R${money(r.budget-r.annual)}k a year, funded at ${pctT(r.funded)} of requirement</td></tr>
      </tbody></table></td></tr>`;
    return `<tr class="click" data-key="a-${r.k}"><td><b>${r.n}</b><span class="ax-src">Click for the condition profile and the timing</span></td>
      <td class="num">${fmt(r.ca)}</td><td class="num">${fmt(r.replacement)}</td>
      <td class="num" style="color:${r.cond>3.2?'var(--red)':'inherit'}">${AX_N(r.cond,1)}</td>
      <td class="num">${fmt(r.due10)}</td><td class="num">${fmt(r.annual)}</td>
      <td><span class="pill ${r.funded>=1?'GREEN':r.funded>=0.6?'AMBER':'RED'}">${pctT(r.funded,0)}</span></td></tr>` + det;
  }).join('') + `<tr class="total"><td>Total</td><td class="num">${fmt(T.ca)}</td><td class="num">${fmt(T.replacement)}</td>
    <td class="num">—</td><td class="num">${fmt(ampTot.due10)}</td><td class="num">${fmt(ampTot.annual)}</td>
    <td><span class="pill ${ampTot.budget>=ampTot.annual?'GREEN':'RED'}">${pctT(ampTot.budget/ampTot.annual,0)}</span></td></tr>`;
  axExpandable(atb);

  axChart(document.getElementById('arAmpChart'), Array.from({length:10},(_,i)=>{
    const y = i+1;
    /* renewal is lumpy: condition-driven, front-loaded on roads and back-loaded on water */
    const shape = [1.42,1.28,1.15,0.98,0.86,0.79,0.84,0.91,0.97,0.80][i];
    const req = ampTot.annual*shape;
    const bud = ampTot.budget*Math.pow(1.055,i);
    return {label:'Y'+y, y, req, bud, gap:Math.max(0,req-bud),
      bars:[{v:req,cls:'b-grey'},{v:bud,cls:'b-teal'},{v:Math.max(0,req-bud),cls:'b-red'}]};
  }), document.getElementById('arAmpRead'),
    c=>`<b>Year ${c.y}</b> — renewal required <b>R${fmt(c.req)}k</b> against budgeted capital and maintenance of <b>R${fmt(c.bud)}k</b>.
        ${c.gap>0?`<span style="color:var(--red)">A gap of R${fmt(c.gap)}k. That is a dated list of assets that will reach the end of their service life
        in this year and will not be replaced — the failure does not wait for the budget.</span>`
        :`Fully funded, with R${fmt(c.bud-c.req)}k available to work off the accumulated backlog.`}
        The profile is front-loaded because roads carry the shortest remaining life in the base at
        ${AX_N(T.rows.find(r=>r.k==='roads').yearsLeft,0)} years.`);

  document.querySelector('#arMaint tbody').innerHTML = amp.filter(r=>r.maint>0).map(r=>{
    const norm = r.k.startsWith('roads')||r.k==='water'||r.k==='san'||r.k==='elec' ? 0.02 : 0.03;
    const ok = r.maint>=norm;
    return `<tr><td><b>${r.n}</b></td><td class="num">${fmt(r.maint*r.ca)}</td><td class="num">${pctT(r.maint,2)}</td>
      <td class="num">${pctT(norm,0)}</td><td><span class="pill ${ok?'GREEN':'RED'}">${ok?'At norm':'Below norm'}</span></td></tr>`;
  }).join('');
  document.getElementById('arMaintNote').innerHTML = `<p class="footnote" style="margin:0">Total maintenance is
    <b>R${fmt(L('5180'))}k</b>, ${pctT(L('5180')/L('1000'),2)} of the carrying value of the asset base against a Circular 71 norm of 8% of the value of
    property, plant and equipment. Under-maintenance is the cheapest saving available in any budget year and the most expensive over ten: deferred
    maintenance raises the failure rate, failures consume the unplanned repair budget, and the unplanned repair budget is the money that would otherwise
    have funded planned renewal.</p>`;

  const gapTot = Array.from({length:10},(_,i)=>Math.max(0, ampTot.annual*[1.42,1.28,1.15,0.98,0.86,0.79,0.84,0.91,0.97,0.80][i] - ampTot.budget*Math.pow(1.055,i))).reduce((s,v)=>s+v,0);
  document.getElementById('arAmpVerdict').innerHTML = `
    <div class="ax-note ${gapTot>0?'bad':'ok'}"><b>${gapTot>0?'The plan is not funded.':'The plan is funded.'}</b>
      ${gapTot>0?`Cumulative unfunded renewal over ten years is <b>R${fmt(gapTot)}k</b>, concentrated in the first four years where the profile is
      front-loaded.`:'Budgeted capital and maintenance meet the renewal requirement in every year of the plan.'}</div>
    <table class="grid"><tbody>
      <tr><td style="width:34%">What the plan actually is</td><td>Not a wish list. Condition rating gives remaining service life, remaining service life
        gives the year each asset falls due, and the sum by year is the profile. Every rand in it is attached to a dated asset.</td></tr>
      <tr><td>Why replacement cost, not carrying amount</td><td>Carrying amount is what the asset cost less what has been consumed. Replacing it costs
        today's price — R${fmt(T.replacement)}k against a carrying amount of R${fmt(T.ca)}k. Planning off the carrying amount understates the requirement by
        <b>R${fmt(T.replacement-T.ca)}k</b>.</td></tr>
      <tr><td>The three options</td><td>Fund the gap, extend asset lives through higher maintenance, or accept a lower level of service. There is no fourth
        option, and deferring the decision selects the third by default.</td></tr>
      <tr class="total"><td>Link to the rest of the system</td><td>This requirement is the input to the structural risk analysis in the risk module, where it
        is tested against revenue growth and the affordable tariff path. A renewal plan that is not affordable is not a plan.</td></tr>
    </tbody></table>`;

  /* ---------- 10 cascade ---------- */
  document.querySelector('#arCascade tbody').innerHTML = [
    ['General ledger — PPE control account','Gross cost and accumulated depreciation by class', 'R'+money(T.diff)+'k difference','Any addition, disposal, transfer from WIP, impairment or depreciation run'],
    ['Statement of financial performance — depreciation','The annual charge computed asset by asset', 'R'+fmt(T.annual)+'k','A change in useful life, residual value or method, and every new asset brought into use'],
    ['Statement of financial position — PPE','The carrying amount on the face', 'R'+fmt(L('1000'))+'k','Everything above'],
    ['PPE note to the annual financial statements','The movement schedule: opening, additions, disposals, depreciation, closing','Recomputed','Year end, and at every interim reporting date'],
    ['Treasury Control Tower — renewal ratio','Capital spend against depreciation', AX_N(b.capex/T.annual,2)+'x','A change in the depreciation charge moves the ratio without any change in spend'],
    ['Risk module — structural analysis','Required renewal, backlog and condition profile','R'+fmt(ampTot.annual)+'k a year','A condition assessment, or a change in replacement cost indexation'],
    ['Credit model — asset factor','Maintenance as a share of infrastructure carrying value', pctT(L('5180')/L('1000'),2),'Maintenance spend and the carrying amount both move it'],
    ['Audit findings — CTL-11 and the WIP test','Certified capital work capitalised, and WIP transferred on completion','R'+fmt(stalledWip)+'k at risk','WIP ageing beyond the construction period'],
    ['Insurance schedule','Replacement value by asset','R'+fmt(T.replacement)+'k','Annual renewal of the policy; ghost assets carry fruitless premium'],
  ].map(r=>`<tr><td><b>${r[0]}</b></td><td style="font-size:11px;color:var(--muted)">${r[1]}</td>
    <td class="num">${r[2]}</td><td style="font-size:11px">${r[3]}</td></tr>`).join('');

  document.querySelector('#arEvents tbody').innerHTML = [
    ['Acquisition','Property, plant and equipment','Trade and other payables or bank','Register: all seventeen fields; insurance schedule','Delegation per the SCM policy'],
    ['Transfer from work in progress','Property, plant and equipment — the relevant class','Capital work in progress','Register: date available for use starts depreciation','Completion certificate and practical completion'],
    ['Depreciation run','Depreciation — statement of financial performance','Accumulated depreciation','Register: accumulated depreciation and carrying amount','None — systematic'],
    ['Revision of useful life','No journal in the period of change','No journal in the period of change','Register: revised life; the charge changes prospectively','Documented review under GRAP 17.51'],
    ['Impairment','Impairment loss — statement of financial performance','Accumulated impairment','Register: condition rating and recoverable amount','Impairment test under GRAP 21 or 26'],
    ['Disposal — proceeds received','Bank; accumulated depreciation','Property, plant and equipment; gain on disposal','Register: disposal status and date','Council resolution under MFMA section 14'],
    ['Write-off — asset not found','Accumulated depreciation; loss on write-off','Property, plant and equipment','Register: removal with the investigation reference','Council resolution, and a police case number where stolen'],
    ['Recognition of an unrecorded asset','Property, plant and equipment','Accumulated surplus or revenue as applicable','Register: full record created at assessed cost','Valuation evidence and accounting officer approval'],
    ['Componentisation of an existing asset','No net journal — reallocation within the class','No net journal','Register: parent split into components with separate lives','Documented assessment under GRAP 17.43'],
  ].map(r=>`<tr><td><b>${r[0]}</b></td><td style="font-size:11px">${r[1]}</td><td style="font-size:11px">${r[2]}</td>
    <td style="font-size:11px;color:var(--muted)">${r[3]}</td><td style="font-size:11px">${r[4]}</td></tr>`).join('');

  document.querySelector('#arIntegrate tbody').innerHTML = [
    ['Project risk','Nothing','Certified capital work, which becomes an addition or a WIP movement','<code>mxPortfolio().certTD → AR_WIP</code>'],
    ['Contract monitor','Nothing','Retention released on practical completion, which completes the asset cost','<code>mxContractTotals().ret</code>'],
    ['Audit findings','Data quality scores, WIP ageing, verification result — these raise findings','Resolution of a finding updates the register','<code>arTotals().diff → mxOpinion()</code>'],
    ['Treasury Control Tower','Carrying amount, depreciation, maintenance ratio','Nothing','<code>arTotals() → axBase()</code>'],
    ['Risk module','Replacement cost, condition profile, renewal requirement, backlog','Nothing','<code>arAmp() → AX_LR.backlogOpen</code>'],
    ['Credit model','Maintenance to infrastructure ratio, renewal ratio','Nothing','<code>arTotals().annual → AX_BANDS.rmRatio</code>'],
    ['Integrated workbook','The full register as a sheet','Manual corrections flow back','<code>23_Asset_Register</code>'],
  ].map(r=>`<tr><td><b>${r[0]}</b></td><td style="font-size:11px">${r[1]}</td><td style="font-size:11px">${r[2]}</td><td>${r[3]}</td></tr>`).join('');
}

/* ==================== module entry point ==================== */
function renderAnnexure(){ renderCredit(); renderScen(); renderTreas(); renderAsset(); }
function axInit(){
  document.getElementById('crTabs').addEventListener('click', e=>{const b=e.target.closest('button'); if(!b)return;
    document.querySelectorAll('#crTabs button').forEach(x=>x.classList.toggle('active',x===b));
    document.querySelectorAll('#panel-credit .ax-sub').forEach(s=>s.classList.toggle('on', s.id==='cr-'+b.dataset.cr));});
  document.getElementById('scTabs').addEventListener('click', e=>{const b=e.target.closest('button'); if(!b)return;
    document.querySelectorAll('#scTabs button').forEach(x=>x.classList.toggle('active',x===b));
    document.querySelectorAll('#panel-scen .ax-sub').forEach(s=>s.classList.toggle('on', s.id==='sc-'+b.dataset.sc));
    const hz = {levers:0,impact:0,library:0,traj:1,sust:1}[b.dataset.sc];
    document.querySelectorAll('#scHorizon .h').forEach((h,i)=>h.classList.toggle('on', i===hz));});
  document.getElementById('arTabs').addEventListener('click', e=>{const b=e.target.closest('button'); if(!b)return;
    document.querySelectorAll('#arTabs button').forEach(x=>x.classList.toggle('active',x===b));
    document.querySelectorAll('#panel-asset > .ax-sub').forEach(x=>x.classList.toggle('on', x.id==='ar-'+b.dataset.ar));});
  document.getElementById('arOnlyGaps').addEventListener('click', e=>{arOnlyGaps=!arOnlyGaps;
    e.target.textContent = arOnlyGaps?'Show every field':'Show only fields with gaps'; renderAsset();});
  document.getElementById('tcTabs').addEventListener('click', e=>{const b=e.target.closest('button'); if(!b)return;
    document.querySelectorAll('#tcTabs button').forEach(x=>x.classList.toggle('active',x===b));
    document.querySelectorAll('#panel-treas .ax-sub').forEach(s=>s.classList.toggle('on', s.id==='tc-'+b.dataset.tc));});
  document.getElementById('scTrajSeg').addEventListener('click', e=>{const b=e.target.closest('button'); if(!b)return;
    scTrajKey=b.dataset.tr; renderTraj();});
  document.getElementById('scAssumReset').addEventListener('click', ()=>{
    Object.assign(AX_LR,{revGrowth:0.055,costGrowth:0.062,popGrowth:0.017,tariffCeiling:0.09,backlogOpen:640000,absorb:40});
    renderSust(); renderTraj(); showToast('Projection assumptions reset');});
  document.getElementById('scReset').addEventListener('click', ()=>{AX_LEVERS.forEach(l=>AX_LEVER_VALS[l.k]=0); renderScen(); showToast('Levers returned to the approved budget');});
  document.getElementById('crResetSens').addEventListener('click', ()=>{axSens={}; renderSens(); showToast('Sensitivity drivers reset to the audited values');});
  document.getElementById('crOnlyFail').addEventListener('click', e=>{crOnlyFail=!crOnlyFail;
    e.target.textContent = crOnlyFail?'Show every measure':'Show only measures below their band'; renderMetrics();});
  document.getElementById('tcWcTabs').addEventListener('click', e=>{const b=e.target.closest('button'); if(!b)return;
    document.querySelectorAll('#tcWcTabs button').forEach(x=>x.classList.toggle('active',x===b));
    document.querySelectorAll('#tc-alm > .ax-sub').forEach(x=>x.classList.toggle('on', x.id==='wc-'+b.dataset.wc));});
  document.getElementById('tcLevReset').addEventListener('click', ()=>{AX_WC_LEV.forEach(l=>AX_WC_VALS[l.k]=0); renderWcLevers(); showToast('Working capital levers reset');});
  document.getElementById('tcOnlyBreach').addEventListener('click', e=>{tcOnlyBreach=!tcOnlyBreach;
    e.target.textContent = tcOnlyBreach?'Show every ratio':'Show only ratios outside their norm'; renderTcRatios();});
}

/* ===== MODULE BLOCK 3 — project appraisal ===== */

/* ========================================================================
   MATOS PROJECT APPRAISAL MODULE
   Replaces the Bankability Engine and the Capital Structure tabs.
   Worked project: Mzansi Rapid — Bus Rapid Transit, Trunk Corridor A.
   Deterministic throughout: every figure is arithmetic over the assumptions
   on the intake tab. Nothing is scored or weighted by a model.
   ======================================================================== */
const N=(v,d)=>Number(v||0).toFixed(d===undefined?1:d);
/* const pctT — defined once in the shared helpers above */
/* const money — defined once in the shared helpers above */
/* const clamp — defined once in the shared helpers above */
const Rm=n=>'R'+fmt(n/1000)+'m';

const PA = {
  name:'Mzansi Rapid — Bus Rapid Transit, Trunk Corridor A',
  sponsor:'Directorate: Transport and Urban Mobility',
  years:20, buildYears:3, opStart:4, concession:20,
  /* capital cost by work package, R'000 */
  capex:[
    ['Trunk roadway, dedicated lanes and civils', 402000, 40, 'Roadway, median, signalisation, stormwater along 18.4 km'],
    ['Stations, termini and interchanges',        188000, 25, '22 closed stations, 2 termini, level boarding and fare gates'],
    ['Depots, workshops and control centre',      146000, 25, 'Two depots for an 84-vehicle fleet, plus the operations control centre'],
    ['Rolling stock — 46 articulated, 38 feeder', 322000, 12, 'Euro V diesel-electric; replacement cycle falls inside the concession'],
    ['Intelligent transport, fare collection',     98000,  8, 'Automated fare collection, AVL, passenger information, CCTV'],
    ['Land acquisition and servitudes',            54000,  0, 'Expropriation and servitude compensation; not depreciated'],
    ['Professional fees, transaction advice, EIA', 30000, 40, 'Capitalised as directly attributable under GRAP 17.23'],
  ],
  phasing:[0.3065, 0.4194, 0.2742],     /* Y1 Y2 Y3 */
  /* demand and fare */
  pax0: 14.2e6, paxGrowth: 0.034, fare0: 14.80, fareEsc: 0.055,
  nonFare0: 24600, nonFareEsc: 0.060,
  /* operating cost */
  opex:[
    ['Vehicle operations — drivers, fuel, maintenance', 238080, 'R38.40 per vehicle-kilometre across 6.2 million kilometres'],
    ['Station operations, cleaning and security',        42000, '22 stations and 2 termini, 18 hours a day'],
    ['Fare collection and control centre',               26000, 'AFC clearing, revenue protection, operations control'],
    ['Contract management and administration',           18000, 'Municipal transport authority, contract monitoring'],
    ['Insurance',                                        14000, 'Fleet, third-party liability, business interruption'],
  ],
  opexEsc: 0.062,
  /* funding and rates */
  ptngCapShare: 0.60, ptngOpShare: 0.60,
  discNom: 0.115, discEcon: 0.08, inflation: 0.052,
  loanRate: 0.0875, loanTenor: 20, grace: 3,
  bondRate: 0.1065, bondTenor: 15, sinkRate: 0.08,
  seniorRate: 0.115, seniorTenor: 18, equityTarget: 0.175,
  botDebtRate: 0.1225, botEquityTarget: 0.195, botDebtShare: 0.70,
  vat: 0.15, rateBase: 1494146,
  /* economic appraisal — benefits, R'000 in year 4 money */
  econ:[
    ['Passenger travel time savings', 268000, '11.4 minutes average saving across 14.2 million trips at the Treasury value of time'],
    ['Vehicle operating cost savings', 84000, 'Modal shift from minibus taxi and private car off the corridor'],
    ['Road accident reduction', 46000, 'Segregated running way; 31% reduction in corridor casualty rate'],
    ['Emissions and air quality', 22000, 'Net of the fleet\u2019s own emissions, at the national carbon value'],
    ['Wider economic and agglomeration', 38000, 'Corridor densification and labour market access'],
  ],
};
/* assumptions the intake tab can override from an uploaded assumptions.csv */
const PA_OVER = {};
function A(k){ return PA_OVER[k]!==undefined ? PA_OVER[k] : PA[k]; }
const PA_INPUTS = [
 ['pax0','Opening annual patronage','passengers','Transport model, 2025 household travel survey'],
 ['paxGrowth','Patronage growth','per year','Corridor densification and population growth'],
 ['fare0','Average fare at opening','R per trip','Tariff policy, distance-banded'],
 ['fareEsc','Fare escalation','per year','Tariff policy — CPI plus 0.3 points'],
 ['opexEsc','Operating cost escalation','per year','Wage settlement and fuel, blended'],
 ['ptngCapShare','Capital grant share','of capital cost','Public Transport Network Grant allocation letter'],
 ['ptngOpShare','Operating grant share','of the deficit','Public Transport Network Grant, operations window'],
 ['discNom','Discount rate — financial','nominal','Municipal weighted average cost of capital'],
 ['discEcon','Discount rate — economic','real','National Treasury social discount rate'],
 ['loanRate','Senior loan rate','per year','DBSA indicative term sheet'],
 ['loanTenor','Loan tenor','years','DBSA indicative term sheet'],
 ['equityTarget','PPP equity return target','per year','Market sounding, SA infrastructure concessions'],
];

/* ================= ENGINE ================= */
function paCapexTotal(){ return PA.capex.reduce((s,c)=>s+c[1],0); }
function paOpexY4(){ return PA.opex.reduce((s,c)=>s+c[1],0); }
function paModel(){
  const Y=PA.years, cap=paCapexTotal(), ph=PA.phasing, op0=paOpexY4();
  const rows=[];
  for(let y=1;y<=Y;y++){
    const building = y<=PA.buildYears;
    const capex = building ? cap*ph[y-1] : (y===9||y===15 ? 322000*0.46 : 0);  /* mid-life fleet renewal */
    const t = y - PA.opStart;                       /* operating year index, 0 at opening */
    const live = t>=0;
    const pax  = live ? A('pax0')*Math.pow(1+A('paxGrowth'), t) : 0;
    const fare = live ? A('fare0')*Math.pow(1+A('fareEsc'), t) : 0;
    const farebox = live ? pax*fare/1000 : 0;        /* R'000 */
    const nonFare = live ? PA.nonFare0*Math.pow(1+PA.nonFareEsc, t) : 0;
    const opex = live ? op0*Math.pow(1+A('opexEsc'), t) : 0;
    const grossRev = farebox + nonFare;
    const netOp = grossRev - opex;                   /* negative until late in the concession */
    const depn = live ? PA.capex.reduce((s,c)=>s + (c[2] ? c[1]/c[2] : 0), 0) : 0;
    const capGrant = building ? cap*ph[y-1]*A('ptngCapShare') : 0;
    const opGrant = live && netOp<0 ? -netOp*A('ptngOpShare') : 0;
    rows.push({y, building, live, capex, pax, fare, farebox, nonFare, grossRev, opex, netOp, depn,
      capGrant, opGrant,
      projectFCF: netOp - capex,                                     /* pre-grant, pre-finance */
      muniFCF: netOp + opGrant - (capex - capGrant),                 /* what the municipality actually carries */
      recovery: opex ? farebox/opex : 0});
  }
  return rows;
}
function npv(rate, flows, t0){ return flows.reduce((s,f,i)=> s + f/Math.pow(1+rate, i+(t0===undefined?1:t0)), 0); }
function irr(flows, guess){
  let r = guess===undefined?0.1:guess;
  for(let i=0;i<200;i++){
    let f=0, d=0;
    flows.forEach((cf,k)=>{ f += cf/Math.pow(1+r,k); d += -k*cf/Math.pow(1+r,k+1); });
    if(Math.abs(d)<1e-9) break;
    const nr = r - f/d;
    if(!isFinite(nr)) break;
    if(Math.abs(nr-r)<1e-8){ r=nr; break; }
    r = clamp(nr,-0.95,5);
  }
  /* an internal rate of return only exists where the flows change sign at least once.
     Validity is tested against the largest flow, not the first — the first is often a
     zero placeholder for time nought. */
  const pos = flows.some(f=>f>0), neg = flows.some(f=>f<0);
  if(!pos || !neg) return null;
  const scale = Math.max(...flows.map(f=>Math.abs(f)));
  const test = flows.reduce((s,cf,k)=>s+cf/Math.pow(1+r,k),0);
  return (isFinite(r) && Math.abs(test) < scale*0.01 && r>-0.95 && r<3) ? r : null;
}
function payback(flows){
  let c=0; for(let i=0;i<flows.length;i++){ const p=c; c+=flows[i];
    if(p<0 && c>=0) return i + (-p/flows[i]); }
  return null;
}
function annuity(P,i,n){ return i? P*i/(1-Math.pow(1+i,-n)) : P/n; }
function paAppraisal(){
  const m = paModel(), cap = paCapexTotal();
  const projFlows = [0, ...m.map(r=>r.projectFCF)];
  const muniFlows = [0, ...m.map(r=>r.muniFCF)];
  /* terminal value: residual carrying value of long-life assets at year 20 */
  const term = PA.capex.reduce((s,c)=> s + (c[2]>PA.years ? c[1]*(1-PA.years/c[2]) : c[2]===0 ? c[1] : 0), 0);
  const projT = [...projFlows]; projT[projT.length-1] += term;
  const muniT = [...muniFlows]; muniT[muniT.length-1] += term*(1-A('ptngCapShare'));
  const dn = A('discNom');
  /* Economic case, in REAL terms throughout. Mixing a nominally escalating operating
     cost with a real benefit stream is the commonest error in transport appraisal and
     it makes every project look worse than it is. Benefits grow with patronage plus
     1.5 points for real growth in the value of time; the real resource cost of running
     the service grows with patronage alone, because the service expands with demand
     but the real unit cost of running a bus does not. */
  const econ0 = PA.econ.reduce((s,e)=>s+e[1],0);
  const opReal = paOpexY4();
  const econFlows = [0, ...m.map(r=>{
    const t = r.y-PA.opStart;
    const ben  = r.live ? econ0*Math.pow(1+A('paxGrowth')+0.015, t) : 0;
    const cost = r.live ? opReal*Math.pow(1+A('paxGrowth'), t) : 0;
    return ben - r.capex - cost;
  })];
  return {
    m, cap, term,
    projNPV: npv(dn, projT.slice(1)), projIRR: irr(projT),
    muniNPV: npv(dn, muniT.slice(1)), muniIRR: irr(muniT),
    econNPV: npv(A('discEcon'), econFlows.slice(1)), econIRR: irr(econFlows),
    projPB: payback(projT), muniPB: payback(muniT),
    pi: Math.abs(npv(dn, projT.slice(1).map((f,i)=>i<PA.buildYears?0:f))) / cap,
    netPresentCost: -npv(dn, muniT.slice(1)),
    capGrant: m.reduce((s,r)=>s+r.capGrant,0),
    muniCapital: cap - m.reduce((s,r)=>s+r.capGrant,0),
    opGrantY4: m[PA.opStart-1].opGrant,
    deficitY4: -m[PA.opStart-1].netOp,
    muniCallY4: -m[PA.opStart-1].muniFCF,
    recoveryY4: m[PA.opStart-1].recovery,
    econ0,
  };
}
/* ---------- debt schedule for the recommended base structure ---------- */
function paDebtSchedule(principal, rate, tenor, grace){
  const rows=[]; let bal=principal;
  for(let y=1;y<=tenor;y++){
    const interest = bal*rate;
    let cap=0;
    if(y<=grace){ bal += interest; }                 /* interest capitalised during construction */
    else {
      const a = annuity(bal, rate, tenor-y+1);
      cap = a - interest;
      bal -= cap;
    }
    rows.push({y, opening:bal+cap, interest, capital:cap, service:cap+interest, closing:bal});
  }
  return rows;
}
/* ---------- six funding models ---------- */
function paFunding(){
  const A0 = paAppraisal(), cap = A0.cap, m = A0.m, dn = A('discNom');
  const muniCap = A0.muniCapital;
  const base = totals(), freeCash = L('1130') - L('2120');
  const existingDebt = L('2000')+L('2110'), opRev = base.revenue - L('4195');
  const opDeficit = y => { const r = m[y-1]; return r.live ? Math.max(0,-r.netOp) : 0; };
  const opGrant = y => { const r = m[y-1]; return r.opGrant; };

  function npcOf(annualSvc, capOutflow, opShare){
    const f = m.map((r,i)=>{
      const svc = r.y>=PA.opStart ? annualSvc(r.y) : (r.y<=PA.buildYears ? 0 : 0);
      const cash = -(capOutflow(r.y)) - svc - (opShare(r.y));
      return cash;
    });
    return -npv(dn, f);
  }
  const opts = [];

  /* 1 — own funds from the capital replacement reserve */
  opts.push({
    k:'own', kind:'Conventional', n:'Own funds — capital replacement reserve',
    sub:'The municipality funds the whole capital cost from accumulated cash, with no borrowing and no grant.',
    capSource:[['Capital replacement reserve', cap]],
    annualSvc:()=>0,
    capOut:y=> y<=PA.buildYears ? cap*PA.phasing[y-1] : 0,
    opShare:y=> { const r=m[y-1]; return r.live ? Math.max(0,-r.netOp) : 0; },
    debtAdded:0, peakCash:cap,
    parties:[['Municipality','All of it — capital, demand and operating risk','Opportunity cost of 8.25% on cash foregone, no return']],
    gates:[['MFMA s17(3)','The budget must be funded from realistically anticipated cash. A reserve drawdown is cash, so this passes on form.'],
           ['MFMA s19','Council must approve the capital project and be satisfied the sources of funding are available and not committed elsewhere.']],
    why:'The cheapest capital there is, because it carries no margin. It is also the only option that converts the entire liquidity buffer into a fixed asset, which is why it fails the affordability test rather than the cost one.',
  });

  /* 2 — DBSA amortising loan */
  const loanSch = paDebtSchedule(cap, A('loanRate'), A('loanTenor'), PA.grace);
  opts.push({
    k:'loan', kind:'Conventional', n:'Development bank term loan',
    sub:`R${fmt(cap)}k over ${A('loanTenor')} years at ${pctT(A('loanRate'),2)}, three years of capitalised interest during construction, level annuity thereafter.`,
    capSource:[['DBSA amortising term loan', cap]],
    annualSvc:y=> { const r = loanSch[y-1]; return r? r.service : 0; },
    capOut:()=>0,
    opShare:y=> { const r=m[y-1]; return r.live ? Math.max(0,-r.netOp) : 0; },
    debtAdded:cap, peakCash:0, sch:loanSch,
    parties:[['Municipality','Capital, demand, operating and interest rate risk','No equity return — this is a cost, not an investment'],
             ['Development bank','Credit risk on the municipality, secured on general revenue','Margin of about 150 basis points over its own cost of funds']],
    gates:[['MFMA s46','Long-term debt may only be incurred for capital expenditure on property, plant and equipment, with public comment and council resolution by majority.'],
           ['MFMA Circular 71','Borrowing to operating revenue must stay at or below 45%. This option takes it to '+pctT((existingDebt+cap)/opRev)+'.']],
    why:'The conventional route, and the one every municipality reaches for first. It is also the one that breaks the borrowing ceiling outright at this scale, which is the single most useful thing this appraisal produces.',
  });

  /* 3 — PTNG conditional grant plus own contribution */
  const g3 = cap*A('ptngCapShare'), own3 = cap-g3, borrow3 = own3*0.60, cash3 = own3-borrow3;
  const sch3 = paDebtSchedule(borrow3, A('loanRate'), A('loanTenor'), PA.grace);
  opts.push({
    k:'ptng', kind:'Conventional', n:'Public Transport Network Grant with own contribution',
    sub:`${pctT(A('ptngCapShare'),0)} capital grant of R${fmt(g3)}k, with the balance split between a reserve drawdown and limited borrowing.`,
    capSource:[['Public Transport Network Grant — capital window', g3],['Capital replacement reserve', cash3],['Development bank term loan', borrow3]],
    annualSvc:y=> { const r = sch3[y-1]; return r? r.service : 0; },
    capOut:y=> y<=PA.buildYears ? cash3*PA.phasing[y-1] : 0,
    opShare:y=> { const r=m[y-1]; return r.live ? Math.max(0,-r.netOp) - r.opGrant : 0; },
    debtAdded:borrow3, peakCash:cash3, sch:sch3,
    parties:[['Municipality','Demand, operating and residual capital risk','No equity return; the grant is not repayable but is conditional'],
             ['National government','Funds 60% of capital and 60% of the operating deficit','Policy return — corridor mobility and modal shift, not a financial one']],
    gates:[['Division of Revenue Act','Conditional grant, spent only on the approved purpose. Unspent funds revert under section 22 unless a rollover is approved.'],
           ['MFMA s46 and Circular 71','Borrowing of R'+fmt(borrow3)+'k takes the ratio to '+pctT((existingDebt+borrow3)/opRev)+'.'],
           ['MFMA s19','Council approval of the capital project, with the full multi-year cost disclosed.']],
    why:'The route most South African BRT systems have actually taken. The grant does the heavy lifting on capital and the municipality carries the operating deficit, which is where these systems get into trouble — the capital is funded once, the deficit arrives every year.',
  });

  /* 4 — municipal bond */
  const bondSize = own3;
  const bondInt = bondSize*A('bondRate');
  const sink = bondSize*PA.sinkRate/(Math.pow(1+PA.sinkRate,PA.bondTenor)-1);
  opts.push({
    k:'bond', kind:'Bespoke', n:'Listed municipal bond with a sinking fund',
    sub:`R${fmt(bondSize)}k fifteen-year bullet at ${pctT(A('bondRate'),2)}, interest paid semi-annually, capital repaid from a sinking fund built over the term.`,
    capSource:[['Public Transport Network Grant — capital window', g3],['Listed municipal bond', bondSize]],
    annualSvc:y=> y<=PA.bondTenor+PA.buildYears ? bondInt+sink : 0,
    capOut:()=>0,
    opShare:y=> { const r=m[y-1]; return r.live ? Math.max(0,-r.netOp) - r.opGrant : 0; },
    debtAdded:bondSize, peakCash:0, bondInt, sink,
    parties:[['Municipality','All project risk, plus refinancing risk at year fifteen if the sinking fund underperforms','No equity return'],
             ['Bondholders','Credit risk only; no project risk','Coupon of '+pctT(A('bondRate'),2)+', which is the national-scale rating plus a liquidity premium'],
             ['Sinking fund manager','Investment risk on the accumulating fund','Assumed to earn '+pctT(PA.sinkRate,1)+', which is itself an assumption the municipality carries']],
    gates:[['MFMA s46 and s48','Long-term debt and the security that may be given for it. A bond is debt like any other for the ceiling.'],
           ['JSE Debt Listings Requirements','Audited annual financial statements must be published within the prescribed period, failing which the listing is suspended.'],
           ['MFMA s33','Not triggered by the bond itself, but by any linked long-term service contract.']],
    why:'Cheaper in the early years than an amortising loan because nothing is repaid, and more expensive over the life once the sinking fund contribution is counted. The real difference is the discipline: a sinking fund that is raided is a default deferred, not avoided.',
  });

  /* 5 — DBFOM PPP, availability payment */
  const spvDebt = cap*0.75, spvEquity = cap*0.25;
  const spvSch = paDebtSchedule(spvDebt, PA.seniorRate, PA.seniorTenor, PA.grace);
  const spvDS = spvSch[PA.grace] ? spvSch[PA.grace].service : annuity(spvDebt,PA.seniorRate,PA.seniorTenor-PA.grace);
  const eqDist = annuity(spvEquity, A('equityTarget'), PA.concession-PA.buildYears);
  const spvOpex = paOpexY4()*0.875;                 /* SPV runs everything but contract management */
  const avail = spvDS*1.30 + eqDist + spvOpex;
  opts.push({
    k:'ppp', kind:'Bespoke', n:'Design, build, finance, operate and maintain concession',
    sub:`A special purpose vehicle finances and builds the system and operates it for ${PA.concession} years against an availability payment. Farebox stays with the municipality.`,
    capSource:[['SPV senior debt', spvDebt],['SPV equity', spvEquity]],
    annualSvc:y=> y>=PA.opStart ? avail : 0,
    capOut:()=>0,
    opShare:y=> { const r=m[y-1]; return r.live ? -(r.farebox + r.nonFare + r.opGrant) : 0; },
    debtAdded:cap, grapNote:true, peakCash:0, avail, spvDebt, spvEquity, spvDS, eqDist, spvOpex,
    parties:[['Municipality','Demand risk — it keeps the farebox. Availability risk sits with the SPV.','No equity return. Pays R'+fmt(avail)+'k a year whether the buses are full or empty.'],
             ['SPV equity investors','Construction, operating, maintenance and lifecycle risk','Target equity internal rate of return of '+pctT(A('equityTarget'),1)+' over '+(PA.concession-PA.buildYears)+' years, distributed after senior debt service'],
             ['Senior lenders to the SPV','Project risk, secured on the concession and step-in rights','Margin at '+pctT(PA.seniorRate,2)+' with a minimum debt service cover of 1.30 times'],
             ['Operator','Performance deductions against the availability payment','Management fee inside the SPV operating cost, typically 3 to 5% of it']],
    gates:[['MFMA s120 and the Municipal PPP Regulations','A feasibility study, then the views of National Treasury and the provincial treasury on the bid documentation at least 30 days before bids are invited, and on the evaluation and preferred bidder at least 30 days before award.'],
           ['MFMA s33','A contract with future budgetary implications beyond three years. Public comment, council resolution, and the views of the National Treasury and the provincial treasury.'],
           ['Municipal Systems Act s78','A section 78 assessment of internal against external mechanisms must precede the decision to go external.'],
           ['GRAP 32','The grantor recognises the asset and a corresponding liability. This structure does not move the asset or the obligation off the balance sheet.']],
    why:'Buys construction and lifecycle certainty and transfers real risk, at a cost. The thing most often got wrong is the accounting: GRAP 32 brings the asset and the liability back onto the municipality\u2019s statements, so the borrowing ceiling is engaged even though no loan was raised.',
  });

  /* 6 — BOT with farebox risk transfer and a minimum revenue guarantee */
  const botDebt = cap*PA.botDebtShare, botEquity = cap*(1-PA.botDebtShare);
  const botSch = paDebtSchedule(botDebt, PA.botDebtRate, 18, PA.grace);
  const botDS = botSch[PA.grace] ? botSch[PA.grace].service : annuity(botDebt,PA.botDebtRate,15);
  const botEq = annuity(botEquity, PA.botEquityTarget, 18-PA.buildYears);
  const mrgLevel = 0.85;
  const subsidy = y=>{ const r=m[y-1]; if(!r.live) return 0;
    const need = botDS*1.25 + botEq + r.opex*0.90;
    return Math.max(0, need - r.farebox - r.nonFare); };
  const mrgExposure = m.filter(r=>r.live).reduce((s,r)=> s + Math.max(0, r.farebox*(1-mrgLevel)), 0);
  opts.push({
    k:'bot', kind:'Bespoke', n:'Build, operate and transfer with a minimum revenue guarantee',
    sub:`The concessionaire builds, owns and operates for 18 years taking farebox revenue risk, supported by a guarantee at ${pctT(mrgLevel,0)} of forecast revenue, and transfers the system to the municipality at the end.`,
    capSource:[['Concessionaire senior debt', botDebt],['Concessionaire equity', botEquity]],
    annualSvc:y=> subsidy(y),
    capOut:()=>0,
    opShare:()=>0,
    debtAdded:0, peakCash:0, botDebt, botEquity, botDS, botEq, mrgExposure, mrgLevel,
    parties:[['Municipality','Guarantee risk only — but the guarantee is written on the number the municipality itself forecast','No equity return. The asset transfers at no further cost in year 18.'],
             ['Concessionaire equity','Construction, operating and demand risk','Target of '+pctT(PA.botEquityTarget,1)+', higher than the availability structure because demand risk is priced'],
             ['Concessionaire lenders','Project and demand risk, mitigated by the guarantee','Margin at '+pctT(PA.botDebtRate,2)+' with cover of 1.25 times'],
             ['Guarantee holder — the municipality','Contingent liability of about R'+fmt(mrgExposure)+'k over the concession','Disclosed under GRAP 19; scored in the credit model as a contingent liability']],
    gates:[['MFMA s120','A municipal public-private partnership: the private party performs a municipal function and takes substantial financial, technical and operational risk.'],
           ['MFMA s33 and s50','Long-term contract and the guarantee. A municipality may not issue a guarantee for any commitment of another person except as permitted.'],
           ['GRAP 19','The minimum revenue guarantee is a contingent liability, measured and disclosed even where no payment is expected.'],
           ['GRAP 32','On transfer the asset is recognised at fair value; during the concession the arrangement is assessed against the control criteria.']],
    why:'The only structure here that transfers demand risk, which is the risk that actually destroys public transport projects. The guarantee is what makes it bankable and also what brings most of the risk back — a guarantee at 85% of a forecast the municipality produced is not much of a transfer.',
  });

  /* evaluate each */
  opts.forEach(o=>{
    const flows = m.map(r=> -(o.capOut(r.y)) - o.annualSvc(r.y) - o.opShare(r.y));
    o.npc = -npv(dn, flows);
    o.annualPeak = Math.max(...m.filter(r=>r.live).map(r=> o.annualSvc(r.y)+o.opShare(r.y)));
    o.annualY4 = o.annualSvc(PA.opStart)+o.opShare(PA.opStart);
    o.debtRatio = (existingDebt + o.debtAdded)/opRev;
    o.callOnSurplus = base.surplus ? o.annualY4/base.surplus : 0;
    o.cashAfter = freeCash - o.peakCash;
    o.flows = flows;
  });
  return {opts, base, freeCash, existingDebt, opRev, A0};
}

/* ================= INTAKE — files, zip, checklist ================= */
const PA_DOCS = [
 [1,'Investment memorandum or business case','States what is being built, why, and by whom. Everything in step 1 comes from it.','memorandum.pdf / business_case.pdf',/memo|business.?case|im\b/i],
 [2,'Financial model','The revenue, cost and funding assumptions. Read directly where supplied as CSV.','assumptions.csv / financial_model.xlsx',/assumption|financial.?model|model\.xls/i],
 [3,'Capital cost estimate and bill of quantities','Work-package costing at step 2. Without it the capital number is a claim, not an estimate.','capex_estimate.csv / boq.xlsx',/capex|boq|bill.?of.?quant|cost.?estimate/i],
 [4,'Demand and patronage study','Step 3. The single assumption that most often destroys a transport project.','demand_study.pdf / patronage.csv',/demand|patronage|ridership|traffic.?study/i],
 [5,'Operating cost model','Step 4. Vehicle-kilometre costing, station operations, fare collection.','opex_model.csv',/opex|operating.?cost/i],
 [6,'Independent technical review','Confirms the cost estimate and the programme are buildable.','technical_review.pdf',/technical|engineer|due.?dil/i],
 [7,'Environmental impact assessment and record of decision','A condition precedent to any construction contract.','eia_rod.pdf',/eia|environment|rod\b/i],
 [8,'Land and servitude schedule','Confirms the corridor can actually be assembled.','land_schedule.csv',/land|servitude|property/i],
 [9,'Grant allocation letter','Evidence the conditional grant is committed, not hoped for.','ptng_allocation.pdf',/ptng|grant|allocation|dora/i],
 [10,'Draft term sheet or funding offer','The rate, tenor and conditions used in step 10.','term_sheet.pdf',/term.?sheet|offer|facility/i],
 [11,'Section 78 assessment','Required by the Municipal Systems Act before any external mechanism is chosen.','s78_assessment.pdf',/s78|section.?78|assessment/i],
 [12,'Council resolution or committee mandate','The authority to proceed to the next gate.','council_resolution.pdf',/council|resolution|mandate/i],
 [13,'Risk register','The project risk register, distinct from the municipal one.','risk_register.csv',/risk.?reg/i],
 [14,'Operating and maintenance plan','Lifecycle and renewal assumptions behind the twenty-year cost.','om_plan.pdf',/o.?m.?plan|maintenance|lifecycle/i],
];
const PA_FILES = [];
function paClassify(name){
  for(const d of PA_DOCS){ if(d[4].test(name)) return d[0]; }
  return null;
}
/* minimal zip reader: central directory + raw deflate via the platform */
async function paReadZip(buf){
  const dv = new DataView(buf), u8 = new Uint8Array(buf);
  let eocd = -1;
  for(let i=u8.length-22; i>=Math.max(0,u8.length-66000); i--){ if(dv.getUint32(i,true)===0x06054b50){ eocd=i; break; } }
  if(eocd<0) throw new Error('Not a zip archive, or the central directory could not be found.');
  const count = dv.getUint16(eocd+10,true); let p = dv.getUint32(eocd+16,true);
  const out = [];
  for(let i=0;i<count;i++){
    if(dv.getUint32(p,true)!==0x02014b50) break;
    const method = dv.getUint16(p+10,true), csize = dv.getUint32(p+20,true), usize = dv.getUint32(p+24,true);
    const nlen = dv.getUint16(p+28,true), elen = dv.getUint16(p+30,true), clen = dv.getUint16(p+32,true);
    const lho = dv.getUint32(p+42,true);
    const name = new TextDecoder().decode(u8.subarray(p+46, p+46+nlen));
    const lnlen = dv.getUint16(lho+26,true), lelen = dv.getUint16(lho+28,true);
    const start = lho+30+lnlen+lelen;
    const raw = u8.subarray(start, start+csize);
    let data = raw;
    if(method===8){
      if(typeof DecompressionStream==='undefined') throw new Error('This browser cannot inflate a compressed archive. Store the zip without compression, or upload the files individually.');
      const ds = new DecompressionStream('deflate-raw');
      const blob = new Blob([raw]).stream().pipeThrough(ds);
      data = new Uint8Array(await new Response(blob).arrayBuffer());
    } else if(method!==0) throw new Error('Unsupported compression method '+method+' in '+name);
    if(!name.endsWith('/')) out.push({name:name.split('/').pop(), path:name, size:usize||data.length, data});
    p += 46+nlen+elen+clen;
  }
  return out;
}
function paParseAssumptions(text){
  const applied = [];
  text.split(/\r?\n/).forEach(line=>{
    const parts = line.split(',');
    if(parts.length<2) return;
    const k = parts[0].trim(), v = parseFloat(String(parts[1]).replace(/[^0-9.\-eE]/g,''));
    if(!k || !isFinite(v)) return;
    if(PA[k]!==undefined && typeof PA[k]==='number'){ PA_OVER[k]=v; applied.push([k,v]); }
  });
  return applied;
}
async function paIngest(fileList){
  for(const f of fileList){
    if(/\.zip$/i.test(f.name)){
      try{
        const entries = await paReadZip(await f.arrayBuffer());
        entries.forEach(e=> PA_FILES.push({name:e.name, size:e.size, from:f.name, doc:paClassify(e.name), data:e.data}));
        showToast(`${f.name} opened — ${entries.length} file(s) read`);
      }catch(err){ showToast(err.message); }
    } else {
      const data = new Uint8Array(await f.arrayBuffer());
      PA_FILES.push({name:f.name, size:f.size, from:'direct', doc:paClassify(f.name), data});
    }
  }
  const ass = PA_FILES.find(f=>/assumption/i.test(f.name) && /\.csv$/i.test(f.name));
  if(ass){
    const applied = paParseAssumptions(new TextDecoder().decode(ass.data));
    if(applied.length) showToast(`${applied.length} assumption(s) read from ${ass.name} — every step recomputed`);
  }
  renderPA();
}
function renderIntake(){
  const found = new Set(PA_FILES.map(f=>f.doc).filter(Boolean));
  document.getElementById('paDocTag').textContent = `${found.size} of ${PA_DOCS.length} required documents present`;
  document.querySelector('#paDocs tbody').innerHTML = PA_DOCS.map(d=>{
    const hit = PA_FILES.find(f=>f.doc===d[0]);
    return `<tr><td class="num">${d[0]}</td><td><b>${d[1]}</b></td>
      <td style="font-size:11px;color:var(--muted)">${d[2]}</td><td><code>${d[3]}</code></td>
      <td>${hit?`<span class="pill GREEN">${hit.name}</span>`:'<span class="pill INCOMPLETE">Not supplied</span>'}</td></tr>`;
  }).join('');
  const pct = found.size/PA_DOCS.length;
  document.getElementById('paDocNote').innerHTML = `<p class="footnote" style="margin:0">${pct>=1
    ? 'The pack is complete. The appraisal below runs on the documents supplied.'
    : `The appraisal runs on the built-in worked project where a document is missing. That is fine for a demonstration and not acceptable for a decision: a capital project approved on ${pctT(pct,0)} of its evidence base is approved on assertion. Missing items are listed above.`}</p>`;

  document.getElementById('paFileList').innerHTML = PA_FILES.length
    ? PA_FILES.map((f,i)=>`<div class="pa-file"><span>${f.doc?'✓':'•'}</span>
        <span class="nm">${f.name}</span>
        <span class="sz">${(f.size/1024).toFixed(1)} kB</span>
        <span class="pill ${f.doc?'GREEN':'INCOMPLETE'}" style="flex:0 0 auto">${f.doc?'Doc '+f.doc:'Unmatched'}</span>
        <button class="btn-ghost" data-rm="${i}" style="padding:2px 8px;font-size:10px">Remove</button></div>`).join('')
    : '<div style="font-size:11.5px;color:var(--muted);text-align:center;padding:14px">No files loaded. The appraisal is running on the built-in worked project.</div>';
  document.querySelectorAll('[data-rm]').forEach(b=>b.addEventListener('click',()=>{
    PA_FILES.splice(+b.dataset.rm,1); renderPA(); }));

  document.getElementById('paAssumTag').textContent = Object.keys(PA_OVER).length
    ? `${Object.keys(PA_OVER).length} value(s) overridden from the uploaded model` : 'Built-in worked project';
  document.querySelector('#paAssum tbody').innerHTML = PA_INPUTS.map(([k,lbl,unit,src])=>{
    const over = PA_OVER[k]!==undefined;
    const v = A(k);
    const disp = unit==='per year'||unit==='of capital cost'||unit==='of the deficit'||unit==='nominal'||unit==='real'
      ? pctT(v,2) : unit==='passengers' ? fmt(v) : unit==='years' ? N(v,0) : 'R'+N(v,2);
    return `<tr><td><b>${lbl}</b><span class="ax-src"><code>${k}</code></span></td>
      <td class="num" style="${over?'color:var(--navy);font-weight:800':''}">${disp}</td>
      <td style="font-size:11px;color:var(--muted)">${unit}</td>
      <td style="font-size:11px">${over?'<span class="pill NAVY">From the uploaded model</span>':src}</td></tr>`;
  }).join('');
}

/* ================= STEPS 1 TO 9 ================= */
const card=(t,d,b,f)=>`<div class="card"><div class="hd"><h3>${t}</h3>${d?`<span class="desc">${d}</span>`:''}</div>
  <div class="bd" style="padding:0;">${b}</div>${f?`<div class="bd" style="border-top:1px solid var(--line);">${f}</div>`:''}</div>`;
const tbl=(h,r)=>`<table class="grid"><thead><tr>${h}</tr></thead><tbody>${r}</tbody></table>`;

function renderDefine(){
  const cap=paCapexTotal(), A0=paAppraisal();
  document.getElementById('paDefine').innerHTML =
  `<div class="ax-note"><b>Step one asks what is being bought and for whom.</b> Everything after this is arithmetic; this step is the only place where the answer depends on what the municipality decided it wanted. Get the scope wrong here and every number downstream is precise and useless.</div>
   ${card('Project definition','Taken from the business case',tbl(
     '<th style="width:26%">Attribute</th><th>Detail</th>',
     [['Project',PA.name],['Sponsoring department',PA.sponsor],
      ['Scope','18.4 kilometres of segregated trunk corridor, 22 closed stations, 2 termini, 2 depots, an 84-vehicle fleet and a fare and control system'],
      ['Municipal function','Municipal public transport — Schedule 4B of the Constitution, a function of the municipality in its own right'],
      ['Capital cost','R'+fmt(cap)+'k, phased over three years at '+PA.phasing.map(p=>pctT(p,1)).join(', ')],
      ['Construction period',PA.buildYears+' years, with revenue service from year '+PA.opStart],
      ['Appraisal horizon',PA.years+' years, matching the concession term and the roadway design life'],
      ['Service life by component','Roadway 40 years, stations and depots 25, fleet 12, fare and control systems 8. The fleet is replaced twice inside the horizon.'],
     ].map(r=>`<tr><td><b>${r[0]}</b></td><td>${r[1]}</td></tr>`).join(''))) }
   ${card('Strategic fit','Tested against the instruments that authorise the spending',
     `<div class="bd">${[
       ['Integrated Development Plan','The corridor is identified in the current IDP as the primary public transport spine. A capital project not in the IDP cannot be in the budget.','pass'],
       ['Spatial Development Framework','The corridor follows the densification axis in the SDF. Without that alignment the patronage forecast has no land use to stand on.','pass'],
       ['Integrated Transport Plan','Required under the National Land Transport Act. The BRT is the designated trunk service with minibus taxi operators as the feeder layer.','pass'],
       ['Service delivery and budget implementation plan','The three-year construction programme must appear in the SDBIP with quarterly milestones, or there is nothing to report against under section 71.','warn'],
       ['Existing operator compensation','Affected minibus taxi operators must be compensated or absorbed. Every South African BRT that skipped this has been stopped by it.','warn'],
     ].map(g=>`<div class="pa-gate ${g[2]==='pass'?'pass':'warn'}"><b>${g[0]}</b><p>${g[1]}</p></div>`).join('')}</div>`)}
   ${card('What the appraisal will and will not answer','',
     `<div class="bd"><table class="grid"><tbody>
      <tr><td style="width:34%"><b>It answers</b></td><td>Whether the project pays for itself financially, what it costs the municipality in present value, whether the debt can be serviced from the surplus, and which funding structure is cheapest and which is affordable — two different questions.</td></tr>
      <tr><td><b>It does not answer</b></td><td>Whether the corridor is the right corridor, whether the patronage forecast is credible, or whether the municipality can build it. Those are engineering and planning questions and the appraisal takes their answers as inputs.</td></tr>
      <tr class="total"><td><b>The honest position on bus rapid transit</b></td><td>No BRT system anywhere recovers its full cost from the farebox. This one recovers ${pctT(A0.recoveryY4)} of operating cost in its first year of service. The financial case is therefore negative by construction and the decision is an economic and policy one — which is exactly why the economic appraisal in step six is presented beside the financial one rather than instead of it.</td></tr>
     </tbody></table></div>`)}`;
}
function renderCapex(){
  const cap=paCapexTotal();
  document.getElementById('paCapex').innerHTML =
  `<div class="ax-note"><b>Step two costs it, by work package and by year.</b> The phasing matters as much as the total: it sets the drawdown profile, the interest capitalised during construction and the year the first rand of grant must be claimed.</div>
   ${card('Capital cost by work package','R\u2019000',tbl(
     '<th style="width:28%">Work package</th><th class="num">Cost</th><th class="num">Share</th><th class="num">Useful life</th><th class="num">Annual depreciation</th><th>Scope</th>',
     PA.capex.map(c=>`<tr><td><b>${c[0]}</b></td><td class="num">${fmt(c[1])}</td><td class="num">${pctT(c[1]/cap)}</td>
       <td class="num">${c[2]?c[2]+' yrs':'Not depreciated'}</td><td class="num">${c[2]?fmt(c[1]/c[2]):'—'}</td>
       <td style="font-size:11px;color:var(--muted)">${c[3]}</td></tr>`).join('')
     +`<tr class="total"><td>Total capital cost</td><td class="num">${fmt(cap)}</td><td class="num">100.0%</td><td class="num">—</td>
       <td class="num">${fmt(PA.capex.reduce((s,c)=>s+(c[2]?c[1]/c[2]:0),0))}</td><td>Componentised on recognition under GRAP 17.43</td></tr>`),
     `<p class="footnote" style="margin:0">The fleet is ${pctT(322000/cap)} of capital cost on a twelve-year life, which means it is bought three times over a forty-year corridor. That is the number that turns a capital decision into a permanent operating commitment, and it is the reason the model carries fleet renewal in years nine and fifteen rather than treating the capital cost as a single event.</p>`)}
   ${card('Drawdown profile','Phasing sets the financing cost, not just the timing',tbl(
     '<th>Year</th><th class="num">Capital drawn</th><th class="num">Cumulative</th><th class="num">Grant at '+pctT(A('ptngCapShare'),0)+'</th><th class="num">Municipal share</th>',
     PA.phasing.map((p,i)=>{ const d=cap*p, cum=cap*PA.phasing.slice(0,i+1).reduce((a,b)=>a+b,0);
       return `<tr><td><b>Year ${i+1}</b></td><td class="num">${fmt(d)}</td><td class="num">${fmt(cum)}</td>
         <td class="num">${fmt(d*A('ptngCapShare'))}</td><td class="num">${fmt(d*(1-A('ptngCapShare')))}</td></tr>`;}).join('')
     +`<tr class="total"><td>Total</td><td class="num">${fmt(cap)}</td><td class="num">${fmt(cap)}</td>
       <td class="num">${fmt(cap*A('ptngCapShare'))}</td><td class="num">${fmt(cap*(1-A('ptngCapShare')))}</td></tr>`))}
   ${card('Cost risk','Where a capital estimate of this kind usually moves',tbl(
     '<th style="width:26%">Risk</th><th>Mechanism</th><th class="num">Plausible movement</th><th>Held by</th>',
     [['Ground conditions','Services relocation and unforeseen utilities along an urban corridor','+8% to +15% on civils','Municipality under a conventional contract; the SPV under a concession'],
      ['Land assembly','Expropriation compensation determined above the valuation','+20% on land','Municipality in every structure — it cannot be transferred'],
      ['Exchange rate','Fleet and fare systems are imported','+12% on fleet at a 10% depreciation','Municipality unless hedged at order'],
      ['Scope creep','Station specification rising during design development','+5% to +9% on stations','Municipality; the discipline is the design freeze, not the contract'],
      ['Programme','Each month of delay carries interest during construction and the standing cost of the team','R'+fmt(cap*A('loanRate')/12)+'k per month','Contractor to the extent of liquidated damages, municipality beyond it'],
     ].map(r=>`<tr><td><b>${r[0]}</b></td><td style="font-size:11px;color:var(--muted)">${r[1]}</td><td class="num">${r[2]}</td><td style="font-size:11px">${r[3]}</td></tr>`).join('')))}`;
}
function renderDemand(){
  const A0=paAppraisal(), m=A0.m;
  const live=m.filter(r=>r.live);
  document.getElementById('paDemand').innerHTML =
  `<div class="ax-note"><b>Step three is where transport projects are made or lost.</b> Patronage and fare are the only two revenue inputs, they multiply, and an error in either compounds across twenty years. Both are shown as single named assumptions rather than buried in a forecast.</div>
   ${card('Revenue build','R\u2019000 unless stated',tbl(
     '<th>Year</th><th class="num">Patronage</th><th class="num">Average fare</th><th class="num">Farebox</th><th class="num">Non-fare</th><th class="num">Total revenue</th><th class="num">Cost recovery</th>',
     live.filter((r,i)=>i%2===0||i===live.length-1).map(r=>
       `<tr><td><b>Year ${r.y}</b></td><td class="num">${fmt(r.pax/1000)}k</td><td class="num">R${N(r.fare,2)}</td>
        <td class="num">${fmt(r.farebox)}</td><td class="num">${fmt(r.nonFare)}</td><td class="num">${fmt(r.grossRev)}</td>
        <td class="num" style="color:${r.recovery<0.7?'var(--red)':'inherit'}">${pctT(r.recovery)}</td></tr>`).join(''),
     ),
     `<p class="footnote" style="margin:0">Farebox recovery opens at <b>${pctT(A0.recoveryY4)}</b> and reaches ${pctT(live[live.length-1].recovery)} by year ${PA.years}, because fares escalate at ${pctT(A('fareEsc'),1)} against operating cost at ${pctT(A('opexEsc'),1)} and patronage grows at ${pctT(A('paxGrowth'),1)}. A recovery ratio below one means the service runs at a loss every year it operates — that is normal for bus rapid transit worldwide and it is the whole reason step ten exists.</p>`)}
   ${card('Non-fare revenue','The part most business cases forget',tbl(
     '<th style="width:30%">Source</th><th class="num">Year 4</th><th>Basis</th>',
     [['Station and vehicle advertising',8400,'Concession to a media operator, revenue share on gross billings'],
      ['Station retail concessions',4200,'22 stations, kiosk and vending lettings'],
      ['Development charges on the corridor',12000,'Land value capture: charges levied on new development within 800 metres of a station'],
     ].map(r=>`<tr><td><b>${r[0]}</b></td><td class="num">${fmt(r[1])}</td><td style="font-size:11px;color:var(--muted)">${r[2]}</td></tr>`).join('')
      +`<tr class="total"><td>Total non-fare</td><td class="num">${fmt(PA.nonFare0)}</td><td>${pctT(PA.nonFare0/live[0].grossRev)} of revenue in the opening year</td></tr>`))}
   ${card('What moves the revenue line','Elasticity of the revenue number to each input',tbl(
     '<th style="width:30%">Input</th><th class="num">Base</th><th class="num">Minus ten per cent</th><th class="num">Effect on year 4 revenue</th><th>Comment</th>',
     [['Patronage',fmt(A('pax0')/1000)+'k',fmt(A('pax0')*0.9/1000)+'k',money(-live[0].farebox*0.10),'Linear. Every point of patronage is a point of farebox.'],
      ['Average fare','R'+N(A('fare0'),2),'R'+N(A('fare0')*0.9,2),money(-live[0].farebox*0.10),'Linear, but a fare cut raises patronage — the two are not independent and this model does not assume they are.'],
      ['Patronage growth',pctT(A('paxGrowth'),1),pctT(A('paxGrowth')-0.01,1),money(-live[live.length-1].farebox*0.15),'Compounds. A single point costs far more in year 20 than in year 4.'],
     ].map(r=>`<tr><td><b>${r[0]}</b></td><td class="num">${r[1]}</td><td class="num">${r[2]}</td><td class="num" style="color:var(--red)">${r[3]}</td><td style="font-size:11px;color:var(--muted)">${r[4]}</td></tr>`).join('')))}`;
}
function renderOpex(){
  const A0=paAppraisal(), m=A0.m, live=m.filter(r=>r.live), op0=paOpexY4();
  document.getElementById('paOpex').innerHTML =
  `<div class="ax-note"><b>Step four costs the service, not the asset.</b> A bus rapid transit system spends more on running itself in five years than it cost to build. Operating cost is the decision; capital cost is only the entry fee.</div>
   ${card('Operating cost at opening','Year 4, R\u2019000',tbl(
     '<th style="width:32%">Cost category</th><th class="num">Year 4</th><th class="num">Share</th><th class="num">Year 20</th><th>Basis</th>',
     PA.opex.map(c=>`<tr><td><b>${c[0]}</b></td><td class="num">${fmt(c[1])}</td><td class="num">${pctT(c[1]/op0)}</td>
       <td class="num">${fmt(c[1]*Math.pow(1+A('opexEsc'),PA.years-PA.opStart))}</td>
       <td style="font-size:11px;color:var(--muted)">${c[2]}</td></tr>`).join('')
     +`<tr class="total"><td>Total operating cost</td><td class="num">${fmt(op0)}</td><td class="num">100.0%</td>
       <td class="num">${fmt(op0*Math.pow(1+A('opexEsc'),PA.years-PA.opStart))}</td><td>Escalating at ${pctT(A('opexEsc'),1)}</td></tr>`),
     `<p class="footnote" style="margin:0">Vehicle operations are ${pctT(238080/op0)} of the total and are driven by kilometres run, not by passengers carried. That asymmetry is the economics of the whole system: the cost is fixed to the timetable and the revenue is variable with demand, so an empty bus costs the same as a full one.</p>`)}
   ${card('The operating deficit','What the municipality funds every year the system runs',tbl(
     '<th>Year</th><th class="num">Revenue</th><th class="num">Operating cost</th><th class="num">Deficit</th><th class="num">Operating grant</th><th class="num">Carried by the municipality</th>',
     live.filter((r,i)=>i%3===0||i===live.length-1).map(r=>
       `<tr><td><b>Year ${r.y}</b></td><td class="num">${fmt(r.grossRev)}</td><td class="num">${fmt(r.opex)}</td>
        <td class="num" style="color:${r.netOp<0?'var(--red)':'var(--green)'}">${money(r.netOp)}</td>
        <td class="num">${fmt(r.opGrant)}</td>
        <td class="num" style="color:var(--red)">${money(r.netOp+r.opGrant)}</td></tr>`).join('')),
     `<p class="footnote" style="margin:0">The deficit in the opening year is <b>R${fmt(A0.deficitY4)}k</b>, of which the Public Transport Network Grant covers ${pctT(A('ptngOpShare'),0)} and the municipality carries <b>R${fmt(A0.deficitY4*(1-A('ptngOpShare')))}k</b> — ${pctT(A0.deficitY4*(1-A('ptngOpShare'))/totals().surplus)} of the current operating surplus, every year, before a single rand of debt service. That is the number this appraisal exists to surface.</p>`)}
   ${card('Depreciation and lifecycle','What has to be set aside to keep the system standing',tbl(
     '<th style="width:34%">Component</th><th class="num">Cost</th><th class="num">Life</th><th class="num">Annual charge</th><th>Renewal inside the horizon</th>',
     PA.capex.filter(c=>c[2]).map(c=>`<tr><td>${c[0]}</td><td class="num">${fmt(c[1])}</td><td class="num">${c[2]} yrs</td>
       <td class="num">${fmt(c[1]/c[2])}</td><td>${c[2]<PA.years?`<span class="pill RED">Replaced in year ${c[2]+PA.buildYears}</span>`:'<span class="pill GREEN">Beyond the horizon</span>'}</td></tr>`).join('')))}`;
}

function renderStmts(){
  const A0=paAppraisal(), m=A0.m, F=paFunding(), sch=F.opts.find(o=>o.k==='ptng').sch;
  const show = m.filter(r=>r.y<=12);
  const pl = [
    ['Farebox revenue', r=>r.farebox],['Non-fare revenue', r=>r.nonFare],
    ['Operating grant', r=>r.opGrant],
    ['Total revenue', r=>r.farebox+r.nonFare+r.opGrant, true],
    ['Operating expenditure', r=>-r.opex],
    ['EBITDA', r=>r.farebox+r.nonFare+r.opGrant-r.opex, true],
    ['Depreciation', r=>-r.depn],
    ['EBIT', r=>r.farebox+r.nonFare+r.opGrant-r.opex-r.depn, true],
    ['Finance cost', r=>{const s=sch[r.y-1]; return s? -s.interest:0;}],
    ['Surplus or deficit for the year', r=>{const s=sch[r.y-1]; return r.farebox+r.nonFare+r.opGrant-r.opex-r.depn-(s?s.interest:0);}, true],
  ];
  const cf = [
    ['Cash from operations', r=>r.farebox+r.nonFare+r.opGrant-r.opex],
    ['Capital expenditure', r=>-r.capex],
    ['Capital grant received', r=>r.capGrant],
    ['Loan drawdown', r=>r.y<=PA.buildYears ? (F.opts.find(o=>o.k==='ptng').debtAdded)*PA.phasing[r.y-1] : 0],
    ['Debt service', r=>{const s=sch[r.y-1]; return s? -s.service:0;}],
    ['Net cash flow', r=>{const s=sch[r.y-1];
      return r.farebox+r.nonFare+r.opGrant-r.opex-r.capex+r.capGrant
        + (r.y<=PA.buildYears?(F.opts.find(o=>o.k==='ptng').debtAdded)*PA.phasing[r.y-1]:0) - (s?s.service:0);}, true],
  ];
  const mk=(rows)=>`<table class="grid"><thead><tr><th style="width:22%">R\u2019000</th>${show.map(r=>`<th class="num">Y${r.y}</th>`).join('')}</tr></thead><tbody>${
    rows.map(([n,f,tot])=>`<tr class="${tot?'total':''}"><td>${n}</td>${show.map(r=>{const v=f(r);
      return `<td class="num" style="${v<0?'color:var(--red)':''}">${Math.abs(v)<0.5?'—':money(v)}</td>`;}).join('')}</tr>`).join('')}</tbody></table>`;
  /* balance sheet roll-forward */
  let ppe=0, debt=0, cash=0;
  const bs = show.map(r=>{ const s=sch[r.y-1];
    ppe += r.capex - r.depn;
    debt = s? s.closing : debt;
    cash += cf[5][1](r);
    return {y:r.y, ppe, debt, cash, net:ppe+cash-debt}; });
  document.getElementById('paStmts').innerHTML =
  `<div class="ax-note"><b>Step five puts it into statements.</b> Shown on the recommended structure from step ten — grant plus limited borrowing — because a statement of financial performance cannot be drawn without deciding how the thing is paid for. Change the structure and these three statements change with it.</div>
   ${card('Statement of financial performance','First twelve years on the grant and limited borrowing structure', mk(pl),
     `<p class="footnote" style="margin:0">The project runs an accounting deficit for its whole life on this structure, because depreciation of R${fmt(m[PA.opStart-1].depn)}k a year is charged against a service that never covers its cash cost. That deficit is real and it is not a cash outflow — which is precisely why the cash flow statement below matters more than this one for the funding decision.</p>`)}
   ${card('Statement of cash flows','What actually moves', mk(cf))}
   ${card('Statement of financial position','Roll-forward',`<table class="grid"><thead><tr><th style="width:22%">R\u2019000</th>${bs.map(b=>`<th class="num">Y${b.y}</th>`).join('')}</tr></thead><tbody>
     <tr><td>Property, plant and equipment</td>${bs.map(b=>`<td class="num">${fmt(b.ppe)}</td>`).join('')}</tr>
     <tr><td>Cash</td>${bs.map(b=>`<td class="num" style="${b.cash<0?'color:var(--red)':''}">${money(b.cash)}</td>`).join('')}</tr>
     <tr><td>Long-term borrowing</td>${bs.map(b=>`<td class="num">(${fmt(b.debt)})</td>`).join('')}</tr>
     <tr class="total"><td>Net assets attributable to the project</td>${bs.map(b=>`<td class="num">${money(b.net)}</td>`).join('')}</tr>
   </tbody></table>`,
     `<p class="footnote" style="margin:0">These balances consolidate into the municipality\u2019s own statements — the asset into property, plant and equipment in the asset register module, the borrowing into long-term debt in the treasury module, and the deficit into the operating result the credit model scores. The project does not have a separate balance sheet in law; it has one here only so the appraisal can be read.</p>`)}`;
}
function renderAppraise(){
  const A0=paAppraisal(), dn=A('discNom');
  const wf = [{n:'Capital cost', v:-A0.cap, c:'var(--red)'},
    {n:'Capital grant', v:A0.capGrant, c:'var(--teal)'},
    {n:'Operating deficits, discounted', v:-(A0.netPresentCost - (A0.cap-A0.capGrant)), c:'var(--red)'},
    {n:'Terminal value', v:A0.term*(1-A('ptngCapShare'))/Math.pow(1+dn,PA.years), c:'var(--gold)'}];
  document.getElementById('paAppraise').innerHTML =
  `<div class="ax-note"><b>Step six appraises it three ways, because one answer is not enough.</b> The project case asks whether the asset pays for itself. The municipal case asks what it costs the municipality after grants. The economic case asks whether it is worth doing at all. They give different answers and all three are true.</div>
   <div class="kpis">${kpiSet([
     ['Financial net present value','R'+money(A0.projNPV)+'k', A0.projNPV<0?'bad':'good',
      `At a nominal discount rate of ${pctT(dn,1)} · ${A0.projNPV<0?'negative, as every bus rapid transit system is on farebox alone':'positive'}`],
     ['Financial internal rate of return', A0.projIRR===null?'Not defined':pctT(A0.projIRR), 'bad',
      A0.projIRR===null?'No rate discounts these flows to zero — the project never repays its capital from farebox':'Against a hurdle of '+pctT(dn,1)],
     ['Economic net present value','R'+money(A0.econNPV)+'k', A0.econNPV>0?'good':'bad',
      `At the social discount rate of ${pctT(A('discEcon'),1)} · time savings, vehicle cost, accidents and emissions`],
     ['Economic internal rate of return', A0.econIRR===null?'—':pctT(A0.econIRR), A0.econIRR>A('discEcon')?'good':'bad',
      `Against the social discount rate of ${pctT(A('discEcon'),1)} · ${A0.econIRR>A('discEcon')?'the project is worth doing':'it is not'}`],
   ])}</div>
   ${card('The three appraisals side by side','Same project, three questions',tbl(
     '<th style="width:22%">Appraisal</th><th>Question it answers</th><th class="num">Net present value</th><th class="num">Internal rate of return</th><th class="num">Payback</th><th>Verdict</th>',
     [['Project financial','Does the asset pay for itself from what it earns?', A0.projNPV, A0.projIRR, A0.projPB,
       'No. Farebox recovers '+pctT(A0.recoveryY4)+' of operating cost.'],
      ['Municipal financial','What does it cost the municipality after grants?', A0.muniNPV, A0.muniIRR, A0.muniPB,
       'A net present cost of R'+fmt(A0.netPresentCost)+'k, which is the honest price of the decision.'],
      ['Economic','Is it worth doing for the city as a whole?', A0.econNPV, A0.econIRR, null,
       A0.econNPV>0?'Yes, on benefits that accrue to travellers rather than to the municipality.':'No.'],
     ].map(r=>`<tr><td><b>${r[0]}</b></td><td style="font-size:11px;color:var(--muted)">${r[1]}</td>
       <td class="num" style="color:${r[2]<0?'var(--red)':'var(--green)'}">${money(r[2])}</td>
       <td class="num">${r[3]===null?'Not defined':pctT(r[3])}</td>
       <td class="num">${r[4]===null?'—':N(r[4],1)+' yrs'}</td>
       <td style="font-size:11px">${r[5]}</td></tr>`).join('')),
     `<p class="footnote" style="margin:0">A negative financial net present value alongside a positive economic one is the normal result for urban public transport and is not a contradiction. It says the benefits are real but accrue to travellers as time saved rather than to the municipality as revenue. The decision that follows is therefore a subsidy decision, and the only remaining question is whether the subsidy is affordable — which is step eleven.</p>`)}
   ${card('Where the value goes','Net present value bridge on the municipal case',
     `<div class="bd"><div class="pa-waterfall" id="paWf"></div>
      <div class="ax-read" id="paWfRead">Hover a bar to read the component.</div></div>`)}
   ${card('Economic benefits','Year 4 money, escalating with patronage',tbl(
     '<th style="width:30%">Benefit</th><th class="num">Year 4</th><th class="num">Share</th><th>Basis</th>',
     PA.econ.map(e=>`<tr><td><b>${e[0]}</b></td><td class="num">${fmt(e[1])}</td><td class="num">${pctT(e[1]/A0.econ0)}</td>
       <td style="font-size:11px;color:var(--muted)">${e[2]}</td></tr>`).join('')
     +`<tr class="total"><td>Total economic benefit</td><td class="num">${fmt(A0.econ0)}</td><td class="num">100.0%</td>
       <td>Against an operating cost of R${fmt(paOpexY4())}k — a benefit-cost ratio of ${N(A0.econ0/paOpexY4(),2)} in the opening year</td></tr>`),
     `<p class="footnote" style="margin:0">Travel time savings are ${pctT(268000/A0.econ0)} of the economic case, which is where the entire argument sits. They are also the softest number in the appraisal: they depend on a value of time, on the time actually saved, and on the assumption that saved time has economic worth. State the assumption openly and let the decision-maker weigh it, rather than folding it into a single benefit-cost ratio.</p>`)}`;
  const max = Math.max(...wf.map(w=>Math.abs(w.v)));
  document.getElementById('paWf').innerHTML = wf.map((w,i)=>
    `<div class="wc" data-wf="${i}"><div class="wb" style="height:${Math.abs(w.v)/max*118}px;background:${w.c}"></div>
      <div class="wl">${w.n}<br/><b>${money(w.v)}</b></div></div>`).join('');
  const rd=document.getElementById('paWfRead'); const def='Hover a bar to read the component.';
  document.querySelectorAll('[data-wf]').forEach(el=>{
    el.addEventListener('mouseenter',()=>{const w=wf[+el.dataset.wf];
      rd.innerHTML=`<b>${w.n}</b> — R${money(w.v)}k in present value terms. ${
        w.n.includes('Capital cost')?'The whole capital cost, undiscounted in year one to three money and then discounted at '+pctT(dn,1)+'.'
        :w.n.includes('grant')?'The Public Transport Network Grant at '+pctT(A('ptngCapShare'),0)+' of capital. It is not repayable but it is conditional, and unspent portions revert under section 22 of the Division of Revenue Act.'
        :w.n.includes('deficits')?'Every year of operating shortfall the municipality carries after the operating grant, discounted back. This is the part that never stops.'
        :'The residual value of the roadway and land at year '+PA.years+', discounted. Assets with lives beyond the horizon, net of the grant-funded share.'}`;});
    el.addEventListener('mouseleave',()=>rd.innerHTML=def);
  });
}

function renderDebt(){
  const F=paFunding(), A0=F.A0, m=A0.m, o=F.opts.find(x=>x.k==='ptng'), sch=o.sch;
  const base=totals();
  const cfads = y=>{ const r=m[y-1]; return r.live ? (r.grossRev + r.opGrant - r.opex) : 0; };
  const muniCfads = y=>{ const r=m[y-1]; return base.surplus + base.expenditure*0 + L('5130') + (r.live? (r.grossRev+r.opGrant-r.opex) : 0); };
  const rows = sch.filter(s=>s.y>PA.grace && s.y<=PA.years);
  document.getElementById('paDebt').innerHTML =
  `<div class="ax-note"><b>Step seven asks whether it can be paid for.</b> Cover is tested twice: on the project\u2019s own cash flow, which fails, and on the municipality\u2019s consolidated cash flow, which is the only test that matters because the lender has recourse to general revenue, not to the buses.</div>
   <div class="kpis">${kpiSet([
     ['Debt raised','R'+fmt(o.debtAdded)+'k','',`${pctT(A('loanRate'),2)} over ${A('loanTenor')} years, ${PA.grace} years of capitalised interest`],
     ['Peak annual debt service','R'+fmt(Math.max(...rows.map(r=>r.service)))+'k','warn',`Reached in year ${rows.reduce((a,b)=>b.service>a.service?b:a).y}`],
     ['Project debt service cover', N(cfads(PA.opStart)/rows[0].service,2)+'x','bad',
      'The project cannot service its own debt in any year. Its cash flow is negative.'],
     ['Consolidated cover', N((base.surplus+L('5130')+L('5140'))/(L('5140')+L('2110')+rows[0].service),2)+'x',
      (base.surplus+L('5130')+L('5140'))/(L('5140')+L('2110')+rows[0].service)>=1.5?'good':'bad',
      'Municipal surplus plus depreciation plus interest, over all debt service including this project'],
   ])}</div>
   ${card('Amortisation schedule','R\u2019000 · capitalised interest during construction, level annuity thereafter',tbl(
     '<th>Year</th><th class="num">Opening</th><th class="num">Interest</th><th class="num">Capital</th><th class="num">Debt service</th><th class="num">Closing</th><th class="num">Project cash flow</th>',
     sch.filter((s,i)=>i%2===0||i===sch.length-1).map(s=>
       `<tr><td><b>Year ${s.y}</b></td><td class="num">${fmt(s.opening)}</td><td class="num">${fmt(s.interest)}</td>
        <td class="num">${s.capital?fmt(s.capital):'<span style="color:var(--muted)">capitalised</span>'}</td>
        <td class="num">${fmt(s.service)}</td><td class="num">${fmt(s.closing)}</td>
        <td class="num" style="color:${cfads(s.y)<0?'var(--red)':'var(--green)'}">${money(cfads(s.y))}</td></tr>`).join(''),
     ))}
   ${card('Coverage tests','Two tests, two answers',tbl(
     '<th style="width:26%">Test</th><th>What it measures</th><th class="num">Value</th><th class="num">Covenant</th><th>Result</th>',
     (()=>{ const ds0=rows[0].service, allDS=L('5140')+L('2110')+ds0;
       const cf=(base.surplus+L('5130')+L('5140'));
       const llcrNum = npv(A('loanRate'), sch.filter(s=>s.y>PA.grace).map(s=>cfads(s.y)));
       return [
        ['Project debt service cover','Project cash available for debt service over project debt service', cfads(PA.opStart)/ds0, 1.30,'hi'],
        ['Consolidated debt service cover','Municipal surplus plus depreciation plus interest, over total debt service', cf/allDS, 1.50,'hi'],
        ['Loan life cover','Present value of project cash flow over debt outstanding', llcrNum/o.debtAdded, 1.40,'hi'],
        ['Borrowing to operating revenue','Total debt after drawdown over operating revenue', (F.existingDebt+o.debtAdded)/F.opRev, 0.45,'lo'],
        ['Debt service to operating revenue','Total debt service over operating revenue', allDS/F.opRev, 0.08,'lo'],
       ].map(r=>{ const ok = r[4]==='hi'? r[2]>=r[3] : r[2]<=r[3];
         const f=x=> r[4]==='hi'? N(x,2)+'x' : pctT(x);
         return `<tr><td><b>${r[0]}</b></td><td style="font-size:11px;color:var(--muted)">${r[1]}</td>
           <td class="num" style="color:${ok?'inherit':'var(--red)'}">${f(r[2])}</td>
           <td class="num">${r[4]==='hi'?'≥ ':'≤ '}${f(r[3])}</td>
           <td><span class="pill ${ok?'GREEN':'RED'}">${ok?'Passes':'Fails'}</span></td></tr>`;}).join('');})()),
     `<p class="footnote" style="margin:0">The project fails its own cover test and passes the consolidated one. That is the entire financing argument for a municipal bus rapid transit system: it is lent against the rates base, not against the farebox, and the lender knows it. Anyone structuring this as project finance on the project\u2019s own cash flow is structuring something that cannot be funded.</p>`)}
   ${card('Against the municipality\u2019s own position','Read from the treasury module',tbl(
     '<th style="width:30%">Measure</th><th class="num">Before the project</th><th class="num">After</th><th class="num">Movement</th><th>Effect</th>',
     [['Long-term borrowing', F.existingDebt, F.existingDebt+o.debtAdded, 'R'],
      ['Borrowing to operating revenue', F.existingDebt/F.opRev, (F.existingDebt+o.debtAdded)/F.opRev, '%'],
      ['Annual debt service', L('5140')+L('2110'), L('5140')+L('2110')+rows[0].service, 'R'],
      ['Free cash', F.freeCash, F.freeCash-o.peakCash, 'R'],
      ['Operating surplus after the project deficit', base.surplus, base.surplus-A0.muniCallY4+rows[0].service*0, 'R'],
     ].map(r=>{ const f=x=> r[3]==='%'?pctT(x):'R'+money(x)+'k'; const d=r[2]-r[1];
       return `<tr><td><b>${r[0]}</b></td><td class="num">${f(r[1])}</td><td class="num">${f(r[2])}</td>
         <td class="num" style="color:${d>0&&r[0].includes('borrow')||d<0&&!r[0].includes('borrow')?'var(--red)':'var(--green)'}">${f(d)}</td>
         <td style="font-size:11px;color:var(--muted)">${r[0].includes('Borrowing to')?'Circular 71 ceiling is 45%':r[0].includes('Free cash')?'Cover falls from '+N(F.freeCash/(base.expenditure/12),1)+' to '+N((F.freeCash-o.peakCash)/(base.expenditure/12),1)+' months':''}</td></tr>`;}).join('')))}`;
}
function renderRatios(){
  const F=paFunding(), A0=F.A0, m=A0.m, o=F.opts.find(x=>x.k==='ptng'), sch=o.sch, ppp=F.opts.find(x=>x.k==='ppp'), bot=F.opts.find(x=>x.k==='bot');
  const live=m.filter(r=>r.live);
  const eq = A0.cap - A0.capGrant - o.debtAdded;
  document.getElementById('paRatios').innerHTML =
  `<div class="ax-note"><b>Step eight is the ratio pack, and the returns for every party.</b> A municipality earns no return on a bus rapid transit system; it buys mobility. The parties that do earn a return are in the private structures, and their return is a cost to the municipality — so it is shown here rather than hidden inside a unitary payment.</div>
   ${card('Return measures — the municipality','A public entity has no equity return, so the measures that apply are cost measures',tbl(
     '<th style="width:30%">Measure</th><th class="num">Value</th><th>How it is computed</th><th>What it means here</th>',
     [['Return on equity employed', eq>0? (live[0].netOp)/eq : 0, 'Project operating result over the municipal cash contribution',
       'Negative. The municipality contributes cash to an asset that produces an operating loss — the return is the service, not the money.'],
      ['Return on invested capital', A0.cap? live[live.length-1].netOp/A0.cap : 0, 'Year 20 operating result over total capital invested',
       'Turns positive only once fares have escalated past operating cost, in year '+ (live.find(r=>r.netOp>0)?live.find(r=>r.netOp>0).y:'never') + '.'],
      ['Net present cost per passenger', A0.netPresentCost/(live.reduce((s,r)=>s+r.pax,0)/1000), 'Municipal net present cost over total passengers carried',
       'The subsidy per trip in present value terms. This is the number to compare against alternatives, not the internal rate of return.'],
      ['Cost recovery ratio', A0.recoveryY4, 'Farebox over operating cost in the opening year',
       'Below one for the whole appraisal period on these assumptions.'],
      ['Capital cost per kilometre', A0.cap/18.4, 'Total capital over 18.4 route kilometres',
       'The benchmark figure lenders and treasuries compare against other corridors.'],
     ].map(r=>`<tr><td><b>${r[0]}</b></td>
       <td class="num" style="color:${r[1]<0?'var(--red)':'inherit'}">${r[0].includes('per passenger')||r[0].includes('per kilometre')?'R'+money(r[1])+'k':pctT(r[1])}</td>
       <td style="font-size:11px;color:var(--muted)">${r[2]}</td><td style="font-size:11px">${r[3]}</td></tr>`).join('')))}
   ${card('Returns to other parties','Only in the structures where another party puts capital at risk',tbl(
     '<th style="width:22%">Structure</th><th>Party</th><th class="num">Capital at risk</th><th class="num">Target return</th><th>What they are paid for</th>',
     [['Concession — availability payment','SPV equity investors', ppp.spvEquity, A('equityTarget'),'Construction, operating, maintenance and lifecycle risk over twenty years. They do not carry demand risk — the municipality keeps the farebox and the risk that goes with it.'],
      ['Concession — availability payment','Senior lenders to the SPV', ppp.spvDebt, PA.seniorRate,'Project completion and operating risk, secured on the concession with step-in rights. Cover of 1.30 times is the binding constraint on how much they will lend.'],
      ['Build, operate and transfer','Concessionaire equity', bot.botEquity, PA.botEquityTarget,'Everything the availability structure covers, plus demand risk. The extra '+pctT(PA.botEquityTarget-A('equityTarget'),1)+' is the price of that risk.'],
      ['Build, operate and transfer','Concessionaire lenders', bot.botDebt, PA.botDebtRate,'Demand risk mitigated by the minimum revenue guarantee, which is why they lend at all.'],
      ['Listed bond','Bondholders', F.opts.find(x=>x.k==='bond').debtAdded, A('bondRate'),'Credit risk on the municipality only. They carry none of the project risk and price accordingly.'],
     ].map(r=>`<tr><td>${r[0]}</td><td><b>${r[1]}</b></td><td class="num">${fmt(r[2])}</td>
       <td class="num">${pctT(r[3],1)}</td><td style="font-size:11px;color:var(--muted)">${r[4]}</td></tr>`).join('')),
     `<p class="footnote" style="margin:0">Every point of equity return in a concession is paid by the municipality through the availability payment or forgone through the farebox. The question is never whether the private return is high, it is whether the risk transferred is worth what the return costs. At an equity target of ${pctT(A('equityTarget'),1)} on R${fmt(ppp.spvEquity)}k, the municipality is paying roughly <b>R${fmt(ppp.eqDist)}k a year</b> for construction and lifecycle certainty.</p>`)}
   ${card('Financial ratio pack','On the recommended structure, first and last operating year',tbl(
     '<th style="width:26%">Ratio</th><th>Group</th><th class="num">Year 4</th><th class="num">Year 20</th><th class="num">Benchmark</th><th>Result</th>',
     (()=>{ const y4=live[0], y20=live[live.length-1], s4=sch[PA.opStart-1], s20=sch[PA.years-1];
       return [
        ['Operating margin','Profitability', y4.netOp/y4.grossRev, y20.netOp/y20.grossRev, 0,'hi'],
        ['EBITDA margin including grant','Profitability', (y4.netOp+y4.opGrant)/(y4.grossRev+y4.opGrant), (y20.netOp+y20.opGrant)/(y20.grossRev+y20.opGrant), 0,'hi'],
        ['Cost recovery','Efficiency', y4.recovery, y20.recovery, 1.0,'hi'],
        ['Operating cost per passenger','Efficiency', y4.opex/(y4.pax/1000), y20.opex/(y20.pax/1000), null,'x'],
        ['Farebox per passenger','Efficiency', y4.farebox/(y4.pax/1000), y20.farebox/(y20.pax/1000), null,'x'],
        ['Interest cover','Leverage', s4? (y4.netOp+y4.opGrant+y4.depn)/s4.interest : 0, s20? (y20.netOp+y20.opGrant+y20.depn)/s20.interest : 0, 2.0,'hi'],
        ['Debt to capital employed','Leverage', o.debtAdded/A0.cap, (s20?s20.closing:0)/A0.cap, 0.45,'lo'],
        ['Asset turnover','Efficiency', y4.grossRev/A0.cap, y20.grossRev/A0.cap, null,'x'],
       ].map(r=>{ const f=x=> r[5]==='x'? (r[0].includes('per passenger')?'R'+N(x*1000,2):N(x,3)) : pctT(x);
         const ok = r[4]===null? null : r[5]==='hi'? r[3]>=r[4] : r[3]<=r[4];
         return `<tr><td><b>${r[0]}</b></td><td>${r[1]}</td><td class="num">${f(r[2])}</td><td class="num">${f(r[3])}</td>
           <td class="num">${r[4]===null?'—':(r[5]==='hi'?'≥ ':'≤ ')+pctT(r[4])}</td>
           <td>${ok===null?'<span class="pill NAVY">Information</span>':`<span class="pill ${ok?'GREEN':'RED'}">${ok?'Meets':'Below'}</span>`}</td></tr>`;}).join('');})()))}`;
}
function renderPaSens(){
  const A0=paAppraisal(), dn=A('discNom');
  const vars=[['pax0','Opening patronage',[-20,-10,0,10,20]],['fare0','Average fare',[-20,-10,0,10,20]],
    ['opexEsc','Operating cost escalation',[-2,-1,0,1,2]],['paxGrowth','Patronage growth',[-1.5,-0.75,0,0.75,1.5]],
    ['ptngCapShare','Capital grant share',[-20,-10,0,10,20]],['discNom','Discount rate',[-2,-1,0,1,2]]];
  const run=(k,delta,isPct)=>{ const old=PA_OVER[k]; const b=A(k);
    PA_OVER[k] = isPct ? b + delta/100 : b*(1+delta/100);
    const r=paAppraisal(); if(old===undefined) delete PA_OVER[k]; else PA_OVER[k]=old; return r; };
  const rows = vars.map(([k,lbl,steps])=>{
    const isPct = ['opexEsc','paxGrowth','discNom','ptngCapShare'].includes(k) && k!=='ptngCapShare';
    const vals = steps.map(s=> run(k, s, ['opexEsc','paxGrowth','discNom'].includes(k)));
    return {k,lbl,steps,vals, swing: Math.max(...vals.map(v=>v.netPresentCost)) - Math.min(...vals.map(v=>v.netPresentCost))};
  }).sort((a,b)=>b.swing-a.swing);
  const switching = [
    ['Patronage at which the operating deficit closes in the opening year',
      (paOpexY4()-PA.nonFare0)/A('fare0')*1000, 'passengers', A('pax0'), 'The system would need '+pctT(((paOpexY4()-PA.nonFare0)/A('fare0')*1000)/A('pax0')-1)+' more passengers than forecast on day one.'],
    ['Fare at which the operating deficit closes in the opening year',
      (paOpexY4()-PA.nonFare0)*1000/A('pax0'), 'rand per trip', A('fare0'), 'Roughly '+N(((paOpexY4()-PA.nonFare0)*1000/A('pax0'))/A('fare0'),1)+' times the planned fare, which would itself destroy the patronage it depends on.'],
    ['Capital grant share at which the municipality carries nothing in capital',
      1.0, 'of capital cost', A('ptngCapShare'), 'Full grant funding, which no national grant window provides. The municipal contribution is structural.'],
    ['Operating grant share at which the annual call on the surplus is nil',
      1.0, 'of the deficit', A('ptngOpShare'), 'Also unavailable. Every South African system carries a municipal share of the deficit.'],
  ];
  document.getElementById('paSens').innerHTML =
  `<div class="ax-note"><b>Step nine finds what the answer actually depends on.</b> Each input is moved on its own and everything else is held. The variables are ranked by how far they move the net present cost, so the ones worth spending money to get right are at the top.</div>
   ${card('Sensitivity of the municipal net present cost','R\u2019000 · ranked by swing',
     `<table class="grid"><thead><tr><th style="width:24%">Input</th>${rows[0].steps.map((s,i)=>`<th class="num">${['−−','−','Base','+','++'][i]}</th>`).join('')}<th class="num">Swing</th><th style="width:16%">Rank</th></tr></thead><tbody>
     ${rows.map((r,i)=>`<tr><td><b>${r.lbl}</b><span class="ax-src">${r.steps.map(s=>(s>0?'+':'')+s+(['opexEsc','paxGrowth','discNom'].includes(r.k)?' pts':'%')).join(' / ')}</span></td>
       ${r.vals.map(v=>`<td class="num">${fmt(v.netPresentCost)}</td>`).join('')}
       <td class="num" style="font-weight:800">${fmt(r.swing)}</td>
       <td><div class="ax-prog"><i class="${i===0?'r':i<3?'a':'g'}" style="width:${r.swing/rows[0].swing*100}%"></i></div>
         <span class="ax-mini">#${i+1} of ${rows.length}</span></td></tr>`).join('')}
     </tbody></table>`,
     `<p class="footnote" style="margin:0">The dominant variable is <b>${rows[0].lbl.toLowerCase()}</b>, at a swing of R${fmt(rows[0].swing)}k across the tested range —
      ${pctT(rows[0].swing/A0.netPresentCost)} of the whole net present cost. That is where independent verification is worth paying for. The variables at the
      bottom of the table can be argued about at length without changing the decision.</p>`)}
   ${card('Switching values','The point at which the answer changes',tbl(
     '<th style="width:34%">Switching point</th><th class="num">Required value</th><th class="num">Forecast value</th><th class="num">Gap</th><th>Comment</th>',
     switching.map(s=>`<tr><td><b>${s[0]}</b></td>
       <td class="num">${s[2]==='passengers'?fmt(s[1]/1000)+'k':s[2]==='rand per trip'?'R'+N(s[1],2):pctT(s[1],0)}</td>
       <td class="num">${s[2]==='passengers'?fmt(s[3]/1000)+'k':s[2]==='rand per trip'?'R'+N(s[3],2):pctT(s[3],0)}</td>
       <td class="num" style="color:var(--red)">${pctT(s[1]/s[3]-1)}</td>
       <td style="font-size:11px;color:var(--muted)">${s[4]}</td></tr>`).join('')),
     `<p class="footnote" style="margin:0">None of the four switching values is reachable. That is not a failure of the project, it is the definition of a
      subsidised public service: there is no combination of fare and patronage at which a bus rapid transit system funds itself, which is why the appraisal
      ends in an affordability test rather than a rate of return.</p>`)}
   ${card('Downside case','All the adverse movements together, which is how they actually arrive',
     (()=>{ const sv={...PA_OVER};
       PA_OVER.pax0=A('pax0')*0.82; PA_OVER.fare0=A('fare0')*0.95; PA_OVER.opexEsc=A('opexEsc')+0.015;
       PA_OVER.paxGrowth=A('paxGrowth')-0.012; PA_OVER.ptngCapShare=A('ptngCapShare')-0.10;
       const d=paAppraisal(); Object.keys(PA_OVER).forEach(k=>delete PA_OVER[k]); Object.assign(PA_OVER,sv);
       return tbl('<th style="width:34%">Measure</th><th class="num">Base case</th><th class="num">Downside</th><th class="num">Movement</th>',
        [['Municipal net present cost', A0.netPresentCost, d.netPresentCost],
         ['Operating deficit in year 4', A0.deficitY4, d.deficitY4],
         ['Annual call on the municipality in year 4', A0.muniCallY4, d.muniCallY4],
         ['Cost recovery in year 4', A0.recoveryY4, d.recoveryY4, true],
         ['Economic net present value', A0.econNPV, d.econNPV],
        ].map(r=>`<tr><td><b>${r[0]}</b></td><td class="num">${r[3]?pctT(r[1]):'R'+money(r[1])+'k'}</td>
          <td class="num">${r[3]?pctT(r[2]):'R'+money(r[2])+'k'}</td>
          <td class="num" style="color:var(--red)">${r[3]?pctT(r[2]-r[1]):'R'+money(r[2]-r[1])+'k'}</td></tr>`).join(''));})(),
     `<p class="footnote" style="margin:0">The downside combines 18% lower opening patronage, a 5% lower fare, operating cost escalating 1.5 points faster,
      growth 1.2 points slower and a grant share 10 points lower. Each is individually plausible and none is extreme; taken together they are the case the
      municipality should be able to survive, not the case it should plan for.</p>`)}`;
}

let paSel='ptng', paOpen=null;
function renderFund(){
  const F=paFunding(), A0=F.A0, base=totals();
  const best = F.opts.slice().sort((a,b)=>a.npc-b.npc);
  const rank = o => best.indexOf(o)+1;
  document.getElementById('paFund').innerHTML =
  `<div class="ax-note"><b>Step ten sets out six ways to pay for it — three conventional and three bespoke.</b> Each is costed on the same cash flows, so the
    net present cost column is directly comparable. Cheapest and affordable are different questions and the table answers both: the cheapest option here
    exhausts the municipality\u2019s entire liquidity buffer, and the one that breaches nothing is not the cheapest.</div>
   ${card('The six side by side','Same project, same discount rate, R\u2019000',tbl(
     '<th style="width:5%">Rank</th><th style="width:24%">Structure</th><th>Type</th><th class="num">Net present cost</th><th class="num">Year 4 call</th><th class="num">Debt added</th><th class="num">Debt to revenue</th><th>Permissible</th>',
     best.map(o=>{ const brk = o.debtRatio>0.45 || o.cashAfter<base.expenditure/12;
       return `<tr class="click" data-fo="${o.k}"><td class="num">${rank(o)}</td>
        <td><b>${o.n}</b><span class="ax-src">Click to open</span></td>
        <td><span class="pill ${o.kind==='Conventional'?'NAVY':'GOLD'}">${o.kind}</span></td>
        <td class="num"><b>${fmt(o.npc)}</b></td><td class="num">${fmt(o.annualY4)}</td>
        <td class="num">${o.debtAdded?fmt(o.debtAdded):'—'}</td>
        <td class="num" style="color:${o.debtRatio>0.45?'var(--red)':'inherit'}">${pctT(o.debtRatio)}</td>
        <td><span class="pill ${brk?'RED':'GREEN'}">${brk?(o.debtRatio>0.45?'Breaches the ceiling':'Exhausts liquidity'):'Permissible'}</span></td></tr>`;}).join(''),
     ),
     `<p class="footnote" style="margin:0">Net present cost is every rand the municipality pays, discounted at ${pctT(A('discNom'),1)}: capital contribution,
      debt service, availability payments and the operating deficit it carries after grants. It is the only number on which six structures with different
      shapes can be compared. The year 4 call is what appears in the budget that year, which is a different and usually more binding constraint.</p>`)}
   <div id="paOpts">${F.opts.map(o=>`
     <div class="pa-opt ${paOpen===o.k?'on':''} ${paSel===o.k?'sel':''}" id="opt-${o.k}">
       <div class="oh" data-fo="${o.k}">
         <div class="rank">${rank(o)}</div>
         <div><b>${o.n}</b><div style="font-size:11px;color:var(--muted);line-height:1.5;margin-top:2px">${o.sub}</div></div>
         <span class="tag">${o.kind}</span>
         <div class="npc">R${fmt(o.npc)}k<div style="font-size:9.5px;font-weight:600;color:var(--muted);text-align:right">net present cost</div></div>
       </div>
       <div class="ob">
         <div class="bd"><div class="ax-note" style="margin:0">${o.why}</div></div>
         <div class="bd" style="padding:0;border-top:1px solid var(--line);">
           <table class="grid"><thead><tr><th style="width:34%">Sources of capital</th><th class="num">R\u2019000</th><th class="num">Share</th></tr></thead><tbody>
           ${o.capSource.map(c=>`<tr><td>${c[0]}</td><td class="num">${fmt(c[1])}</td><td class="num">${pctT(c[1]/A0.cap)}</td></tr>`).join('')}
           <tr class="total"><td>Total</td><td class="num">${fmt(o.capSource.reduce((s,c)=>s+c[1],0))}</td><td class="num">100.0%</td></tr>
           </tbody></table></div>
         <div class="bd" style="padding:0;border-top:1px solid var(--line);">
           <table class="grid"><thead><tr><th style="width:22%">Party</th><th>Risk carried</th><th>Return</th></tr></thead><tbody>
           ${o.parties.map(p=>`<tr><td><b>${p[0]}</b></td><td style="font-size:11px">${p[1]}</td><td style="font-size:11px;color:var(--teal);font-weight:600">${p[2]}</td></tr>`).join('')}
           </tbody></table></div>
         <div class="bd" style="border-top:1px solid var(--line);">
           <div style="font-size:11px;font-weight:800;color:var(--navy-dark);margin-bottom:7px">Legal and regulatory gates</div>
           ${o.gates.map(g=>`<div class="pa-gate"><span class="law">${g[0]}</span><p>${g[1]}</p></div>`).join('')}</div>
         <div class="bd" style="padding:0;border-top:1px solid var(--line);">
           <table class="grid"><thead><tr><th style="width:34%">Effect on the municipality</th><th class="num">Value</th><th>Comment</th></tr></thead><tbody>
             <tr><td>Net present cost</td><td class="num">R${fmt(o.npc)}k</td><td style="font-size:11px">Rank ${rank(o)} of six</td></tr>
             <tr><td>Call on the budget in year 4</td><td class="num">R${fmt(o.annualY4)}k</td><td style="font-size:11px">${pctT(o.callOnSurplus)} of the current operating surplus</td></tr>
             <tr><td>Peak annual call</td><td class="num">R${fmt(o.annualPeak)}k</td><td style="font-size:11px">The year the budget has to absorb the most</td></tr>
             <tr><td>Debt added to the balance sheet</td><td class="num">${o.debtAdded?'R'+fmt(o.debtAdded)+'k':'None'}</td>
               <td style="font-size:11px">${o.k==='ppp'?'No loan is raised, but GRAP 32 brings the asset and a matching liability on anyway — the ceiling is engaged.':o.k==='bot'?'No debt, but a contingent liability of R'+fmt(o.mrgExposure)+'k under the guarantee.':''}</td></tr>
             <tr><td>Borrowing to operating revenue after</td><td class="num" style="color:${o.debtRatio>0.45?'var(--red)':'inherit'}">${pctT(o.debtRatio)}</td>
               <td style="font-size:11px">Circular 71 ceiling 45%</td></tr>
             <tr class="total"><td>Free cash remaining</td><td class="num" style="color:${o.cashAfter<base.expenditure/12?'var(--red)':'inherit'}">R${money(o.cashAfter)}k</td>
               <td style="font-size:11px">${N(o.cashAfter/(base.expenditure/12),1)} months of operating cost, against a one-month minimum</td></tr>
           </tbody></table></div>
         <div class="bd" style="border-top:1px solid var(--line);">
           <button class="btn-${paSel===o.k?'ghost':'primary'}" data-sel="${o.k}">${paSel===o.k?'Selected as the base structure':'Select this structure'}</button>
           <span style="font-size:10.5px;color:var(--muted);margin-left:9px">Selecting a structure re-runs steps five, seven and eleven against it.</span></div>
       </div></div>`).join('')}</div>
   ${card('Risk allocation across the six','Who carries what',
     `<div class="bd" style="padding:0"><div class="pa-risk">
       <div class="hdr">Risk</div><div class="hdr m">Conventional (own, loan, grant)</div><div class="hdr m">Concession — availability</div><div class="hdr m">Build, operate and transfer</div>
       ${[['Construction cost overrun','pub','priv','priv'],['Construction delay','pub','priv','priv'],
          ['Demand and patronage','pub','pub','priv'],['Operating cost escalation','pub','priv','priv'],
          ['Lifecycle and fleet renewal','pub','priv','priv'],['Fare policy','pub','pub','shr'],
          ['Land assembly and expropriation','pub','pub','pub'],['Change in law','pub','shr','shr'],
          ['Interest rate','pub','priv','priv'],['Residual value at hand-back','pub','pub','pub'],
          ['Political and social acceptance','pub','pub','pub']]
         .map(r=>`<div>${r[0]}</div>${r.slice(1).map(x=>`<div class="m ${x}">${x==='pub'?'Municipality':x==='priv'?'Private':'Shared'}</div>`).join('')}`).join('')}
     </div></div>`,
     `<p class="footnote" style="margin:0">Four risks never transfer whatever the structure says: land assembly, residual value, change in law beyond a
      defined scope, and political acceptance. Any structure priced as though they have transferred is mispriced, and the municipality will meet the
      difference in a claim rather than in the tariff.</p>`)}`;
  document.querySelectorAll('[data-fo]').forEach(el=>el.addEventListener('click',()=>{
    paOpen = paOpen===el.dataset.fo ? null : el.dataset.fo; renderFund();
    const t=document.getElementById('opt-'+el.dataset.fo); if(t&&paOpen) t.scrollIntoView({block:'center',behavior:'smooth'});}));
  document.querySelectorAll('[data-sel]').forEach(b=>b.addEventListener('click',e=>{
    e.stopPropagation(); paSel=b.dataset.sel; renderPA();
    showToast(F.opts.find(o=>o.k===paSel).n+' selected — steps five, seven and eleven re-run against it');}));
}

function renderFit(){
  const F=paFunding(), A0=F.A0, o=F.opts.find(x=>x.k===paSel), base=totals();
  const svc = o.annualSvc(PA.opStart), call=o.annualY4;
  /* projected surplus over the funding term, on the risk module's growth assumptions */
  const revG=0.055, costG=0.062;
  const proj=[]; let rev=base.revenue, exp=base.expenditure;
  for(let y=1;y<=A('loanTenor');y++){ rev*=(1+revG); exp*=(1+costG);
    const sv = o.annualSvc(y+PA.buildYears) + o.opShare(Math.min(y+PA.buildYears,PA.years));
    proj.push({y, rev, exp, surplus:rev-exp, sv, after:rev-exp-sv, cover:sv? (rev-exp)/sv : 99}); }
  const firstFail = proj.find(p=>p.after<0);
  document.getElementById('paFit').innerHTML =
  `<div class="ax-note"><b>Step eleven is the only step that can stop the project.</b> Everything before it describes the project; this asks whether this
    municipality, with this balance sheet, under this law, can carry it. The structure under test is <b>${o.n}</b> — change it on step ten and this page changes.</div>
   <div class="kpis">${kpiSet([
     ['Annual call in the opening year','R'+fmt(call)+'k', call/base.surplus>0.25?'bad':call/base.surplus>0.12?'warn':'good',
      pctT(call/base.surplus)+' of the current operating surplus of R'+fmt(base.surplus)+'k'],
     ['Borrowing to operating revenue', pctT(o.debtRatio), o.debtRatio>0.45?'bad':'good',
      `Circular 71 ceiling 45% · ${o.debtRatio>0.45?'breached':'headroom of '+pctT(0.45-o.debtRatio)}`],
     ['Free cash after the capital contribution','R'+money(o.cashAfter)+'k', o.cashAfter<base.expenditure/12?'bad':'good',
      N(o.cashAfter/(base.expenditure/12),1)+' months of operating cost against a one-month minimum'],
     ['Surplus exhausted in', firstFail?'Year '+firstFail.y:'Not within the term', firstFail?'bad':'good',
      firstFail?'The projected surplus no longer covers the annual call':'The projected surplus covers the call throughout the funding term'],
   ])}</div>
   ${card('Debt repayment sustainability against the projected surplus','Revenue growing at '+pctT(revG,1)+' and cost at '+pctT(costG,1)+', from the risk module',tbl(
     '<th>Year</th><th class="num">Projected revenue</th><th class="num">Projected expenditure</th><th class="num">Operating surplus</th><th class="num">Call from this project</th><th class="num">Surplus after</th><th class="num">Cover</th>',
     proj.filter((p,i)=>i%2===0||i===proj.length-1).map(p=>
       `<tr><td><b>Year ${p.y}</b></td><td class="num">${fmt(p.rev)}</td><td class="num">${fmt(p.exp)}</td>
        <td class="num" style="color:${p.surplus<0?'var(--red)':'inherit'}">${money(p.surplus)}</td>
        <td class="num">${fmt(p.sv)}</td>
        <td class="num" style="color:${p.after<0?'var(--red)':'inherit'};font-weight:700">${money(p.after)}</td>
        <td class="num" style="color:${p.cover<1.5?'var(--red)':'inherit'}">${p.cover>50?'—':N(p.cover,2)+'x'}</td></tr>`).join(''),
     ),
     `<p class="footnote" style="margin:0">${firstFail
       ? `The projected surplus stops covering the annual call in <b>year ${firstFail.y}</b> of the funding term. That is the single most important sentence in this appraisal: the project is committed for ${A('loanTenor')} years and the surplus that funds it runs out before then. Either the growth assumption is wrong, or the cost base has to be brought down, or the project has to be smaller.`
       : `The projected surplus covers the annual call in every year of the ${A('loanTenor')}-year term, with cover falling from ${N(proj[0].cover,2)}x to ${N(proj[proj.length-1].cover,2)}x as cost growth outruns revenue growth. The margin narrows throughout — it is adequate, not comfortable.`}
      The growth rates are the assumptions on the structural risk tab of the risk management module, not this one, so the two views cannot disagree.</p>`)}
   ${card('Statutory and regulatory gates','Every one is a condition precedent, not a preference',
     `<div class="bd">${[
       ['MFMA section 19 — capital projects','pass','Council may approve a capital project only after considering the projected cost over all the financial years until it is operational, the future operational costs and revenue, including municipal tax and tariff implications, and after satisfying itself that the sources of funding are available and have not been committed for other purposes. The twenty-year operating cost in step four is exactly what this section requires and is the part most often left out.'],
       ['MFMA section 17(3) — funded budget','warn','The budget must be funded from realistically anticipated revenue. An operating deficit of R'+fmt(A0.deficitY4)+'k a year has to appear in the medium-term revenue and expenditure framework from year '+PA.opStart+', not be discovered in it.'],
       ['MFMA section 46 — long-term debt', o.debtRatio>0.45?'fail':'pass','Long-term debt may be incurred only for capital expenditure on property, plant and equipment, following public comment and a council resolution. '+(o.debtRatio>0.45?'This structure takes borrowing to '+pctT(o.debtRatio)+' of operating revenue, above the 45% guidance in Circular 71. A lender may still lend; the provincial treasury will still object.':'Borrowing stays at '+pctT(o.debtRatio)+', inside the guidance.')],
       ['MFMA section 33 — contracts with future budgetary implications', ['ppp','bot'].includes(paSel)?'warn':'pass','A contract imposing financial obligations beyond the three years of the medium-term framework requires public comment for at least sixty days, the views of National Treasury and the provincial treasury, and a council resolution. '+(['ppp','bot'].includes(paSel)?'A twenty-year concession engages this squarely and the sixty-day period must be built into the programme.':'Only the construction contract engages this, and only if it spans more than three years.')],
       ['MFMA section 120 and the Municipal PPP Regulations', ['ppp','bot'].includes(paSel)?'warn':'pass',
        ['ppp','bot'].includes(paSel)
        ? 'A feasibility study is required before procurement. The accounting officer must notify National Treasury and the provincial treasury of the intention before starting it, and may be required to appoint a transaction advisor. The treasuries\u2019 views must be solicited on the bid documentation at least thirty days before bids are invited, and on the evaluation and the preferred bidder at least thirty days before award. An award may only be made after that process is complete and is subject to section 33.'
        : 'Not engaged. No private party takes over a municipal function or acquires use of municipal property for its own commercial purpose.'],
       ['Municipal Systems Act section 78', ['ppp','bot'].includes(paSel)?'warn':'pass','Before deciding on an external mechanism the municipality must assess the internal option and, if it then explores external provision, follow the section 78(3) process. A PPP that skips this is procedurally void, and it cannot be cured retrospectively.'],
       ['GRAP 32 — service concession arrangements', paSel==='ppp'?'warn':'pass', paSel==='ppp'
        ? 'The grantor recognises the service concession asset and a corresponding liability where it controls or regulates the services, to whom, at what price, and controls the residual interest. All three hold here. The concession therefore appears on the municipality\u2019s statement of financial position at R'+fmt(A0.cap)+'k with a matching liability — it is not off balance sheet, whatever the transaction is called.'
        : 'Not engaged by this structure.'],
       ['GRAP 19 — provisions and contingent liabilities', paSel==='bot'?'warn':'pass', paSel==='bot'
        ? 'The minimum revenue guarantee is a contingent liability of about R'+fmt(F.opts.find(x=>x.k==='bot').mrgExposure)+'k over the concession, disclosed at every reporting date and measured whenever payment becomes probable.'
        : 'Not engaged by this structure.'],
       ['Division of Revenue Act section 22','warn','Conditional grant funds not spent on the approved purpose revert to the National Revenue Fund unless a rollover is approved. A three-year construction programme with a grant claimed annually has three separate opportunities to lose the money.'],
       ['National Land Transport Act','pass','The service must be provided under an operating licence and in accordance with the integrated transport plan. Existing operators on the corridor have rights that must be dealt with before, not after, procurement.'],
     ].map(g=>`<div class="pa-gate ${g[1]}"><span class="law">${g[0]}</span>
        <b style="display:block;margin-top:2px">${g[1]==='pass'?'Satisfied or not engaged':g[1]==='warn'?'Action required':'Not satisfied'}</b><p>${g[2]}</p></div>`).join('')}</div>`)}
   ${card('Links to the rest of MATOS','Where this project shows up elsewhere, and what it does there',tbl(
     '<th style="width:22%">Module</th><th>What it reads</th><th class="num">Effect</th><th>Consequence</th>',
     [['Treasury Control Tower','Debt service added to the maturity ladder and the coverage tests','R'+fmt(svc)+'k a year',
       'Debt service to operating revenue moves to '+pctT((L('5140')+L('2110')+svc)/F.opRev)+' against an 8% norm'],
      ['Credit rating model','Borrowing to operating revenue, and the operating balance after the deficit','Debt ratio '+pctT(o.debtRatio),
       o.debtRatio>0.60?'Takes the debt burden factor from 3 to 4 and weakens the stand-alone profile':'Stays inside the current debt burden band'],
      ['Risk management — scenario lab','The annual call becomes a permanent charge against the surplus in every scenario','R'+fmt(call)+'k',
       'The perfect storm scenario, which already takes '+pctT(0.25)+' off the surplus, would no longer be absorbable'],
      ['Risk management — structural analysis','Twenty years of operating deficit added to the long-run cost base','R'+fmt(A0.netPresentCost)+'k in present value',
       'Raises the required tariff path and brings forward the year the structural balance fails'],
      ['Asset register and AMP','The completed asset, componentised on the lives in step two','R'+fmt(A0.cap)+'k',
       'Adds R'+fmt(PA.capex.reduce((s,c)=>s+(c[2]?c[1]/c[2]:0),0))+'k a year of depreciation and a fleet renewal obligation in years 12 and 24'],
      ['Audit findings','Capital work in progress, grant conditions and procurement process','3 control tests',
       'CTL-11 capitalisation, the conditional grant spending test, and the section 33 and 120 process tests'],
      ['Integrated workbook','A new sheet, 24_Project_Appraisal, feeding the capital budget and the long-term financial plan','—',
       'The medium-term framework must carry the operating deficit from year '+PA.opStart+' onward'],
     ].map(r=>`<tr><td><b>${r[0]}</b></td><td style="font-size:11px;color:var(--muted)">${r[1]}</td>
       <td class="num">${r[2]}</td><td style="font-size:11px">${r[3]}</td></tr>`).join('')))}
   ${card('Policy and audit context','Read from the municipality\u2019s own instruments',tbl(
     '<th style="width:26%">Instrument</th><th>Requirement</th><th>Position</th>',
     [['Borrowing policy','Total borrowing not to exceed 45% of operating revenue and debt service not to exceed 8%',
       o.debtRatio>0.45?'<span class="pill RED">Breached by this structure</span>':'<span class="pill GREEN">Within policy</span>'],
      ['Cash and investment policy','A minimum of one month of operating expenditure held in unrestricted cash at all times',
       o.cashAfter<base.expenditure/12?'<span class="pill RED">Breached — the capital contribution takes cash below the floor</span>':'<span class="pill GREEN">Within policy</span>'],
      ['Supply chain management policy','A PPP or long-term contract must follow the competitive process and the treasury approval sequence',
       ['ppp','bot'].includes(paSel)?'<span class="pill AMBER">Engaged — the full sequence applies</span>':'<span class="pill GREEN">Ordinary competitive bidding</span>'],
      ['Tariff policy','Fare increases must follow the approved policy and the public participation process',
       '<span class="pill AMBER">The fare escalation of '+pctT(A('fareEsc'),1)+' assumed in step three must be capable of approval every year for twenty years</span>'],
      ['Open audit finding AG-25-04','Capital work in progress not transferred to property, plant and equipment on completion',
       '<span class="pill AMBER">Directly relevant — a three-year construction programme with an unresolved WIP finding is a qualification risk</span>'],
      ['Open audit finding AG-25-11','Conditional grant expenditure not incurred in accordance with the grant framework',
       '<span class="pill RED">Directly relevant — this project is '+pctT(A('ptngCapShare'),0)+' grant funded and the finding is unresolved</span>'],
     ].map(r=>`<tr><td><b>${r[0]}</b></td><td style="font-size:11px;color:var(--muted)">${r[1]}</td><td>${r[2]}</td></tr>`).join('')),
     `<p class="footnote" style="margin:0">Two open audit findings bear directly on this project. Approving a grant-funded capital programme of this size
      while the grant compliance finding is unresolved is not a technical problem — it is the specific circumstance in which a conditional grant is stopped.
      The findings should be cleared before the first claim, not alongside it.</p>`)}`;
}
function renderVerdict(){
  const F=paFunding(), A0=F.A0, o=F.opts.find(x=>x.k===paSel), base=totals();
  const best=F.opts.slice().sort((a,b)=>a.npc-b.npc);
  const AFFORD = 0.35;
  const tests = x => ({ceiling: x.debtRatio<=0.45, cash: x.cashAfter>=base.expenditure/12,
                       afford: x.annualY4 <= base.surplus*AFFORD});
  const permissible = F.opts.filter(x=>{const t=tests(x); return t.ceiling&&t.cash&&t.afford;});
  const rec = permissible.sort((a,b)=>a.npc-b.npc)[0];
  let scale=null, scaleOpt=null;
  if(!rec){
    for(const c of F.opts.slice().sort((a,b)=>a.npc-b.npc)){
      for(let sc=0.95; sc>=0.20; sc-=0.05){
        if((F.existingDebt + c.debtAdded*sc)/F.opRev <= 0.45
        && (F.freeCash - c.peakCash*sc) >= base.expenditure/12
        && c.annualY4*sc <= base.surplus*AFFORD){ scale=sc; scaleOpt=c; break; }
      }
      if(scale) break;
    }
  }
  document.getElementById('paVerdict').innerHTML =
  `<div class="ax-note ${A0.econNPV>0?'ok':'bad'}"><b>The appraisal conclusion.</b> The project does not pay for itself financially and
    ${A0.econNPV>0?'is worth doing anyway on economic grounds':'does not carry an economic case either on these benefit assumptions'}.
    The question the council has to answer is not whether to build it but whether it can carry the subsidy for twenty years —
    and on the three tests below, ${permissible.length?'it can, on '+permissible.length+' of the six structures.':'it cannot at full scope.'}</div>
   ${card('The four findings',null,tbl(
     '<th style="width:26%">Finding</th><th>Evidence</th><th>Consequence</th>',
     [['The financial case is negative and always will be',
       `Financial net present value of R${money(A0.projNPV)}k at ${pctT(A('discNom'),1)}, with farebox recovering ${pctT(A0.recoveryY4)} of operating cost at opening and never reaching one. No fare or patronage combination in the switching analysis closes the gap.`,
       'The decision is a subsidy decision. Presenting it as an investment with a return would be wrong and would not survive scrutiny.'],
      ['The economic case is positive and carries the argument',
       `Economic net present value of R${money(A0.econNPV)}k at the social discount rate of ${pctT(A('discEcon'),1)}, an economic internal rate of return of ${A0.econIRR?pctT(A0.econIRR):'—'}, driven ${pctT(268000/A0.econ0)} by travel time savings.`,
       'The project is worth doing for the city. The benefit accrues to travellers, not to the municipality, which is why it never appears in the financial statements.'],
      ['The annual call is the binding constraint, not the capital cost',
       `The capital contribution is funded once. The operating deficit of R${fmt(A0.deficitY4)}k arrives every year from year ${PA.opStart}, of which the municipality carries R${fmt(A0.muniCallY4)}k — ${pctT(A0.muniCallY4/base.surplus)} of the current operating surplus, rising with cost escalation.`,
       'Affordability has to be tested against the projected surplus over the full term, which step eleven does. It is where the project either survives or does not.'],
      ['Two funding structures are impermissible as they stand',
       `Own funds takes free cash to R${money(F.opts.find(x=>x.k==='own').cashAfter)}k, below the one-month policy floor. A full development bank loan takes borrowing to ${pctT(F.opts.find(x=>x.k==='loan').debtRatio)} against a 45% ceiling.`,
       'The project cannot be funded conventionally at full scale. Either the grant share rises, or it is phased, or a concession structure is used.'],
     ].map(r=>`<tr><td><b>${r[0]}</b></td><td style="font-size:11.5px;line-height:1.6">${r[1]}</td><td style="font-size:11.5px;line-height:1.6">${r[2]}</td></tr>`).join('')))}
   ${card('The three affordability tests','A structure has to pass all three, not the average of them',tbl(
     '<th style="width:24%">Structure</th><th class="num">Debt to revenue</th><th class="num">Free cash left</th><th class="num">Year 4 call</th><th>Ceiling ≤ 45%</th><th>Cash ≥ 1 month</th><th>Call ≤ '+pctT(AFFORD,0)+'</th>',
     F.opts.map(x=>{const t=tests(x);
       return `<tr><td><b>${x.n}</b>${x.grapNote?'<span class="ax-src">Liability recognised under GRAP 32 although no loan is raised</span>':''}</td>
         <td class="num">${pctT(x.debtRatio)}</td><td class="num">${money(x.cashAfter)}</td>
         <td class="num">${pctT(x.annualY4/base.surplus)}</td>
         ${[t.ceiling,t.cash,t.afford].map(v=>`<td><span class="pill ${v?'GREEN':'RED'}">${v?'Passes':'Fails'}</span></td>`).join('')}</tr>`;}).join(''),
     ),
     `<p class="footnote" style="margin:0">${permissible.length
       ? permissible.length+' of six structures pass all three tests.'
       : '<b>No structure passes all three tests at full scope.</b> That is the finding, and it is not an artefact of the model: a capital cost of R'+fmt(A0.cap)+'k with an annual operating subsidy against operating revenue of R'+fmt(base.revenue)+'k and a surplus of R'+fmt(base.surplus)+'k is too large for this municipality to carry in one phase, however it is structured. The concession structures do not escape it — GRAP 32 brings the asset and a matching liability onto the statement of financial position, so the borrowing ceiling is engaged whether or not a loan is raised.'}</p>`)}
   ${card('Recommendation',null,
     `<div class="bd"><div class="ax-note ${rec?'ok':'warn'}" style="margin:0 0 12px"><b>${rec?'Recommended structure: '+rec.n+'.':'No structure is affordable at full scope — phase it.'}</b>
       ${rec?`Net present cost of R${fmt(rec.npc)}k, rank ${best.indexOf(rec)+1} of six on cost but the cheapest of those that pass all three tests. Borrowing stays at ${pctT(rec.debtRatio)}, free cash remains at ${N(rec.cashAfter/(base.expenditure/12),1)} months of operating cost and the annual call is ${pctT(rec.annualY4/base.surplus)} of the surplus.`
        :scale?`The cheapest structure that can be made to fit is <b>${scaleOpt.n.toLowerCase()}</b> at roughly <b>${pctT(scale,0)} of scope</b> — a first phase of about R${fmt(A0.cap*scale)}k. At that scale borrowing reaches ${pctT((F.existingDebt+scaleOpt.debtAdded*scale)/F.opRev)}, free cash holds at ${N((F.freeCash-scaleOpt.peakCash*scale)/(base.expenditure/12),1)} months and the annual call falls to ${pctT(scaleOpt.annualY4*scale/base.surplus)} of the surplus.`
        :'No structure fits at any scale on this balance sheet. The project cannot proceed without a materially higher grant share, or without a different entity carrying it — a district or provincial transport authority.'}</div>
      <table class="grid"><tbody>
        <tr><td style="width:28%"><b>Proceed to</b></td><td>A full feasibility study under the Municipal PPP Regulations if a concession is to be considered, or directly to procurement if the grant and limited borrowing route is taken. The two paths diverge here and cannot be run in parallel — a section 78 assessment that is started late cannot be completed retrospectively.</td></tr>
        <tr><td><b>Conditions precedent</b></td><td>The grant allocation letter in hand for all three years, not one. The environmental record of decision issued. The land assembly schedule confirmed. Existing operator compensation agreed in principle. The two open audit findings on capital work in progress and conditional grant compliance cleared.</td></tr>
        <tr><td><b>Phasing</b></td><td>${scale?`The arithmetic above puts a viable first phase at about ${pctT(scale,0)} of scope, or R${fmt(A0.cap*scale)}k. `:''}A shorter corridor is not simply a cheaper one. Capital per kilometre rises, because the depot and the control centre are indivisible, and patronage per kilometre falls, because the corridor no longer reaches the outer catchment. Both work against cost recovery, so phase 1 will look worse on every ratio in step eight than the full scheme does. That is an argument for planning the whole corridor and building it in pieces, not for building half a corridor.</td></tr>
        <tr><td><b>What would change the recommendation</b></td><td>A capital grant share above ${pctT(A('ptngCapShare')+0.15,0)}, a committed operating grant for the full concession rather than a three-year window, or an opening patronage forecast verified independently and materially above ${fmt(A('pax0')/1000)}k.</td></tr>
        <tr class="total"><td><b>What must not happen</b></td><td>The project must not be approved on the capital cost alone. Section 19 requires council to consider the future operational cost and the tariff implications before approving a capital project, and the operating deficit over twenty years is several times the capital contribution in present value terms. That is the whole point of this appraisal.</td></tr>
      </tbody></table></div>`)}
   ${card('Decision summary for council',null,tbl(
     '<th style="width:34%">Question</th><th>Answer</th>',
     [['What is being approved?', PA.name+' at a capital cost of R'+fmt(A0.cap)+'k over three years.'],
      ['What will it cost the municipality?', 'R'+fmt(A0.netPresentCost)+'k in present value over twenty years — a capital contribution of R'+fmt(A0.muniCapital)+'k and an annual operating subsidy starting at R'+fmt(A0.muniCallY4)+'k.'],
      ['Does it pay for itself?','No. It recovers '+pctT(A0.recoveryY4)+' of its operating cost from fares and never reaches full recovery on any tested assumption.'],
      ['Is it worth doing?','Yes on economic grounds — a net present value of R'+money(A0.econNPV)+'k in benefits to travellers, mostly time saved.'],
      ['Can we afford it?', (function(){ const revG=0.055,costG=0.062; let rev=base.revenue,exp=base.expenditure,fail=null;
        for(let y=1;y<=A('loanTenor');y++){ rev*=(1+revG); exp*=(1+costG);
          const sv=o.annualSvc(y+PA.buildYears)+o.opShare(Math.min(y+PA.buildYears,PA.years));
          if(rev-exp-sv<0 && !fail) fail=y; }
        return fail? 'Not for the full term on current growth assumptions. The projected operating surplus stops covering the annual call in year '+fail+' of '+A('loanTenor')+'.'
          : 'Yes on current growth assumptions, with cover narrowing throughout the term. The margin is adequate, not comfortable.';})()],
      ['What is the recommendation?', rec? 'Proceed to feasibility on the '+rec.n.toLowerCase()+' structure, subject to the conditions precedent above.' : 'Rescope before proceeding.'],
     ].map(r=>`<tr><td><b>${r[0]}</b></td><td style="font-size:12px;line-height:1.65">${r[1]}</td></tr>`).join('')))}`;
}

/* ================= ORCHESTRATION ================= */
const PA_STEPS=[['intake','Intake'],['define','Definition'],['capex','Capital cost'],['demand','Demand'],
 ['opex','Operating cost'],['stmts','Statements'],['appraise','Appraisal'],['debt','Debt cover'],
 ['ratios','Ratios'],['sens','Sensitivity'],['fund','Funding'],['fit','Municipal fit'],['verdict','Verdict']];
let paTab='intake';
function renderPA(){
  const A0=paAppraisal(), F=paFunding(), o=F.opts.find(x=>x.k===paSel), base=totals();
  document.getElementById('paSteps').innerHTML = PA_STEPS.map((s,i)=>
    `<div class="s ${s[0]===paTab?'now':PA_STEPS.findIndex(x=>x[0]===paTab)>i?'done':''}">
      <div class="n">STEP ${i}</div><b>${s[1]}</b></div>`).join('');
  document.getElementById('paKpis').innerHTML = kpiSet([
    ['Capital cost','R'+fmt(A0.cap)+'k','',`${PA.buildYears}-year programme · R${fmt(A0.cap/18.4)}k per route kilometre`],
    ['Municipal net present cost','R'+fmt(A0.netPresentCost)+'k','bad',
      `On the ${o.n.toLowerCase()} structure, at ${pctT(A('discNom'),1)}`],
    ['Annual call from year '+PA.opStart,'R'+fmt(o.annualY4)+'k', o.annualY4/base.surplus>0.25?'bad':'warn',
      pctT(o.annualY4/base.surplus)+' of the current operating surplus'],
    ['Economic net present value','R'+money(A0.econNPV)+'k', A0.econNPV>0?'good':'bad',
      `Financial net present value R${money(A0.projNPV)}k — the two answer different questions`],
  ]);
  renderIntake(); renderDefine(); renderCapex(); renderDemand(); renderOpex();
  renderStmts(); renderAppraise(); renderDebt(); renderRatios(); renderPaSens();
  renderFund(); renderFit(); renderVerdict();
}
function paInit(){
  document.getElementById('paTabs').addEventListener('click', e=>{const b=e.target.closest('button'); if(!b)return;
    paTab=b.dataset.pa;
    document.querySelectorAll('#paTabs button').forEach(x=>x.classList.toggle('active',x===b));
    document.querySelectorAll('#panel-pa > .ax-sub').forEach(x=>x.classList.toggle('on', x.id==='pa-'+paTab));
    renderPA(); window.scrollTo({top:0});});
  const drop=document.getElementById('paDrop'), inp=document.getElementById('paFileIn');
  document.getElementById('paBrowse').addEventListener('click',()=>inp.click());
  inp.addEventListener('change',e=>{ paIngest([...e.target.files]); inp.value=''; });
  ['dragenter','dragover'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('over');}));
  ['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('over');}));
  drop.addEventListener('drop',e=>{ if(e.dataTransfer&&e.dataTransfer.files.length) paIngest([...e.dataTransfer.files]); });
  document.getElementById('paDemo').addEventListener('click',()=>{
    PA_FILES.length=0;
    PA_DOCS.forEach(d=> PA_FILES.push({name:d[3].split(' / ')[0], size:14000+d[0]*2300, from:'worked pack', doc:d[0], data:new Uint8Array(0)}));
    renderPA(); showToast('Worked pack loaded — all 14 required documents present');});
}

/*<<<PACK-JS-ORCHESTRATION-START>>>*/
/* ========================================================================
   ORCHESTRATION — one render pass, one init, for all integrated modules.
   Replaces the three separate renderAll()/init() pairs the modules carried
   when they were separate artefacts.
   ======================================================================== */
const PACK_LABEL = {exec:'Executive cockpit',sysint:'System integration',workbook:'Integrated workbook',reports:'Reports & outputs',governance:'Governance & controls',lineage:'Integration & data lineage',
  treas:'Treasury Control Tower', journals:'Journals, ledgers & postings', asset:'Asset register & AMP',
  pa:'Project appraisal', projrisk:'Project risk', contracts:'Contract monitor',
  audit:'Audit findings', scen:'Risk management', credit:'Credit rating & scoring',
  howto:'How to use this system', kit:'Integration kit',
};
function renderPack(){
  /* audit outcome feeds the credit model's financial management factor, so it is
     set before the annexure renders. This is the wiring the separate artefacts
     could only describe. */
  try{ if(typeof mxOpinion==='function') axAuditOutcome = mxOpinion().op || axAuditOutcome; }catch(e){}
  renderRiskSuite();
  renderAnnexure();
  renderPA();
}
function packInit(){ mxInit(); axInit(); paInit(); }
PROMPTHandover
Fold the consolidated MATOS module pack into the host build.

Attached:
  1. MATOS_Integrated_Standalone.html   the host
  2. MATOS_Module_Pack.html             this file — integrated modules, one shim, one kit
  3. BRT_Project_Pack.zip               the worked project pack the appraisal intake reads

This pack SUPERSEDES three earlier artefacts. Do not integrate those as well:
  MATOS_RiskSuite_AddOn.html, MATOS_Annexure_Modules.html, MATOS_Project_Appraisal.html
They each carried their own host shim, their own renderAll() and overlapping content.
All of that is resolved here.

Remove from the host first (the removal list matters as much as the insertion list):
  credit worthiness inside Strategy, Scenario lab, the long-term sustainability model,
  6_ALM, Revenue & expenditure, the MFD-MM Master view, the Bankability Engine,
  the Capital Structure tab, and any fixed asset schedule in the workbook.

Then paste the CSS, the ten panels, the nav buttons and the engine block, add
renderPack() to renderAll() and packInit() to the boot sequence.

Then swap the three illustrative arrays for host data — AR_CLASSES, MX_CONTRACTS, PA —
and wire the seven cross-module links marked "to wire" on the integration tab.

Then run the seven verification tests on that tab. The one that matters most is the
last: no figure should be computed by two renderers.

Then build next: a capital programme module that takes several appraised projects and
sizes them together against one borrowing ceiling and one surplus. The binding
constraint is never a single project, and nothing in this pack yet handles that.