Skip to main content

max / makenotwork

21.2 KB · 590 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 => 1_000_000,
114 }
115 }
116
117 /// Parse an ISO code, case-insensitively.
118 ///
119 /// Returns `None` rather than erroring so callers can decide whether an
120 /// unsupported currency is a validation failure (a creator's Stripe account
121 /// settles somewhere we don't support) or a fall-back-to-USD read of a row
122 /// written before this column existed.
123 pub fn from_code(code: &str) -> Option<Self> {
124 match code.trim().to_ascii_lowercase().as_str() {
125 "usd" => Some(Self::Usd),
126 "cad" => Some(Self::Cad),
127 "gbp" => Some(Self::Gbp),
128 "aud" => Some(Self::Aud),
129 "nzd" => Some(Self::Nzd),
130 "eur" => Some(Self::Eur),
131 _ => None,
132 }
133 }
134
135 /// Read a currency written by a previous version of this code.
136 ///
137 /// The column is `NOT NULL DEFAULT 'usd'`, so a missing value is not
138 /// expected. An *unrecognised* one is: a creator's Stripe account could be
139 /// switched to a currency we don't support after their row was written.
140 /// Falling back to USD keeps their dashboard rendering; the settlement
141 /// currency itself is re-read from Stripe on every account webhook.
142 pub fn from_db(code: &str) -> Self {
143 Self::from_code(code).unwrap_or_default()
144 }
145
146 /// Parse the `default_currency` Stripe reports on a Connect account.
147 ///
148 /// Errors rather than defaulting: silently treating an unsupported
149 /// settlement currency as USD is how a creator ends up with every price
150 /// denominated in a currency they cannot be paid in.
151 pub fn from_stripe_account(code: &str) -> Result<Self, AppError> {
152 Self::from_code(code).ok_or_else(|| {
153 AppError::BadRequest(format!(
154 "MNW cannot yet pay out in {}. Supported settlement currencies are {}.",
155 code.to_ascii_uppercase(),
156 Self::ALL
157 .iter()
158 .map(|c| c.code_upper())
159 .collect::<Vec<_>>()
160 .join(", ")
161 ))
162 })
163 }
164
165 /// The `stripe_types` enum, for the API calls that take one.
166 pub fn to_stripe(self) -> stripe_types::Currency {
167 match self {
168 Self::Usd => stripe_types::Currency::USD,
169 Self::Cad => stripe_types::Currency::CAD,
170 Self::Gbp => stripe_types::Currency::GBP,
171 Self::Aud => stripe_types::Currency::AUD,
172 Self::Nzd => stripe_types::Currency::NZD,
173 Self::Eur => stripe_types::Currency::EUR,
174 }
175 }
176 }
177
178 impl std::fmt::Display for SettlementCurrency {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 f.write_str(self.code_upper())
181 }
182 }
183
184 /// How a buyer chose to handle paying in a currency that is not theirs.
185 ///
186 /// Only bites when the buyer's currency differs from the seller's; when they
187 /// match, there is nothing to convert and both values behave identically.
188 ///
189 /// This is a stored preference rather than a per-purchase question, so a
190 /// returning buyer is not asked every time. It stays changeable at checkout: a
191 /// preference, not a lock.
192 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
193 #[serde(rename_all = "lowercase")]
194 pub enum ConversionChoice {
195 /// Stripe converts, and presents the price in the buyer's local currency.
196 /// Its rate carries a conversion fee (Stripe publishes 2-4% and does not
197 /// break it out as a separate number anywhere we can read).
198 ///
199 /// The default, because it is the path where the buyer sees the real total
200 /// on Stripe's own page before committing to it. The alternative can only
201 /// ever be described, never quoted.
202 #[default]
203 AtCheckout,
204 /// The session stays in the seller's currency and the buyer's card issuer
205 /// converts at its own rate, which MNW cannot see, quote, or predict.
206 ByBuyersBank,
207 }
208
209 impl ConversionChoice {
210 /// The `adaptive_pricing.enabled` value this choice maps to.
211 ///
212 /// Adaptive Pricing is what does the conversion on a Stripe-hosted Checkout
213 /// Session. Turning it off does not change the session's currency; it just
214 /// stops Stripe presenting a converted price, which leaves the buyer's bank
215 /// to do the job.
216 pub fn adaptive_pricing_enabled(self) -> bool {
217 matches!(self, Self::AtCheckout)
218 }
219
220 /// Parse the checkout form field, defaulting to the safer path.
221 ///
222 /// Anything unrecognised reads as `AtCheckout`: a mangled form value should
223 /// land the buyer on the path where the cost is visible, not the one where
224 /// it is invisible until their statement arrives.
225 pub fn from_form_value(raw: Option<&str>) -> Self {
226 match raw {
227 Some("bank") => Self::ByBuyersBank,
228 _ => Self::AtCheckout,
229 }
230 }
231
232 /// The value the checkout form posts, and the value stored in the column.
233 pub fn as_form_value(self) -> &'static str {
234 match self {
235 Self::AtCheckout => "checkout",
236 Self::ByBuyersBank => "bank",
237 }
238 }
239
240 /// Read a stored preference, defaulting anything unrecognised.
241 pub fn from_db(raw: &str) -> Self {
242 Self::from_form_value(Some(raw))
243 }
244 }
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
333 // ── Postgres ──
334 //
335 // Stored as the lowercase ISO code in a `VARCHAR(3)` guarded by a CHECK listing
336 // the six. Decoding uses `from_db`, so a row that somehow holds an unsupported
337 // code renders as USD instead of failing the whole query: a dashboard that loads
338 // with one wrong symbol beats a dashboard that 500s. The CHECK is what stops such
339 // a row existing in the first place.
340
341 impl sqlx::Type<sqlx::Postgres> for SettlementCurrency {
342 fn type_info() -> sqlx::postgres::PgTypeInfo {
343 <str as sqlx::Type<sqlx::Postgres>>::type_info()
344 }
345
346 fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
347 <str as sqlx::Type<sqlx::Postgres>>::compatible(ty)
348 }
349 }
350
351 impl<'r> sqlx::Decode<'r, sqlx::Postgres> for SettlementCurrency {
352 fn decode(
353 value: sqlx::postgres::PgValueRef<'r>,
354 ) -> std::result::Result<Self, sqlx::error::BoxDynError> {
355 Ok(Self::from_db(
356 <&str as sqlx::Decode<sqlx::Postgres>>::decode(value)?,
357 ))
358 }
359 }
360
361 impl sqlx::Encode<'_, sqlx::Postgres> for SettlementCurrency {
362 fn encode_by_ref(
363 &self,
364 buf: &mut sqlx::postgres::PgArgumentBuffer,
365 ) -> std::result::Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
366 <&str as sqlx::Encode<sqlx::Postgres>>::encode(self.code(), buf)
367 }
368 }
369
370 impl sqlx::Type<sqlx::Postgres> for ConversionChoice {
371 fn type_info() -> sqlx::postgres::PgTypeInfo {
372 <str as sqlx::Type<sqlx::Postgres>>::type_info()
373 }
374
375 fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
376 <str as sqlx::Type<sqlx::Postgres>>::compatible(ty)
377 }
378 }
379
380 impl<'r> sqlx::Decode<'r, sqlx::Postgres> for ConversionChoice {
381 fn decode(
382 value: sqlx::postgres::PgValueRef<'r>,
383 ) -> std::result::Result<Self, sqlx::error::BoxDynError> {
384 Ok(Self::from_db(
385 <&str as sqlx::Decode<sqlx::Postgres>>::decode(value)?,
386 ))
387 }
388 }
389
390 impl sqlx::Encode<'_, sqlx::Postgres> for ConversionChoice {
391 fn encode_by_ref(
392 &self,
393 buf: &mut sqlx::postgres::PgArgumentBuffer,
394 ) -> std::result::Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
395 <&str as sqlx::Encode<sqlx::Postgres>>::encode(self.as_form_value(), buf)
396 }
397 }
398
399 #[cfg(test)]
400 mod tests {
401 use super::*;
402
403 #[test]
404 fn codes_round_trip() {
405 for c in SettlementCurrency::ALL {
406 assert_eq!(SettlementCurrency::from_code(c.code()), Some(c));
407 assert_eq!(SettlementCurrency::from_code(c.code_upper()), Some(c));
408 }
409 }
410
411 #[test]
412 fn from_code_is_case_and_whitespace_insensitive() {
413 assert_eq!(
414 SettlementCurrency::from_code(" GbP "),
415 Some(SettlementCurrency::Gbp)
416 );
417 }
418
419 #[test]
420 fn unsupported_code_is_none() {
421 // Zero-decimal, so it would break every `_cents` type in the codebase.
422 assert_eq!(SettlementCurrency::from_code("jpy"), None);
423 assert_eq!(SettlementCurrency::from_code(""), None);
424 }
425
426 #[test]
427 fn from_db_falls_back_to_usd() {
428 assert_eq!(SettlementCurrency::from_db("jpy"), SettlementCurrency::Usd);
429 assert_eq!(SettlementCurrency::from_db("eur"), SettlementCurrency::Eur);
430 }
431
432 #[test]
433 fn from_stripe_account_rejects_unsupported() {
434 let err = SettlementCurrency::from_stripe_account("jpy").unwrap_err();
435 let msg = err.to_string();
436 assert!(msg.contains("JPY"), "should name the currency: {msg}");
437 assert!(msg.contains("USD"), "should list what is supported: {msg}");
438 }
439
440 #[test]
441 fn gbp_is_the_only_thirty_cent_minimum() {
442 for c in SettlementCurrency::ALL {
443 let expected = if c == SettlementCurrency::Gbp { 30 } else { 50 };
444 assert_eq!(c.minimum_charge_cents(), expected, "{c}");
445 }
446 }
447
448 #[test]
449 fn dollar_currencies_are_disambiguated() {
450 // A bare `$` may only ever mean USD.
451 let bare: Vec<_> = SettlementCurrency::ALL
452 .iter()
453 .filter(|c| c.symbol() == "$")
454 .collect();
455 assert_eq!(bare, vec![&SettlementCurrency::Usd]);
456 }
457
458 #[test]
459 fn symbols_are_distinct() {
460 let mut seen = std::collections::HashSet::new();
461 for c in SettlementCurrency::ALL {
462 assert!(seen.insert(c.symbol()), "duplicate symbol for {c}");
463 }
464 }
465
466 #[test]
467 fn stripe_codes_agree_with_ours() {
468 for c in SettlementCurrency::ALL {
469 assert_eq!(c.to_stripe().to_string(), c.code(), "{c}");
470 }
471 }
472
473 // ── ConversionChoice ──
474
475 #[test]
476 fn a_mangled_form_value_lands_on_the_visible_path() {
477 // Anything unrecognised must default to convert-at-checkout, the path
478 // where the buyer sees the total before paying. Defaulting the other way
479 // would hide the cost behind a rate we cannot show.
480 for raw in [None, Some(""), Some("nonsense"), Some("CHECKOUT")] {
481 assert_eq!(
482 ConversionChoice::from_form_value(raw),
483 ConversionChoice::AtCheckout,
484 "{raw:?}"
485 );
486 }
487 assert_eq!(
488 ConversionChoice::from_form_value(Some("bank")),
489 ConversionChoice::ByBuyersBank
490 );
491 }
492
493 #[test]
494 fn the_choice_round_trips_through_the_form_and_the_column() {
495 for choice in [ConversionChoice::AtCheckout, ConversionChoice::ByBuyersBank] {
496 assert_eq!(ConversionChoice::from_db(choice.as_form_value()), choice);
497 }
498 }
499
500 #[test]
501 fn only_convert_at_checkout_turns_adaptive_pricing_on() {
502 // The flag is the entire mechanism, so the mapping must not drift.
503 assert!(ConversionChoice::AtCheckout.adaptive_pricing_enabled());
504 assert!(!ConversionChoice::ByBuyersBank.adaptive_pricing_enabled());
505 }
506
507 // ── MoneyByCurrency ──
508
509 fn money(rows: &[(SettlementCurrency, i64)]) -> MoneyByCurrency {
510 MoneyByCurrency::from_rows(rows.iter().copied())
511 }
512
513 #[test]
514 fn one_currency_renders_exactly_as_before() {
515 // The common case must be indistinguishable from the single-currency
516 // world, or every dashboard changes appearance for no reason.
517 let m = money(&[(SettlementCurrency::Usd, 123_456)]);
518 assert_eq!(m.display(SettlementCurrency::Usd), "$1,234.56");
519 assert_eq!(m.currency_count(), 1);
520 }
521
522 #[test]
523 fn two_currencies_are_listed_not_summed() {
524 let m = money(&[
525 (SettlementCurrency::Usd, 12_000),
526 (SettlementCurrency::Gbp, 90_000),
527 ]);
528 // Largest first, and visibly two amounts rather than one invented total.
529 assert_eq!(m.display(SettlementCurrency::Usd), "\u{a3}900.00 + $120.00");
530 assert_eq!(m.currency_count(), 2);
531 }
532
533 #[test]
534 fn duplicate_currencies_combine() {
535 let m = money(&[
536 (SettlementCurrency::Eur, 500),
537 (SettlementCurrency::Eur, 250),
538 ]);
539 assert_eq!(m.currency_count(), 1);
540 assert_eq!(m.in_currency(SettlementCurrency::Eur), 750);
541 }
542
543 #[test]
544 fn zero_rows_are_dropped_so_the_common_case_stays_single() {
545 // A LEFT JOIN that matched nothing must not invent a second currency.
546 let m = money(&[
547 (SettlementCurrency::Usd, 1000),
548 (SettlementCurrency::Gbp, 0),
549 ]);
550 assert_eq!(m.currency_count(), 1);
551 assert_eq!(m.display(SettlementCurrency::Usd), "$10.00");
552 }
553
554 #[test]
555 fn empty_renders_zero_in_the_viewers_currency() {
556 let m = MoneyByCurrency::default();
557 assert!(m.is_empty());
558 assert_eq!(m.display(SettlementCurrency::Gbp), "\u{a3}0.00");
559 }
560
561 #[test]
562 fn in_currency_does_not_leak_across_currencies() {
563 // The whole point: asking for dollars must never return pounds.
564 let m = money(&[(SettlementCurrency::Gbp, 5000)]);
565 assert_eq!(m.in_currency(SettlementCurrency::Usd), 0);
566 assert_eq!(m.in_currency(SettlementCurrency::Gbp), 5000);
567 }
568
569 #[test]
570 fn ordering_is_stable_for_equal_amounts() {
571 let a = money(&[
572 (SettlementCurrency::Usd, 100),
573 (SettlementCurrency::Gbp, 100),
574 ]);
575 let b = money(&[
576 (SettlementCurrency::Gbp, 100),
577 (SettlementCurrency::Usd, 100),
578 ]);
579 assert_eq!(
580 a.display(SettlementCurrency::Usd),
581 b.display(SettlementCurrency::Usd)
582 );
583 }
584
585 #[test]
586 fn default_is_usd() {
587 assert_eq!(SettlementCurrency::default(), SettlementCurrency::Usd);
588 }
589 }
590