From b029d7aa66ebbddb7707a83623c1ccd2ed55fb46 Mon Sep 17 00:00:00 2001 From: Aaron Allen Date: Sun, 16 Aug 2026 14:18:00 -0500 Subject: [PATCH] fix(wallet): join a trade to its journal twin on the context back-link Pod linked a transaction to its journal entry through the transaction's journal_ref_id. For a trade that settles at once that field holds the id of the trade's own market_transaction journal row, but for a sell-order fill ESI fills it with an id two past that row, on an id the wallet never receives. The join found nothing, the assignment was dropped, and the chip stayed amber. The same skew explains the transaction row disagreeing with its journal twin: assigning from the journal row keys on the row's real id, while the transaction row read through the bad ref. Both paths now resolve the id the same way, off the journal row's own context_id back-link, which is sound for every trade; journal_ref_id remains the fallback for a trade whose twin has not synced yet. The owner axis is unchanged, since read and write already agreed there. A write the ownership guard drops now logs instead of vanishing, which is what let this hide for a release. Closes #68 Closes #65 --- src/features/wallet.rs | 100 ++++++++++++++++++++- src/features/wallet/budget_engine.rs | 74 ++++++++++++++++ src/store/repo/budget.rs | 125 +++++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 4 deletions(-) diff --git a/src/features/wallet.rs b/src/features/wallet.rs index f09d5d7d..af52cdef 100644 --- a/src/features/wallet.rs +++ b/src/features/wallet.rs @@ -703,7 +703,8 @@ impl State { .iter() .find(|e| e.owner == owner && e.transaction_id == entry_id)?; let journal_owner = market_journal_owner(&self.market, entry); - resolution.resolve_market_target(journal_owner, entry.journal_ref_id, &entry.match_target()) + let journal_id = resolution.market_journal_id(journal_owner, entry.transaction_id, entry.journal_ref_id); + resolution.resolve_market_target(journal_owner, journal_id, &entry.match_target()) } } } @@ -787,7 +788,8 @@ impl State { } for entry in &self.market { let journal_owner = market_journal_owner(&self.market, entry); - if let Some(category) = resolution.journal_overrides.get(&(journal_owner, entry.journal_ref_id)) { + let journal_id = resolution.market_journal_id(journal_owner, entry.transaction_id, entry.journal_ref_id); + if let Some(category) = resolution.journal_overrides.get(&(journal_owner, journal_id)) { map.insert(index, *category); } index += 1; @@ -3014,7 +3016,13 @@ fn ledger_journal_entry( .market .iter() .find(|entry| entry.owner == owner && entry.transaction_id == entry_id)?; - Some((market_journal_owner(&state.market, entry), entry.journal_ref_id)) + let journal_owner = market_journal_owner(&state.market, entry); + let journal_id = + state + .budget_chips + .resolution + .market_journal_id(journal_owner, entry.transaction_id, entry.journal_ref_id); + Some((journal_owner, journal_id)) } } } @@ -3083,9 +3091,12 @@ fn market_budget_match( return false; } let journal_owner = market_journal_owner(market, entry); + let journal_id = chips + .resolution + .market_journal_id(journal_owner, entry.transaction_id, entry.journal_ref_id); let assigned = chips .resolution - .resolve_market_target(journal_owner, entry.journal_ref_id, &entry.match_target()); + .resolve_market_target(journal_owner, journal_id, &entry.match_target()); match filter.kind { BudgetFilterKind::Category(id) => assigned == Some(id), BudgetFilterKind::Uncategorized => assigned.is_none(), @@ -3577,6 +3588,7 @@ mod tests { meta: std::collections::HashMap::new(), resolution: crate::features::wallet::budget_engine::ResolutionContext { journal_overrides, + market_journal_twins: std::collections::HashMap::new(), rules: Vec::new(), }, } @@ -3747,6 +3759,86 @@ mod tests { } } + mod budget_category_for { + use pretty_assertions::assert_eq; + + use super::*; + + const CATEGORY: i64 = 42; + /// A sell-order fill: ESI reports a `journal_ref_id` two past the trade's real journal row. + const JOURNAL_REF_ID: i64 = 25_846_397_521; + const REAL_JOURNAL_ID: i64 = 25_846_397_519; + const TRANSACTION_ID: i64 = 6_841_903_031; + + fn state_with_a_sell_order_fill(assigned_journal_id: i64) -> State { + let mut state = State::new(crate::config::FeatureFlags::default()); + + let mut journal = journal_entry(1, Some(100.0), "market_transaction", "Trade"); + journal.id = REAL_JOURNAL_ID; + journal.context_id = Some(TRANSACTION_ID); + state.journal = vec![journal]; + + let mut market = market_entry(1, false, "Large Skill Injector", "Jita"); + market.transaction_id = TRANSACTION_ID; + market.journal_ref_id = JOURNAL_REF_ID; + state.market = vec![market]; + + state.budget_chips = loaders::BudgetChips { + envelopes: Vec::new(), + meta: std::collections::HashMap::new(), + resolution: crate::features::wallet::budget_engine::ResolutionContext { + journal_overrides: std::collections::HashMap::from([( + (BudgetOwner::Character(1), assigned_journal_id), + CATEGORY, + )]), + market_journal_twins: std::collections::HashMap::from([( + (BudgetOwner::Character(1), TRANSACTION_ID), + REAL_JOURNAL_ID, + )]), + rules: Vec::new(), + }, + }; + state + } + + #[test] + fn it_shows_the_transaction_the_category_its_journal_twin_carries() { + let state = state_with_a_sell_order_fill(REAL_JOURNAL_ID); + + let journal = state.budget_category_for(BudgetOwner::Character(1), LedgerKind::Journal, REAL_JOURNAL_ID); + let market = state.budget_category_for(BudgetOwner::Character(1), LedgerKind::Market, TRANSACTION_ID); + + assert_eq!(journal, Some(CATEGORY)); + assert_eq!( + market, journal, + "both rows describe one trade, so one assignment covers both tabs" + ); + } + + #[test] + fn it_writes_the_assignment_under_the_id_the_chip_reads() { + let mut state = state_with_a_sell_order_fill(REAL_JOURNAL_ID); + state.budget_picker = Some((BudgetOwner::Character(1), LedgerKind::Market, TRANSACTION_ID)); + + let written = ledger_journal_entry(&state, BudgetOwner::Character(1), LedgerKind::Market, TRANSACTION_ID); + + assert_eq!( + written, + Some((BudgetOwner::Character(1), REAL_JOURNAL_ID)), + "the upsert keys on the journal row the wallet actually holds" + ); + } + + #[test] + fn it_ignores_a_category_filed_under_the_skewed_ref_id() { + let state = state_with_a_sell_order_fill(JOURNAL_REF_ID); + + let market = state.budget_category_for(BudgetOwner::Character(1), LedgerKind::Market, TRANSACTION_ID); + + assert_eq!(market, None); + } + } + mod budget_drill_tab { use pretty_assertions::assert_eq; diff --git a/src/features/wallet/budget_engine.rs b/src/features/wallet/budget_engine.rs index d1967cf4..3ac55d05 100644 --- a/src/features/wallet/budget_engine.rs +++ b/src/features/wallet/budget_engine.rs @@ -189,7 +189,17 @@ pub async fn assign_entry( entry_id: i64, category_id: i64, ) -> Result, Error> { + // The guard keeps an assignment from landing under an owner that does not hold the entry. It is + // load-bearing, but a silent drop here once hid a broken journal join for a whole release, so + // say so. if !crate::store::repo::budget::owner_holds_entry(db, owner, entry_id).await? { + tracing::warn!( + target: "pod::ui::wallet", + owner = ?owner, + entry_id, + category_id, + "dropped a budget assignment: the owner holds no journal entry with that id", + ); return Ok(None); } seed_scope(db).await?; @@ -201,6 +211,7 @@ pub async fn assign_entry( #[derive(Clone, Debug, Default)] pub struct ResolutionContext { pub journal_overrides: HashMap<(BudgetOwner, i64), i64>, + pub market_journal_twins: HashMap<(BudgetOwner, i64), i64>, pub rules: Vec, } @@ -218,10 +229,26 @@ impl ResolutionContext { Self { journal_overrides, + market_journal_twins: crate::store::repo::budget::market_journal_twins(db) + .await + .unwrap_or_default(), rules: crate::store::repo::budget::list_rules(db).await.unwrap_or_default(), } } + /// The journal entry id a trade's budget assignment is keyed on. + /// + /// Both the chip the row reads and the upsert the picker writes must agree on this id, so both + /// go through here. It prefers the journal row's `context_id` back-link and falls back to the + /// transaction's `journal_ref_id` only for a trade whose journal twin has not synced yet. + pub fn market_journal_id(&self, owner: BudgetOwner, transaction_id: i64, journal_ref_id: i64) -> i64 { + self + .market_journal_twins + .get(&(owner, transaction_id)) + .copied() + .unwrap_or(journal_ref_id) + } + pub fn resolve_target(&self, entry_id: i64, target: &MatchTarget) -> Option { let owner = target.owner?; if let Some(id) = self.override_for(owner, entry_id) { @@ -1637,6 +1664,7 @@ mod tests { journal_overrides.insert((owner, 100_i64), 10_i64); let context = ResolutionContext { journal_overrides, + market_journal_twins: HashMap::new(), rules, }; @@ -1646,6 +1674,52 @@ mod tests { } } + mod market_journal_id { + use pretty_assertions::assert_eq; + + use super::*; + + fn context(twins: &[((BudgetOwner, i64), i64)]) -> ResolutionContext { + ResolutionContext { + journal_overrides: HashMap::new(), + market_journal_twins: twins.iter().copied().collect(), + rules: Vec::new(), + } + } + + #[test] + fn it_prefers_the_journal_twin_over_the_skewed_journal_ref_id() { + let owner = BudgetOwner::Character(1); + let context = context(&[((owner, 6_841_903_031), 25_846_397_519)]); + + let id = context.market_journal_id(owner, 6_841_903_031, 25_846_397_521); + + assert_eq!( + id, 25_846_397_519, + "a sell-order fill keys on its real journal row, not the id two past it" + ); + } + + #[test] + fn it_falls_back_to_the_journal_ref_id_when_no_twin_has_synced() { + let owner = BudgetOwner::Character(1); + + let id = context(&[]).market_journal_id(owner, 500, 123); + + assert_eq!(id, 123); + } + + #[test] + fn it_keys_a_dual_wallet_trade_on_the_owner_it_is_asked_for() { + let pilot = BudgetOwner::Character(1); + let corp = BudgetOwner::Corporation(1); + let context = context(&[((pilot, 500), 100), ((corp, 500), 200)]); + + assert_eq!(context.market_journal_id(pilot, 500, 0), 100); + assert_eq!(context.market_journal_id(corp, 500, 0), 200); + } + } + mod match_count { use pretty_assertions::assert_eq; diff --git a/src/store/repo/budget.rs b/src/store/repo/budget.rs index 4acca071..60301a00 100644 --- a/src/store/repo/budget.rs +++ b/src/store/repo/budget.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::store::{ Database, Error, model::{ @@ -227,6 +229,34 @@ pub async fn future_assigned_total(db: &Database, month: &str) -> Result Result, Error> { + let rows = sqlx::query_as::<_, (String, i64, i64, i64)>( + "SELECT 'character' AS owner_kind, character_id AS owner_id, context_id, id \ + FROM character_wallet_journal \ + WHERE ref_type = 'market_transaction' AND context_id IS NOT NULL \ + UNION ALL \ + SELECT 'corporation', corporation_id, context_id, id \ + FROM corporation_wallet_journal \ + WHERE ref_type = 'market_transaction' AND context_id IS NOT NULL", + ) + .fetch_all(&db.0) + .await?; + + let mut twins = HashMap::new(); + for (owner_kind, owner_id, transaction_id, journal_id) in rows { + if let Some(owner) = BudgetOwner::from_key(&owner_kind, owner_id) { + twins.insert((owner, transaction_id), journal_id); + } + } + Ok(twins) +} + pub async fn owner_holds_entry(db: &Database, owner: BudgetOwner, entry_id: i64) -> Result { let query = match owner { BudgetOwner::Character(_) => { @@ -754,6 +784,101 @@ mod tests { } } + mod market_journal_twins { + use pretty_assertions::assert_eq; + + use super::*; + + async fn disable_foreign_keys(db: &Database) { + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(db.writer()) + .await + .unwrap(); + } + + async fn seed_character_journal(db: &Database, character_id: i64, rows: &[(i64, &str, i64)]) { + disable_foreign_keys(db).await; + for &(id, ref_type, context_id) in rows { + sqlx::query( + "INSERT INTO character_wallet_journal \ + (character_id, id, amount, date, description, ref_type, context_id, context_id_type) \ + VALUES (?, ?, 1.0, '2026-07-24T00:00:00Z', 'Trade', ?, ?, 'market_transaction_id')", + ) + .bind(character_id) + .bind(id) + .bind(ref_type) + .bind(context_id) + .execute(db.writer()) + .await + .unwrap(); + } + } + + async fn seed_corporation_journal(db: &Database, corporation_id: i64, rows: &[(i64, &str, i64)]) { + disable_foreign_keys(db).await; + for &(id, ref_type, context_id) in rows { + sqlx::query( + "INSERT INTO corporation_wallet_journal \ + (corporation_id, division, id, amount, date, description, ref_type, context_id, context_id_type) \ + VALUES (?, 1, ?, 1.0, '2026-07-24T00:00:00Z', 'Trade', ?, ?, 'market_transaction_id')", + ) + .bind(corporation_id) + .bind(id) + .bind(ref_type) + .bind(context_id) + .execute(db.writer()) + .await + .unwrap(); + } + } + + #[tokio::test] + async fn it_joins_a_trade_to_its_journal_row_through_the_context_back_link() { + let db = store::open_test().await.unwrap(); + seed_character_journal(&db, 1, &[(25_846_397_519, "market_transaction", 6_841_903_031)]).await; + + let twins = market_journal_twins(&db).await.unwrap(); + + assert_eq!( + twins.get(&(BudgetOwner::Character(1), 6_841_903_031)).copied(), + Some(25_846_397_519), + "a sell-order fill joins on the journal row's own back-link, not the skewed journal_ref_id" + ); + } + + #[tokio::test] + async fn it_keys_the_two_owners_of_a_dual_wallet_trade_apart() { + let db = store::open_test().await.unwrap(); + seed_character_journal(&db, 1, &[(100, "market_transaction", 500)]).await; + seed_corporation_journal(&db, 1, &[(200, "market_transaction", 500)]).await; + + let twins = market_journal_twins(&db).await.unwrap(); + + assert_eq!(twins.get(&(BudgetOwner::Character(1), 500)).copied(), Some(100)); + assert_eq!(twins.get(&(BudgetOwner::Corporation(1), 500)).copied(), Some(200)); + } + + #[tokio::test] + async fn it_ignores_the_fee_and_tax_legs_of_a_trade() { + let db = store::open_test().await.unwrap(); + seed_character_journal( + &db, + 1, + &[ + (100, "market_transaction", 500), + (101, "transaction_tax", 500), + (102, "brokers_fee", 500), + ], + ) + .await; + + let twins = market_journal_twins(&db).await.unwrap(); + + assert_eq!(twins.len(), 1); + assert_eq!(twins.get(&(BudgetOwner::Character(1), 500)).copied(), Some(100)); + } + } + mod upsert_entry_assignment { use pretty_assertions::assert_eq; -- knotmirror 0.0.1