datasynth-group 5.36.0

Group audit simulation engine for multi-entity consolidation — manifest / shard / aggregate three-phase model with IFRS / IAS 21 / IAS 28 / IFRS 10 compliance
Documentation
//! Task 8.6 — notes to consolidated FS integration tests.

use std::collections::BTreeMap;

use chrono::NaiveDate;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;

use datasynth_group::aggregate::ic_matcher::{IcMatchResult, UnmatchedSide};
use datasynth_group::manifest::builder::GroupManifest;
use datasynth_group::{
    build_coverage_report, build_manifest, build_notes_to_consolidated_fs, CtaRollforward,
    EquityMethodInvestment, GroupConfig, IcRelationshipConfig, NciRollforward, NotesInputs,
};

use datasynth_standards::framework::AccountingFramework;

fn period_end() -> NaiveDate {
    NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
}

/// Build a trimmed two-entity manifest from `mini_acme.yaml`.
fn load_two_entity_manifest() -> GroupManifest {
    let yaml = include_str!("fixtures/mini_acme.yaml");
    let mut cfg: GroupConfig = serde_yaml::from_str(yaml).expect("mini_acme.yaml must parse");

    cfg.ownership
        .entities
        .retain(|e| e.code == "ACME_SA" || e.code == "ACME_USA");
    cfg.intercompany.relationships.retain(|rel| match rel {
        IcRelationshipConfig::Explicit(e) => {
            (e.seller == "ACME_SA" || e.seller == "ACME_USA")
                && (e.buyer == "ACME_SA" || e.buyer == "ACME_USA")
        }
        IcRelationshipConfig::Pattern(_) => true,
    });
    if let Some(p2) = cfg.tax.pillar_two.as_mut() {
        p2.jurisdictions.retain(|j| j == "CH" || j == "US");
    }
    if let Some(tp) = cfg.tax.transfer_pricing.as_mut() {
        tp.local_files_for.retain(|j| j == "CH" || j == "US");
    }
    build_manifest(&cfg).expect("trimmed manifest must build")
}

fn empty_coverage() -> datasynth_group::CoverageReport {
    let result = IcMatchResult {
        matched: Vec::new(),
        unmatched: Vec::<UnmatchedSide>::new(),
        total_planned: 0,
        coverage: 0.0,
    };
    build_coverage_report(&result)
}

fn nci_rf(entity: &str, opening: Decimal, share: Decimal, closing: Decimal) -> NciRollforward {
    NciRollforward {
        entity_code: entity.to_string(),
        parent_entity_code: "ACME_SA".to_string(),
        ownership_percent: dec!(0.80),
        nci_percent: dec!(0.20),
        opening_nci: opening,
        nci_share_of_profit: share,
        nci_share_of_oci: Decimal::ZERO,
        nci_dividends: Decimal::ZERO,
        equity_transaction_adjustments: Decimal::ZERO,
        pl_remeasurement_gain_or_loss: Decimal::ZERO,
        closing_nci: closing,
        period_end: period_end(),
        currency: "CHF".to_string(),
    }
}

fn cta_rf(entity: &str, opening: Decimal, period: Decimal) -> CtaRollforward {
    CtaRollforward {
        entity_code: entity.to_string(),
        functional_currency: "USD".to_string(),
        presentation_currency: "CHF".to_string(),
        opening_cta: opening,
        period_cta: period,
        closing_cta: opening + period,
    }
}

fn em_inv(investee: &str, closing: Decimal, share: Decimal) -> EquityMethodInvestment {
    EquityMethodInvestment {
        investee_code: investee.to_string(),
        investor_entity_code: "ACME_SA".to_string(),
        ownership_percent: dec!(0.30),
        opening_carrying_value: Decimal::ZERO,
        opening_suppressed_loss: Decimal::ZERO,
        share_of_profit: share,
        share_of_profit_recognised: share,
        dividends_received: Decimal::ZERO,
        impairment: Decimal::ZERO,
        suppressed_loss_this_period: Decimal::ZERO,
        closing_suppressed_loss: Decimal::ZERO,
        closing_carrying_value: closing,
        period_end: period_end(),
        currency: "CHF".to_string(),
    }
}

#[test]
fn always_emits_8_notes() {
    let manifest = load_two_entity_manifest();
    let coverage = empty_coverage();
    let inputs = NotesInputs {
        manifest: &manifest,
        framework: AccountingFramework::Ifrs,
        ic_coverage: &coverage,
        nci_rollforwards: &[],
        cta_rollforwards: &[],
        equity_method_investments: &[],
    };

    let notes = build_notes_to_consolidated_fs(&inputs, period_end());
    assert_eq!(notes.notes.len(), 8, "must always emit 8 notes");

    let numbers: Vec<u32> = notes.notes.iter().map(|n| n.note_number).collect();
    assert_eq!(numbers, vec![1, 2, 3, 4, 5, 6, 7, 8]);
}

