Skip to main content

max / makenotwork

24.1 KB · 647 lines History Blame Raw
1 //! Public `/pricing` fee calculator: what a creator keeps on MNW against what
2 //! they keep on a platform whose fees they describe themselves.
3 //!
4 //! This replaces the old `pricing_comparison` module, which shipped a table of
5 //! nine named platforms with their fee parameters hardcoded in
6 //! `assumptions.toml`. Three problems with that table, all of which this design
7 //! removes rather than mitigates:
8 //!
9 //! - It named competitors, which house copy rules forbid.
10 //! - It went stale silently. Every rate in it was a claim about somebody else's
11 //! pricing page, and nobody was tracking those pages.
12 //! - It only ever argued one direction. A comparison that always wins reads as
13 //! a sales prop.
14 //!
15 //! Here the other platform's fees are two dials the visitor sets, so there is
16 //! nothing to keep fresh, nothing to name, and no way to rig the outcome. The
17 //! calculator reports the crossover honestly, including the case where MNW's
18 //! flat fee never pays for itself at the entered item price.
19 //!
20 //! The model, all monthly:
21 //!
22 //! ```text
23 //! gross = item_price * sales
24 //! mnw_net = gross - gross*stripe.percent - sales*stripe.fixed - tier_cost
25 //! other_net = gross - gross*other_pct - sales*other_per_sale
26 //! ```
27 //!
28 //! MNW charges no cut, so its variable side is payment processing alone; the
29 //! tier subscription is the whole fixed side. The other platform's dials are
30 //! its *total* deduction, its own cut and whatever processing it layers on,
31 //! because that is the number a creator can read off a payout.
32 //!
33 //! The difference is linear in sales, which is what makes the crossover exact
34 //! rather than searched for:
35 //!
36 //! ```text
37 //! per_sale_gain = item_price*(other_pct - stripe.percent)
38 //! + (other_per_sale - stripe.fixed)
39 //! mnw_net - other_net = sales*per_sale_gain - tier_cost
40 //! ```
41 //!
42 //! So MNW pulls ahead at `tier_cost / per_sale_gain` sales when
43 //! `per_sale_gain` is positive, and never when it is not. Both branches are
44 //! rendered.
45 //!
46 //! Stripe's fees and the input defaults come from
47 //! `docs/business/assumptions.toml` (`[stripe]`, `[fee_calculator]`) and are
48 //! parsed at startup. A missing or malformed section panics, the same contract
49 //! as `TierPrices::from_assumptions`: production never serves a half-loaded
50 //! calculator. The arithmetic is pinned by `tests` below and never runs in the
51 //! browser.
52
53 use std::path::Path;
54
55 use serde::Deserialize;
56
57 /// Stripe fee model, read from the `[stripe]` block. Extra keys in that block
58 /// (dispute fees, payout rates, connect sub-tables) are ignored.
59 #[derive(Debug, Clone, Deserialize)]
60 pub struct StripeFees {
61 pub percent: f64,
62 pub fixed: f64,
63 }
64
65 /// Where the dials sit before the visitor touches anything, read from
66 /// `[fee_calculator]`. Picked so the first render is a realistic scenario
67 /// rather than a strawman.
68 #[derive(Debug, Clone, Deserialize)]
69 pub struct CalculatorDefaults {
70 pub item_price: f64,
71 pub sales_per_month: f64,
72 /// Other platform's total percentage deduction, as a fraction.
73 pub other_pct: f64,
74 /// Other platform's total flat deduction per sale, in dollars.
75 pub other_per_sale: f64,
76 }
77
78 /// Loaded calculator. Built once at startup, held in `AppState`.
79 #[derive(Debug, Clone)]
80 pub struct FeeCalculator {
81 stripe: StripeFees,
82 defaults: CalculatorDefaults,
83 }
84
85 /// Shape of the subset of `assumptions.toml` this module cares about. Serde
86 /// ignores every other section, so the whole file deserializes cleanly.
87 #[derive(Deserialize)]
88 struct CalculatorToml {
89 stripe: StripeFees,
90 fee_calculator: CalculatorDefaults,
91 }
92
93 /// One set of dial positions. Every field is already clamped to its input
94 /// range by [`FeeCalculator::sanitize`]; `compute` assumes that.
95 #[derive(Debug, Clone, Copy, PartialEq)]
96 pub struct Inputs {
97 pub item_price: f64,
98 pub sales_per_month: f64,
99 /// Monthly cost of the selected MNW tier.
100 pub tier_cost: f64,
101 pub other_pct: f64,
102 pub other_per_sale: f64,
103 }
104
105 /// Widest each dial may go. The upper bounds are display sanity, not business
106 /// limits: past them the layout breaks before the arithmetic does.
107 pub const MAX_ITEM_PRICE: f64 = 10_000.0;
108 pub const MAX_SALES: f64 = 100_000.0;
109 pub const MAX_OTHER_PCT: f64 = 0.9;
110 pub const MAX_OTHER_PER_SALE: f64 = 100.0;
111
112 impl FeeCalculator {
113 /// Parse the calculator from the assumptions TOML at `path`.
114 ///
115 /// Panics on a missing file or a malformed `[stripe]`/`[fee_calculator]`
116 /// section, the same startup contract as the other assumptions loaders.
117 pub fn load<P: AsRef<Path>>(path: P) -> Self {
118 let text = std::fs::read_to_string(path.as_ref()).unwrap_or_else(|e| {
119 panic!(
120 "failed to read assumptions for the fee calculator from {}: {e}",
121 path.as_ref().display()
122 )
123 });
124 Self::parse(&text)
125 }
126
127 /// Parse from a TOML string. Split out for testing.
128 pub fn parse(text: &str) -> Self {
129 let t: CalculatorToml = toml::from_str(text).unwrap_or_else(|e| {
130 panic!("failed to parse calculator sections of assumptions.toml: {e}")
131 });
132 Self {
133 stripe: t.stripe,
134 defaults: t.fee_calculator,
135 }
136 }
137
138 /// The dial positions the page opens on, for the given tier cost.
139 pub fn default_inputs(&self, tier_cost: f64) -> Inputs {
140 Inputs {
141 item_price: self.defaults.item_price,
142 sales_per_month: self.defaults.sales_per_month,
143 tier_cost,
144 other_pct: self.defaults.other_pct,
145 other_per_sale: self.defaults.other_per_sale,
146 }
147 }
148
149 /// Clamp raw inputs into range. Non-finite values fall back to the
150 /// default for that dial rather than to zero, so a garbage query string
151 /// still renders the realistic opening scenario.
152 pub fn sanitize(&self, raw: Inputs) -> Inputs {
153 let d = &self.defaults;
154 let clamp = |v: f64, fallback: f64, lo: f64, hi: f64| {
155 if v.is_finite() {
156 v.clamp(lo, hi)
157 } else {
158 fallback
159 }
160 };
161 Inputs {
162 item_price: clamp(raw.item_price, d.item_price, 0.0, MAX_ITEM_PRICE),
163 sales_per_month: clamp(raw.sales_per_month, d.sales_per_month, 0.0, MAX_SALES).round(),
164 tier_cost: clamp(raw.tier_cost, 0.0, 0.0, MAX_ITEM_PRICE),
165 other_pct: clamp(raw.other_pct, d.other_pct, 0.0, MAX_OTHER_PCT),
166 other_per_sale: clamp(
167 raw.other_per_sale,
168 d.other_per_sale,
169 0.0,
170 MAX_OTHER_PER_SALE,
171 ),
172 }
173 }
174
175 /// What MNW keeps: gross less processing, less the tier subscription.
176 fn mnw_net(&self, i: Inputs) -> f64 {
177 let gross = i.item_price * i.sales_per_month;
178 gross - gross * self.stripe.percent - i.sales_per_month * self.stripe.fixed - i.tier_cost
179 }
180
181 /// What the described platform keeps, on the dials as entered. Takes
182 /// nothing from `self`: we hold no rates for anyone but ourselves.
183 fn other_net(i: Inputs) -> f64 {
184 let gross = i.item_price * i.sales_per_month;
185 gross - gross * i.other_pct - i.sales_per_month * i.other_per_sale
186 }
187
188 /// How much more of each individual sale survives on MNW. Negative means
189 /// MNW's processing costs more per sale than the other platform's total
190 /// deduction, so the flat fee can never be made back.
191 fn per_sale_gain(&self, i: Inputs) -> f64 {
192 i.item_price * (i.other_pct - self.stripe.percent) + (i.other_per_sale - self.stripe.fixed)
193 }
194
195 /// Run the calculator. Every string on the returned struct is display
196 /// ready; the template does no arithmetic and no formatting.
197 pub fn compute(&self, i: Inputs) -> Outcome {
198 let gross = i.item_price * i.sales_per_month;
199 let mnw = self.mnw_net(i);
200 let other = Self::other_net(i);
201 let gain = self.per_sale_gain(i);
202
203 // Exact crossover in sales, as a real number. `None` when MNW's
204 // per-sale advantage is zero or negative: no volume closes that gap,
205 // and saying so is the honest answer.
206 let crossover = (gain > 0.0 && i.tier_cost > 0.0).then(|| i.tier_cost / gain);
207
208 let verdict = match crossover {
209 _ if (mnw - other).abs() < 0.005 => Verdict::Even,
210 _ if mnw > other => Verdict::MnwAhead,
211 Some(_) => Verdict::OtherAheadForNow,
212 None => Verdict::OtherAhead,
213 };
214
215 let headline = match verdict {
216 Verdict::MnwAhead => format!("You keep {} more here", fmt_money(mnw - other)),
217 Verdict::Even => "The two come out the same here".to_string(),
218 Verdict::OtherAheadForNow | Verdict::OtherAhead => {
219 format!(
220 "The other platform keeps {} more here",
221 fmt_money(other - mnw)
222 )
223 }
224 };
225
226 let crossover_note = match (verdict, crossover) {
227 (Verdict::OtherAhead, _) => Some(format!(
228 "At {} an item, our processing costs more per sale than the fees you \
229 entered, so no amount of volume closes the gap. At these numbers the \
230 other platform is the cheaper place to sell.",
231 fmt_money(i.item_price)
232 )),
233 (_, Some(c)) => {
234 let whole = (c.floor() + 1.0).min(MAX_SALES);
235 Some(format!(
236 "The crossover is at {} sales a month. Below that the other platform \
237 costs less, because our fee is flat and theirs is not. From {} sales \
238 on, we cost less, and the gap widens from there.",
239 fmt_count(c.ceil()),
240 fmt_count(whole)
241 ))
242 }
243 (_, None) => None,
244 };
245
246 Outcome {
247 gross: fmt_money(gross),
248 mnw_keep: fmt_money(mnw),
249 other_keep: fmt_money(other),
250 mnw_rate: fmt_rate(gross, mnw),
251 other_rate: fmt_rate(gross, other),
252 headline,
253 verdict,
254 crossover_note,
255 scale: Scale::build(i.sales_per_month, crossover, verdict),
256 }
257 }
258 }
259
260 /// Which side of the crossover the entered numbers land on.
261 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
262 pub enum Verdict {
263 /// MNW keeps more at these numbers.
264 MnwAhead,
265 /// The two are within half a cent.
266 Even,
267 /// The other platform keeps more, but volume would close it.
268 OtherAheadForNow,
269 /// The other platform keeps more at every volume.
270 OtherAhead,
271 }
272
273 impl Verdict {
274 /// CSS modifier for the headline, so the template branches on nothing.
275 pub fn css_class(self) -> &'static str {
276 match self {
277 Verdict::MnwAhead => "verdict--ahead",
278 Verdict::Even => "verdict--even",
279 Verdict::OtherAheadForNow | Verdict::OtherAhead => "verdict--behind",
280 }
281 }
282 }
283
284 /// The sales axis under the numbers, marking where each platform wins.
285 ///
286 /// Rendered as one bar: a leading segment where the other platform costs less,
287 /// the rest where MNW does, and a marker at the entered volume. All three are
288 /// percentages of the bar so the template needs no units and no math.
289 #[derive(Debug, Clone)]
290 pub struct Scale {
291 /// Width of the segment where the other platform wins, as a percentage.
292 pub other_pct_width: String,
293 /// Width of the segment where MNW wins, as a percentage.
294 pub mnw_pct_width: String,
295 /// Position of the "you are here" marker, as a percentage.
296 pub marker_pct: String,
297 /// Right-hand end of the axis, e.g. `160 sales`.
298 pub max_label: String,
299 /// Whether there is a crossover tick to draw at all. Kept alongside
300 /// `crossover_label` so the template can branch without unwrapping.
301 pub has_crossover: bool,
302 /// Label under the crossover tick, `None` when there is no crossover.
303 pub crossover_label: Option<String>,
304 /// Position of the crossover tick, as a percentage. Empty with no tick.
305 pub crossover_pct: String,
306 }
307
308 impl Scale {
309 fn build(sales: f64, crossover: Option<f64>, verdict: Verdict) -> Self {
310 // Show at least twice the interesting point so both regions are
311 // visible, and never a degenerate zero-width axis.
312 let interest = crossover.unwrap_or(0.0).max(sales);
313 let max = (interest * 2.0).max(10.0).ceil().min(MAX_SALES * 2.0);
314 let pct = |v: f64| format!("{:.4}", (v / max * 100.0).clamp(0.0, 100.0));
315
316 match crossover {
317 Some(c) => {
318 let split = (c / max * 100.0).clamp(0.0, 100.0);
319 Self {
320 other_pct_width: format!("{split:.4}"),
321 mnw_pct_width: format!("{:.4}", 100.0 - split),
322 marker_pct: pct(sales),
323 max_label: format!("{} sales", fmt_count(max)),
324 has_crossover: true,
325 crossover_label: Some(format!("{} sales", fmt_count(c.ceil()))),
326 crossover_pct: format!("{split:.4}"),
327 }
328 }
329 // No crossover: one region spans the whole axis. Which one depends
330 // on whether MNW is ahead everywhere or behind everywhere.
331 None => {
332 let mnw_everywhere = verdict == Verdict::MnwAhead || verdict == Verdict::Even;
333 Self {
334 other_pct_width: if mnw_everywhere { "0" } else { "100" }.to_string(),
335 mnw_pct_width: if mnw_everywhere { "100" } else { "0" }.to_string(),
336 marker_pct: pct(sales),
337 max_label: format!("{} sales", fmt_count(max)),
338 has_crossover: false,
339 crossover_label: None,
340 crossover_pct: String::new(),
341 }
342 }
343 }
344 }
345 }
346
347 /// Rendered calculator output, handed to the page and the HTMX partial.
348 #[derive(Debug, Clone)]
349 pub struct Outcome {
350 /// Monthly gross before anyone's fees.
351 pub gross: String,
352 /// Monthly take-home on MNW. Negative when the tier costs more than the
353 /// sales bring in, which is rendered rather than clamped away.
354 pub mnw_keep: String,
355 /// Monthly take-home on the described platform.
356 pub other_keep: String,
357 /// Effective total fee as a percentage of gross, e.g. `4.2%`.
358 pub mnw_rate: String,
359 pub other_rate: String,
360 /// One-line statement of who comes out ahead at these numbers.
361 pub headline: String,
362 pub verdict: Verdict,
363 /// Where the crossover sits, or why there isn't one.
364 pub crossover_note: Option<String>,
365 pub scale: Scale,
366 }
367
368 /// Format dollars as `$1,234.56`, with the sign ahead of the `$`.
369 fn fmt_money(n: f64) -> String {
370 let rounded = (n * 100.0).round() / 100.0;
371 let s = format!("{:.2}", rounded.abs());
372 let (int_part, frac) = s.split_once('.').unwrap_or((s.as_str(), "00"));
373 format!(
374 "{}${}.{}",
375 if rounded < 0.0 { "-" } else { "" },
376 group_thousands(int_part),
377 frac
378 )
379 }
380
381 /// Format a whole count with thousands separators (`1,000`).
382 fn fmt_count(n: f64) -> String {
383 group_thousands(&format!("{:.0}", n.round().abs()))
384 }
385
386 /// Total fees as a percentage of gross, to one decimal. `--` when there is no
387 /// gross to take a percentage of.
388 fn fmt_rate(gross: f64, net: f64) -> String {
389 if gross <= 0.0 {
390 return "--".to_string();
391 }
392 format!("{:.1}%", (gross - net) / gross * 100.0)
393 }
394
395 /// Insert commas every three digits from the right into a bare integer string.
396 fn group_thousands(digits: &str) -> String {
397 let bytes = digits.as_bytes();
398 let len = bytes.len();
399 let mut out = String::with_capacity(len + len / 3);
400 for (i, b) in bytes.iter().enumerate() {
401 if i > 0 && (len - i).is_multiple_of(3) {
402 out.push(',');
403 }
404 out.push(*b as char);
405 }
406 out
407 }
408
409 #[cfg(test)]
410 mod tests {
411 use super::*;
412
413 const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml";
414
415 fn loaded() -> FeeCalculator {
416 FeeCalculator::load(ASSUMPTIONS_PATH)
417 }
418
419 /// Float equality for values that are exactly a clamp bound or a copied
420 /// default, spelled with a tolerance so `float_cmp` stays happy.
421 #[track_caller]
422 fn approx(got: f64, want: f64, what: &str) {
423 assert!((got - want).abs() < 1e-9, "{what}: got {got}, want {want}");
424 }
425
426 /// The canonical toml loads with the fee constants the checkout uses.
427 #[test]
428 fn loads_from_canonical_assumptions() {
429 let c = loaded();
430 assert!((c.stripe.percent - 0.029).abs() < 1e-9);
431 assert!((c.stripe.fixed - 0.30).abs() < 1e-9);
432 assert!(c.defaults.item_price > 0.0);
433 assert!(c.defaults.sales_per_month > 0.0);
434 assert!(c.defaults.other_pct > 0.0);
435 }
436
437 /// Both nets at a round scenario: 40 sales of $25 is $1,000 gross, the
438 /// other platform on 12.6% + $0.30.
439 ///
440 /// mnw = 1000 - 29 - 12 - 16 = 943
441 /// other = 1000 - 126 - 12 = 862
442 #[test]
443 fn pins_both_nets() {
444 let c = loaded();
445 let out = c.compute(Inputs {
446 item_price: 25.0,
447 sales_per_month: 40.0,
448 tier_cost: 16.0,
449 other_pct: 0.126,
450 other_per_sale: 0.30,
451 });
452 assert_eq!(out.gross, "$1,000.00");
453 assert_eq!(out.mnw_keep, "$943.00");
454 assert_eq!(out.other_keep, "$862.00");
455 assert_eq!(out.verdict, Verdict::MnwAhead);
456 assert_eq!(out.mnw_rate, "5.7%");
457 assert_eq!(out.other_rate, "13.8%");
458 }
459
460 /// The crossover is exact, not searched for: per-sale gain is
461 /// 25*(0.126-0.029) + (0.30-0.30) = 2.425, so $16 of tier is made back at
462 /// 16/2.425 = 6.598 sales, and the copy rounds up to 7.
463 #[test]
464 fn crossover_is_where_the_flat_fee_is_repaid() {
465 let c = loaded();
466 let i = Inputs {
467 item_price: 25.0,
468 sales_per_month: 3.0,
469 tier_cost: 16.0,
470 other_pct: 0.126,
471 other_per_sale: 0.30,
472 };
473 let out = c.compute(i);
474 assert_eq!(out.verdict, Verdict::OtherAheadForNow);
475 let note = out.crossover_note.expect("crossover note");
476 assert!(note.contains("7 sales a month"), "note was: {note}");
477
478 // One sale either side of the rounded crossover confirms the flip.
479 let below = c.compute(Inputs {
480 sales_per_month: 6.0,
481 ..i
482 });
483 assert_eq!(below.verdict, Verdict::OtherAheadForNow);
484 let above = c.compute(Inputs {
485 sales_per_month: 7.0,
486 ..i
487 });
488 assert_eq!(above.verdict, Verdict::MnwAhead);
489 }
490
491 /// HONESTY CONTRACT. A platform charging less per sale than bare payment
492 /// processing wins at every volume, and the calculator has to say so
493 /// instead of promising a crossover that does not exist.
494 #[test]
495 fn no_crossover_when_the_other_platform_is_cheaper_per_sale() {
496 let c = loaded();
497 let out = c.compute(Inputs {
498 item_price: 5.0,
499 sales_per_month: 500.0,
500 tier_cost: 16.0,
501 // 1% and no flat fee: below Stripe's own rate.
502 other_pct: 0.01,
503 other_per_sale: 0.0,
504 });
505 assert_eq!(out.verdict, Verdict::OtherAhead);
506 let note = out.crossover_note.expect("no-crossover note");
507 assert!(note.contains("no amount of volume"), "note was: {note}");
508 // The whole axis belongs to the other platform.
509 assert_eq!(out.scale.mnw_pct_width, "0");
510 assert_eq!(out.scale.other_pct_width, "100");
511 assert!(out.scale.crossover_label.is_none());
512 }
513
514 /// A free tier has nothing to repay, so MNW leads from the first sale and
515 /// there is no crossover to draw.
516 #[test]
517 fn zero_tier_cost_has_no_crossover() {
518 let out = loaded().compute(Inputs {
519 item_price: 25.0,
520 sales_per_month: 40.0,
521 tier_cost: 0.0,
522 other_pct: 0.126,
523 other_per_sale: 0.30,
524 });
525 assert_eq!(out.verdict, Verdict::MnwAhead);
526 assert!(out.crossover_note.is_none());
527 assert_eq!(out.scale.mnw_pct_width, "100");
528 }
529
530 /// Zero sales is a real answer, not a placeholder: the subscription still
531 /// costs what it costs, and the page shows the negative.
532 #[test]
533 fn zero_sales_shows_the_subscription_as_a_loss() {
534 let out = loaded().compute(Inputs {
535 item_price: 25.0,
536 sales_per_month: 0.0,
537 tier_cost: 16.0,
538 other_pct: 0.126,
539 other_per_sale: 0.30,
540 });
541 assert_eq!(out.gross, "$0.00");
542 assert_eq!(out.mnw_keep, "-$16.00");
543 assert_eq!(out.other_keep, "$0.00");
544 assert_eq!(out.verdict, Verdict::OtherAheadForNow);
545 assert_eq!(out.mnw_rate, "--");
546 }
547
548 /// Identical fee structures land on Even rather than on either side.
549 #[test]
550 fn identical_terms_are_even() {
551 let out = loaded().compute(Inputs {
552 item_price: 25.0,
553 sales_per_month: 40.0,
554 tier_cost: 0.0,
555 other_pct: 0.029,
556 other_per_sale: 0.30,
557 });
558 assert_eq!(out.verdict, Verdict::Even);
559 assert_eq!(out.headline, "The two come out the same here");
560 }
561
562 /// Out-of-range and garbage dials clamp instead of 400ing or rendering
563 /// nonsense. Non-finite falls back to the default, not to zero.
564 #[test]
565 fn sanitize_clamps_every_dial() {
566 let c = loaded();
567 let s = c.sanitize(Inputs {
568 item_price: -5.0,
569 sales_per_month: 1e12,
570 tier_cost: -1.0,
571 other_pct: 4.0,
572 other_per_sale: f64::NAN,
573 });
574 approx(s.item_price, 0.0, "item_price clamps up to the floor");
575 approx(
576 s.sales_per_month,
577 MAX_SALES,
578 "sales clamp down to the ceiling",
579 );
580 approx(s.tier_cost, 0.0, "tier cost cannot go negative");
581 approx(
582 s.other_pct,
583 MAX_OTHER_PCT,
584 "their cut clamps to the ceiling",
585 );
586 approx(
587 s.other_per_sale,
588 c.defaults.other_per_sale,
589 "NaN falls back to the default, not to zero",
590 );
591
592 let inf = c.sanitize(Inputs {
593 item_price: f64::INFINITY,
594 sales_per_month: f64::NEG_INFINITY,
595 tier_cost: 16.0,
596 other_pct: f64::NAN,
597 other_per_sale: 0.30,
598 });
599 approx(inf.item_price, c.defaults.item_price, "+inf price");
600 approx(
601 inf.sales_per_month,
602 c.defaults.sales_per_month,
603 "-inf sales",
604 );
605 approx(inf.other_pct, c.defaults.other_pct, "NaN cut");
606 }
607
608 /// The bar's two segments always tile the axis exactly, and the marker
609 /// stays inside it, at every dial position the inputs allow.
610 #[test]
611 fn scale_segments_tile_the_axis() {
612 let c = loaded();
613 for sales in [0.0, 1.0, 7.0, 40.0, 5_000.0, MAX_SALES] {
614 for price in [0.0, 1.0, 25.0, MAX_ITEM_PRICE] {
615 for pct in [0.0, 0.029, 0.126, MAX_OTHER_PCT] {
616 let out = c.compute(Inputs {
617 item_price: price,
618 sales_per_month: sales,
619 tier_cost: 16.0,
620 other_pct: pct,
621 other_per_sale: 0.30,
622 });
623 let a: f64 = out.scale.other_pct_width.parse().unwrap();
624 let b: f64 = out.scale.mnw_pct_width.parse().unwrap();
625 assert!(
626 (a + b - 100.0).abs() < 0.01,
627 "segments {a} + {b} at price {price}, {sales} sales, pct {pct}"
628 );
629 let m: f64 = out.scale.marker_pct.parse().unwrap();
630 assert!((0.0..=100.0).contains(&m), "marker {m} out of the axis");
631 }
632 }
633 }
634 }
635
636 #[test]
637 fn money_and_count_formatting() {
638 assert_eq!(fmt_money(943.0), "$943.00");
639 assert_eq!(fmt_money(1234.5), "$1,234.50");
640 assert_eq!(fmt_money(-12.34), "-$12.34");
641 assert_eq!(fmt_money(-0.001), "$0.00");
642 assert_eq!(fmt_count(1000.0), "1,000");
643 assert_eq!(fmt_rate(1000.0, 943.0), "5.7%");
644 assert_eq!(fmt_rate(0.0, -16.0), "--");
645 }
646 }
647