1
use super::*;
2

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

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

            
19
static CONTEXT: OnceCell<()> = OnceCell::const_new();
20
static USER: OnceCell<crate::user::User> = OnceCell::const_new();
21

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

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

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

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

            
60
35
fn find_node(roots: &[ReportNode], account_id: Uuid) -> Option<&ReportNode> {
61
35
    for node in roots {
62
34
        if node.account_id == account_id {
63
21
            return Some(node);
64
13
        }
65
13
        if let Some(found) = find_node(&node.children, account_id) {
66
6
            return Some(found);
67
7
        }
68
    }
69
8
    None
70
35
}
71

            
72
23
fn node_amount(node: &ReportNode, commodity_id: Uuid) -> Option<Rational64> {
73
23
    node.amounts
74
23
        .iter()
75
24
        .find(|ca| ca.commodity_id == commodity_id)
76
23
        .map(|ca| ca.amount)
77
23
}
78

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

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

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

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

            
186
// --- BalanceReport tests ---
187

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

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

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

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

            
208
    assert_eq!(report.periods.len(), 1);
209
    assert!(report.periods[0].label.is_none());
210

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
308
    let result = BalanceReport::new()
309
        .user_id(user.id)
310
        .target_commodity_id(usd)
311
        .run()
312
        .await;
313

            
314
    assert!(result.is_err());
315
}
316

            
317
#[local_db_sqlx_test]
318
async fn test_balance_report_as_of(pool: PgPool) {
319
    let user = USER.get().unwrap();
320
    user.commit().await.expect("Failed to commit user");
321

            
322
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
323
    let acc_a = create_account(user.id, "Account A", None).await;
324
    let acc_b = create_account(user.id, "Account B", None).await;
325

            
326
    let d1 = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
327
    let d2 = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
328
    let cutoff = DateTime::<Utc>::from_timestamp(1700050000, 0).unwrap();
329

            
330
    create_tx(
331
        user.id,
332
        d1,
333
        vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
334
    )
335
    .await;
336
    create_tx(
337
        user.id,
338
        d2,
339
        vec![(acc_a, usd, 200, 1), (acc_b, usd, -200, 1)],
340
    )
341
    .await;
342

            
343
    let report = extract_report(
344
        BalanceReport::new()
345
            .user_id(user.id)
346
            .as_of(cutoff)
347
            .run()
348
            .await
349
            .unwrap(),
350
    );
351

            
352
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
353
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(100, 1)));
354
}
355

            
356
#[local_db_sqlx_test]
357
async fn test_balance_report_with_filter(pool: PgPool) {
358
    let user = USER.get().unwrap();
359
    user.commit().await.expect("Failed to commit user");
360

            
361
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
362
    let acc_a = create_account(user.id, "Account A", None).await;
363
    let acc_b = create_account(user.id, "Account B", None).await;
364
    let acc_c = create_account(user.id, "Account C", None).await;
365

            
366
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
367
    create_tx(
368
        user.id,
369
        d,
370
        vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
371
    )
372
    .await;
373
    create_tx(user.id, d, vec![(acc_c, usd, 50, 1), (acc_b, usd, -50, 1)]).await;
374

            
375
    let report = extract_report(
376
        BalanceReport::new()
377
            .user_id(user.id)
378
            .report_filter(ReportFilter::AccountEq(acc_a))
379
            .run()
380
            .await
381
            .unwrap(),
382
    );
383

            
384
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
385
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(100, 1)));
386
    // acc_c should have no amounts since we filtered to acc_a only
387
    let node_c = find_node(&report.periods[0].roots, acc_c);
388
    assert!(node_c.is_none() || node_amount(node_c.unwrap(), usd).is_none());
