Skip to content
Ritesh FirodiyaGet in touch

Work / Chitragupt / Wiki / Decisions

2026-09-10-website-calculation-audit

Decisioncanonicalverified 2026-09-10

DECISION.2026-09-10.WEBSITE-CALCULATION-AUDIT

Website calculation audit: tax-computer statute fixes, Form 16 gross-salary reconstruction, fabricated-refund guard

Decision

Full correctness pass over the four V1 surfaces — Inbox upload, Tax Review, Expense, Portfolio — verifying arithmetic against statute rather than against captured output. Thirteen defects fixed. Every figure below is hand-computed from the Income-tax Act 1961 and the AY tax tables, not read off the engine.

Method: an independent reference implementation of slab tax + §87A + surcharge

  • cess was written from statute and swept against computeTax over 6,220 salaried scenarios (AY 2025-26 and 2026-27 × both regimes × salary ₹2.5L–₹80L in ₹25k steps × five CG/deduction shapes). A second property sweep asserted monotonicity, marginal rate ≤ 100% + cess, non-negativity of every output field, and the flat §115BBH VDA rate across all 8 verified AYs × 3 age bands. Both sweeps are clean after the fixes; the only surviving property "failures" are the genuine old-regime §87A ₹5L hard cliff and the new-regime cliff in AYs 2021-22 to 2023-24, which are real statute.

1. Surcharge marginal relief compared against the wrong tax (over-charged)

applySurchargeWithRelief computed the relief ceiling from the tax at the taxpayer's actual income, not the tax at the band threshold. That dropped the Tax(actual) − Tax(threshold) term entirely and inflated the notional previous-band surcharge, so every filer inside a relief zone (just above ₹50L / ₹1cr / ₹2cr / ₹5cr) was over-charged.

Worked example — old regime, AY 2026-27, taxable ₹50,10,000:

Statute Was
Slab tax ₹13,15,500 ₹13,15,500
Slab tax at the ₹50L threshold ₹13,12,500 (not computed)
Surcharge ceiling ₹10,000 − ₹3,000 = ₹7,000 ₹9,999.99
Total tax ₹13,75,400 ₹13,78,519.99

Fixed by passing a preSurchargeTaxAtThreshold callback into the helper, which recomputes slab tax with the slab head shaved down to the threshold (special-rate CG/VDA/debt-MF income is flat-rated, so trimming the slab head first is both conventional and taxpayer-favourable; a shave larger than the slab head comes off equity CG at its blended effective rate). §87A is not re-applied at the threshold — every surcharge threshold (₹50L+) sits far above every §87A ceiling (₹12L max). Worst observed over-charge in the sweep before the fix: ₹39,000.

2. §112A equity LTCG wrongly entered the §87A rebate base

cgTaxForRebate gated equity LTCG on carve_out_112 (which guards §112 non-equity LTCG) instead of carve_out_112A. carve_out_112A is true in every AY table and was never read. For every AY with carve_out_112: false (AY 2024-25 and 2025-26), §112A LTCG tax was rebated under §87A — barred by §112A(7). Under-charged tax; the user saw too small a liability.

Example — AY 2025-26 new regime, ₹2.5L salary + ₹2L LTCG: statute is (₹2L − ₹1.25L) × 12.5% = ₹9,375 + 4% cess = ₹9,750. The engine returned ₹0.

carve_out_111A: false for AY ≤ 2025-26 is left as-is — that is a deliberate, documented, taxpayer-favourable reading of the Bombay HC position on §87A vs §111A STCG, and Finance Act 2025 only made the carve-out explicit from AY 2026-27.

3. §80CCD(1B) was allowed in the new regime

chapterVIABreakdown(summary, caps, "new") returned the capped §80CCD(1B) NPS Tier-1 amount. §115BAC(2)(i) spares only §80CCD(2), §80CCH and §80JJAA — and §80CCD(2) is applied by the tax computer, outside this breakdown. §80CCD(1B) is sub-section (1B), not (2), so it is old-regime only. This matches tax-tables, which already lists 80CCD_1B under DEDUCTIONS.OLD-REGIME and only 80CCD_2 / 80CCH / 80JJAA under DEDUCTIONS.BOTH-REGIME — the code was the drift, the entity page was right.

Under-charged new-regime tax by up to ₹50,000 × marginal rate (≈₹15,600 with cess) and could wrongly recommend the new regime. computeTax now zeroes Chapter VI-A for the new regime itself rather than trusting the caller, so a mis-populated input can never re-introduce it.

4. One-paise holes at every slab and surcharge band edge

findMarginalSlabPct and the surcharge band search both used an exclusive totalIncome > band.from_paise. The tables store from = prev.to + 1, so from_paise is the band's first paise — an exclusive test left that exact value matching no band at all. findMarginalSlabPct then fell through to return slabs[last].rate_pct, reporting 30% for a taxable income of exactly ₹4,00,000.01. That rate drives the debt-MF slab-rate tax, so it was a real money error, not just a label. Both switched to an inclusive >=.

