1
use super::BalanceReport;
2

            
3
use chrono::NaiveDate;
4
use num_rational::Rational64;
5

            
6
use super::super::{
7
    CmdResult, FinanceEntity, PeriodGrouping, ReportData, ReportFilter, ReportNode,
8
};
9
use crate::{
10
    command::{account::CreateAccount, commodity::CreateCommodity, transaction::CreateTransaction},
11
    db::DB_POOL,
12
};
13
use finance::{price::Price, split::Split};
14
use sqlx::{
15
    PgPool,
16
    types::Uuid,
17
    types::chrono::{DateTime, Utc},
18
};
19
use supp_macro::local_db_sqlx_test;
20
use tokio::sync::OnceCell;
21

            
22
static CONTEXT: OnceCell<()> = OnceCell::const_new();
23
static USER: OnceCell<crate::user::User> = OnceCell::const_new();
24

            
25
26
async fn setup() {
26
26
    CONTEXT
27
26
        .get_or_init(|| async {
28
            #[cfg(feature = "testlog")]
29
1
            let _ = env_logger::builder()
30
1
                .is_test(true)
31
1
                .filter_level(log::LevelFilter::Trace)
32
1
                .try_init();
33
2
        })
34
26
        .await;
35
26
    USER.get_or_init(|| async { crate::user::User { id: Uuid::new_v4() } })
36
26
        .await;
37
26
}
38

            
39
34
fn extract_commodity_id(result: Option<CmdResult>) -> Uuid {
40
34
    if let Some(CmdResult::String(id)) = result {
41
34
        Uuid::parse_str(&id).unwrap()
42
    } else {
43
        panic!("Expected commodity ID");
44
    }
45
34
}
46

            
47
62
fn extract_account_id(result: Option<CmdResult>) -> Uuid {
48
62
    if let Some(CmdResult::Entity(FinanceEntity::Account(acc))) = result {
49
62
        acc.id
50
    } else {
51
        panic!("Expected account entity");
52
    }
53
62
}
54

            
55
12
fn extract_report(result: Option<CmdResult>) -> ReportData {
56
12
    if let Some(CmdResult::Report(data)) = result {
57
12
        data
58
    } else {
59
        panic!("Expected Report result");
60
    }
61
12
}
62

            
63
33
fn find_node(roots: &[ReportNode], account_id: Uuid) -> Option<&ReportNode> {
64
33
    for node in roots {
65
32
        if node.account_id == account_id {
66
19
            return Some(node);
67
13
        }
68
13
        if let Some(found) = find_node(&node.children, account_id) {
69
6
            return Some(found);
70
7
        }
71
    }
72
8
    None
73
33
}
74

            
75
21
fn node_amount(node: &ReportNode, commodity_id: Uuid) -> Option<Rational64> {
76
21
    node.amounts
77
21
        .iter()
78
22
        .find(|ca| ca.commodity_id == commodity_id)
79
21
        .map(|ca| ca.amount)
80
21
}
81

            
82
34
async fn create_commodity(user_id: Uuid, symbol: &str, name: &str) -> Uuid {
83
34
    extract_commodity_id(
84
34
        CreateCommodity::new()
85
34
            .symbol(symbol.to_string())
86
34
            .name(name.to_string())
87
34
            .user_id(user_id)
88
34
            .run()
89
34
            .await
90
34
            .unwrap(),
91
    )
92
34
}
93

            
94
62
async fn create_account(user_id: Uuid, name: &str, parent: Option<Uuid>) -> Uuid {
95
62
    match parent {
96
4
        Some(pid) => extract_account_id(
97
4
            CreateAccount::new()
98
4
                .name(name.to_string())
99
4
                .user_id(user_id)
100
4
                .parent(pid)
101
4
                .run()
102
4
                .await
103
4
                .unwrap(),
104
        ),
105
58
        None => extract_account_id(
106
58
            CreateAccount::new()
107
58
                .name(name.to_string())
108
58
                .user_id(user_id)
109
58
                .run()
110
58
                .await
111
58
                .unwrap(),
112
        ),
113
    }
114
62
}
115

            
116
47
async fn create_tx(
117
47
    user_id: Uuid,
118
47
    post_date: DateTime<Utc>,
119
47
    splits: Vec<(Uuid, Uuid, i64, i64)>,
120
47
) -> (Uuid, Vec<Uuid>) {
121
47
    let tx_id = Uuid::new_v4();
122
47
    let mut split_ids = Vec::new();
123
47
    let split_entities: Vec<FinanceEntity> = splits
124
47
        .into_iter()
125
97
        .map(|(account_id, commodity_id, value_num, value_denom)| {
126
97
            let sid = Uuid::new_v4();
127
97
            split_ids.push(sid);
128
97
            FinanceEntity::Split(Split {
129
97
                id: sid,
130
97
                tx_id,
131
97
                account_id,
132
97
                commodity_id,
133
97
                value_num,
134
97
                value_denom,
135
97
                reconcile_state: None,
136
97
                reconcile_date: None,
137
97
                lot_id: None,
138
97
            })
139
97
        })
140
47
        .collect();
141
47
    CreateTransaction::new()
142
47
        .user_id(user_id)
143
47
        .splits(split_entities)
144
47
        .id(tx_id)
145
47
        .post_date(post_date)
146
47
        .enter_date(post_date)
147
47
        .run()
148
47
        .await
149
47
        .unwrap();
150
47
    (tx_id, split_ids)
151
47
}
152

            
153
13
async fn insert_price(
154
13
    user: &crate::user::User,
155
13
    commodity_split_id: Uuid,
156
13
    commodity_id: Uuid,
157
13
    currency_id: Uuid,
158
13
    date: DateTime<Utc>,
159
13
    value_num: i64,
160
13
    value_denom: i64,
161
13
) {
162
13
    let price = Price {
163
13
        id: Uuid::new_v4(),
164
13
        date,
165
13
        commodity_id,
166
13
        currency_id,
167
13
        commodity_split: Some(commodity_split_id),
168
13
        currency_split: None,
169
13
        value_num,
170
13
        value_denom,
171
13
    };
172
13
    let mut conn = user.get_connection().await.unwrap();
173
13
    sqlx::query_file!(
174
        "sql/insert/prices/price.sql",
175
        price.id,
176
        price.commodity_id,
177
        price.currency_id,
178
        price.commodity_split,
179
        price.currency_split,
180
        price.date,
181
        price.value_num,
182
        price.value_denom
183
    )
184
13
    .execute(&mut *conn)
185
13
    .await
186
13
    .unwrap();
187
13
}
188

            
189
// --- BalanceReport tests ---
190

            
191
#[local_db_sqlx_test]
192
async fn test_balance_report_single_currency(pool: PgPool) {
193
    let user = USER.get().unwrap();
194
    user.commit().await.expect("Failed to commit user");
195

            
196
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
197
    let assets = create_account(user.id, "Assets", None).await;
198
    let bank = create_account(user.id, "Bank", Some(assets)).await;
199
    let expenses = create_account(user.id, "Expenses", None).await;
200

            
201
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
202
    create_tx(
203
        user.id,
204
        d,
205
        vec![(bank, usd, -200, 1), (expenses, usd, 200, 1)],
206
    )
207
    .await;
208

            
209
    let report = extract_report(BalanceReport::new().user_id(user.id).run().await.unwrap());
210

            
211
    assert_eq!(report.periods.len(), 1);
212
    assert!(report.periods[0].label.is_none());
213

            
214
    let bank_node = find_node(&report.periods[0].roots, bank).unwrap();
215
    assert_eq!(node_amount(bank_node, usd), Some(Rational64::new(-200, 1)));
216
    assert_eq!(bank_node.depth, 1);
217
    assert_eq!(bank_node.account_path, "Assets:Bank");
218

            
219
    let assets_node = find_node(&report.periods[0].roots, assets).unwrap();
220
    assert_eq!(
221
        node_amount(assets_node, usd),
222
        Some(Rational64::new(-200, 1))
223
    );
224

            
225
    let expenses_node = find_node(&report.periods[0].roots, expenses).unwrap();
226
    assert_eq!(
227
        node_amount(expenses_node, usd),
228
        Some(Rational64::new(200, 1))
229
    );
230
}
231

            
232
#[local_db_sqlx_test]
233
async fn test_balance_report_multi_currency(pool: PgPool) {
234
    let user = USER.get().unwrap();
235
    user.commit().await.expect("Failed to commit user");
236

            
237
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
238
    let eur = create_commodity(user.id, "EUR", "Euro").await;
239
    let acc_a = create_account(user.id, "Account A", None).await;
240
    let acc_b = create_account(user.id, "Account B", None).await;
241

            
242
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
243
    create_tx(
244
        user.id,
245
        d,
246
        vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
247
    )
248
    .await;
249
    create_tx(user.id, d, vec![(acc_a, eur, 50, 1), (acc_b, eur, -50, 1)]).await;
250

            
251
    let report = extract_report(BalanceReport::new().user_id(user.id).run().await.unwrap());
252

            
253
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
254
    assert_eq!(node_a.amounts.len(), 2);
255
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(100, 1)));
256
    assert_eq!(node_amount(node_a, eur), Some(Rational64::new(50, 1)));
