chore: Remove dead code - delete obsolete money.rs file

- Remove src/db/money.rs which contained the old Decimal-based MoneyAmount implementation
- This file was leftover from the INTEGER cents refactoring and was not referenced in mod.rs
- Only money_amount.rs with INTEGER storage is now active
- Compilation confirmed successful after removal
This commit is contained in:
Dylan Knutson
2025-08-27 23:24:44 +00:00
parent 0eaab7c743
commit eca570c887

View File

@@ -1,730 +0,0 @@
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sqlx::{
encode::IsNull, error::BoxDynError, sqlite::SqliteTypeInfo, Decode, Encode, Sqlite, Type,
TypeInfo,
};
use std::ops::{Add, Sub};
use std::str::FromStr;
use super::currency_type::CurrencyType;
/// Newtype wrapper around Decimal for SQLx integration
/// Stores as NUMERIC (TEXT) in SQLite to maintain exact precision
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct MoneyAmount(Decimal);
impl MoneyAmount {
pub fn new(decimal: Decimal) -> Self {
Self(decimal)
}
pub fn from_str(s: &str) -> Result<Self, rust_decimal::Error> {
Ok(Self(Decimal::from_str(s)?))
}
pub fn zero() -> Self {
Self(Decimal::ZERO)
}
pub fn inner(&self) -> Decimal {
self.0
}
}
// Allow easy conversion from Decimal
impl From<Decimal> for MoneyAmount {
fn from(decimal: Decimal) -> Self {
Self(decimal)
}
}
// Allow easy conversion to Decimal
impl From<MoneyAmount> for Decimal {
fn from(money: MoneyAmount) -> Self {
money.0
}
}
// Implement Display for easy printing
impl std::fmt::Display for MoneyAmount {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
// Implement arithmetic operations
impl Add for MoneyAmount {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl Sub for MoneyAmount {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
Self(self.0 - rhs.0)
}
}
// Implement SQLx Type trait for MoneyAmount with SQLite (store as TEXT with NUMERIC affinity)
impl Type<Sqlite> for MoneyAmount {
fn type_info() -> SqliteTypeInfo {
// Use TEXT type but this will work with NUMERIC affinity columns
<&str as Type<Sqlite>>::type_info()
}
fn compatible(ty: &SqliteTypeInfo) -> bool {
// Accept both TEXT and NUMERIC/REAL types
<&str as Type<Sqlite>>::compatible(ty)
|| <f64 as Type<Sqlite>>::compatible(ty)
|| ty.name() == "NUMERIC"
|| ty.name() == "DECIMAL"
}
}
// Implement SQLx Encode trait for MoneyAmount with SQLite
impl<'q> Encode<'q, Sqlite> for MoneyAmount {
fn encode_by_ref(
&self,
args: &mut Vec<sqlx::sqlite::SqliteArgumentValue<'q>>,
) -> Result<IsNull, BoxDynError> {
// Store decimal as string - SQLite NUMERIC affinity will handle the conversion
let decimal_str = self.0.to_string();
args.push(sqlx::sqlite::SqliteArgumentValue::Text(decimal_str.into()));
Ok(IsNull::No)
}
}
// Implement SQLx Decode trait for MoneyAmount with SQLite
impl<'r> Decode<'r, Sqlite> for MoneyAmount {
fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
// Decode as string from NUMERIC affinity column (stored as TEXT for precision)
let decimal_str = <&str as Decode<Sqlite>>::decode(value)?;
let decimal = Decimal::from_str(decimal_str)?;
Ok(MoneyAmount(decimal))
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use sqlx::{Row, SqlitePool};
#[test]
fn test_money_amount_creation() {
let money1 = MoneyAmount::from(Decimal::new(12345, 2)); // 123.45
assert_eq!(money1.to_string(), "123.45");
let money2 = MoneyAmount::from_str("456.789123").unwrap();
assert_eq!(money2.to_string(), "456.789123");
let zero_money = MoneyAmount::zero();
assert_eq!(zero_money.to_string(), "0");
}
#[test]
fn test_money_amount_precision() {
let precise_money = MoneyAmount::from_str("0.000001").unwrap(); // 1 micro-unit
assert_eq!(precise_money.to_string(), "0.000001");
let large_money = MoneyAmount::from_str("999999.999999").unwrap(); // Near max precision
assert_eq!(large_money.to_string(), "999999.999999");
}
#[test]
fn test_money_amount_conversions() {
let decimal = Decimal::new(12345, 2);
let money: MoneyAmount = decimal.into();
let back_to_decimal: Decimal = money.into();
assert_eq!(decimal, back_to_decimal);
}
// Database integration test utilities
async fn create_test_pool() -> SqlitePool {
// Create an in-memory SQLite database for testing
SqlitePool::connect("sqlite::memory:")
.await
.expect("Failed to create in-memory database")
}
async fn setup_test_table(pool: &SqlitePool) {
// Create a test table with MoneyAmount and CurrencyType fields
// Use TEXT for MoneyAmount fields in STRICT mode (stores as TEXT, preserves precision)
sqlx::query(
r#"
CREATE TABLE test_money (
id INTEGER PRIMARY KEY,
amount TEXT NOT NULL,
currency TEXT NOT NULL,
optional_amount TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
) STRICT
"#,
)
.execute(pool)
.await
.expect("Failed to create test table");
}
#[rstest]
#[case("0")]
#[case("123.45")]
#[case("0.000001")] // Micro precision
#[case("999999.999999")] // Large with precision
#[case("1234567890.123456")] // Very large
#[case("0.1")]
#[case("0.01")]
#[case("0.001")]
#[case("1.0")]
#[case("1.00")]
#[case("1.10")]
#[case("1.01")]
#[tokio::test]
async fn test_money_amount_database_encode_decode(#[case] amount_str: &str) {
let pool = create_test_pool().await;
setup_test_table(&pool).await;
let amount = MoneyAmount::from_str(amount_str).unwrap();
// Insert test data
sqlx::query("INSERT INTO test_money (amount, currency) VALUES (?, ?)")
.bind(&amount)
.bind(CurrencyType::USD)
.execute(&pool)
.await
.expect("Failed to insert test data");
// Retrieve and verify data
let row = sqlx::query("SELECT amount, currency FROM test_money LIMIT 1")
.fetch_one(&pool)
.await
.expect("Failed to fetch test data");
let retrieved_amount: MoneyAmount = row.get("amount");
let retrieved_currency: CurrencyType = row.get("currency");
assert_eq!(retrieved_amount, amount);
assert_eq!(retrieved_currency, CurrencyType::USD);
// Verify string representation matches exactly
assert_eq!(retrieved_amount.to_string(), amount.to_string());
assert_eq!(retrieved_amount.to_string(), amount_str);
}
#[tokio::test]
async fn test_optional_money_amount_database() {
let pool = create_test_pool().await;
setup_test_table(&pool).await;
// Test with NULL (None) value
sqlx::query("INSERT INTO test_money (amount, currency, optional_amount) VALUES (?, ?, ?)")
.bind(MoneyAmount::from_str("50.00").unwrap())
.bind(CurrencyType::USD)
.bind(None::<MoneyAmount>)
.execute(&pool)
.await
.expect("Failed to insert null optional amount");
// Test with Some value
let optional_amount = Some(MoneyAmount::from_str("25.75").unwrap());
sqlx::query("INSERT INTO test_money (amount, currency, optional_amount) VALUES (?, ?, ?)")
.bind(MoneyAmount::from_str("100.00").unwrap())
.bind(CurrencyType::USD)
.bind(&optional_amount)
.execute(&pool)
.await
.expect("Failed to insert some optional amount");
// Retrieve and verify
let rows =
sqlx::query("SELECT amount, currency, optional_amount FROM test_money ORDER BY id")
.fetch_all(&pool)
.await
.expect("Failed to fetch optional amount test data");
assert_eq!(rows.len(), 2);
// First row - NULL optional_amount
let first_optional: Option<MoneyAmount> = rows[0].get("optional_amount");
assert!(first_optional.is_none());
// Second row - Some optional_amount
let second_optional: Option<MoneyAmount> = rows[1].get("optional_amount");
assert_eq!(second_optional, optional_amount);
}
#[rstest]
#[case("0", "0")]
#[case("1", "1")]
#[case("1.0", "1.0")]
#[case("1.00", "1.00")]
#[case("1.10", "1.10")]
#[case("1.01", "1.01")]
#[case("0.1", "0.1")]
#[case("0.01", "0.01")]
#[case("0.001", "0.001")]
#[case("0.0001", "0.0001")]
#[case("0.00001", "0.00001")]
#[case("0.000001", "0.000001")]
#[case("123.456789", "123.456789")]
#[tokio::test]
async fn test_money_precision_preservation(
#[case] input_str: &str,
#[case] expected_str: &str,
) {
let pool = create_test_pool().await;
setup_test_table(&pool).await;
let amount = MoneyAmount::from_str(input_str).unwrap();
// Insert into database
sqlx::query("INSERT INTO test_money (amount, currency) VALUES (?, ?)")
.bind(&amount)
.bind(CurrencyType::USD)
.execute(&pool)
.await
.expect("Failed to insert precision test data");
// Retrieve from database
let row = sqlx::query("SELECT amount FROM test_money LIMIT 1")
.fetch_one(&pool)
.await
.expect("Failed to fetch precision test data");
let retrieved_amount: MoneyAmount = row.get("amount");
// Verify exact string representation is preserved
assert_eq!(
retrieved_amount.to_string(),
expected_str,
"Precision not preserved for input: {}",
input_str
);
}
// ========== ARITHMETIC OPERATIONS TESTS ==========
#[rstest]
#[case("0", "0", "0")]
#[case("1", "2", "3")]
#[case("10.50", "5.25", "15.75")]
#[case("0.000001", "0.000002", "0.000003")]
#[case("999999.999999", "0.000001", "1000000")]
#[case("123.45", "876.55", "1000")]
#[case("0.1", "0.2", "0.3")]
#[case("0.33", "0.67", "1.00")]
fn test_money_addition(#[case] a_str: &str, #[case] b_str: &str, #[case] expected: &str) {
let a = MoneyAmount::from_str(a_str).unwrap();
let b = MoneyAmount::from_str(b_str).unwrap();
let result = a + b;
let expected_amount = MoneyAmount::from_str(expected).unwrap();
assert_eq!(result, expected_amount);
}
#[rstest]
#[case("10", "5", "5")]
#[case("100.50", "0.50", "100")]
#[case("1", "1", "0")]
#[case("0.000003", "0.000001", "0.000002")]
#[case("1000000", "0.000001", "999999.999999")]
#[case("1000", "123.45", "876.55")]
#[case("0.3", "0.1", "0.2")]
fn test_money_subtraction(#[case] a_str: &str, #[case] b_str: &str, #[case] expected: &str) {
let a = MoneyAmount::from_str(a_str).unwrap();
let b = MoneyAmount::from_str(b_str).unwrap();
let result = a - b;
let expected_amount = MoneyAmount::from_str(expected).unwrap();
assert_eq!(result, expected_amount);
}
#[test]
fn test_money_arithmetic_precision() {
let a = MoneyAmount::from_str("0.1").unwrap();
let b = MoneyAmount::from_str("0.2").unwrap();
let sum = a + b;
// This should be exactly 0.3, not 0.30000000000000004 like with f64
assert_eq!(sum.to_string(), "0.3");
let c = MoneyAmount::from_str("1.0").unwrap();
let d = MoneyAmount::from_str("0.9").unwrap();
let diff = c - d;
// This should be exactly 0.1
assert_eq!(diff.to_string(), "0.1");
}
#[test]
fn test_money_chained_operations() {
let a = MoneyAmount::from_str("100").unwrap();
let b = MoneyAmount::from_str("25.50").unwrap();
let c = MoneyAmount::from_str("10.25").unwrap();
let result = a + b - c;
let expected = MoneyAmount::from_str("115.25").unwrap();
assert_eq!(result, expected);
let result2 = a - b + c;
let expected2 = MoneyAmount::from_str("84.75").unwrap();
assert_eq!(result2, expected2);
}
#[test]
fn test_money_zero_operations() {
let zero = MoneyAmount::zero();
let amount = MoneyAmount::from_str("42.50").unwrap();
assert_eq!(zero + amount, amount);
assert_eq!(amount + zero, amount);
assert_eq!(amount - zero, amount);
assert_eq!(amount - amount, zero);
}
#[test]
fn test_money_negative_results() {
let a = MoneyAmount::from_str("10").unwrap();
let b = MoneyAmount::from_str("15").unwrap();
let result = a - b;
let expected = MoneyAmount::from_str("-5").unwrap();
assert_eq!(result, expected);
}
// ========== DATABASE QUERY OPERATIONS TESTS ==========
async fn setup_test_data_for_queries(pool: &SqlitePool) {
// Create test table for query operations
sqlx::query(
r#"
CREATE TABLE test_bids (
id INTEGER PRIMARY KEY,
listing_id INTEGER NOT NULL,
buyer_id INTEGER NOT NULL,
bid_amount TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
) STRICT
"#,
)
.execute(pool)
.await
.expect("Failed to create test_bids table");
// Insert test data with various amounts
let test_amounts = [
"10.00", "25.50", "100.00", "15.75", "200.25", "0.01", "999.99", "50.00", "75.25",
"125.50",
];
for (i, amount_str) in test_amounts.iter().enumerate() {
let amount = MoneyAmount::from_str(amount_str).unwrap();
sqlx::query(
"INSERT INTO test_bids (listing_id, buyer_id, bid_amount) VALUES (?, ?, ?)",
)
.bind(1) // listing_id
.bind(i as i64 + 1) // buyer_id
.bind(amount)
.execute(pool)
.await
.expect("Failed to insert test bid data");
}
}
#[tokio::test]
async fn test_money_database_max_min() {
let pool = create_test_pool().await;
setup_test_data_for_queries(&pool).await;
// Test MAX
let max_row = sqlx::query("SELECT MAX(bid_amount) as max_bid FROM test_bids")
.fetch_one(&pool)
.await
.expect("Failed to fetch max bid");
let max_amount: MoneyAmount = max_row.get("max_bid");
let expected_max = MoneyAmount::from_str("999.99").unwrap();
assert_eq!(max_amount, expected_max);
// Test MIN
let min_row = sqlx::query("SELECT MIN(bid_amount) as min_bid FROM test_bids")
.fetch_one(&pool)
.await
.expect("Failed to fetch min bid");
let min_amount: MoneyAmount = min_row.get("min_bid");
let expected_min = MoneyAmount::from_str("0.01").unwrap();
assert_eq!(min_amount, expected_min);
}
#[rstest]
#[case(">", "50.00", 5)] // amounts > 50.00: 100.00, 200.25, 999.99, 75.25, 125.50
#[case(">=", "50.00", 6)] // amounts >= 50.00: includes 50.00
#[case("<", "50.00", 4)] // amounts < 50.00: 10.00, 25.50, 15.75, 0.01
#[case("<=", "50.00", 5)] // amounts <= 50.00: includes 50.00
#[tokio::test]
async fn test_money_database_comparison_operators(
#[case] operator: &str,
#[case] threshold: &str,
#[case] expected_count: usize,
) {
let pool = create_test_pool().await;
setup_test_data_for_queries(&pool).await;
let threshold_amount = MoneyAmount::from_str(threshold).unwrap();
let query = format!(
"SELECT COUNT(*) as count FROM test_bids WHERE CAST(bid_amount AS REAL) {} CAST(? AS REAL)",
operator
);
let count_row = sqlx::query(&query)
.bind(threshold_amount)
.fetch_one(&pool)
.await
.expect("Failed to fetch count with comparison");
let count: i64 = count_row.get("count");
assert_eq!(
count as usize, expected_count,
"Comparison {} {} failed",
operator, threshold
);
}
#[rstest]
#[case("25.00", "100.00", 4)] // amounts between 25.00 and 100.00: 25.50, 100.00, 50.00, 75.25
#[case("0.00", "20.00", 3)] // amounts between 0.00 and 20.00: 10.00, 15.75, 0.01
#[case("200.00", "1000.00", 2)] // amounts between 200.00 and 1000.00: 200.25, 999.99
#[tokio::test]
async fn test_money_database_between_operator(
#[case] min_amount: &str,
#[case] max_amount: &str,
#[case] expected_count: usize,
) {
let pool = create_test_pool().await;
setup_test_data_for_queries(&pool).await;
let min = MoneyAmount::from_str(min_amount).unwrap();
let max = MoneyAmount::from_str(max_amount).unwrap();
let count_row =
sqlx::query("SELECT COUNT(*) as count FROM test_bids WHERE CAST(bid_amount AS REAL) BETWEEN CAST(? AS REAL) AND CAST(? AS REAL)")
.bind(min)
.bind(max)
.fetch_one(&pool)
.await
.expect("Failed to fetch count with BETWEEN");
let count: i64 = count_row.get("count");
assert_eq!(
count as usize, expected_count,
"BETWEEN {} AND {} failed",
min_amount, max_amount
);
}
#[tokio::test]
async fn test_money_database_ordering() {
let pool = create_test_pool().await;
setup_test_data_for_queries(&pool).await;
// Test ORDER BY ASC
let rows_asc =
sqlx::query("SELECT bid_amount FROM test_bids ORDER BY CAST(bid_amount AS REAL) ASC")
.fetch_all(&pool)
.await
.expect("Failed to fetch ordered data ASC");
let amounts_asc: Vec<MoneyAmount> = rows_asc
.iter()
.map(|row| row.get::<MoneyAmount, _>("bid_amount"))
.collect();
// Verify ascending order
for i in 1..amounts_asc.len() {
assert!(
amounts_asc[i - 1] <= amounts_asc[i],
"ASC order violated: {} should be <= {}",
amounts_asc[i - 1],
amounts_asc[i]
);
}
// Test ORDER BY DESC
let rows_desc =
sqlx::query("SELECT bid_amount FROM test_bids ORDER BY CAST(bid_amount AS REAL) DESC")
.fetch_all(&pool)
.await
.expect("Failed to fetch ordered data DESC");
let amounts_desc: Vec<MoneyAmount> = rows_desc
.iter()
.map(|row| row.get::<MoneyAmount, _>("bid_amount"))
.collect();
// Verify descending order
for i in 1..amounts_desc.len() {
assert!(
amounts_desc[i - 1] >= amounts_desc[i],
"DESC order violated: {} should be >= {}",
amounts_desc[i - 1],
amounts_desc[i]
);
}
// Verify first and last elements
let expected_min = MoneyAmount::from_str("0.01").unwrap();
let expected_max = MoneyAmount::from_str("999.99").unwrap();
assert_eq!(amounts_asc[0], expected_min);
assert_eq!(amounts_asc[amounts_asc.len() - 1], expected_max);
assert_eq!(amounts_desc[0], expected_max);
assert_eq!(amounts_desc[amounts_desc.len() - 1], expected_min);
}
#[tokio::test]
async fn test_money_database_aggregation_functions() {
let pool = create_test_pool().await;
setup_test_data_for_queries(&pool).await;
// Test SUM, AVG, COUNT
let agg_row = sqlx::query(
"SELECT
SUM(bid_amount) as total_sum,
AVG(bid_amount) as average,
COUNT(bid_amount) as count
FROM test_bids",
)
.fetch_one(&pool)
.await
.expect("Failed to fetch aggregation data");
let total_sum: MoneyAmount = agg_row.get("total_sum");
let average: MoneyAmount = agg_row.get("average");
let count: i64 = agg_row.get("count");
// Verify count
assert_eq!(count, 10);
// Calculate expected sum manually
let expected_sum = MoneyAmount::from_str("10.00").unwrap()
+ MoneyAmount::from_str("25.50").unwrap()
+ MoneyAmount::from_str("100.00").unwrap()
+ MoneyAmount::from_str("15.75").unwrap()
+ MoneyAmount::from_str("200.25").unwrap()
+ MoneyAmount::from_str("0.01").unwrap()
+ MoneyAmount::from_str("999.99").unwrap()
+ MoneyAmount::from_str("50.00").unwrap()
+ MoneyAmount::from_str("75.25").unwrap()
+ MoneyAmount::from_str("125.50").unwrap();
assert_eq!(total_sum, expected_sum);
// Verify average is approximately correct (may have rounding differences)
let expected_avg_decimal = expected_sum.inner() / Decimal::new(10, 0);
let avg_diff = (average.inner() - expected_avg_decimal).abs();
assert!(
avg_diff < Decimal::new(1, 2),
"Average calculation too far off: expected ~{}, got {}",
expected_avg_decimal,
average
);
}
#[tokio::test]
async fn test_money_database_group_by_with_aggregation() {
let pool = create_test_pool().await;
// Create a more complex test table
sqlx::query(
r#"
CREATE TABLE test_listing_bids (
id INTEGER PRIMARY KEY,
listing_id INTEGER NOT NULL,
bid_amount TEXT NOT NULL
) STRICT
"#,
)
.execute(&pool)
.await
.expect("Failed to create test_listing_bids table");
// Insert test data for multiple listings
let test_data = [
(1, "10.00"),
(1, "20.00"),
(1, "30.00"), // Listing 1: total 60.00, max 30.00
(2, "50.00"),
(2, "75.00"), // Listing 2: total 125.00, max 75.00
(3, "100.00"), // Listing 3: total 100.00, max 100.00
];
for (listing_id, amount_str) in test_data {
let amount = MoneyAmount::from_str(amount_str).unwrap();
sqlx::query("INSERT INTO test_listing_bids (listing_id, bid_amount) VALUES (?, ?)")
.bind(listing_id)
.bind(amount)
.execute(&pool)
.await
.expect("Failed to insert listing bid data");
}
// Test GROUP BY with aggregation
let grouped_rows = sqlx::query(
"SELECT
listing_id,
SUM(bid_amount) as total_bids,
MAX(bid_amount) as highest_bid,
COUNT(*) as bid_count
FROM test_listing_bids
GROUP BY listing_id
ORDER BY listing_id",
)
.fetch_all(&pool)
.await
.expect("Failed to fetch grouped data");
assert_eq!(grouped_rows.len(), 3);
// Verify listing 1
let listing1 = &grouped_rows[0];
assert_eq!(listing1.get::<i64, _>("listing_id"), 1);
assert_eq!(
listing1.get::<MoneyAmount, _>("total_bids"),
MoneyAmount::from_str("60.00").unwrap()
);
assert_eq!(
listing1.get::<MoneyAmount, _>("highest_bid"),
MoneyAmount::from_str("30.00").unwrap()
);
assert_eq!(listing1.get::<i64, _>("bid_count"), 3);
// Verify listing 2
let listing2 = &grouped_rows[1];
assert_eq!(listing2.get::<i64, _>("listing_id"), 2);
assert_eq!(
listing2.get::<MoneyAmount, _>("total_bids"),
MoneyAmount::from_str("125.00").unwrap()
);
assert_eq!(
listing2.get::<MoneyAmount, _>("highest_bid"),
MoneyAmount::from_str("75.00").unwrap()
);
assert_eq!(listing2.get::<i64, _>("bid_count"), 2);
// Verify listing 3
let listing3 = &grouped_rows[2];
assert_eq!(listing3.get::<i64, _>("listing_id"), 3);
assert_eq!(
listing3.get::<MoneyAmount, _>("total_bids"),
MoneyAmount::from_str("100.00").unwrap()
);
assert_eq!(
listing3.get::<MoneyAmount, _>("highest_bid"),
MoneyAmount::from_str("100.00").unwrap()
);
assert_eq!(listing3.get::<i64, _>("bid_count"), 1);
}
}