Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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()
        })}
    }
}