257
}
258

            
259
#[local_db_sqlx_test]
260
async fn test_balance_report_with_conversion(pool: PgPool) {
261
    let user = USER.get().unwrap();
262
    user.commit().await.expect("Failed to commit user");
263

            
264
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
265
    let eur = create_commodity(user.id, "EUR", "Euro").await;
266
    let acc_a = create_account(user.id, "Account A", None).await;
267
    let acc_b = create_account(user.id, "Account B", None).await;
268

            
269
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
270
    create_tx(
271
        user.id,
272
        d,
273
        vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
274
    )
275
    .await;
276
    let (_tx2, split_ids) =
277
        create_tx(user.id, d, vec![(acc_a, eur, 50, 1), (acc_b, eur, -50, 1)]).await;
278

            
279
    // Insert split-specific prices for EUR→USD conversion (1.2 USD per EUR)
280
    insert_price(user, split_ids[0], eur, usd, d, 12, 10).await;
281
    insert_price(user, split_ids[1], eur, usd, d, 12, 10).await;
282

            
283
    let report = extract_report(
284
        BalanceReport::new()
285
            .user_id(user.id)
286
            .target_commodity_id(usd)
287
            .run()
288
            .await
289
            .unwrap(),
290
    );
291

            
292
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
293
    assert_eq!(node_a.amounts.len(), 1);
294
    // 100 USD + 50 EUR * 1.2 = 100 + 60 = 160 USD
295
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(160, 1)));
296
}
297

            
298
#[local_db_sqlx_test]
299
async fn test_balance_report_missing_conversion(pool: PgPool) {
300
    let user = USER.get().unwrap();
301
    user.commit().await.expect("Failed to commit user");
302

            
303
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
304
    let eur = create_commodity(user.id, "EUR", "Euro").await;
305
    let acc_a = create_account(user.id, "Account A", None).await;
306
    let acc_b = create_account(user.id, "Account B", None).await;
307

            
308
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
309
    create_tx(user.id, d, vec![(acc_a, eur, 50, 1), (acc_b, eur, -50, 1)]).await;
310

            
311
    let result = BalanceReport::new()
312
        .user_id(user.id)
313
        .target_commodity_id(usd)
314
        .run()
315
        .await;
316

            
317
    assert!(result.is_err());
318
}
319

            
320
/// A split whose commodity is already the target currency must contribute
321
/// its face value exactly once, even when the LEFT JOIN to `prices` finds
322
/// no matching row. Regression test for a bug where the report double-
323
/// counted already-target-currency splits alongside their converted
324
/// counterparts.
325
#[local_db_sqlx_test]
326
async fn test_balance_report_conversion_includes_target_currency_splits(pool: PgPool) {
327
    let user = USER.get().unwrap();
328
    user.commit().await.expect("Failed to commit user");
329

            
330
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
331
    let eur = create_commodity(user.id, "EUR", "Euro").await;
332
    let acc_a = create_account(user.id, "Account A", None).await;
333
    let acc_b = create_account(user.id, "Account B", None).await;
334

            
335
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
336
    create_tx(
337
        user.id,
338
        d,
339
        vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
340
    )
341
    .await;
342
    let (_tx2, split_ids) =
343
        create_tx(user.id, d, vec![(acc_a, eur, 50, 1), (acc_b, eur, -50, 1)]).await;
344
    insert_price(user, split_ids[0], eur, usd, d, 12, 10).await;
345
    insert_price(user, split_ids[1], eur, usd, d, 12, 10).await;
346

            
347
    let report = extract_report(
348
        BalanceReport::new()
349
            .user_id(user.id)
350
            .target_commodity_id(usd)
351
            .run()
352
            .await
353
            .unwrap(),
354
    );
355

            
356
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
357
    // 100 USD (no conversion needed) + 50 EUR * 1.2 = 160 USD. A bug that
358
    // skipped target-currency splits would yield 60; one that double-counted
359
    // them would yield 260.
360
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(160, 1)));
361
}
362

            
363
/// Duplicate (commodity_split_id, currency_id) rows in the `prices` table
364
/// (possible via bulk imports or manual edits) must not multiply a split's
365
/// contribution. The report should converge on a single price row per
366
/// split, not sum over every matching price.
367
#[local_db_sqlx_test]
368
async fn test_balance_report_conversion_is_stable_with_duplicate_prices(pool: PgPool) {
369
    let user = USER.get().unwrap();
370
    user.commit().await.expect("Failed to commit user");
371

            
372
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
373
    let eur = create_commodity(user.id, "EUR", "Euro").await;
374
    let acc_a = create_account(user.id, "Account A", None).await;
375
    let acc_b = create_account(user.id, "Account B", None).await;
376

            
377
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
378
    let (_tx, split_ids) =
379
        create_tx(user.id, d, vec![(acc_a, eur, 50, 1), (acc_b, eur, -50, 1)]).await;
380

            
381
    // Two price rows for the same (commodity_split_id, currency_id). A naive
382
    // LEFT JOIN would emit the split twice and double-count it.
383
    insert_price(user, split_ids[0], eur, usd, d, 12, 10).await;
384
    insert_price(user, split_ids[0], eur, usd, d, 12, 10).await;
385
    insert_price(user, split_ids[1], eur, usd, d, 12, 10).await;
386

            
387
    let report = extract_report(
388
        BalanceReport::new()
389
            .user_id(user.id)
390
            .target_commodity_id(usd)
391
            .run()
392
            .await
393
            .unwrap(),
394
    );
395

            
396
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
397
    // 50 EUR * 1.2 = 60 USD, regardless of how many duplicate price rows exist.
398
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(60, 1)));
399
}
400

            
401
/// Multi-commodity account balances must preserve their per-commodity sums
402
/// when one leg converts and the other is already in the target currency.
403
/// Covers aggregation symmetry between the no-conversion leaf path and the
404
/// conversion leaf path inside `accumulate_split_converted`.
405
#[local_db_sqlx_test]
406
async fn test_balance_report_conversion_mixed_commodities_same_account(pool: PgPool) {
407
    let user = USER.get().unwrap();
408
    user.commit().await.expect("Failed to commit user");
409

            
410
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
411
    let eur = create_commodity(user.id, "EUR", "Euro").await;
412
    let acc_a = create_account(user.id, "Account A", None).await;
413
    let acc_b = create_account(user.id, "Account B", None).await;
414

            
415
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
416
    // Three USD deposits of 100 each to acc_a
417
    for _ in 0..3 {
418
        create_tx(
419
            user.id,
420
            d,
421
            vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
422
        )
423
        .await;
424
    }
425
    // Two EUR deposits of 50 each, priced at 1.2 USD/EUR
426
    for _ in 0..2 {
427
        let (_tx, split_ids) =
428
            create_tx(user.id, d, vec![(acc_a, eur, 50, 1), (acc_b, eur, -50, 1)]).await;
429
        insert_price(user, split_ids[0], eur, usd, d, 12, 10).await;
430
        insert_price(user, split_ids[1], eur, usd, d, 12, 10).await;
431
    }
432

            
433
    let report = extract_report(
434
        BalanceReport::new()
435
            .user_id(user.id)
436
            .target_commodity_id(usd)
437
            .run()
438
            .await
439
            .unwrap(),
440
    );
441

            
442
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
443
    // 3 * 100 USD + 2 * (50 EUR * 1.2) = 300 + 120 = 420 USD
444
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(420, 1)));
445
}
446

            
447
#[local_db_sqlx_test]
448
async fn test_balance_report_as_of(pool: PgPool) {
449
    let user = USER.get().unwrap();
450
    user.commit().await.expect("Failed to commit user");
451

            
452
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
453
    let acc_a = create_account(user.id, "Account A", None).await;
454
    let acc_b = create_account(user.id, "Account B", None).await;
455

            
456
    let d1 = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
457
    let d2 = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
458
    let cutoff = DateTime::<Utc>::from_timestamp(1700050000, 0).unwrap();
459

            
460
    create_tx(
461
        user.id,
462
        d1,
463
        vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
464
    )
465
    .await;
466
    create_tx(
467
        user.id,
468
        d2,
469
        vec![(acc_a, usd, 200, 1), (acc_b, usd, -200, 1)],
470
    )
471
    .await;
472

            
473
    let report = extract_report(
474
        BalanceReport::new()
475
            .user_id(user.id)
476
            .as_of(cutoff)
477
            .run()
478
            .await
479
            .unwrap(),
480
    );
481

            
482
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
483
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(100, 1)));
484
}
485

            
486
#[local_db_sqlx_test]
487
async fn test_balance_report_with_filter(pool: PgPool) {
488
    let user = USER.get().unwrap();
489
    user.commit().await.expect("Failed to commit user");
490

            
491
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
492
    let acc_a = create_account(user.id, "Account A", None).await;
493
    let acc_b = create_account(user.id, "Account B", None).await;
494
    let acc_c = create_account(user.id, "Account C", None).await;
495

            
496
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
497
    create_tx(
498
        user.id,
499
        d,
500
        vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
501
    )
502
    .await;
503
    create_tx(user.id, d, vec![(acc_c, usd, 50, 1), (acc_b, usd, -50, 1)]).await;
504

            
505
    let report = extract_report(
506
        BalanceReport::new()
507
            .user_id(user.id)
508
            .report_filter(ReportFilter::AccountEq(acc_a))
509
            .run()
510
            .await
511
            .unwrap(),
512
    );
513

            
514
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
515
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(100, 1)));
516
    // acc_c should have no amounts since we filtered to acc_a only
