feat: customizable VAT, payment-method overrides, and template polish#46
Conversation
Add a per-invoice VAT rate (configured on `PaymentInformation`) that is rendered as a row at the end of the Typst items table and included in the grand total. When the rate is `0%` (the default) the VAT row is omitted entirely and grand total equals subtotal — preserving existing output. Service unit prices remain VAT-exclusive; this is now reinforced in the TUI prompts, README, and rustdoc on `Vat`, `UnitPrice`, `Cost`, `Rate`, `ServiceFees`, and `PaymentInformation::vat`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds per-invoice VAT support by introducing a validated Vat model, wiring it through invoice data + CLI input, and updating the Typst layout to conditionally render VAT/subtotal rows and compute grand total accordingly.
Changes:
- Introduce
Vatnewtype (0–100%) with parsing/serde support, errors, and tests. - Add
vattoPaymentInformation(serde/builder default) and propagate it into Typst data + layout computations and l10n strings. - Update TUI prompts + docs to clarify that service rates/unit prices are VAT-exclusive; add render/typst tests for VAT=0 and VAT>0.
Reviewed changes
Copilot reviewed 28 out of 29 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/render-typst/src/render.rs | Adds end-to-end render tests to ensure Typst compilation/rendering works with VAT enabled/disabled. |
| crates/render-typst/Cargo.toml | Adds rust_decimal dev-dependency for VAT test construction. |
| crates/foundation/src/models/vat.rs | Implements validated Vat newtype with parsing, display, serde, and unit tests. |
| crates/foundation/src/models/unit_price.rs | Documents that UnitPrice is VAT-exclusive. |
| crates/foundation/src/models/snapshots/klirr_foundation__models__vat__tests__serde.snap | Adds snapshot for Vat serde representation. |
| crates/foundation/src/models/rate.rs | Documents that Rate is VAT-exclusive and VAT is applied at render time. |
| crates/foundation/src/models/model_error.rs | Adds VAT validation/parsing error variants. |
| crates/foundation/src/models/mod.rs | Registers and re-exports the new vat model module. |
| crates/foundation/src/models/cost.rs | Documents that Cost is VAT-exclusive (VAT added on top at render time). |
| crates/foundation/src/lib.rs | Re-exports Vat from the foundation crate public API. |
| crates/core-invoice/tests/typst_conversion.rs | Adds snapshot test verifying vat is present in Typst data dict when configured. |
| crates/core-invoice/tests/snapshots/typst_conversion__l10n_english_to_typst.snap | Updates English l10n snapshot for new subtotal/vat strings. |
| crates/core-invoice/tests/snapshots/typst_conversion__data_services_with_vat_to_typst.snap | Adds snapshot showing payment_info.vat propagates to Typst data. |
| crates/core-invoice/tests/snapshots/typst_conversion__data_services_to_typst.snap | Updates existing snapshot to include default vat: 0.0. |
| crates/core-invoice/tests/snapshots/typst_conversion__data_expenses_to_typst.snap | Updates existing snapshot to include default vat: 0.0. |
| crates/core-invoice/src/models/mod.rs | Re-exports Vat from klirr_foundation through klirr_core_invoice. |
| crates/core-invoice/src/models/l10n/swedish.rs | Adds Swedish strings for subtotal and vat. |
| crates/core-invoice/src/models/l10n/snapshots/klirr_core_invoice__models__l10n__localization__tests__l10n_swedish.snap | Updates Swedish l10n snapshot with new fields. |
| crates/core-invoice/src/models/l10n/snapshots/klirr_core_invoice__models__l10n__localization__tests__l10n_english.snap | Updates English l10n snapshot with new fields. |
| crates/core-invoice/src/models/l10n/line_items.rs | Extends L10nLineItems with subtotal and vat labels. |
| crates/core-invoice/src/models/error.rs | Maps new foundation VAT errors into core-invoice error variants. |
| crates/core-invoice/src/models/data/submodels/service_fees.rs | Documents that stored service rates are VAT-exclusive. |
| crates/core-invoice/src/models/data/submodels/payment_information.rs | Adds vat: Vat with serde/builder defaults and documentation. |
| crates/core-invoice/layouts/aioo.typ | Computes subtotal/VAT/grand total and conditionally renders subtotal+VAT rows when VAT>0. |
| crates/cli/src/main.rs | Re-exports Vat into the CLI crate’s import surface. |
| crates/cli/src/input/tui/helpers/build_service_fees.rs | Updates unit price prompt text/help to emphasize VAT-exclusive pricing. |
| crates/cli/src/input/tui/helpers/build_payment_info.rs | Adds VAT percentage prompt to payment-info builder. |
| crates/cli/README.md | Documents VAT location (payment.ron) and the VAT-exclusive unit price contract with an IMPORTANT callout. |
| Cargo.lock | Records dependency graph update (adds rust_decimal for render-typst dev usage). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Suppress warning about Layout being unused if compilation order shifts. | ||
| let _: Layout = layout; | ||
| } |
| dict.contains("vat: 25.0"), | ||
| "expected vat: 25.0 in dict, got: {dict}" |
| // Compile the layout — the Layout enum's ToTypstFn must succeed. | ||
| let layout_fn = layout.to_typst_fn(); | ||
| assert!( | ||
| layout_fn.contains("payment_info"), | ||
| "layout should reference payment_info" | ||
| ); |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #46 +/- ##
==========================================
+ Coverage 96.84% 97.07% +0.22%
==========================================
Files 102 104 +2
Lines 2218 2287 +69
==========================================
+ Hits 2148 2220 +72
+ Misses 70 67 -3 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Replace fragile string-substring assertions on the serialized Typst dict and layout source with a direct structural check on `prepared.payment_info().vat()`. Drop the redundant `Layout` round-trip and unused `TYPST_LAYOUT_FOUNDATION` / `ToTypstFn` imports — the actual `render(...)` call already proves Typst compiles and renders, and the propagation check now verifies the value made it through unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 29 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov flagged the patch as dropping coverage. Add tests for every line introduced in this PR that the existing suite did not exercise: - foundation::ModelError: Display formatting + Clone/PartialEq for both new VAT variants. - foundation::Vat: doc-tests on `from_percent`, `is_zero`, `percent`. - core-invoice::Error: extend `from_model_error_maps_all_variants` to cover the new VAT mappings (and the previously-uncovered InvalidHexColor branch); add Display tests for both new VAT variants. - core-invoice::L10nLineItems: getters for `subtotal` / `vat` in both English and Swedish, plus existing labels for parity. - core-invoice::PaymentInformation: assert default VAT on `sample()`, non-zero VAT on `sample_other()`, `with_vat`, `#[serde(default)]` for legacy RON files without the field, and `#[builder(default)]`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`klirr sample` now applies a 25% VAT to the sample payment info before rendering, so the example asset shipped with the README demonstrates the full subtotal + VAT + grand-total flow this PR introduces. The canonical `Data::sample()` is unchanged — VAT is layered on top inside `render_invoice_sample_with_nonce`, which keeps every existing test fixture and snapshot stable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the items table contains exactly one row, its "Total cost" already equals the subtotal — printing a separate "Subtotal:" line is just visual noise (and matches what Fortnox/Visma/Bokio/Stripe do). The row is still rendered for multi-line invoices, where the eye can't sum the column at a glance. Adds an `expenses_with_vat_renders_without_error` test that exercises the multi-line branch end-to-end so the kept-row path stays covered, and refreshes the README example.jpg to show the new single-line service layout (VAT 25% + Grand Total, no Subtotal). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add `LabeledField` (label/value, neither validated) and `PaymentInformation::payment_method_overrides: Vec<LabeledField>` (max 2, defaulted empty for serde back-compat). The Typst layout's middle column now renders Bank Name on top, then two slots that default to IBAN and BIC; the override list fills those slots bottom-up: 0 overrides -> Bank, IBAN, BIC 1 override -> Bank, IBAN, override 2 overrides -> Bank, override[0], override[1] `iban` and `bic` stay in the data model so they can still be persisted or sent over the wire — they are only suppressed at render time when overridden. Includes a foundation fix: the JSON->Typst converter previously emitted `(\n,\n)` for empty arrays and objects, which Typst rejects as `unexpected comma`. Empty arrays now render as `()` and empty objects as `(:)`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The footer's three-column block previously used `(1fr, auto, auto)`, which let the first column hog all leftover row width and slammed the middle and right columns flush against the right margin — producing a visible asymmetric gap. Switch to `(auto, 1fr, auto, 1fr, auto)`: each `auto` content column shrinks to its widest cell, and the two `1fr` spacer columns absorb the leftover row width and split it 50/50. Effectively: gutter = (page.width − margins − Σ auto_widths) / 2 so the gap between Address↔Bank equals the gap between Bank↔Org. No. without any explicit width math in the layout. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds `<PO>` to the email template language so subject/body strings can embed the invoice's purchase order. Materialises to the purchase-order string when one is set on the invoice; expands to the empty string when unset, so leaving the placeholder in a default template is harmless for invoices without a PO. Also extends the tutorial string and adds tests for both the present-PO and absent-PO branches. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 38 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| )); | ||
| } | ||
| self.information.validate()?; | ||
| Ok(self) | ||
| let payment_info = self.payment_info.clone().validate()?; | ||
| Ok(Self { | ||
| payment_info, | ||
| ..self | ||
| }) |
`Data::validate` consumes `self` already; `payment_info.validate()` also consumes self. Destructure so the field can be moved through validation without an intermediate clone — keeps the validation allocation-free even as `PaymentInformation` grows. Addresses copilot review feedback on PR #46. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the Bankgiro/Kontonummer placeholder samples (which mirrored real-looking Swedish payment routing) with locale-neutral fictional values so HasSample doesn't double as a leak vector for plausible payment data. Also updates the matching ron-deserialization test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Summary
This branch adds three user-facing features to invoice rendering and one set of polish/refactor changes:
(label, value)pairs (e.g. Bankgiro / Plusgiro / Kontonummer / Account Number).<PO>email-template placeholder — purchase order is now interpolatable insubject_format/body_format.Plus a layout polish (Subtotal row suppressed on single-line invoices, even gutters between footer columns) and a refactor.
What's in the diff
1. VAT
Vatnewtype inklirr-foundation(validated 0–100 %, parses both\"25\"and\"25%\", transparent serde, doc-tested).PaymentInformationgains avat: Vatfield (#[serde(default)]+#[builder(default)]for back-compat — existingpayment.ronfiles load unchanged).Subtotal → VAT XX% → Grand Totalwhen VAT > 0%; falls back to a singleGrand Totalrow when VAT = 0% (no behavioral change for existing users).2. Subtotal row suppression
3. Payment-method overrides
New
LabeledField { label, value }model — both fields free-form, neither validated.PaymentInformation::payment_method_overrides: Vec<LabeledField>capped at 2 (validated; newError::TooManyPaymentMethodOverrides).Slot mapping in the rendered footer middle column (top → bottom):
iban/bicstay in the data model (still persisted/sent over the wire) — they're only suppressed at render time when overridden.4. Email template
<PO>placeholderTemplatePartnow accepts<PO>in subject/body strings; expands to the purchase-order string when set, empty string when unset.5. Layout polish
(1fr, auto, auto)to(auto, 1fr, auto, 1fr, auto). Eachautocontent column shrinks to its widest cell; the two1frspacers absorb the leftover row width and split it 50/50, giving equal gutters between Address ↔ Bank and Bank ↔ Org. No.6. Refactor + supporting fix
Data::validatenow destructuresselfand movespayment_infothroughvalidate()instead of cloning (PR review feedback).to_typst_value(foundation): empty arrays/objects now serialize as()/(:)instead of(\\n,\\n)— Typst rejected the latter asunexpected comma. Required sopayment_method_overrides: []round-trips.Migration / back-compat
#[serde(default)]+#[builder(default)]. Existingpayment.ron/email.ronfiles deserialize unchanged.Data::sample()is unchanged; the README example uses VAT only insiderender_invoice_sample_with_nonce, so existing test fixtures and snapshots stay green.Test plan
cargo test --workspace— 494 tests passing.Vat,LabeledField,PaymentInformation(incl. legacy-RON round-trip), error mappings, l10n getters, and<PO>materialization (set + unset).klirr-render-typstcover all four VAT/override branches: VAT-0/VAT-25, single-line/multi-line, 1-override/2-overrides.cargo clippy --workspace --all-targets,cargo fmt --all— clean.klirr sampleand liveklirr invoice services-offruns against my production data.🤖 Generated with Claude Code