389
}
390

            
391
// --- IncomeExpenseReport tests ---
392

            
393
#[local_db_sqlx_test]
394
async fn test_income_expense_report_date_range(pool: PgPool) {
395
    let user = USER.get().unwrap();
396
    user.commit().await.expect("Failed to commit user");
397

            
398
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
399
    let income = create_account(user.id, "Income", None).await;
400
    let expenses = create_account(user.id, "Expenses", None).await;
401

            
402
    let d1 = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
403
    let d2 = DateTime::<Utc>::from_timestamp(1700200000, 0).unwrap();
404
    let d_outside = DateTime::<Utc>::from_timestamp(1700400000, 0).unwrap();
405

            
406
    create_tx(
407
        user.id,
408
        d1,
409
        vec![(income, usd, -100, 1), (expenses, usd, 100, 1)],
410
    )
411
    .await;
412
    create_tx(
413
        user.id,
414
        d2,
415
        vec![(income, usd, -50, 1), (expenses, usd, 50, 1)],
416
    )
417
    .await;
418
    create_tx(
419
        user.id,
420
        d_outside,
421
        vec![(income, usd, -999, 1), (expenses, usd, 999, 1)],
422
    )
423
    .await;
424

            
425
    let from = DateTime::<Utc>::from_timestamp(1699999999, 0).unwrap();
426
    let to = DateTime::<Utc>::from_timestamp(1700300000, 0).unwrap();
427

            
428
    let report = extract_report(
429
        IncomeExpenseReport::new()
430
            .user_id(user.id)
431
            .date_from(from)
432
            .date_to(to)
433
            .run()
434
            .await
435
            .unwrap(),
436
    );
437

            
438
    assert_eq!(report.periods.len(), 1);
439
    assert!(report.periods[0].label.is_none());
440

            
441
    let expenses_node = find_node(&report.periods[0].roots, expenses).unwrap();
442
    assert_eq!(
443
        node_amount(expenses_node, usd),
444
        Some(Rational64::new(150, 1))
445
    );
446
}
447

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

            
453
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
454
    let income = create_account(user.id, "Income", None).await;
455
    let expenses = create_account(user.id, "Expenses", None).await;
456

            
457
    // January 2025
458
    let jan = NaiveDate::from_ymd_opt(2025, 1, 15)
459
        .unwrap()
460
        .and_hms_opt(12, 0, 0)
461
        .unwrap()
462
        .and_utc();
463
    // February 2025
464
    let feb = NaiveDate::from_ymd_opt(2025, 2, 10)
465
        .unwrap()
466
        .and_hms_opt(12, 0, 0)
467
        .unwrap()
468
        .and_utc();
469

            
470
    create_tx(
471
        user.id,
472
        jan,
473
        vec![(income, usd, -100, 1), (expenses, usd, 100, 1)],
474
    )
475
    .await;
476
    create_tx(
477
        user.id,
478
        feb,
479
        vec![(income, usd, -200, 1), (expenses, usd, 200, 1)],
480
    )
481
    .await;
482

            
483
    let from = NaiveDate::from_ymd_opt(2025, 1, 1)
484
        .unwrap()
485
        .and_hms_opt(0, 0, 0)
486
        .unwrap()
487
        .and_utc();
488
    let to = NaiveDate::from_ymd_opt(2025, 3, 1)
489
        .unwrap()
490
        .and_hms_opt(0, 0, 0)
491
        .unwrap()
492
        .and_utc();
493

            
494
    let report = extract_report(
495
        IncomeExpenseReport::new()
496
            .user_id(user.id)
497
            .date_from(from)
498
            .date_to(to)
499
            .period_grouping("month".to_string())
500
            .run()
501
            .await
502
            .unwrap(),
503
    );
504

            
505
    assert_eq!(report.periods.len(), 2);
506
    assert_eq!(report.periods[0].label.as_deref(), Some("2025-01"));
507
    assert_eq!(report.periods[1].label.as_deref(), Some("2025-02"));
508

            
509
    let jan_expenses = find_node(&report.periods[0].roots, expenses).unwrap();
510
    assert_eq!(
511
        node_amount(jan_expenses, usd),
512
        Some(Rational64::new(100, 1))
513
    );
514

            
515
    let feb_expenses = find_node(&report.periods[1].roots, expenses).unwrap();
516
    assert_eq!(
517
        node_amount(feb_expenses, usd),
518
        Some(Rational64::new(200, 1))
519
    );
520
}
521

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

            
527
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
528
    let acc_a = create_account(user.id, "Account A", None).await;
529
    let acc_b = create_account(user.id, "Account B", None).await;
530

            
531
    let q1 = NaiveDate::from_ymd_opt(2025, 2, 15)
532
        .unwrap()
533
        .and_hms_opt(12, 0, 0)
534
        .unwrap()
535
        .and_utc();
536
    let q2 = NaiveDate::from_ymd_opt(2025, 5, 15)
537
        .unwrap()
538
        .and_hms_opt(12, 0, 0)
539
        .unwrap()
540
        .and_utc();
541

            
542
    create_tx(
543
        user.id,
544
        q1,
545
        vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
546
    )
547
    .await;
548
    create_tx(
549
        user.id,
550
        q2,
551
        vec![(acc_a, usd, 300, 1), (acc_b, usd, -300, 1)],
552
    )
