Work / Chitragupt / Wiki / Decisions
2026-09-10-website-calculation-audit
Decisioncanonicalverified 2026-09-10
DECISION.2026-09-10.WEBSITE-CALCULATION-AUDITWebsite 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
computeTaxover 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
BurnDonutdidn't close.total_paisecounts every expense row butby_category_paiseonly carries categorised ones, so arcs stopped short of the full circle while the centre read the full total. The reference donut inexpense-review.htmlis a closed circle andotheris the spec's catch-all bucket, so the residual now folds intoother.transfersleaked 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:useExpenseLedgerEntriesalready orders byamount_paisedesc.)- "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
FlagKindenum (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'smessagealready 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.
CashflowChartuses inlinestyle={{ background }}for series colours..claude/rules/frontend.mdwas amended in #46 to allow exactly this for sharedCHART_COLORStokens, so this is no longer a violation.
Related
- tax-recompute-engine · pillar-tax · pillar-expense · pillar-portfolio
- tax-tables — confirmed §80CCD(1B) as old-regime-only
- 2026-06-10-section-87a-total-income — §87A ceiling reads total income
- 2026-08-10-round-4-self-validation-and-hardcoding-sweep — prior validation round
Every project of mine is written down like this.
Read the résumé