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

nordisk/monetize

crates/monetize-server/src/services/tax.rs

//! **`Tax.Periods` / `Tax.Report`** — the ledger as the jurisdiction's boxes.
//!
//! Both calls fold the WHOLE ledger (`monetize::Books::view`), which is a full
//! scan and is meant to be: a return covers a period that has closed, and the
//! bounded recent-sales walk every other read uses would silently drop the
//! oldest sales in it. A return is asked for a handful of times a year, by an
//! operator, on a blocking thread like every other ledger read here.
//!
//! The jurisdiction is whatever `Linked::tax` put in the state. With none
//! linked and none declared that is `monetize::PerCountry`: each buyer
//! country's totals, from the country, rate and tax every payment row carries.

use std::sync::Arc;

use monetize::tax::{Amount, Period};
use monetize_proto::tax_server::Tax;
use monetize_proto::{self as pb};
use tonic::{Request, Response, Status};

use super::{blocking, ledger_err};
use crate::convert::money_to_pb;
use crate::state::AppState;

pub struct TaxService(pub Arc<AppState>);

/// **Which year's turnover chooses the period.** The year BEFORE the one being
/// filed: a company's reporting frequency for 2026 follows what it turned over
/// in 2025, and a rule applied to the year in progress would change its own
/// answer every time a sale landed.
fn turnover_year(year: u16) -> u16 {
    year.saturating_sub(1)
}

/// A year a caller may ask about. Anything outside it is a typo or a probe, and
/// the period arithmetic below would happily produce twelve empty months for
/// year 0.
fn year_or_invalid(year: u32) -> Result<u16, Status> {
    match year {
        1970..=9999 => Ok(year as u16),
        _ => Err(Status::invalid_argument(format!(
            "{year} is not a year monetize can file for; 1970..=9999"
        ))),
    }
}

fn period_to_pb(p: &Period) -> pb::TaxPeriod {
    pb::TaxPeriod {
        label: p.label.clone(),
        kind: p.kind.name().into(),
        from_unix_ms: p.from_unix_ms,
        to_unix_ms: p.to_unix_ms,
    }
}

fn line_to_pb(l: &monetize::tax::Line) -> pb::TaxLine {
    pb::TaxLine {
        box_id: l.box_id.clone(),
        label: l.label.clone(),
        // Absent stays absent all the way to the browser: `—` is not 0.
        amount_minor: l.amount.map(|a: Amount| a.minor),
        currency: l
            .amount
            .map(|a| a.currency.code().to_owned())
            .unwrap_or_default(),
    }
}

/// **The ledger AND the paid signups**, as one view. A signup writes no ledger
/// row, and a return folded from the ledger alone declared none of them
/// (2026-09-15) — see [`crate::signup::tax_sales`].
fn tax_view(s: &AppState) -> Result<monetize::tax::LedgerView, Status> {
    let books = s.books.read().unwrap_or_else(|e| e.into_inner()).clone();
    let mut view = books.view(&s.ledger).map_err(ledger_err)?;
    view.sales.extend(
        crate::signup::tax_sales(&s.signups, &books)
            .map_err(|e| Status::internal(format!("signup store: {e}")))?,
    );
    Ok(view)
}

#[tonic::async_trait]
impl Tax for TaxService {
    async fn periods(
        &self,
        req: Request<pb::TaxPeriodsRequest>,
    ) -> Result<Response<pb::TaxPeriodsResponse>, Status> {
        let year = year_or_invalid(req.into_inner().year)?;
        blocking(&self.0, move |s| {
            let view = tax_view(s)?;
            // The turnover the rule is applied to, in the currency the
            // deployment prices in. A jurisdiction whose thresholds are in
            // another currency refuses inside `periods`, by name.
            let currency = s
                .terms
                .read()
                .unwrap_or_else(|e| e.into_inner())
                .default_market
                .currency();
            let turnover = view.turnover_of_year(currency, turnover_year(year));
            let periods = s.tax.periods(turnover, year);
            Ok(Response::new(pb::TaxPeriodsResponse {
                jurisdiction: s.tax.jurisdiction().to_owned(),
                periods: periods.iter().map(period_to_pb).collect(),
                turnover: Some(money_to_pb(turnover)),
                turnover_year: turnover_year(year) as u32,
            }))
        })
        .await
    }

