Lines
97.54 %
Functions
100 %
Branches
use super::BalanceReport;
use chrono::NaiveDate;
use num_rational::Rational64;
use super::super::{
CmdResult, FinanceEntity, PeriodGrouping, ReportData, ReportFilter, ReportNode,
};
use crate::{
command::{account::CreateAccount, commodity::CreateCommodity, transaction::CreateTransaction},
db::DB_POOL,
use finance::{price::Price, split::Split};
use sqlx::{
PgPool,
types::Uuid,
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());
/// A split whose commodity is already the target currency must contribute
/// its face value exactly once, even when the LEFT JOIN to `prices` finds
/// no matching row. Regression test for a bug where the report double-
/// counted already-target-currency splits alongside their converted
/// counterparts.
async fn test_balance_report_conversion_includes_target_currency_splits(pool: PgPool) {
// 100 USD (no conversion needed) + 50 EUR * 1.2 = 160 USD. A bug that
// skipped target-currency splits would yield 60; one that double-counted
// them would yield 260.
/// Duplicate (commodity_split_id, currency_id) rows in the `prices` table
/// (possible via bulk imports or manual edits) must not multiply a split's
/// contribution. The report should converge on a single price row per
/// split, not sum over every matching price.
async fn test_balance_report_conversion_is_stable_with_duplicate_prices(pool: PgPool) {
let (_tx, split_ids) =
// Two price rows for the same (commodity_split_id, currency_id). A naive
// LEFT JOIN would emit the split twice and double-count it.
// 50 EUR * 1.2 = 60 USD, regardless of how many duplicate price rows exist.
assert_eq!(node_amount(node_a, usd), Some(Rational64::new(60, 1)));
/// Multi-commodity account balances must preserve their per-commodity sums
/// when one leg converts and the other is already in the target currency.
/// Covers aggregation symmetry between the no-conversion leaf path and the
/// conversion leaf path inside `accumulate_split_converted`.
async fn test_balance_report_conversion_mixed_commodities_same_account(pool: PgPool) {
// Three USD deposits of 100 each to acc_a
for _ in 0..3 {
// Two EUR deposits of 50 each, priced at 1.2 USD/EUR
for _ in 0..2 {
// 3 * 100 USD + 2 * (50 EUR * 1.2) = 300 + 120 = 420 USD
assert_eq!(node_amount(node_a, usd), Some(Rational64::new(420, 1)));
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());
// --- BalanceReport period-mode tests (formerly TrialBalance) ---
async fn test_balance_report_period_basic(pool: PgPool) {
let liabilities = create_account(user.id, "Liabilities", None).await;
let income = create_account(user.id, "Income", 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();
.date_from(from)
.as_of(to)
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_balance_report_period_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_balance_report_period_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))
// --- split-tagging helpers for CategoryBreakdown / Activity tests ---
async fn tag_tx_category(user: &crate::user::User, tx_id: Uuid, category: &str) {
user.create_transaction_tag(tx_id, "category".to_string(), category.to_string(), None)
async fn tag_split_category(user: &crate::user::User, split_id: Uuid, category: &str) {
user.create_split_tag(split_id, "category".to_string(), category.to_string(), None)
async fn tag_split_kv(user: &crate::user::User, split_id: Uuid, name: &str, value: &str) {
user.create_split_tag(split_id, name.to_string(), value.to_string(), None)
// --- CategoryBreakdown tests ---
use super::super::{BreakdownData, BreakdownSort};
use super::CategoryBreakdown;
fn extract_breakdown(result: Option<CmdResult>) -> BreakdownData {
if let Some(CmdResult::Breakdown(data)) = result {
panic!("Expected Breakdown result");
fn find_row<'a>(
rows: &'a [super::super::BreakdownRow],
tag_value: &str,
) -> Option<&'a super::super::BreakdownRow> {
rows.iter().find(|r| r.tag_value == tag_value)
fn row_amount(row: &super::super::BreakdownRow, commodity_id: Uuid) -> Option<Rational64> {
row.amounts
.find(|a| a.commodity_id == commodity_id)
.map(|a| a.amount)
async fn test_category_breakdown_basic(pool: PgPool) {
let checking = create_account(user.id, "Checking", None).await;
tag_account_type(user.id, expenses, "expense").await;
tag_account_type(user.id, checking, "asset").await;
let (_, splits_a) = create_tx(
vec![(expenses, usd, 300, 1), (checking, usd, -300, 1)],
let (_, splits_b) = create_tx(
vec![(expenses, usd, 150, 1), (checking, usd, -150, 1)],
let (_, splits_c) = create_tx(
vec![(expenses, usd, 75, 1), (checking, usd, -75, 1)],
tag_split_category(user, splits_a[0], "food").await;
tag_split_category(user, splits_b[0], "transport").await;
tag_split_category(user, splits_c[0], "food").await;
let from = DateTime::<Utc>::from_timestamp(1699999000, 0).unwrap();
let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
let breakdown = extract_breakdown(
CategoryBreakdown::new()
.date_to(to)
assert_eq!(breakdown.tag_name, "category");
assert_eq!(breakdown.periods.len(), 1);
let rows = &breakdown.periods[0].rows;
let food = find_row(rows, "food").expect("food row");
let transport = find_row(rows, "transport").expect("transport row");
let food_total: Rational64 = food.amounts.iter().map(|a| a.amount).sum();
let transport_total: Rational64 = transport.amounts.iter().map(|a| a.amount).sum();
let abs = |r: Rational64| if r < Rational64::new(0, 1) { -r } else { r };
assert_eq!(abs(food_total), Rational64::new(375, 1));
assert_eq!(abs(transport_total), Rational64::new(150, 1));
async fn test_category_breakdown_uncategorized_toggle(pool: PgPool) {
let (_, splits_tagged) = create_tx(
vec![(expenses, usd, 100, 1), (checking, usd, -100, 1)],
vec![(expenses, usd, 200, 1), (checking, usd, -200, 1)],
tag_split_category(user, splits_tagged[0], "food").await;
let with_uncat = extract_breakdown(
assert!(
find_row(&with_uncat.periods[0].rows, super::super::UNCATEGORIZED_KEY).is_some(),
"uncategorized present by default"
let without_uncat = extract_breakdown(
.include_uncategorized(false)
find_row(
&without_uncat.periods[0].rows,
super::super::UNCATEGORIZED_KEY
.is_none(),
"uncategorized filtered when include_uncategorized=false"
assert_eq!(without_uncat.periods[0].rows.len(), 1);
async fn test_category_breakdown_sort_orders(pool: PgPool) {
let (_, s_a) = create_tx(
let (_, s_b) = create_tx(
let (_, s_c) = create_tx(
tag_split_category(user, s_a[0], "alpha").await;
tag_split_category(user, s_b[0], "beta").await;
tag_split_category(user, s_c[0], "gamma").await;
let desc = extract_breakdown(
.sort_order(BreakdownSort::AmountDesc)
let order: Vec<&str> = desc.periods[0]
.rows
.map(|r| r.tag_value.as_str())
assert_eq!(order, ["beta", "gamma", "alpha"]);
let asc = extract_breakdown(
.sort_order(BreakdownSort::AmountAsc)
let order: Vec<&str> = asc.periods[0]
assert_eq!(order, ["alpha", "gamma", "beta"]);
let name_asc = extract_breakdown(
.sort_order(BreakdownSort::NameAsc)
let order: Vec<&str> = name_asc.periods[0]
assert_eq!(order, ["alpha", "beta", "gamma"]);
async fn test_category_breakdown_configurable_tag_name(pool: PgPool) {
let (_, s) = create_tx(
tag_split_kv(user, s[0], "project", "roof").await;
tag_split_kv(user, s[0], "category", "home").await;
let by_project = extract_breakdown(
.tag_name("project".to_string())
assert_eq!(by_project.tag_name, "project");
assert!(find_row(&by_project.periods[0].rows, "roof").is_some());
let by_category = extract_breakdown(
assert_eq!(by_category.tag_name, "category");
assert!(find_row(&by_category.periods[0].rows, "home").is_some());
async fn test_category_breakdown_period_grouping(pool: PgPool) {
let jan = NaiveDate::from_ymd_opt(2025, 1, 15)
.unwrap()
.and_hms_opt(12, 0, 0)
.and_utc();
let feb = NaiveDate::from_ymd_opt(2025, 2, 10)
let (_, s_jan) = create_tx(
jan,
let (_, s_feb) = create_tx(
feb,
tag_split_category(user, s_jan[0], "food").await;
tag_split_category(user, s_feb[0], "food").await;
let from = NaiveDate::from_ymd_opt(2025, 1, 1)
.and_hms_opt(0, 0, 0)
let to = NaiveDate::from_ymd_opt(2025, 3, 1)
let bd = extract_breakdown(
.period_grouping(PeriodGrouping::Month)
assert_eq!(bd.periods.len(), 2);
assert_eq!(bd.periods[0].label.as_deref(), Some("2025-01"));
assert_eq!(bd.periods[1].label.as_deref(), Some("2025-02"));
let jan_food = find_row(&bd.periods[0].rows, "food").unwrap();
let feb_food = find_row(&bd.periods[1].rows, "food").unwrap();
assert_eq!(row_amount(jan_food, usd), Some(Rational64::new(100, 1)));
assert_eq!(row_amount(feb_food, usd), Some(Rational64::new(300, 1)));
async fn test_category_breakdown_with_filter(pool: PgPool) {
tag_account_type(user.id, income, "income").await;
let (_, s1) = create_tx(
let (_, s2) = create_tx(
vec![(income, usd, -500, 1), (checking, usd, 500, 1)],
tag_split_category(user, s1[0], "food").await;
tag_split_category(user, s2[0], "salary").await;
.report_filter(ReportFilter::AccountEq(expenses))
assert!(find_row(&bd.periods[0].rows, "food").is_some());
assert!(find_row(&bd.periods[0].rows, "salary").is_none());
// --- ActivityReport tests ---
use super::super::{ActivityData, ActivityGroup, FilterEntity};
use super::ActivityReport;
async fn tag_account_type(user_id: Uuid, account_id: Uuid, account_type: &str) {
crate::command::account::SetAccountTag::new()
.account_id(account_id)
.tag_name("type".to_string())
.tag_value(account_type.to_string())
fn extract_activity(result: Option<CmdResult>) -> ActivityData {
if let Some(CmdResult::Activity(data)) = result {
panic!("Expected Activity result");
fn find_group<'a>(
period: &'a super::super::ActivityPeriod,
label: &str,
) -> Option<&'a super::super::ActivityGroupResult> {
period.groups.iter().find(|g| g.label == label)
fn group_total(group: &super::super::ActivityGroupResult, commodity_id: Uuid) -> Rational64 {
group
.roots
.filter_map(|n| {
n.amounts
.sum()
async fn test_activity_report_default_groups(pool: PgPool) {
let income = create_account(user.id, "Salary", None).await;
let expense = create_account(user.id, "Groceries", None).await;
let asset = create_account(user.id, "Checking", None).await;
tag_account_type(user.id, expense, "expense").await;
tag_account_type(user.id, asset, "asset").await;
vec![
(income, usd, -500, 1),
(expense, usd, 200, 1),
(asset, usd, 300, 1),
],
let activity = extract_activity(
ActivityReport::new()
assert_eq!(activity.periods.len(), 1);
let period = &activity.periods[0];
assert_eq!(period.groups.len(), 2, "default = Income + Expense");
let income_group = find_group(period, "Income").expect("Income group present");
let expense_group = find_group(period, "Expense").expect("Expense group present");
assert!(income_group.flip_sign);
assert!(!expense_group.flip_sign);
// Raw sign: income credit is stored as -500 and should come back as -500.
assert_eq!(group_total(income_group, usd), Rational64::new(-500, 1));
assert_eq!(group_total(expense_group, usd), Rational64::new(200, 1));
// Asset account must not appear in either group.
assert!(!income_group.roots.iter().any(|n| n.account_id == asset));
assert!(!expense_group.roots.iter().any(|n| n.account_id == asset));
async fn test_activity_report_three_way_split(pool: PgPool) {
let salary = create_account(user.id, "Salary", None).await;
let savings = create_account(user.id, "401k", None).await;
let rent = create_account(user.id, "Rent", None).await;
tag_account_type(user.id, salary, "income").await;
tag_account_type(user.id, savings, "savings").await;
tag_account_type(user.id, rent, "expense").await;
(salary, usd, -1000, 1),
(savings, usd, 300, 1),
(rent, usd, 700, 1),
let groups = vec![
ActivityGroup {
label: "Income".into(),
filter: ReportFilter::Tag {
entity: FilterEntity::Account,
name: "type".into(),
value: "income".into(),
},
flip_sign: true,
label: "Savings".into(),
value: "savings".into(),
flip_sign: false,
label: "Expenses".into(),
value: "expense".into(),
];
.groups(groups)
assert_eq!(period.groups.len(), 3);
group_total(find_group(period, "Income").unwrap(), usd),
Rational64::new(-1000, 1)
group_total(find_group(period, "Savings").unwrap(), usd),
Rational64::new(300, 1)
group_total(find_group(period, "Expenses").unwrap(), usd),
Rational64::new(700, 1)
async fn test_activity_report_custom_tag_classification(pool: PgPool) {
let earning = create_account(user.id, "Consulting", None).await;
let cost = create_account(user.id, "AWS", None).await;
// Using a different tag name ('role') and different values: any string works.
.account_id(earning)
.tag_name("role".to_string())
.tag_value("earning".to_string())
.account_id(cost)
.tag_value("cost".to_string())
vec![(earning, usd, -1000, 1), (cost, usd, 1000, 1)],
.groups(vec![
label: "Revenue".into(),
name: "role".into(),
value: "earning".into(),
label: "Costs".into(),
value: "cost".into(),
])
assert!(find_group(period, "Revenue").is_some());
assert!(find_group(period, "Costs").is_some());
group_total(find_group(period, "Revenue").unwrap(), usd),
async fn test_activity_report_user_filter_composition(pool: PgPool) {
let groceries = create_account(user.id, "Groceries", None).await;
tag_account_type(user.id, groceries, "expense").await;
(income, usd, -1500, 1),
(groceries, usd, 500, 1),
(rent, usd, 1000, 1),
.report_filter(ReportFilter::AccountEq(groceries))
let expense_group = find_group(period, "Expense").unwrap();
// Only groceries survives the user filter.
assert_eq!(group_total(expense_group, usd), Rational64::new(500, 1));
let income_group = find_group(period, "Income").unwrap();
// Income group's filter excludes groceries, so zero income rows remain.
assert!(income_group.roots.is_empty());
async fn test_activity_report_monthly_grouping(pool: PgPool) {
vec![(income, usd, -100, 1), (expense, usd, 100, 1)],
vec![(income, usd, -200, 1), (expense, usd, 200, 1)],
assert_eq!(activity.periods.len(), 2);
assert_eq!(activity.periods[0].label.as_deref(), Some("2025-01"));
assert_eq!(activity.periods[1].label.as_deref(), Some("2025-02"));
group_total(find_group(&activity.periods[0], "Expense").unwrap(), usd),
Rational64::new(100, 1)
group_total(find_group(&activity.periods[1], "Expense").unwrap(), usd),
Rational64::new(200, 1)
async fn test_category_breakdown_falls_back_to_transaction_tag(pool: PgPool) {
let (tx_id, _) = create_tx(
// Tag the transaction — no split tag. CategoryBreakdown should pick up
// the tx-level tag via COALESCE fallback.
tag_tx_category(user, tx_id, "food").await;
let food = find_row(&bd.periods[0].rows, "food").expect("food row via tx tag");
assert_eq!(row_amount(food, usd), Some(Rational64::new(100, 1)));
async fn test_category_breakdown_excludes_asset_splits(pool: PgPool) {
// Tag BOTH sides of a balanced transaction as 'food'. Under the old
// "all splits" semantics, the food bucket would sum to zero because
// +100 and -100 cancel. Under the scope filter, only the expense side
// counts and food = +100.
tag_split_category(user, s[0], "food").await;
tag_split_category(user, s[1], "food").await;
let food = find_row(&bd.periods[0].rows, "food").expect("food row");
row_amount(food, usd),
Some(Rational64::new(100, 1)),
"asset leg is scoped out; only expense side contributes"