diff --git a/client b/client index e3eef67d..d76107b4 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit e3eef67d20077a6cf469a90df6091f7503c494bc +Subproject commit d76107b4c4b89c78f070e27725d2530c9c89cd8b diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index 93b420e1..c73d90b9 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -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"]); } } + diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html index d6123d19..198958ca 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -90,7 +90,7 @@ {% endif %} - {% if page.draft.show_rounding() %} + {% if page.draft.show_money_options() %}