517
    let node_c = find_node(&report.periods[0].roots, acc_c);
518
    assert!(node_c.is_none() || node_amount(node_c.unwrap(), usd).is_none());
519
}
520

            
521
// --- BalanceReport period-mode tests (formerly TrialBalance) ---
522

            
523
#[local_db_sqlx_test]
524
async fn test_balance_report_period_basic(pool: PgPool) {
525
    let user = USER.get().unwrap();
526
    user.commit().await.expect("Failed to commit user");
527

            
528
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
529
    let assets = create_account(user.id, "Assets", None).await;
530
    let liabilities = create_account(user.id, "Liabilities", None).await;
531
    let income = create_account(user.id, "Income", None).await;
532

            
533
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
534
    create_tx(
535
        user.id,
536
        d,
537
        vec![(assets, usd, 500, 1), (income, usd, -500, 1)],
538
    )
539
    .await;
540
    create_tx(
541
        user.id,
542
        d,
543
        vec![(liabilities, usd, -200, 1), (assets, usd, 200, 1)],
544
    )
545
    .await;
546

            
547
    let from = DateTime::<Utc>::from_timestamp(1699000000, 0).unwrap();
548
    let to = DateTime::<Utc>::from_timestamp(1701000000, 0).unwrap();
549

            
550
    let report = extract_report(
551
        BalanceReport::new()
552
            .user_id(user.id)
553
            .date_from(from)
554
            .as_of(to)
555
            .run()
556
            .await
557
            .unwrap(),
558
    );
559

            
560
    assert_eq!(report.periods.len(), 1);
561
    assert!(report.periods[0].label.is_none());
562

            
563
    let assets_node = find_node(&report.periods[0].roots, assets).unwrap();
564
    assert_eq!(node_amount(assets_node, usd), Some(Rational64::new(700, 1)));
565

            
566
    let liabilities_node = find_node(&report.periods[0].roots, liabilities).unwrap();
567
    assert_eq!(
568
        node_amount(liabilities_node, usd),
569
        Some(Rational64::new(-200, 1))
570
    );
571

            
572
    let income_node = find_node(&report.periods[0].roots, income).unwrap();
573
    assert_eq!(
574
        node_amount(income_node, usd),
575
        Some(Rational64::new(-500, 1))
576
    );
577
}
578

            
579
#[local_db_sqlx_test]
580
async fn test_balance_report_period_with_conversion(pool: PgPool) {
581
    let user = USER.get().unwrap();
582
    user.commit().await.expect("Failed to commit user");
583

            
584
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
585
    let eur = create_commodity(user.id, "EUR", "Euro").await;
586
    let acc_a = create_account(user.id, "Account A", None).await;
587
    let acc_b = create_account(user.id, "Account B", None).await;
588

            
589
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
590
    create_tx(
591
        user.id,
592
        d,
593
        vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
594
    )
595
    .await;
596
    let (_tx2, split_ids) =
597
        create_tx(user.id, d, vec![(acc_a, eur, 80, 1), (acc_b, eur, -80, 1)]).await;
598

            
599
    insert_price(user, split_ids[0], eur, usd, d, 11, 10).await;
600
    insert_price(user, split_ids[1], eur, usd, d, 11, 10).await;
601

            
602
    let from = DateTime::<Utc>::from_timestamp(1699000000, 0).unwrap();
603
    let to = DateTime::<Utc>::from_timestamp(1701000000, 0).unwrap();
604

            
605
    let report = extract_report(
606
        BalanceReport::new()
607
            .user_id(user.id)
608
            .date_from(from)
609
            .as_of(to)
610
            .target_commodity_id(usd)
611
            .run()
612
            .await
613
            .unwrap(),
614
    );
615

            
616
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
617
    assert_eq!(node_a.amounts.len(), 1);
618
    // 100 USD + 80 EUR * 1.1 = 100 + 88 = 188 USD
619
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(188, 1)));
620
}
621

            
622
#[local_db_sqlx_test]
623
async fn test_balance_report_period_with_filter(pool: PgPool) {
624
    let user = USER.get().unwrap();
625
    user.commit().await.expect("Failed to commit user");
626

            
627
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
628
    let eur = create_commodity(user.id, "EUR", "Euro").await;
629
    let acc_a = create_account(user.id, "Account A", None).await;
630
    let acc_b = create_account(user.id, "Account B", None).await;
631

            
632
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
633
    create_tx(
634
        user.id,
635
        d,
636
        vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
637
    )
638
    .await;
639
    create_tx(user.id, d, vec![(acc_a, eur, 50, 1), (acc_b, eur, -50, 1)]).await;
640

            
641
    let from = DateTime::<Utc>::from_timestamp(1699000000, 0).unwrap();
642
    let to = DateTime::<Utc>::from_timestamp(1701000000, 0).unwrap();
643

            
644
    let report = extract_report(
645
        BalanceReport::new()
646
            .user_id(user.id)
647
            .date_from(from)
648
            .as_of(to)
649
            .report_filter(ReportFilter::CommodityEq(usd))
650
            .run()
651
            .await
652
            .unwrap(),
653
    );
654

            
655
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
656
    assert_eq!(node_a.amounts.len(), 1);
657
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(100, 1)));
658
    assert!(node_amount(node_a, eur).is_none());
