Skip to main content

max / makenotwork

Group revenue by currency instead of summing across them The settlement-currency work left every roll-up adding pounds to dollars. A creator's own sales are normally all in one currency, but the platform-wide totals span every creator, and a creator whose settlement currency changed has older sales denominated in the previous one. MoneyByCurrency is what the four aggregates in revenue_stats now return. It has no total() and no conversion, so the wrong thing is no longer expressible rather than merely discouraged. Zero rows are dropped, which keeps the common case a single entry; one currency renders exactly as it always did, and two render as "£900.00 + $120.00" — listed, never summed, because there is no rate here that would make a sum true. Ranking projects against each other has no honest cross-currency answer, so the comparison bars are scaled per currency: each project is measured against the largest total in its own. With one currency that is the old behaviour exactly. Twelve more call sites were formatting money by hand with format!("${}.{:02}", cents / 100, cents % 100). The pricing-format seal never covered that form — it banned the float idiom and cents math in templates — so it had spread across the dashboard, the wizards, the bulk item API, the cart minimum message and the chart bars, every one of them hardcoding a dollar sign. All twelve now go through the formatters, and the seal covers the integer form so it cannot come back. It then caught three SyncKit billing displays that are legitimately USD; those route through format_revenue with an explicit currency rather than taking an exemption, so the seal holds with no carve-outs. The internal creator API feeds mnw-cli's TUI, which reads revenue_cents over HTTP where a rename would have failed silently at runtime. The change is additive: revenue_cents keeps its meaning, currency names what it is denominated in, and revenue_cents_by_currency carries the rest. mnw-cli still renders every amount with a hardcoded dollar sign, which is its own defect and is filed as mnw-cli 77f5faa8.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 22:39 UTC
Signed with PGP, not checked
Commit: 1633bfbe3c1163c32855bbe424631bd1ba52859d
Parent: 294355c
21 files changed, +554 insertions, -209 deletions
@@ -243,6 +243,93 @@
243 243 }
244 244 }
245 245
246 + /// A money total that may span more than one currency.
247 + ///
248 + /// Revenue queries return this instead of a bare `i64` so that adding pounds to
249 + /// dollars stops being expressible. There is no `total()` and no conversion:
250 + /// MNW holds no exchange-rate table, and a single number spanning currencies
251 + /// would be a lie whichever rate produced it.
252 + ///
253 + /// The common case is one currency, and callers should stay cheap for it — a
254 + /// creator's own projects are all in their own currency. Two currencies show up
255 + /// on the uncommon path: a creator who takes revenue splits from another
256 + /// creator's project is paid in *that* project's currency, and a creator whose
257 + /// settlement currency changed has historical sales denominated in the old one.
258 + ///
259 + /// Ordering is largest-first, so the biggest number leads wherever this is
260 + /// rendered.
261 + #[derive(Clone, Debug, Default, PartialEq, Eq)]
262 + pub struct MoneyByCurrency {
263 + totals: Vec<(SettlementCurrency, i64)>,
264 + }
265 +
266 + impl MoneyByCurrency {
267 + /// Build from `(currency, cents)` rows, dropping zeroes and combining
268 + /// duplicates. Zero-dropping is what keeps the common case at one entry: a
269 + /// `LEFT JOIN` that found no sales contributes nothing rather than a
270 + /// spurious second currency reading `£0.00`.
271 + pub fn from_rows(rows: impl IntoIterator<Item = (SettlementCurrency, i64)>) -> Self {
272 + let mut totals: Vec<(SettlementCurrency, i64)> = Vec::new();
273 + for (currency, cents) in rows {
274 + if cents == 0 {
275 + continue;
276 + }
277 + match totals.iter_mut().find(|(c, _)| *c == currency) {
278 + Some(entry) => entry.1 += cents,
279 + None => totals.push((currency, cents)),
280 + }
281 + }
282 + totals.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.code().cmp(b.0.code())));
283 + Self { totals }
284 + }
285 +
286 + /// Nothing earned in any currency.
287 + pub fn is_empty(&self) -> bool {
288 + self.totals.is_empty()
289 + }
290 +
291 + /// How many distinct currencies this total spans.
292 + pub fn currency_count(&self) -> usize {
293 + self.totals.len()
294 + }
295 +
296 + pub fn iter(&self) -> impl Iterator<Item = (SettlementCurrency, i64)> + '_ {
297 + self.totals.iter().copied()
298 + }
299 +
300 + /// The amount in one currency, or zero if there is none.
301 + ///
302 + /// For the callers that legitimately want a single currency's figure (a
303 + /// creator's own sales in their own currency). It does not silently discard
304 + /// the rest — pair it with [`Self::currency_count`] when that matters.
305 + pub fn in_currency(&self, currency: SettlementCurrency) -> i64 {
306 + self.totals
307 + .iter()
308 + .find(|(c, _)| *c == currency)
309 + .map_or(0, |(_, cents)| *cents)
310 + }
311 +
312 + /// Render every currency, largest first, joined with `+`.
313 + ///
314 + /// One currency renders exactly as it always did (`$1,234.00`), so the
315 + /// common case is unchanged. Two render as `£900.00 + $120.00`, which is
316 + /// deliberately not a sum: the reader can see there are two currencies and
317 + /// that MNW has not invented a rate between them.
318 + ///
319 + /// `fallback` supplies the currency for the empty case, where there is no
320 + /// money to name a currency for — pass the viewer's own.
321 + pub fn display(&self, fallback: SettlementCurrency) -> String {
322 + if self.totals.is_empty() {
323 + return crate::formatting::format_revenue(0, fallback);
324 + }
325 + self.totals
326 + .iter()
327 + .map(|(c, cents)| crate::formatting::format_revenue(*cents, *c))
328 + .collect::<Vec<_>>()
329 + .join(" + ")
330 + }
331 + }
332 +
246 333 // ── Postgres ──
247 334 //
248 335 // Stored as the lowercase ISO code in a `VARCHAR(3)` guarded by a CHECK listing
@@ -383,6 +470,84 @@
383 470 }
384 471 }
385 472
473 + // ── MoneyByCurrency ──
474 +
475 + fn money(rows: &[(SettlementCurrency, i64)]) -> MoneyByCurrency {
476 + MoneyByCurrency::from_rows(rows.iter().copied())
477 + }
478 +
479 + #[test]
480 + fn one_currency_renders_exactly_as_before() {
481 + // The common case must be indistinguishable from the single-currency
482 + // world, or every dashboard changes appearance for no reason.
483 + let m = money(&[(SettlementCurrency::Usd, 123_456)]);
484 + assert_eq!(m.display(SettlementCurrency::Usd), "$1,234.56");
485 + assert_eq!(m.currency_count(), 1);
486 + }
487 +
488 + #[test]
489 + fn two_currencies_are_listed_not_summed() {
490 + let m = money(&[
491 + (SettlementCurrency::Usd, 12_000),
492 + (SettlementCurrency::Gbp, 90_000),
493 + ]);
494 + // Largest first, and visibly two amounts rather than one invented total.
495 + assert_eq!(m.display(SettlementCurrency::Usd), "\u{a3}900.00 + $120.00");
496 + assert_eq!(m.currency_count(), 2);
497 + }
498 +
499 + #[test]
500 + fn duplicate_currencies_combine() {
501 + let m = money(&[
502 + (SettlementCurrency::Eur, 500),
503 + (SettlementCurrency::Eur, 250),
504 + ]);
505 + assert_eq!(m.currency_count(), 1);
506 + assert_eq!(m.in_currency(SettlementCurrency::Eur), 750);
507 + }
508 +
509 + #[test]
510 + fn zero_rows_are_dropped_so_the_common_case_stays_single() {
511 + // A LEFT JOIN that matched nothing must not invent a second currency.
512 + let m = money(&[
513 + (SettlementCurrency::Usd, 1000),
514 + (SettlementCurrency::Gbp, 0),
515 + ]);
516 + assert_eq!(m.currency_count(), 1);
517 + assert_eq!(m.display(SettlementCurrency::Usd), "$10.00");
518 + }
519 +
520 + #[test]
521 + fn empty_renders_zero_in_the_viewers_currency() {
522 + let m = MoneyByCurrency::default();
523 + assert!(m.is_empty());
524 + assert_eq!(m.display(SettlementCurrency::Gbp), "\u{a3}0.00");
525 + }
526 +
527 + #[test]
528 + fn in_currency_does_not_leak_across_currencies() {
529 + // The whole point: asking for dollars must never return pounds.
530 + let m = money(&[(SettlementCurrency::Gbp, 5000)]);
531 + assert_eq!(m.in_currency(SettlementCurrency::Usd), 0);
532 + assert_eq!(m.in_currency(SettlementCurrency::Gbp), 5000);
533 + }
534 +
535 + #[test]
536 + fn ordering_is_stable_for_equal_amounts() {
537 + let a = money(&[
538 + (SettlementCurrency::Usd, 100),
539 + (SettlementCurrency::Gbp, 100),
540 + ]);
541 + let b = money(&[
542 + (SettlementCurrency::Gbp, 100),
543 + (SettlementCurrency::Usd, 100),
544 + ]);
545 + assert_eq!(
546 + a.display(SettlementCurrency::Usd),
547 + b.display(SettlementCurrency::Usd)
548 + );
549 + }
550 +
386 551 #[test]
387 552 fn default_is_usd() {
388 553 assert_eq!(SettlementCurrency::default(), SettlementCurrency::Usd);
@@ -899,7 +899,17 @@
899 899 continue;
900 900 }
901 901 let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect();
902 - if squished.contains("asf64/100") || squished.contains("as_f64()/100") {
902 + // The float idiom, in both the raw-cast and newtype-accessor forms.
903 + let float_drift =
904 + squished.contains("asf64/100") || squished.contains("as_f64()/100");
905 + // The integer idiom, which the original seal did not cover and
906 + // which twelve call sites had quietly grown: `format!("${}.{:02}",
907 + // cents / 100, cents % 100)`. It hardcodes a dollar sign, so every
908 + // one of them rendered a British creator's revenue as USD. The
909 + // literal `$` is what makes it wrong, not the arithmetic, so that
910 + // is what this matches.
911 + let hardcoded_dollar_format = squished.contains(r#""${}.{:02}"#);
912 + if float_drift || hardcoded_dollar_format {
903 913 offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
904 914 }
905 915 }
@@ -924,10 +934,11 @@
924 934
925 935 assert!(
926 936 offenders.is_empty(),
927 - "pricing-format seal violated, convert cents to dollars via \
937 + "pricing-format seal violated, convert cents to an amount via \
928 938 formatting::format_price / format_revenue / format_dollars_plain (never a raw \
929 - `as f64 / 100.0` or `.as_f64() / 100` in Rust, never cents math in a template). \
930 - Offending lines:\n{}",
939 + `as f64 / 100.0` or `.as_f64() / 100`, never a hardcoded `format!(\"${{}}.{{:02}}\", \
940 + ...)` which cannot render a non-USD creator's currency, never cents math in a \
941 + template). Offending lines:\n{}",
931 942 offenders.join("\n")
932 943 );
933 944 }
@@ -531,17 +531,11 @@
531 531 db::transactions::get_platform_revenue_stats(pool).await?;
532 532
533 533 println!("Platform Revenue");
534 - // KNOWN WRONG UNDER MIXED CURRENCIES, and labelled USD so nobody reads it as
535 - // a settled figure. `get_platform_revenue_stats` SUMs `amount_cents` across
536 - // every creator, so once a non-USD creator has sales this adds pounds to
537 - // dollars. The fix is the GROUP BY currency work in mnw-server task
538 - // 06f7abad (item 3); until then this line is USD-only bookkeeping.
534 + // Platform-wide, so it spans every creator's currency. Rendered per
535 + // currency rather than summed: there is no rate that makes one number true.
539 536 println!(
540 - " Total revenue: {} (USD rows only once multi-currency lands)",
541 - makenotwork::formatting::format_revenue(
542 - revenue_cents,
543 - makenotwork::currency::SettlementCurrency::Usd
544 - )
537 + " Total revenue: {}",
538 + revenue_cents.display(makenotwork::currency::SettlementCurrency::Usd)
545 539 );
546 540 println!(" Total sales: {completed}");
547 541 println!(" Total refunds: {refunded}");
@@ -570,7 +564,8 @@
570 564 );
571 565 println!("{}", "-".repeat(65));
572 566
573 - let mut total_cents: i64 = 0;
567 + // Accumulated per currency: this listing is not scoped to one creator.
568 + let mut totals: Vec<(makenotwork::currency::SettlementCurrency, i64)> = Vec::new();
574 569 for tx in &txs {
575 570 let date = tx.created_at.format("%Y-%m-%d");
576 571 let title = tx.item_title.as_deref().unwrap_or("(deleted)");
@@ -579,28 +574,22 @@
579 574 } else {
580 575 title.to_string()
581 576 };
582 - let amount = makenotwork::formatting::format_revenue(
583 - tx.amount_cents.as_i64(),
584 - makenotwork::currency::SettlementCurrency::Usd,
585 - );
577 + let amount =
578 + makenotwork::formatting::format_revenue(tx.amount_cents.as_i64(), tx.currency());
586 579 println!(
587 580 "{:<12} {:<30} {:>10} {:<10}",
588 581 date, title_short, amount, tx.status
589 582 );
590 583 if tx.status == TransactionStatus::Completed {
591 - total_cents += tx.amount_cents.as_i64();
584 + totals.push((tx.currency(), tx.amount_cents.as_i64()));
592 585 }
593 586 }
594 587
595 - // Same caveat as the platform revenue total above: this sums across
596 - // creators and therefore across currencies. See task 06f7abad item 3.
597 588 println!(
598 589 "\n{} transaction(s), {} total revenue.",
599 590 txs.len(),
600 - makenotwork::formatting::format_revenue(
601 - total_cents,
602 - makenotwork::currency::SettlementCurrency::Usd
603 - )
591 + makenotwork::currency::MoneyByCurrency::from_rows(totals)
592 + .display(makenotwork::currency::SettlementCurrency::Usd)
604 593 );
605 594 Ok(())
606 595 }
@@ -765,7 +765,12 @@
765 765 b.key_cap.map(|v| v as u32),
766 766 b.gb_per_key.map(|v| v as u32),
767 767 );
768 - format!("${}.{:02} / month", cents / 100, cents % 100)
768 + // SyncKit developer billing is USD regardless of the developer's
769 + // own settlement currency, same as the creator tiers and Fan+.
770 + format!(
771 + "{} / month",
772 + crate::formatting::format_revenue(cents, SettlementCurrency::Usd)
773 + )
769 774 } else {
770 775 String::new()
771 776 };
@@ -803,15 +808,15 @@
803 808 storage_rate_cents: crate::synckit_billing::STORAGE_RATE_CENTS_PER_GB,
804 809 base_floor_cents: crate::synckit_billing::BASE_FLOOR_CENTS,
805 810 max_storage_gb: crate::synckit_billing::MAX_STORAGE_GB as i32,
806 - rate_per_gb_display: format!(
807 - "${}.{:02}",
808 - crate::synckit_billing::STORAGE_RATE_CENTS_PER_GB / 100,
809 - crate::synckit_billing::STORAGE_RATE_CENTS_PER_GB % 100
811 + // USD: this is Make Creative's own price list for SyncKit, not a
812 + // creator's price for their own work.
813 + rate_per_gb_display: crate::formatting::format_revenue(
814 + crate::synckit_billing::STORAGE_RATE_CENTS_PER_GB,
815 + SettlementCurrency::Usd,
810 816 ),
811 - floor_display: format!(
812 - "${}.{:02}",
813 - crate::synckit_billing::BASE_FLOOR_CENTS / 100,
814 - crate::synckit_billing::BASE_FLOOR_CENTS % 100
817 + floor_display: crate::formatting::format_revenue(
818 + crate::synckit_billing::BASE_FLOOR_CENTS,
819 + SettlementCurrency::Usd,
815 820 ),
816 821 // Populated by the dashboard route for `per_key` mode after this
817 822 // call; default is empty so `from_db` stays usable in contexts
@@ -1,30 +1,48 @@
1 + //! Every aggregate here groups by `currency`.
2 + //!
3 + //! A creator's own sales are normally all in their own currency, so the common
4 + //! result has one entry. Two show up when a creator's settlement currency
5 + //! changed (old sales keep the currency they were written in) and on the
6 + //! platform-wide roll-ups, which span every creator. Summing those into one
7 + //! number would add pounds to dollars, so the type refuses to.
8 +
1 9 use super::{
2 10 Cents, ClaimToken, DbTransaction, DownloadToken, ItemId, PgPool, ProjectId, PromoCodeId,
3 11 Result, TransactionId, UserId,
4 12 };
13 + use crate::currency::{MoneyByCurrency, SettlementCurrency};
5 14
6 - /// Sum completed revenue and count sales for all items in a project.
15 + /// Completed revenue and sales count for all items in a project.
7 16 ///
8 - /// Returns `(total_revenue_cents, total_sales)`. Only completed transactions
9 - /// are counted; pending, failed, and refunded are excluded.
17 + /// Only completed transactions count; pending, failed and refunded are excluded.
18 + /// Revenue is grouped by currency: a project belongs to one creator, but if that
19 + /// creator's settlement currency ever changed, their older sales are denominated
20 + /// in the previous one and must not be added to the newer ones.
10 21 #[tracing::instrument(skip_all)]
11 - pub async fn get_revenue_by_project(pool: &PgPool, project_id: ProjectId) -> Result<(i64, i64)> {
12 - let row = sqlx::query!(
22 + pub async fn get_revenue_by_project(
23 + pool: &PgPool,
24 + project_id: ProjectId,
25 + ) -> Result<(MoneyByCurrency, i64)> {
26 + let rows = sqlx::query!(
13 27 r#"
14 28 SELECT
29 + t.currency AS "currency!: SettlementCurrency",
15 30 COALESCE(SUM(t.amount_cents), 0)::BIGINT AS "total!",
16 31 COUNT(*) AS "count!"
17 32 FROM transactions t
18 33 JOIN items i ON t.item_id = i.id
19 34 WHERE i.project_id = $1
20 35 AND t.status = 'completed'
36 + GROUP BY t.currency
21 37 "#,
22 38 project_id as ProjectId,
23 39 )
24 - .fetch_one(pool)
40 + .fetch_all(pool)
25 41 .await?;
26 42
27 - Ok((row.total, row.count))
43 + let sales = rows.iter().map(|r| r.count).sum();
44 + let revenue = MoneyByCurrency::from_rows(rows.into_iter().map(|r| (r.currency, r.total)));
45 + Ok((revenue, sales))
28 46 }
29 47
30 48 /// Revenue per project for a given seller, returned as (project_id, title, revenue_cents).
@@ -33,15 +51,22 @@
33 51 pub async fn get_revenue_by_user_projects(
34 52 pool: &PgPool,
35 53 user_id: UserId,
36 - ) -> Result<Vec<(ProjectId, String, i64)>> {
54 + ) -> Result<Vec<(ProjectId, String, MoneyByCurrency)>> {
55 + // Grouped by (project, currency) and folded per project in Rust. Ordering
56 + // stays on the project's largest single-currency total, because ranking
57 + // projects against each other across currencies is the thing there is no
58 + // honest rate for; within a project the currencies are then listed
59 + // largest-first by `MoneyByCurrency`.
37 60 let rows = sqlx::query!(
38 61 r#"
39 - SELECT p.id AS "id: ProjectId", p.title, COALESCE(SUM(t.amount_cents), 0)::BIGINT AS "revenue!"
62 + SELECT p.id AS "id: ProjectId", p.title,
63 + t.currency AS "currency!: SettlementCurrency",
64 + COALESCE(SUM(t.amount_cents), 0)::BIGINT AS "revenue!"
40 65 FROM projects p
41 - LEFT JOIN items i ON i.project_id = p.id
42 - LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed'
66 + JOIN items i ON i.project_id = p.id
67 + JOIN transactions t ON t.item_id = i.id AND t.status = 'completed'
43 68 WHERE p.user_id = $1
44 - GROUP BY p.id, p.title
69 + GROUP BY p.id, p.title, t.currency
45 70 HAVING COALESCE(SUM(t.amount_cents), 0) > 0
46 71 ORDER BY COALESCE(SUM(t.amount_cents), 0) DESC
47 72 "#,
@@ -50,9 +75,16 @@
50 75 .fetch_all(pool)
51 76 .await?;
52 77
53 - Ok(rows
78 + let mut out: Vec<(ProjectId, String, Vec<(SettlementCurrency, i64)>)> = Vec::new();
79 + for r in rows {
80 + match out.iter_mut().find(|(id, _, _)| *id == r.id) {
81 + Some(entry) => entry.2.push((r.currency, r.revenue)),
82 + None => out.push((r.id, r.title, vec![(r.currency, r.revenue)])),
83 + }
84 + }
85 + Ok(out
54 86 .into_iter()
55 - .map(|r| (r.id, r.title, r.revenue))
87 + .map(|(id, title, totals)| (id, title, MoneyByCurrency::from_rows(totals)))
56 88 .collect())
57 89 }
58 90
@@ -64,22 +96,26 @@
64 96 pool: &PgPool,
65 97 user_id: UserId,
66 98 range: &crate::db::analytics::TimeRange,
67 - ) -> Result<Vec<(ProjectId, String, i64, i64)>> {
99 + ) -> Result<Vec<(ProjectId, String, MoneyByCurrency, i64)>> {
68 100 let time_filter = match range.interval_sql() {
69 101 Some(interval) => format!(" AND t.completed_at >= NOW() - INTERVAL '{interval}'"),
70 102 None => String::new(),
71 103 };
72 104
105 + // The LEFT JOINs stay: this table lists every project the creator has,
106 + // including ones with no sales in the range, so a zero row is meaningful
107 + // here in a way it is not in the all-time list above. A project with no
108 + // sales yields a NULL currency, which folds to an empty `MoneyByCurrency`.
73 109 let sql = format!(
74 110 r"
75 - SELECT p.id, p.title,
111 + SELECT p.id, p.title, t.currency,
76 112 COALESCE(SUM(t.amount_cents), 0)::BIGINT,
77 113 COUNT(t.id)::BIGINT
78 114 FROM projects p
79 115 LEFT JOIN items i ON i.project_id = p.id
80 116 LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed'{time_filter}
81 117 WHERE p.user_id = $1
82 - GROUP BY p.id, p.title
118 + GROUP BY p.id, p.title, t.currency
83 119 ORDER BY COALESCE(SUM(t.amount_cents), 0) DESC
84 120 "
85 121 );
@@ -87,28 +123,52 @@
87 123 // runtime-checked: dynamically-built SQL string, the `time_filter` clause is
88 124 // conditionally interpolated via `format!`, so the query text isn't a literal
89 125 // and can't be compile-checked by the macro.
90 - let rows: Vec<(ProjectId, String, i64, i64)> =
126 + let rows: Vec<(ProjectId, String, Option<SettlementCurrency>, i64, i64)> =
91 127 sqlx::query_as(&sql).bind(user_id).fetch_all(pool).await?;
92 128
93 - Ok(rows)
129 + let mut out: Vec<(ProjectId, String, Vec<(SettlementCurrency, i64)>, i64)> = Vec::new();
130 + for (id, title, currency, revenue, sales) in rows {
131 + let totals = currency.map(|c| (c, revenue));
132 + match out.iter_mut().find(|(pid, _, _, _)| *pid == id) {
133 + Some(entry) => {
134 + entry.2.extend(totals);
135 + entry.3 += sales;
136 + }
137 + None => out.push((id, title, totals.into_iter().collect(), sales)),
138 + }
139 + }
140 + Ok(out
141 + .into_iter()
142 + .map(|(id, title, totals, sales)| (id, title, MoneyByCurrency::from_rows(totals), sales))
143 + .collect())
94 144 }
95 145
96 - /// Platform-wide revenue stats: total completed revenue, completed count, refunded count.
146 + /// Platform-wide revenue stats: completed revenue per currency, completed
147 + /// count, refunded count.
148 + ///
149 + /// This is the roll-up that spans every creator, so more than one currency is
150 + /// the expected case rather than the edge one.
97 151 #[tracing::instrument(skip_all)]
98 - pub async fn get_platform_revenue_stats(pool: &PgPool) -> Result<(i64, i64, i64)> {
99 - let row = sqlx::query!(
152 + pub async fn get_platform_revenue_stats(pool: &PgPool) -> Result<(MoneyByCurrency, i64, i64)> {
153 + let rows = sqlx::query!(
100 154 r#"
101 155 SELECT
156 + currency AS "currency!: SettlementCurrency",
102 157 COALESCE(SUM(CASE WHEN status = 'completed' THEN amount_cents ELSE 0 END), 0)::BIGINT AS "revenue!",
103 158 COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)::BIGINT AS "completed!",
104 159 COALESCE(SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END), 0)::BIGINT AS "refunded!"
105 160 FROM transactions
161 + GROUP BY currency
106 162 "#,
107 163 )
108 - .fetch_one(pool)
164 + .fetch_all(pool)
109 165 .await?;
110 166
111 - Ok((row.revenue, row.completed, row.refunded))
167 + // Counts are currency-free, so they still add up across the groups.
168 + let completed = rows.iter().map(|r| r.completed).sum();
169 + let refunded = rows.iter().map(|r| r.refunded).sum();
170 + let revenue = MoneyByCurrency::from_rows(rows.into_iter().map(|r| (r.currency, r.revenue)));
171 + Ok((revenue, completed, refunded))
112 172 }
113 173
114 174 /// Completed and refunded sales for a specific item, for the item dashboard Sales tab.
@@ -88,10 +88,25 @@
88 88 // Validate amount against minimum
89 89 let min = item.pwyw_min_cents.unwrap_or(0);
90 90 if body.amount_cents < min {
91 + // The floor is denominated in the seller's currency, so the message has
92 + // to name theirs and not the buyer's. Only looked up on the reject path.
93 + let seller_currency = db::projects::get_project_by_id(&db, item.project_id)
94 + .await
95 + .ok()
96 + .flatten()
97 + .map(|p| p.user_id);
98 + let seller_currency = match seller_currency {
99 + Some(uid) => db::users::get_user_by_id(&db, uid)
100 + .await
101 + .ok()
102 + .flatten()
103 + .map(|u| u.settlement_currency)
104 + .unwrap_or_default(),
105 + None => crate::currency::SettlementCurrency::default(),
106 + };
91 107 return Err(AppError::BadRequest(format!(
92 - "Amount must be at least ${}.{:02}.",
93 - min / 100,
94 - min % 100
108 + "Amount must be at least {}.",
109 + crate::formatting::format_revenue(i64::from(min), seller_currency)
95 110 )));
96 111 }
97 112
@@ -31,7 +31,17 @@
31 31 project_type: ProjectType,
32 32 is_public: bool,
33 33 item_count: i64,
34 + /// Revenue in `currency`, which is the project's largest single-currency
35 + /// total. Kept as a bare number so existing consumers keep working, but it
36 + /// is only meaningful next to `currency`: a consumer that assumes USD is
37 + /// wrong the moment the creator settles anywhere else.
34 38 revenue_cents: i64,
39 + /// The ISO code `revenue_cents` is denominated in, lowercase.
40 + currency: String,
41 + /// Every currency this project earned in, for the uncommon case where a
42 + /// creator's settlement currency changed and older sales are denominated in
43 + /// the previous one. Normally a single entry matching `revenue_cents`.
44 + revenue_cents_by_currency: std::collections::BTreeMap<String, i64>,
35 45 }
36 46
37 47 /// GET /api/internal/creator/projects?user_id={uuid}
@@ -48,10 +58,11 @@
48 58 let revenue = db::transactions::get_revenue_by_user_projects(&db, actor.user_id()).await?;
49 59
50 60 // Build revenue lookup: project_id -> cents
51 - let revenue_map: std::collections::HashMap<ProjectId, i64> = revenue
52 - .into_iter()
53 - .map(|(pid, _title, cents)| (pid, cents))
54 - .collect();
61 + let revenue_map: std::collections::HashMap<ProjectId, crate::currency::MoneyByCurrency> =
62 + revenue
63 + .into_iter()
64 + .map(|(pid, _title, money)| (pid, money))
65 + .collect();
55 66
56 67 // Count items per project in a single query
57 68 let item_counts = db::items::count_items_by_user_projects(&db, actor.user_id()).await?;
@@ -66,7 +77,9 @@
66 77 project_type: p.project_type,
67 78 is_public: p.is_public,
68 79 item_count: count_map.get(&p.id).copied().unwrap_or(0),
69 - revenue_cents: revenue_map.get(&p.id).copied().unwrap_or(0),
80 + revenue_cents: money_for(&revenue_map, p.id).0,
81 + currency: money_for(&revenue_map, p.id).1,
82 + revenue_cents_by_currency: revenue_map.get(&p.id).map(by_currency).unwrap_or_default(),
70 83 })
71 84 .collect();
72 85
@@ -192,7 +205,13 @@
192 205 struct ProjectRevenueSummary {
193 206 id: ProjectId,
194 207 title: String,
208 + /// Revenue in `currency`. See `CreatorProject::revenue_cents`.
195 209 revenue_cents: i64,
210 + /// The ISO code `revenue_cents` is denominated in, lowercase.
211 + currency: String,
212 + /// Every currency this project earned in. See
213 + /// `CreatorProject::revenue_cents_by_currency`.
214 + revenue_cents_by_currency: std::collections::BTreeMap<String, i64>,
196 215 }
197 216
198 217 #[derive(Serialize)]
@@ -230,10 +249,15 @@
230 249
231 250 let top_projects: Vec<ProjectRevenueSummary> = revenue
232 251 .into_iter()
233 - .map(|(id, title, cents)| ProjectRevenueSummary {
234 - id,
235 - title,
236 - revenue_cents: cents,
252 + .map(|(id, title, money)| {
253 + let (revenue_cents, currency) = dominant(&money);
254 + ProjectRevenueSummary {
255 + id,
256 + title,
257 + revenue_cents,
258 + currency,
259 + revenue_cents_by_currency: by_currency(&money),
260 + }
237 261 })
238 262 .collect();
239 263
@@ -367,3 +391,49 @@
367 391
368 392 Ok(Json(serde_json::json!({ "csv": csv, "row_count": total })))
369 393 }
394 +
395 + /// The largest single-currency total and its ISO code.
396 + ///
397 + /// "Largest" rather than a sum, because there is no rate that would make a sum
398 + /// true. A project with no sales reports zero in USD, the platform default.
399 + fn dominant(money: &crate::currency::MoneyByCurrency) -> (i64, String) {
400 + money.iter().max_by_key(|(_, cents)| *cents).map_or_else(
401 + || {
402 + (
403 + 0,
404 + crate::currency::SettlementCurrency::default()
405 + .code()
406 + .to_string(),
407 + )
408 + },
409 + |(currency, cents)| (cents, currency.code().to_string()),
410 + )
411 + }
412 +
413 + /// Every currency, keyed by lowercase ISO code.
414 + fn by_currency(
415 + money: &crate::currency::MoneyByCurrency,
416 + ) -> std::collections::BTreeMap<String, i64> {
417 + money
418 + .iter()
419 + .map(|(c, cents)| (c.code().to_string(), cents))
420 + .collect()
421 + }
422 +
423 + /// `dominant` for a project id that may have no revenue row at all.
424 + fn money_for(
425 + map: &std::collections::HashMap<ProjectId, crate::currency::MoneyByCurrency>,
426 + id: ProjectId,
427 + ) -> (i64, String) {
428 + map.get(&id).map_or_else(
429 + || {
430 + (
431 + 0,
432 + crate::currency::SettlementCurrency::default()
433 + .code()
434 + .to_string(),
435 + )
436 + },
437 + dominant,
438 + )
439 + }