//! Settlement currency, as much of it as a display client needs. //! //! The authority is `server/src/currency.rs`; this is the read-only half of //! that table — the codes and the symbols — because the CLI shares no crate //! with the server and cannot link it. Keep the symbols identical to the //! server's or the same creator sees two different marks for the same money on //! the web dashboard and in the TUI. Everything the server holds that only the //! server can act on (Stripe mapping, charge minimums, price ceilings) is //! deliberately absent rather than copied. //! //! All six currencies are two-decimal, which is what lets every amount here //! stay an integer number of cents. A zero-decimal currency (JPY) would mean //! revisiting every `_cents` field, not just this enum. use std::collections::BTreeMap; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// A currency a creator can settle in. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] pub(crate) enum Currency { /// The default for accounts that predate settlement currency, and for MNW's /// own billing, which is USD whoever is looking. #[default] Usd, Cad, Gbp, Aud, Nzd, Eur, } impl Currency { /// Every supported currency, for iteration in tests. #[cfg(test)] pub(crate) const ALL: [Currency; 6] = [ Self::Usd, Self::Cad, Self::Gbp, Self::Aud, Self::Nzd, Self::Eur, ]; /// Lowercase ISO 4217 code, the form the server's JSON uses. pub(crate) fn code(self) -> &'static str { match self { Self::Usd => "usd", Self::Cad => "cad", Self::Gbp => "gbp", Self::Aud => "aud", Self::Nzd => "nzd", Self::Eur => "eur", } } /// The symbol to prefix an amount with. /// /// The dollar currencies keep their region prefix, exactly as the server /// renders them: a bare `$` on a Canadian creator's screen reads as USD. pub(crate) fn symbol(self) -> &'static str { match self { Self::Usd => "$", Self::Cad => "CA$", Self::Gbp => "\u{a3}", Self::Aud => "A$", Self::Nzd => "NZ$", Self::Eur => "\u{20ac}", } } /// Parse an ISO code, case-insensitively. `None` for anything else. pub(crate) fn from_code(code: &str) -> Option { match code.trim().to_ascii_lowercase().as_str() { "usd" => Some(Self::Usd), "cad" => Some(Self::Cad), "gbp" => Some(Self::Gbp), "aud" => Some(Self::Aud), "nzd" => Some(Self::Nzd), "eur" => Some(Self::Eur), _ => None, } } /// Read a code off the wire, falling back to USD. /// /// Never an error. An unrecognised code means the server supports a /// currency this build of the CLI does not, and a dashboard that renders /// with one wrong symbol beats a dashboard that refuses to parse the /// response at all. Same reasoning as the server's own `from_db`. pub(crate) fn from_wire(code: &str) -> Self { Self::from_code(code).unwrap_or_default() } } impl std::fmt::Display for Currency { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.code()) } } impl<'de> Deserialize<'de> for Currency { fn deserialize>(d: D) -> Result { // Via `from_wire`, not a derived enum: an unknown code has to degrade to // USD rather than fail the whole response. A derive would reject it. Ok(Self::from_wire(&String::deserialize(d)?)) } } impl Serialize for Currency { fn serialize(&self, s: S) -> Result { s.serialize_str(self.code()) } } /// A revenue total that may span more than one currency. /// /// Mirrors the server's `MoneyByCurrency`, and for the same reason: adding /// pounds to dollars must not be expressible. There is no total and no /// conversion — MNW holds no exchange-rate table, so any single number spanning /// currencies would be invented. /// /// The normal case is one currency. Two show up when a creator's settlement /// currency changed and older sales keep the previous one. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct RevenueByCurrency { /// Largest first, so the biggest number leads wherever this is rendered. totals: Vec<(Currency, i64)>, } impl RevenueByCurrency { /// Build from `(currency, cents)` rows, dropping zeroes and combining /// duplicates. /// /// Zero-dropping is what keeps the common case at one entry, and duplicates /// are possible on the wire even though the server's map is keyed by code: /// two unsupported codes both fall back to USD. pub(crate) fn from_rows(rows: impl IntoIterator) -> Self { let mut totals: Vec<(Currency, i64)> = Vec::new(); for (currency, cents) in rows { if cents == 0 { continue; } match totals.iter_mut().find(|(c, _)| *c == currency) { Some(entry) => entry.1 += cents, None => totals.push((currency, cents)), } } totals.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.code().cmp(b.0.code()))); Self { totals } } /// Build from the server's `revenue_cents_by_currency` map. pub(crate) fn from_wire_map(map: &BTreeMap) -> Self { Self::from_rows(map.iter().map(|(k, v)| (Currency::from_wire(k), *v))) } /// Render every currency, largest first, joined with `+`. /// /// One currency renders exactly as a single amount always did, so the /// common case looks unchanged. Two render as `£900.00 + $120.00`, which is /// deliberately not a sum: the reader can see there are two currencies and /// that MNW has not invented a rate between them. /// /// `fallback` names the currency for the empty case, where there is no /// money to name one — pass the viewer's own. pub(crate) fn display(&self, fallback: Currency) -> String { if self.totals.is_empty() { return crate::format::format_cents(0, fallback); } self.totals .iter() .map(|(c, cents)| crate::format::format_cents(*cents, *c)) .collect::>() .join(" + ") } /// Render for a fixed-width table cell. /// /// The full [`Self::display`] is right for a detail line, which has the /// room. In a table column it would truncate, and a truncated /// `£900.00 + $12...` is worse than useless. So the leading amount renders /// whole and the rest becomes a `+N` count: the reader sees that the number /// is not the whole story and can open the project for the breakdown. /// Never a sum, same as everywhere else. pub(crate) fn display_compact(&self, fallback: Currency) -> String { match self.totals.split_first() { None => crate::format::format_cents(0, fallback), Some((first, [])) => crate::format::format_cents(first.1, first.0), Some((first, rest)) => format!( "{} +{}", crate::format::format_cents(first.1, first.0), rest.len() ), } } } #[cfg(test)] mod tests { use super::*; #[test] fn codes_round_trip() { for c in Currency::ALL { assert_eq!(Currency::from_code(c.code()), Some(c)); assert_eq!(Currency::from_code(&c.code().to_uppercase()), Some(c)); } } #[test] fn unsupported_codes_fall_back_to_usd_rather_than_failing() { // A newer server settling somewhere this build doesn't know must not // take the whole response down with it. assert_eq!(Currency::from_code("jpy"), None); assert_eq!(Currency::from_wire("jpy"), Currency::Usd); assert_eq!(Currency::from_wire(""), Currency::Usd); } #[test] fn deserializes_from_the_wire_form() { let c: Currency = serde_json::from_str("\"gbp\"").unwrap(); assert_eq!(c, Currency::Gbp); // And degrades rather than erroring. let c: Currency = serde_json::from_str("\"jpy\"").unwrap(); assert_eq!(c, Currency::Usd); } #[test] fn a_bare_dollar_sign_may_only_ever_mean_usd() { let bare: Vec<_> = Currency::ALL.iter().filter(|c| c.symbol() == "$").collect(); assert_eq!(bare, vec![&Currency::Usd]); } #[test] fn symbols_are_distinct() { let mut seen = std::collections::HashSet::new(); for c in Currency::ALL { assert!(seen.insert(c.symbol()), "duplicate symbol for {c}"); } } #[test] fn symbols_match_the_server_table() { // These are copied from server/src/currency.rs. If that table changes, // this test is the thing that should fail. assert_eq!(Currency::Usd.symbol(), "$"); assert_eq!(Currency::Cad.symbol(), "CA$"); assert_eq!(Currency::Gbp.symbol(), "\u{a3}"); assert_eq!(Currency::Aud.symbol(), "A$"); assert_eq!(Currency::Nzd.symbol(), "NZ$"); assert_eq!(Currency::Eur.symbol(), "\u{20ac}"); } fn revenue(rows: &[(Currency, i64)]) -> RevenueByCurrency { RevenueByCurrency::from_rows(rows.iter().copied()) } #[test] fn one_currency_renders_as_a_plain_amount() { let m = revenue(&[(Currency::Usd, 123_456)]); assert_eq!(m.display(Currency::Usd), "$1234.56"); } #[test] fn two_currencies_are_listed_largest_first_and_not_summed() { let m = revenue(&[(Currency::Usd, 12_000), (Currency::Gbp, 90_000)]); assert_eq!(m.display(Currency::Usd), "\u{a3}900.00 + $120.00"); } #[test] fn the_compact_form_counts_the_currencies_it_could_not_show() { let one = revenue(&[(Currency::Gbp, 90_000)]); // One currency is identical to the full form: no `+0` noise. assert_eq!( one.display_compact(Currency::Usd), one.display(Currency::Usd) ); let two = revenue(&[(Currency::Usd, 12_000), (Currency::Gbp, 90_000)]); assert_eq!(two.display_compact(Currency::Usd), "\u{a3}900.00 +1"); let three = revenue(&[ (Currency::Usd, 12_000), (Currency::Gbp, 90_000), (Currency::Eur, 400), ]); assert_eq!(three.display_compact(Currency::Usd), "\u{a3}900.00 +2"); } #[test] fn the_compact_form_never_sums() { // The guard against the one mistake this type exists to prevent. let two = revenue(&[(Currency::Usd, 12_000), (Currency::Gbp, 90_000)]); assert!(!two.display_compact(Currency::Usd).contains("1020")); } #[test] fn empty_compact_renders_zero_in_the_viewers_currency() { assert_eq!( RevenueByCurrency::default().display_compact(Currency::Eur), "\u{20ac}0" ); } #[test] fn zero_rows_are_dropped_so_the_common_case_stays_single() { let m = revenue(&[(Currency::Usd, 1000), (Currency::Gbp, 0)]); assert_eq!(m.display(Currency::Usd), "$10.00"); } #[test] fn empty_renders_zero_in_the_viewers_currency() { let m = RevenueByCurrency::default(); assert_eq!(m.display(Currency::Gbp), "\u{a3}0"); } #[test] fn a_wire_map_sorts_by_amount_not_by_code() { // The server sends a BTreeMap, so the wire order is alphabetical by // code. Rendering must not inherit that. let map: BTreeMap = [("usd".to_string(), 500), ("gbp".to_string(), 9000)].into(); let m = RevenueByCurrency::from_wire_map(&map); assert_eq!(m.display(Currency::Usd), "\u{a3}90.00 + $5.00"); } #[test] fn unknown_codes_in_a_wire_map_combine_into_usd() { let map: BTreeMap = [("jpy".to_string(), 100), ("usd".to_string(), 200)].into(); let m = RevenueByCurrency::from_wire_map(&map); // One entry, summed, because both codes read as USD here. assert_eq!(m.display(Currency::Usd), "$3.00"); } }