max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
18 files changed,
+656 insertions,
-48 deletions
| @@ -12,6 +12,7 @@ | |||
| 12 | 12 | config.rs Environment variable configuration (6 vars) | |
| 13 | 13 | api.rs HTTP client for MNW internal API (~50 methods, ~890 LOC) | |
| 14 | 14 | commands.rs Non-interactive command handlers (8 commands) | |
| 15 | + | currency.rs Settlement currencies: codes, symbols, multi-currency totals | |
| 15 | 16 | format.rs Display formatting (prices, tiers, project types) | |
| 16 | 17 | staging.rs Per-user upload staging (1 GB quota, 24h TTL) | |
| 17 | 18 | ||
| @@ -46,6 +47,25 @@ | |||
| 46 | 47 | - Git operations work natively through the same connection | |
| 47 | 48 | - Non-interactive commands work from any SSH client (`ssh cli.makenot.work projects`) | |
| 48 | 49 | ||
| 50 | + | ### Money is never converted, and never rendered without a currency | |
| 51 | + | ||
| 52 | + | Six settlement currencies (USD, CAD, GBP, AUD, NZD, EUR), all two-decimal, so | |
| 53 | + | every amount stays an integer number of cents. Two rules hold everywhere: | |
| 54 | + | ||
| 55 | + | - Every amount renders with the currency it is denominated in. The viewer's own | |
| 56 | + | settlement currency arrives on the fingerprint lookup at login and covers | |
| 57 | + | their prices and their period totals. Per-project revenue carries its own | |
| 58 | + | currency and can differ, because a revenue split is paid in the currency of | |
| 59 | + | the project that earned it. | |
| 60 | + | - Amounts in different currencies are listed, never added. There is no exchange | |
| 61 | + | rate anywhere in MNW, so a single figure spanning currencies would be | |
| 62 | + | invented. A project that spans two shows both (`£900.00 + $120.00`) in a | |
| 63 | + | detail line, and the leading amount plus a `+N` marker in a table cell. | |
| 64 | + | ||
| 65 | + | `currency.rs` is the display half of `server/src/currency.rs`. The symbols must | |
| 66 | + | stay identical to the server's or a creator sees two different marks for the | |
| 67 | + | same money on the web dashboard and in the TUI. | |
| 68 | + | ||
| 49 | 69 | ### Per-connection isolation | |
| 50 | 70 | ||
| 51 | 71 | Each SSH connection spawns an independent `MnwHandler`. No shared mutable state between connections. The handler owns: |
| @@ -1,7 +1,11 @@ | |||
| 1 | 1 | //! HTTP client for the MNW internal API. | |
| 2 | 2 | ||
| 3 | + | use std::collections::BTreeMap; | |
| 4 | + | ||
| 3 | 5 | use serde::{Deserialize, Serialize}; | |
| 4 | 6 | ||
| 7 | + | use crate::currency::{Currency, RevenueByCurrency}; | |
| 8 | + | ||
| 5 | 9 | /// A repository as `repo list` renders it. | |
| 6 | 10 | #[derive(Debug, Clone, Deserialize, Serialize)] | |
| 7 | 11 | pub(crate) struct CliRepo { | |
| @@ -44,6 +48,11 @@ | |||
| 44 | 48 | /// SSH-authenticated token rather than a caller-supplied `user_id`. | |
| 45 | 49 | #[serde(default)] | |
| 46 | 50 | pub actor_token: String, | |
| 51 | + | /// The currency this creator is paid in. Every amount that is theirs — | |
| 52 | + | /// their prices, their period totals — renders in it. Defaulted for the | |
| 53 | + | /// window where a new CLI talks to a server that predates the field. | |
| 54 | + | #[serde(default)] | |
| 55 | + | pub settlement_currency: Currency, | |
| 47 | 56 | } | |
| 48 | 57 | ||
| 49 | 58 | /// A creator's project with item count and revenue. | |
| @@ -59,7 +68,53 @@ | |||
| 59 | 68 | pub project_type: String, | |
| 60 | 69 | pub is_public: bool, | |
| 61 | 70 | pub item_count: i64, | |
| 71 | + | /// Revenue in `currency` — the project's largest single-currency total, not | |
| 72 | + | /// a sum across currencies. Only meaningful next to `currency`. | |
| 62 | 73 | pub revenue_cents: i64, | |
| 74 | + | /// The currency `revenue_cents` is denominated in. A project can earn in a | |
| 75 | + | /// currency that is not the viewer's: revenue splits are paid in the | |
| 76 | + | /// currency of the project that generated them. | |
| 77 | + | #[serde(default)] | |
| 78 | + | pub currency: Currency, | |
| 79 | + | /// Every currency this project earned in, keyed by lowercase ISO code. | |
| 80 | + | /// Normally one entry matching `revenue_cents`; empty from a server that | |
| 81 | + | /// predates the field. | |
| 82 | + | #[serde(default)] | |
| 83 | + | pub revenue_cents_by_currency: BTreeMap<String, i64>, | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | impl Project { | |
| 87 | + | /// Revenue across every currency it was earned in. | |
| 88 | + | pub(crate) fn revenue(&self) -> RevenueByCurrency { | |
| 89 | + | revenue_of( | |
| 90 | + | self.revenue_cents, | |
| 91 | + | self.currency, | |
| 92 | + | &self.revenue_cents_by_currency, | |
| 93 | + | ) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// Read a revenue figure that arrives as both a dominant amount and a full | |
| 98 | + | /// per-currency map. | |
| 99 | + | /// | |
| 100 | + | /// The map is authoritative when present. It is empty in two cases that must | |
| 101 | + | /// not render blank: a server too old to send it, and a project with no sales. | |
| 102 | + | /// Both fall back to the single pair. | |
| 103 | + | /// | |
| 104 | + | /// A zero amount then reduces to nothing, and the render falls through to the | |
| 105 | + | /// viewer's own currency. That is the right symbol for it: with no sales there | |
| 106 | + | /// is no currency the money is *in*, and the `currency` the server names for an | |
| 107 | + | /// empty total is its own default rather than a fact about the project. | |
| 108 | + | fn revenue_of( | |
| 109 | + | cents: i64, | |
| 110 | + | currency: Currency, | |
| 111 | + | by_currency: &BTreeMap<String, i64>, | |
| 112 | + | ) -> RevenueByCurrency { | |
| 113 | + | if by_currency.is_empty() { | |
| 114 | + | RevenueByCurrency::from_rows([(currency, cents)]) | |
| 115 | + | } else { | |
| 116 | + | RevenueByCurrency::from_wire_map(by_currency) | |
| 117 | + | } | |
| 63 | 118 | } | |
| 64 | 119 | ||
| 65 | 120 | /// An item within a project. | |
| @@ -240,7 +295,23 @@ | |||
| 240 | 295 | pub(crate) struct ProjectRevenue { | |
| 241 | 296 | pub id: String, | |
| 242 | 297 | pub title: String, | |
| 298 | + | /// Revenue in `currency`. See [`Project::revenue_cents`]. | |
| 243 | 299 | pub revenue_cents: i64, | |
| 300 | + | #[serde(default)] | |
| 301 | + | pub currency: Currency, | |
| 302 | + | #[serde(default)] | |
| 303 | + | pub revenue_cents_by_currency: BTreeMap<String, i64>, | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | impl ProjectRevenue { | |
| 307 | + | /// Revenue across every currency it was earned in. | |
| 308 | + | pub(crate) fn revenue(&self) -> RevenueByCurrency { | |
| 309 | + | revenue_of( | |
| 310 | + | self.revenue_cents, | |
| 311 | + | self.currency, | |
| 312 | + | &self.revenue_cents_by_currency, | |
| 313 | + | ) | |
| 314 | + | } | |
| 244 | 315 | } | |
| 245 | 316 | ||
| 246 | 317 | /// Analytics response with timeseries, comparison, and top projects. | |
| @@ -1761,3 +1832,94 @@ | |||
| 1761 | 1832 | empty_response(resp, "key_remove").await | |
| 1762 | 1833 | } | |
| 1763 | 1834 | } | |
| 1835 | + | ||
| 1836 | + | #[cfg(test)] | |
| 1837 | + | mod tests { | |
| 1838 | + | use super::*; | |
| 1839 | + | ||
| 1840 | + | /// The shape `/api/internal/creator/projects` sends today. | |
| 1841 | + | fn project_json(extra: &str) -> String { | |
| 1842 | + | format!( | |
| 1843 | + | r#"{{"id":"p1","slug":"s","title":"T","project_type":"music", | |
| 1844 | + | "is_public":true,"item_count":2,"revenue_cents":90000{extra}}}"# | |
| 1845 | + | ) | |
| 1846 | + | } | |
| 1847 | + | ||
| 1848 | + | #[test] | |
| 1849 | + | fn a_project_renders_the_currency_the_server_named() { | |
| 1850 | + | let p: Project = serde_json::from_str(&project_json( | |
| 1851 | + | r#","currency":"gbp","revenue_cents_by_currency":{"gbp":90000}"#, | |
| 1852 | + | )) | |
| 1853 | + | .unwrap(); | |
| 1854 | + | assert_eq!(p.currency, Currency::Gbp); | |
| 1855 | + | assert_eq!(p.revenue().display(Currency::Usd), "\u{a3}900.00"); | |
| 1856 | + | } | |
| 1857 | + | ||
| 1858 | + | #[test] | |
| 1859 | + | fn a_project_spanning_two_currencies_shows_both() { | |
| 1860 | + | // The whole point of the task: never one of them, never their sum. | |
| 1861 | + | let p: Project = serde_json::from_str(&project_json( | |
| 1862 | + | r#","currency":"gbp","revenue_cents_by_currency":{"gbp":90000,"usd":12000}"#, | |
| 1863 | + | )) | |
| 1864 | + | .unwrap(); | |
| 1865 | + | assert_eq!(p.revenue().display(Currency::Usd), "\u{a3}900.00 + $120.00"); | |
| 1866 | + | assert_eq!( | |
| 1867 | + | p.revenue().display_compact(Currency::Usd), | |
| 1868 | + | "\u{a3}900.00 +1" | |
| 1869 | + | ); | |
| 1870 | + | } | |
| 1871 | + | ||
| 1872 | + | #[test] | |
| 1873 | + | fn a_response_without_the_currency_fields_still_parses_as_usd() { | |
| 1874 | + | // A new CLI against a server that predates the settlement-currency pass | |
| 1875 | + | // must render exactly what it always did, not fail to load the screen. | |
| 1876 | + | let p: Project = serde_json::from_str(&project_json("")).unwrap(); | |
| 1877 | + | assert_eq!(p.currency, Currency::Usd); | |
| 1878 | + | assert_eq!(p.revenue().display(Currency::Usd), "$900.00"); | |
| 1879 | + | } | |
| 1880 | + | ||
| 1881 | + | #[test] | |
| 1882 | + | fn a_project_with_no_sales_renders_zero_in_the_viewers_currency() { | |
| 1883 | + | // An empty cell here would read as "no data" rather than "no revenue". | |
| 1884 | + | // The `currency` the server names on an empty total is its own default, | |
| 1885 | + | // so the viewer's own is what the zero renders in. | |
| 1886 | + | let p: Project = serde_json::from_str( | |
| 1887 | + | r#"{"id":"p1","slug":"s","title":"T","project_type":"music","is_public":true, | |
| 1888 | + | "item_count":0,"revenue_cents":0,"currency":"usd","revenue_cents_by_currency":{}}"#, | |
| 1889 | + | ) | |
| 1890 | + | .unwrap(); | |
| 1891 | + | assert_eq!(p.revenue().display(Currency::Gbp), "\u{a3}0"); | |
| 1892 | + | assert_eq!(p.revenue().display_compact(Currency::Gbp), "\u{a3}0"); | |
| 1893 | + | } | |
| 1894 | + | ||
| 1895 | + | #[test] | |
| 1896 | + | fn the_login_lookup_carries_the_viewers_currency() { | |
| 1897 | + | let u: UserInfo = serde_json::from_str( | |
| 1898 | + | r#"{"user_id":"u1","username":"max","display_name":null,"creator_tier":"basic", | |
| 1899 | + | "can_create_projects":true,"suspended":false,"actor_token":"t", | |
| 1900 | + | "settlement_currency":"cad"}"#, | |
| 1901 | + | ) | |
| 1902 | + | .unwrap(); | |
| 1903 | + | assert_eq!(u.settlement_currency, Currency::Cad); | |
| 1904 | + | } | |
| 1905 | + | ||
| 1906 | + | #[test] | |
| 1907 | + | fn a_login_lookup_without_the_field_defaults_to_usd() { | |
| 1908 | + | let u: UserInfo = serde_json::from_str( | |
| 1909 | + | r#"{"user_id":"u1","username":"max","display_name":null,"creator_tier":null, | |
| 1910 | + | "can_create_projects":true,"suspended":false,"actor_token":"t"}"#, | |
| 1911 | + | ) | |
| 1912 | + | .unwrap(); | |
| 1913 | + | assert_eq!(u.settlement_currency, Currency::Usd); | |
| 1914 | + | } | |
| 1915 | + | ||
| 1916 | + | #[test] | |
| 1917 | + | fn top_project_revenue_reads_the_same_contract() { | |
| 1918 | + | let p: ProjectRevenue = serde_json::from_str( | |
| 1919 | + | r#"{"id":"p1","title":"T","revenue_cents":5000,"currency":"nzd", | |
| 1920 | + | "revenue_cents_by_currency":{"nzd":5000}}"#, | |
| 1921 | + | ) | |
| 1922 | + | .unwrap(); | |
| 1923 | + | assert_eq!(p.revenue().display(Currency::Usd), "NZ$50.00"); | |
| 1924 | + | } | |
| 1925 | + | } |
| @@ -178,17 +178,17 @@ | |||
| 178 | 178 | return b"No projects.\r\n".to_vec(); | |
| 179 | 179 | } | |
| 180 | 180 | let mut out = format!( | |
| 181 | - | "{:<30} {:<12} {:<8} {:<6} {:<10}\r\n", | |
| 181 | + | "{:<30} {:<12} {:<8} {:<6} {:<15}\r\n", | |
| 182 | 182 | "Title", "Type", "Status", "Items", "Revenue" | |
| 183 | 183 | ); | |
| 184 | - | out.push_str(&"-".repeat(70)); | |
| 184 | + | out.push_str(&"-".repeat(75)); | |
| 185 | 185 | out.push_str("\r\n"); | |
| 186 | 186 | for p in &projects { | |
| 187 | 187 | let status = if p.is_public { "public" } else { "draft" }; | |
| 188 | - | let revenue = format::format_cents(p.revenue_cents); | |
| 188 | + | let revenue = p.revenue().display_compact(user.settlement_currency); | |
| 189 | 189 | write!( | |
| 190 | 190 | out, | |
| 191 | - | "{:<30} {:<12} {:<8} {:<6} {:<10}\r\n", | |
| 191 | + | "{:<30} {:<12} {:<8} {:<6} {:<15}\r\n", | |
| 192 | 192 | truncate(&p.title, 29), | |
| 193 | 193 | p.project_type, | |
| 194 | 194 | status, | |
| @@ -213,8 +213,9 @@ | |||
| 213 | 213 | let mut out = String::new(); | |
| 214 | 214 | write!(out, "Analytics ({range})\r\n\r\n").unwrap(); | |
| 215 | 215 | ||
| 216 | - | let rev = format::format_cents(data.current_revenue_cents); | |
| 217 | - | let prev_rev = format::format_cents(data.previous_revenue_cents); | |
| 216 | + | let rev = format::format_cents(data.current_revenue_cents, user.settlement_currency); | |
| 217 | + | let prev_rev = | |
| 218 | + | format::format_cents(data.previous_revenue_cents, user.settlement_currency); | |
| 218 | 219 | write!(out, "Revenue: {rev} (prev: {prev_rev})\r\n").unwrap(); | |
| 219 | 220 | write!( | |
| 220 | 221 | out, | |
| @@ -236,7 +237,7 @@ | |||
| 236 | 237 | out, | |
| 237 | 238 | " {:<30} {}\r\n", | |
| 238 | 239 | truncate(&p.title, 29), | |
| 239 | - | format::format_cents(p.revenue_cents) | |
| 240 | + | p.revenue().display(user.settlement_currency) | |
| 240 | 241 | ) | |
| 241 | 242 | .unwrap(); | |
| 242 | 243 | } | |
| @@ -265,7 +266,8 @@ | |||
| 265 | 266 | out.push_str("\r\n"); | |
| 266 | 267 | for tx in &txs { | |
| 267 | 268 | let title = tx.item_title.as_deref().unwrap_or("--"); | |
| 268 | - | let amount = format::format_cents(tx.amount_cents as i64); | |
| 269 | + | let amount = | |
| 270 | + | format::format_cents(i64::from(tx.amount_cents), user.settlement_currency); | |
| 269 | 271 | let date = tx.created_at.get(..10).unwrap_or(&tx.created_at); | |
| 270 | 272 | write!( | |
| 271 | 273 | out, | |
| @@ -300,15 +302,18 @@ | |||
| 300 | 302 | return b"No promo codes.\r\n".to_vec(); | |
| 301 | 303 | } | |
| 302 | 304 | let mut out = format!( | |
| 303 | - | "{:<20} {:<12} {:<20} {:<10}\r\n", | |
| 305 | + | "{:<20} {:<16} {:<20} {:<10}\r\n", | |
| 304 | 306 | "Code", "Discount", "Scope", "Uses" | |
| 305 | 307 | ); | |
| 306 | - | out.push_str(&"-".repeat(64)); | |
| 308 | + | out.push_str(&"-".repeat(68)); | |
| 307 | 309 | out.push_str("\r\n"); | |
| 308 | 310 | for c in &codes { | |
| 309 | 311 | let discount = match (c.discount_type.as_deref(), c.discount_value) { | |
| 310 | 312 | (Some("percentage"), Some(v)) => format!("{v}% off"), | |
| 311 | - | (Some("fixed"), Some(v)) => format!("${}.{:02} off", v / 100, v % 100), | |
| 313 | + | (Some("fixed"), Some(v)) => format!( | |
| 314 | + | "{} off", | |
| 315 | + | format::format_cents(i64::from(v), user.settlement_currency) | |
| 316 | + | ), | |
| 312 | 317 | _ => "Free".to_string(), | |
| 313 | 318 | }; | |
| 314 | 319 | let scope = c | |
| @@ -322,7 +327,7 @@ | |||
| 322 | 327 | }; | |
| 323 | 328 | write!( | |
| 324 | 329 | out, | |
| 325 | - | "{:<20} {:<12} {:<20} {:<10}\r\n", | |
| 330 | + | "{:<20} {:<16} {:<20} {:<10}\r\n", | |
| 326 | 331 | truncate(&c.code, 19), | |
| 327 | 332 | discount, | |
| 328 | 333 | truncate(scope, 19), |
| @@ -1,21 +1,32 @@ | |||
| 1 | 1 | //! Shared formatting utilities used across TUI screens and commands. | |
| 2 | 2 | ||
| 3 | - | /// Format a revenue amount in cents. Returns "$0" for zero. | |
| 4 | - | pub(crate) fn format_cents(cents: i64) -> String { | |
| 3 | + | use crate::currency::Currency; | |
| 4 | + | ||
| 5 | + | /// Format a revenue amount in cents. Returns the bare symbol and "0" for zero. | |
| 6 | + | /// | |
| 7 | + | /// Takes the currency the amount is denominated in, because there is no such | |
| 8 | + | /// thing as a bare amount: the same integer is different money to a US and a | |
| 9 | + | /// British creator. Pass the currency the *number* came with — per-project | |
| 10 | + | /// revenue carries its own — falling back to the viewer's settlement currency | |
| 11 | + | /// only where the server sends no currency alongside the figure. | |
| 12 | + | pub(crate) fn format_cents(cents: i64, currency: Currency) -> String { | |
| 5 | 13 | if cents == 0 { | |
| 6 | - | "$0".to_string() | |
| 7 | - | } else { | |
| 8 | - | format!("${}.{:02}", cents / 100, cents.abs() % 100) | |
| 14 | + | return format!("{}0", currency.symbol()); | |
| 9 | 15 | } | |
| 16 | + | let sign = if cents < 0 { "-" } else { "" }; | |
| 17 | + | let abs = cents.unsigned_abs(); | |
| 18 | + | format!("{sign}{}{}.{:02}", currency.symbol(), abs / 100, abs % 100) | |
| 10 | 19 | } | |
| 11 | 20 | ||
| 12 | 21 | /// Format an item price in cents. Returns "Free" for zero. | |
| 13 | - | pub(crate) fn format_price(cents: i32) -> String { | |
| 22 | + | /// | |
| 23 | + | /// See [`format_cents`] on why the currency is not optional. A creator's prices | |
| 24 | + | /// are always in their own settlement currency. | |
| 25 | + | pub(crate) fn format_price(cents: i32, currency: Currency) -> String { | |
| 14 | 26 | if cents == 0 { | |
| 15 | - | "Free".to_string() | |
| 16 | - | } else { | |
| 17 | - | format!("${}.{:02}", cents / 100, cents % 100) | |
| 27 | + | return "Free".to_string(); | |
| 18 | 28 | } | |
| 29 | + | format_cents(i64::from(cents), currency) | |
| 19 | 30 | } | |
| 20 | 31 | ||
| 21 | 32 | /// Human-readable creator tier label. | |
| @@ -66,27 +77,48 @@ | |||
| 66 | 77 | mod tests { | |
| 67 | 78 | use super::*; | |
| 68 | 79 | ||
| 80 | + | use crate::currency::Currency; | |
| 81 | + | ||
| 69 | 82 | #[test] | |
| 70 | 83 | fn format_cents_zero() { | |
| 71 | - | assert_eq!(format_cents(0), "$0"); | |
| 84 | + | assert_eq!(format_cents(0, Currency::Usd), "$0"); | |
| 72 | 85 | } | |
| 73 | 86 | ||
| 74 | 87 | #[test] | |
| 75 | 88 | fn format_cents_positive() { | |
| 76 | - | assert_eq!(format_cents(999), "$9.99"); | |
| 77 | - | assert_eq!(format_cents(100), "$1.00"); | |
| 78 | - | assert_eq!(format_cents(1050), "$10.50"); | |
| 89 | + | assert_eq!(format_cents(999, Currency::Usd), "$9.99"); | |
| 90 | + | assert_eq!(format_cents(100, Currency::Usd), "$1.00"); | |
| 91 | + | assert_eq!(format_cents(1050, Currency::Usd), "$10.50"); | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | #[test] | |
| 95 | + | fn format_cents_carries_the_sign() { | |
| 96 | + | // A refunded period is negative revenue, and dropping the minus turns a | |
| 97 | + | // loss into a gain on screen. | |
| 98 | + | assert_eq!(format_cents(-1050, Currency::Usd), "-$10.50"); | |
| 99 | + | assert_eq!(format_cents(-5, Currency::Gbp), "-\u{a3}0.05"); | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | #[test] | |
| 103 | + | fn format_cents_uses_the_currencys_own_symbol() { | |
| 104 | + | assert_eq!(format_cents(1050, Currency::Gbp), "\u{a3}10.50"); | |
| 105 | + | assert_eq!(format_cents(1050, Currency::Cad), "CA$10.50"); | |
| 106 | + | assert_eq!(format_cents(1050, Currency::Eur), "\u{20ac}10.50"); | |
| 107 | + | assert_eq!(format_cents(0, Currency::Nzd), "NZ$0"); | |
| 79 | 108 | } | |
| 80 | 109 | ||
| 81 | 110 | #[test] | |
| 82 | 111 | fn format_price_free() { | |
| 83 | - | assert_eq!(format_price(0), "Free"); | |
| 112 | + | // Free is free in every currency; no symbol belongs on it. | |
| 113 | + | assert_eq!(format_price(0, Currency::Usd), "Free"); | |
| 114 | + | assert_eq!(format_price(0, Currency::Gbp), "Free"); | |
| 84 | 115 | } | |
| 85 | 116 | ||
| 86 | 117 | #[test] | |
| 87 | 118 | fn format_price_nonzero() { | |
| 88 | - | assert_eq!(format_price(550), "$5.50"); | |
| 89 | - | assert_eq!(format_price(1299), "$12.99"); | |
| 119 | + | assert_eq!(format_price(550, Currency::Usd), "$5.50"); | |
| 120 | + | assert_eq!(format_price(1299, Currency::Usd), "$12.99"); | |
| 121 | + | assert_eq!(format_price(1299, Currency::Aud), "A$12.99"); | |
| 90 | 122 | } | |
| 91 | 123 | ||
| 92 | 124 | #[test] |
| @@ -13,6 +13,7 @@ | |||
| 13 | 13 | mod api; | |
| 14 | 14 | mod commands; | |
| 15 | 15 | mod config; | |
| 16 | + | mod currency; | |
| 16 | 17 | mod format; | |
| 17 | 18 | mod ota; | |
| 18 | 19 | mod rate_limit; |
| @@ -109,7 +109,10 @@ | |||
| 109 | 109 | let stats = [ | |
| 110 | 110 | ( | |
| 111 | 111 | "Revenue", | |
| 112 | - | format::format_cents(data.as_ref().map_or(0, |d| d.current_revenue_cents)), | |
| 112 | + | format::format_cents( | |
| 113 | + | data.as_ref().map_or(0, |d| d.current_revenue_cents), | |
| 114 | + | app.currency(), | |
| 115 | + | ), | |
| 113 | 116 | data.as_ref() | |
| 114 | 117 | .map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)), | |
| 115 | 118 | ), | |
| @@ -193,7 +196,7 @@ | |||
| 193 | 196 | .value(b.revenue_cents as u64) | |
| 194 | 197 | .label(Line::from(b.label.clone())) | |
| 195 | 198 | .text_value(if b.revenue_cents > 0 { | |
| 196 | - | format::format_cents(b.revenue_cents) | |
| 199 | + | format::format_cents(b.revenue_cents, app.currency()) | |
| 197 | 200 | } else { | |
| 198 | 201 | String::new() | |
| 199 | 202 | }) | |
| @@ -241,12 +244,13 @@ | |||
| 241 | 244 | .map(|p| { | |
| 242 | 245 | Row::new(vec![ | |
| 243 | 246 | format!(" {}", p.title), | |
| 244 | - | format::format_cents(p.revenue_cents), | |
| 247 | + | p.revenue().display_compact(app.currency()), | |
| 245 | 248 | ]) | |
| 246 | 249 | }) | |
| 247 | 250 | .collect(); | |
| 248 | 251 | ||
| 249 | - | let widths = [Constraint::Min(20), Constraint::Length(12)]; | |
| 252 | + | // See the home-screen revenue column on why 15 rather than 12. | |
| 253 | + | let widths = [Constraint::Min(20), Constraint::Length(15)]; | |
| 250 | 254 | widgets::render_table(frame, chunks[4], &[" Project", "Revenue"], &widths, rows); | |
| 251 | 255 | } | |
| 252 | 256 | } | |
| @@ -284,7 +288,7 @@ | |||
| 284 | 288 | .enumerate() | |
| 285 | 289 | .map(|(i, tx)| { | |
| 286 | 290 | let title = tx.item_title.as_deref().unwrap_or("--"); | |
| 287 | - | let amount = format::format_cents(tx.amount_cents as i64); | |
| 291 | + | let amount = format::format_cents(i64::from(tx.amount_cents), app.currency()); | |
| 288 | 292 | let date = tx.created_at.get(..10).unwrap_or(&tx.created_at); | |
| 289 | 293 | ||
| 290 | 294 | Row::new(vec![ |
| @@ -114,7 +114,7 @@ | |||
| 114 | 114 | ||
| 115 | 115 | let (revenue, sales, followers, items) = if let Some(ref s) = app.stats { | |
| 116 | 116 | ( | |
| 117 | - | format::format_cents(s.current_revenue_cents), | |
| 117 | + | format::format_cents(s.current_revenue_cents, app.currency()), | |
| 118 | 118 | s.current_sales.to_string(), | |
| 119 | 119 | s.current_followers.to_string(), | |
| 120 | 120 | s.total_items.to_string(), | |
| @@ -161,7 +161,7 @@ | |||
| 161 | 161 | format::format_project_type(&p.project_type).to_string(), | |
| 162 | 162 | visibility.to_string(), | |
| 163 | 163 | p.item_count.to_string(), | |
| 164 | - | format::format_cents(p.revenue_cents), | |
| 164 | + | p.revenue().display_compact(app.currency()), | |
| 165 | 165 | ]) | |
| 166 | 166 | .style(widgets::selected_style(i, Some(app.selected_index))) | |
| 167 | 167 | }) | |
| @@ -172,7 +172,9 @@ | |||
| 172 | 172 | Constraint::Length(12), | |
| 173 | 173 | Constraint::Length(8), | |
| 174 | 174 | Constraint::Length(7), | |
| 175 | - | Constraint::Length(12), | |
| 175 | + | // Wider than the amount alone needs: a three-character symbol (`CA$`) | |
| 176 | + | // plus the `+N` multi-currency marker has to fit without truncating. | |
| 177 | + | Constraint::Length(15), | |
| 176 | 178 | ]; | |
| 177 | 179 | ||
| 178 | 180 | widgets::render_table( |
| @@ -143,7 +143,7 @@ | |||
| 143 | 143 | } | |
| 144 | 144 | ||
| 145 | 145 | fn render_item_info(frame: &mut Frame, app: &App, item: &ItemDetail, area: ratatui::layout::Rect) { | |
| 146 | - | let price = format::format_price(item.price_cents); | |
| 146 | + | let price = format::format_price(item.price_cents, app.currency()); | |
| 147 | 147 | let desc_preview = item | |
| 148 | 148 | .description | |
| 149 | 149 | .as_deref() | |
| @@ -187,7 +187,9 @@ | |||
| 187 | 187 | Span::styled("Price: ", Style::default().fg(Color::DarkGray)), | |
| 188 | 188 | if editing == Some(ItemEditField::Price) { | |
| 189 | 189 | Span::styled( | |
| 190 | - | format!("${}_", app.edit_buffer), | |
| 190 | + | // The symbol on the edit prompt is the creator's own: they are | |
| 191 | + | // typing an amount in the currency they are paid in. | |
| 192 | + | format!("{}{}_", app.currency().symbol(), app.edit_buffer), | |
| 191 | 193 | Style::default().add_modifier(Modifier::UNDERLINED), | |
| 192 | 194 | ) | |
| 193 | 195 | } else { |
| @@ -28,6 +28,7 @@ | |||
| 28 | 28 | MnwApiClient, Project, PromoCode, SshKeyInfo, StorageInfo, TagInfo, TierInfo, Transaction, | |
| 29 | 29 | UserInfo, Version, | |
| 30 | 30 | }; | |
| 31 | + | use crate::currency::Currency; | |
| 31 | 32 | use crate::ssh::terminal::TerminalHandle; | |
| 32 | 33 | use crate::staging::{self, StagedFile}; | |
| 33 | 34 | ||
| @@ -283,6 +284,16 @@ | |||
| 283 | 284 | } | |
| 284 | 285 | ||
| 285 | 286 | impl App { | |
| 287 | + | /// The viewer's own settlement currency. | |
| 288 | + | /// | |
| 289 | + | /// The right currency for every amount the server sends without one: this | |
| 290 | + | /// creator's prices, their period totals, their transactions. It is *not* | |
| 291 | + | /// right for per-project revenue, which carries its own currency because a | |
| 292 | + | /// revenue split is paid in the currency of the project that earned it. | |
| 293 | + | pub(crate) fn currency(&self) -> Currency { | |
| 294 | + | self.user.settlement_currency | |
| 295 | + | } | |
| 296 | + | ||
| 286 | 297 | fn new(user: UserInfo) -> Self { | |
| 287 | 298 | Self { | |
| 288 | 299 | user, |
| @@ -60,7 +60,9 @@ | |||
| 60 | 60 | Span::raw(" "), | |
| 61 | 61 | Span::raw(visibility), | |
| 62 | 62 | Span::raw(" "), | |
| 63 | - | Span::raw(format::format_cents(project.revenue_cents)), | |
| 63 | + | // The detail line has the room for the full breakdown, so a project | |
| 64 | + | // spanning two currencies shows both here rather than a `+1` marker. | |
| 65 | + | Span::raw(project.revenue().display(app.currency())), | |
| 64 | 66 | Span::styled(" revenue", Style::default().fg(Color::DarkGray)), | |
| 65 | 67 | ])); | |
| 66 | 68 | frame.render_widget(info, chunks[1]); | |
| @@ -182,7 +184,7 @@ | |||
| 182 | 184 | Row::new(vec![ | |
| 183 | 185 | format!(" {} {}", marker, item.title), | |
| 184 | 186 | format::format_item_type(&item.item_type).to_string(), | |
| 185 | - | format::format_price(item.price_cents), | |
| 187 | + | format::format_price(item.price_cents, app.currency()), | |
| 186 | 188 | visibility.to_string(), | |
| 187 | 189 | ]) | |
| 188 | 190 | .style(widgets::selected_style(i, Some(app.selected_index))) |