553
    .await;
554

            
555
    let from = NaiveDate::from_ymd_opt(2025, 1, 1)
556
        .unwrap()
557
        .and_hms_opt(0, 0, 0)
558
        .unwrap()
559
        .and_utc();
560
    let to = NaiveDate::from_ymd_opt(2025, 7, 1)
561
        .unwrap()
562
        .and_hms_opt(0, 0, 0)
563
        .unwrap()
564
        .and_utc();
565

            
566
    let report = extract_report(
567
        IncomeExpenseReport::new()
568
            .user_id(user.id)
569
            .date_from(from)
570
            .date_to(to)
571
            .period_grouping("quarter".to_string())
572
            .run()
573
            .await
574
            .unwrap(),
575
    );
576

            
577
    assert_eq!(report.periods.len(), 2);
578
    assert_eq!(report.periods[0].label.as_deref(), Some("2025-Q1"));
579
    assert_eq!(report.periods[1].label.as_deref(), Some("2025-Q2"));
580

            
581
    let q1_node = find_node(&report.periods[0].roots, acc_a).unwrap();
582
    assert_eq!(node_amount(q1_node, usd), Some(Rational64::new(100, 1)));
583

            
584
    let q2_node = find_node(&report.periods[1].roots, acc_a).unwrap();
585
    assert_eq!(node_amount(q2_node, usd), Some(Rational64::new(300, 1)));
586
}
587

            
588
#[local_db_sqlx_test]
589
async fn test_income_expense_report_invalid_grouping(pool: PgPool) {
590
    let user = USER.get().unwrap();
591
    user.commit().await.expect("Failed to commit user");
592

            
593
    let from = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
594
    let to = DateTime::<Utc>::from_timestamp(1700100000, 0).unwrap();
595

            
596
    let result = IncomeExpenseReport::new()
597
        .user_id(user.id)
598
        .date_from(from)
599
        .date_to(to)
600
        .period_grouping("weekly".to_string())
601
        .run()
602
        .await;
603

            
604
    assert!(result.is_err());
605
}
606

            
607
// --- TrialBalance tests ---
608

            
609
#[local_db_sqlx_test]
610
async fn test_trial_balance_basic(pool: PgPool) {
611
    let user = USER.get().unwrap();
612
    user.commit().await.expect("Failed to commit user");
613

            
614
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
615
    let assets = create_account(user.id, "Assets", None).await;
616
    let liabilities = create_account(user.id, "Liabilities", None).await;
617
    let income = create_account(user.id, "Income", None).await;
618

            
619
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
620
    create_tx(
621
        user.id,
622
        d,
623
        vec![(assets, usd, 500, 1), (income, usd, -500, 1)],
624
    )
625
    .await;
626
    create_tx(
627
        user.id,
628
        d,
629
        vec![(liabilities, usd, -200, 1), (assets, usd, 200, 1)],
630
    )
631
    .await;
632

            
633
    let from = DateTime::<Utc>::from_timestamp(1699000000, 0).unwrap();
634
    let to = DateTime::<Utc>::from_timestamp(1701000000, 0).unwrap();
635

            
636
    let report = extract_report(
637
        TrialBalance::new()
638
            .user_id(user.id)
639
            .date_from(from)
640
            .date_to(to)
641
            .run()
642
            .await
643
            .unwrap(),
644
    );
645

            
646
    assert_eq!(report.periods.len(), 1);
647
    assert!(report.periods[0].label.is_none());
648

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

            
652
    let liabilities_node = find_node(&report.periods[0].roots, liabilities).unwrap();
653
    assert_eq!(
654
        node_amount(liabilities_node, usd),
655
        Some(Rational64::new(-200, 1))
656
    );
657

            
658
    let income_node = find_node(&report.periods[0].roots, income).unwrap();
659
    assert_eq!(
660
        node_amount(income_node, usd),
661
        Some(Rational64::new(-500, 1))
662
    );
663
}
664

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

            
670
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
671
    let eur = create_commodity(user.id, "EUR", "Euro").await;
672
    let acc_a = create_account(user.id, "Account A", None).await;
673
    let acc_b = create_account(user.id, "Account B", None).await;
674

            
675
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
676
    create_tx(
677
        user.id,
678
        d,
679
        vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
680
    )
681
    .await;
682
    let (_tx2, split_ids) =
683
        create_tx(user.id, d, vec![(acc_a, eur, 80, 1), (acc_b, eur, -80, 1)]).await;
