Skip to main content

max / makenotwork

Simplify CSV amount parsing, deduplicate analytics queries Remove ambiguous parse_amount_cents heuristic (values 100-10000 misinterpreted). Whole numbers are now always cents, decimal values are dollars.cents. No format option needed. Extract Scope enum in analytics.rs to deduplicate 12 near-identical query branches (3 scopes x 2 interval variants x 2 functions) into dynamic query builders. 623 → 468 lines. Update todo.md: all Audit Run 18 items complete. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-02 19:24 UTC
Commit: 9a8e5eb9295d2682260f481051a3e5890043d192
Parent: 7d91c48
3 files changed, +152 insertions, -309 deletions
@@ -3,34 +3,42 @@
3 3 ## Status
4 4 Done: All pre-beta phases. Active: Creator setup (Stripe), manual testing. Next: Soft launch.
5 5
6 - v0.4.6. Audit grade A (Run 18, 2026-05-01). 1,209 unit + 724 integration = 1,933 tests. 34 integration failures (uncommitted changes). Mutation kill rate 99.4%. Property-based testing active (proptest).
7 -
8 - Code fuzz (2026-05-01): all 10 findings resolved (4 serious, 4 medium, 2 minor). Test fuzz (2026-05-01): all 3 validation hardening items resolved.
6 + v0.4.6. Audit grade A (Run 18, 2026-05-01). ~1,230 unit + ~684 integration = ~1,914 tests. Mutation kill rate 99.4%. Property-based testing active (proptest). `cargo test --features fast-tests` for fast runs.
9 7
10 8 Human tasks (manual testing, outreach, legal, infrastructure) moved to `human_todo.md`.
11 9
12 10 ---
13 11
14 - ## Audit Run 18 (2026-05-01)
12 + ## Integration Test Fixes (completed 2026-05-02)
13 +
14 + All 34 previously-failing tests resolved (10 root causes). Additional 3 sandbox test failures fixed. Key changes:
15 +
16 + - Auth rate limiter: moved from `route_layer` to per-handler `.layer()` — GET /login no longer rate-limited (production fix)
17 + - Advisory lock deadlock: `pg_advisory_lock`/`pg_advisory_unlock` used different pool connections, leaving locks permanently held. Replaced with `check_sandbox_cap` that pins lock+count+unlock to a single connection (production fix)
18 + - Test harness: unique IP per TestClient via atomic counter, preventing cross-test rate limit interference
19 + - `fast-tests` feature: Argon2 reduced to 8MiB/1iter (~10ms vs ~600ms), sandbox rate limit relaxed. Run tests with `cargo test --features fast-tests`. Sandbox suite: 2.3s (was 40+ min)
20 + - Consolidated 4 sandbox "blocks" tests into 1, fixed UUID type in subscription test
15 21
22 + ---
23 +
24 + ## Audit Run 18 (2026-05-01)
16 25
17 26 ### Testing
18 - - [ ] **[LOW]** Add unit tests to `wam_client.rs`
19 - - [ ] **[LOW]** Add unit tests to `git_ssh.rs` for `parse_ssh_command` and `parse_repo_path`
27 + - [x] **[LOW]** Add unit tests to `wam_client.rs` — 5 tests (URL construction, serialization, fire-and-forget error handling)
28 + - [x] **[LOW]** Add unit tests to `git_ssh.rs` for `parse_ssh_command` and `parse_repo_path` — 14 tests (all operations, quoting, traversal, edge cases)
20 29
21 30 ### Performance
22 - - [ ] **[LOW]** `scanning/hash_lookup.rs` creates new `reqwest::Client` per call — reuse from AppState
23 - - [ ] **[LOW]** `routes/ota.rs` `delete_release_handler` does 3 queries (list_releases + list_artifacts + delete) — consolidate
31 + - [x] **[LOW]** `scanning/hash_lookup.rs` creates new `reqwest::Client` per call — fixed: static `LazyLock<reqwest::Client>`
32 + - [x] **[LOW]** `routes/ota.rs` `delete_release_handler` — replaced `list_releases` (O(N)) + `list_artifacts` with `get_release_artifact_keys` (O(1) ownership check + S3 key fetch)
24 33
25 34 ### Data Integrity
26 - - [ ] **[LOW]** CSV import `parse_amount_cents` heuristic misinterprets 100-10,000 range — add explicit cents/dollars format option
35 + - [x] **[LOW]** CSV import `parse_amount_cents` — removed ambiguous heuristic. Whole numbers are always cents. Decimal values (e.g. "12.50") are dollars.cents. No format option needed.
27 36
28 37 ### Deferred
29 - - [ ] Split `helpers.rs` (~1,268 lines) into focused modules (formatting, crypto, rate_limit)
30 - - [ ] Reduce `analytics.rs` query duplication via builder pattern or macro (~150 LOC savings)
31 - - [ ] Remove `async-trait` crate in favor of Rust 2024 native async traits
32 - - [ ] Reduce `discover.rs` query duplication (3 near-identical base query blocks per function)
33 - - [ ] `routes/admin/` performance: `admin_users` calls `count_users` twice — batch into single query
38 + - [x] Split `helpers.rs` (~1,268 lines) into `formatting.rs`, `crypto.rs`, `rate_limit.rs` — re-exported from helpers for backward compat
39 + - [x] Reduce `analytics.rs` query duplication — extracted `Scope` enum with `where_clause`/`table_prefix`/`bind_scope`, 623→468 lines (155 LOC removed)
40 + - [x] `discover.rs` — already deduplicated via `append_item_discover_filters` + `bind_item_discover_filters!` macro. Remaining 3 SELECT variants differ in `match_score` expression (trigram/constant/NULL) — genuinely distinct SQL
41 + - [x] `routes/admin/` performance: `admin_users` called `count_users` 3× — replaced with single `count_users_summary` using `COUNT(*) FILTER`
34 42
35 43 ---
36 44
@@ -121,19 +121,67 @@ fn pct_change(current: i64, previous: i64) -> Option<(String, bool)> {
121 121 /// Format a bucket timestamp into a human-readable label.
122 122 fn format_bucket_label(dt: &DateTime<Utc>, range: &TimeRange) -> String {
123 123 match range {
124 - TimeRange::Days7 | TimeRange::Days30 => {
125 - // "Mar 1", "Jan 15"
126 - dt.format("%b %-d").to_string()
124 + TimeRange::Days7 | TimeRange::Days30 => dt.format("%b %-d").to_string(),
125 + TimeRange::Days90 => format!("Week {}", dt.iso_week().week()),
126 + TimeRange::All => dt.format("%b %Y").to_string(),
127 + }
128 + }
129 +
130 + // ── Scope-aware query building ──
131 +
132 + /// Scope determines the WHERE clause and bind parameters for transaction queries.
133 + enum Scope {
134 + Item(ItemId),
135 + Project(ProjectId),
136 + User,
137 + }
138 +
139 + impl Scope {
140 + fn from_ids(item_id: Option<ItemId>, project_id: Option<ProjectId>) -> Self {
141 + match (item_id, project_id) {
142 + (Some(iid), _) => Scope::Item(iid),
143 + (None, Some(pid)) => Scope::Project(pid),
144 + (None, None) => Scope::User,
145 + }
146 + }
147 +
148 + /// WHERE clause fragment (assumes seller_id is $1).
149 + fn where_clause(&self) -> &'static str {
150 + match self {
151 + Scope::Item(_) => "seller_id = $1 AND item_id = $2 AND status = 'completed'",
152 + Scope::Project(_) => "t.seller_id = $1 AND t.item_id IN (SELECT id FROM items WHERE project_id = $2) AND t.status = 'completed'",
153 + Scope::User => "seller_id = $1 AND status = 'completed'",
127 154 }
128 - TimeRange::Days90 => {
129 - // "Week 9"
130 - format!("Week {}", dt.iso_week().week())
155 + }
156 +
157 + /// Table alias prefix: "t." for project scope (uses subquery), empty for others.
158 + fn table_prefix(&self) -> &'static str {
159 + match self {
160 + Scope::Project(_) => "t.",
161 + _ => "",
131 162 }
132 - TimeRange::All => {
133 - // "Jan 2026"
134 - dt.format("%b %Y").to_string()
163 + }
164 +
165 + /// Table alias: "transactions t" for project scope, "transactions" for others.
166 + fn table_name(&self) -> &'static str {
167 + match self {
168 + Scope::Project(_) => "transactions t",
169 + _ => "transactions",
135 170 }
136 171 }
172 +
173 + /// Bind the scope-specific parameter ($2) if applicable.
174 + fn bind_scope<'q, O>(
175 + &self,
176 + query: sqlx::query::QueryAs<'q, sqlx::Postgres, O, sqlx::postgres::PgArguments>,
177 + ) -> sqlx::query::QueryAs<'q, sqlx::Postgres, O, sqlx::postgres::PgArguments> {
178 + match self {
179 + Scope::Item(iid) => query.bind(*iid),
180 + Scope::Project(pid) => query.bind(*pid),
181 + Scope::User => query,
182 + }
183 + }
184 +
137 185 }
138 186
139 187 /// Fetch time-bucketed revenue data for a seller, optionally filtered by project or item.
@@ -146,153 +194,33 @@ pub async fn get_revenue_timeseries(
146 194 range: &TimeRange,
147 195 ) -> Result<Vec<TimeBucket>> {
148 196 let bucket = range.bucket_sql();
149 -
150 - let rows: Vec<(DateTime<Utc>, i64, i64)> = match (item_id, project_id) {
151 - (Some(iid), _) => {
152 - // Item scope
153 - if let Some(interval) = range.interval_sql() {
154 - sqlx::query_as(
155 - &format!(
156 - r#"
157 - SELECT
158 - date_trunc('{bucket}', completed_at) AS bucket,
159 - COALESCE(SUM(amount_cents), 0)::BIGINT,
160 - COUNT(*)
161 - FROM transactions
162 - WHERE seller_id = $1
163 - AND item_id = $2
164 - AND status = 'completed'
165 - AND completed_at >= NOW() - INTERVAL '{interval}'
166 - GROUP BY bucket
167 - ORDER BY bucket
168 - LIMIT 500
169 - "#
170 - ),
171 - )
172 - .bind(seller_id)
173 - .bind(iid)
174 - .fetch_all(pool)
175 - .await?
176 - } else {
177 - sqlx::query_as(
178 - &format!(
179 - r#"
180 - SELECT
181 - date_trunc('{bucket}', completed_at) AS bucket,
182 - COALESCE(SUM(amount_cents), 0)::BIGINT,
183 - COUNT(*)
184 - FROM transactions
185 - WHERE seller_id = $1
186 - AND item_id = $2
187 - AND status = 'completed'
188 - GROUP BY bucket
189 - ORDER BY bucket
190 - LIMIT 500
191 - "#
192 - ),
193 - )
194 - .bind(seller_id)
195 - .bind(iid)
196 - .fetch_all(pool)
197 - .await?
198 - }
199 - }
200 - (None, Some(pid)) => {
201 - // Project scope
202 - if let Some(interval) = range.interval_sql() {
203 - sqlx::query_as(
204 - &format!(
205 - r#"
206 - SELECT
207 - date_trunc('{bucket}', t.completed_at) AS bucket,
208 - COALESCE(SUM(t.amount_cents), 0)::BIGINT,
209 - COUNT(*)
210 - FROM transactions t
211 - WHERE t.seller_id = $1
212 - AND t.item_id IN (SELECT id FROM items WHERE project_id = $2)
213 - AND t.status = 'completed'
214 - AND t.completed_at >= NOW() - INTERVAL '{interval}'
215 - GROUP BY bucket
216 - ORDER BY bucket
217 - LIMIT 500
218 - "#
219 - ),
220 - )
221 - .bind(seller_id)
222 - .bind(pid)
223 - .fetch_all(pool)
224 - .await?
225 - } else {
226 - sqlx::query_as(
227 - &format!(
228 - r#"
229 - SELECT
230 - date_trunc('{bucket}', t.completed_at) AS bucket,
231 - COALESCE(SUM(t.amount_cents), 0)::BIGINT,
232 - COUNT(*)
233 - FROM transactions t
234 - WHERE t.seller_id = $1
235 - AND t.item_id IN (SELECT id FROM items WHERE project_id = $2)
236 - AND t.status = 'completed'
237 - GROUP BY bucket
238 - ORDER BY bucket
239 - LIMIT 500
240 - "#
241 - ),
242 - )
243 - .bind(seller_id)
244 - .bind(pid)
245 - .fetch_all(pool)
246 - .await?
247 - }
248 - }
249 - (None, None) => {
250 - // User scope
251 - if let Some(interval) = range.interval_sql() {
252 - sqlx::query_as(
253 - &format!(
254 - r#"
255 - SELECT
256 - date_trunc('{bucket}', completed_at) AS bucket,
257 - COALESCE(SUM(amount_cents), 0)::BIGINT,
258 - COUNT(*)
259 - FROM transactions
260 - WHERE seller_id = $1
261 - AND status = 'completed'
262 - AND completed_at >= NOW() - INTERVAL '{interval}'
263 - GROUP BY bucket
264 - ORDER BY bucket
265 - LIMIT 500
266 - "#
267 - ),
268 - )
269 - .bind(seller_id)
270 - .fetch_all(pool)
271 - .await?
272 - } else {
273 - sqlx::query_as(
274 - &format!(
275 - r#"
276 - SELECT
277 - date_trunc('{bucket}', completed_at) AS bucket,
278 - COALESCE(SUM(amount_cents), 0)::BIGINT,
279 - COUNT(*)
280 - FROM transactions
281 - WHERE seller_id = $1
282 - AND status = 'completed'
283 - GROUP BY bucket
284 - ORDER BY bucket
285 - LIMIT 500
286 - "#
287 - ),
288 - )
289 - .bind(seller_id)
290 - .fetch_all(pool)
291 - .await?
292 - }
293 - }
197 + let scope = Scope::from_ids(item_id, project_id);
198 + let prefix = scope.table_prefix();
199 + let table = scope.table_name();
200 + let where_clause = scope.where_clause();
201 +
202 + let time_filter = match range.interval_sql() {
203 + Some(interval) => format!(" AND {prefix}completed_at >= NOW() - INTERVAL '{interval}'"),
204 + None => String::new(),
294 205 };
295 206
207 + let sql = format!(
208 + r#"
209 + SELECT
210 + date_trunc('{bucket}', {prefix}completed_at) AS bucket,
211 + COALESCE(SUM({prefix}amount_cents), 0)::BIGINT,
212 + COUNT(*)
213 + FROM {table}
214 + WHERE {where_clause}{time_filter}
215 + GROUP BY bucket
216 + ORDER BY bucket
217 + LIMIT 500
218 + "#
219 + );
220 +
221 + let q = sqlx::query_as::<_, (DateTime<Utc>, i64, i64)>(&sql).bind(seller_id);
222 + let rows = scope.bind_scope(q).fetch_all(pool).await?;
223 +
296 224 let buckets = rows
297 225 .into_iter()
298 226 .map(|(dt, revenue, count)| TimeBucket {
@@ -341,126 +269,43 @@ async fn get_transaction_comparison(
341 269 item_id: Option<ItemId>,
342 270 range: &TimeRange,
343 271 ) -> Result<(i64, i64, i64, i64)> {
344 - // For All: just sum everything, no previous period
272 + let scope = Scope::from_ids(item_id, project_id);
273 + let prefix = scope.table_prefix();
274 + let table = scope.table_name();
275 + let where_clause = scope.where_clause();
276 +
345 277 let Some(interval) = range.interval_sql() else {
346 - let row: (i64, i64) = match (item_id, project_id) {
347 - (Some(iid), _) => {
348 - sqlx::query_as(
349 - r#"
350 - SELECT
351 - COALESCE(SUM(amount_cents), 0)::BIGINT,
352 - COUNT(*)
353 - FROM transactions
354 - WHERE seller_id = $1 AND item_id = $2 AND status = 'completed'
355 - "#,
356 - )
357 - .bind(seller_id)
358 - .bind(iid)
359 - .fetch_one(pool)
360 - .await?
361 - }
362 - (None, Some(pid)) => {
363 - sqlx::query_as(
364 - r#"
365 - SELECT
366 - COALESCE(SUM(t.amount_cents), 0)::BIGINT,
367 - COUNT(*)
368 - FROM transactions t
369 - WHERE t.seller_id = $1
370 - AND t.item_id IN (SELECT id FROM items WHERE project_id = $2)
371 - AND t.status = 'completed'
372 - "#,
373 - )
374 - .bind(seller_id)
375 - .bind(pid)
376 - .fetch_one(pool)
377 - .await?
378 - }
379 - (None, None) => {
380 - sqlx::query_as(
381 - r#"
382 - SELECT
383 - COALESCE(SUM(amount_cents), 0)::BIGINT,
384 - COUNT(*)
385 - FROM transactions
386 - WHERE seller_id = $1 AND status = 'completed'
387 - "#,
388 - )
389 - .bind(seller_id)
390 - .fetch_one(pool)
391 - .await?
392 - }
393 - };
278 + // All time: just sum everything, no previous period
279 + let sql = format!(
280 + r#"
281 + SELECT
282 + COALESCE(SUM({prefix}amount_cents), 0)::BIGINT,
283 + COUNT(*)
284 + FROM {table}
285 + WHERE {where_clause}
286 + "#
287 + );
288 + let q = sqlx::query_as::<_, (i64, i64)>(&sql).bind(seller_id);
289 + let row = scope.bind_scope(q).fetch_one(pool).await?;
394 290 return Ok((row.0, 0, row.1, 0));
395 291 };
396 292
397 293 // Current vs previous period using FILTER
398 - let row: (i64, i64, i64, i64) = match (item_id, project_id) {
399 - (Some(iid), _) => {
400 - sqlx::query_as(
401 - &format!(
402 - r#"
403 - SELECT
404 - COALESCE(SUM(amount_cents) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'), 0)::BIGINT,
405 - COUNT(*) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'),
406 - COALESCE(SUM(amount_cents) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}'), 0)::BIGINT,
407 - COUNT(*) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}')
408 - FROM transactions
409 - WHERE seller_id = $1
410 - AND item_id = $2
411 - AND status = 'completed'
412 - AND completed_at >= NOW() - INTERVAL '{interval}' * 2
413 - "#
414 - ),
415 - )
416 - .bind(seller_id)
417 - .bind(iid)
418 - .fetch_one(pool)
419 - .await?
420 - }
421 - (None, Some(pid)) => {
422 - sqlx::query_as(
423 - &format!(
424 - r#"
425 - SELECT
426 - COALESCE(SUM(t.amount_cents) FILTER (WHERE t.completed_at >= NOW() - INTERVAL '{interval}'), 0)::BIGINT,
427 - COUNT(*) FILTER (WHERE t.completed_at >= NOW() - INTERVAL '{interval}'),
428 - COALESCE(SUM(t.amount_cents) FILTER (WHERE t.completed_at < NOW() - INTERVAL '{interval}'), 0)::BIGINT,
429 - COUNT(*) FILTER (WHERE t.completed_at < NOW() - INTERVAL '{interval}')
430 - FROM transactions t
431 - WHERE t.seller_id = $1
432 - AND t.item_id IN (SELECT id FROM items WHERE project_id = $2)
433 - AND t.status = 'completed'
434 - AND t.completed_at >= NOW() - INTERVAL '{interval}' * 2
435 - "#
436 - ),
437 - )
438 - .bind(seller_id)
439 - .bind(pid)
440 - .fetch_one(pool)
441 - .await?
442 - }
443 - (None, None) => {
444 - sqlx::query_as(
445 - &format!(
446 - r#"
447 - SELECT
448 - COALESCE(SUM(amount_cents) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'), 0)::BIGINT,
449 - COUNT(*) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'),
450 - COALESCE(SUM(amount_cents) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}'), 0)::BIGINT,
451 - COUNT(*) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}')
452 - FROM transactions
453 - WHERE seller_id = $1
454 - AND status = 'completed'
455 - AND completed_at >= NOW() - INTERVAL '{interval}' * 2
456 - "#
457 - ),
458 - )
459 - .bind(seller_id)
460 - .fetch_one(pool)
461 - .await?
462 - }
463 - };
294 + let sql = format!(
295 + r#"
296 + SELECT
297 + COALESCE(SUM({prefix}amount_cents) FILTER (WHERE {prefix}completed_at >= NOW() - INTERVAL '{interval}'), 0)::BIGINT,
298 + COUNT(*) FILTER (WHERE {prefix}completed_at >= NOW() - INTERVAL '{interval}'),
299 + COALESCE(SUM({prefix}amount_cents) FILTER (WHERE {prefix}completed_at < NOW() - INTERVAL '{interval}'), 0)::BIGINT,
300 + COUNT(*) FILTER (WHERE {prefix}completed_at < NOW() - INTERVAL '{interval}')
301 + FROM {table}
302 + WHERE {where_clause}
303 + AND {prefix}completed_at >= NOW() - INTERVAL '{interval}' * 2
304 + "#
305 + );
306 +
307 + let q = sqlx::query_as::<_, (i64, i64, i64, i64)>(&sql).bind(seller_id);
308 + let row = scope.bind_scope(q).fetch_one(pool).await?;
464 309
465 310 Ok((row.0, row.2, row.1, row.3))
466 311 }
@@ -54,7 +54,7 @@ pub fn parse_csv(bytes: &[u8], mapping: &ColumnMapping) -> Result<ImportPayload>
54 54 .map(sanitize_field)
55 55 .filter(|s| !s.is_empty());
56 56
57 - // Extract amount
57 + // Extract amount (always in cents)
58 58 let amount_cents = mapping
59 59 .amount
60 60 .and_then(|i| record.get(i))
@@ -128,7 +128,10 @@ pub fn parse_csv(bytes: &[u8], mapping: &ColumnMapping) -> Result<ImportPayload>
128 128 }
129 129
130 130 /// Parse a currency string into cents.
131 - /// Handles: "$12.50", "12.50", "€10", "£5.00", "1,234.56", "1234"
131 + ///
132 + /// All values are interpreted as cents. A decimal point is treated as
133 + /// dollars.cents (e.g. "12.50" → 1250). Whole numbers are cents as-is
134 + /// (e.g. "500" → 500). Currency symbols and commas are stripped.
132 135 fn parse_amount_cents(s: &str) -> Option<i64> {
133 136 let cleaned: String = s
134 137 .trim()
@@ -141,7 +144,7 @@ fn parse_amount_cents(s: &str) -> Option<i64> {
141 144 return None;
142 145 }
143 146
144 - // Try as decimal (dollars.cents)
147 + // Decimal point means dollars.cents
145 148 if let Some((dollars, cents_str)) = cleaned.split_once('.') {
146 149 let dollars: i64 = dollars.trim().parse().ok()?;
147 150 // Pad or truncate to exactly 2 decimal places
@@ -158,14 +161,8 @@ fn parse_amount_cents(s: &str) -> Option<i64> {
158 161 return Some(dollars * 100 + cents);
159 162 }
160 163
161 - // Try as whole number (already cents or whole dollars)
162 - let n: i64 = cleaned.parse().ok()?;
163 - // Heuristic: values > 10000 are probably already in cents
164 - if n > 10000 {
165 - Some(n)
166 - } else {
167 - Some(n * 100)
168 - }
164 + // Whole number — already in cents
165 + cleaned.parse().ok()
169 166 }
170 167
171 168 /// Parse dates flexibly. Supports:
@@ -239,7 +236,7 @@ mod tests {
239 236 use super::*;
240 237
241 238 #[test]
242 - fn parse_amount_dollars_and_cents() {
239 + fn parse_amount_decimal_as_dollars_cents() {
243 240 assert_eq!(parse_amount_cents("$12.50"), Some(1250));
244 241 assert_eq!(parse_amount_cents("12.50"), Some(1250));
245 242 assert_eq!(parse_amount_cents("€10.00"), Some(1000));
@@ -248,15 +245,11 @@ mod tests {
248 245 }
249 246
250 247 #[test]
251 - fn parse_amount_whole_numbers() {
252 - assert_eq!(parse_amount_cents("10"), Some(1000));
253 - assert_eq!(parse_amount_cents("0"), Some(0));
254 - }
255 -
256 - #[test]
257 - fn parse_amount_large_numbers_as_cents() {
258 - // > 10000 treated as already cents
248 + fn parse_amount_whole_numbers_are_cents() {
249 + assert_eq!(parse_amount_cents("500"), Some(500));
259 250 assert_eq!(parse_amount_cents("15000"), Some(15000));
251 + assert_eq!(parse_amount_cents("0"), Some(0));
252 + assert_eq!(parse_amount_cents("10"), Some(10));
260 253 }
261 254
262 255 #[test]
@@ -481,16 +474,15 @@ mod tests {
481 474
482 475 #[test]
483 476 fn parse_amount_free_or_zero() {
484 - // "Free" is not a numeric string, should return None
485 477 assert_eq!(parse_amount_cents("Free"), None);
486 - // Explicit zero
487 478 assert_eq!(parse_amount_cents("$0.00"), Some(0));
488 479 assert_eq!(parse_amount_cents("0"), Some(0));
489 480 }
490 481
491 482 #[test]
492 483 fn parse_amount_yen_symbol() {
493 - assert_eq!(parse_amount_cents("¥1500"), Some(150000));
484 + // ¥1500 whole number = 1500 cents
485 + assert_eq!(parse_amount_cents("¥1500"), Some(1500));
494 486 }
495 487
496 488 #[test]
@@ -511,19 +503,16 @@ mod tests {
511 503
512 504 #[test]
513 505 fn parse_amount_single_decimal_digit() {
514 - // e.g. "5.5" should be 550 cents
515 506 assert_eq!(parse_amount_cents("5.5"), Some(550));
516 507 }
517 508
518 509 #[test]
519 510 fn parse_amount_three_decimal_digits_truncates() {
520 - // "12.999" => takes first two decimal digits "99" => 1299
521 511 assert_eq!(parse_amount_cents("12.999"), Some(1299));
522 512 }
523 513
524 514 #[test]
525 515 fn parse_amount_negative_parses_as_negative_cents() {
526 - // Negative values parse successfully (e.g. refunds)
527 516 assert_eq!(parse_amount_cents("-10.00"), Some(-1000));
528 517 assert_eq!(parse_amount_cents("-"), None);
529 518 }
@@ -628,6 +617,7 @@ mod tests {
628 617 item_title: Some(4),
629 618 tier: Some(5),
630 619 status: Some(6),
620 + ..Default::default()
631 621 };
632 622 let payload = parse_csv(csv, &mapping).unwrap();
633 623 assert_eq!(payload.subscribers.len(), 1);