mod account_balance;
mod cursor;
mod effective;
pub mod error;
mod repo;
mod snapshot;
use chrono::{DateTime, NaiveDate, Utc};
use sqlx::PgPool;
use std::collections::{HashMap, HashSet};
use tracing::instrument;
pub use cala_types::{
balance::{BalanceAmount, BalanceSnapshot},
journal::JournalValues,
};
use cala_types::{entry::EntryValues, primitives::*};
use crate::{journal::Journals, outbox::*, primitives::JournalId};
pub use account_balance::*;
pub use cursor::*;
#[cfg(feature = "fuzz")]
pub use effective::fuzz_recalculate;
use effective::*;
use error::BalanceError;
use repo::*;
pub(crate) use snapshot::*;
pub(crate) struct EcRollupTxn {
pub journal_id: JournalId,
pub effective: NaiveDate,
pub created_at: DateTime<Utc>,
pub entries: Vec<EntryValues>,
}
#[derive(Clone)]
pub struct Balances {
repo: BalanceRepo,
journals: Journals,
effective: EffectiveBalances,
_pool: PgPool,
}
impl Balances {
pub(crate) fn new(pool: &PgPool, publisher: &OutboxPublisher, journals: &Journals) -> Self {
Self {
repo: BalanceRepo::new(pool),
effective: EffectiveBalances::new(pool, publisher),
journals: journals.clone(),
_pool: pool.clone(),
}
}
pub fn effective(&self) -> &EffectiveBalances {
&self.effective
}
#[instrument(level = "debug", name = "cala_ledger.balance.find", skip(self))]
pub async fn find(
&self,
journal_id: JournalId,
account_id: impl Into<AccountId> + std::fmt::Debug,
currency: Currency,
) -> Result<AccountBalance, BalanceError> {
self.repo
.find(journal_id, account_id.into(), currency)
.await
}
#[instrument(
level = "debug",
name = "cala_ledger.balance.find_in_op",
skip(self, op)
)]
pub async fn find_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
journal_id: JournalId,
account_id: impl Into<AccountId> + std::fmt::Debug,
currency: Currency,
) -> Result<AccountBalance, BalanceError> {
self.repo
.find_in_op(op, journal_id, account_id.into(), currency)
.await
}
#[instrument(level = "debug", name = "cala_ledger.balance.find_all", skip(self, ids), fields(ids_count = ids.len()))]
pub async fn find_all(
&self,
ids: &[BalanceId],
) -> Result<HashMap<BalanceId, AccountBalance>, BalanceError> {
self.repo.find_all(ids).await
}
#[instrument(
level = "debug",
name = "cala_ledger.balance.list_for_account",
skip(self)
)]
pub async fn list_for_account(
&self,
journal_id: JournalId,
account_id: impl Into<AccountId> + std::fmt::Debug,
args: es_entity::PaginatedQueryArgs<AccountBalanceByCurrencyCursor>,
) -> Result<
es_entity::PaginatedQueryRet<AccountBalance, AccountBalanceByCurrencyCursor>,
BalanceError,
> {
self.repo
.list_for_account(journal_id, account_id.into(), args)
.await
}
#[instrument(level = "debug", name = "cala_ledger.balance.list_for_accounts", skip(self, account_ids), fields(account_ids_count = account_ids.len()))]
pub async fn list_for_accounts(
&self,
journal_id: JournalId,
account_ids: &[AccountId],
args: es_entity::PaginatedQueryArgs<AccountBalanceCursor>,
) -> Result<es_entity::PaginatedQueryRet<AccountBalance, AccountBalanceCursor>, BalanceError>
{
self.repo
.list_for_accounts(journal_id, account_ids, args)
.await
}
#[instrument(level = "debug", name = "cala_ledger.balance.find_all_in_op", skip(self, op, ids), fields(ids_count = ids.len()))]
pub async fn find_all_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
ids: &[BalanceId],
) -> Result<HashMap<BalanceId, AccountBalance>, BalanceError> {
self.repo.find_all_in_op(op, ids).await
}
#[instrument(
level = "debug",
name = "cala_ledger.balance.list_for_account_in_op",
skip(self, op)
)]
pub async fn list_for_account_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
journal_id: JournalId,
account_id: impl Into<AccountId> + std::fmt::Debug,
args: es_entity::PaginatedQueryArgs<AccountBalanceByCurrencyCursor>,
) -> Result<
es_entity::PaginatedQueryRet<AccountBalance, AccountBalanceByCurrencyCursor>,
BalanceError,
> {
self.repo
.list_for_account_in_op(op, journal_id, account_id.into(), args)
.await
}
#[instrument(level = "debug", name = "cala_ledger.balance.list_for_accounts_in_op", skip(self, op, account_ids), fields(account_ids_count = account_ids.len()))]
pub async fn list_for_accounts_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
journal_id: JournalId,
account_ids: &[AccountId],
args: es_entity::PaginatedQueryArgs<AccountBalanceCursor>,
) -> Result<es_entity::PaginatedQueryRet<AccountBalance, AccountBalanceCursor>, BalanceError>
{
self.repo
.list_for_accounts_in_op(op, journal_id, account_ids, args)
.await
}
#[instrument(
level = "debug",
name = "cala_ledger.balance.update_balances_in_op",
skip(self, op, entries, account_set_mappings),
fields(journal_id = %journal_id, entries_count = entries.len()),
err(level = "warn")
)]
pub(crate) async fn update_balances_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
journal_id: JournalId,
entries: Vec<EntryValues>,
effective: NaiveDate,
created_at: DateTime<Utc>,
account_set_mappings: HashMap<AccountId, Vec<AccountSetId>>,
) -> Result<(), BalanceError> {
let journal = self.journals.find(journal_id).await?;
if journal.is_locked() {
return Err(BalanceError::JournalLocked(journal.id));
}
let mut all_involved_balances: HashSet<_> = HashSet::new();
let empty = Vec::new();
for entry in entries.iter() {
all_involved_balances.extend(
account_set_mappings
.get(&entry.account_id)
.unwrap_or(&empty)
.iter()
.map(AccountId::from)
.chain(std::iter::once(entry.account_id))
.map(|id| (id, entry.currency)),
);
}
let all_involved_balances: (Vec<_>, Vec<_>) = all_involved_balances
.into_iter()
.map(|(a, c)| (a, c.code()))
.unzip();
let current_balances = self
.repo
.find_for_update(op, journal.id, &all_involved_balances)
.await?;
let new_balances = Snapshots::from_entries(
created_at,
current_balances,
&entries,
&account_set_mappings,
);
self.repo
.insert_new_snapshots(op, journal.id, new_balances)
.await?;
if journal.insert_effective_balances() {
self.effective
.update_cumulative_balances_in_op(
op,
journal_id,
entries,
effective,
created_at,
account_set_mappings,
all_involved_balances,
)
.await?;
}
Ok(())
}
#[instrument(
level = "debug",
name = "cala_ledger.balance.lock_entry_balances_in_op",
skip(self, op, entries),
fields(count = entries.len()),
err(level = "warn")
)]
pub(crate) async fn lock_entry_balances_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
journal_id: JournalId,
entries: &[crate::entry::NewEntry],
) -> Result<(), BalanceError> {
let entry_balances: (Vec<AccountId>, Vec<&str>) = entries
.iter()
.map(|entry| (entry.account_id(), entry.currency().code()))
.collect::<HashSet<_>>()
.into_iter()
.unzip();
self.repo
.lock_entry_balances_in_op(op, journal_id, &entry_balances)
.await
}
#[instrument(
level = "debug",
name = "cala_ledger.balance.member_has_balance_history_in_op",
skip(self, op),
fields(
journal_id = %journal_id,
member_id = %member_id,
),
err(level = "warn")
)]
pub(crate) async fn member_has_balance_history_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
journal_id: JournalId,
member_id: AccountId,
) -> Result<bool, BalanceError> {
self.repo
.member_has_balance_history_in_op(op, journal_id, member_id)
.await
}
#[instrument(
level = "debug",
name = "cala_ledger.balance.members_with_balance_history_in_op",
skip(self, op, pairs),
fields(count = pairs.len()),
err(level = "warn")
)]
pub(crate) async fn members_with_balance_history_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
pairs: &[(JournalId, AccountId)],
) -> Result<Vec<AccountId>, BalanceError> {
self.repo
.members_with_balance_history_in_op(op, pairs)
.await
}
#[instrument(
level = "debug",
name = "cala_ledger.balance.apply_ec_rollup_in_op",
skip_all,
fields(txns_count = txns.len()),
err(level = "warn")
)]
pub(crate) async fn apply_ec_rollup_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
txns: Vec<EcRollupTxn>,
) -> Result<(), BalanceError> {
let mut groups: Vec<(JournalId, Vec<EcRollupTxn>)> = Vec::new();
for tx in txns {
match groups.iter_mut().find(|(j, _)| *j == tx.journal_id) {
Some((_, group)) => group.push(tx),
None => groups.push((tx.journal_id, vec![tx])),
}
}
for (journal_id, group) in groups {
self.apply_ec_rollup_group_in_op(op, journal_id, group)
.await?;
}
Ok(())
}
async fn apply_ec_rollup_group_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
journal_id: JournalId,
group: Vec<EcRollupTxn>,
) -> Result<(), BalanceError> {
let member_account_ids: Vec<AccountId> = group
.iter()
.flat_map(|tx| tx.entries.iter().map(|e| e.account_id))
.collect::<HashSet<_>>()
.into_iter()
.collect();
let ec_mappings = self
.repo
.fetch_ec_set_mappings(op, journal_id, &member_account_ids)
.await?;
let ec_leaves = self
.repo
.fetch_ec_leaf_accounts(op, &member_account_ids)
.await?;
if ec_mappings.is_empty() && ec_leaves.is_empty() {
return Ok(());
}
let empty = Vec::new();
let mut involved: HashSet<(AccountId, Currency)> = HashSet::new();
for entry in group.iter().flat_map(|tx| tx.entries.iter()) {
for set_id in ec_mappings.get(&entry.account_id).unwrap_or(&empty) {
involved.insert((AccountId::from(set_id), entry.currency));
}
if ec_leaves.contains(&entry.account_id) {
involved.insert((entry.account_id, entry.currency));
}
}
if involved.is_empty() {
return Ok(());
}
let (account_ids, currencies): (Vec<AccountId>, Vec<&str>) =
involved.into_iter().map(|(a, c)| (a, c.code())).unzip();
let mut current_balances = self
.repo
.find_ec_balances_for_update(op, journal_id, &(account_ids, currencies))
.await?;
let mut all_new = Vec::new();
for tx in group.iter() {
let new_balances = Snapshots::from_ec_entries(
tx.created_at,
current_balances.clone(),
&tx.entries,
&ec_mappings,
&ec_leaves,
);
for snapshot in new_balances.iter() {
current_balances.insert(
(snapshot.account_id, snapshot.currency),
Some(snapshot.clone()),
);
}
all_new.extend(new_balances);
}
if !all_new.is_empty() {
self.repo
.insert_new_snapshots(op, journal_id, all_new)
.await?;
}
let journal = self.journals.find(journal_id).await?;
if journal.insert_effective_balances() {
for tx in group {
let mut tx_involved: HashSet<(AccountId, Currency)> = HashSet::new();
for entry in tx.entries.iter() {
for set_id in ec_mappings.get(&entry.account_id).unwrap_or(&empty) {
tx_involved.insert((AccountId::from(set_id), entry.currency));
}
if ec_leaves.contains(&entry.account_id) {
tx_involved.insert((entry.account_id, entry.currency));
}
}
if tx_involved.is_empty() {
continue;
}
let (account_ids, currencies): (Vec<AccountId>, Vec<&str>) =
tx_involved.into_iter().map(|(a, c)| (a, c.code())).unzip();
self.effective
.apply_ec_rollup_in_op(
op,
journal_id,
tx.entries,
tx.effective,
tx.created_at,
ec_mappings.clone(),
(account_ids, currencies),
&ec_leaves,
)
.await?;
}
}
Ok(())
}
}