//! Settlement currency: the one currency a creator is paid in. //! //! A creator's settlement currency comes from `default_currency` on their Stripe //! Connect account, and every price they set is denominated in it. There is no //! per-project currency and no per-fan presentment pricing: one creator, one //! currency, and a checkout session is always created in the currency of the //! single seller it belongs to. //! //! What this module deliberately does not hold: exchange rates, rounding policy, //! and per-currency price tables. Conversion is Stripe's job, either at checkout //! (Adaptive Pricing) or at the buyer's card issuer. See the `stripe` and //! `payouts` guides, and wiki `mnw-settlement-currency`. use crate::error::AppError; /// The currencies a creator can settle in. /// /// All six are two-decimal, prefix-symbol currencies, which is why the whole /// codebase can keep saying "cents" and never grow zero-decimal (JPY) or /// exponent handling. Adding a currency outside that shape means revisiting /// every `_cents` type, not just this enum. #[derive( Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, )] #[serde(rename_all = "lowercase")] pub enum SettlementCurrency { /// The default for accounts that predate settlement currency, and for /// MNW's own membership billing, which is USD regardless of the creator. #[default] Usd, Cad, Gbp, Aud, Nzd, Eur, } impl SettlementCurrency { /// Every supported currency, for iteration in tests and admin surfaces. pub const ALL: [SettlementCurrency; 6] = [ Self::Usd, Self::Cad, Self::Gbp, Self::Aud, Self::Nzd, Self::Eur, ]; /// Lowercase ISO 4217 code, the form Stripe's API and our DB column use. pub fn code(self) -> &'static str { match self { Self::Usd => "usd", Self::Cad => "cad", Self::Gbp => "gbp", Self::Aud => "aud", Self::Nzd => "nzd", Self::Eur => "eur", } } /// Uppercase ISO 4217 code, for display next to an amount. pub fn code_upper(self) -> &'static str { match self { Self::Usd => "USD", Self::Cad => "CAD", Self::Gbp => "GBP", Self::Aud => "AUD", Self::Nzd => "NZD", Self::Eur => "EUR", } } /// The symbol to prefix an amount with. /// /// The dollar currencies carry their region prefix (`CA$`, `A$`, `NZ$`) /// because a buyer reading a price has no other way to tell them apart, and /// a bare `$` on a Canadian creator's page reads as USD to most of the web. /// USD keeps the bare `$`, which is what every existing price renders as. pub fn symbol(self) -> &'static str { match self { Self::Usd => "$", Self::Cad => "CA$", Self::Gbp => "\u{a3}", Self::Aud => "A$", Self::Nzd => "NZ$", Self::Eur => "\u{20ac}", } } /// Stripe's minimum charge amount, in minor units, for this settlement /// currency. /// /// Not a flat 50: GBP is 30. Stripe enforces the minimum of the *settlement* /// currency, and we always create the session in the creator's settlement /// currency, so this is the only minimum that applies. On the convert-at- /// checkout path Stripe derives the presented amount itself, so there is no /// second presentment-side floor for us to check. pub fn minimum_charge_cents(self) -> i64 { match self { Self::Gbp => 30, Self::Usd | Self::Cad | Self::Aud | Self::Nzd | Self::Eur => 50, } } /// The ceiling on a single price, in minor units. /// /// A round 10,000 in the creator's own currency rather than a USD /// equivalence. Equivalence would need an exchange-rate table, which is /// exactly what this design refuses to hold, and a cap that drifts with the /// pound would be worse than one a creator can state. pub fn max_price_cents(self) -> i32 { match self { Self::Usd | Self::Cad | Self::Gbp | Self::Aud | Self::Nzd | Self::Eur => { crate::constants::MAX_PRICE_CENTS } } } /// Parse an ISO code, case-insensitively. /// /// Returns `None` rather than erroring so callers can decide whether an /// unsupported currency is a validation failure (a creator's Stripe account /// settles somewhere we don't support) or a fall-back-to-USD read of a row /// written before this column existed. pub fn from_code(code: &str) -> Option { match code.trim().to_ascii_lowercase().as_str() { "usd" => Some(Self::Usd), "cad" => Some(Self::Cad), "gbp" => Some(Self::Gbp), "aud" => Some(Self::Aud), "nzd" => Some(Self::Nzd), "eur" => Some(Self::Eur), _ => None, } } /// Read a currency written by a previous version of this code. /// /// The column is `NOT NULL DEFAULT 'usd'`, so a missing value is not /// expected. An *unrecognised* one is: a creator's Stripe account could be /// switched to a currency we don't support after their row was written. /// Falling back to USD keeps their dashboard rendering; the settlement /// currency itself is re-read from Stripe on every account webhook. pub fn from_db(code: &str) -> Self { Self::from_code(code).unwrap_or_default() } /// Parse the `default_currency` Stripe reports on a Connect account. /// /// Errors rather than defaulting: silently treating an unsupported /// settlement currency as USD is how a creator ends up with every price /// denominated in a currency they cannot be paid in. pub fn from_stripe_account(code: &str) -> Result { Self::from_code(code).ok_or_else(|| { AppError::BadRequest(format!( "MNW cannot yet pay out in {}. Supported settlement currencies are {}.", code.to_ascii_uppercase(), Self::ALL .iter() .map(|c| c.code_upper()) .collect::>() .join(", ") )) }) } /// The `stripe_types` enum, for the API calls that take one. pub fn to_stripe(self) -> stripe_types::Currency { match self { Self::Usd => stripe_types::Currency::USD, Self::Cad => stripe_types::Currency::CAD, Self::Gbp => stripe_types::Currency::GBP, Self::Aud => stripe_types::Currency::AUD, Self::Nzd => stripe_types::Currency::NZD, Self::Eur => stripe_types::Currency::EUR, } } } impl std::fmt::Display for SettlementCurrency { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.code_upper()) } } /// How a buyer chose to handle paying in a currency that is not theirs. /// /// Only bites when the buyer's currency differs from the seller's; when they /// match, there is nothing to convert and both values behave identically. /// /// This is a stored preference rather than a per-purchase question, so a /// returning buyer is not asked every time. It stays changeable at checkout: a /// preference, not a lock. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum ConversionChoice { /// Stripe converts, and presents the price in the buyer's local currency. /// Its rate carries a conversion fee (Stripe publishes 2-4% and does not /// break it out as a separate number anywhere we can read). /// /// The default, because it is the path where the buyer sees the real total /// on Stripe's own page before committing to it. The alternative can only /// ever be described, never quoted. #[default] AtCheckout, /// The session stays in the seller's currency and the buyer's card issuer /// converts at its own rate, which MNW cannot see, quote, or predict. ByBuyersBank, } impl ConversionChoice { /// The `adaptive_pricing.enabled` value this choice maps to. /// /// Adaptive Pricing is what does the conversion on a Stripe-hosted Checkout /// Session. Turning it off does not change the session's currency; it just /// stops Stripe presenting a converted price, which leaves the buyer's bank /// to do the job. pub fn adaptive_pricing_enabled(self) -> bool { matches!(self, Self::AtCheckout) } /// Parse the checkout form field, defaulting to the safer path. /// /// Anything unrecognised reads as `AtCheckout`: a mangled form value should /// land the buyer on the path where the cost is visible, not the one where /// it is invisible until their statement arrives. pub fn from_form_value(raw: Option<&str>) -> Self { match raw { Some("bank") => Self::ByBuyersBank, _ => Self::AtCheckout, } } /// The value the checkout form posts, and the value stored in the column. pub fn as_form_value(self) -> &'static str { match self { Self::AtCheckout => "checkout", Self::ByBuyersBank => "bank", } } /// Read a stored preference, defaulting anything unrecognised. pub fn from_db(raw: &str) -> Self { Self::from_form_value(Some(raw)) } } /// A money total that may span more than one currency. /// /// Revenue queries return this instead of a bare `i64` so that adding pounds to /// dollars stops being expressible. There is no `total()` and no conversion: /// MNW holds no exchange-rate table, and a single number spanning currencies /// would be a lie whichever rate produced it. /// /// The common case is one currency, and callers should stay cheap for it — a /// creator's own projects are all in their own currency. Two currencies show up /// on the uncommon path: a creator who takes revenue splits from another /// creator's project is paid in *that* project's currency, and a creator whose /// settlement currency changed has historical sales denominated in the old one. /// /// Ordering is largest-first, so the biggest number leads wherever this is /// rendered. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct MoneyByCurrency { totals: Vec<(SettlementCurrency, i64)>, } impl MoneyByCurrency { /// Build from `(currency, cents)` rows, dropping zeroes and combining /// duplicates. Zero-dropping is what keeps the common case at one entry: a /// `LEFT JOIN` that found no sales contributes nothing rather than a /// spurious second currency reading `£0.00`. pub fn from_rows(rows: impl IntoIterator) -> Self { let mut totals: Vec<(SettlementCurrency, i64)> = Vec::new(); for (currency, cents) in rows { if cents == 0 { continue; } match totals.iter_mut().find(|(c, _)| *c == currency) { Some(entry) => entry.1 += cents, None => totals.push((currency, cents)), } } totals.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.code().cmp(b.0.code()))); Self { totals } } /// Nothing earned in any currency. pub fn is_empty(&self) -> bool { self.totals.is_empty() } /// How many distinct currencies this total spans. pub fn currency_count(&self) -> usize { self.totals.len() } pub fn iter(&self) -> impl Iterator + '_ { self.totals.iter().copied() } /// The amount in one currency, or zero if there is none. /// /// For the callers that legitimately want a single currency's figure (a /// creator's own sales in their own currency). It does not silently discard /// the rest — pair it with [`Self::currency_count`] when that matters. pub fn in_currency(&self, currency: SettlementCurrency) -> i64 { self.totals .iter() .find(|(c, _)| *c == currency) .map_or(0, |(_, cents)| *cents) } /// Render every currency, largest first, joined with `+`. /// /// One currency renders exactly as it always did (`$1,234.00`), so the /// common case is unchanged. Two render as `£900.00 + $120.00`, which is /// deliberately not a sum: the reader can see there are two currencies and /// that MNW has not invented a rate between them. /// /// `fallback` supplies the currency for the empty case, where there is no /// money to name a currency for — pass the viewer's own. pub fn display(&self, fallback: SettlementCurrency) -> String { if self.totals.is_empty() { return crate::formatting::format_revenue(0, fallback); } self.totals .iter() .map(|(c, cents)| crate::formatting::format_revenue(*cents, *c)) .collect::>() .join(" + ") } } // ── Postgres ── // // Stored as the lowercase ISO code in a `VARCHAR(3)` guarded by a CHECK listing // the six. Decoding uses `from_db`, so a row that somehow holds an unsupported // code renders as USD instead of failing the whole query: a dashboard that loads // with one wrong symbol beats a dashboard that 500s. The CHECK is what stops such // a row existing in the first place. impl sqlx::Type for SettlementCurrency { fn type_info() -> sqlx::postgres::PgTypeInfo { >::type_info() } fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { >::compatible(ty) } } impl<'r> sqlx::Decode<'r, sqlx::Postgres> for SettlementCurrency { fn decode( value: sqlx::postgres::PgValueRef<'r>, ) -> std::result::Result { Ok(Self::from_db( <&str as sqlx::Decode>::decode(value)?, )) } } impl sqlx::Encode<'_, sqlx::Postgres> for SettlementCurrency { fn encode_by_ref( &self, buf: &mut sqlx::postgres::PgArgumentBuffer, ) -> std::result::Result { <&str as sqlx::Encode>::encode(self.code(), buf) } } impl sqlx::Type for ConversionChoice { fn type_info() -> sqlx::postgres::PgTypeInfo { >::type_info() } fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool { >::compatible(ty) } } impl<'r> sqlx::Decode<'r, sqlx::Postgres> for ConversionChoice { fn decode( value: sqlx::postgres::PgValueRef<'r>, ) -> std::result::Result { Ok(Self::from_db( <&str as sqlx::Decode>::decode(value)?, )) } } impl sqlx::Encode<'_, sqlx::Postgres> for ConversionChoice { fn encode_by_ref( &self, buf: &mut sqlx::postgres::PgArgumentBuffer, ) -> std::result::Result { <&str as sqlx::Encode>::encode(self.as_form_value(), buf) } } #[cfg(test)] mod tests { use super::*; #[test] fn codes_round_trip() { for c in SettlementCurrency::ALL { assert_eq!(SettlementCurrency::from_code(c.code()), Some(c)); assert_eq!(SettlementCurrency::from_code(c.code_upper()), Some(c)); } } #[test] fn from_code_is_case_and_whitespace_insensitive() { assert_eq!( SettlementCurrency::from_code(" GbP "), Some(SettlementCurrency::Gbp) ); } #[test] fn unsupported_code_is_none() { // Zero-decimal, so it would break every `_cents` type in the codebase. assert_eq!(SettlementCurrency::from_code("jpy"), None); assert_eq!(SettlementCurrency::from_code(""), None); } #[test] fn from_db_falls_back_to_usd() { assert_eq!(SettlementCurrency::from_db("jpy"), SettlementCurrency::Usd); assert_eq!(SettlementCurrency::from_db("eur"), SettlementCurrency::Eur); } #[test] fn from_stripe_account_rejects_unsupported() { let err = SettlementCurrency::from_stripe_account("jpy").unwrap_err(); let msg = err.to_string(); assert!(msg.contains("JPY"), "should name the currency: {msg}"); assert!(msg.contains("USD"), "should list what is supported: {msg}"); } #[test] fn gbp_is_the_only_thirty_cent_minimum() { for c in SettlementCurrency::ALL { let expected = if c == SettlementCurrency::Gbp { 30 } else { 50 }; assert_eq!(c.minimum_charge_cents(), expected, "{c}"); } } #[test] fn dollar_currencies_are_disambiguated() { // A bare `$` may only ever mean USD. let bare: Vec<_> = SettlementCurrency::ALL .iter() .filter(|c| c.symbol() == "$") .collect(); assert_eq!(bare, vec![&SettlementCurrency::Usd]); } #[test] fn symbols_are_distinct() { let mut seen = std::collections::HashSet::new(); for c in SettlementCurrency::ALL { assert!(seen.insert(c.symbol()), "duplicate symbol for {c}"); } } #[test] fn stripe_codes_agree_with_ours() { for c in SettlementCurrency::ALL { assert_eq!(c.to_stripe().to_string(), c.code(), "{c}"); } } // ── ConversionChoice ── #[test] fn a_mangled_form_value_lands_on_the_visible_path() { // Anything unrecognised must default to convert-at-checkout, the path // where the buyer sees the total before paying. Defaulting the other way // would hide the cost behind a rate we cannot show. for raw in [None, Some(""), Some("nonsense"), Some("CHECKOUT")] { assert_eq!( ConversionChoice::from_form_value(raw), ConversionChoice::AtCheckout, "{raw:?}" ); } assert_eq!( ConversionChoice::from_form_value(Some("bank")), ConversionChoice::ByBuyersBank ); } #[test] fn the_choice_round_trips_through_the_form_and_the_column() { for choice in [ConversionChoice::AtCheckout, ConversionChoice::ByBuyersBank] { assert_eq!(ConversionChoice::from_db(choice.as_form_value()), choice); } } #[test] fn only_convert_at_checkout_turns_adaptive_pricing_on() { // The flag is the entire mechanism, so the mapping must not drift. assert!(ConversionChoice::AtCheckout.adaptive_pricing_enabled()); assert!(!ConversionChoice::ByBuyersBank.adaptive_pricing_enabled()); } // ── MoneyByCurrency ── fn money(rows: &[(SettlementCurrency, i64)]) -> MoneyByCurrency { MoneyByCurrency::from_rows(rows.iter().copied()) } #[test] fn one_currency_renders_exactly_as_before() { // The common case must be indistinguishable from the single-currency // world, or every dashboard changes appearance for no reason. let m = money(&[(SettlementCurrency::Usd, 123_456)]); assert_eq!(m.display(SettlementCurrency::Usd), "$1,234.56"); assert_eq!(m.currency_count(), 1); } #[test] fn two_currencies_are_listed_not_summed() { let m = money(&[ (SettlementCurrency::Usd, 12_000), (SettlementCurrency::Gbp, 90_000), ]); // Largest first, and visibly two amounts rather than one invented total. assert_eq!(m.display(SettlementCurrency::Usd), "\u{a3}900.00 + $120.00"); assert_eq!(m.currency_count(), 2); } #[test] fn duplicate_currencies_combine() { let m = money(&[ (SettlementCurrency::Eur, 500), (SettlementCurrency::Eur, 250), ]); assert_eq!(m.currency_count(), 1); assert_eq!(m.in_currency(SettlementCurrency::Eur), 750); } #[test] fn zero_rows_are_dropped_so_the_common_case_stays_single() { // A LEFT JOIN that matched nothing must not invent a second currency. let m = money(&[ (SettlementCurrency::Usd, 1000), (SettlementCurrency::Gbp, 0), ]); assert_eq!(m.currency_count(), 1); assert_eq!(m.display(SettlementCurrency::Usd), "$10.00"); } #[test] fn empty_renders_zero_in_the_viewers_currency() { let m = MoneyByCurrency::default(); assert!(m.is_empty()); assert_eq!(m.display(SettlementCurrency::Gbp), "\u{a3}0.00"); } #[test] fn in_currency_does_not_leak_across_currencies() { // The whole point: asking for dollars must never return pounds. let m = money(&[(SettlementCurrency::Gbp, 5000)]); assert_eq!(m.in_currency(SettlementCurrency::Usd), 0); assert_eq!(m.in_currency(SettlementCurrency::Gbp), 5000); } #[test] fn ordering_is_stable_for_equal_amounts() { let a = money(&[ (SettlementCurrency::Usd, 100), (SettlementCurrency::Gbp, 100), ]); let b = money(&[ (SettlementCurrency::Gbp, 100), (SettlementCurrency::Usd, 100), ]); assert_eq!( a.display(SettlementCurrency::Usd), b.display(SettlementCurrency::Usd) ); } #[test] fn default_is_usd() { assert_eq!(SettlementCurrency::default(), SettlementCurrency::Usd); } }