Skip to main content

max / makenotwork

12.1 KB · 337 lines History Blame Raw
1 //! Settlement currency, as much of it as a display client needs.
2 //!
3 //! The authority is `server/src/currency.rs`; this is the read-only half of
4 //! that table — the codes and the symbols — because the CLI shares no crate
5 //! with the server and cannot link it. Keep the symbols identical to the
6 //! server's or the same creator sees two different marks for the same money on
7 //! the web dashboard and in the TUI. Everything the server holds that only the
8 //! server can act on (Stripe mapping, charge minimums, price ceilings) is
9 //! deliberately absent rather than copied.
10 //!
11 //! All six currencies are two-decimal, which is what lets every amount here
12 //! stay an integer number of cents. A zero-decimal currency (JPY) would mean
13 //! revisiting every `_cents` field, not just this enum.
14
15 use std::collections::BTreeMap;
16
17 use serde::{Deserialize, Deserializer, Serialize, Serializer};
18
19 /// A currency a creator can settle in.
20 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
21 pub(crate) enum Currency {
22 /// The default for accounts that predate settlement currency, and for MNW's
23 /// own billing, which is USD whoever is looking.
24 #[default]
25 Usd,
26 Cad,
27 Gbp,
28 Aud,
29 Nzd,
30 Eur,
31 }
32
33 impl Currency {
34 /// Every supported currency, for iteration in tests.
35 #[cfg(test)]
36 pub(crate) const ALL: [Currency; 6] = [
37 Self::Usd,
38 Self::Cad,
39 Self::Gbp,
40 Self::Aud,
41 Self::Nzd,
42 Self::Eur,
43 ];
44
45 /// Lowercase ISO 4217 code, the form the server's JSON uses.
46 pub(crate) fn code(self) -> &'static str {
47 match self {
48 Self::Usd => "usd",
49 Self::Cad => "cad",
50 Self::Gbp => "gbp",
51 Self::Aud => "aud",
52 Self::Nzd => "nzd",
53 Self::Eur => "eur",
54 }
55 }
56
57 /// The symbol to prefix an amount with.
58 ///
59 /// The dollar currencies keep their region prefix, exactly as the server
60 /// renders them: a bare `$` on a Canadian creator's screen reads as USD.
61 pub(crate) fn symbol(self) -> &'static str {
62 match self {
63 Self::Usd => "$",
64 Self::Cad => "CA$",
65 Self::Gbp => "\u{a3}",
66 Self::Aud => "A$",
67 Self::Nzd => "NZ$",
68 Self::Eur => "\u{20ac}",
69 }
70 }
71
72 /// Parse an ISO code, case-insensitively. `None` for anything else.
73 pub(crate) fn from_code(code: &str) -> Option<Self> {
74 match code.trim().to_ascii_lowercase().as_str() {
75 "usd" => Some(Self::Usd),
76 "cad" => Some(Self::Cad),
77 "gbp" => Some(Self::Gbp),
78 "aud" => Some(Self::Aud),
79 "nzd" => Some(Self::Nzd),
80 "eur" => Some(Self::Eur),
81 _ => None,
82 }
83 }
84
85 /// Read a code off the wire, falling back to USD.
86 ///
87 /// Never an error. An unrecognised code means the server supports a
88 /// currency this build of the CLI does not, and a dashboard that renders
89 /// with one wrong symbol beats a dashboard that refuses to parse the
90 /// response at all. Same reasoning as the server's own `from_db`.
91 pub(crate) fn from_wire(code: &str) -> Self {
92 Self::from_code(code).unwrap_or_default()
93 }
94 }
95
96 impl std::fmt::Display for Currency {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.write_str(self.code())
99 }
100 }
101
102 impl<'de> Deserialize<'de> for Currency {
103 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
104 // Via `from_wire`, not a derived enum: an unknown code has to degrade to
105 // USD rather than fail the whole response. A derive would reject it.
106 Ok(Self::from_wire(&String::deserialize(d)?))
107 }
108 }
109
110 impl Serialize for Currency {
111 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
112 s.serialize_str(self.code())
113 }
114 }
115
116 /// A revenue total that may span more than one currency.
117 ///
118 /// Mirrors the server's `MoneyByCurrency`, and for the same reason: adding
119 /// pounds to dollars must not be expressible. There is no total and no
120 /// conversion — MNW holds no exchange-rate table, so any single number spanning
121 /// currencies would be invented.
122 ///
123 /// The normal case is one currency. Two show up when a creator's settlement
124 /// currency changed and older sales keep the previous one.
125 #[derive(Debug, Clone, Default, PartialEq, Eq)]
126 pub(crate) struct RevenueByCurrency {
127 /// Largest first, so the biggest number leads wherever this is rendered.
128 totals: Vec<(Currency, i64)>,
129 }
130
131 impl RevenueByCurrency {
132 /// Build from `(currency, cents)` rows, dropping zeroes and combining
133 /// duplicates.
134 ///
135 /// Zero-dropping is what keeps the common case at one entry, and duplicates
136 /// are possible on the wire even though the server's map is keyed by code:
137 /// two unsupported codes both fall back to USD.
138 pub(crate) fn from_rows(rows: impl IntoIterator<Item = (Currency, i64)>) -> Self {
139 let mut totals: Vec<(Currency, i64)> = Vec::new();
140 for (currency, cents) in rows {
141 if cents == 0 {
142 continue;
143 }
144 match totals.iter_mut().find(|(c, _)| *c == currency) {
145 Some(entry) => entry.1 += cents,
146 None => totals.push((currency, cents)),
147 }
148 }
149 totals.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.code().cmp(b.0.code())));
150 Self { totals }
151 }
152
153 /// Build from the server's `revenue_cents_by_currency` map.
154 pub(crate) fn from_wire_map(map: &BTreeMap<String, i64>) -> Self {
155 Self::from_rows(map.iter().map(|(k, v)| (Currency::from_wire(k), *v)))
156 }
157
158 /// Render every currency, largest first, joined with `+`.
159 ///
160 /// One currency renders exactly as a single amount always did, so the
161 /// common case looks unchanged. Two render as `£900.00 + $120.00`, which is
162 /// deliberately not a sum: the reader can see there are two currencies and
163 /// that MNW has not invented a rate between them.
164 ///
165 /// `fallback` names the currency for the empty case, where there is no
166 /// money to name one — pass the viewer's own.
167 pub(crate) fn display(&self, fallback: Currency) -> String {
168 if self.totals.is_empty() {
169 return crate::format::format_cents(0, fallback);
170 }
171 self.totals
172 .iter()
173 .map(|(c, cents)| crate::format::format_cents(*cents, *c))
174 .collect::<Vec<_>>()
175 .join(" + ")
176 }
177
178 /// Render for a fixed-width table cell.
179 ///
180 /// The full [`Self::display`] is right for a detail line, which has the
181 /// room. In a table column it would truncate, and a truncated
182 /// `£900.00 + $12...` is worse than useless. So the leading amount renders
183 /// whole and the rest becomes a `+N` count: the reader sees that the number
184 /// is not the whole story and can open the project for the breakdown.
185 /// Never a sum, same as everywhere else.
186 pub(crate) fn display_compact(&self, fallback: Currency) -> String {
187 match self.totals.split_first() {
188 None => crate::format::format_cents(0, fallback),
189 Some((first, [])) => crate::format::format_cents(first.1, first.0),
190 Some((first, rest)) => format!(
191 "{} +{}",
192 crate::format::format_cents(first.1, first.0),
193 rest.len()
194 ),
195 }
196 }
197 }
198
199 #[cfg(test)]
200 mod tests {
201 use super::*;
202
203 #[test]
204 fn codes_round_trip() {
205 for c in Currency::ALL {
206 assert_eq!(Currency::from_code(c.code()), Some(c));
207 assert_eq!(Currency::from_code(&c.code().to_uppercase()), Some(c));
208 }
209 }
210
211 #[test]
212 fn unsupported_codes_fall_back_to_usd_rather_than_failing() {
213 // A newer server settling somewhere this build doesn't know must not
214 // take the whole response down with it.
215 assert_eq!(Currency::from_code("jpy"), None);
216 assert_eq!(Currency::from_wire("jpy"), Currency::Usd);
217 assert_eq!(Currency::from_wire(""), Currency::Usd);
218 }
219
220 #[test]
221 fn deserializes_from_the_wire_form() {
222 let c: Currency = serde_json::from_str("\"gbp\"").unwrap();
223 assert_eq!(c, Currency::Gbp);
224 // And degrades rather than erroring.
225 let c: Currency = serde_json::from_str("\"jpy\"").unwrap();
226 assert_eq!(c, Currency::Usd);
227 }
228
229 #[test]
230 fn a_bare_dollar_sign_may_only_ever_mean_usd() {
231 let bare: Vec<_> = Currency::ALL.iter().filter(|c| c.symbol() == "$").collect();
232 assert_eq!(bare, vec![&Currency::Usd]);
233 }
234
235 #[test]
236 fn symbols_are_distinct() {
237 let mut seen = std::collections::HashSet::new();
238 for c in Currency::ALL {
239 assert!(seen.insert(c.symbol()), "duplicate symbol for {c}");
240 }
241 }
242
243 #[test]
244 fn symbols_match_the_server_table() {
245 // These are copied from server/src/currency.rs. If that table changes,
246 // this test is the thing that should fail.
247 assert_eq!(Currency::Usd.symbol(), "$");
248 assert_eq!(Currency::Cad.symbol(), "CA$");
249 assert_eq!(Currency::Gbp.symbol(), "\u{a3}");
250 assert_eq!(Currency::Aud.symbol(), "A$");
251 assert_eq!(Currency::Nzd.symbol(), "NZ$");
252 assert_eq!(Currency::Eur.symbol(), "\u{20ac}");
253 }
254
255 fn revenue(rows: &[(Currency, i64)]) -> RevenueByCurrency {
256 RevenueByCurrency::from_rows(rows.iter().copied())
257 }
258
259 #[test]
260 fn one_currency_renders_as_a_plain_amount() {
261 let m = revenue(&[(Currency::Usd, 123_456)]);
262 assert_eq!(m.display(Currency::Usd), "$1234.56");
263 }
264
265 #[test]
266 fn two_currencies_are_listed_largest_first_and_not_summed() {
267 let m = revenue(&[(Currency::Usd, 12_000), (Currency::Gbp, 90_000)]);
268 assert_eq!(m.display(Currency::Usd), "\u{a3}900.00 + $120.00");
269 }
270
271 #[test]
272 fn the_compact_form_counts_the_currencies_it_could_not_show() {
273 let one = revenue(&[(Currency::Gbp, 90_000)]);
274 // One currency is identical to the full form: no `+0` noise.
275 assert_eq!(
276 one.display_compact(Currency::Usd),
277 one.display(Currency::Usd)
278 );
279
280 let two = revenue(&[(Currency::Usd, 12_000), (Currency::Gbp, 90_000)]);
281 assert_eq!(two.display_compact(Currency::Usd), "\u{a3}900.00 +1");
282
283 let three = revenue(&[
284 (Currency::Usd, 12_000),
285 (Currency::Gbp, 90_000),
286 (Currency::Eur, 400),
287 ]);
288 assert_eq!(three.display_compact(Currency::Usd), "\u{a3}900.00 +2");
289 }
290
291 #[test]
292 fn the_compact_form_never_sums() {
293 // The guard against the one mistake this type exists to prevent.
294 let two = revenue(&[(Currency::Usd, 12_000), (Currency::Gbp, 90_000)]);
295 assert!(!two.display_compact(Currency::Usd).contains("1020"));
296 }
297
298 #[test]
299 fn empty_compact_renders_zero_in_the_viewers_currency() {
300 assert_eq!(
301 RevenueByCurrency::default().display_compact(Currency::Eur),
302 "\u{20ac}0"
303 );
304 }
305
306 #[test]
307 fn zero_rows_are_dropped_so_the_common_case_stays_single() {
308 let m = revenue(&[(Currency::Usd, 1000), (Currency::Gbp, 0)]);
309 assert_eq!(m.display(Currency::Usd), "$10.00");
310 }
311
312 #[test]
313 fn empty_renders_zero_in_the_viewers_currency() {
314 let m = RevenueByCurrency::default();
315 assert_eq!(m.display(Currency::Gbp), "\u{a3}0");
316 }
317
318 #[test]
319 fn a_wire_map_sorts_by_amount_not_by_code() {
320 // The server sends a BTreeMap, so the wire order is alphabetical by
321 // code. Rendering must not inherit that.
322 let map: BTreeMap<String, i64> =
323 [("usd".to_string(), 500), ("gbp".to_string(), 9000)].into();
324 let m = RevenueByCurrency::from_wire_map(&map);
325 assert_eq!(m.display(Currency::Usd), "\u{a3}90.00 + $5.00");
326 }
327
328 #[test]
329 fn unknown_codes_in_a_wire_map_combine_into_usd() {
330 let map: BTreeMap<String, i64> =
331 [("jpy".to_string(), 100), ("usd".to_string(), 200)].into();
332 let m = RevenueByCurrency::from_wire_map(&map);
333 // One entry, summed, because both codes read as USD here.
334 assert_eq!(m.display(Currency::Usd), "$3.00");
335 }
336 }
337