max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | + | } |