Skip to main content

max / makenotwork

Denominate every creator sale in the creator's settlement currency Every price, checkout session and subscription was created in USD. A creator outside the US ate FX on every payout, and an account Stripe restricts to its domestic currency could not sell here at all. A creator now has one settlement currency, read from default_currency on their Stripe account. Every price of theirs is denominated in it, and a checkout session is created in it. Direct charges convert presentment to the connected account's default currency, so denominating in the seller's currency is what makes the creator receive the amount they set. Six currencies (USD, CAD, GBP, AUD, NZD, EUR), all two-decimal and prefix-symbol, which is what lets every amount in the codebase stay an integer count of cents. A CHECK on each new column refuses the rest: a zero-decimal currency reaching one of them is a hundredfold pricing error, not a display bug. The buyer carries any conversion and chooses how, stored as a preference so a returning buyer is not asked twice. Mechanically that is adaptive_pricing.enabled on the session, set explicitly every time: left unset, Stripe falls back to the Connect dashboard setting and the buyer's choice silently stops meaning anything. Stripe will not surface the conversion fee as a separate number on hosted Checkout. Adaptive Pricing reports only a converted total, after payment, with the fee inside a rate Stripe varies between 2 and 4 percent; the FX Quotes API does break the fee out but attaches to PaymentIntents and Transfers, not to Checkout Sessions. So no itemised fee line is built, because none can be computed honestly. Rows that mix creators (discover feed, cart, wishlist, collections, a buyer's subscription list, promo codes) carry a joined currency, so no caller can pair the wrong currency with an amount. Single-creator surfaces take it as a parameter. Amounts stored on a transaction or tip keep the currency they were written with, so an old sale stays readable after a creator's settlement currency changes. format_price and format_revenue now require a currency. format_dollars_plain does not: it emits no symbol, and all six are two-decimal. Stripe's per-transaction minimum is per currency (GBP is 30, not 50) and the price ceiling is a round 10,000 in the creator's own currency rather than a USD equivalence, which would need an exchange-rate table this design deliberately does not hold. Not yet done, and marked in the code rather than left to look correct: the platform-wide revenue aggregates still sum across currencies, so the admin totals carry a caveat until the GROUP BY currency work lands. The cart conversion toggle, the split-recipient disclosure and the doc pass are still open on the task.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 21:47 UTC
Signed with PGP, not checked
Commit: bfa46bfffb8dd0c55fc803066e90d1018292fcaf
Parent: 9520ad2
87 files changed, +1694 insertions, -397 deletions
@@ -160,6 +160,8 @@
160 160
161 161 fn user(can_create_projects: bool, is_fan_plus: bool) -> SessionUser {
162 162 SessionUser {
163 + settlement_currency: crate::currency::SettlementCurrency::Usd,
164 + conversion_preference: crate::currency::ConversionChoice::AtCheckout,
163 165 id: UserId::default(),
164 166 username: Username::from_trusted("t".into()),
165 167 email: "t@example.com".into(),
@@ -63,6 +63,20 @@
63 63 pub deactivated: bool,
64 64 #[serde(default)]
65 65 pub is_sandbox: bool,
66 + /// This creator's settlement currency, cached from `users` at session build.
67 + ///
68 + /// Cached, so it can lag by one session if the creator's Stripe currency
69 + /// changes mid-session. That is tolerable precisely because a change is not
70 + /// silent: it raises a creator alert telling them to re-check their prices,
71 + /// and signing in again refreshes this. Surfaces that must never lag (the
72 + /// project dashboard's revenue figures) read the row instead.
73 + #[serde(default)]
74 + pub settlement_currency: crate::currency::SettlementCurrency,
75 + /// As a *buyer*: how this user wants a cross-currency purchase converted.
76 + /// A default for the checkout form, not a lock, so a session-cached copy is
77 + /// harmless: the form is what actually decides, per purchase.
78 + #[serde(default)]
79 + pub conversion_preference: crate::currency::ConversionChoice,
66 80 }
67 81
68 82 impl SessionUser {
@@ -86,6 +100,8 @@
86 100 .ok()
87 101 .flatten();
88 102 Self {
103 + settlement_currency: user.settlement_currency,
104 + conversion_preference: user.conversion_preference,
89 105 id: user.id,
90 106 username: user.username,
91 107 email: user.email.into_inner(),
@@ -995,6 +1011,8 @@
995 1011 creator_tier: None,
996 1012 deactivated: false,
997 1013 is_sandbox: false,
1014 + settlement_currency: crate::currency::SettlementCurrency::Usd,
1015 + conversion_preference: crate::currency::ConversionChoice::AtCheckout,
998 1016 };
999 1017 let config = Config {
1000 1018 host: "127.0.0.1".parse().unwrap(),
@@ -1077,6 +1095,8 @@
1077 1095 creator_tier: None,
1078 1096 deactivated: false,
1079 1097 is_sandbox: false,
1098 + settlement_currency: crate::currency::SettlementCurrency::Usd,
1099 + conversion_preference: crate::currency::ConversionChoice::AtCheckout,
1080 1100 };
1081 1101 let config = Config {
1082 1102 host: "127.0.0.1".parse().unwrap(),
@@ -1143,6 +1163,8 @@
1143 1163 creator_tier: None,
1144 1164 deactivated,
1145 1165 is_sandbox,
1166 + settlement_currency: crate::currency::SettlementCurrency::Usd,
1167 + conversion_preference: crate::currency::ConversionChoice::AtCheckout,
1146 1168 }
1147 1169 }
1148 1170
@@ -1,6 +1,8 @@
1 1 //! Formatting utilities: prices, file sizes, initials, slugs, CSV cells.
2 2 use std::fmt::Write as _;
3 3
4 + use crate::currency::SettlementCurrency;
5 +
4 6 /// Group thousands with commas (US locale). Returns the input string unchanged
5 7 /// for values ≤999. Operates on a digit-only string so callers stay in i64
6 8 /// arithmetic territory and don't need `f64` formatting tricks.
@@ -17,8 +19,14 @@
17 19 out
18 20 }
19 21
20 - /// Format a price in cents as a human-readable dollar string or "Free".
21 - pub fn format_price(cents: impl Into<i64>) -> String {
22 + /// Format a price in cents as a human-readable amount or "Free".
23 + ///
24 + /// Takes the currency the amount is denominated in, because there is no such
25 + /// thing as a bare price: the same integer means different money to a US and a
26 + /// British creator. Pass the seller's `settlement_currency`, or
27 + /// `SettlementCurrency::Usd` explicitly for MNW's own billing (membership tiers,
28 + /// Fan+, SyncKit), which is USD regardless of who is looking.
29 + pub fn format_price(cents: impl Into<i64>, currency: SettlementCurrency) -> String {
22 30 let cents: i64 = cents.into();
23 31 if cents == 0 {
24 32 return "Free".to_string();
@@ -28,28 +36,37 @@
28 36 let dollars = group_thousands(abs / 100);
29 37 let frac = (abs % 100) as u32;
30 38 let sign = if neg { "-" } else { "" };
39 + let symbol = currency.symbol();
31 40 if frac == 0 {
32 - format!("{sign}${dollars}")
41 + format!("{sign}{symbol}{dollars}")
33 42 } else {
34 - format!("{sign}${dollars}.{frac:02}")
43 + format!("{sign}{symbol}{dollars}.{frac:02}")
35 44 }
36 45 }
37 46
38 - /// Format a revenue amount in cents as a dollar string (always shows decimals).
47 + /// Format a revenue amount in cents (always shows decimals).
39 48 ///
40 49 /// Unlike [`format_price`], this never returns "Free": zero revenue is "$0.00".
41 - pub fn format_revenue(cents: i64) -> String {
50 + /// See [`format_price`] on why the currency is not optional.
51 + pub fn format_revenue(cents: i64, currency: SettlementCurrency) -> String {
42 52 let neg = cents < 0;
43 53 let abs = cents.unsigned_abs();
44 54 let dollars = group_thousands(abs / 100);
45 55 let frac = (abs % 100) as u32;
46 56 let sign = if neg { "-" } else { "" };
47 - format!("{sign}${dollars}.{frac:02}")
57 + let symbol = currency.symbol();
58 + format!("{sign}{symbol}{dollars}.{frac:02}")
48 59 }
49 60
50 61 /// Format a price in cents as a plain decimal string: no currency symbol, no
51 62 /// thousands separators, always two decimal places (e.g. "9.99", "1234.50").
52 63 ///
64 + /// The one formatter here that takes no currency, and deliberately so: it emits
65 + /// no symbol, and all six settlement currencies are two-decimal, so there is
66 + /// nothing for a currency to change. Where the currency matters to the reader
67 + /// (a CSV a creator will open in a spreadsheet), name it in its own column
68 + /// rather than gluing a symbol onto a machine-readable value.
69 + ///
53 70 /// For CSV cells and form `value=` attributes where a bare numeric is required
54 71 /// and the surrounding context (spreadsheet column, template `$` prefix)
55 72 /// supplies its own framing. For human-facing display use [`format_price`]
@@ -214,87 +231,138 @@
214 231
215 232 #[test]
216 233 fn format_price_free() {
217 - assert_eq!(format_price(0), "Free");
234 + assert_eq!(format_price(0, SettlementCurrency::Usd), "Free");
218 235 }
219 236
220 237 #[test]
221 238 fn format_price_whole_dollars() {
222 - assert_eq!(format_price(500), "$5");
223 - assert_eq!(format_price(100), "$1");
224 - assert_eq!(format_price(10000), "$100");
239 + assert_eq!(format_price(500, SettlementCurrency::Usd), "$5");
240 + assert_eq!(format_price(100, SettlementCurrency::Usd), "$1");
241 + assert_eq!(format_price(10000, SettlementCurrency::Usd), "$100");
225 242 }
226 243
227 244 #[test]
228 245 fn format_price_with_cents() {
229 - assert_eq!(format_price(999), "$9.99");
230 - assert_eq!(format_price(150), "$1.50");
231 - assert_eq!(format_price(1), "$0.01");
246 + assert_eq!(format_price(999, SettlementCurrency::Usd), "$9.99");
247 + assert_eq!(format_price(150, SettlementCurrency::Usd), "$1.50");
248 + assert_eq!(format_price(1, SettlementCurrency::Usd), "$0.01");
232 249 }
233 250
234 251 #[test]
235 252 fn format_price_negative_whole() {
236 - assert_eq!(format_price(-500i64), "-$5");
253 + assert_eq!(format_price(-500i64, SettlementCurrency::Usd), "-$5");
237 254 }
238 255
239 256 #[test]
240 257 fn format_price_negative_with_cents() {
241 - assert_eq!(format_price(-999i64), "-$9.99");
258 + assert_eq!(format_price(-999i64, SettlementCurrency::Usd), "-$9.99");
242 259 }
243 260
244 261 #[test]
245 262 fn format_price_one_cent() {
246 - assert_eq!(format_price(1), "$0.01");
263 + assert_eq!(format_price(1, SettlementCurrency::Usd), "$0.01");
247 264 }
248 265
249 266 #[test]
250 267 fn format_price_99_cents() {
251 - assert_eq!(format_price(99), "$0.99");
268 + assert_eq!(format_price(99, SettlementCurrency::Usd), "$0.99");
252 269 }
253 270
254 271 // ── format_revenue ──
255 272
256 273 #[test]
257 274 fn format_revenue_zero() {
258 - assert_eq!(format_revenue(0), "$0.00");
275 + assert_eq!(format_revenue(0, SettlementCurrency::Usd), "$0.00");
259 276 }
260 277
261 278 #[test]
262 279 fn format_revenue_whole_dollars() {
263 - assert_eq!(format_revenue(500), "$5.00");
264 - assert_eq!(format_revenue(10000), "$100.00");
280 + assert_eq!(format_revenue(500, SettlementCurrency::Usd), "$5.00");
281 + assert_eq!(format_revenue(10000, SettlementCurrency::Usd), "$100.00");
265 282 }
266 283
267 284 #[test]
268 285 fn format_revenue_with_cents() {
269 - assert_eq!(format_revenue(999), "$9.99");
270 - assert_eq!(format_revenue(150), "$1.50");
271 - assert_eq!(format_revenue(1), "$0.01");
286 + assert_eq!(format_revenue(999, SettlementCurrency::Usd), "$9.99");
287 + assert_eq!(format_revenue(150, SettlementCurrency::Usd), "$1.50");
288 + assert_eq!(format_revenue(1, SettlementCurrency::Usd), "$0.01");
272 289 }
273 290
274 291 #[test]
275 292 fn format_revenue_large_amount() {
276 - assert_eq!(format_revenue(1_000_000), "$10,000.00");
293 + assert_eq!(
294 + format_revenue(1_000_000, SettlementCurrency::Usd),
295 + "$10,000.00"
296 + );
277 297 }
278 298
279 299 #[test]
280 300 fn format_revenue_million_dollars() {
281 - assert_eq!(format_revenue(100_000_000), "$1,000,000.00");
301 + assert_eq!(
302 + format_revenue(100_000_000, SettlementCurrency::Usd),
303 + "$1,000,000.00"
304 + );
282 305 }
283 306
284 307 #[test]
285 308 fn format_price_thousands() {
286 - assert_eq!(format_price(1_234_500), "$12,345");
287 - assert_eq!(format_price(1_234_567), "$12,345.67");
309 + assert_eq!(format_price(1_234_500, SettlementCurrency::Usd), "$12,345");
310 + assert_eq!(
311 + format_price(1_234_567, SettlementCurrency::Usd),
312 + "$12,345.67"
313 + );
288 314 }
289 315
290 316 #[test]
291 317 fn format_price_negative_thousands() {
292 - assert_eq!(format_price(-1_234_567i64), "-$12,345.67");
318 + assert_eq!(
319 + format_price(-1_234_567i64, SettlementCurrency::Usd),
320 + "-$12,345.67"
321 + );
293 322 }
294 323
295 324 #[test]
296 325 fn format_revenue_negative() {
297 - assert_eq!(format_revenue(-500), "-$5.00");
326 + assert_eq!(format_revenue(-500, SettlementCurrency::Usd), "-$5.00");
327 + }
328 +
329 + // ── currency-aware formatting ──
330 +
331 + #[test]
332 + fn price_carries_the_currency_symbol() {
333 + assert_eq!(format_price(999, SettlementCurrency::Gbp), "\u{a3}9.99");
334 + assert_eq!(format_price(999, SettlementCurrency::Eur), "\u{20ac}9.99");
335 + assert_eq!(format_price(999, SettlementCurrency::Cad), "CA$9.99");
336 + assert_eq!(format_price(999, SettlementCurrency::Aud), "A$9.99");
337 + assert_eq!(format_price(999, SettlementCurrency::Nzd), "NZ$9.99");
338 + }
339 +
340 + #[test]
341 + fn revenue_carries_the_currency_symbol() {
342 + assert_eq!(
343 + format_revenue(1_234_567, SettlementCurrency::Gbp),
344 + "\u{a3}12,345.67"
345 + );
346 + assert_eq!(format_revenue(0, SettlementCurrency::Eur), "\u{20ac}0.00");
347 + assert_eq!(format_revenue(-500, SettlementCurrency::Cad), "-CA$5.00");
348 + }
349 +
350 + #[test]
351 + fn free_is_free_in_every_currency() {
352 + // Zero is the one amount with no denomination worth printing.
353 + for c in SettlementCurrency::ALL {
354 + assert_eq!(format_price(0, c), "Free", "{c}");
355 + }
356 + }
357 +
358 + #[test]
359 + fn two_currencies_never_render_alike() {
360 + // The failure this guards: a Canadian creator's price reading as USD.
361 + let rendered: std::collections::HashSet<_> = SettlementCurrency::ALL
362 + .iter()
363 + .map(|c| format_price(999, *c))
364 + .collect();
365 + assert_eq!(rendered.len(), SettlementCurrency::ALL.len());
298 366 }
299 367
300 368 // ── format_file_size ──
@@ -573,28 +641,31 @@
573 641
574 642 #[test]
575 643 fn format_price_negative_one_cent() {
576 - assert_eq!(format_price(-1i64), "-$0.01");
644 + assert_eq!(format_price(-1i64, SettlementCurrency::Usd), "-$0.01");
577 645 }
578 646
579 647 #[test]
580 648 fn format_price_negative_whole_dollar() {
581 - assert_eq!(format_price(-100i64), "-$1");
649 + assert_eq!(format_price(-100i64, SettlementCurrency::Usd), "-$1");
582 650 }
583 651
584 652 #[test]
585 653 fn format_price_large_value() {
586 654 // $1 billion in cents
587 - assert_eq!(format_price(100_000_000_000i64), "$1,000,000,000");
655 + assert_eq!(
656 + format_price(100_000_000_000i64, SettlementCurrency::Usd),
657 + "$1,000,000,000"
658 + );
588 659 }
589 660
590 661 #[test]
591 662 fn format_revenue_one_cent() {
592 - assert_eq!(format_revenue(1), "$0.01");
663 + assert_eq!(format_revenue(1, SettlementCurrency::Usd), "$0.01");
593 664 }
594 665
595 666 #[test]
596 667 fn format_revenue_negative_one_cent() {
597 - assert_eq!(format_revenue(-1), "-$0.01");
668 + assert_eq!(format_revenue(-1, SettlementCurrency::Usd), "-$0.01");
598 669 }
599 670
600 671 #[test]
@@ -755,7 +826,7 @@
755 826 proptest::proptest! {
756 827 #[test]
757 828 fn prop_format_price_never_panics(cents in proptest::num::i64::ANY) {
758 - let result = format_price(cents);
829 + let result = format_price(cents, SettlementCurrency::Usd);
759 830 proptest::prop_assert!(!result.is_empty());
760 831 match cents.cmp(&0) {
761 832 std::cmp::Ordering::Equal => {
@@ -774,7 +845,7 @@
774 845
775 846 #[test]
776 847 fn prop_format_revenue_never_panics(cents in proptest::num::i64::ANY) {
777 - let result = format_revenue(cents);
848 + let result = format_revenue(cents, SettlementCurrency::Usd);
778 849 proptest::prop_assert!(result.starts_with('$') || result.starts_with("-$"),
779 850 "Revenue should start with $ or -$: {}", result);
780 851 }
@@ -26,6 +26,7 @@
26 26 pub mod constants;
27 27 pub mod crypto;
28 28 pub mod csrf;
29 + pub mod currency;
29 30 pub mod custom_pages;
30 31 pub mod db;
31 32 pub mod email;
@@ -7,6 +7,7 @@
7 7 //!
8 8 //! See also: `/docs/guide/pricing`
9 9
10 + use crate::currency::SettlementCurrency;
10 11 use crate::db;
11 12 use crate::error::AppError;
12 13 use crate::helpers;
@@ -192,7 +193,7 @@
192 193 fn can_access(&self, ctx: &AccessContext) -> bool;
193 194
194 195 /// Human-readable price string for display (e.g. "$9.99", "Free", "PWYW").
195 - fn price_display(&self) -> String;
196 + fn price_display(&self, currency: SettlementCurrency) -> String;
196 197
197 198 /// Raw price in cents (0 for free/subscription).
198 199 fn price_cents(&self) -> i32;
@@ -206,7 +207,11 @@
206 207 fn checkout_type(&self) -> CheckoutType;
207 208
208 209 /// Validate a buyer-submitted amount in cents. Returns `Ok(())` or an error message.
209 - fn validate_amount(&self, amount_cents: i32) -> Result<(), String>;
210 + fn validate_amount(
211 + &self,
212 + amount_cents: i32,
213 + currency: SettlementCurrency,
214 + ) -> Result<(), String>;
210 215
211 216 /// The DB discriminant for this pricing model.
212 217 fn kind(&self) -> db::PricingKind;
@@ -227,7 +232,7 @@
227 232 true
228 233 }
229 234
230 - fn price_display(&self) -> String {
235 + fn price_display(&self, _currency: SettlementCurrency) -> String {
231 236 "Free".to_string()
232 237 }
233 238
@@ -239,7 +244,11 @@
239 244 CheckoutType::None
240 245 }
241 246
242 - fn validate_amount(&self, _amount_cents: i32) -> Result<(), String> {
247 + fn validate_amount(
248 + &self,
249 + _amount_cents: i32,
250 + _currency: SettlementCurrency,
251 + ) -> Result<(), String> {
243 252 Ok(())
244 253 }
245 254
@@ -266,8 +275,8 @@
266 275 ctx.is_creator || ctx.has_purchased || ctx.has_active_subscription()
267 276 }
268 277
269 - fn price_display(&self) -> String {
270 - helpers::format_price(self.price_cents)
278 + fn price_display(&self, currency: SettlementCurrency) -> String {
279 + helpers::format_price(self.price_cents, currency)
271 280 }
272 281
273 282 fn price_cents(&self) -> i32 {
@@ -278,11 +287,15 @@
278 287 CheckoutType::OneTime
279 288 }
280 289
281 - fn validate_amount(&self, amount_cents: i32) -> Result<(), String> {
290 + fn validate_amount(
291 + &self,
292 + amount_cents: i32,
293 + currency: SettlementCurrency,
294 + ) -> Result<(), String> {
282 295 if amount_cents < self.price_cents {
283 296 Err(format!(
284 297 "Amount must be at least {}",
285 - helpers::format_price(self.price_cents)
298 + helpers::format_price(self.price_cents, currency)
286 299 ))
287 300 } else {
288 301 Ok(())
@@ -311,9 +324,9 @@
311 324 ctx.is_creator || ctx.has_purchased || ctx.has_active_subscription()
312 325 }
313 326
314 - fn price_display(&self) -> String {
327 + fn price_display(&self, currency: SettlementCurrency) -> String {
315 328 match self.min_cents {
316 - Some(min) if min > 0 => format!("From {}", helpers::format_price(min)),
329 + Some(min) if min > 0 => format!("From {}", helpers::format_price(min, currency)),
317 330 _ => "Pay what you want".to_string(),
318 331 }
319 332 }
@@ -330,12 +343,16 @@
330 343 CheckoutType::PayWhatYouWant
331 344 }
332 345
333 - fn validate_amount(&self, amount_cents: i32) -> Result<(), String> {
346 + fn validate_amount(
347 + &self,
348 + amount_cents: i32,
349 + currency: SettlementCurrency,
350 + ) -> Result<(), String> {
334 351 let min = self.min_cents.unwrap_or(0);
335 352 if amount_cents < min {
336 353 return Err(format!(
337 354 "Amount must be at least {}",
338 - crate::formatting::format_revenue(min as i64)
355 + crate::formatting::format_revenue(min as i64, currency)
339 356 ));
340 357 }
341 358 // Cap at $10,000 (same ceiling as tips) to prevent accidental mega-charges
@@ -366,7 +383,8 @@
366 383 ctx.is_creator || ctx.has_active_subscription()
367 384 }
368 385
369 - fn price_display(&self) -> String {
386 + fn price_display(&self, _currency: SettlementCurrency) -> String {
387 + // The tiers carry the prices; this label names the model, not an amount.
370 388 "Subscription".to_string()
371 389 }
372 390
@@ -378,7 +396,11 @@
378 396 CheckoutType::Subscription
379 397 }
380 398
381 - fn validate_amount(&self, _amount_cents: i32) -> Result<(), String> {
399 + fn validate_amount(
400 + &self,
401 + _amount_cents: i32,
402 + _currency: SettlementCurrency,
403 + ) -> Result<(), String> {
382 404 Err("Subscription items cannot be purchased directly".to_string())
383 405 }
384 406
@@ -468,7 +490,7 @@
468 490
469 491 #[test]
470 492 fn free_price_display() {
471 - assert_eq!(FreePricing.price_display(), "Free");
493 + assert_eq!(FreePricing.price_display(SettlementCurrency::Usd), "Free");
472 494 }
473 495
474 496 #[test]
@@ -483,8 +505,16 @@
483 505
484 506 #[test]
485 507 fn free_validate_amount() {
486 - assert!(FreePricing.validate_amount(0).is_ok());
487 - assert!(FreePricing.validate_amount(100).is_ok());
508 + assert!(
509 + FreePricing
510 + .validate_amount(0, SettlementCurrency::Usd)
511 + .is_ok()
512 + );
513 + assert!(
514 + FreePricing
515 + .validate_amount(100, SettlementCurrency::Usd)
516 + .is_ok()
517 + );
488 518 }
489 519
490 520 #[test]
@@ -536,26 +566,26 @@
536 566 #[test]
537 567 fn fixed_price_display_whole() {
538 568 let p = FixedPricing { price_cents: 1000 };
539 - assert_eq!(p.price_display(), "$10");
569 + assert_eq!(p.price_display(SettlementCurrency::Usd), "$10");
540 570 }
541 571
542 572 #[test]
543 573 fn fixed_price_display_cents() {
544 574 let p = FixedPricing { price_cents: 999 };
545 - assert_eq!(p.price_display(), "$9.99");
575 + assert_eq!(p.price_display(SettlementCurrency::Usd), "$9.99");
546 576 }
547 577
548 578 #[test]
549 579 fn fixed_validate_amount_ok() {
550 580 let p = FixedPricing { price_cents: 999 };
551 - assert!(p.validate_amount(999).is_ok());
552 - assert!(p.validate_amount(1500).is_ok());
581 + assert!(p.validate_amount(999, SettlementCurrency::Usd).is_ok());
582 + assert!(p.validate_amount(1500, SettlementCurrency::Usd).is_ok());
553 583 }
554 584
555 585 #[test]
556 586 fn fixed_validate_amount_too_low() {
557 587 let p = FixedPricing { price_cents: 999 };
558 - assert!(p.validate_amount(500).is_err());
588 + assert!(p.validate_amount(500, SettlementCurrency::Usd).is_err());
559 589 }
560 590
561 591 #[test]
@@ -613,19 +643,25 @@
613 643 let p = PwywPricing {
614 644 min_cents: Some(500),
615 645 };
616 - assert_eq!(p.price_display(), "From $5");
646 + assert_eq!(p.price_display(SettlementCurrency::Usd), "From $5");
617 647 }
618 648
619 649 #[test]
620 650 fn pwyw_price_display_no_min() {
621 651 let p = PwywPricing { min_cents: None };
622 - assert_eq!(p.price_display(), "Pay what you want");
652 + assert_eq!(
653 + p.price_display(SettlementCurrency::Usd),
654 + "Pay what you want"
655 + );
623 656 }
624 657
625 658 #[test]
626 659 fn pwyw_price_display_zero_min() {
627 660 let p = PwywPricing { min_cents: Some(0) };
628 - assert_eq!(p.price_display(), "Pay what you want");
661 + assert_eq!(
662 + p.price_display(SettlementCurrency::Usd),
663 + "Pay what you want"
664 + );
629 665 }
630 666
631 667 #[test]
@@ -633,8 +669,8 @@
633 669 let p = PwywPricing {
634 670 min_cents: Some(500),
635 671 };
636 - assert!(p.validate_amount(500).is_ok());
637 - assert!(p.validate_amount(1000).is_ok());
672 + assert!(p.validate_amount(500, SettlementCurrency::Usd).is_ok());
673 + assert!(p.validate_amount(1000, SettlementCurrency::Usd).is_ok());
638 674 }
639 675
640 676 #[test]
@@ -642,13 +678,13 @@
642 678 let p = PwywPricing {
643 679 min_cents: Some(500),
644 680 };
645 - assert!(p.validate_amount(400).is_err());
681 + assert!(p.validate_amount(400, SettlementCurrency::Usd).is_err());
646 682 }
647 683
648 684 #[test]
649 685 fn pwyw_validate_amount_zero_min() {
650 686 let p = PwywPricing { min_cents: Some(0) };
651 - assert!(p.validate_amount(0).is_ok());
687 + assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok());
652 688 }
653 689
654 690 #[test]
@@ -722,7 +758,10 @@
722 758
723 759 #[test]
724 760 fn subscription_price_display() {
725 - assert_eq!(SubscriptionPricing.price_display(), "Subscription");
761 + assert_eq!(
762 + SubscriptionPricing.price_display(SettlementCurrency::Usd),
763 + "Subscription"
764 + );
726 765 }
727 766
728 767 #[test]
@@ -735,7 +774,11 @@
735 774
736 775 #[test]
737 776 fn subscription_validate_amount() {
738 - assert!(SubscriptionPricing.validate_amount(100).is_err());
777 + assert!(
778 + SubscriptionPricing
779 + .validate_amount(100, SettlementCurrency::Usd)
780 + .is_err()
781 + );
739 782 }
740 783
741 784 #[test]
@@ -819,23 +862,29 @@
819 862 // Negative price_cents is semantically wrong but FixedPricing doesn't validate construction
820 863 let p = FixedPricing { price_cents: -100 };
821 864 // amount >= price_cents (-100), so 0 and -50 pass, but -200 fails
822 - assert!(p.validate_amount(0).is_ok());
823 - assert!(p.validate_amount(-50).is_ok());
824 - assert!(p.validate_amount(-200).is_err()); // -200 < -100
865 + assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok());
866 + assert!(p.validate_amount(-50, SettlementCurrency::Usd).is_ok());
867 + assert!(p.validate_amount(-200, SettlementCurrency::Usd).is_err()); // -200 < -100
825 868 }
826 869
827 870 #[test]
828 871 fn pwyw_validate_amount_at_cap() {
829 872 let p = PwywPricing { min_cents: Some(0) };
830 - assert!(p.validate_amount(1_000_000).is_ok()); // exactly $10,000
831 - assert!(p.validate_amount(1_000_001).is_err()); // $10,000.01
873 + assert!(
874 + p.validate_amount(1_000_000, SettlementCurrency::Usd)
875 + .is_ok()
876 + ); // exactly $10,000
877 + assert!(
878 + p.validate_amount(1_000_001, SettlementCurrency::Usd)
879 + .is_err()
880 + ); // $10,000.01
832 881 }
833 882
834 883 #[test]
835 884 fn pwyw_validate_amount_negative() {
836 885 let p = PwywPricing { min_cents: Some(0) };
837 886 // Negative amount is below min (0), should fail
838 - assert!(p.validate_amount(-1).is_err());
887 + assert!(p.validate_amount(-1, SettlementCurrency::Usd).is_err());
839 888 }
840 889
841 890 #[test]
@@ -845,14 +894,17 @@
845 894 min_cents: Some(-100),
846 895 };
847 896 // Negative amount still above negative min
848 - assert!(p.validate_amount(-50).is_ok());
897 + assert!(p.validate_amount(-50, SettlementCurrency::Usd).is_ok());
849 898 }
850 899
851 900 #[test]
852 901 fn fixed_validate_amount_no_upper_cap() {
853 902 // FixedPricing has no $10k cap like PWYW does
854 903 let p = FixedPricing { price_cents: 100 };
855 - assert!(p.validate_amount(99_999_999).is_ok());
904 + assert!(
905 + p.validate_amount(99_999_999, SettlementCurrency::Usd)
906 + .is_ok()
907 + );
856 908 }
857 909
858 910 #[test]
@@ -897,13 +949,19 @@
897 949 fn adversarial_pwyw_max_i32_amount() {
898 950 let p = PwywPricing { min_cents: Some(0) };
899 951 // i32::MAX = 2,147,483,647 cents = ~$21.4M, should be rejected by $10k cap
900 - assert!(p.validate_amount(i32::MAX).is_err());
952 + assert!(
953 + p.validate_amount(i32::MAX, SettlementCurrency::Usd)
954 + .is_err()
955 + );
901 956 }
902 957
903 958 #[test]
904 959 fn adversarial_pwyw_min_i32_amount() {
905 960 let p = PwywPricing { min_cents: Some(0) };
906 - assert!(p.validate_amount(i32::MIN).is_err());
961 + assert!(
962 + p.validate_amount(i32::MIN, SettlementCurrency::Usd)
963 + .is_err()
964 + );
907 965 }
908 966
909 967 #[test]
@@ -912,9 +970,12 @@
912 970 price_cents: i32::MAX,
913 971 };
914 972 // validate_amount with exactly i32::MAX should pass
915 - assert!(p.validate_amount(i32::MAX).is_ok());
973 + assert!(p.validate_amount(i32::MAX, SettlementCurrency::Usd).is_ok());
916 974 // Any amount below should fail
917 - assert!(p.validate_amount(i32::MAX - 1).is_err());
975 + assert!(
976 + p.validate_amount(i32::MAX - 1, SettlementCurrency::Usd)
977 + .is_err()
978 + );
918 979 }
919 980
920 981 #[test]
@@ -1050,11 +1111,20 @@
1050 1111 min_cents: Some(1_000_001),
1051 1112 };
1052 1113 // Any amount below min fails the min check
1053 - assert!(p.validate_amount(1_000_000).is_err());
1114 + assert!(
1115 + p.validate_amount(1_000_000, SettlementCurrency::Usd)
1116 + .is_err()
1117 + );
1054 1118 // Any amount at/above min fails the cap check
1055 - assert!(p.validate_amount(1_000_001).is_err());
1119 + assert!(
1120 + p.validate_amount(1_000_001, SettlementCurrency::Usd)
1121 + .is_err()
1122 + );
1056 1123 // Even i32::MAX fails
1057 - assert!(p.validate_amount(i32::MAX).is_err());
1124 + assert!(
1125 + p.validate_amount(i32::MAX, SettlementCurrency::Usd)
1126 + .is_err()
1127 + );
1058 1128 }
1059 1129
1060 1130 #[test]
@@ -1063,27 +1133,39 @@
1063 1133 let p = PwywPricing {
1064 1134 min_cents: Some(1_000_000),
1065 1135 };
1066 - assert!(p.validate_amount(1_000_000).is_ok());
1067 - assert!(p.validate_amount(999_999).is_err());
1068 - assert!(p.validate_amount(1_000_001).is_err());
1136 + assert!(
1137 + p.validate_amount(1_000_000, SettlementCurrency::Usd)
1138 + .is_ok()
1139 + );
1140 + assert!(p.validate_amount(999_999, SettlementCurrency::Usd).is_err());
1141 + assert!(
1142 + p.validate_amount(1_000_001, SettlementCurrency::Usd)
1143 + .is_err()
1144 + );
1069 1145 }
1070 1146
1071 1147 #[test]
1072 1148 fn pwyw_none_min_allows_zero() {
1073 1149 // min_cents = None → unwrap_or(0) → amount >= 0 required
1074 1150 let p = PwywPricing { min_cents: None };
1075 - assert!(p.validate_amount(0).is_ok());
1076 - assert!(p.validate_amount(-1).is_err());
1077 - assert!(p.validate_amount(1_000_000).is_ok());
1078 - assert!(p.validate_amount(1_000_001).is_err());
1151 + assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok());
1152 + assert!(p.validate_amount(-1, SettlementCurrency::Usd).is_err());
1153 + assert!(
1154 + p.validate_amount(1_000_000, SettlementCurrency::Usd)
1155 + .is_ok()
1156 + );
1157 + assert!(
1158 + p.validate_amount(1_000_001, SettlementCurrency::Usd)
1159 + .is_err()
1160 + );
1079 1161 }
1080 1162
1081 1163 #[test]
1082 1164 fn fixed_validate_amount_at_exact_boundary() {
1083 1165 // Amount exactly equal to price should pass (not off-by-one)
1084 1166 let p = FixedPricing { price_cents: 1 };
1085 - assert!(p.validate_amount(1).is_ok());
1086 - assert!(p.validate_amount(0).is_err());
1167 + assert!(p.validate_amount(1, SettlementCurrency::Usd).is_ok());
1168 + assert!(p.validate_amount(0, SettlementCurrency::Usd).is_err());
1087 1169 }
1088 1170
1089 1171 #[test]
@@ -1151,7 +1233,7 @@
1151 1233 #[test]
1152 1234 fn prop_fixed_validate_amount_consistent(price in 0..=1_000_000i32, amount in -100_000..=2_000_000i32) {
1153 1235 let p = FixedPricing { price_cents: price };
1154 - let result = p.validate_amount(amount);
1236 + let result = p.validate_amount(amount, SettlementCurrency::Usd);
1155 1237 if amount >= price {
1156 1238 proptest::prop_assert!(result.is_ok());
1157 1239 } else {
@@ -1162,7 +1244,7 @@
1162 1244 #[test]
1163 1245 fn prop_pwyw_validate_enforces_min_and_cap(min in 0..=1_100_000i32, amount in -1_000..=1_100_000i32) {
1164 1246 let p = PwywPricing { min_cents: Some(min) };
1165 - let result = p.validate_amount(amount);
1247 + let result = p.validate_amount(amount, SettlementCurrency::Usd);
1166 1248 if amount >= min && amount <= 1_000_000 {
1167 1249 proptest::prop_assert!(result.is_ok());
1168 1250 } else {
@@ -1172,7 +1254,7 @@
1172 1254
1173 1255 #[test]
1174 1256 fn prop_subscription_never_direct_purchase(amount in proptest::num::i32::ANY) {
1175 - proptest::prop_assert!(SubscriptionPricing.validate_amount(amount).is_err());
1257 + proptest::prop_assert!(SubscriptionPricing.validate_amount(amount, SettlementCurrency::Usd).is_err());
1176 1258 }
1177 1259 }
1178 1260 }