currency per column in table definition

This commit is contained in:
Priec
2026-08-03 16:10:50 +02:00
parent 050f93e1fd
commit 0e6d764702
3 changed files with 71 additions and 13 deletions

2
client

Submodule client updated: e3eef67d20...d76107b4c4

View File

@@ -60,6 +60,16 @@ const CANONICAL_TYPES: &[&str] = &[
"bigint",
];
/// Whether a column of this type declares a currency.
///
/// Both MONEY and ACCOUNTING do, which is why this is a named predicate rather
/// than an inline comparison: written by hand, the ACCOUNTING half is easy to
/// forget, and forgetting it is silent — the currency is still stored and sent,
/// it just stops being validated or displayed.
pub(crate) fn carries_currency(field_type: &str) -> bool {
field_type.eq_ignore_ascii_case("money") || field_type.eq_ignore_ascii_case("accounting")
}
/// Types a quantity-ledger column may use.
fn quantity_ledger_type_allowed(field_type: &str) -> bool {
matches!(field_type, "int" | "bigint" | "money")
@@ -146,8 +156,8 @@ pub(crate) struct ColumnDefinition {
impl ColumnDefinition {
/// The `option` cell of the preview, mirroring the client's preview table.
pub(crate) fn option_label(&self) -> String {
let is_money = self.data_type.eq_ignore_ascii_case("money");
match (self.indexed, is_money) {
let has_currency = carries_currency(&self.data_type);
match (self.indexed, has_currency) {
(true, true) => format!("indexed, {}, {}", self.currency, self.money_mode.label()),
(true, false) => "indexed".to_string(),
(false, true) => format!("{}, {}", self.currency, self.money_mode.label()),
@@ -219,9 +229,8 @@ impl TableDraft {
// ---- pending-column input -------------------------------------------
pub(crate) fn is_money_column_input(&self) -> bool {
let value = self.column_type_input.trim();
value.eq_ignore_ascii_case("money") || value.eq_ignore_ascii_case("accounting")
pub(crate) fn pending_column_carries_currency(&self) -> bool {
carries_currency(self.column_type_input.trim())
}
pub(crate) fn is_temporal_column_input(&self) -> bool {
@@ -273,8 +282,9 @@ impl TableDraft {
self.is_gtin_column_input()
}
pub(crate) fn show_rounding(&self) -> bool {
self.is_money_column_input()
/// Currency and rounding both apply only to a money column.
pub(crate) fn show_money_options(&self) -> bool {
self.pending_column_carries_currency()
}
// ---- mutations -------------------------------------------------------
@@ -315,9 +325,8 @@ impl TableDraft {
);
}
let is_money = column_type.eq_ignore_ascii_case("money")
|| column_type.eq_ignore_ascii_case("accounting");
let currency = if is_money {
let has_currency = carries_currency(&column_type);
let currency = if has_currency {
normalize_currency_input(&self.column_currency_input)?
} else {
String::new()
@@ -330,7 +339,7 @@ impl TableDraft {
.trim()
.eq_ignore_ascii_case("yes"),
quantity_ledger,
money_mode: if is_money {
money_mode: if has_currency {
MoneyMode::from_input(&self.column_rounding_input)
} else {
MoneyMode::Exact
@@ -554,6 +563,20 @@ impl TableDraft {
if let Some(error) = validate_field_type(&column.data_type) {
return Err(format!("Column `{}`: {error}", column.name));
}
// The same rule the server enforces: required for a money column,
// forbidden for every other type. `add_column_from_inputs` already
// applies it, but a draft rebuilt from a posted form has not been
// through that path.
if carries_currency(&column.data_type) {
if let Err(error) = normalize_currency_input(&column.currency) {
return Err(format!("Column `{}`: {error}", column.name));
}
} else if !column.currency.trim().is_empty() {
return Err(format!(
"Column `{}`: only MONEY and ACCOUNTING columns may declare a currency.",
column.name
));
}
}
Ok(())
}
@@ -810,6 +833,40 @@ mod tests {
assert_eq!(draft.into_request().unwrap().accounting_currency, "EUR");
}
/// `add_column_from_inputs` enforces this, but a draft rebuilt from a
/// posted form skips that path, so `validate` has to enforce it too.
#[test]
fn a_rebuilt_draft_is_still_held_to_the_currency_rule() {
let mut draft = draft_with_column("total", "money");
draft.columns[0].currency = String::new();
assert!(draft.validate().is_err());
draft.columns[0].currency = "XYZ".to_string();
assert!(draft.validate().is_err());
draft.columns[0].currency = "EUR".to_string();
assert!(draft.validate().is_ok());
// Forbidden on everything else, exactly as the server has it.
let mut draft = draft_with_column("note", "text");
draft.columns[0].currency = "EUR".to_string();
assert!(draft.validate().is_err());
}
#[test]
fn an_accounting_column_shows_its_currency_too() {
let mut draft = TableDraft::new();
draft.column_type_input = "accounting".to_string();
draft.column_currency_input = "czk".to_string();
draft.add_column_from_inputs().unwrap();
assert_eq!(draft.columns[0].currency, "CZK");
assert_eq!(draft.columns[0].option_label(), "CZK, exact");
draft.toggle_column_indexed(0);
assert_eq!(draft.columns[0].option_label(), "indexed, CZK, exact");
}
#[test]
fn existing_profile_sends_no_accounting_currency() {
let draft = draft_with_column("total", "int");
@@ -927,3 +984,4 @@ mod tests {
assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]);
}
}

View File

@@ -90,7 +90,7 @@
</label>
{% endif %}
{% if page.draft.show_rounding() %}
{% if page.draft.show_money_options() %}
<label>Currency
<input name="column_currency_input" value="{{ page.draft.column_currency_input }}" list="currency-codes"
maxlength="3" placeholder="EUR">