| 1 |
|
- |
//! Public `/pricing` comparison model, the single source of truth for the
|
| 2 |
|
- |
//! "you keep more with MNW" claim.
|
| 3 |
|
- |
//!
|
| 4 |
|
- |
//! The fee constants and every competitor's parameters live in
|
| 5 |
|
- |
//! `docs/business/assumptions.toml` (`[stripe]`, `[pricing_calc]`,
|
| 6 |
|
- |
//! `[[competitors]]`) and are parsed here at startup. This replaces the old
|
| 7 |
|
- |
//! `static/page-pricing.js`, which hardcoded the Stripe rate and nine
|
| 8 |
|
- |
//! competitor fee models client-side with no server backing and no test, a
|
| 9 |
|
- |
//! public marketing claim that could silently drift from the real checkout
|
| 10 |
|
- |
//! rate. The page now recomputes server-side via a debounced HTMX request; the
|
| 11 |
|
- |
//! math never runs in the browser.
|
| 12 |
|
- |
//!
|
| 13 |
|
- |
//! Missing/malformed sections panic at startup (same contract as
|
| 14 |
|
- |
//! `TierPrices::from_assumptions`): production never serves a half-loaded
|
| 15 |
|
- |
//! comparison. The per-competitor formula is pinned by `tests` below to the
|
| 16 |
|
- |
//! exact values the retired JS produced.
|
| 17 |
|
- |
|
| 18 |
|
- |
use std::path::Path;
|
| 19 |
|
- |
|
| 20 |
|
- |
use serde::Deserialize;
|
| 21 |
|
- |
|
| 22 |
|
- |
/// Stripe fee model, read from the `[stripe]` block. Extra keys in that block
|
| 23 |
|
- |
/// (dispute fees, payout rates, connect sub-tables) are ignored.
|
| 24 |
|
- |
#[derive(Debug, Clone, Deserialize)]
|
| 25 |
|
- |
pub struct StripeFees {
|
| 26 |
|
- |
pub percent: f64,
|
| 27 |
|
- |
pub fixed: f64,
|
| 28 |
|
- |
}
|
| 29 |
|
- |
|
| 30 |
|
- |
/// Calculator tunables from `[pricing_calc]`.
|
| 31 |
|
- |
#[derive(Debug, Clone, Deserialize)]
|
| 32 |
|
- |
pub struct PricingCalc {
|
| 33 |
|
- |
/// Assumed average sale, used to estimate per-transaction fee count.
|
| 34 |
|
- |
pub avg_transaction: f64,
|
| 35 |
|
- |
}
|
| 36 |
|
- |
|
| 37 |
|
- |
/// How a competitor layers payment processing on top of its platform cut.
|
| 38 |
|
- |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
| 39 |
|
- |
#[serde(rename_all = "snake_case")]
|
| 40 |
|
- |
pub enum StripeMode {
|
| 41 |
|
- |
/// Processing is included in the platform cut (e.g. Lemon Squeezy).
|
| 42 |
|
- |
None,
|
| 43 |
|
- |
/// Adds the percent fee on the post-platform amount (no fixed per-tx).
|
| 44 |
|
- |
Percent,
|
| 45 |
|
- |
/// Adds both the percent fee and the fixed per-transaction fee.
|
| 46 |
|
- |
PercentAndFixed,
|
| 47 |
|
- |
}
|
| 48 |
|
- |
|
| 49 |
|
- |
/// One competitor's fee parameters. All numeric knobs default to zero/none so a
|
| 50 |
|
- |
/// simple percentage-only platform needs just `platform_pct` + `stripe_mode`.
|
| 51 |
|
- |
#[derive(Debug, Clone, Deserialize)]
|
| 52 |
|
- |
pub struct Competitor {
|
| 53 |
|
- |
pub name: String,
|
| 54 |
|
- |
pub url: String,
|
| 55 |
|
- |
pub fee_label: String,
|
| 56 |
|
- |
#[serde(default)]
|
| 57 |
|
- |
pub platform_pct: f64,
|
| 58 |
|
- |
/// Reduced rate above `platform_pct_threshold` (Bandcamp: 10% over $5k).
|
| 59 |
|
- |
#[serde(default)]
|
| 60 |
|
- |
pub platform_pct_over: Option<f64>,
|
| 61 |
|
- |
#[serde(default)]
|
| 62 |
|
- |
pub platform_pct_threshold: Option<f64>,
|
| 63 |
|
- |
/// Flat per-transaction platform fee, applied before processing (Gumroad,
|
| 64 |
|
- |
/// Lemon Squeezy: $0.50).
|
| 65 |
|
- |
#[serde(default)]
|
| 66 |
|
- |
pub platform_per_tx: f64,
|
| 67 |
|
- |
/// Flat monthly platform charge (Ko-fi Gold: $12).
|
| 68 |
|
- |
#[serde(default)]
|
| 69 |
|
- |
pub flat_monthly: f64,
|
| 70 |
|
- |
pub stripe_mode: StripeMode,
|
| 71 |
|
- |
}
|
| 72 |
|
- |
|
| 73 |
|
- |
/// Loaded pricing model. Built once at startup, held in `AppState`.
|
| 74 |
|
- |
#[derive(Debug, Clone)]
|
| 75 |
|
- |
pub struct PricingComparison {
|
| 76 |
|
- |
stripe: StripeFees,
|
| 77 |
|
- |
calc: PricingCalc,
|
| 78 |
|
- |
competitors: Vec<Competitor>,
|
| 79 |
|
- |
}
|
| 80 |
|
- |
|
| 81 |
|
- |
/// Shape of the subset of `assumptions.toml` this module cares about. Serde
|
| 82 |
|
- |
/// ignores every other section, so the whole file deserializes cleanly.
|
| 83 |
|
- |
#[derive(Deserialize)]
|
| 84 |
|
- |
struct PricingToml {
|
| 85 |
|
- |
stripe: StripeFees,
|
| 86 |
|
- |
pricing_calc: PricingCalc,
|
| 87 |
|
- |
#[serde(default)]
|
| 88 |
|
- |
competitors: Vec<Competitor>,
|
| 89 |
|
- |
}
|
| 90 |
|
- |
|
| 91 |
|
- |
impl PricingComparison {
|
| 92 |
|
- |
/// Parse the pricing model from the assumptions TOML at `path`.
|
| 93 |
|
- |
///
|
| 94 |
|
- |
/// Panics on a missing file or a malformed `[stripe]`/`[pricing_calc]`
|
| 95 |
|
- |
/// section, the same startup contract as the other assumptions loaders.
|
| 96 |
|
- |
pub fn load<P: AsRef<Path>>(path: P) -> Self {
|
| 97 |
|
- |
let text = std::fs::read_to_string(path.as_ref()).unwrap_or_else(|e| {
|
| 98 |
|
- |
panic!(
|
| 99 |
|
- |
"failed to read assumptions for pricing comparison from {}: {e}",
|
| 100 |
|
- |
path.as_ref().display()
|
| 101 |
|
- |
)
|
| 102 |
|
- |
});
|
| 103 |
|
- |
Self::parse(&text)
|
| 104 |
|
- |
}
|
| 105 |
|
- |
|
| 106 |
|
- |
/// Parse from a TOML string. Split out for testing.
|
| 107 |
|
- |
pub fn parse(text: &str) -> Self {
|
| 108 |
|
- |
let t: PricingToml = toml::from_str(text).unwrap_or_else(|e| {
|
| 109 |
|
- |
panic!("failed to parse pricing sections of assumptions.toml: {e}")
|
| 110 |
|
- |
});
|
| 111 |
|
- |
Self {
|
| 112 |
|
- |
stripe: t.stripe,
|
| 113 |
|
- |
calc: t.pricing_calc,
|
| 114 |
|
- |
competitors: t.competitors,
|
| 115 |
|
- |
}
|
| 116 |
|
- |
}
|
| 117 |
|
- |
|
| 118 |
|
- |
/// Estimated number of transactions to clear `revenue` at the assumed
|
| 119 |
|
- |
/// average sale. Zero for non-positive revenue; otherwise at least one.
|
| 120 |
|
- |
fn estimate_transactions(&self, revenue: f64) -> f64 {
|
| 121 |
|
- |
if revenue <= 0.0 {
|
| 122 |
|
- |
return 0.0;
|
| 123 |
|
- |
}
|
| 124 |
|
- |
(revenue / self.calc.avg_transaction).round().max(1.0)
|
| 125 |
|
- |
}
|
| 126 |
|
- |
|
| 127 |
|
- |
/// What a creator keeps on MNW's processing alone (before membership).
|
| 128 |
|
- |
fn stripe_net(&self, revenue: f64) -> f64 {
|
| 129 |
|
- |
if revenue <= 0.0 {
|
| 130 |
|
- |
return 0.0;
|
| 131 |
|
- |
}
|
| 132 |
|
- |
let txns = self.estimate_transactions(revenue);
|
| 133 |
|
- |
revenue - revenue * self.stripe.percent - txns * self.stripe.fixed
|
| 134 |
|
- |
}
|
| 135 |
|
- |
|
| 136 |
|
- |
/// Net revenue a creator keeps on a competitor at `revenue`.
|
| 137 |
|
- |
fn competitor_net(&self, c: &Competitor, revenue: f64) -> f64 {
|
| 138 |
|
- |
if revenue <= 0.0 {
|
| 139 |
|
- |
return -c.flat_monthly;
|
| 140 |
|
- |
}
|
| 141 |
|
- |
let txns = self.estimate_transactions(revenue);
|
| 142 |
|
- |
let pct = match (c.platform_pct_over, c.platform_pct_threshold) {
|
| 143 |
|
- |
(Some(over), Some(threshold)) if revenue > threshold => over,
|
| 144 |
|
- |
_ => c.platform_pct,
|
| 145 |
|
- |
};
|
| 146 |
|
- |
let after_platform = revenue * (1.0 - pct) - txns * c.platform_per_tx;
|
| 147 |
|
- |
let after_processing = match c.stripe_mode {
|
| 148 |
|
- |
StripeMode::None => after_platform,
|
| 149 |
|
- |
StripeMode::Percent => after_platform - after_platform * self.stripe.percent,
|
| 150 |
|
- |
StripeMode::PercentAndFixed => {
|
| 151 |
|
- |
after_platform - after_platform * self.stripe.percent - txns * self.stripe.fixed
|
| 152 |
|
- |
}
|
| 153 |
|
- |
};
|
| 154 |
|
- |
after_processing - c.flat_monthly
|
| 155 |
|
- |
}
|
| 156 |
|
- |
|
| 157 |
|
- |
/// Build the full comparison for a given monthly `revenue` and selected
|
| 158 |
|
- |
/// tier's monthly `tier_cost` (both in whole dollars). Rows are sorted by
|
| 159 |
|
- |
/// net kept, descending, with the MNW row flagged.
|
| 160 |
|
- |
pub fn compute(&self, revenue: f64, tier_cost: f64) -> ComparisonResult {
|
| 161 |
|
- |
let mnw_net = self.stripe_net(revenue) - tier_cost;
|
| 162 |
|
- |
|
| 163 |
|
- |
struct Raw {
|
| 164 |
|
- |
name: String,
|
| 165 |
|
- |
url: String,
|
| 166 |
|
- |
fee_label: String,
|
| 167 |
|
- |
is_mnw: bool,
|
| 168 |
|
- |
net: f64,
|
| 169 |
|
- |
}
|
| 170 |
|
- |
|
| 171 |
|
- |
let mut raws = Vec::with_capacity(self.competitors.len() + 1);
|
| 172 |
|
- |
raws.push(Raw {
|
| 173 |
|
- |
name: "Makenotwork".to_string(),
|
| 174 |
|
- |
url: String::new(),
|
| 175 |
|
- |
fee_label: format!("{}/mo flat + ~3% processing", fmt_money(tier_cost)),
|
| 176 |
|
- |
is_mnw: true,
|
| 177 |
|
- |
net: mnw_net,
|
| 178 |
|
- |
});
|
| 179 |
|
- |
for c in &self.competitors {
|
| 180 |
|
- |
raws.push(Raw {
|
| 181 |
|
- |
name: c.name.clone(),
|
| 182 |
|
- |
url: c.url.clone(),
|
| 183 |
|
- |
fee_label: c.fee_label.clone(),
|
| 184 |
|
- |
is_mnw: false,
|
| 185 |
|
- |
net: self.competitor_net(c, revenue),
|
| 186 |
|
- |
});
|
| 187 |
|
- |
}
|
| 188 |
|
- |
raws.sort_by(|a, b| {
|
| 189 |
|
- |
b.net
|
| 190 |
|
- |
.partial_cmp(&a.net)
|
| 191 |
|
- |
.unwrap_or(std::cmp::Ordering::Equal)
|
| 192 |
|
- |
});
|
| 193 |
|
- |
|
| 194 |
|
- |
let mut breakeven_any = false;
|
| 195 |
|
- |
let rows = raws
|
| 196 |
|
- |
.into_iter()
|
| 197 |
|
- |
.map(|r| {
|
| 198 |
|
- |
let keep = if revenue > 0.0 {
|
| 199 |
|
- |
fmt_money(r.net.max(0.0))
|
| 200 |
|
- |
} else {
|
| 201 |
|
- |
"--".to_string()
|
| 202 |
|
- |
};
|
| 203 |
|
- |
let (diff, diff_class) = if r.is_mnw {
|
| 204 |
|
- |
("--".to_string(), String::new())
|
| 205 |
|
- |
} else if revenue > 0.0 {
|
| 206 |
|
- |
let d = mnw_net - r.net;
|
| 207 |
|
- |
if d > 0.005 {
|
| 208 |
|
- |
(format!("+{}", fmt_money(d)), "savings-negative".to_string())
|
| 209 |
|
- |
} else if d < -0.005 {
|
| 210 |
|
- |
breakeven_any = true;
|
| 211 |
|
- |
(fmt_money(d), "savings-positive".to_string())
|
| 212 |
|
- |
} else {
|
| 213 |
|
- |
("$0.00".to_string(), String::new())
|
| 214 |
|
- |
}
|
| 215 |
|
- |
} else {
|
| 216 |
|
- |
("--".to_string(), String::new())
|
| 217 |
|
- |
};
|
| 218 |
|
- |
ComparisonRow {
|
| 219 |
|
- |
name: r.name,
|
| 220 |
|
- |
url: r.url,
|
| 221 |
|
- |
fee_label: r.fee_label,
|
| 222 |
|
- |
is_mnw: r.is_mnw,
|
| 223 |
|
- |
keep,
|
| 224 |
|
- |
diff,
|
| 225 |
|
- |
diff_class,
|
| 226 |
|
- |
}
|
| 227 |
|
- |
})
|
| 228 |
|
- |
.collect();
|
| 229 |
|
- |
|
| 230 |
|
- |
let breakeven = if breakeven_any && revenue > 0.0 {
|
| 231 |
|
- |
Some(format!(
|
| 232 |
|
- |
"At {}/mo, some platforms with percentage-only fees cost less than \
|
| 233 |
|
- |
MNW's flat rate. MNW becomes cheaper as your revenue grows.",
|
| 234 |
|
- |
fmt_whole(revenue)
|
| 235 |
|
- |
))
|
| 236 |
|
- |
} else {
|
| 237 |
|
- |
None
|
| 238 |
|
- |
};
|
| 239 |
|
- |
|
| 240 |
|
- |
ComparisonResult {
|
| 241 |
|
- |
mnw_keep: fmt_money(mnw_net.max(0.0)),
|
| 242 |
|
- |
mnw_detail: format!(
|
| 243 |
|
- |
"of every {} after processing fees (~3%) and {}/mo membership",
|
| 244 |
|
- |
fmt_whole(revenue),
|
| 245 |
|
- |
fmt_money(tier_cost)
|
| 246 |
|
- |
),
|
| 247 |
|
- |
rows,
|
| 248 |
|
- |
breakeven,
|
| 249 |
|
- |
}
|
| 250 |
|
- |
}
|
| 251 |
|
- |
}
|
| 252 |
|
- |
|
| 253 |
|
- |
/// Rendered comparison, handed to the pricing template / HTMX partial.
|
| 254 |
|
- |
#[derive(Debug, Clone)]
|
| 255 |
|
- |
pub struct ComparisonResult {
|
| 256 |
|
- |
/// Formatted "you keep" headline for the MNW row (never negative).
|
| 257 |
|
- |
pub mnw_keep: String,
|
| 258 |
|
- |
/// Sub-line under the headline.
|
| 259 |
|
- |
pub mnw_detail: String,
|
| 260 |
|
- |
/// All rows (MNW + competitors), sorted by net kept, descending.
|
| 261 |
|
- |
pub rows: Vec<ComparisonRow>,
|
| 262 |
|
- |
/// Breakeven note when a competitor beats MNW at this revenue, else `None`.
|
| 263 |
|
- |
pub breakeven: Option<String>,
|
| 264 |
|
- |
}
|
| 265 |
|
- |
|
| 266 |
|
- |
/// One rendered comparison row. All display strings are preformatted so the
|
| 267 |
|
- |
/// template does no math.
|
| 268 |
|
- |
#[derive(Debug, Clone)]
|
| 269 |
|
- |
pub struct ComparisonRow {
|
| 270 |
|
- |
pub name: String,
|
| 271 |
|
- |
/// Empty for the MNW row (rendered as bold text, not a link).
|
| 272 |
|
- |
pub url: String,
|
| 273 |
|
- |
pub fee_label: String,
|
| 274 |
|
- |
pub is_mnw: bool,
|
| 275 |
|
- |
/// "--" when revenue is zero, else formatted dollars.
|
| 276 |
|
- |
pub keep: String,
|
| 277 |
|
- |
/// "--" / "$0.00" / "+$x" / "$-x".
|
| 278 |
|
- |
pub diff: String,
|
| 279 |
|
- |
/// CSS class for the diff cell; empty when none applies.
|
| 280 |
|
- |
pub diff_class: String,
|
| 281 |
|
- |
}
|
| 282 |
|
- |
|
| 283 |
|
- |
/// Format dollars as `$1,234.56`, matching the retired JS `fmt`. Negative
|
| 284 |
|
- |
/// values render the sign after the `$` (`$-12.34`) to match exactly.
|
| 285 |
|
- |
fn fmt_money(n: f64) -> String {
|
| 286 |
|
- |
let neg = n < 0.0;
|
| 287 |
|
- |
let s = format!("{:.2}", n.abs());
|
| 288 |
|
- |
let (int_part, frac) = s.split_once('.').unwrap_or((s.as_str(), "00"));
|
| 289 |
|
- |
format!(
|
| 290 |
|
- |
"${}{}.{}",
|
| 291 |
|
- |
if neg { "-" } else { "" },
|
| 292 |
|
- |
group_thousands(int_part),
|
| 293 |
|
- |
frac
|
| 294 |
|
- |
)
|
| 295 |
|
- |
}
|
| 296 |
|
- |
|
| 297 |
|
- |
/// Format dollars as a whole number with thousands separators (`$1,000`),
|
| 298 |
|
- |
/// matching the retired JS `fmtWhole`.
|
| 299 |
|
- |
fn fmt_whole(n: f64) -> String {
|
| 300 |
|
- |
format!("${}", group_thousands(&format!("{:.0}", n.round().abs())))
|
| 301 |
|
- |
}
|
| 302 |
|
- |
|
| 303 |
|
- |
/// Insert commas every three digits from the right into a bare integer string.
|
| 304 |
|
- |
fn group_thousands(digits: &str) -> String {
|
| 305 |
|
- |
let bytes = digits.as_bytes();
|
| 306 |
|
- |
let len = bytes.len();
|
| 307 |
|
- |
let mut out = String::with_capacity(len + len / 3);
|
| 308 |
|
- |
for (i, b) in bytes.iter().enumerate() {
|
| 309 |
|
- |
if i > 0 && (len - i).is_multiple_of(3) {
|
| 310 |
|
- |
out.push(',');
|
| 311 |
|
- |
}
|
| 312 |
|
- |
out.push(*b as char);
|
| 313 |
|
- |
}
|
| 314 |
|
- |
out
|
| 315 |
|
- |
}
|
| 316 |
|
- |
|
| 317 |
|
- |
#[cfg(test)]
|
| 318 |
|
- |
mod tests {
|
| 319 |
|
- |
use super::*;
|
| 320 |
|
- |
|
| 321 |
|
- |
const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml";
|
| 322 |
|
- |
|
| 323 |
|
- |
fn loaded() -> PricingComparison {
|
| 324 |
|
- |
PricingComparison::load(ASSUMPTIONS_PATH)
|
| 325 |
|
- |
}
|
| 326 |
|
- |
|
| 327 |
|
- |
/// The canonical toml loads and carries all nine competitors.
|
| 328 |
|
- |
#[test]
|
| 329 |
|
- |
fn loads_from_canonical_assumptions() {
|
| 330 |
|
- |
let p = loaded();
|
| 331 |
|
- |
assert_eq!(p.competitors.len(), 9, "expected 9 competitors");
|
| 332 |
|
- |
assert!((p.stripe.percent - 0.029).abs() < 1e-9);
|
| 333 |
|
- |
assert!((p.stripe.fixed - 0.30).abs() < 1e-9);
|
| 334 |
|
- |
assert!((p.calc.avg_transaction - 25.0).abs() < 1e-9);
|
| 335 |
|
- |
}
|
| 336 |
|
- |
|
| 337 |
|
- |
fn net_of(result: &ComparisonResult, name: &str) -> String {
|
| 338 |
|
- |
result
|
| 339 |
|
- |
.rows
|
| 340 |
|
- |
.iter()
|
| 341 |
|
- |
.find(|r| r.name == name)
|
| 342 |
|
- |
.unwrap_or_else(|| panic!("row {name} missing"))
|
| 343 |
|
- |
.keep
|
| 344 |
|
- |
.clone()
|
| 345 |
|
- |
}
|
| 346 |
|
- |
|
| 347 |
|
- |
/// Pin the headline + every competitor's kept-amount at $1,000/mo, Basic
|
| 348 |
|
- |
/// tier ($16). These are the exact figures the retired page-pricing.js
|
| 349 |
|
- |
/// produced, the regression anchor the JS never had.
|
| 350 |
|
- |
#[test]
|
| 351 |
|
- |
fn pins_net_at_1000_basic() {
|
| 352 |
|
- |
let p = loaded();
|
| 353 |
|
- |
let r = p.compute(1000.0, 16.0);
|
| 354 |
|
- |
// stripe_net(1000) = 1000 - 29 - 40*0.30 = 959; minus $16 = 943.
|
| 355 |
|
- |
assert_eq!(r.mnw_keep, "$943.00");
|
| 356 |
|
- |
assert_eq!(net_of(&r, "Makenotwork"), "$943.00");
|
| 357 |
|
- |
assert_eq!(net_of(&r, "Patreon"), "$861.90"); // 900 - 26.1 - 12
|
| 358 |
|
- |
assert_eq!(net_of(&r, "Gumroad"), "$854.48"); // (900-20) - 25.52
|
| 359 |
|
- |
assert_eq!(net_of(&r, "Bandcamp"), "$825.35"); // 850 - 24.65
|
| 360 |
|
- |
assert_eq!(net_of(&r, "Ko-fi Gold"), "$947.00"); // 959 - 12
|
| 361 |
|
- |
assert_eq!(net_of(&r, "Lemon Squeezy"), "$930.00"); // 950 - 20
|
| 362 |
|
- |
}
|
| 363 |
|
- |
|
| 364 |
|
- |
/// Rows are sorted by net kept, descending, and the MNW row is present.
|
| 365 |
|
- |
#[test]
|
| 366 |
|
- |
fn rows_sorted_descending() {
|
| 367 |
|
- |
let r = loaded().compute(1000.0, 16.0);
|
| 368 |
|
- |
assert_eq!(r.rows.len(), 10);
|
| 369 |
|
- |
assert!(r.rows.iter().any(|row| row.is_mnw));
|
| 370 |
|
- |
// At $1,000 Ko-fi Gold ($947) beats MNW Basic ($943), so MNW is not
|
| 371 |
|
- |
// first and a breakeven note appears.
|
| 372 |
|
- |
assert!(r.breakeven.is_some());
|
| 373 |
|
- |
assert_eq!(r.rows[0].name, "Ko-fi Gold");
|
| 374 |
|
- |
}
|
| 375 |
|
- |
|
| 376 |
|
- |
/// Bandcamp's rate drops from 15% to 10% above the $5k threshold.
|
| 377 |
|
- |
#[test]
|
| 378 |
|
- |
fn bandcamp_tier_switch_at_threshold() {
|
| 379 |
|
- |
let p = loaded();
|
| 380 |
|
- |
// At $4,000 (below): 15%. ap = 3400; net = 3400 - 3400*0.029 = 3301.40
|
| 381 |
|
- |
assert_eq!(net_of(&p.compute(4000.0, 16.0), "Bandcamp"), "$3,301.40");
|
| 382 |
|
- |
// At $6,000 (above): 10%. ap = 5400; net = 5400 - 5400*0.029 = 5243.40
|
| 383 |
|
- |
assert_eq!(net_of(&p.compute(6000.0, 16.0), "Bandcamp"), "$5,243.40");
|
| 384 |
|
- |
}
|
| 385 |
|
- |
|
| 386 |
|
- |
/// Zero revenue renders placeholders, not computed money.
|
| 387 |
|
- |
#[test]
|
| 388 |
|
- |
fn zero_revenue_is_placeholder() {
|
| 389 |
|
- |
let r = loaded().compute(0.0, 16.0);
|
| 390 |
|
- |
assert_eq!(r.mnw_keep, "$0.00");
|
| 391 |
|
- |
assert!(r.rows.iter().all(|row| row.keep == "--"));
|
| 392 |
|
- |
assert!(r.breakeven.is_none());
|
| 393 |
|
- |
}
|
| 394 |
|
- |
|
| 395 |
|
- |
#[test]
|
| 396 |
|
- |
fn money_formatting_matches_js() {
|
| 397 |
|
- |
assert_eq!(fmt_money(943.0), "$943.00");
|
| 398 |
|
- |
assert_eq!(fmt_money(1234.5), "$1,234.50");
|
| 399 |
|
- |
assert_eq!(fmt_money(-12.34), "$-12.34");
|
| 400 |
|
- |
assert_eq!(fmt_whole(1000.0), "$1,000");
|
| 401 |
|
- |
assert_eq!(fmt_whole(5243.4), "$5,243");
|
| 402 |
|
- |
}
|
| 403 |
|
- |
}
|