max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
26 files changed,
+483 insertions,
-101 deletions
| @@ -46,6 +46,25 @@ | |||
| 46 | 46 | format!("{sign}${dollars}.{frac:02}") | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | /// Format a price in cents as a plain decimal string: no currency symbol, no | |
| 50 | + | /// thousands separators, always two decimal places (e.g. "9.99", "1234.50"). | |
| 51 | + | /// | |
| 52 | + | /// For CSV cells and form `value=` attributes where a bare numeric is required | |
| 53 | + | /// and the surrounding context (spreadsheet column, template `$` prefix) | |
| 54 | + | /// supplies its own framing. For human-facing display use [`format_price`] | |
| 55 | + | /// (shows "Free" / drops trailing `.00`) or [`format_revenue`] (always `$X.XX`). | |
| 56 | + | /// | |
| 57 | + | /// This is the canonical cents→decimal conversion: bypassing it with a raw | |
| 58 | + | /// `as f64 / 100.0` is what the pricing-format drift chronic keeps re-finding, | |
| 59 | + | /// so a grep-enforcing test (below) bans that idiom outside this module. | |
| 60 | + | pub fn format_dollars_plain(cents: impl Into<i64>) -> String { | |
| 61 | + | let cents: i64 = cents.into(); | |
| 62 | + | let neg = cents < 0; | |
| 63 | + | let abs = cents.unsigned_abs(); | |
| 64 | + | let sign = if neg { "-" } else { "" }; | |
| 65 | + | format!("{sign}{}.{:02}", abs / 100, (abs % 100) as u32) | |
| 66 | + | } | |
| 67 | + | ||
| 49 | 68 | /// Format a byte count as a human-readable file size string. | |
| 50 | 69 | /// Returns "N/A" for zero bytes (useful for optional file sizes). | |
| 51 | 70 | pub fn format_file_size(bytes: i64) -> String { | |
| @@ -168,6 +187,29 @@ | |||
| 168 | 187 | mod tests { | |
| 169 | 188 | use super::*; | |
| 170 | 189 | ||
| 190 | + | // ── format_dollars_plain ── | |
| 191 | + | ||
| 192 | + | #[test] | |
| 193 | + | fn dollars_plain_basic() { | |
| 194 | + | assert_eq!(format_dollars_plain(0), "0.00"); | |
| 195 | + | assert_eq!(format_dollars_plain(999), "9.99"); | |
| 196 | + | assert_eq!(format_dollars_plain(100), "1.00"); | |
| 197 | + | assert_eq!(format_dollars_plain(1), "0.01"); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[test] | |
| 201 | + | fn dollars_plain_no_thousands_separator() { | |
| 202 | + | // CSV cells and form values must stay machine-parseable — no commas. | |
| 203 | + | assert_eq!(format_dollars_plain(123_456), "1234.56"); | |
| 204 | + | assert_eq!(format_dollars_plain(1_000_000), "10000.00"); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | #[test] | |
| 208 | + | fn dollars_plain_negative() { | |
| 209 | + | assert_eq!(format_dollars_plain(-999), "-9.99"); | |
| 210 | + | assert_eq!(format_dollars_plain(-5), "-0.05"); | |
| 211 | + | } | |
| 212 | + | ||
| 171 | 213 | // ── format_price ── | |
| 172 | 214 | ||
| 173 | 215 | #[test] | |
| @@ -714,3 +756,64 @@ | |||
| 714 | 756 | } | |
| 715 | 757 | } | |
| 716 | 758 | } | |
| 759 | + | ||
| 760 | + | /// Seal for the pricing-format drift chronic. | |
| 761 | + | /// | |
| 762 | + | /// The audit kept re-finding ad-hoc `cents as f64 / 100.0` conversions scattered | |
| 763 | + | /// across routes, exports, and admin tooling — each one a place where a future | |
| 764 | + | /// edit could silently reintroduce a rounding or display inconsistency. This | |
| 765 | + | /// test fails the build if that idiom appears anywhere under `src/` outside this | |
| 766 | + | /// module, forcing every cents→dollars conversion through [`format_price`], | |
| 767 | + | /// [`format_revenue`], or [`format_dollars_plain`]. The seal is resolved when the | |
| 768 | + | /// drifted variant can no longer compile-and-pass. | |
| 769 | + | #[cfg(test)] | |
| 770 | + | mod pricing_format_seal_guard { | |
| 771 | + | use std::path::Path; | |
| 772 | + | ||
| 773 | + | #[test] | |
| 774 | + | fn no_ad_hoc_cents_to_dollars_conversion() { | |
| 775 | + | let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); | |
| 776 | + | let this_file = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/formatting.rs"); | |
| 777 | + | let mut offenders = Vec::new(); | |
| 778 | + | walk(&src_dir, &mut |path, contents| { | |
| 779 | + | // This module documents the banned idiom; skip it. | |
| 780 | + | if path == this_file { | |
| 781 | + | return; | |
| 782 | + | } | |
| 783 | + | for (i, line) in contents.lines().enumerate() { | |
| 784 | + | // Comment/doc lines may legitimately mention the idiom. | |
| 785 | + | if line.trim_start().starts_with("//") { | |
| 786 | + | continue; | |
| 787 | + | } | |
| 788 | + | // Normalize whitespace so spacing variations all match. | |
| 789 | + | let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect(); | |
| 790 | + | if squished.contains("asf64/100") { | |
| 791 | + | offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); | |
| 792 | + | } | |
| 793 | + | } | |
| 794 | + | }); | |
| 795 | + | assert!( | |
| 796 | + | offenders.is_empty(), | |
| 797 | + | "pricing-format seal violated — convert cents to dollars via \ | |
| 798 | + | formatting::format_price / format_revenue / format_dollars_plain, never a raw \ | |
| 799 | + | `as f64 / 100.0`. Offending lines:\n{}", | |
| 800 | + | offenders.join("\n") | |
| 801 | + | ); | |
| 802 | + | } | |
| 803 | + | ||
| 804 | + | fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) { | |
| 805 | + | let Ok(entries) = std::fs::read_dir(dir) else { | |
| 806 | + | return; | |
| 807 | + | }; | |
| 808 | + | for entry in entries.flatten() { | |
| 809 | + | let path = entry.path(); | |
| 810 | + | if path.is_dir() { | |
| 811 | + | walk(&path, f); | |
| 812 | + | } else if path.extension().is_some_and(|e| e == "rs") | |
| 813 | + | && let Ok(contents) = std::fs::read_to_string(&path) | |
| 814 | + | { | |
| 815 | + | f(&path, &contents); | |
| 816 | + | } | |
| 817 | + | } | |
| 818 | + | } | |
| 819 | + | } |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | pub mod auth; | |
| 5 | 5 | pub mod background; | |
| 6 | 6 | pub mod build_runner; | |
| 7 | + | pub mod cloudflare; | |
| 7 | 8 | pub mod config; | |
| 8 | 9 | pub mod constants; | |
| 9 | 10 | pub mod csrf; |
| @@ -402,6 +402,8 @@ | |||
| 402 | 402 | scan_semaphore: state.scan_semaphore.clone(), | |
| 403 | 403 | wam: state.wam.clone(), | |
| 404 | 404 | clamav_healthy: clamav_healthy.clone(), | |
| 405 | + | cloudflare: makenotwork::cloudflare::CloudflarePurger::from_env(), | |
| 406 | + | cdn_base_url: state.config.cdn_base_url.as_deref().map(std::sync::Arc::from), | |
| 405 | 407 | }); | |
| 406 | 408 | let worker_count = makenotwork::constants::SCAN_WORKER_COUNT; | |
| 407 | 409 | let worker_shutdown_rx = shutdown_tx.subscribe(); |
| @@ -319,8 +319,8 @@ | |||
| 319 | 319 | let min = self.min_cents.unwrap_or(0); | |
| 320 | 320 | if amount_cents < min { | |
| 321 | 321 | return Err(format!( | |
| 322 | - | "Amount must be at least ${:.2}", | |
| 323 | - | min as f64 / 100.0 | |
| 322 | + | "Amount must be at least {}", | |
| 323 | + | crate::formatting::format_revenue(min as i64) | |
| 324 | 324 | )); | |
| 325 | 325 | } | |
| 326 | 326 | // Cap at $10,000 (same ceiling as tips) to prevent accidental mega-charges |
| @@ -448,10 +448,8 @@ | |||
| 448 | 448 | let (revenue_cents, completed, refunded) = | |
| 449 | 449 | db::transactions::get_platform_revenue_stats(pool).await?; | |
| 450 | 450 | ||
| 451 | - | let dollars = revenue_cents as f64 / 100.0; | |
| 452 | - | ||
| 453 | 451 | println!("Platform Revenue"); | |
| 454 | - | println!(" Total revenue: ${:.2}", dollars); | |
| 452 | + | println!(" Total revenue: {}", makenotwork::formatting::format_revenue(revenue_cents)); | |
| 455 | 453 | println!(" Total sales: {}", completed); | |
| 456 | 454 | println!(" Total refunds: {}", refunded); | |
| 457 | 455 | ||
| @@ -499,9 +497,9 @@ | |||
| 499 | 497 | } | |
| 500 | 498 | ||
| 501 | 499 | println!( | |
| 502 | - | "\n{} transaction(s), ${:.2} total revenue.", | |
| 500 | + | "\n{} transaction(s), {} total revenue.", | |
| 503 | 501 | txs.len(), | |
| 504 | - | total_cents as f64 / 100.0 | |
| 502 | + | makenotwork::formatting::format_revenue(total_cents) | |
| 505 | 503 | ); | |
| 506 | 504 | Ok(()) | |
| 507 | 505 | } |
| @@ -16,6 +16,12 @@ | |||
| 16 | 16 | description: Option<&str>, | |
| 17 | 17 | is_public: bool, | |
| 18 | 18 | ) -> Result<DbCollection> { | |
| 19 | + | // Collections intentionally REJECT a slug clash rather than auto-suffix | |
| 20 | + | // (unlike projects/items/sections): the slug is user-chosen and they should | |
| 21 | + | // be told it's taken. Map the per-user unique violation to a clean | |
| 22 | + | // validation error here so every caller — web and the CLI/service route — | |
| 23 | + | // gets the same 400 instead of a raw 500. This is the seal: no caller can | |
| 24 | + | // surface an unhandled 23505 from a collection insert. | |
| 19 | 25 | let collection = sqlx::query_as::<_, DbCollection>( | |
| 20 | 26 | r#" | |
| 21 | 27 | INSERT INTO collections (user_id, slug, title, description, is_public) | |
| @@ -29,7 +35,17 @@ | |||
| 29 | 35 | .bind(description) | |
| 30 | 36 | .bind(is_public) | |
| 31 | 37 | .fetch_one(pool) | |
| 32 | - | .await?; | |
| 38 | + | .await | |
| 39 | + | .map_err(|e| { | |
| 40 | + | let e = crate::error::AppError::from(e); | |
| 41 | + | if crate::helpers::is_unique_violation(&e) { | |
| 42 | + | crate::error::AppError::validation( | |
| 43 | + | "You already have a collection with this slug".to_string(), | |
| 44 | + | ) | |
| 45 | + | } else { | |
| 46 | + | e | |
| 47 | + | } | |
| 48 | + | })?; | |
| 33 | 49 | ||
| 34 | 50 | Ok(collection) | |
| 35 | 51 | } |
| @@ -20,23 +20,32 @@ | |||
| 20 | 20 | features: &[String], | |
| 21 | 21 | ) -> Result<DbProject> { | |
| 22 | 22 | let project_type = super::ProjectFeature::derive_project_type(features); | |
| 23 | - | let project = sqlx::query_as::<_, DbProject>( | |
| 24 | - | r#" | |
| 25 | - | INSERT INTO projects (user_id, slug, title, description, project_type, features) | |
| 26 | - | VALUES ($1, $2, $3, $4, $5, $6) | |
| 27 | - | RETURNING * | |
| 28 | - | "#, | |
| 29 | - | ) | |
| 30 | - | .bind(user_id) | |
| 31 | - | .bind(slug) | |
| 32 | - | .bind(title) | |
| 33 | - | .bind(description) | |
| 34 | - | .bind(project_type) | |
| 35 | - | .bind(features) | |
| 36 | - | .fetch_one(pool) | |
| 37 | - | .await?; | |
| 38 | - | ||
| 39 | - | Ok(project) | |
| 23 | + | // Slug uniqueness is enforced by the per-table unique indexes — including the | |
| 24 | + | // cross-creator `idx_projects_public_slug` (migration 062). Route the bare | |
| 25 | + | // INSERT through `insert_with_unique_slug` so a collision auto-suffixes | |
| 26 | + | // (`slug`, `slug-2`, ...) and retries instead of surfacing a raw 500 (the | |
| 27 | + | // CHRONIC slug-dedup drift, ultra-fuzz Run 2 UX). This is the seal: there is | |
| 28 | + | // no public bare-insert constructor for projects. | |
| 29 | + | crate::helpers::insert_with_unique_slug(slug.as_str(), |candidate| async move { | |
| 30 | + | let candidate = Slug::from_trusted(candidate); | |
| 31 | + | sqlx::query_as::<_, DbProject>( | |
| 32 | + | r#" | |
| 33 | + | INSERT INTO projects (user_id, slug, title, description, project_type, features) | |
| 34 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 35 | + | RETURNING * | |
| 36 | + | "#, | |
| 37 | + | ) | |
| 38 | + | .bind(user_id) | |
| 39 | + | .bind(&candidate) | |
| 40 | + | .bind(title) | |
| 41 | + | .bind(description) | |
| 42 | + | .bind(project_type) | |
| 43 | + | .bind(features) | |
| 44 | + | .fetch_one(pool) | |
| 45 | + | .await | |
| 46 | + | .map_err(Into::into) | |
| 47 | + | }) | |
| 48 | + | .await | |
| 40 | 49 | } | |
| 41 | 50 | ||
| 42 | 51 | /// Fetch a project by primary key. Returns `None` if not found. |