From 95dc38e8694cb6eb48088ada5f52dd64ca693b07 Mon Sep 17 00:00:00 2001 From: danielededo <37807398+danielededo@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:11:49 +0000 Subject: [PATCH 1/2] feat(report): where the gross pay goes, as a waterfall The month's stacked bar is gone. The rows under "Where your gross pay goes" now step from the gross pay, through what is added to it (an expense refund, a contribution relief, a tax bonus), down each deduction to the net pay; each row has a span on a track that starts where the one before it ended. The gross pay's items open from its row, and the items some payroll software leaves out of the gross are listed under what is added rather than among them. A rounding under a euro with no item of its own makes no step. The list of items left out of the gross moves to the payslip package, so that the checks and the report share it. --- CHANGELOG.md | 5 ++ internal/extract/validate.go | 13 +---- internal/i18n/i18n.go | 4 ++ internal/payslip/payslip.go | 15 ++++++ internal/report/report.go | 88 ++++++++++++++++++++++---------- internal/report/report.html.tmpl | 31 ++++------- internal/report/report_test.go | 52 ++++++++++++++++--- web/test/e2e.test.mjs | 12 ++--- 8 files changed, 147 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 094f2c8..2e134a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ How to use this file: ### Changed +- "Where your gross pay goes" reads as a waterfall: from the gross pay, + what is added to it, such as an expense refund or a contribution relief, + then each deduction, down to the net pay, each with a span that starts + where the one before it ended. The gross pay's items open from its row, + and what is added is no longer counted in it. - On a wide screen the month takes two columns, what it is beside where the gross pay went, and the glossary two; the charts over time keep a size at which their text reads well, and the web app widens with the report. diff --git a/internal/extract/validate.go b/internal/extract/validate.go index 3c0c025..2df30bb 100644 --- a/internal/extract/validate.go +++ b/internal/extract/validate.go @@ -12,17 +12,6 @@ import ( // apprentice rate. A rate outside this list is not an error, only unverified. var inpsRates = []int64{919, 949, 584} -// outsideGross are item kinds that some payroll software leaves out of the -// printed gross because they are not taxable pay. -var outsideGross = map[string]bool{ - "earnings.reimbursement": true, - "earnings.contribution_relief": true, - "tax.credit": true, - "tax.return": true, - "earnings.family_allowance": true, - "rounding": true, -} - // validate fills totals that can be computed, runs the cross-checks, and sets // the status of each total: verified when a check confirms it, inconsistent // when a check contradicts it, extracted when no check applies. @@ -41,7 +30,7 @@ func validate(p *payslip.Payslip) { if hasItems { var taxable int64 for _, it := range p.Items { - if it.Side == payslip.Earning && !outsideGross[it.Key] { + if it.Side == payslip.Earning && !payslip.OutsideGross(it.Key) { taxable += it.Cents } } diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index ae02257..c084db7 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -203,6 +203,8 @@ var catalog = map[Lang]map[string]string{ "report.part.taxes": "Taxes", "report.part.taxes.text": "IRPEF and the regional and municipal surcharges, paid to the State for you.", "report.part.other": "Other deductions", + "report.part.plus": "Added to the gross pay", + "report.part.plus.text": "Paid with the net but not part of the gross pay: expense refunds, contribution reliefs, tax bonuses, roundings.", "report.part.other.fund": "Other deductions, mostly the pension fund", "report.part.other.text": "Everything else withheld, such as a pension fund, union dues or meal vouchers.", "report.earnings": "What makes up your gross pay", @@ -404,6 +406,8 @@ var catalog = map[Lang]map[string]string{ "report.part.taxes": "Imposte", "report.part.taxes.text": "IRPEF e addizionali regionale e comunale, versate allo Stato al posto tuo.", "report.part.other": "Altre trattenute", + "report.part.plus": "In più rispetto al lordo", + "report.part.plus.text": "Arrivano nel netto ma non fanno parte del lordo: rimborsi spese, esoneri contributivi, bonus fiscali, arrotondamenti.", "report.part.other.fund": "Altre trattenute, soprattutto fondo pensione", "report.part.other.text": "Tutto il resto trattenuto, come fondo pensione, quota sindacale o buoni pasto.", "report.earnings": "Cosa compone il lordo", diff --git a/internal/payslip/payslip.go b/internal/payslip/payslip.go index ca3225e..9ec034a 100644 --- a/internal/payslip/payslip.go +++ b/internal/payslip/payslip.go @@ -177,6 +177,21 @@ type Payslip struct { Checks []Check `json:"checks"` } +// outsideGross are item kinds that some payroll software leaves out of the +// printed gross because they are not taxable pay. +var outsideGross = map[string]bool{ + "earnings.reimbursement": true, + "earnings.contribution_relief": true, + "tax.credit": true, + "tax.return": true, + "earnings.family_allowance": true, + "rounding": true, +} + +// OutsideGross reports whether some payroll software leaves an item of this +// kind out of the printed gross. +func OutsideGross(key string) bool { return outsideGross[key] } + // Party is a person or a company named on a payslip. type Party struct { ID string `json:"id,omitempty"` diff --git a/internal/report/report.go b/internal/report/report.go index fa6e838..fb268e3 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -70,9 +70,11 @@ type Month struct { // Part is one share of the gross pay: the net or a kind of deduction. type Part struct { Key, Label, Amount, Text string - Color string - Width float64 // percent of the bar - Lines []Line + // From and Width place the part's span on its track, in percent of + // the gross pay and what is added to it: the parts step down from the + // gross pay to the net, like a waterfall. + From, Width float64 + Lines []Line } // Line is one printed item with its explanation. @@ -496,13 +498,15 @@ func month(l i18n.Lang, p payslip.Payslip, per insights.Period) Month { line := func(it payslip.Item) Line { return Line{Description: it.Description, Label: i18n.ItemLabel(l, it.Key), Amount: money.Format(it.Cents) + " €", Text: i18n.ItemText(l, it.Key)} } - var contrib, tax, other []Line + var contrib, tax, other, plus []Line fund := pensionInContributions(p) // aside sums what builds up for you; withheld is the part of it that // your own pay gave, also among the other deductions. var aside, withheld int64 for _, it := range p.Items { switch { + case it.Side == payslip.Earning && payslip.OutsideGross(it.Key) && outside(p): + plus = append(plus, line(it)) case it.Side == payslip.Earning: m.Earnings = append(m.Earnings, line(it)) case it.Key == "pension_fund.employee" && fund > 0, it.Key == "deductions.pension_fund": @@ -544,44 +548,74 @@ func month(l i18n.Lang, p payslip.Payslip, per insights.Period) Month { m.Fund = i18n.T(l, "report.fund.month", moneyValue(f.total())) } shares := split(p) - lines := [][]Line{nil, contrib, tax, other} - parts := make([]struct { + // From the gross pay, what is added to it, then each deduction, down + // to the net pay. + var added int64 + if p.Gross.Found() && p.Net.Found() && p.Deductions.Found() { + added = max(p.Net.Cents+p.Deductions.Cents-p.Gross.Cents, 0) + } + // Less than a euro with no item of its own is a rounding: no step. + if added < 100 && len(plus) == 0 { + added = 0 + } + steps := []struct { key string cents int64 - color string + sign string lines []Line - }, len(partKeys)) - for i, key := range partKeys { - parts[i].key, parts[i].cents, parts[i].color, parts[i].lines = key, shares[i], partColors[i], lines[i] + }{ + {"contrib", shares[1], "−", contrib}, + {"taxes", shares[2], "−", tax}, + {"other", shares[3], "−", other}, } - var total int64 - for _, pt := range parts { - total += pt.cents + top := shares[0] + shares[1] + shares[2] + shares[3] + if p.Gross.Found() { + top = p.Gross.Cents + added } - if total == 0 { + if top <= 0 { return m } - for _, pt := range parts { - if pt.cents == 0 && len(pt.lines) == 0 { + pct := func(c int64) float64 { return float64(c) * 100 / float64(top) } + if p.Gross.Found() { + m.Parts = append(m.Parts, Part{Key: "gross", Label: i18n.T(l, "report.earnings"), Amount: money.Format(p.Gross.Cents) + " €", Width: pct(p.Gross.Cents), Lines: m.Earnings}) + if added > 0 { + m.Parts = append(m.Parts, Part{Key: "plus", Label: i18n.T(l, "report.part.plus"), Amount: "+" + money.Format(added) + " €", + Text: i18n.T(l, "report.part.plus.text"), From: pct(p.Gross.Cents), Width: pct(added), Lines: plus}) + } + } + at := top + for _, st := range steps { + if st.cents == 0 && len(st.lines) == 0 { continue } - label := i18n.T(l, "report.part."+pt.key) - if pt.key == "other" && 2*withheld > pt.cents { + label := i18n.T(l, "report.part."+st.key) + if st.key == "other" && 2*withheld > st.cents { label = i18n.T(l, "report.part.other.fund") } - m.Parts = append(m.Parts, Part{ - Key: pt.key, - Label: label, - Amount: money.Format(pt.cents) + " €", - Text: i18n.T(l, "report.part."+pt.key+".text"), - Color: pt.color, - Width: float64(pt.cents) * 100 / float64(total), - Lines: pt.lines, - }) + at -= st.cents + m.Parts = append(m.Parts, Part{Key: st.key, Label: label, Amount: st.sign + money.Format(st.cents) + " €", + Text: i18n.T(l, "report.part."+st.key+".text"), From: pct(at), Width: pct(st.cents), Lines: st.lines}) } + m.Parts = append(m.Parts, Part{Key: "net", Label: i18n.T(l, "report.part.net"), Amount: money.Format(shares[0]) + " €", + Text: i18n.T(l, "report.part.net.text"), Width: pct(shares[0])}) return m } +// outside reports whether the payslip's printed gross leaves out the items +// that some payroll software leaves out: the other earnings add up to it. +func outside(p payslip.Payslip) bool { + if !p.Gross.Found() { + return false + } + var in int64 + for _, it := range p.Items { + if it.Side == payslip.Earning && !payslip.OutsideGross(it.Key) { + in += it.Cents + } + } + return in == p.Gross.Cents +} + // The shares of the gross pay, in the order the bar and the columns stack // them, with their colors. var ( diff --git a/internal/report/report.html.tmpl b/internal/report/report.html.tmpl index 7660899..416b185 100644 --- a/internal/report/report.html.tmpl +++ b/internal/report/report.html.tmpl @@ -123,15 +123,9 @@ p { margin: 0 0 12px; } .net .label { font-size: 14px; color: var(--ink-2); } .net .big { font-size: 44px; line-height: 1.1; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; } .summary { margin: 12px 0 0; font-size: 17px; max-width: 34em; } -.bar { display: flex; gap: 2px; height: 28px; margin: 12px 0 6px; } -.bar .seg { border: 0; padding: 0; min-width: 4px; cursor: pointer; } -.bar .seg:first-child { border-radius: 6px 0 0 6px; } -.bar .seg:last-child { border-radius: 0 6px 6px 0; } -.bar .seg:hover, .bar .seg:focus-visible { opacity: .8; } .hint { font-size: 14px; color: var(--muted); margin: 0 0 8px; } .part-net { --c: var(--series-1); } .part-contrib { --c: var(--series-2); } .part-taxes { --c: var(--series-3); } .part-other { --c: var(--series-4); } -.part-gross, .part-aside { --c: transparent; } -.bar .seg { background: var(--c); } +.part-gross { --c: var(--muted); } .part-aside { --c: transparent; } .parts { border-top: 1px solid var(--grid); } .part { margin: 0; border-bottom: 1px solid var(--grid); } .part > summary { display: flex; align-items: center; gap: 10px; padding: 12px 0; color: var(--ink); font-size: 16px; list-style: none; } @@ -139,6 +133,12 @@ p { margin: 0 0 12px; } .part > summary .key { flex: none; width: 12px; height: 12px; border-radius: 3px; background: var(--c); border: 1px solid var(--c); } .part > summary .name { flex: 1; } .part > summary .amount { font-variant-numeric: tabular-nums; font-weight: 600; } +/* Each part's span on the way from the gross pay to the net. */ +.part > summary { flex-wrap: wrap; } +.part > summary .track { order: 4; flex: 0 0 calc(100% - 40px); position: relative; height: 8px; margin: 0 0 0 22px; border-radius: 4px; background: color-mix(in srgb, var(--grid) 60%, transparent); } +.part > summary .fill { position: absolute; top: 0; bottom: 0; min-width: 3px; border-radius: 4px; background: var(--c); } +.part-gross > summary .amount, .part-net > summary .amount { font-weight: 700; } +.part-plus { --c: var(--good); } .part > summary::after { content: "›"; color: var(--muted); transition: transform .15s; } .part[open] > summary::after { transform: rotate(90deg); } .part .body { padding: 0 0 12px 22px; } @@ -550,14 +550,6 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); } hit.addEventListener("click", function () { if (pointer === "mouse") openMonth(i); }); }); }); - // A segment of the bar opens its line below. - document.querySelectorAll(".bar .seg").forEach(function (seg) { - seg.addEventListener("click", function () { - var month = seg.closest("section.month"); - month.querySelectorAll("details.part").forEach(function (d) { d.open = d.dataset.part === seg.dataset.part; }); - month.querySelector('details.part[data-part="' + seg.dataset.part + '"]').scrollIntoView({ block: "nearest" }); - }); - }); // Inside the web app the page shows the tabs: it is told which there // are, and asks for one in turn. if (embedded && tabs.length) { @@ -674,15 +666,10 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
{{.Text}}
+{{.}}
{{end}} {{if .Lines}}{{$m.Label}} {{$m.StatusText}}{{if gt (len $.Months) 1}}{{end}}
-{{index $.T "report.net"}}{{$m.Net}}
-{{if $m.Gross}}{{$m.Gross}}
{{end}} +{{$m.NetOf}}{{if gt (len $.Months) 1}}{{end}}
+{{$m.Net}}
+{{with $m.Gross}}{{.}} · {{end}}{{$m.StatusText}}
{{if $m.Summary}}{{$m.Summary}}
{{end}} {{range $m.SummaryWhy}}{{.}}
{{end}} {{with $m.Reread}}{{.}}
{{end}} diff --git a/web/test/e2e.test.mjs b/web/test/e2e.test.mjs index fbc01ef..9771c6a 100644 --- a/web/test/e2e.test.mjs +++ b/web/test/e2e.test.mjs @@ -239,7 +239,7 @@ test('shows the latest of many months first', async () => { await page.waitForSelector('#report:not(.pending)'); const visible = await frame.$$eval('section.month', (ms) => ms.filter((m) => getComputedStyle(m).display !== 'none').map((m) => m.querySelector('.when').textContent)); assert.equal(visible.length, 1); - assert.match(visible[0], /^September 2026/); + assert.match(visible[0], /^Net pay for September 2026/); assert.equal(await frame.$eval('.picker button[aria-pressed="true"]', (b) => b.textContent), 'Sep 26'); // On a phone the row of months is wider than the screen: it opens // scrolled to the marked month, not to January.