Lines
97.73 %
Functions
100 %
Branches
use super::*;
use chrono::NaiveDate;
use num_rational::Rational64;
use super::super::{FinanceEntity, ReportNode};
use crate::{
command::{account::CreateAccount, commodity::CreateCommodity, transaction::CreateTransaction},
db::DB_POOL,
};
use finance::{price::Price, split::Split};
use sqlx::{
PgPool,
types::chrono::{DateTime, Utc},
use supp_macro::local_db_sqlx_test;
use tokio::sync::OnceCell;
static CONTEXT: OnceCell<()> = OnceCell::const_new();
static USER: OnceCell<crate::user::User> = OnceCell::const_new();
async fn setup() {
CONTEXT
.get_or_init(|| async {
#[cfg(feature = "testlog")]
let _ = env_logger::builder()
.is_test(true)
.filter_level(log::LevelFilter::Trace)
.try_init();
})
.await;
USER.get_or_init(|| async { crate::user::User { id: Uuid::new_v4() } })
}
fn extract_commodity_id(result: Option<CmdResult>) -> Uuid {
if let Some(CmdResult::String(id)) = result {
Uuid::parse_str(&id).unwrap()
} else {
panic!("Expected commodity ID");
fn extract_account_id(result: Option<CmdResult>) -> Uuid {
if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = result {
acc.id
panic!("Expected account entity");
fn extract_report(result: Option<CmdResult>) -> ReportData {
if let Some(CmdResult::Report(data)) = result {
data
panic!("Expected Report result");
fn find_node(roots: &[ReportNode], account_id: Uuid) -> Option<&ReportNode> {
for node in roots {
if node.account_id == account_id {
return Some(node);
if let Some(found) = find_node(&node.children, account_id) {
return Some(found);
None
fn node_amount(node: &ReportNode, commodity_id: Uuid) -> Option<Rational64> {
node.amounts
.iter()
.find(|ca| ca.commodity_id == commodity_id)
.map(|ca| ca.amount)
async fn create_commodity(user_id: Uuid, symbol: &str, name: &str) -> Uuid {
extract_commodity_id(
CreateCommodity::new()
.symbol(symbol.to_string())
.name(name.to_string())
.user_id(user_id)
.run()
.await
.unwrap(),
)
async fn create_account(user_id: Uuid, name: &str, parent: Option<Uuid>) -> Uuid {
match parent {
Some(pid) => extract_account_id(
CreateAccount::new()
.parent(pid)
),
None => extract_account_id(
async fn create_tx(
user_id: Uuid,
post_date: DateTime<Utc>,
splits: Vec<(Uuid, Uuid, i64, i64)>,
) -> (Uuid, Vec<Uuid>) {
let tx_id = Uuid::new_v4();
let mut split_ids = Vec::new();
let split_entities: Vec<FinanceEntity> = splits
.into_iter()
.map(|(account_id, commodity_id, value_num, value_denom)| {
let sid = Uuid::new_v4();
split_ids.push(sid);
FinanceEntity::Split(Split {
id: sid,
tx_id,
account_id,
commodity_id,
value_num,
value_denom,
reconcile_state: None,
reconcile_date: None,
lot_id: None,
.collect();
CreateTransaction::new()
.splits(split_entities)
.id(tx_id)
.post_date(post_date)
.enter_date(post_date)
.unwrap();
(tx_id, split_ids)
async fn insert_price(
user: &crate::user::User,
commodity_split_id: Uuid,
commodity_id: Uuid,
currency_id: Uuid,
date: DateTime<Utc>,
value_num: i64,
value_denom: i64,
) {
let price = Price {
id: Uuid::new_v4(),
date,
currency_id,
commodity_split: Some(commodity_split_id),
currency_split: None,
let mut conn = user.get_connection().await.unwrap();
sqlx::query_file!(
"sql/insert/prices/price.sql",
price.id,
price.commodity_id,
price.currency_id,
price.commodity_split,
price.currency_split,
price.date,
price.value_num,
price.value_denom
.execute(&mut *conn)
// --- BalanceReport tests ---
#[local_db_sqlx_test]
async fn test_balance_report_single_currency(pool: PgPool) {
let user = USER.get().unwrap();
user.commit().await.expect("Failed to commit user");
let usd = create_commodity(user.id, "USD", "US Dollar").await;
let assets = create_account(user.id, "Assets", None).await;
let bank = create_account(user.id, "Bank", Some(assets)).await;
let expenses = create_account(user.id, "Expenses", None).await;
let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
create_tx(
user.id,
d,
vec![(bank, usd, -200, 1), (expenses, usd, 200, 1)],
let report = extract_report(BalanceReport::new().user_id(user.id).run().await.unwrap());
assert_eq!(report.periods.len(), 1);
assert!(report.periods[0].label.is_none());
let bank_node = find_node(&report.periods[0].roots, bank).unwrap();
assert_eq!(node_amount(bank_node, usd), Some(Rational64::new(-200, 1)));
assert_eq!(bank_node.depth, 1);
assert_eq!(bank_node.account_path, "Assets:Bank");
let assets_node = find_node(&report.periods[0].roots, assets).unwrap();
assert_eq!(
node_amount(assets_node, usd),
Some(Rational64::new(-200, 1))
);
let expenses_node = find_node(&report.periods[0].roots, expenses).unwrap();
node_amount(expenses_node, usd),
Some(Rational64::new(200, 1))
async fn test_balance_report_multi_currency(pool: PgPool) {
let eur = create_commodity(user.id, "EUR", "Euro").await;
let acc_a = create_account(user.id, "Account A", None).await;
let acc_b = create_account(user.id, "Account B", None).await;
vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
create_tx(user.id, d, vec![(acc_a, eur, 50, 1), (acc_b, eur, -50, 1)]).await;
let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
assert_eq!(node_a.amounts.len(), 2);
assert_eq!(node_amount(node_a, usd), Some(Rational64::new(100, 1)));
assert_eq!(node_amount(node_a, eur), Some(Rational64::new(50, 1)));
async fn test_balance_report_with_conversion(pool: PgPool) {
let (_tx2, split_ids) =
// Insert split-specific prices for EUR→USD conversion (1.2 USD per EUR)
insert_price(user, split_ids[0], eur, usd, d, 12, 10).await;
insert_price(user, split_ids[1], eur, usd, d, 12, 10).await;
let report = extract_report(
BalanceReport::new()
.user_id(user.id)
.target_commodity_id(usd)
assert_eq!(node_a.amounts.len(), 1);
// 100 USD + 50 EUR * 1.2 = 100 + 60 = 160 USD
assert_eq!(node_amount(node_a, usd), Some(Rational64::new(160, 1)));
async fn test_balance_report_missing_conversion(pool: PgPool) {
let result = BalanceReport::new()
assert!(result.is_err());
async fn test_balance_report_as_of(pool: PgPool) {
let d1 = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
let d2 = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
let cutoff = DateTime::<Utc>::from_timestamp(1700050000, 0).unwrap();
d1,
d2,
vec![(acc_a, usd, 200, 1), (acc_b, usd, -200, 1)],
.as_of(cutoff)
async fn test_balance_report_with_filter(pool: PgPool) {
let acc_c = create_account(user.id, "Account C", None).await;
create_tx(user.id, d, vec![(acc_c, usd, 50, 1), (acc_b, usd, -50, 1)]).await;
.report_filter(ReportFilter::AccountEq(acc_a))
// acc_c should have no amounts since we filtered to acc_a only
let node_c = find_node(&report.periods[0].roots, acc_c);
assert!(node_c.is_none() || node_amount(node_c.unwrap(), usd).is_none());
// --- IncomeExpenseReport tests ---
async fn test_income_expense_report_date_range(pool: PgPool) {
let income = create_account(user.id, "Income", None).await;
let d2 = DateTime::<Utc>::from_timestamp(1700200000, 0).unwrap();
let d_outside = DateTime::<Utc>::from_timestamp(1700400000, 0).unwrap();
vec![(income, usd, -100, 1), (expenses, usd, 100, 1)],
vec![(income, usd, -50, 1), (expenses, usd, 50, 1)],
d_outside,
vec![(income, usd, -999, 1), (expenses, usd, 999, 1)],
let from = DateTime::<Utc>::from_timestamp(1699999999, 0).unwrap();
let to = DateTime::<Utc>::from_timestamp(1700300000, 0).unwrap();
IncomeExpenseReport::new()
.date_from(from)
.date_to(to)
Some(Rational64::new(150, 1))
async fn test_income_expense_report_monthly_grouping(pool: PgPool) {
// January 2025
let jan = NaiveDate::from_ymd_opt(2025, 1, 15)
.unwrap()
.and_hms_opt(12, 0, 0)
.and_utc();
// February 2025
let feb = NaiveDate::from_ymd_opt(2025, 2, 10)
jan,
feb,
vec![(income, usd, -200, 1), (expenses, usd, 200, 1)],
let from = NaiveDate::from_ymd_opt(2025, 1, 1)
.and_hms_opt(0, 0, 0)
let to = NaiveDate::from_ymd_opt(2025, 3, 1)
.period_grouping("month".to_string())
assert_eq!(report.periods.len(), 2);
assert_eq!(report.periods[0].label.as_deref(), Some("2025-01"));
assert_eq!(report.periods[1].label.as_deref(), Some("2025-02"));
let jan_expenses = find_node(&report.periods[0].roots, expenses).unwrap();
node_amount(jan_expenses, usd),
Some(Rational64::new(100, 1))
let feb_expenses = find_node(&report.periods[1].roots, expenses).unwrap();
node_amount(feb_expenses, usd),
async fn test_income_expense_report_quarterly_grouping(pool: PgPool) {
let q1 = NaiveDate::from_ymd_opt(2025, 2, 15)
let q2 = NaiveDate::from_ymd_opt(2025, 5, 15)
q1,
q2,
vec![(acc_a, usd, 300, 1), (acc_b, usd, -300, 1)],
let to = NaiveDate::from_ymd_opt(2025, 7, 1)
.period_grouping("quarter".to_string())
assert_eq!(report.periods[0].label.as_deref(), Some("2025-Q1"));
assert_eq!(report.periods[1].label.as_deref(), Some("2025-Q2"));
let q1_node = find_node(&report.periods[0].roots, acc_a).unwrap();
assert_eq!(node_amount(q1_node, usd), Some(Rational64::new(100, 1)));
let q2_node = find_node(&report.periods[1].roots, acc_a).unwrap();
assert_eq!(node_amount(q2_node, usd), Some(Rational64::new(300, 1)));
async fn test_income_expense_report_invalid_grouping(pool: PgPool) {
let from = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
let result = IncomeExpenseReport::new()
.period_grouping("weekly".to_string())
// --- TrialBalance tests ---
async fn test_trial_balance_basic(pool: PgPool) {
let liabilities = create_account(user.id, "Liabilities", None).await;
vec![(assets, usd, 500, 1), (income, usd, -500, 1)],
vec![(liabilities, usd, -200, 1), (assets, usd, 200, 1)],
let from = DateTime::<Utc>::from_timestamp(1699000000, 0).unwrap();
let to = DateTime::<Utc>::from_timestamp(1701000000, 0).unwrap();
TrialBalance::new()
assert_eq!(node_amount(assets_node, usd), Some(Rational64::new(700, 1)));
let liabilities_node = find_node(&report.periods[0].roots, liabilities).unwrap();
node_amount(liabilities_node, usd),
let income_node = find_node(&report.periods[0].roots, income).unwrap();
node_amount(income_node, usd),
Some(Rational64::new(-500, 1))
async fn test_trial_balance_with_conversion(pool: PgPool) {
create_tx(user.id, d, vec![(acc_a, eur, 80, 1), (acc_b, eur, -80, 1)]).await;
insert_price(user, split_ids[0], eur, usd, d, 11, 10).await;
insert_price(user, split_ids[1], eur, usd, d, 11, 10).await;
// 100 USD + 80 EUR * 1.1 = 100 + 88 = 188 USD
assert_eq!(node_amount(node_a, usd), Some(Rational64::new(188, 1)));
async fn test_trial_balance_with_filter(pool: PgPool) {
.report_filter(ReportFilter::CommodityEq(usd))
assert!(node_amount(node_a, eur).is_none());
// --- Hierarchy rollup test ---
async fn test_balance_report_hierarchy_rollup(pool: PgPool) {
let checking = create_account(user.id, "Checking", Some(bank)).await;
let savings = create_account(user.id, "Savings", Some(bank)).await;
let other = create_account(user.id, "Other", None).await;
vec![(checking, usd, 300, 1), (other, usd, -300, 1)],
vec![(savings, usd, 700, 1), (other, usd, -700, 1)],
let checking_node = find_node(&report.periods[0].roots, checking).unwrap();
node_amount(checking_node, usd),
Some(Rational64::new(300, 1))
assert_eq!(checking_node.account_path, "Assets:Bank:Checking");
assert_eq!(checking_node.depth, 2);
let savings_node = find_node(&report.periods[0].roots, savings).unwrap();
node_amount(savings_node, usd),
Some(Rational64::new(700, 1))
// Bank should roll up: 300 + 700 = 1000
assert_eq!(node_amount(bank_node, usd), Some(Rational64::new(1000, 1)));
// Assets should roll up: same as bank = 1000
Some(Rational64::new(1000, 1))