659
}
660

            
661
// --- Hierarchy rollup test ---
662

            
663
#[local_db_sqlx_test]
664
async fn test_balance_report_hierarchy_rollup(pool: PgPool) {
665
    let user = USER.get().unwrap();
666
    user.commit().await.expect("Failed to commit user");
667

            
668
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
669
    let assets = create_account(user.id, "Assets", None).await;
670
    let bank = create_account(user.id, "Bank", Some(assets)).await;
671
    let checking = create_account(user.id, "Checking", Some(bank)).await;
672
    let savings = create_account(user.id, "Savings", Some(bank)).await;
673
    let other = create_account(user.id, "Other", None).await;
674

            
675
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
676
    create_tx(
677
        user.id,
678
        d,
679
        vec![(checking, usd, 300, 1), (other, usd, -300, 1)],
680
    )
681
    .await;
682
    create_tx(
683
        user.id,
684
        d,
685
        vec![(savings, usd, 700, 1), (other, usd, -700, 1)],
686
    )
687
    .await;
688

            
689
    let report = extract_report(BalanceReport::new().user_id(user.id).run().await.unwrap());
690

            
691
    let checking_node = find_node(&report.periods[0].roots, checking).unwrap();
692
    assert_eq!(
693
        node_amount(checking_node, usd),
694
        Some(Rational64::new(300, 1))
695
    );
696
    assert_eq!(checking_node.account_path, "Assets:Bank:Checking");
697
    assert_eq!(checking_node.depth, 2);
698

            
699
    let savings_node = find_node(&report.periods[0].roots, savings).unwrap();
700
    assert_eq!(
701
        node_amount(savings_node, usd),
702
        Some(Rational64::new(700, 1))
703
    );
704

            
705
    // Bank should roll up: 300 + 700 = 1000
706
    let bank_node = find_node(&report.periods[0].roots, bank).unwrap();
707
    assert_eq!(node_amount(bank_node, usd), Some(Rational64::new(1000, 1)));
708

            
709
    // Assets should roll up: same as bank = 1000
710
    let assets_node = find_node(&report.periods[0].roots, assets).unwrap();
711
    assert_eq!(
712
        node_amount(assets_node, usd),
713
        Some(Rational64::new(1000, 1))
714
    );
715
}
716

            
717
// --- split-tagging helpers for CategoryBreakdown / Activity tests ---
718

            
719
1
async fn tag_tx_category(user: &crate::user::User, tx_id: Uuid, category: &str) {
720
1
    user.create_transaction_tag(tx_id, "category".to_string(), category.to_string(), None)
721
1
        .await
722
1
        .unwrap();
723
1
}
724

            
725
13
async fn tag_split_category(user: &crate::user::User, split_id: Uuid, category: &str) {
726
13
    user.create_split_tag(split_id, "category".to_string(), category.to_string(), None)
727
13
        .await
728
13
        .unwrap();
729
13
}
730

            
731
2
async fn tag_split_kv(user: &crate::user::User, split_id: Uuid, name: &str, value: &str) {
732
2
    user.create_split_tag(split_id, name.to_string(), value.to_string(), None)
733
2
        .await
734
2
        .unwrap();
735
2
}
736

            
737
// --- CategoryBreakdown tests ---
738

            
739
use super::super::{BreakdownData, BreakdownSort};
740
use super::CategoryBreakdown;
741

            
742
12
fn extract_breakdown(result: Option<CmdResult>) -> BreakdownData {
743
12
    if let Some(CmdResult::Breakdown(data)) = result {
744
12
        data
745
    } else {
746
        panic!("Expected Breakdown result");
747
    }
748
12
}
749

            
750
12
fn find_row<'a>(
751
12
    rows: &'a [super::super::BreakdownRow],
