From 73c3d1951f29e7fbaf1827cf20f57e264c8f9f63 Mon Sep 17 00:00:00 2001 From: bodymindarts Date: Tue, 2 Jul 2024 10:33:43 +0200 Subject: [PATCH] chore: balance_as_of --- ...88dfc202a2ac8d47b189d2228ac8c89bdffca.json | 60 ++++++++++++++ .../20231208110808_cala_ledger_setup.sql | 1 + cala-ledger/src/balance/account_balance.rs | 19 +++++ cala-ledger/src/balance/mod.rs | 20 +++++ cala-ledger/src/balance/repo.rs | 80 +++++++++++++++++++ ...88dfc202a2ac8d47b189d2228ac8c89bdffca.json | 60 ++++++++++++++ cala-server/schema.graphql | 2 + cala-server/src/graphql/account.rs | 27 +++++++ cala-server/src/graphql/primitives.rs | 5 ++ cala-server/src/graphql/schema.rs | 28 +++++++ 10 files changed, 302 insertions(+) create mode 100644 cala-ledger/.sqlx/query-97693c4bf7fc0e7b3dd6349f0df88dfc202a2ac8d47b189d2228ac8c89bdffca.json create mode 100644 cala-server/.sqlx/query-97693c4bf7fc0e7b3dd6349f0df88dfc202a2ac8d47b189d2228ac8c89bdffca.json diff --git a/cala-ledger/.sqlx/query-97693c4bf7fc0e7b3dd6349f0df88dfc202a2ac8d47b189d2228ac8c89bdffca.json b/cala-ledger/.sqlx/query-97693c4bf7fc0e7b3dd6349f0df88dfc202a2ac8d47b189d2228ac8c89bdffca.json new file mode 100644 index 00000000..2160d4f2 --- /dev/null +++ b/cala-ledger/.sqlx/query-97693c4bf7fc0e7b3dd6349f0df88dfc202a2ac8d47b189d2228ac8c89bdffca.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH last_before_as_of AS (\n SELECT\n true AS last_before, false AS up_until, h.values,\n a.normal_balance_type AS \"normal_balance_type!: DebitOrCredit\", h.recorded_at\n FROM cala_balance_history h\n JOIN cala_accounts a\n ON h.data_source_id = a.data_source_id\n AND h.account_id = a.id\n WHERE h.data_source_id = '00000000-0000-0000-0000-000000000000'\n AND h.journal_id = $1\n AND h.account_id = $2\n AND h.currency = $3\n AND h.recorded_at < $4\n ORDER BY h.recorded_at DESC\n LIMIT 1\n ),\n last_before_or_equal_up_until AS (\n SELECT \n false AS last_before, true AS up_until, h.values,\n a.normal_balance_type AS \"normal_balance_type!: DebitOrCredit\", h.recorded_at\n FROM cala_balance_history h\n JOIN cala_accounts a\n ON h.data_source_id = a.data_source_id\n AND h.account_id = a.id\n WHERE h.data_source_id = '00000000-0000-0000-0000-000000000000'\n AND h.journal_id = $1\n AND h.account_id = $2\n AND h.currency = $3\n AND h.recorded_at <= COALESCE($5, NOW())\n ORDER BY h.recorded_at DESC\n LIMIT 1\n )\n SELECT * FROM last_before_as_of\n UNION ALL\n SELECT * FROM last_before_or_equal_up_until\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "last_before", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "up_until", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "values", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "normal_balance_type!: DebitOrCredit", + "type_info": { + "Custom": { + "name": "debitorcredit", + "kind": { + "Enum": [ + "debit", + "credit" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "recorded_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + null, + null, + null, + null, + null + ] + }, + "hash": "97693c4bf7fc0e7b3dd6349f0df88dfc202a2ac8d47b189d2228ac8c89bdffca" +} diff --git a/cala-ledger/migrations/20231208110808_cala_ledger_setup.sql b/cala-ledger/migrations/20231208110808_cala_ledger_setup.sql index 7a173ab3..f579891d 100644 --- a/cala-ledger/migrations/20231208110808_cala_ledger_setup.sql +++ b/cala-ledger/migrations/20231208110808_cala_ledger_setup.sql @@ -190,6 +190,7 @@ CREATE TABLE cala_balance_history ( FOREIGN KEY (data_source_id, journal_id, account_id, currency) REFERENCES cala_current_balances(data_source_id, journal_id, account_id, currency), FOREIGN KEY (data_source_id, latest_entry_id) REFERENCES cala_entries(data_source_id, id) ); +CREATE INDEX idx_cala_balance_history_recorded_at ON cala_balance_history (recorded_at); CREATE TABLE cala_velocity_limits ( data_source_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000', diff --git a/cala-ledger/src/balance/account_balance.rs b/cala-ledger/src/balance/account_balance.rs index d2b72b48..9bc5f7b9 100644 --- a/cala-ledger/src/balance/account_balance.rs +++ b/cala-ledger/src/balance/account_balance.rs @@ -11,6 +11,25 @@ pub struct AccountBalance { } impl AccountBalance { + pub(super) fn derive_as_of(mut self, as_of: Self) -> Self { + self.details.settled = BalanceAmount { + dr_balance: self.details.settled.dr_balance - as_of.details.settled.dr_balance, + cr_balance: self.details.settled.cr_balance - as_of.details.settled.cr_balance, + ..self.details.settled + }; + self.details.pending = BalanceAmount { + dr_balance: self.details.pending.dr_balance - as_of.details.pending.dr_balance, + cr_balance: self.details.pending.cr_balance - as_of.details.pending.cr_balance, + ..self.details.pending + }; + self.details.encumbrance = BalanceAmount { + dr_balance: self.details.encumbrance.dr_balance - as_of.details.encumbrance.dr_balance, + cr_balance: self.details.encumbrance.cr_balance - as_of.details.encumbrance.cr_balance, + ..self.details.encumbrance + }; + self + } + pub fn pending(&self) -> Decimal { if self.balance_type == DebitOrCredit::Credit { self.details.pending.cr_balance - self.details.pending.dr_balance diff --git a/cala-ledger/src/balance/mod.rs b/cala-ledger/src/balance/mod.rs index 34db15ab..a675d123 100644 --- a/cala-ledger/src/balance/mod.rs +++ b/cala-ledger/src/balance/mod.rs @@ -64,6 +64,26 @@ impl Balances { .await } + #[instrument(name = "cala_ledger.balance.find_as_of", skip(self), err)] + pub async fn find_as_of( + &self, + journal_id: JournalId, + account_id: AccountId, + currency: Currency, + as_of: DateTime, + up_until: Option>, + ) -> Result { + match self + .repo + .find_as_of(journal_id, account_id, currency, as_of, up_until) + .await? + { + (Some(last_before), Some(up_until)) => Ok(up_until.derive_as_of(last_before)), + (None, Some(up_until)) => Ok(up_until), + _ => Err(BalanceError::NotFound(journal_id, account_id, currency)), + } + } + #[instrument(name = "cala_ledger.balance.find_all", skip(self), err)] pub async fn find_all( &self, diff --git a/cala-ledger/src/balance/repo.rs b/cala-ledger/src/balance/repo.rs index 0ffcb262..a7356d74 100644 --- a/cala-ledger/src/balance/repo.rs +++ b/cala-ledger/src/balance/repo.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use sqlx::{Executor, PgPool, Postgres, QueryBuilder, Row, Transaction}; use tracing::instrument; @@ -86,6 +87,85 @@ impl BalanceRepo { } } + pub(super) async fn find_as_of( + &self, + journal_id: JournalId, + account_id: AccountId, + currency: Currency, + as_of: DateTime, + up_until: Option>, + ) -> Result<(Option, Option), BalanceError> { + let rows = sqlx::query!( + r#" + WITH last_before_as_of AS ( + SELECT + true AS last_before, false AS up_until, h.values, + a.normal_balance_type AS "normal_balance_type!: DebitOrCredit", h.recorded_at + FROM cala_balance_history h + JOIN cala_accounts a + ON h.data_source_id = a.data_source_id + AND h.account_id = a.id + WHERE h.data_source_id = '00000000-0000-0000-0000-000000000000' + AND h.journal_id = $1 + AND h.account_id = $2 + AND h.currency = $3 + AND h.recorded_at < $4 + ORDER BY h.recorded_at DESC + LIMIT 1 + ), + last_before_or_equal_up_until AS ( + SELECT + false AS last_before, true AS up_until, h.values, + a.normal_balance_type AS "normal_balance_type!: DebitOrCredit", h.recorded_at + FROM cala_balance_history h + JOIN cala_accounts a + ON h.data_source_id = a.data_source_id + AND h.account_id = a.id + WHERE h.data_source_id = '00000000-0000-0000-0000-000000000000' + AND h.journal_id = $1 + AND h.account_id = $2 + AND h.currency = $3 + AND h.recorded_at <= COALESCE($5, NOW()) + ORDER BY h.recorded_at DESC + LIMIT 1 + ) + SELECT * FROM last_before_as_of + UNION ALL + SELECT * FROM last_before_or_equal_up_until + "#, + journal_id as JournalId, + account_id as AccountId, + currency.code(), + as_of, + up_until, + ) + .fetch_all(&self.pool) + .await?; + + let mut last_before = None; + let mut up_until = None; + for row in rows { + if row.last_before.expect("last_before is not null") { + let details: BalanceSnapshot = + serde_json::from_value(row.values.expect("values is not null")) + .expect("Failed to deserialize balance snapshot"); + last_before = Some(AccountBalance { + balance_type: row.normal_balance_type, + details, + }); + } else { + let details: BalanceSnapshot = + serde_json::from_value(row.values.expect("values is not null")) + .expect("Failed to deserialize balance snapshot"); + up_until = Some(AccountBalance { + balance_type: row.normal_balance_type, + details, + }); + } + } + Ok((last_before, up_until)) + } + pub(super) async fn find_all( &self, ids: &[BalanceId], diff --git a/cala-server/.sqlx/query-97693c4bf7fc0e7b3dd6349f0df88dfc202a2ac8d47b189d2228ac8c89bdffca.json b/cala-server/.sqlx/query-97693c4bf7fc0e7b3dd6349f0df88dfc202a2ac8d47b189d2228ac8c89bdffca.json new file mode 100644 index 00000000..2160d4f2 --- /dev/null +++ b/cala-server/.sqlx/query-97693c4bf7fc0e7b3dd6349f0df88dfc202a2ac8d47b189d2228ac8c89bdffca.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH last_before_as_of AS (\n SELECT\n true AS last_before, false AS up_until, h.values,\n a.normal_balance_type AS \"normal_balance_type!: DebitOrCredit\", h.recorded_at\n FROM cala_balance_history h\n JOIN cala_accounts a\n ON h.data_source_id = a.data_source_id\n AND h.account_id = a.id\n WHERE h.data_source_id = '00000000-0000-0000-0000-000000000000'\n AND h.journal_id = $1\n AND h.account_id = $2\n AND h.currency = $3\n AND h.recorded_at < $4\n ORDER BY h.recorded_at DESC\n LIMIT 1\n ),\n last_before_or_equal_up_until AS (\n SELECT \n false AS last_before, true AS up_until, h.values,\n a.normal_balance_type AS \"normal_balance_type!: DebitOrCredit\", h.recorded_at\n FROM cala_balance_history h\n JOIN cala_accounts a\n ON h.data_source_id = a.data_source_id\n AND h.account_id = a.id\n WHERE h.data_source_id = '00000000-0000-0000-0000-000000000000'\n AND h.journal_id = $1\n AND h.account_id = $2\n AND h.currency = $3\n AND h.recorded_at <= COALESCE($5, NOW())\n ORDER BY h.recorded_at DESC\n LIMIT 1\n )\n SELECT * FROM last_before_as_of\n UNION ALL\n SELECT * FROM last_before_or_equal_up_until\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "last_before", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "up_until", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "values", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "normal_balance_type!: DebitOrCredit", + "type_info": { + "Custom": { + "name": "debitorcredit", + "kind": { + "Enum": [ + "debit", + "credit" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "recorded_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + null, + null, + null, + null, + null + ] + }, + "hash": "97693c4bf7fc0e7b3dd6349f0df88dfc202a2ac8d47b189d2228ac8c89bdffca" +} diff --git a/cala-server/schema.graphql b/cala-server/schema.graphql index 50ee0dae..9005b577 100644 --- a/cala-server/schema.graphql +++ b/cala-server/schema.graphql @@ -12,6 +12,7 @@ type Account { createdAt: Timestamp! modifiedAt: Timestamp! balance(journalId: UUID!, currency: CurrencyCode!): Balance + balanceAsOf(journalId: UUID!, currency: CurrencyCode!, asOf: Timestamp!, upUntil: Timestamp): Balance sets(first: Int!, after: String): AccountSetConnection! } @@ -388,6 +389,7 @@ type Query { accountSet(id: UUID!): AccountSet journal(id: UUID!): Journal balance(journalId: UUID!, accountId: UUID!, currency: CurrencyCode!): Balance + balanceAsOf(journalId: UUID!, accountId: UUID!, currency: CurrencyCode!, asOf: Timestamp!, upUntil: Timestamp): Balance transaction(id: UUID!): Transaction transactionByExternalId(externalId: String!): Transaction txTemplate(id: UUID!): TxTemplate diff --git a/cala-server/src/graphql/account.rs b/cala-server/src/graphql/account.rs index f12b0d8e..427d9c55 100644 --- a/cala-server/src/graphql/account.rs +++ b/cala-server/src/graphql/account.rs @@ -61,6 +61,33 @@ impl Account { Ok(balance.map(Balance::from)) } + async fn balance_as_of( + &self, + ctx: &Context<'_>, + journal_id: UUID, + currency: CurrencyCode, + as_of: Timestamp, + up_until: Option, + ) -> async_graphql::Result> { + let app = ctx.data_unchecked::(); + match app + .ledger() + .balances() + .find_as_of( + JournalId::from(journal_id), + AccountId::from(self.account_id), + Currency::from(currency), + as_of.into_inner(), + up_until.map(|ts| ts.into_inner()), + ) + .await + { + Ok(balance) => Ok(Some(balance.into())), + Err(cala_ledger::balance::error::BalanceError::NotFound(_, _, _)) => Ok(None), + Err(err) => Err(err.into()), + } + } + async fn sets( &self, ctx: &Context<'_>, diff --git a/cala-server/src/graphql/primitives.rs b/cala-server/src/graphql/primitives.rs index 47c062a0..ae093837 100644 --- a/cala-server/src/graphql/primitives.rs +++ b/cala-server/src/graphql/primitives.rs @@ -160,6 +160,11 @@ impl From for Date { Self(value) } } +impl From for NaiveDate { + fn from(value: Date) -> Self { + value.0 + } +} #[derive(Serialize, Deserialize)] #[serde(transparent)] diff --git a/cala-server/src/graphql/schema.rs b/cala-server/src/graphql/schema.rs index fffe88f0..39fb3a69 100644 --- a/cala-server/src/graphql/schema.rs +++ b/cala-server/src/graphql/schema.rs @@ -124,6 +124,34 @@ impl CoreQuery { Ok(balance.map(Balance::from)) } + async fn balance_as_of( + &self, + ctx: &Context<'_>, + journal_id: UUID, + account_id: UUID, + currency: CurrencyCode, + as_of: Timestamp, + up_until: Option, + ) -> async_graphql::Result> { + let app = ctx.data_unchecked::(); + match app + .ledger() + .balances() + .find_as_of( + JournalId::from(journal_id), + AccountId::from(account_id), + Currency::from(currency), + as_of.into_inner(), + up_until.map(|ts| ts.into_inner()), + ) + .await + { + Ok(balance) => Ok(Some(balance.into())), + Err(cala_ledger::balance::error::BalanceError::NotFound(_, _, _)) => Ok(None), + Err(err) => Err(err.into()), + } + } + async fn transaction( &self, ctx: &Context<'_>,