684

            
685
    insert_price(user, split_ids[0], eur, usd, d, 11, 10).await;
686
    insert_price(user, split_ids[1], eur, usd, d, 11, 10).await;
687

            
688
    let from = DateTime::<Utc>::from_timestamp(1699000000, 0).unwrap();
689
    let to = DateTime::<Utc>::from_timestamp(1701000000, 0).unwrap();
690

            
691
    let report = extract_report(
692
        TrialBalance::new()
693
            .user_id(user.id)
694
            .date_from(from)
695
            .date_to(to)
696
            .target_commodity_id(usd)
697
            .run()
698
            .await
699
            .unwrap(),
700
    );
701

            
702
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
703
    assert_eq!(node_a.amounts.len(), 1);
704
    // 100 USD + 80 EUR * 1.1 = 100 + 88 = 188 USD
705
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(188, 1)));
706
}
707

            
708
#[local_db_sqlx_test]
709
async fn test_trial_balance_with_filter(pool: PgPool) {
710
    let user = USER.get().unwrap();
711
    user.commit().await.expect("Failed to commit user");
712

            
713
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
714
    let eur = create_commodity(user.id, "EUR", "Euro").await;
715
    let acc_a = create_account(user.id, "Account A", None).await;
716
    let acc_b = create_account(user.id, "Account B", None).await;
717

            
718
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
719
    create_tx(
720
        user.id,
721
        d,
722
        vec![(acc_a, usd, 100, 1), (acc_b, usd, -100, 1)],
723
    )
724
    .await;
725
    create_tx(user.id, d, vec![(acc_a, eur, 50, 1), (acc_b, eur, -50, 1)]).await;
726

            
727
    let from = DateTime::<Utc>::from_timestamp(1699000000, 0).unwrap();
728
    let to = DateTime::<Utc>::from_timestamp(1701000000, 0).unwrap();
729

            
730
    let report = extract_report(
731
        TrialBalance::new()
732
            .user_id(user.id)
733
            .date_from(from)
734
            .date_to(to)
735
            .report_filter(ReportFilter::CommodityEq(usd))
736
            .run()
737
            .await
738
            .unwrap(),
739
    );
740

            
741
    let node_a = find_node(&report.periods[0].roots, acc_a).unwrap();
742
    assert_eq!(node_a.amounts.len(), 1);
743
    assert_eq!(node_amount(node_a, usd), Some(Rational64::new(100, 1)));
744
    assert!(node_amount(node_a, eur).is_none());
745
}
746

            
747
// --- Hierarchy rollup test ---
748

            
749
#[local_db_sqlx_test]
750
async fn test_balance_report_hierarchy_rollup(pool: PgPool) {
751
    let user = USER.get().unwrap();
752
    user.commit().await.expect("Failed to commit user");
753

            
754
    let usd = create_commodity(user.id, "USD", "US Dollar").await;
755
    let assets = create_account(user.id, "Assets", None).await;
756
    let bank = create_account(user.id, "Bank", Some(assets)).await;
757
    let checking = create_account(user.id, "Checking", Some(bank)).await;
758
    let savings = create_account(user.id, "Savings", Some(bank)).await;
759
    let other = create_account(user.id, "Other", None).await;
760

            
761
    let d = DateTime::<Utc>::from_timestamp(1700000000, 0).unwrap();
762
    create_tx(
763
        user.id,
764
        d,
765
        vec![(checking, usd, 300, 1), (other, usd, -300, 1)],
766
    )
767
    .await;
768
    create_tx(
769
        user.id,
770
        d,
771
        vec![(savings, usd, 700, 1), (other, usd, -700, 1)],
772
    )
773
    .await;
774

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

            
777
    let checking_node = find_node(&report.periods[0].roots, checking).unwrap();
778
    assert_eq!(
779
        node_amount(checking_node, usd),
780
        Some(Rational64::new(300, 1))
781
    );
782
    assert_eq!(checking_node.account_path, "Assets:Bank:Checking");
783
    assert_eq!(checking_node.depth, 2);
784

            
785
    let savings_node = find_node(&report.periods[0].roots, savings).unwrap();
786
    assert_eq!(
787
        node_amount(savings_node, usd),
788
        Some(Rational64::new(700, 1))
789
    );
790

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

            
795
    // Assets should roll up: same as bank = 1000
796
    let assets_node = find_node(&report.periods[0].roots, assets).unwrap();
797
    assert_eq!(
798
        node_amount(assets_node, usd),
799
        Some(Rational64::new(1000, 1))
800
    );
801
}