752
12
    tag_value: &str,
753
12
) -> Option<&'a super::super::BreakdownRow> {
754
13
    rows.iter().find(|r| r.tag_value == tag_value)
755
12
}
756

            
757
4
fn row_amount(row: &super::super::BreakdownRow, commodity_id: Uuid) -> Option<Rational64> {
758
4
    row.amounts
759
4
        .iter()
760
4
        .find(|a| a.commodity_id == commodity_id)
761
4
        .map(|a| a.amount)
762
4
}
763

            
764
#[local_db_sqlx_test]
765
async fn test_category_breakdown_basic(pool: PgPool) {
766
    let user = USER.get().unwrap();
767
    user.commit().await.expect("Failed to commit user");
768

            
769
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
770
    let expenses = create_account(user.id, "Expenses", None).await;
771
    let checking = create_account(user.id, "Checking", None).await;
772
    tag_account_type(user.id, expenses, "expense").await;
773
    tag_account_type(user.id, checking, "asset").await;
774

            
775
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
776
    let (_, splits_a) = create_tx(
777
        user.id,
778
        d,
779
        vec![(expenses, usd, 300, 1), (checking, usd, -300, 1)],
780
    )
781
    .await;
782
    let (_, splits_b) = create_tx(
783
        user.id,
784
        d,
785
        vec![(expenses, usd, 150, 1), (checking, usd, -150, 1)],
786
    )
787
    .await;
788
    let (_, splits_c) = create_tx(
789
        user.id,
790
        d,
791
        vec![(expenses, usd, 75, 1), (checking, usd, -75, 1)],
792
    )
793
    .await;
794
    tag_split_category(user, splits_a[0], "food").await;
795
    tag_split_category(user, splits_b[0], "transport").await;
796
    tag_split_category(user, splits_c[0], "food").await;
797

            
798
    let from = DateTime::<Utc>::from_timestamp(1699999000, 0).unwrap();
799
    let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
800

            
801
    let breakdown = extract_breakdown(
802
        CategoryBreakdown::new()
803
            .user_id(user.id)
804
            .date_from(from)
805
            .date_to(to)
806
            .run()
807
            .await
808
            .unwrap(),
809
    );
810

            
811
    assert_eq!(breakdown.tag_name, "category");
812
    assert_eq!(breakdown.periods.len(), 1);
813
    let rows = &breakdown.periods[0].rows;
814

            
815
    let food = find_row(rows, "food").expect("food row");
816
    let transport = find_row(rows, "transport").expect("transport row");
817

            
818
    let food_total: Rational64 = food.amounts.iter().map(|a| a.amount).sum();
819
    let transport_total: Rational64 = transport.amounts.iter().map(|a| a.amount).sum();
820
2
    let abs = |r: Rational64| if r < Rational64::new(0, 1) { -r } else { r };
821
    assert_eq!(abs(food_total), Rational64::new(375, 1));
822
    assert_eq!(abs(transport_total), Rational64::new(150, 1));
823
}
824

            
825
#[local_db_sqlx_test]
826
async fn test_category_breakdown_uncategorized_toggle(pool: PgPool) {
827
    let user = USER.get().unwrap();
828
    user.commit().await.expect("Failed to commit user");
829

            
830
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
831
    let expenses = create_account(user.id, "Expenses", None).await;
832
    let checking = create_account(user.id, "Checking", None).await;
833
    tag_account_type(user.id, expenses, "expense").await;
834
    tag_account_type(user.id, checking, "asset").await;
835

            
836
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
837
    let (_, splits_tagged) = create_tx(
838
        user.id,
839
        d,
840
        vec![(expenses, usd, 100, 1), (checking, usd, -100, 1)],
841
    )
842
    .await;
843
    create_tx(
844
        user.id,
845
        d,
846
        vec![(expenses, usd, 200, 1), (checking, usd, -200, 1)],
847
    )
848
    .await;
849
    tag_split_category(user, splits_tagged[0], "food").await;
850

            
851
    let from = DateTime::<Utc>::from_timestamp(1699999000, 0).unwrap();
852
    let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
853

            
854
    let with_uncat = extract_breakdown(
855
        CategoryBreakdown::new()
856
            .user_id(user.id)
857
            .date_from(from)
858
            .date_to(to)
859
            .run()
860
            .await
861
            .unwrap(),
862
    );
863
    assert!(
864
        find_row(&with_uncat.periods[0].rows, super::super::UNCATEGORIZED_KEY).is_some(),
865
        "uncategorized present by default"
866
    );
867

            
868
    let without_uncat = extract_breakdown(
869
        CategoryBreakdown::new()
870
            .user_id(user.id)
871
            .date_from(from)
872
            .date_to(to)
873
            .include_uncategorized(false)
874
            .run()
875
            .await
876
            .unwrap(),
877
    );
878
    assert!(
879
        find_row(
880
            &without_uncat.periods[0].rows,
881
            super::super::UNCATEGORIZED_KEY
882
        )
883
        .is_none(),
884
        "uncategorized filtered when include_uncategorized=false"
885
    );
886
    assert_eq!(without_uncat.periods[0].rows.len(), 1);
887
}
888

            
889
#[local_db_sqlx_test]
890
async fn test_category_breakdown_sort_orders(pool: PgPool) {
891
    let user = USER.get().unwrap();
892
    user.commit().await.expect("Failed to commit user");
893

            
894
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
895
    let expenses = create_account(user.id, "Expenses", None).await;
896
    let checking = create_account(user.id, "Checking", None).await;
897
    tag_account_type(user.id, expenses, "expense").await;
898
    tag_account_type(user.id, checking, "asset").await;
899

            
900
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
901
    let (_, s_a) = create_tx(
902
        user.id,
903
        d,
904
        vec![(expenses, usd, 100, 1), (checking, usd, -100, 1)],
905
    )
906
    .await;
907
    let (_, s_b) = create_tx(
908
        user.id,
909
        d,
910
        vec![(expenses, usd, 300, 1), (checking, usd, -300, 1)],
911
    )
912
    .await;
913
    let (_, s_c) = create_tx(
914
        user.id,
915
        d,
916
        vec![(expenses, usd, 200, 1), (checking, usd, -200, 1)],
917
    )
918
    .await;
919
    tag_split_category(user, s_a[0], "alpha").await;
920
    tag_split_category(user, s_b[0], "beta").await;
921
    tag_split_category(user, s_c[0], "gamma").await;
922

            
923
    let from = DateTime::<Utc>::from_timestamp(1699999000, 0).unwrap();
924
    let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
925

            
926
    let desc = extract_breakdown(
927
        CategoryBreakdown::new()
928
            .user_id(user.id)
929
            .date_from(from)
930
            .date_to(to)
931
            .include_uncategorized(false)
932
            .target_commodity_id(usd)
933
            .sort_order(BreakdownSort::AmountDesc)
934
            .run()
935
            .await
936
            .unwrap(),
937
    );
938
    let order: Vec<&str> = desc.periods[0]
939
        .rows
940
        .iter()
941
3
        .map(|r| r.tag_value.as_str())
942
        .collect();
943
    assert_eq!(order, ["beta", "gamma", "alpha"]);
944

            
945
    let asc = extract_breakdown(
946
        CategoryBreakdown::new()
947
            .user_id(user.id)
948
            .date_from(from)
949
            .date_to(to)
950
            .include_uncategorized(false)
951
            .target_commodity_id(usd)
952
            .sort_order(BreakdownSort::AmountAsc)
953
            .run()
954
            .await
955
            .unwrap(),
956
    );
957
    let order: Vec<&str> = asc.periods[0]
958
        .rows
959
        .iter()
960
3
        .map(|r| r.tag_value.as_str())
961
        .collect();
962
    assert_eq!(order, ["alpha", "gamma", "beta"]);
963

            
964
    let name_asc = extract_breakdown(
965
        CategoryBreakdown::new()
966
            .user_id(user.id)
967
            .date_from(from)
968
            .date_to(to)
969
            .include_uncategorized(false)
970
            .sort_order(BreakdownSort::NameAsc)
971
            .run()
972
            .await
973
            .unwrap(),
974
    );
975
    let order: Vec<&str> = name_asc.periods[0]
976
        .rows
977
        .iter()
978
3
        .map(|r| r.tag_value.as_str())
979
        .collect();
980
    assert_eq!(order, ["alpha", "beta", "gamma"]);
981
}
982

            
983
#[local_db_sqlx_test]
984
async fn test_category_breakdown_configurable_tag_name(pool: PgPool) {
985
    let user = USER.get().unwrap();
986
    user.commit().await.expect("Failed to commit user");
987

            
988
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
989
    let expenses = create_account(user.id, "Expenses", None).await;
990
    let checking = create_account(user.id, "Checking", None).await;
991
    tag_account_type(user.id, expenses, "expense").await;
992
    tag_account_type(user.id, checking, "asset").await;
993

            
994
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
995
    let (_, s) = create_tx(
996
        user.id,
997
        d,
998
        vec![(expenses, usd, 100, 1), (checking, usd, -100, 1)],
999
    )
    .await;
    tag_split_kv(user, s[0], "project", "roof").await;
    tag_split_kv(user, s[0], "category", "home").await;
    let from = DateTime::<Utc>::from_timestamp(1699999000, 0).unwrap();
    let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
    let by_project = extract_breakdown(
        CategoryBreakdown::new()
            .user_id(user.id)
            .date_from(from)
            .date_to(to)
            .tag_name("project".to_string())
            .include_uncategorized(false)
            .run()
            .await
            .unwrap(),
    );
    assert_eq!(by_project.tag_name, "project");
    assert!(find_row(&by_project.periods[0].rows, "roof").is_some());
    let by_category = extract_breakdown(
        CategoryBreakdown::new()
            .user_id(user.id)
            .date_from(from)
            .date_to(to)
            .include_uncategorized(false)
            .run()
            .await
            .unwrap(),
    );
    assert_eq!(by_category.tag_name, "category");
    assert!(find_row(&by_category.periods[0].rows, "home").is_some());
}
#[local_db_sqlx_test]
async fn test_category_breakdown_period_grouping(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 expenses = create_account(user.id, "Expenses", None).await;
    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 jan = NaiveDate::from_ymd_opt(2025, 1, 15)
        .unwrap()
        .and_hms_opt(12, 0, 0)
        .unwrap()
        .and_utc();
    let feb = NaiveDate::from_ymd_opt(2025, 2, 10)
        .unwrap()
        .and_hms_opt(12, 0, 0)
        .unwrap()
        .and_utc();
    let (_, s_jan) = create_tx(
        user.id,
        jan,
        vec![(expenses, usd, 100, 1), (checking, usd, -100, 1)],
    )
    .await;
    let (_, s_feb) = create_tx(
        user.id,
        feb,
        vec![(expenses, usd, 300, 1), (checking, usd, -300, 1)],
    )
    .await;
    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)
        .unwrap()
        .and_hms_opt(0, 0, 0)
        .unwrap()
        .and_utc();
    let to = NaiveDate::from_ymd_opt(2025, 3, 1)
        .unwrap()
        .and_hms_opt(0, 0, 0)
        .unwrap()
        .and_utc();
    let bd = extract_breakdown(
        CategoryBreakdown::new()
            .user_id(user.id)
            .date_from(from)
            .date_to(to)
            .period_grouping(PeriodGrouping::Month)
            .include_uncategorized(false)
            .run()
            .await
            .unwrap(),
    );
    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)));
}
#[local_db_sqlx_test]
async fn test_category_breakdown_with_filter(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 expenses = create_account(user.id, "Expenses", None).await;
    let income = create_account(user.id, "Income", None).await;
    let checking = create_account(user.id, "Checking", None).await;
    tag_account_type(user.id, expenses, "expense").await;
    tag_account_type(user.id, income, "income").await;
    tag_account_type(user.id, checking, "asset").await;
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
    let (_, s1) = create_tx(
        user.id,
        d,
        vec![(expenses, usd, 100, 1), (checking, usd, -100, 1)],
    )
    .await;
    let (_, s2) = create_tx(
        user.id,
        d,
        vec![(income, usd, -500, 1), (checking, usd, 500, 1)],
    )
    .await;
    tag_split_category(user, s1[0], "food").await;
    tag_split_category(user, s2[0], "salary").await;
    let from = DateTime::<Utc>::from_timestamp(1699999000, 0).unwrap();
    let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
    let bd = extract_breakdown(
        CategoryBreakdown::new()
            .user_id(user.id)
            .date_from(from)
            .date_to(to)
            .report_filter(ReportFilter::AccountEq(expenses))
            .include_uncategorized(false)
            .run()
            .await
            .unwrap(),
    );
    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;
28
async fn tag_account_type(user_id: Uuid, account_id: Uuid, account_type: &str) {
28
    crate::command::account::SetAccountTag::new()
28
        .user_id(user_id)
28
        .account_id(account_id)
28
        .tag_name("type".to_string())
28
        .tag_value(account_type.to_string())
28
        .run()
28
        .await
28
        .unwrap();
28
}
5
fn extract_activity(result: Option<CmdResult>) -> ActivityData {
5
    if let Some(CmdResult::Activity(data)) = result {
5
        data
    } else {
        panic!("Expected Activity result");
    }
5
}
12
fn find_group<'a>(
12
    period: &'a super::super::ActivityPeriod,
12
    label: &str,
12
) -> Option<&'a super::super::ActivityGroupResult> {
20
    period.groups.iter().find(|g| g.label == label)
