Project milestone batch update
Review requiredCompare planned and actual milestones across the capital programme; route schedule and cost exceptions.
One synchronized operating environment for accounting, treasury, projects, budgets, GRAP reporting, performance and accountable oversight.
Each role opens only the financial information, decisions and statutory actions within its mandate.
One deterministic municipal finance spine from source transaction to council decision. Every card reads the same controlled ledger and reporting cut-off.
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.
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.
Compare planned and actual milestones across the capital programme; route schedule and cost exceptions.
Validate CP-01/02/03 certificates and release qualifying conditional-grant revenue.
Settle certified non-retention balances older than 30 days after supplier and cash controls.
Confirm over-committed contracts and route the MFMA section 116(3) approval trail.
Recalculate estimate-at-completion from current project cost and schedule performance.
Confirm the authorised AJE schedule and refresh GRAP statements and controls.
Execute every deterministic integrity, evidence and compliance test.
Bank and allocate the controlled rates and service-charge receipt batch.
Apply the Council-authorised credit-control write-off schedule.
Post verified burst, blockage, roads and transformer work orders.
Apply the approved +100bp reset to the JIBAR-linked borrowing tranche.
Cost storm and flood restoration and match approved external funding.
SDBIP and service-delivery KPIs, sourced from the same figures the Integrated Workbook uses.
| Line | Adjusted budget | Actual | Variance |
|---|---|---|---|
| Property rates | 196 000 | 198 450 | 2 450 |
| Service charges - electricity | 370 000 | 374 850 | 4 850 |
| Service charges - water | 240 000 | 242 550 | 2 550 |
| Service charges - sanitation | 98 000 | 99 225 | 1 225 |
| Service charges - refuse | 65 000 | 66 150 | 1 150 |
| Rental of facilities and equipment | 9 000 | 8 820 | (180) |
| Interest earned - external investments | 13 000 | 10 000 | (3 000) |
| Fines, penalties and forfeits | 18 000 | 16 538 | (1 462) |
| Licences and permits | 6 000 | 5 513 | (487) |
| Equitable share (unconditional) | 300 000 | 320 000 | 20 000 |
| Conditional grants received | 145 000 | 130 000 | (15 000) |
| Other income | 18 000 | 22 050 | 4 050 |
| Employee related costs | 318 000 | 313 000 | (5 000) |
| Remuneration of councillors | 20 000 | 19 845 | (155) |
| Debt impairment | 30 000 | 41 000 | 11 000 |
| Depreciation and amortisation | 104 000 | 103 000 | (1 000) |
| Finance costs | 27 000 | 26 000 | (1 000) |
| Bulk purchases - water and electricity | 325 000 | 333 000 | 8 000 |
| Contracted services | 63 000 | 66 000 | 3 000 |
| Grants and subsidies paid | 11 000 | 11 025 | 25 |
| Repairs and maintenance | 95 000 | 78 000 | (17 000) |
| Other general expenses | 48 000 | 54 000 | 6 000 |
A faithful desktop accounting simulation of Pastel, Sage and CaseWare. Actions post balanced journals into MATOS and identify every downstream surface affected.
Chronological source-to-report sheets. Inputs are blue; formulas and derived outputs are locked to prevent parallel calculations.
Mode, cut-off, ownership and validation
| # | Control | Current selection | Owner | Validation |
|---|---|---|---|---|
| 1 | Operating mode | Mode 3 | CFO | Active |
| 2 | Reporting cut-off | FY2025 | CFO Finance | Applied |
| 3 | User role | CFO | System administrator | Authorised |
| 4 | Ledger records | 35 | Finance Manager | Loaded |
| 5 | Posted journals | 0 | Chief Accountant | Balanced |
Reconciled management, statutory, budget and investment outputs generated from the current cut-off.
Financial health, liquidity, delivery, audit and immediate decisions
Statements, mapped balances, note controls and reconciliation
Budget performance, revenue, expenditure and corrective actions
Cash ladder, debtor ageing, obligations and funding cover
Projects, contracts, grants, assets and delivery risk
Investment case, funding structure, affordability and risk gates
Automated checks do not replace accountable approval. External issue remains gated by preparer, reviewer and authorised sign-off.
| Control | Evidence | Owner | Status |
|---|---|---|---|
| Double-entry integrity | MX_JOURNAL debits = credits | CFO Finance | Pass |
| PPE control account | Asset register → GL → GRAP note | Asset Manager | Pass |
| Audit-to-credit linkage | Projected opinion penalty applied | Internal Audit | Pass |
| Investment upload | 14-document BRT checklist plus 12 assumptions | PMU | Pass |
| Role segregation | Posting buttons follow role matrix | System Administrator | Pass |
| External issue gate | Preparer → reviewer → authorised signatory | Accounting Officer | Approval required |
The addendum supersedes the former duplicate ALM, scenario, bankability, capital-structure and fixed-asset calculations. These live bridges show the surviving ownership.
| From | To | Value transferred | Owner / formula | Status |
|---|---|---|---|---|
| Audit findings | Credit rating | Projected audit outcome | mxOpinion().op | Live |
| Treasury tower | Credit rating | Free cash, debt service, ratio set | axBase() | Live |
| Risk scenario | Credit rating | Stressed ratios | axScenarioRating() | Live |
| Risk scenario | Long-run resilience | Month 36 closing position | axLongRun(true) | Live |
| Balanced posting | All downstream modules | Account movements and evidence | mxPost() | Live |
| Project risk | Asset register | Certified work to WIP | mxPortfolio().certTD → integratedWip | Live · integrated |
| Contract monitor | Treasury ladder | Amounts due in 0–3 months | mxContractTotals().due → axLadder()[0] | Live · integrated |
| Asset register | Risk management | Condition-derived renewal backlog | integratedAssetRenewal() → AX_LR.backlogOpen | Live · integrated |
| Asset register | Treasury tower | Carrying value, depreciation, maintenance | arTotals() → axBase() | Live · integrated |
| Project appraisal | Treasury tower | Selected annual debt service | paFunding()[paSel] → axLadder()[0] | Live · integrated |
| Risk management | Project appraisal | Revenue and cost growth | AX_LR.revGrowth / costGrowth → step 11 | Live · integrated |
| Project appraisal | Asset register | Capital components and useful lives | PA.capex → projected asset intake | Live · integrated |
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.
| Source | Billed | Collected | Rate | Control |
|---|---|---|---|---|
| Property ratesCouncil sets the rate in the rates policy under the Municipal Property Rates Act | 198 450 | 190 909 | 96.2% | Own |
| Service charges - electricityRetail tariff approved by council, bulk price set by NERSA | 374 850 | 366 603 | 97.8% | Own |
| Service charges - waterTariff approved by council, bulk price set by the water board | 242 550 | 235 516 | 97.1% | Own |
| Service charges - sanitationTariff approved by council | 99 225 | 96 050 | 96.8% | Own |
| Service charges - refuseTariff approved by council | 66 150 | 63 173 | 95.5% | Own |
| Rental of facilities and equipmentLease agreements | 8 820 | 8 203 | 93.0% | Own |
| Interest earned - external investmentsInterest on the investment portfolio | 10 000 | 10 000 | 100.0% | Own |
| Fines, penalties and forfeitsSet by by-law, realisation is poor | 16 538 | 6 781 | 41.0% | Own |
| Licences and permitsSet by by-law | 5 513 | 5 458 | 99.0% | Own |
| Equitable share (unconditional)Division of Revenue Act — formula driven, not negotiable | 320 000 | 320 000 | 100.0% | Transfer |
| Conditional grants receivedDivision of Revenue Act — conditional, reverts if unspent | 130 000 | 130 000 | 100.0% | Transfer |
| Other incomeSundry | 22 050 | 19 845 | 90.0% | Own |
| Total revenue | 1 494 146 | 1 452 537 | 97.2% | 69.9% own |
| Category | Actual | Share | Cash | Nature |
|---|---|---|---|---|
| Employee related costsContractual, set by the national bargaining council | 313 000 | 30.0% | 313 000 | Committed |
| Remuneration of councillorsDetermined by the Minister by notice | 19 845 | 1.9% | 19 845 | Committed |
| Debt impairmentAccounting estimate against receivables | 41 000 | 3.9% | — | Non-cash |
| Depreciation and amortisationConsumption of the asset base | 103 000 | 9.9% | — | Non-cash |
| Finance costsContractual debt service | 26 000 | 2.5% | 26 000 | Committed |
| Bulk purchases - water and electricityBulk supply agreements, take-or-pay in parts | 333 000 | 31.9% | 333 000 | Committed |
| Contracted servicesContracted services, cancellable at notice | 66 000 | 6.3% | 66 000 | Discretionary |
| Grants and subsidies paidTransfers made by council resolution | 11 025 | 1.1% | 11 025 | Discretionary |
| Repairs and maintenanceDeferrable in year, costly over time | 78 000 | 7.5% | 78 000 | Discretionary |
| Other general expensesGeneral expenses | 54 000 | 5.2% | 54 000 | Discretionary |
| Total expenditure | 1 044 870 | 100.0% | 900 870 | 66.2% committed |
| Line | R'000 | % of revenue | Note |
|---|---|---|---|
| Total revenue | 1 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: depreciation | 103 000 | 6.9% | Non-cash — the asset was consumed, no money left |
| Add back: debt impairment | 41 000 | 2.7% | Non-cash — revenue was billed and written down, no money arrived either way |
| Cash generated from operations | 593 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 discretion | 360 676 | 24.1% | What is genuinely free after everything contractual and capital |
| Item | FY2023 | FY2024 | FY2025 | Movement | Treasury view |
|---|---|---|---|---|---|
| Cash treasury can deploy | |||||
| Cash and cash equivalents | 273 000 | 720 000 | 1 194 011 | 474 011 | Deployable, subject to the restriction shown below |
| Subtotal | 273 000 | 720 000 | 1 194 011 | ||
| Amounts owed to the municipality | |||||
| Receivables from exchange transactions (trade debtors) | 65 000 | 71 000 | 79 000 | 8 000 | Collectable in principle; the impairment charge says how much is not |
| Receivables from non-exchange transactions (rates & taxes) | 140 000 | 158 000 | 179 000 | 21 000 | Collectable in principle; the impairment charge says how much is not |
| Subtotal | 205 000 | 229 000 | 258 000 | ||
| Assets that must be maintained | |||||
| Property, plant and equipment (net) | 2 450 000 | 2 491 000 | 2 548 000 | 57 000 | Generate the billing and consume the maintenance budget |
| Investment property | 85 000 | 87 000 | 89 000 | 2 000 | Generate the billing and consume the maintenance budget |
| Intangible assets | 6 000 | 6 500 | 7 000 | 500 | Generate the billing and consume the maintenance budget |
| Inventory | 9 000 | 9 500 | 10 000 | 500 | Generate the billing and consume the maintenance budget |
| Subtotal | 2 550 000 | 2 594 000 | 2 654 000 | ||
| Amounts owed by the municipality within a year | |||||
| Trade and other payables | 155 000 | 168 000 | 182 000 | 14 000 | First call on the cash box |
| Current portion of long-term borrowing | 38 000 | 41 000 | 44 000 | 3 000 | First call on the cash box |
| Unspent conditional grants | 22 000 | 19 000 | 24 000 | 5 000 | First call on the cash box |
| VAT payable | 14 000 | 15 000 | 16 000 | 1 000 | First call on the cash box |
| Subtotal | 229 000 | 243 000 | 266 000 | ||
| Long-dated obligations | |||||
| Long-term borrowing | 420 000 | 441 000 | 483 000 | 42 000 | Funded from future operating surpluses, not from today’s cash |
| Employee benefit obligations | 140 000 | 148 000 | 156 000 | 8 000 | Funded from future operating surpluses, not from today’s cash |
| Subtotal | 560 000 | 589 000 | 639 000 | ||
| Net assets | 2 239 000 | 2 711 000 | 3 201 011 | 490 011 | |
| Layer | R'000 | Restriction | Days of cover |
|---|---|---|---|
| Cash and equivalents | 1 194 011 | None — the gross balance on the statement of financial position | 484 |
| 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 cash | 1 142 011 | Genuinely available to treasury | 463 |
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.
| Stage | How it is measured | FY2023 | FY2024 | FY2025 | Norm | Result |
|---|---|---|---|---|---|---|
| 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 | 55 | 59 | 63 | ≤ 60 | Outside |
| Inventory daysSmall for a municipality — most consumption is bulk purchased and passed straight through. | Inventory over daily bulk purchases | 11 | 11 | 11 | ≤ 30 | Within |
| 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 | 60 | 62 | 64 | ≤ 30 | Outside |
| 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 | 6 | 8 | 10 | ≤ 45 | Within |
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.
| Ageing band | Balance | Share | Expected to be collected | Treatment |
|---|---|---|---|---|
| Current — 30 days | 105 780 | 41.0% | 97.0% | Collected in the normal cycle |
| 31 to 60 days | 43 860 | 17.0% | 88.0% | Reminder and final notice stage |
| 61 to 90 days | 28 380 | 11.0% | 71.0% | Disconnection notice under the credit control policy |
| 91 to 180 days | 33 540 | 13.0% | 42.0% | Handed to debt collection; partially impaired |
| Over 180 days | 46 440 | 18.0% | 9.0% | Substantially impaired; write-off requires council resolution under section 32 |
| Total receivables | 258 000 | 100.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%.
| Layer | R'000 | Restriction | Days of cover |
|---|
| Week | W1 | W2 | W3 | W4 | W5 | W6 | W7 | W8 | W9 | W10 | W11 | W12 | W13 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Receipts | 70 553 | 24 805 | 24 805 | 24 805 | 70 553 | 24 805 | 24 805 | 24 805 | 70 553 | 24 805 | 24 805 | 24 805 | 38 053 |
| Payments | 10 403 | 38 153 | 10 403 | 38 140 | 10 403 | 38 153 | 10 403 | 38 140 | 10 403 | 38 153 | 10 403 | 38 140 | 10 403 |
| Net movement | 60 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 balance | 1 230 161 | 1 216 813 | 1 231 215 | 1 217 880 | 1 278 030 | 1 264 682 | 1 279 084 | 1 265 749 | 1 325 899 | 1 312 551 | 1 326 953 | 1 313 618 | 1 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.
| Commitment | Amount | Timing | Deferrable | Consequence of deferral |
|---|---|---|---|---|
| Employee and councillor costs | 83 211 | 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 | 83 250 | Weeks 2, 6 and 10 | No | Supply interruption, interest at the supplier’s penalty rate, and the arrears that drive Circular 124 eligibility |
| Debt service instalment | 11 000 | Week 7 | No | Event of default; acceleration of the facility |
| Capital certificates | 40 650 | Throughout | Partly | Deferrable by agreement with the contractor; forfeits grant if it delays qualifying expenditure past year end |
| Contracted services and general | 30 000 | Throughout | Partly | Cancellable at notice; breaches the thirty-day rule if merely delayed rather than renegotiated |
| Cover measure | What it protects against | Value | Covenant | Headroom | Result |
|---|---|---|---|---|---|
| Debt service coverClick for the formula and the headroom | A fall in the operating balance leaving debt service unpayable | 8.26x | ≥ 1.50x |
451% headroom |
Compliant |
| Interest coverClick for the formula and the headroom | Interest alone outrunning the surplus | 22.24x | ≥ 3.00x |
641% headroom |
Compliant |
| Liquidity coverClick for the formula and the headroom | Cash running out before the next instalment falls due | 16.71x | ≥ 1.00x |
1571% headroom |
Compliant |
| Borrowing to operating revenueClick for the formula and the headroom | Leverage rising beyond the regulated ceiling | 38.6% | ≤ 45.0% |
14% headroom |
Compliant |
| Debt service to operating revenueClick for the formula and the headroom | Debt service crowding out service delivery | 5.1% | ≤ 8.0% |
36% headroom |
Compliant |
| Cost coverageClick for the formula and the headroom | Being unable to fund one month of operations from cash | 15.59x | ≥ 1.00x |
1459% headroom |
Compliant |
| Instrument | Balance | Basis | Rate | Repricing |
|---|---|---|---|---|
| DBSA amortising loan 2019 | 164 220 | Fixed | 9.15% | Fixed to maturity in 2034 |
| DBSA amortising loan 2023 | 125 580 | Fixed | 9.85% | Fixed to maturity in 2038 |
| Commercial bank term facility | 135 240 | Floating | 10.10% | Reprices quarterly against three-month JIBAR |
| Annuity loan — fleet and plant | 57 960 | Floating | 10.65% | Reprices monthly against prime |
| Current portion falling due | 44 000 | Mixed | 9.40% | Repaid within twelve months |
| Weighted average | 527 000 | 37% 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.
| Constraint | Limit | Used | Capacity remaining |
|---|---|---|---|
| Borrowing to operating revenue — Circular 71 ceiling of 45% | 613 866 | 527 000 | 86 866 |
| Debt service to operating revenue — norm band upper bound of 8% | 1 160 975 | 744 681 | 416 294 |
| Debt service cover of 1.5x on the existing facility | 385 517 | 70 000 | 315 517 |
| Binding constraint | Borrowing 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.
| Bucket | Inflows | Outflows | Net gap | Cumulative | Cover |
|---|---|---|---|---|---|
| 0-3 months | 812 527 | 431 485 | 381 041 | 381 041 |
1.88x cover |
| 3-12 months | 451 923 | 426 905 | 25 019 | 406 060 |
1.06x cover |
| 1-3 years | 152 281 | 121 860 | 30 421 | 436 481 |
1.25x cover |
| 3-5 years | 25 800 | 137 760 | (111 960) | 324 521 |
0.19x cover |
| 5-10 years | 19 480 | 211 020 | (191 540) | 132 981 |
0.09x cover |
| Over 10 years | 50 960 | 168 360 | (117 400) | 15 581 |
0.30x cover |
| Total | 1 512 971 | 1 497 390 | 15 581 |
| Measure | Base | After the levers | Movement | Effect |
|---|---|---|---|---|
| Debtor days | 63 d | 63 d | — | No change |
| Creditor days | 64 d | 64 d | — | No change |
| Cash conversion cycle | 10 d | 10 d | — | No change |
| Free cash | R1 170 011k | R1 170 011k | — | No change |
| Days cash on hand | 474 d | 474 d | — | No change |
| Operating surplus | R449 276k | R449 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.
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.
| Leak | R'000 | Controlled by | Lever that moves it | Module |
|---|---|---|---|---|
| Non-payment and indigent relief | 18 643 | Revenue & credit control | Collection rate | Treasury — revenue |
| Bulk purchases | 333 000 | Treasury — bulk contracts | Bulk cost above tariff increase | Scenario lab |
| Employee related cost | 332 845 | Corporate services | Wage settlement above budget | Scenario lab |
| Other operating expenditure | 131 025 | Departments | No lever — departmental budget decision | Treasury — expenditure |
| Finance cost | 26 000 | Treasury — debt | Interest rate movement | Treasury — maturity & liquidity |
| Repairs and maintenance | 78 000 | Infrastructure | Repairs and maintenance | Scenario lab |
| Retained, not reinvested | 29 112 | Council — capital budget | Capital programme delivery | Project risk |
| Ratio | Group | FY2023 | FY2024 | FY2025 | Norm | Result |
|---|---|---|---|---|---|---|
| Operating surplus marginClick for the three-year path and the cross-references | Performance | 30.9% | 30.5% | 30.1% | ≥ 0% | Within |
| Own revenue shareClick for the three-year path and the cross-references | Performance | 69.9% | 69.8% | 69.9% | ≥ 50% | Within |
| Collection rateClick for the three-year path and the cross-references | Performance | 98.1% | 98.1% | 98.1% | ≥ 95% | Within |
| Current ratioClick for the three-year path and the cross-references | Liquidity | 2.13x | 3.94x | 5.50x | 1.5 – 2.0 | Within |
| Days cash on handClick for the three-year path and the cross-references | Liquidity | 113 d | 299 d | 474 d | ≥ 30 days | Within |
| Cost coverageClick for the three-year path and the cross-references | Liquidity | 3.72x | 9.85x | 15.59x | 1 – 3 months | Within |
| Debt to operating revenueClick for the three-year path and the cross-references | Debt | 36.9% | 37.1% | 38.6% | ≤ 45% | Within |
| Debt service to operating revenueClick for the three-year path and the cross-references | Debt | 4.8% | 5.0% | 5.1% | 6 – 8% | Within |
| Interest coverClick for the three-year path and the cross-references | Debt | 24.41x | 23.23x | 22.24x | ≥ 2.0x | Within |
| Creditor payment daysClick for the three-year path and the cross-references | Working capital | 60 d | 62 d | 64 d | ≤ 30 days | Outside |
| Debtor daysClick for the three-year path and the cross-references | Working capital | 55 d | 59 d | 63 d | ≤ 60 days | Outside |
| Impairment to receivablesClick for the three-year path and the cross-references | Working capital | 17.1% | 16.6% | 15.9% | ≤ 16% | Within |
| Capital renewal to depreciationClick for the three-year path and the cross-references | Asset | 1.45x | 1.53x | 1.58x | ≥ 1.0x | Within |
| Repairs and maintenance to infrastructureClick for the three-year path and the cross-references | Asset | 2.9% | 3.0% | 3.1% | ≥ 8% | Outside |
| Capital spend to total spendClick for the three-year path and the cross-references | Asset | 12.8% | 13.2% | 13.5% | ≥ 10% | Within |
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.
| Asset class | Items | Gross cost | Accumulated depreciation | Carrying amount | Consumed | GRAP treatment |
|---|---|---|---|---|---|---|
| LandGRAP 17 — not depreciated; impairment still applies | 1 842 | 186 000 | — | 186 000 |
0.0% consumed |
Indefinite — not depreciated |
| Buildings and officesGRAP 17 — componentise structure, roof, services | 318 | 412 000 | (128 400) | 283 600 |
31.2% consumed |
30 to 50 years |
| Infrastructure — roads and stormwaterGRAP 17 — componentise subgrade, base, surfacing, kerbs | 2 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, electrical | 1 685 | 742 000 | (241 000) | 501 000 |
32.5% consumed |
Pipes 30 to 60 years |
| Infrastructure — sanitationGRAP 17 — componentise reticulation and treatment plant | 1 204 | 508 000 | (177 000) | 331 000 |
34.8% consumed |
30 to 50 years |
| Infrastructure — electricityGRAP 17 — componentise network, substations, metering | 1 516 | 624 000 | (228 000) | 396 000 |
36.5% consumed |
25 to 40 years |
| Community assetsGRAP 17 — halls, parks, cemeteries, libraries | 462 | 168 000 | (61 000) | 107 000 |
36.3% consumed |
20 to 40 years |
| Plant and equipmentGRAP 17 — units of production where use varies | 874 | 96 000 | (54 000) | 42 000 |
56.3% consumed |
8 to 15 years |
| Motor vehiclesGRAP 17 — residual value 10 to 20% of cost typical | 216 | 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 rapid | 3 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 only | 11 | 60 400 | — | 60 400 |
0.0% consumed |
Not depreciated until available for use |
| Register total | 13 536 | 3 894 400 | (1 346 400) | 2 548 000 | 34.6% consumed | The 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 600 | — | 11 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 |
| Asset class | Life remaining | Years left | Position |
|---|---|---|---|
| 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.
| # | Field | Purpose | Assertion | Complete | Population | Result |
|---|---|---|---|---|---|---|
| 1 | Unique asset ID / barcodeClick for the detail | Enables one-to-one physical linkage to the asset | Existence | 98.2% |
244 missing |
Gaps |
| 2 | Asset descriptionClick for the detail | Identifies the asset with enough specificity to be found and verified | Completeness / Existence | 91.8% |
1 110 missing |
Gaps |
| 3 | Asset classClick for the detail | Determines which standard applies and where it is disclosed | Classification | 99.6% |
54 missing |
Complete |
| 4 | Physical locationClick for the detail | Enables verification and supports the existence assertion | Existence | 87.4% |
1 706 missing |
Material gap |
| 5 | Asset custodianClick for the detail | Assigns accountability under MFMA section 63 | Rights and obligations | 79.1% |
2 829 missing |
Material gap |
| 6 | Date available for useClick for the detail | Determines when depreciation starts | Accuracy / Cut-off | 93.4% |
893 missing |
Gaps |
| 7 | Cost or historical costClick for the detail | Opening carrying amount and the basis for depreciation | Valuation / Accuracy | 98.8% |
162 missing |
Gaps |
| 8 | Accumulated depreciationClick for the detail | Supports the carrying amount and reconciles to the ledger | Valuation | 96.1% |
528 missing |
Gaps |
| 9 | Useful life — original and revisedClick for the detail | Basis for the annual charge; must be reviewed each year | Valuation / Accuracy | 84.7% |
2 071 missing |
Material gap |
| 10 | Residual valueClick for the detail | Reduces the depreciable amount; annual review required | Valuation | 61.2% |
5 252 missing |
Material gap |
| 11 | Depreciation methodClick for the detail | Documents the policy choice and lets an auditor recalculate | Consistency / Accuracy | 99.4% |
81 missing |
Complete |
| 12 | Carrying amountClick for the detail | Must agree to the ledger balance and the note | Valuation / Completeness | 95.8% |
569 missing |
Gaps |
| 13 | Condition ratingClick for the detail | Supports impairment assessment and maintenance planning | Valuation / Disclosure | 70.3% |
4 020 missing |
Material gap |
| 14 | Disposal statusClick for the detail | Identifies assets disposed, scrapped, stolen or written off | Completeness / Rights | 88.6% |
1 543 missing |
Material gap |
| 15 | Component informationClick for the detail | Required wherever an asset has been componentised | Completeness / Accuracy | 39.4% |
8 203 missing |
Material gap |
| 16 | Supporting document referenceClick for the detail | Links the asset to its invoice, contract or resolution | Rights and obligations | 82.2% |
2 409 missing |
Material gap |
| 17 | Insurance informationClick for the detail | Assets must be insured at replacement cost | Completeness | 75.8% |
3 276 missing |
Material gap |
| Asset class | Reg. gross | GL gross | Reg. accum. | GL accum. | CA difference | Match |
|---|---|---|---|---|---|---|
| Land | 186 000 | 186 000 | (0) | (0) | — | Agrees |
| Buildings and offices | 412 000 | 412 000 | (128 400) | (128 400) | — | Agrees |
| Infrastructure — roads and stormwater | 985 000 | 985 000 | (386 000) | (386 000) | — | Agrees |
| Infrastructure — water | 742 000 | 742 000 | (241 000) | (241 000) | — | Agrees |
| Infrastructure — sanitation | 508 000 | 508 000 | (177 000) | (177 000) | — | Agrees |
| Infrastructure — electricity | 624 000 | 624 000 | (228 000) | (228 000) | — | Agrees |
| Community assets | 168 000 | 168 000 | (61 000) | (61 000) | — | Agrees |
| Plant and equipment | 96 000 | 96 000 | (54 000) | (47 800) | 6 200 | Difference |
| Motor vehicles | 82 000 | 87 400 | (49 000) | (49 000) | 5 400 | Difference |
| Furniture, fittings and IT | 31 000 | 31 000 | (22 000) | (22 000) | — | Agrees |
| Capital work in progress | 60 400 | 60 400 | (0) | (0) | — | Agrees |
| Total | 3 894 400 | 3 899 800 | (1 346 400) | (1 340 200) | 0 | Clean |
| Test | What it catches | Exceptions | Value at risk | Result |
|---|---|---|---|---|
| Carrying amount is not negative | Accumulated depreciation exceeding cost, usually from a manual adjustment | — | — | Pass |
| Accumulated depreciation does not exceed depreciable amount | Depreciation continuing past the residual value | 34 | 1 240 | Fail |
| Every asset has a useful life greater than zero | Assets that will never depreciate because the field is blank | 148 | 8 600 | Fail — material |
| Date available for use is not in the future | Cut-off error at year end | 7 | 3 100 | Fail |
| No duplicate barcodes | The same asset counted twice | 21 | 2 400 | Fail |
| Disposed assets carry a council resolution reference | Removal without the authority MFMA section 14 requires | 12 | 5 400 | Fail — material |
| Insured value is at replacement, not cost | Under-insurance, and a premium that is fruitless if the asset does not exist | 2612 | — | Fail |
| Every component links to a parent asset | Orphan components that depreciate against nothing | — | — | Pass |
| Asset class | Carrying amount | Componentised | Coverage | Depreciation understated | Result |
|---|---|---|---|---|---|
| Buildings and officesClick for the rule and the effect | 283 600 | 96 424 |
34.0% componentised |
3 237 | Partial |
| Infrastructure — roads and stormwaterClick for the rule and the effect | 599 000 | 107 820 |
18.0% componentised |
23 602 | Not componentised |
| Infrastructure — waterClick for the rule and the effect | 501 000 | 260 520 |
52.0% componentised |
3 887 | Adequate |
| Infrastructure — sanitationClick for the rule and the effect | 331 000 | 155 570 |
47.0% componentised |
3 497 | Partial |
| Infrastructure — electricityClick for the rule and the effect | 396 000 | 162 360 |
41.0% componentised |
6 209 | Partial |
| Community assetsClick for the rule and the effect | 107 000 | 9 630 |
9.0% componentised |
2 784 | Not componentised |
| Plant and equipmentClick for the rule and the effect | 42 000 | 0 |
0.0% componentised |
3 429 | Not componentised |
| Motor vehiclesClick for the rule and the effect | 33 000 | 0 |
0.0% componentised |
5 532 | Not componentised |
| Component | Cost | % of total | Useful life | Residual | Annual depreciation |
|---|---|---|---|---|---|
| Civil structureConcrete reservoirs, inlet works and channels | 18 200 | 42.7% | 50 yrs | 500 | 354 |
| Mechanical plantPumps, blowers, screens and dosing equipment | 9 800 | 23.0% | 20 yrs | 300 | 475 |
| Electrical systemsSwitchgear, motor control centres, cabling | 6 400 | 15.0% | 25 yrs | 150 | 250 |
| Filtration membranesConsumable by design; replaced on a fixed cycle | 4 200 | 9.9% | 7 yrs | — | 600 |
| SCADA and controlInstrumentation, telemetry and control software | 2 800 | 6.6% | 10 yrs | 100 | 270 |
| Access roads and civilsSite access, hardstanding and fencing | 1 200 | 2.8% | 30 yrs | — | 40 |
| Total | 42 600 | 100.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.
| Asset class | Life applied | NT benchmark | Last reviewed | Annual charge | Position |
|---|---|---|---|---|---|
| Buildings and officesClick for the review position | 42 yrs | 30 to 50 years | 2019 | 9 810 | 6 years stale |
| Infrastructure — roads and stormwaterClick for the review position | 28 yrs | Surfacing 8 to 15 years; componentised 12 to 40 | 2018 | 35 179 | 7 years stale |
| Infrastructure — waterClick for the review position | 48 yrs | Pipes 30 to 60 years | 2023 | 15 458 | Reviewed |
| Infrastructure — sanitationClick for the review position | 44 yrs | 30 to 50 years | 2023 | 11 545 | Reviewed |
| Infrastructure — electricityClick for the review position | 36 yrs | 25 to 40 years | 2022 | 17 333 | 3 years stale |
| Community assetsClick for the review position | 34 yrs | 20 to 40 years | 2019 | 4 941 | 6 years stale |
| Plant and equipmentClick for the review position | 12 yrs | 8 to 15 years | 2024 | 8 000 | Reviewed |
| Motor vehiclesClick for the review position | 9 yrs | Light 5 to 10; heavy 10 to 15 | 2024 | 9 111 | Reviewed |
| Furniture, fittings and ITClick for the review position | 8 yrs | IT 3 to 5; furniture 8 to 15 | 2024 | 3 875 | Reviewed |
| Line | As reported | At benchmark lives | Movement |
|---|---|---|---|
| Depreciation charge | R115 253k | R132 778k | R17 526k |
| Operating surplus | R449 276k | R431 750k | R(17 526)k |
| Carrying amount at year end | R2 548 000k | R2 530 474k | R(17 526)k |
| Renewal ratio — capital spend over depreciation | 1.41x | 1.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.
| Project | Opening | Additions | Transferred to PPE | Closing | Age | Status |
|---|---|---|---|---|---|---|
| CP-01 Bulk water augmentation phase 2 | 11 600 | 24 500 | — | 36 100 | 2.0 yrs | On programme |
| CP-02 Electricity network strengthening | — | 16 400 | — | 16 400 | 1.0 yrs | On programme |
| CP-03 Wastewater works refurbishment | — | 14 200 | — | 14 200 | 1.0 yrs | On programme |
| CP-04 Roads rehabilitation wards 4 to 9 | — | 12 100 | — | 12 100 | 1.0 yrs | Behind programme |
| CP-05 Landfill cell 3 and weighbridge | — | 7 600 | — | 7 600 | 0.5 yrs | On programme |
| Clinic upgrade — Ward 11 | 8 900 | — | — | 8 900 | 3.5 yrs | Complete but not transferred |
| Sports facility — Ward 3 | 6 200 | — | — | 6 200 | 4.5 yrs | Stalled; contractor in liquidation |
| Traffic signalling upgrade | 4 100 | — | 4 100 | 0 | 1.5 yrs | Transferred to PPE in the year |
| Total | 30 800 | 74 800 | 4 100 | 101 500 |
| Cost | Treatment | Basis |
|---|---|---|
| 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 |
| Age band | Balance | Projects | Treatment required |
|---|---|---|---|
| Under 1 year | 7 600 | 1 | Normal construction period — no action |
| 1 to 2 years | 42 700 | 3 | Normal construction period — no action |
| 2 to 3 years | 36 100 | 1 | Confirm the project is still active and the cost is still recoverable |
| Over 3 years | 15 100 | 2 | 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.
| Class | Difference | Cause | Which side is correct | Correction |
|---|---|---|---|---|
| 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 vehicles | 5 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 equipment | 6 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 amount | 0 | Below materiality, but still corrected. | ||
| Correction | Account | Debit | Credit | Where it posts |
|---|---|---|---|---|
| Correction 1Remove disposed vehicles from the ledger; section 14 resolution obtained | Accumulated depreciation — motor vehicles | 5 400 | — | General ledger |
| Cost — motor vehicles | — | 5 400 | General ledger | |
| Correction 2Process fourth-quarter depreciation in the register | Accumulated depreciation — plant and equipment | — | 6 200 | Register only — no journal |
| Effect on carrying amount | Register unchanged; ledger reduced to agree | 5 400 | 11 600 | Difference cleared |
| PPE note | Land | Buildings | Infrastructure | Infrastructure | Infrastructure | Infrastructure | Community | Plant | Motor | Capital | Total |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Cost — opening | 186 000 | 412 000 | 985 000 | 742 000 | 508 000 | 624 000 | 168 000 | 96 000 | 82 000 | 60 400 | 3 863 400 |
| Additions | — | — | 26 400 | 56 900 | 32 700 | 39 000 | — | — | — | 60 400 | 215 400 |
| Disposals | — | — | — | — | — | — | — | — | (5 400) | — | (5 400) |
| Cost — closing | 186 000 | 412 000 | 1 011 400 | 798 900 | 540 700 | 663 000 | 168 000 | 96 000 | 76 600 | 120 800 | 4 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 end | 186 000 | 273 790 | 590 221 | 542 442 | 352 155 | 417 667 | 102 059 | 34 000 | 18 489 | 120 800 | 2 637 622 |
| Asset class | On register | Verified | Not found | Unrecorded | Coverage | Result |
|---|---|---|---|---|---|---|
| Buildings and offices | 318 | 299 | 1 | — |
94.0% verified |
Qualified |
| Infrastructure — roads and stormwater | 2 140 | 1 519 | 24 | 2 |
71.0% verified |
Insufficient |
| Infrastructure — water | 1 685 | 1 112 | 24 | 5 |
66.0% verified |
Insufficient |
| Infrastructure — sanitation | 1 204 | 831 | 14 | 3 |
69.0% verified |
Insufficient |
| Infrastructure — electricity | 1 516 | 1 122 | 14 | 4 |
74.0% verified |
Insufficient |
| Community assets | 462 | 407 | 10 | 6 |
88.0% verified |
Qualified |
| Plant and equipment | 874 | 795 | 30 | 18 |
91.0% verified |
Qualified |
| Motor vehicles | 216 | 210 | 4 | 2 |
97.0% verified |
Qualified |
| Furniture, fittings and IT | 3 268 | 2 712 | 203 | 64 |
83.0% verified |
Qualified |
| Total | 11 683 | 9 007 | 324 | 104 |
| Effect | R'000 | Assertion failed |
|---|---|---|
| Gross cost overstated by assets that do not exist | 41 421 | Existence |
| Accumulated depreciation overstated on the same assets | 14 320 | Accuracy |
| Depreciation charged in the year on assets that do not exist | 2 258 | Accuracy — expenditure overstated |
| Insurance premium paid on assets that do not exist | 114 | Fruitless and wasteful expenditure under the MFMA |
| Assets in use never recognised | 3 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.
| Asset tag | Asset / custodian | GPS | Last scan | Status | Action |
|---|---|---|---|---|---|
| MAT-WAT-000184 Water | Bulk water pump 3 M. Ndlovu · Water Treatment Works | -25.7218, 28.2134 | 15 Sep 2026 | Verified | |
| MAT-ELC-000077 Electricity | 11kV transformer T77 L. Mokoena · Substation East | -25.7061, 28.1980 | 14 Sep 2026 | Verified | |
| MAT-RDS-001442 Roads | Ward 4 stormwater culvert P. Khumalo · Mahlasedi Road R4 | -25.7392, 28.1716 | 12 Sep 2026 | Geofence review | |
| MAT-BLD-000023 Buildings | Civic Centre HVAC plant T. Jacobs · Civic Centre | -25.7275, 28.2081 | 10 Sep 2026 | Verified | |
| MAT-VEH-000116 Vehicles | Refuse compactor 116 S. Molefe · Fleet depot | -25.7503, 28.2249 | 08 Sep 2026 | Tag rescan |
| Asset class | Carrying amount | Replacement cost | Average condition | Renewal due in 10 years | Annual requirement | Funded |
|---|---|---|---|---|---|---|
| Buildings and officesClick for the condition profile and the timing | 283 600 | 667 440 | 2.8 | 256 708 | 25 671 | 114% |
| Infrastructure — roads and stormwaterClick for the condition profile and the timing | 599 000 | 1 753 300 | 3.4 | 1 078 954 | 107 895 | 50% |
| Infrastructure — waterClick for the condition profile and the timing | 501 000 | 1 268 820 | 2.9 | 536 808 | 53 681 | 91% |
| Infrastructure — sanitationClick for the condition profile and the timing | 331 000 | 858 520 | 3.1 | 429 260 | 42 926 | 72% |
| Infrastructure — electricityClick for the condition profile and the timing | 396 000 | 1 085 760 | 3.0 | 501 120 | 50 112 | 78% |
| Community assetsClick for the condition profile and the timing | 107 000 | 265 440 | 3.3 | 153 138 | 15 314 | 60% |
| Plant and equipmentClick for the condition profile and the timing | 42 000 | 138 240 | 3.2 | 74 437 | 7 444 | 83% |
| Motor vehiclesClick for the condition profile and the timing | 33 000 | 113 160 | 3.5 | 73 989 | 7 399 | 85% |
| Furniture, fittings and ITClick for the condition profile and the timing | 9 000 | 40 920 | 3.6 | 28 329 | 2 833 | 51% |
| Total | 2 548 000 | 6 438 000 | — | 3 132 744 | 313 274 | 72% |
| Class | Maintenance spend | % of carrying amount | Norm | Result |
|---|---|---|---|---|
| Buildings and offices | 5 956 | 2.10% | 3% | Below norm |
| Infrastructure — roads and stormwater | 8 386 | 1.40% | 2% | Below norm |
| Infrastructure — water | 9 018 | 1.80% | 2% | Below norm |
| Infrastructure — sanitation | 5 296 | 1.60% | 2% | Below norm |
| Infrastructure — electricity | 7 524 | 1.90% | 2% | Below norm |
| Community assets | 1 284 | 1.20% | 3% | Below norm |
| Plant and equipment | 1 890 | 4.50% | 3% | At norm |
| Motor vehicles | 2 244 | 6.80% | 3% | At norm |
| Furniture, fittings and IT | 468 | 5.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 actually is | 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. |
| Why replacement cost, not carrying amount | Carrying 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 options | 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. |
| Link to the rest of the system | 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. |
| Destination | What travels | Current movement | Trigger |
|---|---|---|---|
| General ledger — PPE control account | Gross cost and accumulated depreciation by class | R0k difference | Any addition, disposal, transfer from WIP, impairment or depreciation run |
| Statement of financial performance — depreciation | The annual charge computed asset by asset | R115 253k | 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 | R2 548 000k | 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 | 1.41x | A change in the depreciation charge moves the ratio without any change in spend |
| Risk module — structural analysis | Required renewal, backlog and condition profile | R313 274k a year | A condition assessment, or a change in replacement cost indexation |
| Credit model — asset factor | Maintenance 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 test | Certified capital work capitalised, and WIP transferred on completion | R15 100k at risk | WIP ageing beyond the construction period |
| Insurance schedule | Replacement value by asset | R6 438 000k | Annual renewal of the policy; ghost assets carry fruitless premium |
| Event | Debit | Credit | Also updates | Authority required |
|---|---|---|---|---|
| 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 |
| Module | Reads from the register | Writes to the register | Function |
|---|---|---|---|
| Project risk | Nothing | Certified capital work, which becomes an addition or a WIP movement | mxPortfolio().certTD → AR_WIP |
| Contract monitor | Nothing | Retention released on practical completion, which completes the asset cost | mxContractTotals().ret |
| Audit findings | Data quality scores, WIP ageing, verification result — these raise findings | Resolution of a finding updates the register | arTotals().diff → mxOpinion() |
| Treasury Control Tower | Carrying amount, depreciation, maintenance ratio | Nothing | arTotals() → axBase() |
| Risk module | Replacement cost, condition profile, renewal requirement, backlog | Nothing | arAmp() → AX_LR.backlogOpen |
| Credit model | Maintenance to infrastructure ratio, renewal ratio | Nothing | arTotals().annual → AX_BANDS.rmRatio |
| Integrated workbook | The full register as a sheet | Manual corrections flow back | 23_Asset_Register |
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.
0 of 14 required documents classified · 14 outstanding
MATOS will retain and analyse the 0 supplied item(s), but it will not release a conclusion built on missing evidence. Still required:
assumptions.csv is present its values replace the built-in ones and every step downstream recomputes.A single .zip, or individual files: PDF, XLSX, CSV, DOCX, images.
Everything is read locally in this browser.
| # | Required document | Why the appraisal needs it | Expected file | Status |
|---|---|---|---|---|
| 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 |
Not supplied |
| 2 | Financial model | The revenue, cost and funding assumptions. Read directly where supplied as CSV. | assumptions.csv / financial_model.xlsx |
Not supplied |
| 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 |
Not supplied |
| 4 | Demand and patronage study | Step 3. The single assumption that most often destroys a transport project. | demand_study.pdf / patronage.csv |
Not supplied |
| 5 | Operating cost model | Step 4. Vehicle-kilometre costing, station operations, fare collection. | opex_model.csv |
Not supplied |
| 6 | Independent technical review | Confirms the cost estimate and the programme are buildable. | technical_review.pdf |
Not supplied |
| 7 | Environmental impact assessment and record of decision | A condition precedent to any construction contract. | eia_rod.pdf |
Not supplied |
| 8 | Land and servitude schedule | Confirms the corridor can actually be assembled. | land_schedule.csv |
Not supplied |
| 9 | Grant allocation letter | Evidence the conditional grant is committed, not hoped for. | ptng_allocation.pdf |
Not supplied |
| 10 | Draft term sheet or funding offer | The rate, tenor and conditions used in step 10. | term_sheet.pdf |
Not supplied |
| 11 | Section 78 assessment | Required by the Municipal Systems Act before any external mechanism is chosen. | s78_assessment.pdf |
Not supplied |
| 12 | Council resolution or committee mandate | The authority to proceed to the next gate. | council_resolution.pdf |
Not supplied |
| 13 | Risk register | The project risk register, distinct from the municipal one. | risk_register.csv |
Not supplied |
| 14 | Operating 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.
| Input | Value | Unit | Source |
|---|---|---|---|
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 |
MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.
MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.
MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.
MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.
MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.
MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.
MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.
MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.
MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.
MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.
MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.
MATOS has not populated this decision step because its evidence gate is incomplete. This prevents worked-example values being mistaken for analysed results.
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.
| Project | Funding | Approved | Planned to date | Paid to date | Certified | Complete | Cost var. | Forecast outturn | Status |
|---|---|---|---|---|---|---|---|---|---|
| CP-01 Bulk water supply augmentation — phase 2Water · contract SCM/2023/41 · CPI-linked | RBIG grant 70% · own funds 30% | 142 000 | 82 000 | 88 040 | 84 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 000 | 47 000 | 39 200 | 37 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 000 | 30 000 | 32 640 | 32 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 000 | 41 000 | 26 320 | 24 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 000 | 8 000 | 7 480 | 7 400 | 21.8% certified |
(80) | 34 368+368 | On track 10 months late |
| Portfolio | 5 live | 408 000 | 208 000 | 193 680 | 186 300 | 45.7% | (7 380) | 424 162 | 4.0% |
| Measure | How it is derived | Value | Threshold | Result |
|---|---|---|---|---|
| Cash-flow (schedule) variance | Paid to date less the approved rollout to date | (14 320) | ±20 800 | Within tolerance |
| Cost variance | Certified value less amounts paid for it | (7 380) | ±3 874 | Breach |
| Funding variance | Grants and loans received less expenditure incurred | (18 080) | ±9 684 | Breach |
| Forecast outturn variance | Approved value ÷ cost-performance index, less approved value | 16 162 | ±20 400 | Within tolerance |
| Programme slip | Elapsed periods ÷ proportion certified, weighted by value | 6.8 months | 3.0 months | Breach |
| Cost-performance index | Certified value ÷ cost incurred. Below 1.00 means work is costing more than it is worth. | 0.9619 | ≥ 1.0000 | Eroding |
| Destination | Mechanism | Movement | |
|---|---|---|---|
| Scenario lab — project and infrastructure delay lever | Weighted slip across the live portfolio sets the lever | 6.8 months | |
| Long-term model — borrowing | Forecast overrun funded from the DBSA facility | +16 162 | |
| Statement of financial performance — finance costs | Overrun at 8.75% | +1 414 p.a. | |
| Statement of financial performance — depreciation | Overrun over a 30-year useful life | +539 p.a. | |
| Credit worthiness — capital financing | Borrowing ÷ total revenue, MFMA Circular 71 norm ≤45% | 35.3% → 36.4% | |
| MFD-MM Master — Rates-to-Resilience cascade | Delivery rate feeds the asset-renewal leg | 45.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-15 | Capitalisation completeness and drawdown-against-spend tests | 2 failing |
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.
| Contract | Supplier / linked project | Awarded | Committed | Certified | Paid | Retention | Due & unpaid | Days | Status |
|---|---|---|---|---|---|---|---|---|---|
| SCM/2023/41CPI-linked · CP-01 | Kwena–Meyiwa Pipelines JV | 128 000 | 128 000 | 84 800 | 78 600 | 4 240 | 1 960 | 22 | Compliant |
| SCM/2024/07Fixed price · CP-02 | Thembalihle Power EPC | 104 000 | 104 000 | 37 700 | 33 900 | 1 885 | 1 915 | 18 | Compliant |
| SCM/2024/12CPI-linked · CP-03 | Aqua-Mech Process Engineering | 58 000 | 58 000 | 32 300 | 30 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 100 | 21 700 | 1 205 | 1 195 | 74 | Variation |
| SCM/2025/03Fixed price · CP-05 | Sibanye Waste Infrastructure | 31 000 | 31 000 | 7 400 | 6 900 | 370 | 130 | 9 | Compliant |
| SCM/2022/88NERSA determination | Eskom Holdings SOC — bulk supply | Tariff | — | 649 000 | 649 000 | — | — | — | Compliant |
| SCM/2023/15CPI-linked | Ikhaya Protection Services | 26 400 | 26 400 | 17 600 | 16 200 | — | 1 400 | 41 | Late payment |
| SCM/2024/22Fixed price | Metrix Revenue Protection | 18 000 | 18 000 | 9 000 | 8 100 | — | 900 | 36 | Late payment |
| Ageing band | Balance R'000 | Contracts | Result |
|---|---|---|---|
| Current — 30 days or less | 4 290 | 4 | Compliant |
| 31 to 60 days | 2 300 | 2 | Breach |
| 61 to 90 days | 1 195 | 1 | Breach |
| More than 90 days | 0 | 0 | Nil |
| Total certified and unpaid, excluding retention | 7 785 | 7 | 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.
| Measure | How it is derived | Value | Threshold | Result |
|---|---|---|---|---|
| Commitment against award | Orders raised less the value formally awarded | 7 900 | Nil | Breach |
| Largest single variation | Forecast final spend on one contract against its award | 18.2% | 20% | Within tolerance |
| Certified against committed | Work certified as a share of what is committed | 50.2% | — | Information |
| Balance of commitment | Committed but not yet certified — the call on future budgets | 211 400 | — | Information |
| Payment beyond 30 days | Certified, non-retention balances older than 30 days | 3 495 | Nil | Breach |
| Destination | Mechanism | Movement | |
|---|---|---|---|
| Statement of financial position — trade and other payables | Certified less paid less retention | R7 785k of R182 000k | |
| Ratios — creditor payment days | Payables ÷ daily expenditure | 63.6 days | |
| Cash flow — liquidity | Releasing the overdue balance reduces cash | −3 495 | |
| Governance and controls — CTL-12 and CTL-13 | Over-commitment and ageing tests | 2 failing | |
| Audit findings — AG-25-02 and AG-25-03 | Raised automatically from the two failing tests | R11 395k | |
| Reports and outputs — s116(3) contract report | Variations above the delegation are reportable to council | 1 item(s) | |
| Bankability engine — uncommitted fiscal capacity | Committed but uncertified reduces headroom | −211 400 | |
| Project risk — contract to project link | Certified value is the progress measure used by the projects | 5 linked |
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.
| Test | Basis | Value R'000 | Result |
|---|---|---|---|
| Materiality | 1% of total expenditure of R1 044 870k | 10 449 | Benchmark |
| Unresolved material misstatement | Open and in-progress findings classified as misstatement | 29 217 | Above materiality |
| Unresolved compliance findings | Findings against the MFMA, the MSA or the VAT Act | 4 | Reportable |
| Unresolved internal control deficiencies | Raised by internal audit or by a failing automated test | 3 | Reportable |
| Projected outcome | Misstatement above materiality qualifies the opinion; anything else unresolved gives findings | 18 768 | Qualified opinion |
| Ref | Finding | Classification | R'000 | Linked control | Owner | Age | Status |
|---|---|---|---|---|---|---|---|
| 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 misstatement | 7 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 misstatement | 8 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 misstatement | 4 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 control | 2 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 control | 9 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 control | Not 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 s32 | Not 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 misstatement | 9 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 09 | Not quantified | CTL-20Test failing |
Supply Chain ManagerRaised 06 Feb 2025 | 2 yrs | OpenClick to expand |
| Finding | Years | Automated test that closes it | Test result |
|---|---|---|---|
| AG-25-01 Certified capital work in progress expensed to repairs and maintenance | 2 | Certified capital work is capitalised, not expensed | Failing |
| AG-25-03 Certified creditors settled beyond 30 days | 3 | Certified creditors settled within 30 days | Failing |
| AG-25-05 Debt impairment provision not supported by a debtors ageing analysis | 2 | Debt impairment agrees to the debtors ageing analysis | Failing |
| AG-24-08 Input VAT claimed on non-qualifying supplies | 2 | Input VAT claimed only on qualifying supplies | Passing |
| MPAC-25-10 Consequence management register incomplete for prior-year irregular expenditure | 2 | Consequence management register is complete | Failing |
| AG-25-13 Mandatory procurement evidence absent or expired on the tender files | 2 | Every mandatory procurement evidence item is on file and in date | Failing |
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.
| Finding | Correcting entry | R'000 | Effect on the opinion |
|---|---|---|---|
| AG-25-01 capital work expensed | Debit property, plant and equipment · credit repairs and maintenance | 7 200 | Removes it from unresolved misstatement |
| AG-25-04 grant recognised early | Debit conditional grant revenue · credit unspent conditional grants | 8 600 | Removes it from unresolved misstatement |
| Net effect on the surplus | Expenditure down, revenue down | (1 400) | Still qualified until posted |
| Destination | Mechanism | Movement | |
|---|---|---|---|
| Governance and controls — control health | Tests moving from failing to passing | 3 of 14 | |
| Statement of financial position — PPE | Capital work correctly capitalised | +7 200 | |
| Statement of financial performance — repairs and maintenance | Capital work removed from operating expenditure | −7 200 | |
| Statement of financial performance — conditional grants | Revenue reversed to the unspent liability | −8 600 | |
| Strategy and performance — ratios | Surplus margin and asset-renewal ratios recalculate | Recalculated | |
| Credit worthiness — governance score | The audit outcome is a scored rating input | Qualified opinion | |
| Reports and outputs — audit action plan | Closed findings leave the plan | 2 closed | |
| CFO dashboard — assurance indicator | Driven by the projected outcome | Red |
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.
| Line | Base | Stressed | Movement | Effect |
|---|---|---|---|---|
| Total revenue | 1 494 146 | 1 494 146 | — | No change |
| Cash revenue collected collection-rate lever | 962 582 | 962 582 | +0 | No change |
| of which lost to volume and transfer cuts | — | — | — | No change |
| Total expenditure | 1 044 870 | 1 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 276 | 449 276 | — | No change |
| Cash and equivalents | 1 194 011 | 1 194 011 | — | No change |
| Free cash after restricted grants | 1 170 011 | 1 170 011 | — | No change |
| Capital programme | 162 600 | 162 600 | — | No change |
No lever has been moved, so this is the approved budget. Move one on the left, or load a scenario from the library.
| Lever | Setting | Effect on surplus | Share of the total move |
|---|---|---|---|
| No lever has been moved. | |||
| Rating factor | Base score | Under the scenario | Driver of the change |
|---|---|---|---|
| Economy | 4.00 | 4.00 | Not affected by the levers currently moved |
| Financial management | 3.87 | 3.87 | Not affected by the levers currently moved |
| Budgetary flexibility | 2.50 | 2.50 | Not affected by the levers currently moved |
| Budgetary performance | 1.00 | 1.00 | Not affected by the levers currently moved |
| Liquidity | 1.00 | 1.00 | Not affected by the levers currently moved |
| Debt burden | 1.50 | 1.50 | Not affected by the levers currently moved |
| Contingent liabilities | 2.00 | 2.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.
| Lever set by this scenario | Setting | Position | Why this scenario moves it |
|---|---|---|---|
| The base case moves no lever. It is the approved budget executed as tabled. | |||
| Measure | Base | Under this risk | Movement | Severity of the effect |
|---|---|---|---|---|
| Operating surplus | R449 276k | R449 276k | — | Absorbed |
| Free cash | R1 170 011k | R1 170 011k | — | Absorbed |
| Days cash on hand | 474 d | 474 d | — | Absorbed |
| Months of cost covered | 15.59x | 15.59x | — | Absorbed |
| Debt to operating revenue | 38.6% | 38.6% | — | Absorbed |
| Debt service cover | 8.26x | 8.26x | — | Absorbed |
| Capital programme | R162 600k | R162 600k | — | Absorbed |
| Collection rate | 98.1% | 98.1% | — | Absorbed |
| Stand-alone credit profile | A | A | — | Absorbed |
Nothing is being stressed. Pick a risk on the left to see what it costs.
| Risk | What it represents | Surplus | Days cash | Debt / revenue | Profile | |
|---|---|---|---|---|---|---|
| Perfect storm | Tariff gap, collection deterioration, a rate rise and a disaster in the same financial year. | 337 021 | 388 | 39.7% | BBB | |
| Drought restriction | Level 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 defection | Rooftop solar and wheeling take a fifth of electricity volume, concentrated in the highest-paying customer segment. | 374 306 | 436 | 40.9% | BBB | |
| Disaster year | A 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 shock | The 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 consolidation | The equitable share is cut by eight per cent and conditional grant rollovers are not approved. | 399 676 | 454 | 40.1% | A | |
| Recovery plan | Collection improves two points, maintenance rises to the norm and the capital programme delivers in full. | 421 976 | 457 | 38.6% | A | |
| Collection collapse | Payment behaviour deteriorates by eight points and the impairment provision follows it up. | 428 636 | 442 | 38.6% | BBB | |
| Wage settlement | The bargaining council settles five points above the budgeted increase, backdated. | 432 634 | 459 | 38.6% | A | |
| Base case | The approved budget executed as tabled. | 449 276 | 474 | 38.6% | A |
| Year | Y1 | Y2 | Y3 | Y4 | Y5 | Y6 | Y7 | Y8 | Y9 | Y10 |
|---|---|---|---|---|---|---|---|---|---|---|
| Revenue | 1 576 324 | 1 663 022 | 1 754 488 | 1 850 985 | 1 952 789 | 2 060 192 | 2 173 503 | 2 293 046 | 2 419 163 | 2 552 217 |
| Expenditure | 1 109 652 | 1 178 450 | 1 251 514 | 1 329 108 | 1 411 513 | 1 499 027 | 1 591 966 | 1 690 668 | 1 795 490 | 1 906 810 |
| Operating balance | 466 672 | 484 571 | 502 974 | 521 877 | 541 276 | 561 166 | 581 537 | 602 377 | 623 674 | 645 407 |
| Capital spend | 236 404 | 245 506 | 254 958 | 264 774 | 274 967 | 285 554 | 296 547 | 307 964 | 319 821 | 332 134 |
| Depreciation | 103 194 | 108 589 | 114 134 | 119 837 | 125 707 | 131 752 | 137 981 | 144 403 | 151 028 | 157 864 |
| Debt | 535 498 | 545 267 | 556 279 | 568 514 | 581 954 | 596 588 | 612 407 | 629 407 | 647 587 | 666 952 |
| Cash | 1 535 291 | 1 891 933 | 2 264 215 | 2 652 411 | 3 056 793 | 3 477 621 | 3 915 145 | 4 369 601 | 4 841 207 | 5 330 159 |
| Renewal backlog | 2 902 600 | 2 832 455 | 2 759 610 | 2 683 961 | 2 605 399 | 2 523 812 | 2 439 084 | 2 351 094 | 2 259 717 | 2 164 821 |
| Days cash | 557 | 645 | 727 | 801 | 868 | 928 | 983 | 1031 | 1075 | 1112 |
| Analysis | The question it answers | Now | Year 10 | Threshold | Verdict |
|---|---|---|---|---|---|
| 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 |
| 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 |
| 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.41x | 0.73x | ≥ 1.00x | Not sustainable |
| 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 |
| 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 |
| Generational equityClick for the method and the ten-year working | Is this generation leaving more than it inherited, per resident? | R7 803 | R17 913 | ≥ R7 803 | Sustainable |
| What is driving it | Costs 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 side | A 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 side | Holding 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 position | Cumulative 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 reading | 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. |
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.
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.
| Step | What it does | Score / level | Movement |
|---|---|---|---|
| Institutional framework | The legal and fiscal environment the municipality operates in, assessed on five dimensions | 4.00 | 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 | 2.47 | Rounds to 2 |
| Indicative credit level | Read off the matrix at profile 2 against framework 4 | A | Before any override |
| Stand-alone credit profile | Indicative level after overrides | A | Above the sovereign |
| 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 |
| Issuer credit rating | After the sovereign ceiling test | BB- | Capped |
| Direction | Trigger | Current | Threshold | Distance |
|---|---|---|---|---|
| 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 opinion | Qualified opinion | Unqualified | — |
| Upgrade | Collection sustained above 97%Feeds financial management and, through cash, liquidity | 98.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 service | 474 d | 30 d | 444 d |
| Downgrade | Direct debt above 60% of operating revenueMoves the debt burden factor from 3 to 4 | 38.6% | 60.0% | 21.4% |
| Downgrade | Operating balance turns negativeBudgetary performance falls from 1 to 4 in a single step | 30.1% | 0.0% | 30.1% |
| Downgrade | A downgrade of the Republic of South AfricaThe ceiling moves with the sovereign whatever the municipality does | BB- | BB- | Binding now |
| Dimension | Assessment | Score |
|---|---|---|
| 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 |
| Institutional framework assessment | Simple average of the five dimensions | 4.00 |
| Factor | Weight | Key measures | Score | Band | Weighted |
|---|---|---|---|---|---|
| EconomyClick to open the sub-metrics | 20% | Gross regional product per head (R), Unemployment rate, Share of output in the three largest sectors | 4.00 | Adequate | 0.800 |
| Financial managementClick to open the sub-metrics | 20% | Collection rate on billed revenue, Debt impairment charge, Repairs and maintenance | 3.87 | Adequate | 0.773 |
| Budgetary flexibilityClick to open the sub-metrics | 10% | Own revenue, Capital spend | 2.50 | Very strong | 0.250 |
| Budgetary performanceClick to open the sub-metrics | 10% | Operating balance, Balance after capital account | 1.00 | Extremely strong | 0.100 |
| LiquidityClick to open the sub-metrics | 20% | Free cash, Free cash | 1.00 | Extremely strong | 0.200 |
| Debt burdenClick to open the sub-metrics | 10% | Direct debt, Interest | 1.50 | Extremely strong | 0.150 |
| Contingent liabilitiesClick to open the sub-metrics | 10% | Contingent liabilities | 2.00 | Very strong | 0.200 |
| Individual credit profile | 100% | 2.47 | Rounds to 2 | 2.473 |
| Profile ↓ / Framework → | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| 1 | AAA | AA+ | AA | AA- | A+ | A |
| 2 | AA | AA- | A+ | A | A- | BBB |
| 3 | A+ | A | A- | BBB | BB+ | BB |
| 4 | A- | BBB | BBB- | BB+ | BB | BB- |
| 5 | BBB- | BB+ | BB | BB- | B+ | B |
| 6 | BB | BB- | B+ | B | B- | 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.
| Measure | Factor | Value | Score | Position in band | Assessment |
|---|---|---|---|---|---|
| Operating balance ÷ operating revenueClick for the formula and the band table | Budgetary performance | 30.1% | 1 |
≥ 15% |
Extremely strong |
| Balance after capital account ÷ total revenueClick for the formula and the band table | Budgetary performance | 26.1% | 1 |
≥ 5% |
Extremely strong |
| Free cash ÷ daily cash operating costClick for the formula and the band table | Liquidity | 474 days | 1 |
≥ 120 |
Extremely strong |
| Free cash ÷ debt service falling due in 12 monthsClick for the formula and the band table | Liquidity | 16.71x | 1 |
≥ 3.0x |
Extremely strong |
| Direct debt ÷ operating revenueClick for the formula and the band table | Debt burden | 38.6% | 2 |
30% to 45% |
Very strong |
| Interest ÷ operating revenueClick for the formula and the band table | Debt burden | 1.9% | 1 |
≤ 2% |
Extremely strong |
| Own revenue ÷ total revenueClick for the formula and the band table | Budgetary flexibility | 69.9% | 2 |
65% to 80% |
Very strong |
| Capital spend ÷ total spend — room to deferClick for the formula and the band table | Budgetary flexibility | 13.5% | 3 |
12% to 18% |
Strong |
| Collection rate on billed revenueClick for the formula and the band table | Financial management | 98.1% | 1 |
≥ 97% |
Extremely strong |
| Gross regional product per head (R)Click for the formula and the band table | Economy | R64 200 | 4 |
R55 000 to R75 000 |
Adequate |
| Unemployment rateClick for the formula and the band table | Economy | 31.4% | 4 |
27% to 33% |
Adequate |
| Share of output in the three largest sectorsClick for the formula and the band table | Economy | 58.0% | 4 |
55% to 65% |
Adequate |
| Contingent liabilities ÷ operating revenueClick for the formula and the band table | Contingent liabilities | 4.5% | 2 |
2% to 5% |
Very strong |
| Debt impairment charge ÷ receivablesClick for the formula and the band table | Financial management | 15.9% | 3 |
10% to 16% |
Strong |
| Repairs and maintenance ÷ carrying value of infrastructureClick for the formula and the band table | Financial management | 3.1% | 4 |
3% to 4% |
Adequate |
| Adjustment | Basis | Notches | Level after | Applied |
|---|---|---|---|---|
| Indicative credit level | Matrix outcome at profile 2 against framework 4 | — | A | Starting point |
| Liquidity override | Free cash below 0.8 times debt service falling due within twelve monthsMeasured: 16.71x against 0.80x | 0 | A | Not triggered |
| Debt override | Direct debt above 120% of operating revenueMeasured: 38.6% against 120% | 0 | A | Not triggered |
| Budgetary override | Operating balance negative in two consecutive yearsMeasured: 30.1% — positive | 0 | A | Not triggered |
| Financial management override | A disclaimer or adverse audit opinionMeasured: Qualified opinion | 0 | A | Not triggered |
| Exceptional single-asset concentration | More than half of own revenue from one payer or one sectorMeasured: Largest single payer below the threshold | 0 | A | Not triggered |
| Stand-alone credit profile | After every override | — | A | SACP |
| Sovereign ceiling | A local government is not normally rated above the Republic of South Africa | Capped | BB- | Binding |
| Sovereign foreign-currency rating | BB- |
| Moody’s equivalent | Ba2 |
| Stand-alone credit profile | A |
| Ceiling applied | Yes |
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.
| Legal obligation on national government | None. MFMA section 45 is explicit that national government does not guarantee municipal debt. |
| Intervention mechanism | Constitution section 139 and MFMA chapter 13 provide for intervention and a financial recovery plan, not for funding. |
| Track record | Recent practice has been conditional support and administration rather than a cash bailout. |
| Systemic importance | Moderate. Not a metropolitan municipality; failure would be contained regionally. |
| Uplift applied | None |
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 | Agency convention | Rating | Outlook | What it means for pricing |
|---|---|---|---|---|
| Global long-term | S&P convention, comparable across countries | BB- | Stable, weakening | What an offshore lender or a development finance institution prices from |
| Global equivalent | Moody’s convention | Ba3 | Stable, weakening | Used where the counterparty mandates a Moody’s rating |
| National long-term | Relative to the strongest credit in South Africa | A+(ZA) | Stable, weakening | What a domestic bond issue and most bank facilities price from |
| National short-term | Twelve months or less | A1(ZA) | — | Commercial paper and short-dated facilities |
| Factor | Base score | Adjusted | Weight | Movement |
|---|---|---|---|---|
| Economy | 4.00 | 4.00 | 20% | Unchanged |
| Financial management | 3.87 | 3.87 | 20% | Unchanged |
| Budgetary flexibility | 2.50 | 2.50 | 10% | Unchanged |
| Budgetary performance | 1.00 | 1.00 | 10% | Unchanged |
| Liquidity | 1.00 | 1.00 | 20% | Unchanged |
| Debt burden | 1.50 | 1.50 | 10% | Unchanged |
| Contingent liabilities | 2.00 | 2.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.
| Municipality | Rating and scale | Debt / revenue | Days cash | Collection | What drives it |
|---|---|---|---|---|---|
| This municipality | BB- / 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 Town | Ba2 (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 Johannesburg | A(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 Tshwane | Caa2 (Moody’s) | — | — | — | Downgraded on liquidity concerns. |
| Republic of South Africa | BB- / 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.
| Why municipalities are not companies | 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. |
| 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 scores | 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 1.2 points for a qualification rising to 3.0 for a disclaimer. |
| Why there is no support uplift | 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. |
| Where judgement remains | 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. |
| Measure | 1 — extremely strong | 2 | 3 | 4 | 5 | 6 — 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 | ≥ 120 | 90 to 120 | 60 to 90 | 30 to 60 | 15 to 30 | < 15 |
| Free cash ÷ debt service falling due in 12 monthsLiquidity · higher is stronger | ≥ 3.0x | 1.8x to 3.0x | 1.2x to 1.8x | 0.8x to 1.2x | 0.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 000 | R100 000 to R140 000 | R75 000 to R100 000 | R55 000 to R75 000 | R35 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% |
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.
| Button | What it means | When you would use it |
|---|---|---|
| Mode 1 | Your own figures | 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. |
| Mode 2 | Half year | 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. |
| Mode 3 | Full year | The 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.
| Tab | In one sentence | The question it answers |
|---|---|---|
| The original MATOS tabs | ||
| Executive cockpit | The one-page summary. | How are we doing, overall, right now? |
| System modules | A map of every part of the system. | Where does this number come from, and what depends on it? |
| System integration | Your 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 workbook | The actual Excel sheets, on screen. | Show me the working. |
| Strategy and performance | The ratios, against their benchmarks. | Which of our numbers are outside the norm, and by how much? |
| Scenario lab | Sliders for the things that could go wrong. | What if collections drop, or rates rise, or a project runs late? |
| Reports and outputs | The statutory reports and the download buttons. | What do I have to submit, by when, and can I get it out of here? |
| Governance and controls | The automated checks. | Which controls are passing and which are failing? |
| The risk and assurance tabs | ||
| Project risk | Big 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 monitor | The 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 findings | The 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 expenditure | Actual against budget, plus collections, interest, tariffs and maintenance. | Are we collecting what we bill, and what is eating the budget? |
| Journals, ledgers and bank | The audit trail for everything this system posts. | Show me the double entry, the bank movement and the VAT treatment. |
| Word | What it actually means |
|---|---|
| Certified | 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. |
| Committed | An order has been placed. The money is not gone yet, but you are obliged to spend it. |
| Retention | Money you are allowed to hold back until the job is properly finished. It is not a late payment. |
| Forecast outturn | 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. |
| Materiality | The size of error that would change someone's mind about the accounts. Here it is one per cent of total spending. |
| Qualified opinion | The AG found errors bigger than materiality that were not fixed. It is the outcome you are trying to avoid. |
| Adjusted budget | The budget after council changed it during the year. It is what you measure against, not the original one. |
| Unplanned maintenance | Fixing things after they break. Above about sixty per cent of the repairs budget, you are paying to fail instead of paying to prevent. |
| Deterministic | 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. |
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.
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.
renderAll() functions each thought they owned the
render pass. All of that is resolved here. Integrate this file, not the three it replaces.| Removed | Why | Where the content now lives |
|---|---|---|
| Revenue & expenditure panel | Budget 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 shims | All 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 constants | pctT, 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 stylesheets | The 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-references | The 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.
| Existing in MATOS | Becomes | What happens to its code |
|---|---|---|
panel-strategy — Credit worthiness section | Credit rating & scoring | Delete the credit block inside renderStrategy(). The ratio scorecard above it stays. |
panel-scenario — Scenario lab | Risk management, tabs 1 to 3 | Delete computeScenario(), renderScenSliders() and the eleven-lever array. |
8_Sustainability_Report long-term model | Risk management, tabs 4 and 5 | Keep the sheet, repoint it at axLongRun(). |
6_ALM — Assets & liabilities model | Treasury Control Tower, tab 3 | Delete the standalone ALM renderer. |
panel-revexp — Revenue & expenditure | Treasury Control Tower, tabs 1 and 5 | Delete. Already removed from this pack; nothing to carry across. |
4_MFD-MM_Master — Rates-to-Resilience | Treasury Control Tower, tab 4 | Keep the sheet, repoint it at axCascade(). |
panel-bankability — Bankability Engine | Project appraisal, steps 6 to 9 | Delete. Scoring is replaced by deterministic arithmetic; nothing is reusable. |
panel-capital — Capital Structure | Project appraisal, step 10 | Delete. Six funding models supersede the two it carried. |
| Fixed asset schedule inside the workbook | Asset register & AMP | Retire the sheet and repoint it at arTotals(). |
mx-, ax- and pa- sets are all new.<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.data-panel buttons plus the three group headings. The host nav handler routes on data-panel already.renderAll(). It exposes exactly two entry points: renderPack() and packInit().renderPack(); as the last line of renderAll(), and packInit(); between applyModeData() and the first renderAll().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.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.MX_JOURNAL, and pass mxDate() into the host batch-date and period fields so a date change re-dates entries everywhere.| From | To | What travels | Function |
|---|---|---|---|
| Audit findings | Credit rating | The projected audit outcome, which carries a scoring penalty inside financial management | axAuditOutcome = mxOpinion().op — live |
| Treasury Control Tower | Credit rating | Free cash, debt service and the ratio set the scorecard reads | axBase() — live |
| Risk management | Credit rating | Stressed ratios re-scored through the same published bands | axScenarioRating(axScenario()) — live |
| Risk management — scenario | Risk management — structural | The closing position at month 36 seeds year zero of the long run | axLongRun(true) — live |
| Any posting action | Everything downstream | A balanced journal into MX_JOURNAL and ACTION_ADJ, through applyModeData() into LINES | mxPost() — live |
| Project risk | Asset register | Certified capital work, as a WIP addition and then a transfer to PPE | mxPortfolio().certTD → AR_WIP — to wire |
| Contract monitor | Treasury Control Tower | Creditor ageing into the nought-to-three-month maturity bucket | mxContractTotals().due → axLadder() — to wire |
| Asset register | Risk management | Replacement cost and the condition-derived renewal requirement, seeding the backlog | arAmp() → AX_LR.backlogOpen — to wire |
| Asset register | Treasury Control Tower | Carrying amount, depreciation charge, maintenance ratio | arTotals() → axBase() — to wire |
| Project appraisal | Treasury Control Tower | Debt service on the selected structure, into the ladder and the coverage tests | paFunding().opts[sel].annualSvc — to wire |
| Risk management | Project appraisal | Revenue and cost growth for the step-11 affordability test | AX_LR.revGrowth, AX_LR.costGrowth — to wire |
| Project appraisal | Asset register | Capital cost and component lives on completion | PA.capex → AR_CLASSES — to 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 symbol | Used for | Read or write |
|---|---|---|
LINES, L(), LA() | Every ratio, scorecard metric, cascade stage and maturity bucket | Read |
totals(), totalsAt(), sumAt() | Aggregates and the three-year comparison | Read |
state.mode, state.period | Re-cutting every module to the half-year or full-year convention | Read |
ACTION_ADJ | Where posting actions write, so the host cascade picks them up | Write |
applyModeData(), renderAll() | Re-entry after a mode, period or posting change | Call |
fmt(), kpiSet(), showToast(), triggerCascade() | Formatting, notification and the impact popup, so the pack looks native | Call |
renderPack().arTotals().ca must equal L('1000') at every reporting date, in every mode./* =========================================================================
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;}<!-- ==================== 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 & expenditure</button>
<button data-tc="position">2 · Position — balance sheet</button>
<button data-tc="alm">3 · Working capital & 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 & 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 & 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 & 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 & 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 & 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 & cover</button>
<button data-pa="ratios">8 · Ratios & returns</button>
<button data-pa="sens">9 · Sensitivity</button>
<button data-pa="fund">10 · Funding models</button>
<button data-pa="fit">11 · Municipal fit & 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 & 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 & 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 & overrides</button>
<button data-cr="sens">Sensitivity & 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>/* ===== 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> ${mxDateTxt(j.date)} · ${j.src} · ${j.narr}
<span class="mx-src">Evidence: ${j.evid} · 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(); }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.