Skip to main content

max / makenotwork

21.3 KB · 592 lines History Blame Raw
1 //! Settlement currency: the one currency a creator is paid in.
2 //!
3 //! A creator's settlement currency comes from `default_currency` on their Stripe
4 //! Connect account, and every price they set is denominated in it. There is no
5 //! per-project currency and no per-fan presentment pricing: one creator, one
6 //! currency, and a checkout session is always created in the currency of the
7 //! single seller it belongs to.
8 //!
9 //! What this module deliberately does not hold: exchange rates, rounding policy,
10 //! and per-currency price tables. Conversion is Stripe's job, either at checkout
11 //! (Adaptive Pricing) or at the buyer's card issuer. See the `stripe` and
12 //! `payouts` guides, and wiki `mnw-settlement-currency`.
13
14 use crate::error::AppError;
15
16 /// The currencies a creator can settle in.
17 ///
18 /// All six are two-decimal, prefix-symbol currencies, which is why the whole
19 /// codebase can keep saying "cents" and never grow zero-decimal (JPY) or
20 /// exponent handling. Adding a currency outside that shape means revisiting
21 /// every `_cents` type, not just this enum.
22 #[derive(
23 Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
24 )]
25 #[serde(rename_all = "lowercase")]
26 pub enum SettlementCurrency {
27 /// The default for accounts that predate settlement currency, and for
28 /// MNW's own membership billing, which is USD regardless of the creator.
29 #[default]
30 Usd,
31 Cad,
32 Gbp,
33 Aud,
34 Nzd,
35 Eur,
36 }
37
38 impl SettlementCurrency {
39 /// Every supported currency, for iteration in tests and admin surfaces.
40 pub const ALL: [SettlementCurrency; 6] = [
41 Self::Usd,
42 Self::Cad,
43 Self::Gbp,
44 Self::Aud,
45 Self::Nzd,
46 Self::Eur,
47 ];
48
49 /// Lowercase ISO 4217 code, the form Stripe's API and our DB column use.
50 pub fn code(self) -> &'static str {
51 match self {
52 Self::Usd => "usd",
53 Self::Cad => "cad",
54 Self::Gbp => "gbp",
55 Self::Aud => "aud",
56 Self::Nzd => "nzd",
57 Self::Eur => "eur",
58 }
59 }
60
61 /// Uppercase ISO 4217 code, for display next to an amount.
62 pub fn code_upper(self) -> &'static str {
63 match self {
64 Self::Usd => "USD",
65 Self::Cad => "CAD",
66 Self::Gbp => "GBP",
67 Self::Aud => "AUD",
68 Self::Nzd => "NZD",
69 Self::Eur => "EUR",
70 }
71 }
72
73 /// The symbol to prefix an amount with.
74 ///
75 /// The dollar currencies carry their region prefix (`CA$`, `A$`, `NZ$`)
76 /// because a buyer reading a price has no other way to tell them apart, and
77 /// a bare `$` on a Canadian creator's page reads as USD to most of the web.
78 /// USD keeps the bare `$`, which is what every existing price renders as.
79 pub fn symbol(self) -> &'static str {
80 match self {
81 Self::Usd => "$",
82 Self::Cad => "CA$",
83 Self::Gbp => "\u{a3}",
84 Self::Aud => "A$",
85 Self::Nzd => "NZ$",
86 Self::Eur => "\u{20ac}",
87 }
88 }
89
90 /// Stripe's minimum charge amount, in minor units, for this settlement
91 /// currency.
92 ///
93 /// Not a flat 50: GBP is 30. Stripe enforces the minimum of the *settlement*
94 /// currency, and we always create the session in the creator's settlement
95 /// currency, so this is the only minimum that applies. On the convert-at-
96 /// checkout path Stripe derives the presented amount itself, so there is no
97 /// second presentment-side floor for us to check.
98 pub fn minimum_charge_cents(self) -> i64 {
99 match self {
100 Self::Gbp => 30,
101 Self::Usd | Self::Cad | Self::Aud | Self::Nzd | Self::Eur => 50,
102 }
103 }
104
105 /// The ceiling on a single price, in minor units.
106 ///
107 /// A round 10,000 in the creator's own currency rather than a USD
108 /// equivalence. Equivalence would need an exchange-rate table, which is
109 /// exactly what this design refuses to hold, and a cap that drifts with the
110 /// pound would be worse than one a creator can state.
111 pub fn max_price_cents(self) -> i32 {
112 match self {
113 Self::Usd | Self::Cad | Self::Gbp | Self::Aud | Self::Nzd | Self::Eur => {
114 crate::constants::MAX_PRICE_CENTS
115 }
116 }
117 }
118
119 /// Parse an ISO code, case-insensitively.
120 ///
121 /// Returns `None` rather than erroring so callers can decide whether an
122 /// unsupported currency is a validation failure (a creator's Stripe account
123 /// settles somewhere we don't support) or a fall-back-to-USD read of a row
124 /// written before this column existed.
125 pub fn from_code(code: &str) -> Option<Self> {
126 match code.trim().to_ascii_lowercase().as_str() {
127 "usd" => Some(Self::Usd),
128 "cad" => Some(Self::Cad),
129 "gbp" => Some(Self::Gbp),
130 "aud" => Some(Self::Aud),
131 "nzd" => Some(Self::Nzd),
132 "eur" => Some(Self::Eur),
133 _ => None,
134 }
135 }
136
137 /// Read a currency written by a previous version of this code.
138 ///
139 /// The column is `NOT NULL DEFAULT 'usd'`, so a missing value is not
140 /// expected. An *unrecognised* one is: a creator's Stripe account could be
141 /// switched to a currency we don't support after their row was written.
142 /// Falling back to USD keeps their dashboard rendering; the settlement
143 /// currency itself is re-read from Stripe on every account webhook.
144 pub fn from_db(code: &str) -> Self {
145 Self::from_code(code).unwrap_or_default()
146 }
147
148 /// Parse the `default_currency` Stripe reports on a Connect account.
149 ///
150 /// Errors rather than defaulting: silently treating an unsupported
151 /// settlement currency as USD is how a creator ends up with every price
152 /// denominated in a currency they cannot be paid in.
153 pub fn from_stripe_account(code: &str) -> Result<Self, AppError> {
154 Self::from_code(code).ok_or_else(|| {
155 AppError::BadRequest(format!(
156 "MNW cannot yet pay out in {}. Supported settlement currencies are {}.",
157 code.to_ascii_uppercase(),
158 Self::ALL
159 .iter()
160 .map(|c| c.code_upper())
161 .collect::<Vec<_>>()
162 .join(", ")
163 ))
164 })
165 }
166
167 /// The `stripe_types` enum, for the API calls that take one.
168 pub fn to_stripe(self) -> stripe_types::Currency {
169 match self {
170 Self::Usd => stripe_types::Currency::USD,
171 Self::Cad => stripe_types::Currency::CAD,
172 Self::Gbp => stripe_types::Currency::GBP,
173 Self::Aud => stripe_types::Currency::AUD,
174 Self::Nzd => stripe_types::Currency::NZD,
175 Self::Eur => stripe_types::Currency::EUR,
176 }
177 }
178 }
179
180 impl std::fmt::Display for SettlementCurrency {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.write_str(self.code_upper())
183 }
184 }
185
186 /// How a buyer chose to handle paying in a currency that is not theirs.
187 ///
188 /// Only bites when the buyer's currency differs from the seller's; when they
189 /// match, there is nothing to convert and both values behave identically.
190 ///
191 /// This is a stored preference rather than a per-purchase question, so a
192 /// returning buyer is not asked every time. It stays changeable at checkout: a
193 /// preference, not a lock.
194 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
195 #[serde(rename_all = "lowercase")]
196 pub enum ConversionChoice {
197 /// Stripe converts, and presents the price in the buyer's local currency.
198 /// Its rate carries a conversion fee (Stripe publishes 2-4% and does not
199 /// break it out as a separate number anywhere we can read).
200 ///
201 /// The default, because it is the path where the buyer sees the real total
202 /// on Stripe's own page before committing to it. The alternative can only
203 /// ever be described, never quoted.
204 #[default]
205 AtCheckout,
206 /// The session stays in the seller's currency and the buyer's card issuer
207 /// converts at its own rate, which MNW cannot see, quote, or predict.
208 ByBuyersBank,
209 }
210
211 impl ConversionChoice {
212 /// The `adaptive_pricing.enabled` value this choice maps to.
213 ///
214 /// Adaptive Pricing is what does the conversion on a Stripe-hosted Checkout
215 /// Session. Turning it off does not change the session's currency; it just
216 /// stops Stripe presenting a converted price, which leaves the buyer's bank
217 /// to do the job.
218 pub fn adaptive_pricing_enabled(self) -> bool {
219 matches!(self, Self::AtCheckout)
220 }
221
222 /// Parse the checkout form field, defaulting to the safer path.
223 ///
224 /// Anything unrecognised reads as `AtCheckout`: a mangled form value should
225 /// land the buyer on the path where the cost is visible, not the one where
226 /// it is invisible until their statement arrives.
227 pub fn from_form_value(raw: Option<&str>) -> Self {
228 match raw {
229 Some("bank") => Self::ByBuyersBank,
230 _ => Self::AtCheckout,
231 }
232 }
233
234 /// The value the checkout form posts, and the value stored in the column.
235 pub fn as_form_value(self) -> &'static str {
236 match self {
237 Self::AtCheckout => "checkout",
238 Self::ByBuyersBank => "bank",
239 }
240 }
241
242 /// Read a stored preference, defaulting anything unrecognised.
243 pub fn from_db(raw: &str) -> Self {
244 Self::from_form_value(Some(raw))
245 }
246 }
247
248 /// A money total that may span more than one currency.
249 ///
250 /// Revenue queries return this instead of a bare `i64` so that adding pounds to
251 /// dollars stops being expressible. There is no `total()` and no conversion:
252 /// MNW holds no exchange-rate table, and a single number spanning currencies
253 /// would be a lie whichever rate produced it.
254 ///
255 /// The common case is one currency, and callers should stay cheap for it — a
256 /// creator's own projects are all in their own currency. Two currencies show up
257 /// on the uncommon path: a creator who takes revenue splits from another
258 /// creator's project is paid in *that* project's currency, and a creator whose
259 /// settlement currency changed has historical sales denominated in the old one.
260 ///
261 /// Ordering is largest-first, so the biggest number leads wherever this is
262 /// rendered.
263 #[derive(Clone, Debug, Default, PartialEq, Eq)]
264 pub struct MoneyByCurrency {
265 totals: Vec<(SettlementCurrency, i64)>,
266 }
267
268 impl MoneyByCurrency {
269 /// Build from `(currency, cents)` rows, dropping zeroes and combining
270 /// duplicates. Zero-dropping is what keeps the common case at one entry: a
271 /// `LEFT JOIN` that found no sales contributes nothing rather than a
272 /// spurious second currency reading `£0.00`.
273 pub fn from_rows(rows: impl IntoIterator<Item = (SettlementCurrency, i64)>) -> Self {
274 let mut totals: Vec<(SettlementCurrency, i64)> = Vec::new();
275 for (currency, cents) in rows {
276 if cents == 0 {
277 continue;
278 }
279 match totals.iter_mut().find(|(c, _)| *c == currency) {
280 Some(entry) => entry.1 += cents,
281 None => totals.push((currency, cents)),
282 }
283 }
284 totals.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.code().cmp(b.0.code())));
285 Self { totals }
286 }
287
288 /// Nothing earned in any currency.
289 pub fn is_empty(&self) -> bool {
290 self.totals.is_empty()
291 }
292
293 /// How many distinct currencies this total spans.
294 pub fn currency_count(&self) -> usize {
295 self.totals.len()
296 }
297
298 pub fn iter(&self) -> impl Iterator<Item = (SettlementCurrency, i64)> + '_ {
299 self.totals.iter().copied()
300 }
301
302 /// The amount in one currency, or zero if there is none.
303 ///
304 /// For the callers that legitimately want a single currency's figure (a
305 /// creator's own sales in their own currency). It does not silently discard
306 /// the rest — pair it with [`Self::currency_count`] when that matters.
307 pub fn in_currency(&self, currency: SettlementCurrency) -> i64 {
308 self.totals
309 .iter()
310 .find(|(c, _)| *c == currency)
311 .map_or(0, |(_, cents)| *cents)
312 }
313
314 /// Render every currency, largest first, joined with `+`.
315 ///
316 /// One currency renders exactly as it always did (`$1,234.00`), so the
317 /// common case is unchanged. Two render as `£900.00 + $120.00`, which is
318 /// deliberately not a sum: the reader can see there are two currencies and
319 /// that MNW has not invented a rate between them.
320 ///
321 /// `fallback` supplies the currency for the empty case, where there is no
322 /// money to name a currency for — pass the viewer's own.
323 pub fn display(&self, fallback: SettlementCurrency) -> String {
324 if self.totals.is_empty() {
325 return crate::formatting::format_revenue(0, fallback);
326 }
327 self.totals
328 .iter()
329 .map(|(c, cents)| crate::formatting::format_revenue(*cents, *c))
330 .collect::<Vec<_>>()
331 .join(" + ")
332 }
333 }
334
335 // ── Postgres ──
336 //
337 // Stored as the lowercase ISO code in a `VARCHAR(3)` guarded by a CHECK listing
338 // the six. Decoding uses `from_db`, so a row that somehow holds an unsupported
339 // code renders as USD instead of failing the whole query: a dashboard that loads
340 // with one wrong symbol beats a dashboard that 500s. The CHECK is what stops such
341 // a row existing in the first place.
342
343 impl sqlx::Type<sqlx::Postgres> for SettlementCurrency {
344 fn type_info() -> sqlx::postgres::PgTypeInfo {
345 <str as sqlx::Type<sqlx::Postgres>>::type_info()
346 }
347
348 fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
349 <str as sqlx::Type<sqlx::Postgres>>::compatible(ty)
350 }
351 }
352
353 impl<'r> sqlx::Decode<'r, sqlx::Postgres> for SettlementCurrency {
354 fn decode(
355 value: sqlx::postgres::PgValueRef<'r>,
356 ) -> std::result::Result<Self, sqlx::error::BoxDynError> {
357 Ok(Self::from_db(
358 <&str as sqlx::Decode<sqlx::Postgres>>::decode(value)?,
359 ))
360 }
361 }
362
363 impl sqlx::Encode<'_, sqlx::Postgres> for SettlementCurrency {
364 fn encode_by_ref(
365 &self,
366 buf: &mut sqlx::postgres::PgArgumentBuffer,
367 ) -> std::result::Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
368 <&str as sqlx::Encode<sqlx::Postgres>>::encode(self.code(), buf)
369 }
370 }
371
372 impl sqlx::Type<sqlx::Postgres> for ConversionChoice {
373 fn type_info() -> sqlx::postgres::PgTypeInfo {
374 <str as sqlx::Type<sqlx::Postgres>>::type_info()
375 }
376
377 fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
378 <str as sqlx::Type<sqlx::Postgres>>::compatible(ty)
379 }
380 }
381
382 impl<'r> sqlx::Decode<'r, sqlx::Postgres> for ConversionChoice {
383 fn decode(
384 value: sqlx::postgres::PgValueRef<'r>,
385 ) -> std::result::Result<Self, sqlx::error::BoxDynError> {
386 Ok(Self::from_db(
387 <&str as sqlx::Decode<sqlx::Postgres>>::decode(value)?,
388 ))
389 }
390 }
391
392 impl sqlx::Encode<'_, sqlx::Postgres> for ConversionChoice {
393 fn encode_by_ref(
394 &self,
395 buf: &mut sqlx::postgres::PgArgumentBuffer,
396 ) -> std::result::Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
397 <&str as sqlx::Encode<sqlx::Postgres>>::encode(self.as_form_value(), buf)
398 }
399 }
400
401 #[cfg(test)]
402 mod tests {
403 use super::*;
404
405 #[test]
406 fn codes_round_trip() {
407 for c in SettlementCurrency::ALL {
408 assert_eq!(SettlementCurrency::from_code(c.code()), Some(c));
409 assert_eq!(SettlementCurrency::from_code(c.code_upper()), Some(c));
410 }
411 }
412
413 #[test]
414 fn from_code_is_case_and_whitespace_insensitive() {
415 assert_eq!(
416 SettlementCurrency::from_code(" GbP "),
417 Some(SettlementCurrency::Gbp)
418 );
419 }
420
421 #[test]
422 fn unsupported_code_is_none() {
423 // Zero-decimal, so it would break every `_cents` type in the codebase.
424 assert_eq!(SettlementCurrency::from_code("jpy"), None);
425 assert_eq!(SettlementCurrency::from_code(""), None);
426 }
427
428 #[test]
429 fn from_db_falls_back_to_usd() {
430 assert_eq!(SettlementCurrency::from_db("jpy"), SettlementCurrency::Usd);
431 assert_eq!(SettlementCurrency::from_db("eur"), SettlementCurrency::Eur);
432 }
433
434 #[test]
435 fn from_stripe_account_rejects_unsupported() {
436 let err = SettlementCurrency::from_stripe_account("jpy").unwrap_err();
437 let msg = err.to_string();
438 assert!(msg.contains("JPY"), "should name the currency: {msg}");
439 assert!(msg.contains("USD"), "should list what is supported: {msg}");
440 }
441
442 #[test]
443 fn gbp_is_the_only_thirty_cent_minimum() {
444 for c in SettlementCurrency::ALL {
445 let expected = if c == SettlementCurrency::Gbp { 30 } else { 50 };
446 assert_eq!(c.minimum_charge_cents(), expected, "{c}");
447 }
448 }
449
450 #[test]
451 fn dollar_currencies_are_disambiguated() {
452 // A bare `$` may only ever mean USD.
453 let bare: Vec<_> = SettlementCurrency::ALL
454 .iter()
455 .filter(|c| c.symbol() == "$")
456 .collect();
457 assert_eq!(bare, vec![&SettlementCurrency::Usd]);
458 }
459
460 #[test]
461 fn symbols_are_distinct() {
462 let mut seen = std::collections::HashSet::new();
463 for c in SettlementCurrency::ALL {
464 assert!(seen.insert(c.symbol()), "duplicate symbol for {c}");
465 }
466 }
467
468 #[test]
469 fn stripe_codes_agree_with_ours() {
470 for c in SettlementCurrency::ALL {
471 assert_eq!(c.to_stripe().to_string(), c.code(), "{c}");
472 }
473 }
474
475 // ── ConversionChoice ──
476
477 #[test]
478 fn a_mangled_form_value_lands_on_the_visible_path() {
479 // Anything unrecognised must default to convert-at-checkout, the path
480 // where the buyer sees the total before paying. Defaulting the other way
481 // would hide the cost behind a rate we cannot show.
482 for raw in [None, Some(""), Some("nonsense"), Some("CHECKOUT")] {
483 assert_eq!(
484 ConversionChoice::from_form_value(raw),
485 ConversionChoice::AtCheckout,
486 "{raw:?}"
487 );
488 }
489 assert_eq!(
490 ConversionChoice::from_form_value(Some("bank")),
491 ConversionChoice::ByBuyersBank
492 );
493 }
494
495 #[test]
496 fn the_choice_round_trips_through_the_form_and_the_column() {
497 for choice in [ConversionChoice::AtCheckout, ConversionChoice::ByBuyersBank] {
498 assert_eq!(ConversionChoice::from_db(choice.as_form_value()), choice);
499 }
500 }
501
502 #[test]
503 fn only_convert_at_checkout_turns_adaptive_pricing_on() {
504 // The flag is the entire mechanism, so the mapping must not drift.
505 assert!(ConversionChoice::AtCheckout.adaptive_pricing_enabled());
506 assert!(!ConversionChoice::ByBuyersBank.adaptive_pricing_enabled());
507 }
508
509 // ── MoneyByCurrency ──
510
511 fn money(rows: &[(SettlementCurrency, i64)]) -> MoneyByCurrency {
512 MoneyByCurrency::from_rows(rows.iter().copied())
513 }
514
515 #[test]
516 fn one_currency_renders_exactly_as_before() {
517 // The common case must be indistinguishable from the single-currency
518 // world, or every dashboard changes appearance for no reason.
519 let m = money(&[(SettlementCurrency::Usd, 123_456)]);
520 assert_eq!(m.display(SettlementCurrency::Usd), "$1,234.56");
521 assert_eq!(m.currency_count(), 1);
522 }
523
524 #[test]
525 fn two_currencies_are_listed_not_summed() {
526 let m = money(&[
527 (SettlementCurrency::Usd, 12_000),
528 (SettlementCurrency::Gbp, 90_000),
529 ]);
530 // Largest first, and visibly two amounts rather than one invented total.
531 assert_eq!(m.display(SettlementCurrency::Usd), "\u{a3}900.00 + $120.00");
532 assert_eq!(m.currency_count(), 2);
533 }
534
535 #[test]
536 fn duplicate_currencies_combine() {
537 let m = money(&[
538 (SettlementCurrency::Eur, 500),
539 (SettlementCurrency::Eur, 250),
540 ]);
541 assert_eq!(m.currency_count(), 1);
542 assert_eq!(m.in_currency(SettlementCurrency::Eur), 750);
543 }
544
545 #[test]
546 fn zero_rows_are_dropped_so_the_common_case_stays_single() {
547 // A LEFT JOIN that matched nothing must not invent a second currency.
548 let m = money(&[
549 (SettlementCurrency::Usd, 1000),
550 (SettlementCurrency::Gbp, 0),
551 ]);
552 assert_eq!(m.currency_count(), 1);
553 assert_eq!(m.display(SettlementCurrency::Usd), "$10.00");
554 }
555
556 #[test]
557 fn empty_renders_zero_in_the_viewers_currency() {
558 let m = MoneyByCurrency::default();
559 assert!(m.is_empty());
560 assert_eq!(m.display(SettlementCurrency::Gbp), "\u{a3}0.00");
561 }
562
563 #[test]
564 fn in_currency_does_not_leak_across_currencies() {
565 // The whole point: asking for dollars must never return pounds.
566 let m = money(&[(SettlementCurrency::Gbp, 5000)]);
567 assert_eq!(m.in_currency(SettlementCurrency::Usd), 0);
568 assert_eq!(m.in_currency(SettlementCurrency::Gbp), 5000);
569 }
570
571 #[test]
572 fn ordering_is_stable_for_equal_amounts() {
573 let a = money(&[
574 (SettlementCurrency::Usd, 100),
575 (SettlementCurrency::Gbp, 100),
576 ]);
577 let b = money(&[
578 (SettlementCurrency::Gbp, 100),
579 (SettlementCurrency::Usd, 100),
580 ]);
581 assert_eq!(
582 a.display(SettlementCurrency::Usd),
583 b.display(SettlementCurrency::Usd)
584 );
585 }
586
587 #[test]
588 fn default_is_usd() {
589 assert_eq!(SettlementCurrency::default(), SettlementCurrency::Usd);
590 }
591 }
592