crates/monetize-ui-web/src/pages/tax.rs
//! **Tax** — the ledger per buyer country (owner 2026-10-11: Stripe decides
//! per country, each payment is a row: `monetize::PerCountry`), or as a
//! declared jurisdiction's boxes, for the period an operator picks.
//!
//! One page and one shape: pick a year, pick a period, read the table. Box ·
//! label · amount, in the authority's own numbering and the authority's own
//! words, with a CSV of exactly those lines to hand an accountant. Datasheet
//! tone: no rhetoric, no advice, no "you owe" — the figures and what the
//! report could not do.
//!
//! **A `—` is not a zero and this page never blurs the two** (`fmt::tax_amount`).
//! An authority that wants a 0 in a box means the digit; a box monetize could
//! not fill is a box nobody computed, and with no jurisdiction linked every box
//! is one. That is why the default deployment's Tax page lists what a return is
//! folded from and fills nothing in, instead of showing an empty screen.
//!
//! The download carries monetize's OWN csv bytes, not a second rendering of the
//! table beside it — one writer, so the file and the screen cannot disagree.
//!
//! Decisions: `fmt::tax_amount`, `tax_schedule_line`, `tax_period_label`,
//! `tax_csv_filename`, `tax_csv_href`, all tested on the host. This lays them
//! out.
use leptos::prelude::*;
use crate::fmt::{
tax_amount, tax_csv_filename, tax_csv_href, tax_default_period, tax_period_label,
tax_schedule_line,
};
use crate::pages::{now_ms, TaxPeriodsJson, TaxReportJson};
use crate::ui::{resolved, Empty, PageHeader, Panel};
/// The year the page opens on: the current one, from the browser's clock. A
/// return is filed for a period that has closed, so the year in progress is
/// where an operator starts and steps back from.
fn this_year() -> u32 {
// Days since the epoch, over the mean Gregorian year. Good enough to CHOOSE
// a default in a picker; every figure on the page comes from the server,
// which does real calendar arithmetic.
let days = now_ms() / 86_400_000;
1970 + (days as f64 / 365.2425) as u32
}
#[component]
pub fn TaxPage() -> impl IntoView {
let year = RwSignal::new(this_year());
let period = RwSignal::new(String::new());
let periods = LocalResource::new(move || {
let y = year.get();
async move { crate::api::get::<TaxPeriodsJson>(&format!("/api/tax/periods?year={y}")).await }
});
let report = LocalResource::new(move || {
let (y, p) = (year.get(), period.get());
async move {
if p.is_empty() {
return Ok(None);
}
crate::api::get::<TaxReportJson>(&format!(
"/api/tax/report?year={y}&period={}",
crate::fmt::percent_encode(&p)
))
.await
.map(Some)
}
});
view! {
<PageHeader title=format!("{} · Tax", crate::api::estate().unwrap_or_default()) subtitle="Tax owed per country, as each payment recorded it. Nothing is filed for you: these are the figures to enter.">
<button class="btn quiet" on:click=move |_| { year.update(|y| *y -= 1); period.set(String::new()); }>"←"</button>
<span class="badge plain mono">{move || year.get().to_string()}</span>
<button class="btn quiet" on:click=move |_| { year.update(|y| *y += 1); period.set(String::new()); }>"→"</button>
</PageHeader>
{move || resolved(periods.get(), None, move |p: TaxPeriodsJson| {
if p.periods.is_empty() {
return view! { <Empty title="No periods" detail="Tax figures are not available."/> }.into_any();
}
let kind = p.periods.first().map(|x| x.kind.clone()).unwrap_or_default();
let schedule = tax_schedule_line(&p.jurisdiction, &kind, p.turnover_minor, &p.currency, p.turnover_year);
// The period containing TODAY is selected on arrival (the first
// one in any other year), so the page shows the quarter being
// sold in rather than an empty Q1. Another is one click away.
if period.get_untracked().is_empty() {
if let Some(label) = tax_default_period(&p.periods, now_ms()) {
period.set(label);
}
}
view! {
<div class="stack">
<footer class="row hint" aria-label="Filing schedule">
<span class="mono" title="the turnover the jurisdiction's period rule was applied to">{schedule}</span>
</footer>
<nav class="nav-group" aria-label="Period">
{p.periods.into_iter().map(|x| {
let label = tax_period_label(&x);
let (for_class, for_aria, pick) = (x.label.clone(), x.label.clone(), x.label.clone());
view! {
<button
class=move || if period.get() == for_class { "btn" } else { "btn quiet" }
aria-pressed=move || (period.get() == for_aria).to_string()
on:click=move |_| period.set(pick.clone())
>{label}</button>
}
}).collect_view()}
</nav>
</div>
}.into_any()
})}
{move || resolved(report.get(), None, |r: Option<TaxReportJson>| {
let Some(r) = r else {
return view! { <Empty title="Pick a period" detail="Choose one of the periods above to see the boxes for it."/> }.into_any();
};
let filename = tax_csv_filename(&r);
let href = tax_csv_href(&r.csv);
let title = format!("{} · {}", r.jurisdiction, r.period.as_ref().map(|p| p.label.clone()).unwrap_or_default());
let notes = r.notes.clone();
view! {
<Panel title=title>
<table class="rows" aria-label="Tax return boxes">
<thead><tr><th scope="col">"Box"</th><th scope="col">"Label"</th><th scope="col" class="num">"Amount"</th></tr></thead>
<tbody>
{r.lines.iter().map(|l| view! {
<tr>
<td class="mono">{l.box_id.clone()}</td>
<td>{l.label.clone()}</td>
<td class="mono num">{tax_amount(l)}</td>
</tr>
}).collect_view()}
</tbody>
</table>
{(!notes.is_empty()).then(|| view! {
<ul class="hint" aria-label="What this report could not do">
{notes.into_iter().map(|n| view! { <li>{n}</li> }).collect_view()}
</ul>
})}
<footer class="row">
<a class="btn quiet" href=href download=filename.clone()>{format!("Download {filename}")}</a>
</footer>
</Panel>
}.into_any()
})}
}
}