applySlabs keeps its existing bounds — its residual is ≤1 paise per slab and rounds away; changing it would risk a one-paise error the other way for no gain.

5. Form 16 never emitted gross salary unless the 1(d) total line parsed

mapForm16 read only the gross_salary field. A real Form 16 that yielded salary_17_1 but no Part B item 1(d) total emitted a tds_total entry and no salary entry at all. Found on the seeded corpus: salary_17_1 = ₹18,40,000 present, gross_salary absent, ledger salary ₹0.

Now reconstructs 1(d) from its own statutory definition — §17(1) salary + §17(2) perquisites + §17(3) profits in lieu — preferring the printed total when present. Confidence carries the weakest component used, so a shaky §17(2) cannot launder itself into a confident total.

6. Tax credits with zero income produced a fabricated refund

computePosition's completeness guard checked that a Form 16 existed, never that it parsed. Combined with (5), the review showed Gross total income ₹0, TDS ₹2,12,000, "Refund due + ₹2,12,000" — the entire TDS presented as a refund against no income data. This is the most damaging number the engine can emit.

TDS is withheld on income, so credits-with-zero-income has no legitimate shape (even a below-threshold filer whose bank withheld §194A TDS carries bank_interest_paise > 0). The guard now returns incomplete when no income head parsed and credits exist, checking every head including PGBP and the four debt-MF buckets that computeGrossIncome omits, so a PGBP-only, rental-only or CG-only filer is never flagged. New rule recon-credits-without-income explains why the position is blank, and the waterfall carries a next-step line distinguishing "we could not read your salary" from "we read it, confirm it".

7. A pending CA invite hid the user's entire paid Tax Review

TaxPage early-returned a lean placeholder whenever shareState.kind === "invited". .context/designs/web/tax/ca-invited.html is tax-review.html plus a "CA invite status" panel — same "What you claimed", regime comparison, salary-to-refund waterfall, 15-question checklist, "What you missed" and tax-journey chart; it contains no "In the meantime" or "What Tax Review needs" section at all. The stronger engaged state never hid the review, which made the weaker state the more restrictive one — for up to the 14 days an invite can sit unaccepted.

The invite is now a banner plus a rail card (CaInvitePendingCard). TaxCaInvitedView.tsx and CaInvitePendingSidebar.tsx deleted per the no-legacy rule.

8. Expense: donut gap, transfer leak, and a health verdict on no data

  • BurnDonut didn't close. total_paise counts every expense row but by_category_paise only carries categorised ones, so arcs stopped short of the full circle while the centre read the full total. The reference donut in expense-review.html is a closed circle and other is the spec's catch-all bucket, so the residual now folds into other.
  • transfers leaked into "Largest single outflows". The table's own footer promises "Excludes investments & family transfers" and the review engine drops the category from every aggregation — so a large family transfer could headline a table sitting directly under a KPI hero and donut that both excluded it. (The list ordering was checked and is correct: useExpenseLedgerEntries already orders by amount_paise desc.)
  • "HEALTHY · income and expense are tracking evenly this AY" rendered on an all-zero review, directly above three panels correctly saying they were waiting for a statement to parse. The narrative is now suppressed when there are no transactions and no income.

9. Portfolio: a lifetime gain labelled with a year-to-date percentage

HoldingObservationRow printed unrealised_paise (value − cost, since inception) next to ytd_return_pct (a year-to-date figure from the broker feed) as a single pair — "+₹13.68L (+11.0%)" — which reads as "this gain is 11%". On real data they diverge wildly: ₹13.68L on a holding now worth ₹15.00L is about +1036%, not +11%. The percentage is now derived from the rupee figure it sits beside (unrealised / (value − unrealised)), null when there is no cost basis to divide by.

Also: ltcg_exemption_unused_paise computed exemption − realisedLT without clamping, so a net long-term loss inflated it above the statutory cap (−₹50k of losses read as ₹1.75L of headroom against a ₹1.25L exemption). Both the engine and PortfolioPage's exemptionUsed now clamp realised LT at zero.

10. Upload rejected files the picker and the storage rules both accept

uploadFiles gated on file.type alone while the file input advertises .pdf,.xlsx,.zip by extension. Windows Chrome/Edge report .zip as application/x-zip-compressed — which storage.rules already allows on both the vault and the zip-import path — and .xlsx arrives as application/vnd.ms-excel where the registry maps the older Excel type; a file with no OS mapping arrives with an empty type. All were rejected client-side as "unsupported format" before upload was attempted. resolveAcceptedMime now resolves aliases and falls back to the extension the picker filtered on, and uploads under the canonical MIME so pdfOrXlsx() and the parser's content-type routing stay on one set of three values.

Also fixed: the Dashboard rendered two cashflow legends — its own four-item one (matching dashboard.html) plus CashflowChart's six-item one (matching expense-review.html, which the Expense page relies on). CashflowChart gained a showLegend prop, default on; the Dashboard passes false.

Verified end-to-end

Upload → parse → confirm → ledger fan-out → recompute → display, driven through the browser against the emulator. Aarav's AY 2026-27 review after confirming Form 16, every figure hand-checked:

Line Value Check
Salary / Gross total income ₹18,40,000 §17(1) reconstruction
− Standard deduction ₹75,000 new-regime table
Taxable income ₹17,65,000
New-regime tax ₹1,59,120 20,000 + 40,000 + 60,000 + 33,000 = ₹1,53,000, +4% cess
Old-regime tax ₹3,63,480 12,500 + 1,00,000 + 2,37,000 = ₹3,49,500, +4% cess
Regime saving ₹2,04,360 shown as "saves ₹2.04 L"
− TDS ₹2,12,000
Refund due ₹52,880 2,12,000 − 1,59,120

yarn typecheck · yarn lint (0 errors) · yarn test (1,193 passing) · yarn format:check all green. 23 regression tests added across tax-computer.test.ts, orchestrator.test.ts and a new form-16-gross-salary.test.ts.

11. PGBP shape questions were asked of every salaried filer

The interrogator catalog carries 19 questions and documents the intent in its own header: "1 gate + 3 shape questions surfaced only when the gate is 'yes'". resolveQuickCheck mapped the whole catalog unconditionally, so a salaried filer with no business income was asked "Presumptive scheme?", "Books of account?" and "Partner in a firm?" against a permanently empty PGBP head. Every tax wireframe's checklist carries the gate ("Side / freelance income?") and none of the three shapes.

The three shapes are now gated on real signal — a PGBP amount, or one of the pgbp_has_* flags the summariser sets from the ledger. There is no stored "yes" answer to gate on: QuickCheckUserAnswer is "no" | "none", because a yes is always earned by a parsed document (upload-only). A salaried filer now sees 16 rows, a business filer 19, and QuickCheckCard renders the count the engine actually emitted instead of a fixed number the rows below contradict.

Residual wireframe drift, not fixed: the header still reads 16 against the wireframe's 15. The wireframe's own row list is 14 and includes a row we don't model ("EPF / PPF withdrawal?") while omitting bank interest and capital gains — its 15 counts 8 income heads + 7 deductions and doesn't count the PGBP gate it draws. Reconciling needs a redraw; designs are immutable raw sources.

12. Rule flags were invisible to anyone without a CA

AlertsStack was gated on shareState.kind === "engaged", so a solo filer never saw a single flag the engine computed for them — §80C headroom, §80D, HRA, Schedule AL, the Form 16 ↔ 26AS TDS gap, or the new credits-without-income explanation. None of that is about the CA relationship.

Ungated for non-family, non-past-AY reviews. The component already returns null when it has nothing to say, which is the state every tax wireframe depicts — so this is not a component appearing in no design, it is a component whose empty state the designs happen to show.

Two things surfaced by making it visible, both fixed:

  • The card title printed the raw FlagKind enum (incomplete_profile, reconciliation). A filer who has never spoken to a CA cannot read that. Now mapped to plain English ("We need one more document", "Two documents disagree").
  • Each card carried a Resolve → button with no handler. A control that looks actionable and does nothing is worse than none, so it is gone; each flag's message already carries its own next step.

13. The seed had no expense data at all

No persona carried a single entry_class: "expense" row, so every Expense surface rendered its empty state on a freshly seeded stack and the pillar's arithmetic could not be exercised end to end — which is how the "Healthy · income and expense are tracking evenly" verdict on an all-zero review survived until this audit.

writeLedgerEntries was also silently dropping txn_date, expense_category, merchant_slug, merchant_display, identity_id and narration, so even a hand-added expense row would have landed in the ledger with every Expense surface still reading zero. Those now pass through.

bankStatementExpenses() in seed/_helpers.mjs generates a full AY of rows for Aarav — 9 recurring monthly debits, 7 one-offs placed across the year, and a monthly SIP as entry_class: "investment". Income is deliberately not emitted: Form 16's annual salary_gross already carries it and the engine spreads that envelope across twelve months, so per-month salary credits would double-count exactly the way 2026-08-06-expense-portfolio-double-counting-round-3 fixed.

Verified against the seeded data, each figure hand-checked:

Value Check
Income ₹18,40,000 Form 16 salary, matches the Tax review
Expense (burn) ₹14,67,276 114 rows — the ₹1.5L transfer correctly excluded
Invested ₹3,00,000 12 × ₹25,000 SIP
Saving ₹3,72,724 income − burn
Save rate 20.3% 3,72,724 / 18,40,000
by_category sum ₹14,67,276 equals total — the donut closes
Recurring 7 active · ₹23,948/mo rent and EMI correctly dropped by the ₹10,000 median cap
Feb deficit callout shortfall ₹61.1k ₹2.14L spend (incl. advance tax) vs ₹1.53L monthly income

Open — not fixed, needs a product call

  • The checklist header reads 16 where the wireframes read 15 — see §11. Wireframe drift; needs a redraw, not a code change.
  • CashflowChart uses inline style={{ background }} for series colours. .claude/rules/frontend.md was amended in #46 to allow exactly this for shared CHART_COLORS tokens, so this is no longer a violation.

Related

Every project of mine is written down like this.

Read the résumé