Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions crates/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,18 @@ After setup is complete, you should have the following files in `$DATA_PATH/klir
1. `vendor.ron`
1. `client.ron`
1. `invoice_info.ron`
1. `payment.ron`
1. `service_fees.ron`
1. `payment.ron` — bank info, currency, payment terms, **and your VAT rate** (default `0%`).
1. `service_fees.ron` — service name, billing cadence, and unit price **excluding VAT**.
1. `expenses.ron`

> [!IMPORTANT]
> **Service unit prices are stored VAT-exclusive.** Klirr keeps your service
> rate (`service_fees.ron`) and any VAT rate (`payment.ron`) as separate
> values. The rendered invoice computes the subtotal from the VAT-exclusive
> rate, then adds VAT on top to produce the grand total. If your VAT is `0%`
> the VAT row is omitted entirely. Never bake VAT into the unit price — set it
> explicitly via `payment.ron` (or the TUI prompt) instead.

These files use [`RON` ("Rusty Object Notation")][ron] file format, a modern object notation superior to JSON/YAML/TOML.

## Edit Data<a href="#edit-data" id="edit-data"/>[ ^](#thetoc)
Expand Down
14 changes: 12 additions & 2 deletions crates/cli/src/input/tui/helpers/build_payment_info.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use inquire::{CustomType, Text, error::InquireResult};

use crate::{Currency, InvoiceDataFromTuiError, PaymentInformation, PaymentTerms, Result};
use crate::{Currency, InvoiceDataFromTuiError, PaymentInformation, PaymentTerms, Result, Vat};

pub fn build_payment_info(default: &PaymentInformation) -> Result<PaymentInformation> {
fn inner(default: &PaymentInformation) -> InquireResult<PaymentInformation> {
Expand All @@ -26,13 +26,23 @@ pub fn build_payment_info(default: &PaymentInformation) -> Result<PaymentInforma
.with_default(PaymentTerms::net30())
.prompt()?;

let vat = CustomType::<Vat>::new("VAT percentage?")
.with_help_message(
"Value-added tax rate added on top of the VAT-exclusive subtotal \
(your service unit price excludes VAT), e.g. '25' for 25%. \
Enter '0' to omit the VAT row entirely.",
)
.with_default(*default.vat())
.prompt()?;

let payment_info = default
.clone()
.with_bank_name(bank_name)
.with_iban(iban)
.with_bic(bic)
.with_currency(currency)
.with_terms(payment_terms);
.with_terms(payment_terms)
.with_vat(vat);

Ok(payment_info)
}
Expand Down
4 changes: 2 additions & 2 deletions crates/cli/src/input/tui/helpers/build_service_fees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ pub fn build_service_fees(default: &ServiceFees) -> Result<ServiceFees> {
.with_default(default.rate().granularity())
.prompt()?;

let unit_price = CustomType::<UnitPrice>::new("Unit price?")
let unit_price = CustomType::<UnitPrice>::new("Unit price (excl. VAT)?")
.with_help_message(&format!(
"Price per {}, e.g. {}",
"Price per {}, excluding VAT (VAT is configured separately on payment info), e.g. {}",
granularity,
granularity.example_rate()
))
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub(crate) use klirr_core_invoice::{
Path, PathBuf, PaymentInformation, PaymentTerms, PostalAddress, ProtoInvoiceInfo,
PurchaseOrder, Quantity, Rate, RelativeTime, ResultExt, Salt, Select, ServiceFees, SmtpServer,
StreetAddress, Template, TemplatePart, TimeOff, TimestampedInvoiceNumber, UnitPrice,
ValidInput, Year, client_path, create_invoice_pdf_with_data, curry1, curry2, data_dir,
ValidInput, Vat, Year, client_path, create_invoice_pdf_with_data, curry1, curry2, data_dir,
data_dir_create_if, edit_data_at, edit_email_data_at, email_settings_path,
expensed_periods_path, init_data_at, init_email_data_at,
load_email_data_and_send_test_email_at, payment_info_path, period_end_from_relative_time,
Expand Down
29 changes: 23 additions & 6 deletions crates/core-invoice/layouts/aioo.typ
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,14 @@

// ** Invoice Items Table **
double-line()
// Calculate total in a scripting block
let grand_total
{
grand_total = 0.0
for it in data.line_items.items { grand_total = grand_total + it.total_cost }
}
// Calculate subtotal, VAT amount, and grand total.
// When VAT is 0% the VAT and subtotal rows are suppressed and the grand
// total equals the subtotal.
let subtotal = 0.0
for it in data.line_items.items { subtotal = subtotal + it.total_cost }
let vat_percent = if "vat" in data.payment_info { data.payment_info.vat } else { 0 }
let vat_amount = subtotal * vat_percent / 100
let grand_total = subtotal + vat_amount
v(-10pt)
table(
columns: (auto, auto, 1fr, auto, auto),
Expand All @@ -154,6 +156,21 @@
)
},
)
// Subtotal + VAT rows shown only when VAT > 0%.
if vat_percent > 0 {
align(right)[
#set text(weight: "bold")
#l10n.line_items.subtotal
#format_amount(subtotal, data.payment_info.currency)
]
v(-5pt)
align(right)[
#set text(weight: "bold")
#l10n.line_items.vat #str(vat_percent)%
#format_amount(vat_amount, data.payment_info.currency)
]
v(-5pt)
}
// Grand Total Row
align(right)[
#set text(weight: "bold")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::{Currency, HasSample, PaymentTerms};
use bon::Builder;
use getset::Getters;
use getset::WithSetters;
use klirr_foundation::Vat;
use serde::Deserialize;
use serde::Serialize;

Expand Down Expand Up @@ -31,6 +32,19 @@ pub struct PaymentInformation {
/// The payment terms of this invoice, e.g. `Net { due_in: 30 }`
#[getset(get = "pub", set_with = "pub")]
terms: PaymentTerms,

/// VAT rate applied **on top of** the VAT-exclusive invoice subtotal.
///
/// The subtotal is the sum of each line item's `quantity * unit_price`
/// where unit prices are stored VAT-exclusive (see [`crate::ServiceFees`]
/// and [`klirr_foundation::UnitPrice`]). VAT is computed as
/// `subtotal * percent / 100` and added to the subtotal to produce the
/// grand total. When `0%` (the default), no VAT row is rendered and the
/// grand total equals the subtotal.
#[builder(default)]
#[serde(default)]
#[getset(get = "pub", set_with = "pub")]
vat: Vat,
}

impl HasSample for PaymentInformation {
Expand All @@ -41,6 +55,7 @@ impl HasSample for PaymentInformation {
.bic("BNPAFRPP".into())
.currency(Currency::EUR)
.terms(PaymentTerms::sample())
.vat(Vat::ZERO)
.build()
}

Expand All @@ -51,6 +66,7 @@ impl HasSample for PaymentInformation {
.bic("NWBKGB2L".into())
.currency(Currency::USD)
.terms(PaymentTerms::sample_other())
.vat(Vat::sample())
.build()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ use serde::Serialize;

/// Represents the fees for a consulting service, including the name, rate,
/// and billing cadence.
///
/// **The `rate` is VAT-exclusive.** Any VAT is configured separately on
/// [`crate::PaymentInformation::vat`] and applied to the resulting subtotal at
/// render time, never embedded in the unit price stored here.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Getters, WithSetters)]
pub struct ServiceFees {
/// Description of the consulting service, e.g. `"Agreed Consulting Fees"`
#[getset(get = "pub", set_with = "pub")]
name: String,

/// The invoice rate
/// The invoice rate, **excluding VAT**. VAT — if any — is configured on
/// [`crate::PaymentInformation::vat`] and added on top of the computed
/// subtotal when the invoice is rendered.
#[getset(get = "pub", set_with = "pub")]
rate: Rate,

Expand Down
28 changes: 28 additions & 0 deletions crates/core-invoice/src/models/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,24 @@ pub enum Error {
invalid_string: String,
},

/// VAT percentage was outside the allowed `[0, 100]` range.
#[error("Invalid VAT percentage: {percent}, reason: {reason}")]
InvalidVatPercentage {
/// The offending percentage value (as `f64` for diagnostics).
percent: f64,
/// Human-readable reason the value was rejected.
reason: String,
},

/// Failed to parse a VAT percentage from a string.
#[error("Failed to parse VAT percentage from {invalid_string:?}: {reason}")]
InvalidVatPercentageFromString {
/// String that failed to parse as a VAT percentage.
invalid_string: String,
/// Underlying reason from the parser or validator.
reason: String,
},

/// Failed to parse a date, e.g. when the format is incorrect or the date is invalid.
#[error("Failed to parse date, because: {underlying}")]
FailedToParseDate {
Expand Down Expand Up @@ -513,6 +531,16 @@ impl From<klirr_foundation::ModelError> for Error {
klirr_foundation::ModelError::InvalidDate { underlying } => {
Self::InvalidDate { underlying }
}
klirr_foundation::ModelError::InvalidVatPercentage { percent, reason } => {
Self::InvalidVatPercentage { percent, reason }
}
klirr_foundation::ModelError::InvalidVatPercentageFromString {
invalid_string,
reason,
} => Self::InvalidVatPercentageFromString {
invalid_string,
reason,
},
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions crates/core-invoice/src/models/l10n/line_items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ pub struct L10nLineItems {
#[getset(get = "pub")]
total_cost: String,

/// EN: "Subtotal:" — printed above the VAT row when VAT > 0%.
#[getset(get = "pub")]
subtotal: String,

/// EN: "VAT" — label for the value-added tax row, hidden when VAT is 0%.
#[getset(get = "pub")]
vat: String,

/// EN: "Grand Total:"
#[getset(get = "pub")]
grand_total: String,
Expand All @@ -40,6 +48,8 @@ impl L10nLineItems {
.quantity("Quantity".to_string())
.unit_price("Unit price".to_string())
.total_cost("Total cost".to_string())
.subtotal("Subtotal:".to_string())
.vat("VAT".to_string())
.grand_total("Grand Total:".to_string())
.build()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ L10n(
quantity: "Quantity",
unit_price: "Unit price",
total_cost: "Total cost",
subtotal: "Subtotal:",
vat: "VAT",
grand_total: "Grand Total:",
),
month_names: ("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ L10n(
quantity: "Antal",
unit_price: "Enhetspris",
total_cost: "Kostnad",
subtotal: "Delsumma:",
vat: "Moms",
grand_total: "Totalt:",
),
month_names: ("Januari", "Februari", "Mars", "April", "Maj", "June", "July", "Augusti", "September", "October", "November", "December"),
Expand Down
2 changes: 2 additions & 0 deletions crates/core-invoice/src/models/l10n/swedish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ impl L10nLineItems {
.quantity("Antal".to_string())
.unit_price("Enhetspris".to_string())
.total_cost("Kostnad".to_string())
.subtotal("Delsumma:".to_string())
.vat("Moms".to_string())
.grand_total("Totalt:".to_string())
.build()
}
Expand Down
2 changes: 1 addition & 1 deletion crates/core-invoice/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pub use klirr_foundation::HasSample;
pub use klirr_foundation::OutputPath;
pub use klirr_foundation::{
CompanyInformation, Cost, Date, Day, Decimal, HexColor, Month, MonthHalf, PostalAddress,
Quantity, Rate, RelativeTime, StreetAddress, UnitPrice, Year,
Quantity, Rate, RelativeTime, StreetAddress, UnitPrice, Vat, Year,
};
pub use l10n::*;
pub use layout::*;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
source: crates/core-invoice/tests/typst_conversion.rs
assertion_line: 35
expression: typst
---
#let provide() = {
Expand Down Expand Up @@ -50,6 +49,7 @@ expression: typst
currency: "EUR",
iban: "FR76 3000 6000 0112 3456 7890 189",
terms: "Net 30",
vat: 0.0,
),
vendor: (
company_name: "Lupin et Associés",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
source: crates/core-invoice/tests/typst_conversion.rs
assertion_line: 46
expression: typst
---
#let provide() = {
Expand Down Expand Up @@ -50,6 +49,7 @@ expression: typst
currency: "EUR",
iban: "FR76 3000 6000 0112 3456 7890 189",
terms: "Net 30",
vat: 0.0,
),
vendor: (
company_name: "Lupin et Associés",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
source: crates/core-invoice/tests/typst_conversion.rs
expression: typst
---
#let provide() = {
(
client: (
company_name: "Holmes Ltd",
contact_person: "Sherlock Holmes",
organisation_number: "9876543-2101",
postal_address: (
city: "London",
country: "England",
street_address: (
line_1: "221B Baker Street",
line_2: "",
),
zip: "NW1 6XE",
),
vat_number: "GB987654321",
),
information: (
due_date: "2025-06-30",
emphasize_color_hex: "#8b008b",
footer_text: "Billed with the utmost discretion—your secrets are safe, for a price.",
invoice_date: "2025-05-31",
number: 22,
purchase_order: "PO-12345",
),
line_items: (
is_expenses: false,
items: (
(
currency: "EUR",
name: "Discreet Investigative Services",
quantity: 22.0,
total_cost: 17094.0,
transaction_date: "2025-05-31",
unit_price: 777.0,
),
),
),
output_path: (
name: "2025-05-31_Lupin_et_Associés_invoice_22.pdf",
),
payment_info: (
bank_name: "Banque de Paris",
bic: "BNPAFRPP",
currency: "EUR",
iban: "FR76 3000 6000 0112 3456 7890 189",
terms: "Net 30",
vat: 25.0,
),
vendor: (
company_name: "Lupin et Associés",
contact_person: "Arsène Lupin",
organisation_number: "7418529-3012",
postal_address: (
city: "Paris",
country: "France",
street_address: (
line_1: "5 Avenue Henri-Martin",
line_2: "Appartement 24",
),
zip: "75116",
),
vat_number: "FR74185293012",
),
)
}
Loading
Loading