Skip to content

feat: customizable VAT, payment-method overrides, and template polish#46

Merged
Sajjon merged 10 commits into
mainfrom
feat/vat-customization
May 6, 2026
Merged

feat: customizable VAT, payment-method overrides, and template polish#46
Sajjon merged 10 commits into
mainfrom
feat/vat-customization

Conversation

@Sajjon

@Sajjon Sajjon commented May 4, 2026

Copy link
Copy Markdown
Owner

Summary

This branch adds three user-facing features to invoice rendering and one set of polish/refactor changes:

  1. Customizable VAT rate — invoices can now declare a per-payment VAT percentage that's added on top of the VAT-exclusive subtotal at render time.
  2. Payment-method overrides — vendors can replace the IBAN and/or BIC rows in the footer with arbitrary (label, value) pairs (e.g. Bankgiro / Plusgiro / Kontonummer / Account Number).
  3. <PO> email-template placeholder — purchase order is now interpolatable in subject_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

  • New Vat newtype in klirr-foundation (validated 0–100 %, parses both \"25\" and \"25%\", transparent serde, doc-tested).
  • PaymentInformation gains a vat: Vat field (#[serde(default)] + #[builder(default)] for back-compat — existing payment.ron files load unchanged).
  • Localized labels: EN "Subtotal:" / "VAT", SV "Delsumma:" / "Moms".
  • Typst layout renders Subtotal → VAT XX% → Grand Total when VAT > 0%; falls back to a single Grand Total row when VAT = 0% (no behavioral change for existing users).
  • TUI: new "VAT percentage?" prompt; service unit-price prompt + rustdoc + README all explicitly state that service rates are VAT-exclusive.

2. Subtotal row suppression

  • When the items table contains exactly one row (typical for service invoices), the Subtotal line is omitted — its "Total cost" already equals the subtotal. Multi-line expense invoices keep the Subtotal row.

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; new Error::TooManyPaymentMethodOverrides).

  • Slot mapping in the rendered footer middle column (top → bottom):

    overrides Slot 1 Slot 2 (IBAN slot) Slot 3 (BIC slot)
    0 Bank IBAN BIC
    1 Bank IBAN overrides[0]
    2 Bank overrides[0] overrides[1]
  • iban / bic stay in the data model (still persisted/sent over the wire) — they're only suppressed at render time when overridden.

4. Email template <PO> placeholder

  • TemplatePart now accepts <PO> in subject/body strings; expands to the purchase-order string when set, empty string when unset.
  • Tutorial string and unit tests updated to cover both branches.

5. Layout polish

  • Footer's three-column block switched from (1fr, auto, auto) to (auto, 1fr, auto, 1fr, auto). Each auto content column shrinks to its widest cell; the two 1fr spacers absorb the leftover row width and split it 50/50, giving equal gutters between Address ↔ Bank and Bank ↔ Org. No.
  • Middle column reordered: Bank Name on top (was: IBAN on top).

6. Refactor + supporting fix

  • Data::validate now destructures self and moves payment_info through validate() instead of cloning (PR review feedback).
  • to_typst_value (foundation): empty arrays/objects now serialize as () / (:) instead of (\\n,\\n) — Typst rejected the latter as unexpected comma. Required so payment_method_overrides: [] round-trips.
  • README example.jpg regenerated to demonstrate the new VAT row + suppressed Subtotal layout.

Migration / back-compat

  • All new fields use #[serde(default)] + #[builder(default)]. Existing payment.ron / email.ron files deserialize unchanged.
  • Data::sample() is unchanged; the README example uses VAT only inside render_invoice_sample_with_nonce, so existing test fixtures and snapshots stay green.

Test plan

  • cargo test --workspace — 494 tests passing.
  • Patch-coverage tests for Vat, LabeledField, PaymentInformation (incl. legacy-RON round-trip), error mappings, l10n getters, and <PO> materialization (set + unset).
  • End-to-end render tests in klirr-render-typst cover all four VAT/override branches: VAT-0/VAT-25, single-line/multi-line, 1-override/2-overrides.
  • Snapshot tests updated for English + Swedish localizations and Typst-dict serialization.
  • cargo clippy --workspace --all-targets, cargo fmt --all — clean.
  • Manually validated end-to-end via klirr sample and live klirr invoice services-off runs against my production data.

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Vat newtype (0–100%) with parsing/serde support, errors, and tests.
  • Add vat to PaymentInformation (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.

Comment thread crates/render-typst/src/render.rs Outdated
Comment on lines +179 to +181
// Suppress warning about Layout being unused if compilation order shifts.
let _: Layout = layout;
}
Comment thread crates/render-typst/src/render.rs Outdated
Comment on lines +155 to +156
dict.contains("vat: 25.0"),
"expected vat: 25.0 in dict, got: {dict}"
Comment thread crates/render-typst/src/render.rs Outdated
Comment on lines +162 to +167
// 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

codecov Bot commented May 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.07%. Comparing base (af22eba) to head (15577ea).
⚠️ Report is 2 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sajjon and others added 6 commits May 5, 2026 06:52
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 83 to +90
));
}
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>
@Sajjon Sajjon changed the title feat: customizable VAT rate on invoices feat: customizable VAT, payment-method overrides, and template polish May 6, 2026
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>
@Sajjon Sajjon merged commit b9c0ca6 into main May 6, 2026
5 checks passed
@Sajjon Sajjon deleted the feat/vat-customization branch May 6, 2026 05:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants