currency per column in table definition

This commit is contained in:
Priec
2026-08-03 15:48:21 +02:00
parent 023f8c9dcb
commit 050f93e1fd
16 changed files with 87 additions and 105 deletions

View File

@@ -140,6 +140,7 @@ pub(crate) struct ColumnDefinition {
pub indexed: bool,
pub quantity_ledger: bool,
pub money_mode: MoneyMode,
pub currency: String,
}
impl ColumnDefinition {
@@ -147,9 +148,9 @@ impl ColumnDefinition {
pub(crate) fn option_label(&self) -> String {
let is_money = self.data_type.eq_ignore_ascii_case("money");
match (self.indexed, is_money) {
(true, true) => format!("indexed, {}", self.money_mode.label()),
(true, true) => format!("indexed, {}, {}", self.currency, self.money_mode.label()),
(true, false) => "indexed".to_string(),
(false, true) => self.money_mode.label().to_string(),
(false, true) => format!("{}, {}", self.currency, self.money_mode.label()),
(false, false) => String::new(),
}
}
@@ -181,7 +182,6 @@ pub(crate) struct TableDraft {
pub accounting_currency: String,
pub table_name: String,
pub base_currency: String,
// The column-input panel: one pending column being described.
pub column_name_input: String,
@@ -191,6 +191,7 @@ pub(crate) struct TableDraft {
pub column_indexing_input: String,
pub column_quantity_ledger_input: String,
pub column_rounding_input: String,
pub column_currency_input: String,
pub columns: Vec<ColumnDefinition>,
pub links: Vec<LinkDefinition>,
@@ -208,10 +209,10 @@ impl TableDraft {
pub(crate) fn new() -> Self {
Self {
accounting_currency: "EUR".to_string(),
base_currency: "EUR".to_string(),
column_indexing_input: "no".to_string(),
column_quantity_ledger_input: "no".to_string(),
column_rounding_input: "none".to_string(),
column_currency_input: "EUR".to_string(),
..Self::default()
}
}
@@ -276,10 +277,6 @@ impl TableDraft {
self.is_money_column_input()
}
pub(crate) fn show_base_currency(&self) -> bool {
self.is_money_column_input() || self.money_column_count() > 0
}
// ---- mutations -------------------------------------------------------
/// Appends the pending column, then clears the input panel.
@@ -320,6 +317,11 @@ impl TableDraft {
let is_money = column_type.eq_ignore_ascii_case("money")
|| column_type.eq_ignore_ascii_case("accounting");
let currency = if is_money {
normalize_currency_input(&self.column_currency_input)?
} else {
String::new()
};
self.columns.push(ColumnDefinition {
name: column_name.clone(),
data_type: column_type,
@@ -333,6 +335,7 @@ impl TableDraft {
} else {
MoneyMode::Exact
},
currency,
});
self.clear_column_inputs();
@@ -347,6 +350,7 @@ impl TableDraft {
self.column_indexing_input = "no".to_string();
self.column_quantity_ledger_input = "no".to_string();
self.column_rounding_input = "none".to_string();
self.column_currency_input = "EUR".to_string();
}
/// Removes one column, and drops it from the display columns with it.
@@ -440,16 +444,6 @@ impl TableDraft {
.any(|name| name == &self.table_name)
}
pub(crate) fn money_column_count(&self) -> usize {
self.columns
.iter()
.filter(|column| {
column.data_type.eq_ignore_ascii_case("money")
|| column.data_type.eq_ignore_ascii_case("accounting")
})
.count()
}
pub(crate) fn selected_index_names(&self) -> Vec<String> {
self.columns
.iter()
@@ -561,9 +555,6 @@ impl TableDraft {
return Err(format!("Column `{}`: {error}", column.name));
}
}
if let Some(error) = validate_base_currency(self) {
return Err(error);
}
Ok(())
}
@@ -585,6 +576,7 @@ impl TableDraft {
MoneyMode::Exact => MoneyRounding::None.into(),
},
quantity_ledger: column.quantity_ledger,
currency: column.currency.clone(),
})
.collect(),
indexes: self.selected_index_names(),
@@ -597,11 +589,6 @@ impl TableDraft {
required: link.mode.is_required(),
})
.collect(),
base_currency: if self.money_column_count() == 0 {
String::new()
} else {
self.base_currency.trim().to_ascii_uppercase()
},
accounting_currency: if self.creating_new_profile {
self.accounting_currency.trim().to_ascii_uppercase()
} else {
@@ -612,15 +599,12 @@ impl TableDraft {
}
}
pub(crate) fn validate_base_currency(draft: &TableDraft) -> Option<String> {
if draft.money_column_count() == 0 {
return None;
fn normalize_currency_input(value: &str) -> Result<String, String> {
let currency = value.trim().to_ascii_uppercase();
if rusty_money::iso::find(&currency).is_none() {
return Err("Currency must be a three-letter ISO-4217 code".to_string());
}
let currency = draft.base_currency.trim();
if currency.len() != 3 || !currency.chars().all(|c| c.is_ascii_alphabetic()) {
return Some("Base currency must be a three-letter ISO-4217 code".to_string());
}
None
Ok(currency)
}
pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option<String> {
@@ -720,6 +704,11 @@ mod tests {
indexed: false,
quantity_ledger: false,
money_mode: MoneyMode::Exact,
currency: if matches!(data_type, "money" | "accounting") {
"EUR".to_string()
} else {
String::new()
},
});
draft
}
@@ -795,14 +784,16 @@ mod tests {
}
#[test]
fn money_columns_require_a_valid_base_currency() {
let mut draft = draft_with_column("total", "money");
draft.base_currency = "EU".to_string();
assert!(draft.validate().is_err());
fn money_columns_require_a_valid_currency() {
let mut draft = TableDraft::new();
draft.column_name_input = "total".to_string();
draft.column_type_input = "money".to_string();
draft.column_currency_input = "EU".to_string();
assert!(draft.add_column_from_inputs().is_err());
draft.base_currency = "eur".to_string();
assert!(draft.validate().is_ok());
assert_eq!(draft.into_request().unwrap().base_currency, "EUR");
draft.column_currency_input = "eur".to_string();
draft.add_column_from_inputs().unwrap();
assert_eq!(draft.columns[0].currency, "EUR");
}
#[test]
@@ -826,8 +817,6 @@ mod tests {
assert_eq!(request.accounting_currency, "");
assert_eq!(request.profile_name, "billing");
// No money column, so no base currency either.
assert_eq!(request.base_currency, "");
}
#[test]
@@ -886,6 +875,7 @@ mod tests {
indexed: false,
quantity_ledger: false,
money_mode: MoneyMode::Exact,
currency: String::new(),
});
draft.toggle_row_display_candidate(2); // issued_on

View File

@@ -28,8 +28,6 @@ pub(crate) struct BuilderForm {
pub accounting_currency: String,
#[serde(default)]
pub table_name: String,
#[serde(default)]
pub base_currency: String,
// The pending column being described in the input panel.
#[serde(default)]
@@ -46,6 +44,8 @@ pub(crate) struct BuilderForm {
pub column_quantity_ledger_input: String,
#[serde(default)]
pub column_rounding_input: String,
#[serde(default)]
pub column_currency_input: String,
// One entry per already-added column, in order.
#[serde(default)]
@@ -58,6 +58,8 @@ pub(crate) struct BuilderForm {
pub column_quantity_ledger: Vec<String>,
#[serde(default)]
pub column_rounding: Vec<String>,
#[serde(default)]
pub column_currencies: Vec<String>,
// One entry per link target offered by the profile, in order.
#[serde(default)]
@@ -92,6 +94,7 @@ impl BuilderForm {
self.column_indexed.len(),
self.column_quantity_ledger.len(),
self.column_rounding.len(),
self.column_currencies.len(),
]
.into_iter()
.min()
@@ -108,6 +111,7 @@ impl BuilderForm {
} else {
MoneyMode::Exact
},
currency: self.column_currencies[index].clone(),
})
.collect::<Vec<_>>();
@@ -138,7 +142,6 @@ impl BuilderForm {
creating_new_profile,
accounting_currency: self.accounting_currency.clone(),
table_name: self.table_name.clone(),
base_currency: self.base_currency.clone(),
column_name_input: self.column_name_input.clone(),
column_type_input: self.column_type_input.clone(),
temporal_type_input: self.temporal_type_input.clone(),
@@ -146,6 +149,7 @@ impl BuilderForm {
column_indexing_input: self.column_indexing_input.clone(),
column_quantity_ledger_input: self.column_quantity_ledger_input.clone(),
column_rounding_input: self.column_rounding_input.clone(),
column_currency_input: self.column_currency_input.clone(),
columns,
links,
row_display_columns,
@@ -231,6 +235,7 @@ mod tests {
column_indexed: vec!["yes".into(), "no".into()],
column_quantity_ledger: vec!["no".into(), "no".into()],
column_rounding: vec!["exact".into(), "half-up".into()],
column_currencies: vec![String::new(), "EUR".into()],
link_tables: vec!["customer".into(), "project".into()],
link_modes: vec!["required".into(), "none".into()],
row_display_columns: vec!["number".into()],

View File

@@ -70,6 +70,7 @@ mod tests {
indexed: true,
quantity_ledger: false,
money_mode: MoneyMode::Exact,
currency: String::new(),
});
draft.set_available_relation_tables(vec!["customer".to_string()]);
draft.cycle_link_mode(0);
@@ -126,7 +127,7 @@ mod tests {
state.draft.column_type_input = "gtin".to_string();
assert!(render_builder(&state).contains(r#"name="gtin_type_input""#));
// Money reveals rounding, and the base currency becomes editable.
// Money reveals its currency and rounding inputs.
state.draft.column_type_input = "money".to_string();
let html = render_builder(&state);
assert!(html.contains(r#"name="column_rounding_input""#));

View File

@@ -144,7 +144,6 @@ fn catalog_view(catalog: &GetAnalyticsCatalogResponse) -> CatalogView {
fn catalog_table_view(table: &AnalyticsTable) -> CatalogTableView {
CatalogTableView {
name: table.name.clone(),
base_currency: table.base_currency.clone(),
starter_query: format!("SELECT *\nFROM {}\nLIMIT 100;", quote_identifier(&table.name)),
columns: table
.columns
@@ -157,6 +156,9 @@ fn catalog_table_view(table: &AnalyticsTable) -> CatalogTableView {
if !column.rounding.is_empty() {
details.push_str(&format!(", rounding {}", column.rounding));
}
if !column.currency.is_empty() {
details.push_str(&format!(", currency {}", column.currency));
}
CatalogColumnView {
name: column.name.clone(),
insert_text: quote_identifier(&column.name),
@@ -193,9 +195,6 @@ AVAILABLE ANALYTICS SCHEMA\n",
for table in &catalog.tables {
text.push_str(&format!("\nTABLE {}\n", quote_identifier(&table.name)));
if !table.base_currency.is_empty() {
text.push_str(&format!(" Base currency: {}\n", table.base_currency));
}
text.push_str(" Columns:\n");
for column in &table.columns {
text.push_str(&format!(
@@ -209,6 +208,9 @@ AVAILABLE ANALYTICS SCHEMA\n",
if !column.rounding.is_empty() {
text.push_str(&format!(" [rounding: {}]", column.rounding));
}
if !column.currency.is_empty() {
text.push_str(&format!(" [currency: {}]", column.currency));
}
text.push('\n');
}
if !table.links.is_empty() {

View File

@@ -48,7 +48,6 @@ pub(crate) struct CatalogView {
pub(crate) struct CatalogTableView {
pub name: String,
pub base_currency: String,
pub starter_query: String,
pub columns: Vec<CatalogColumnView>,
pub links: Vec<CatalogLinkView>,