Skip to main content

max / makenotwork

Render every amount in the currency it is actually denominated in format_cents hardcoded a dollar sign and every amount in the CLI went through it, so a creator settling in GBP saw their own revenue marked as USD on every screen. The number was right and the symbol was a lie. mnw-cli/src/currency.rs is the display half of server/src/currency.rs: the six codes and their symbols, plus RevenueByCurrency, the client-side twin of MoneyByCurrency. No total and no conversion, so adding pounds to dollars stays inexpressible here as well. An unrecognised code degrades to USD rather than failing the response, matching the server's from_db. A project spanning two currencies shows both. The detail line has room for the full breakdown, largest first; a table cell does not, so it shows the leading amount and a +N marker instead of truncating a number mid-digit. Never a sum either way. The creator-level totals needed a currency the server was not sending. Per-project revenue carries its own, but /creator/stats and the analytics comparison carry none, and the CLI had no source at all for the viewer's own. The fingerprint lookup now carries settlement_currency so it arrives once at login, before any dashboard call returns. Also fixes three sites that formatted money by hand outside format.rs (promo discounts in both the TUI and commands.rs, and the item price edit prompt), and a sign bug: format_cents(-1050) rendered $0.50, which turned a refund into a gain.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-07 00:51 UTC
Signed with PGP, not checked
Commit: d38eb269074c43cec67fa7a2037a33fbf6701000
Parent: 76995e3
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)))