diff --git a/CHANGELOG.md b/CHANGELOG.md index 094f2c8..3bd70b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,13 @@ How to use this file: ### Changed +- The month opens with one line less: "Net pay for October 2026" over the + amount, and whether the numbers add up beside the gross pay below it. +- "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..f836880 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -129,6 +129,7 @@ var catalog = map[Lang]map[string]string{ "report.privacy": "Made on your device with no network connection. It holds your payslip data: keep it private.", "report.disclaimer": "cedolo explains what your payslips say; it is not tax, legal or labour advice and it can be wrong. For decisions, ask your payroll office, a CAF, a patronato or union office, or a consulente del lavoro.", "report.net": "Net pay", + "report.net_of": "Net pay for %s", "report.gross": "Gross pay", "report.deductions": "Deductions", "report.vs_previous": "%s vs previous month", @@ -203,6 +204,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", @@ -330,6 +333,7 @@ var catalog = map[Lang]map[string]string{ "report.privacy": "Creato sul tuo dispositivo, senza connessioni a internet. Contiene i dati dei tuoi cedolini: tienilo riservato.", "report.disclaimer": "cedolo spiega cosa dicono i tuoi cedolini; non è una consulenza fiscale, legale o del lavoro e può sbagliare. Per decidere, rivolgiti all'ufficio paghe, a un CAF, a un patronato o sindacato, o a un consulente del lavoro.", "report.net": "Netto", + "report.net_of": "Netto di %s", "report.gross": "Lordo", "report.deductions": "Trattenute", "report.vs_previous": "%s rispetto al mese precedente", @@ -404,6 +408,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..11ebecf 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -47,6 +47,7 @@ type Month struct { Prev, Next string // the index of the month before and after, empty at either end Status Status StatusText string + NetOf string // "Netto di ottobre 2026", over the net pay Net string Gross string // "of 2.194,50 € gross pay" Delta string // change of the net from the previous month, empty when unknown @@ -70,9 +71,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. @@ -472,6 +475,7 @@ func month(l i18n.Lang, p payslip.Payslip, per insights.Period) Month { Status: st, StatusText: i18n.T(l, "report."+string(st)), Net: amount(p.Net), + NetOf: i18n.T(l, "report.net_of", per.Label), } m.Reread = reread(l, p) if p.Adjustment { @@ -496,13 +500,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 +550,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..f0d9472 100644 --- a/internal/report/report.html.tmpl +++ b/internal/report/report.html.tmpl @@ -118,20 +118,12 @@ p { margin: 0 0 12px; } .yearpick button[aria-pressed="true"] { background: var(--raised); color: var(--ink); font-weight: 600; box-shadow: 0 1px 2px rgb(0 0 0 / .12); } .month { margin-top: 24px; } .when { color: var(--ink-2); margin: 0; } -.when .status { font-size: 14px; margin-left: 8px; } .net { margin: 4px 0 0; display: flex; flex-direction: column; } -.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 +131,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 +548,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) { @@ -650,9 +640,9 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); } {{range $i, $m := .Months}}
-

{{$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}} @@ -674,15 +664,10 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
{{if $m.Parts}}

{{index $.T "report.where"}}

-{{if $m.Earnings}}
-{{index $.T "report.earnings"}}{{$m.GrossTotal}} -
    {{range $m.Earnings}}{{template "line" .}}{{end}}
-
{{end}} -
{{range $m.Parts}}{{end}}
{{range $m.Parts}}
-{{.Label}}{{.Amount}} -

{{.Text}}

+{{.Label}}{{.Amount}} +
{{with .Text}}

{{.}}