12
}
9
fn group_total(group: &super::super::ActivityGroupResult, commodity_id: Uuid) -> Rational64 {
9
    group
9
        .roots
9
        .iter()
9
        .filter_map(|n| {
9
            n.amounts
9
                .iter()
9
                .find(|a| a.commodity_id == commodity_id)
9
                .map(|a| a.amount)
9
        })
9
        .sum()
9
}
#[local_db_sqlx_test]
async fn test_activity_report_default_groups(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 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, income, "income").await;
    tag_account_type(user.id, expense, "expense").await;
    tag_account_type(user.id, asset, "asset").await;
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
    create_tx(
        user.id,
        d,
        vec![
            (income, usd, -500, 1),
            (expense, usd, 200, 1),
            (asset, usd, 300, 1),
        ],
    )
    .await;
    let from = DateTime::<Utc>::from_timestamp(1699999000, 0).unwrap();
    let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
    let activity = extract_activity(
        ActivityReport::new()
            .user_id(user.id)
            .date_from(from)
            .date_to(to)
            .run()
            .await
            .unwrap(),
    );
    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.
1
    assert!(!income_group.roots.iter().any(|n| n.account_id == asset));
1
    assert!(!expense_group.roots.iter().any(|n| n.account_id == asset));
}
#[local_db_sqlx_test]
async fn test_activity_report_three_way_split(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 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;
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
    create_tx(
        user.id,
        d,
        vec![
            (salary, usd, -1000, 1),
            (savings, usd, 300, 1),
            (rent, usd, 700, 1),
        ],
    )
    .await;
    let from = DateTime::<Utc>::from_timestamp(1699999000, 0).unwrap();
    let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
    let groups = vec![
        ActivityGroup {
            label: "Income".into(),
            filter: ReportFilter::Tag {
                entity: FilterEntity::Account,
                name: "type".into(),
                value: "income".into(),
            },
            flip_sign: true,
        },
        ActivityGroup {
            label: "Savings".into(),
            filter: ReportFilter::Tag {
                entity: FilterEntity::Account,
                name: "type".into(),
                value: "savings".into(),
            },
            flip_sign: false,
        },
        ActivityGroup {
            label: "Expenses".into(),
            filter: ReportFilter::Tag {
                entity: FilterEntity::Account,
                name: "type".into(),
                value: "expense".into(),
            },
            flip_sign: false,
        },
    ];
    let activity = extract_activity(
        ActivityReport::new()
            .user_id(user.id)
            .date_from(from)
            .date_to(to)
            .groups(groups)
            .run()
            .await
            .unwrap(),
    );
    let period = &activity.periods[0];
    assert_eq!(period.groups.len(), 3);
    assert_eq!(
        group_total(find_group(period, "Income").unwrap(), usd),
        Rational64::new(-1000, 1)
    );
    assert_eq!(
        group_total(find_group(period, "Savings").unwrap(), usd),
        Rational64::new(300, 1)
    );
    assert_eq!(
        group_total(find_group(period, "Expenses").unwrap(), usd),
        Rational64::new(700, 1)
    );
}
#[local_db_sqlx_test]
async fn test_activity_report_custom_tag_classification(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 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.
    crate::command::account::SetAccountTag::new()
        .user_id(user.id)
        .account_id(earning)
        .tag_name("role".to_string())
        .tag_value("earning".to_string())
        .run()
        .await
        .unwrap();
    crate::command::account::SetAccountTag::new()
        .user_id(user.id)
        .account_id(cost)
        .tag_name("role".to_string())
        .tag_value("cost".to_string())
        .run()
        .await
        .unwrap();
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
    create_tx(
        user.id,
        d,
        vec![(earning, usd, -1000, 1), (cost, usd, 1000, 1)],
    )
    .await;
    let from = DateTime::<Utc>::from_timestamp(1699999000, 0).unwrap();
    let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
    let activity = extract_activity(
        ActivityReport::new()
            .user_id(user.id)
            .date_from(from)
            .date_to(to)
            .groups(vec![
                ActivityGroup {
                    label: "Revenue".into(),
                    filter: ReportFilter::Tag {
                        entity: FilterEntity::Account,
                        name: "role".into(),
                        value: "earning".into(),
                    },
                    flip_sign: true,
                },
                ActivityGroup {
                    label: "Costs".into(),
                    filter: ReportFilter::Tag {
                        entity: FilterEntity::Account,
                        name: "role".into(),
                        value: "cost".into(),
                    },
                    flip_sign: false,
                },
            ])
            .run()
            .await
            .unwrap(),
    );
    let period = &activity.periods[0];
    assert!(find_group(period, "Revenue").is_some());
    assert!(find_group(period, "Costs").is_some());
    assert_eq!(
        group_total(find_group(period, "Revenue").unwrap(), usd),
        Rational64::new(-1000, 1)
    );
}
#[local_db_sqlx_test]
async fn test_activity_report_user_filter_composition(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 income = create_account(user.id, "Salary", None).await;
    let groceries = create_account(user.id, "Groceries", None).await;
    let rent = create_account(user.id, "Rent", None).await;
    tag_account_type(user.id, income, "income").await;
    tag_account_type(user.id, groceries, "expense").await;
    tag_account_type(user.id, rent, "expense").await;
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
    create_tx(
        user.id,
        d,
        vec![
            (income, usd, -1500, 1),
            (groceries, usd, 500, 1),
            (rent, usd, 1000, 1),
        ],
    )
    .await;
    let from = DateTime::<Utc>::from_timestamp(1699999000, 0).unwrap();
    let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
    let activity = extract_activity(
        ActivityReport::new()
            .user_id(user.id)
            .date_from(from)
            .date_to(to)
            .report_filter(ReportFilter::AccountEq(groceries))
            .run()
            .await
            .unwrap(),
    );
    let period = &activity.periods[0];
    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());
}
#[local_db_sqlx_test]
async fn test_activity_report_monthly_grouping(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 income = create_account(user.id, "Salary", None).await;
    let expense = create_account(user.id, "Groceries", None).await;
    tag_account_type(user.id, income, "income").await;
    tag_account_type(user.id, expense, "expense").await;
    let jan = NaiveDate::from_ymd_opt(2025, 1, 15)
        .unwrap()
        .and_hms_opt(12, 0, 0)
        .unwrap()
        .and_utc();
    let feb = NaiveDate::from_ymd_opt(2025, 2, 10)
        .unwrap()
        .and_hms_opt(12, 0, 0)
        .unwrap()
        .and_utc();
    create_tx(
        user.id,
        jan,
        vec![(income, usd, -100, 1), (expense, usd, 100, 1)],
    )
    .await;
    create_tx(
        user.id,
        feb,
        vec![(income, usd, -200, 1), (expense, usd, 200, 1)],
    )
    .await;
    let from = NaiveDate::from_ymd_opt(2025, 1, 1)
        .unwrap()
        .and_hms_opt(0, 0, 0)
        .unwrap()
        .and_utc();
    let to = NaiveDate::from_ymd_opt(2025, 3, 1)
        .unwrap()
        .and_hms_opt(0, 0, 0)
        .unwrap()
        .and_utc();
    let activity = extract_activity(
        ActivityReport::new()
            .user_id(user.id)
            .date_from(from)
            .date_to(to)
            .period_grouping(PeriodGrouping::Month)
            .run()
            .await
            .unwrap(),
    );
    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"));
    assert_eq!(
        group_total(find_group(&activity.periods[0], "Expense").unwrap(), usd),
        Rational64::new(100, 1)
    );
    assert_eq!(
        group_total(find_group(&activity.periods[1], "Expense").unwrap(), usd),
        Rational64::new(200, 1)
    );
}
#[local_db_sqlx_test]
async fn test_category_breakdown_falls_back_to_transaction_tag(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 expenses = create_account(user.id, "Expenses", None).await;
    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 d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
    let (tx_id, _) = create_tx(
        user.id,
        d,
        vec![(expenses, usd, 100, 1), (checking, usd, -100, 1)],
    )
    .await;
    // 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 from = DateTime::<Utc>::from_timestamp(1699999000, 0).unwrap();
    let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
    let bd = extract_breakdown(
        CategoryBreakdown::new()
            .user_id(user.id)
            .date_from(from)
            .date_to(to)
            .include_uncategorized(false)
            .run()
            .await
            .unwrap(),
    );
    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)));
}
#[local_db_sqlx_test]
async fn test_category_breakdown_excludes_asset_splits(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 expenses = create_account(user.id, "Expenses", None).await;
    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 d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
    // 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.
    let (_, s) = create_tx(
        user.id,
        d,
        vec![(expenses, usd, 100, 1), (checking, usd, -100, 1)],
    )
    .await;
    tag_split_category(user, s[0], "food").await;
    tag_split_category(user, s[1], "food").await;
    let from = DateTime::<Utc>::from_timestamp(1699999000, 0).unwrap();
    let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
    let bd = extract_breakdown(
        CategoryBreakdown::new()
            .user_id(user.id)
            .date_from(from)
            .date_to(to)
            .include_uncategorized(false)
            .run()
            .await
            .unwrap(),
    );
    let food = find_row(&bd.periods[0].rows, "food").expect("food row");
    assert_eq!(
        row_amount(food, usd),
        Some(Rational64::new(100, 1)),
        "asset leg is scoped out; only expense side contributes"
    );
}