#[test]
fn nci_summary_lists_each_rollforward() {
    let manifest = load_two_entity_manifest();
    let coverage = empty_coverage();
    let nci = vec![
        nci_rf("SUB1", dec!(1000), dec!(200), dec!(1200)),
        nci_rf("SUB2", dec!(500), dec!(100), dec!(600)),
    ];
    let inputs = NotesInputs {
        manifest: &manifest,
        framework: AccountingFramework::Ifrs,
        ic_coverage: &coverage,
        nci_rollforwards: &nci,
        cta_rollforwards: &[],
        equity_method_investments: &[],
    };
    let notes = build_notes_to_consolidated_fs(&inputs, period_end());
    let nci_note = &notes.notes[3]; // note 4
    assert_eq!(nci_note.note_number, 4);
    assert!(
        nci_note.body.contains("SUB1"),
        "NCI note must list SUB1: {}",
        nci_note.body,
    );
    assert!(
        nci_note.body.contains("SUB2"),
        "NCI note must list SUB2: {}",
        nci_note.body,
    );
    assert!(nci_note.body.contains("1200"));
    assert!(nci_note.body.contains("600"));
}

#[test]
fn cta_summary_lists_each_rollforward() {
    let manifest = load_two_entity_manifest();
    let coverage = empty_coverage();
    let cta = vec![
        cta_rf("ENT_USA", Decimal::ZERO, dec!(1234)),
        cta_rf("ENT_BR", dec!(500), dec!(-250)),
    ];
    let inputs = NotesInputs {
        manifest: &manifest,
        framework: AccountingFramework::Ifrs,
        ic_coverage: &coverage,
        nci_rollforwards: &[],
        cta_rollforwards: &cta,
        equity_method_investments: &[],
    };
    let notes = build_notes_to_consolidated_fs(&inputs, period_end());
    let cta_note = &notes.notes[4]; // note 5
    assert_eq!(cta_note.note_number, 5);
    assert!(
        cta_note.body.contains("ENT_USA"),
        "CTA note must list ENT_USA"
    );
    assert!(
        cta_note.body.contains("ENT_BR"),
        "CTA note must list ENT_BR"
    );
    assert!(cta_note.body.contains("1234"));
}

#[test]
fn note_6_emits_geographic_segmentation_not_placeholder() {
    // v5.1: Note 6 (operating segments) is no longer a "deferred"
    // placeholder.  It now lists per-country entity counts derived
    // from the manifest's ownership graph (IFRS 8.13 entity-wide
    // geographic disclosure).
    let manifest = load_two_entity_manifest();
    let coverage = empty_coverage();
    let inputs = NotesInputs {
        manifest: &manifest,
        framework: AccountingFramework::Ifrs,
        ic_coverage: &coverage,
        nci_rollforwards: &[],
        cta_rollforwards: &[],
        equity_method_investments: &[],
    };
    let notes = build_notes_to_consolidated_fs(&inputs, period_end());
    let note_6 = notes
        .notes
        .iter()
        .find(|n| n.note_number == 6)
        .expect("note 6 must exist");

    // Body must reference IFRS 8 geographic basis and contain the
    // entity / country totals.
    assert!(
        note_6.body.contains("IFRS 8.13"),
        "note 6 must cite IFRS 8.13 (entity-wide geographic disclosure), got:\n{}",
        note_6.body
    );
    assert!(
        note_6.body.contains("consolidates"),
        "note 6 must summarise the entity count, got:\n{}",
        note_6.body
    );
    // Mini-Acme fixture has 2 entities — make sure both appear.
    let entity_count = manifest.ownership_graph.entities.len();
    assert!(
        note_6.body.contains(&format!("{}", entity_count)),
        "note 6 must include the total entity count ({}), got:\n{}",
        entity_count,
        note_6.body
    );

    // Critically: must NOT contain the v5.0 placeholder string.
    assert!(
        !note_6.body.contains("deferred to v5.1"),
        "note 6 must no longer carry the placeholder string"
    );
}

#[test]
fn determinism_two_calls_match() {
    let manifest = load_two_entity_manifest();
    let coverage = empty_coverage();
    let nci = vec![nci_rf("SUB", dec!(1000), dec!(200), dec!(1200))];
    let cta = vec![cta_rf("ENT", Decimal::ZERO, dec!(500))];
    let em = vec![em_inv("ASSOC", dec!(50_000), dec!(10_000))];

    let make_notes = || {
        let inputs = NotesInputs {
            manifest: &manifest,
            framework: AccountingFramework::Ifrs,
            ic_coverage: &coverage,
            nci_rollforwards: &nci,
            cta_rollforwards: &cta,
            equity_method_investments: &em,
        };
        build_notes_to_consolidated_fs(&inputs, period_end())
    };
    let a = make_notes();
    let b = make_notes();
    assert_eq!(a, b, "two calls must produce equal notes");

    // And byte-identical JSON.
    let json_a = serde_json::to_string_pretty(&a).unwrap();
    let json_b = serde_json::to_string_pretty(&b).unwrap();
    assert_eq!(json_a, json_b, "JSON must be byte-identical");

    // Silence unused for placeholder vars.
    let _ = BTreeMap::<String, Decimal>::new();
}