{{end}} {{if .Lines}}
    {{range .Lines}}{{template "line" .}}{{end}}
{{end}}
{{end}}
diff --git a/internal/report/report_test.go b/internal/report/report_test.go index 77a96b6..a0e7325 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "html" + "math" "slices" "strings" "testing" @@ -62,21 +63,37 @@ func TestBuild(t *testing.T) { if d.Trend == "" { t.Error("no trend for three months") } + // The parts step down from the gross pay to the net, each starting + // where the one before it ended, and the net pay with the deductions + // make up the whole. var width float64 keys := map[string]bool{} + at := 100.0 for _, p := range m.Parts { - width += p.Width keys[p.Key] = true - if p.Text == "" || p.Amount == "" { + if p.Amount == "" || (p.Text == "" && p.Key != "gross") { t.Errorf("part %+v", p) } + switch p.Key { + case "gross", "plus": + case "net": + width += p.Width + default: + width += p.Width + if math.Abs(p.From+p.Width-at) > 0.01 { + t.Errorf("%s spans %.2f+%.2f, want to end at %.2f", p.Key, p.From, p.Width, at) + } + at = p.From + } } - if !keys["net"] || !keys["contrib"] || !keys["taxes"] || width < 99.9 || width > 100.1 { + if m.Parts[0].Key != "gross" || m.Parts[len(m.Parts)-1].Key != "net" || !keys["contrib"] || !keys["taxes"] || width < 99.9 || width > 100.1 { t.Errorf("parts %+v sum to %.2f%%", m.Parts, width) } lines := len(m.Earnings) + len(m.SetAside) for _, p := range m.Parts { - lines += len(p.Lines) + if p.Key != "gross" { + lines += len(p.Lines) + } } if lines != len(slips[2].Items) { t.Errorf("%d lines for %d items", lines, len(slips[2].Items)) @@ -489,8 +506,10 @@ func TestPensionFundInContributions(t *testing.T) { if m.AsideTotal != "960,00 €" || m.Withheld != "Of this, 700,00 € is withheld from your pay: you also find it among the other deductions." || m.Fund != "" { t.Errorf("set aside %q, withheld %q, fund %q", m.AsideTotal, m.Withheld, m.Fund) } - if other := m.Parts[len(m.Parts)-1]; other.Label != "Other deductions, mostly the pension fund" { - t.Errorf("other deductions labeled %q", other.Label) + for _, pt := range m.Parts { + if pt.Key == "other" && pt.Label != "Other deductions, mostly the pension fund" { + t.Errorf("other deductions labeled %q", pt.Label) + } } // A TFR kept by the employer is set aside but not paid into the fund. kept := slip(9, 70000, 142430) @@ -642,3 +661,24 @@ func TestYears(t *testing.T) { t.Errorf("one year: %d views", len(one.Views)) } } + +// TestAddedToGross shows what reaches the net pay without being part of +// the gross, such as a contribution relief, as a step up after the gross. +func TestAddedToGross(t *testing.T) { + a := func(c int64) payslip.Amount { return payslip.Amount{Cents: c, Status: payslip.Verified} } + p := payslip.Payslip{Year: 2026, Month: 3, Kind: payslip.Monthly, + Items: []payslip.Item{ + {Description: "Retribuzione", Key: "earnings.base_salary", Side: payslip.Earning, Cents: 200000}, + {Description: "Esonero contributivo", Key: "earnings.contribution_relief", Side: payslip.Earning, Cents: 10000}, + }, + Gross: a(200000), Net: a(160000), Deductions: a(50000), SocialContributions: a(18000), Taxes: a(32000)} + m := Build(i18n.EN, []payslip.Payslip{p}, "test").Months[0] + var got []string + for _, pt := range m.Parts { + got = append(got, fmt.Sprintf("%s %s %.0f+%.0f %d", pt.Key, pt.Amount, pt.From, pt.Width, len(pt.Lines))) + } + want := []string{"gross 2.000,00 € 0+95 1", "plus +100,00 € 95+5 1", "contrib −180,00 € 91+9 0", "taxes −320,00 € 76+15 0", "net 1.600,00 € 0+76 0"} + if !slices.Equal(got, want) { + t.Errorf("parts =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} diff --git a/web/test/e2e.test.mjs b/web/test/e2e.test.mjs index 369b4d6..9771c6a 100644 --- a/web/test/e2e.test.mjs +++ b/web/test/e2e.test.mjs @@ -112,17 +112,17 @@ test('reads payslips in every style and rotation and shows the report', async () 'ok: — 1 payslip(s) read', ]); const frame = page.frames().find((f) => f !== page.mainFrame()); - await frame.waitForSelector('section.month:not([hidden]) .bar'); + await frame.waitForSelector('section.month:not([hidden]) .parts'); // The frame grows to its content: shorter on this wide window, where // the month takes two columns. await page.waitForFunction(() => parseInt(document.getElementById('report').style.height, 10) > 400); - // One payslip at a time: the picker switches, and a segment of the bar + // One payslip at a time: the picker switches, and a step from the gross // opens its explanation. const shown = () => frame.$$eval('section.month', (ms) => ms.filter((m) => !m.hidden).map((m) => m.dataset.month)); assert.deepEqual(await shown(), ['1']); await frame.click('.picker button[data-month="0"]'); assert.deepEqual(await shown(), ['0']); - await frame.click('section.month:not([hidden]) .bar .seg.part-taxes'); + await frame.click('section.month:not([hidden]) details.part-taxes summary'); assert.equal(await frame.$eval('section.month:not([hidden]) details.part-taxes', (d) => d.open), true); assert.match(await frame.$eval('section.month:not([hidden]) details.part-taxes', (d) => d.textContent), /IRPEF/); assert.deepEqual(seen.errors, []); @@ -208,7 +208,7 @@ test('the report frame fits its content on a phone', async () => { await page.setInputFiles('#files', [synth('phone.pdf')]); await statuses(page); const frame = page.frames().find((f) => f !== page.mainFrame()); - await frame.waitForSelector('section.month:not([hidden]) .bar'); + await frame.waitForSelector('section.month:not([hidden]) .parts'); const height = () => page.$eval('#report', (f) => parseInt(f.style.height, 10) || 0); await page.waitForFunction(() => parseInt(document.getElementById('report').style.height, 10) > 500); const first = await height(); @@ -234,12 +234,12 @@ test('shows the latest of many months first', async () => { await page.setInputFiles('#files', files); await statuses(page); const frame = page.frames().find((f) => f !== page.mainFrame()); - await frame.waitForSelector('section.month:not([hidden]) .bar'); + await frame.waitForSelector('section.month:not([hidden]) .parts'); // The frame is laid out hidden first; what counts is the report shown. 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. @@ -475,7 +475,7 @@ test('makes an anonymized copy that reads as the same payslip', async () => { await page.setInputFiles('#files', [copy]); assert.deepEqual(await statuses(page), ['ok: — 1 payslip(s) read']); const frame = page.frames().find((f) => f !== page.mainFrame()); - await frame.waitForSelector('section.month:not([hidden]) .bar'); + await frame.waitForSelector('section.month:not([hidden]) .parts'); assert.match(await frame.content(), /1\.667,40/); assert.deepEqual(seen.errors, []); onlyThisSite(seen);