    async fn report(
        &self,
        req: Request<pb::TaxReportRequest>,
    ) -> Result<Response<pb::TaxReportResponse>, Status> {
        let r = req.into_inner();
        let year = year_or_invalid(r.year)?;
        if r.period.is_empty() {
            return Err(Status::invalid_argument("report needs a period label from Tax.Periods; a return for the wrong period is worse than none"));
        }
        blocking(&self.0, move |s| {
            let view = tax_view(s)?;
            let currency = s
                .terms
                .read()
                .unwrap_or_else(|e| e.into_inner())
                .default_market
                .currency();
            let turnover = view.turnover_of_year(currency, turnover_year(year));
            // THE PERIOD IS RESOLVED FROM THE JURISDICTION'S OWN LIST, never
            // parsed out of the label. A label the deployment does not file is
            // refused by name — `2026-Q3` on a monthly filer is not a period
            // that exists, and reporting it as if it did would produce a figure
            // for a form nobody sends.
            let offered = s.tax.periods(turnover, year);
            let period = offered
                .iter()
                .find(|p| p.label == r.period)
                .ok_or_else(|| {
                    Status::not_found(format!(
                        "{:?} is not a period {} files for {year}; it files {:?}",
                        r.period,
                        s.tax.jurisdiction(),
                        offered.iter().map(|p| p.label.as_str()).collect::<Vec<_>>()
                    ))
                })?;
            let report = s
                .tax
                .report(&view, period)
                .map_err(|e| Status::failed_precondition(e.to_string()))?;
            Ok(Response::new(pb::TaxReportResponse {
                jurisdiction: report.jurisdiction.clone(),
                period: Some(period_to_pb(&report.period)),
                lines: report.lines.iter().map(line_to_pb).collect(),
                notes: report.notes.clone(),
                // One writer for the table and the file: the page shows
                // `lines`, the accountant gets this, and they are the same fold.
                csv: report.to_csv(),
            }))
        })
        .await
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_period_rule_looks_at_the_year_before_the_one_being_filed() {
        assert_eq!(turnover_year(2026), 2025);
        assert_eq!(
            turnover_year(0),
            0,
            "saturating, so no year underflows into 65535"
        );
    }

    #[test]
    fn a_year_outside_the_window_is_refused_rather_than_filed_for() {
        assert_eq!(year_or_invalid(2026).unwrap(), 2026);
        assert!(year_or_invalid(0).is_err());
        assert!(year_or_invalid(10_000).is_err());
    }

    /// The wire keeps `—` as absent. A line that arrived as 0 would read as a
    /// figure somebody computed.
    #[test]
    fn an_uncomputed_box_travels_as_absent_and_not_as_zero() {
        let absent = line_to_pb(&monetize::tax::Line::absent("49", "to pay or get back"));
        assert_eq!(absent.amount_minor, None);
        assert_eq!(absent.currency, "");
        let zero = line_to_pb(&monetize::tax::Line::new(
            "49",
            "to pay or get back",
            Amount::zero(monetize_pay::Currency::Sek),
        ));
        assert_eq!(zero.amount_minor, Some(0));
        assert_eq!(zero.currency, "SEK");
    }

    #[test]
    fn a_period_travels_with_its_kind_as_a_word() {
        let p = monetize::tax::PeriodKind::Quarterly.periods(2026).remove(2);
        let wire = period_to_pb(&p);
        assert_eq!(
            (wire.label.as_str(), wire.kind.as_str()),
            ("2026-Q3", "quarterly")
        );
        assert_eq!(
            wire.to_unix_ms,
            monetize_pay::time::ymd_to_unix_ms(2026, 10, 1).unwrap()
        );
    }
}