Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 1 addition & 12 deletions internal/extract/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
}
Expand Down
6 changes: 6 additions & 0 deletions internal/i18n/i18n.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions internal/payslip/payslip.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
90 changes: 63 additions & 27 deletions internal/report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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":
Expand Down Expand Up @@ -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 (
Expand Down
39 changes: 12 additions & 27 deletions internal/report/report.html.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -118,27 +118,25 @@ 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; }
.part > summary::-webkit-details-marker { display: none; }
.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; }
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -650,9 +640,9 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
{{range $i, $m := .Months}}
<section class="month" data-month="{{$i}}"{{if ne $i $.Selected}} hidden{{end}}>
<div class="m-head">
<p class="when"><span class="label">{{$m.Label}} <span class="status {{$m.Status}}">{{$m.StatusText}}</span></span>{{if gt (len $.Months) 1}}<span class="steps"><button type="button" data-to="{{$m.Prev}}" aria-label="{{index $.T "report.prev"}}"{{if not $m.Prev}} disabled{{end}}>‹</button><button type="button" data-to="{{$m.Next}}" aria-label="{{index $.T "report.next"}}"{{if not $m.Next}} disabled{{end}}>›</button></span>{{end}}</p>
<p class="net"><span class="label">{{index $.T "report.net"}}</span><span class="big">{{$m.Net}}</span></p>
{{if $m.Gross}}<p class="sub">{{$m.Gross}}</p>{{end}}
<p class="when"><span class="label">{{$m.NetOf}}</span>{{if gt (len $.Months) 1}}<span class="steps"><button type="button" data-to="{{$m.Prev}}" aria-label="{{index $.T "report.prev"}}"{{if not $m.Prev}} disabled{{end}}>‹</button><button type="button" data-to="{{$m.Next}}" aria-label="{{index $.T "report.next"}}"{{if not $m.Next}} disabled{{end}}>›</button></span>{{end}}</p>
<p class="net"><span class="big">{{$m.Net}}</span></p>
<p class="sub">{{with $m.Gross}}{{.}} · {{end}}<span class="status {{$m.Status}}">{{$m.StatusText}}</span></p>
{{if $m.Summary}}<p class="summary">{{$m.Summary}}</p>{{end}}
{{range $m.SummaryWhy}}<p class="why">{{.}}</p>{{end}}
{{with $m.Reread}}<p class="note">{{.}}</p>{{end}}
Expand All @@ -674,15 +664,10 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
<div class="m-body">
{{if $m.Parts}}
<h2>{{index $.T "report.where"}}</h2>
{{if $m.Earnings}}<details class="part gross">
<summary><span class="name">{{index $.T "report.earnings"}}</span><span class="amount">{{$m.GrossTotal}}</span></summary>
<div class="body"><ul class="lines">{{range $m.Earnings}}{{template "line" .}}{{end}}</ul></div>
</details>{{end}}
<div class="bar">{{range $m.Parts}}<button type="button" class="seg part-{{.Key}}" style="width:{{printf "%.2f" .Width}}%" data-part="{{.Key}}" aria-label="{{.Label}} {{.Amount}}"></button>{{end}}</div>
<div class="parts">
{{range $m.Parts}}<details class="part part-{{.Key}}" data-part="{{.Key}}">
<summary><span class="key"></span><span class="name">{{.Label}}</span><span class="amount">{{.Amount}}</span></summary>
<div class="body"><p class="explain">{{.Text}}</p>
<summary><span class="key"></span><span class="name">{{.Label}}</span><span class="amount">{{.Amount}}</span><span class="track" aria-hidden="true"><span class="fill" style="left:{{printf "%.2f" .From}}%;width:{{printf "%.2f" .Width}}%"></span></span></summary>
<div class="body">{{with .Text}}<p class="explain">{{.}}</p>{{end}}
{{if .Lines}}<ul class="lines">{{range .Lines}}{{template "line" .}}{{end}}</ul>{{end}}</div>
</details>
{{end}}</div>
Expand Down
Loading
Loading