#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fmt::Write as _;
use diesel::sql_types::{BigInt, Bool, Text};
use diesel_async::RunQueryDsl as _;
use sha2::Digest as _;
use crate::db::RuntimeConnection;
use crate::money::{AnyMoney, CurrencyCode, MoneyError};
pub const ACCOUNTS_TABLE: &str = "_autumn_money_accounts";
pub const TRANSACTIONS_TABLE: &str = "_autumn_money_transactions";
pub const POSTINGS_TABLE: &str = "_autumn_money_postings";
const MAX_ACCOUNT_ID: usize = 255;
const MAX_IDEMPOTENCY_KEY: usize = 255;
const MAX_MEMO: usize = 1024;
const MAX_POSTINGS: usize = 1024;
const NOW: &str = "CURRENT_TIMESTAMP";
const SPLIT: i64 = 1_000_000;
fn split_sum(column: &str) -> String {
format!(
"CAST(COALESCE(SUM({column} / {SPLIT}), 0) AS BIGINT) AS hi, \
CAST(COALESCE(SUM({column} % {SPLIT}), 0) AS BIGINT) AS lo"
)
}
fn join_split(hi: i64, lo: i64) -> Result<i64, LedgerError> {
let total = i128::from(hi)
.checked_mul(i128::from(SPLIT))
.and_then(|high| high.checked_add(i128::from(lo)))
.ok_or(MoneyError::Overflow)?;
i64::try_from(total).map_err(|_| LedgerError::Money(MoneyError::Overflow))
}
#[cfg(not(feature = "sqlite"))]
fn ph(n: usize) -> String {
format!("${n}")
}
#[cfg(feature = "sqlite")]
fn ph(_n: usize) -> String {
"?".to_owned()
}
#[cfg(not(feature = "sqlite"))]
const FOR_UPDATE: &str = " FOR UPDATE";
#[cfg(feature = "sqlite")]
const FOR_UPDATE: &str = "";
#[derive(Debug)]
#[non_exhaustive]
pub enum LedgerError {
Money(MoneyError),
Unbalanced {
debits: i64,
credits: i64,
currency: &'static str,
},
PostingCount {
count: usize,
},
OneSided,
ZeroPosting {
account: String,
},
MixedCurrencies {
expected: &'static str,
found: &'static str,
},
UnknownAccount {
id: String,
},
AccountCurrency {
account: String,
expected: &'static str,
found: &'static str,
},
KeyReuse {
key: String,
},
NegativeAmount {
account: String,
},
InvalidText {
field: &'static str,
reason: &'static str,
},
NegativeBalance {
account: String,
balance: i64,
currency: &'static str,
},
NotInTransaction,
Conflict {
key: String,
},
EmptyTransaction {
id: String,
},
Database(diesel::result::Error),
}
impl LedgerError {
#[must_use]
pub const fn http_status(&self) -> axum::http::StatusCode {
use axum::http::StatusCode;
match self {
Self::Money(err) => err.http_status(),
Self::KeyReuse { .. } | Self::NegativeBalance { .. } | Self::Conflict { .. } => {
StatusCode::CONFLICT
}
Self::NotInTransaction | Self::EmptyTransaction { .. } | Self::Database(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
_ => StatusCode::UNPROCESSABLE_ENTITY,
}
}
}
impl fmt::Display for LedgerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Money(err) => write!(f, "{err}"),
Self::Unbalanced {
debits,
credits,
currency,
} => write!(
f,
"ledger transaction does not balance: {debits} debit \
against {credits} credit ({currency} minor units)"
),
Self::PostingCount { count } => write!(
f,
"a ledger transaction takes 2 to {MAX_POSTINGS} postings, not {count}"
),
Self::OneSided => f.write_str("a ledger transaction needs a debit and a credit"),
Self::ZeroPosting { account } => {
write!(
f,
"the posting to {account} moves nothing; leave the line out"
)
}
Self::MixedCurrencies { expected, found } => write!(
f,
"a ledger transaction is in one currency: expected {expected}, found {found}"
),
Self::UnknownAccount { id } => write!(f, "no ledger account {id}"),
Self::NegativeAmount { account } => write!(
f,
"the posting to {account} has a negative amount; use the credit side instead"
),
Self::AccountCurrency {
account,
expected,
found,
} => write!(
f,
"account {account} holds {expected}, but the posting is {found}"
),
Self::KeyReuse { key } => write!(
f,
"idempotency key {key} was used before for different postings"
),
Self::InvalidText { field, reason } => write!(f, "{field} {reason}"),
Self::NegativeBalance {
account,
balance,
currency,
} => write!(
f,
"account {account} refuses a negative balance, and this posting \
leaves {balance} {currency} minor units"
),
Self::NotInTransaction => f.write_str(
"a ledger posting must run inside a database transaction; wrap the call in Db::tx",
),
Self::Conflict { key } => write!(
f,
"another transaction is posting idempotency key {key}; retry this transaction"
),
Self::EmptyTransaction { id } => {
write!(f, "stored ledger transaction {id} has no postings")
}
Self::Database(err) => write!(f, "ledger database error: {err}"),
}
}
}
impl std::error::Error for LedgerError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Money(err) => Some(err),
Self::Database(err) => Some(err),
_ => None,
}
}
}
impl From<MoneyError> for LedgerError {
fn from(err: MoneyError) -> Self {
Self::Money(err)
}
}
impl From<diesel::result::Error> for LedgerError {
fn from(err: diesel::result::Error) -> Self {
Self::Database(err)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Account {
id: String,
currency: CurrencyCode,
allow_negative: bool,
}
impl Account {
#[must_use]
pub fn new(id: impl Into<String>, currency: CurrencyCode) -> Self {
Self {
id: id.into(),
currency,
allow_negative: true,
}
}
#[must_use]
pub const fn disallow_negative(mut self) -> Self {
self.allow_negative = false;
self
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub const fn currency(&self) -> CurrencyCode {
self.currency
}
#[must_use]
pub const fn allows_negative(&self) -> bool {
self.allow_negative
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Side {
Debit,
Credit,
}
impl Side {
const fn of(signed_minor: i64) -> Self {
if signed_minor < 0 {
Self::Credit
} else {
Self::Debit
}
}
const fn label(self) -> &'static str {
match self {
Self::Debit => "debit",
Self::Credit => "credit",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Posting {
account_id: String,
amount: AnyMoney,
side: Side,
}
impl Posting {
#[must_use]
pub fn debit(account_id: impl Into<String>, amount: impl Into<AnyMoney>) -> Self {
Self {
account_id: account_id.into(),
amount: amount.into(),
side: Side::Debit,
}
}
#[must_use]
pub fn credit(account_id: impl Into<String>, amount: impl Into<AnyMoney>) -> Self {
Self {
account_id: account_id.into(),
amount: amount.into(),
side: Side::Credit,
}
}
#[must_use]
pub fn account_id(&self) -> &str {
&self.account_id
}
#[must_use]
pub const fn amount(&self) -> AnyMoney {
self.amount
}
#[must_use]
pub const fn side(&self) -> Side {
self.side
}
#[must_use]
pub fn is_debit(&self) -> bool {
self.side == Side::Debit
}
#[must_use]
pub fn is_credit(&self) -> bool {
self.side == Side::Credit
}
pub fn signed_minor(&self) -> Result<i64, LedgerError> {
let minor = self.amount.minor();
if minor < 0 {
return Err(LedgerError::NegativeAmount {
account: self.account_id.clone(),
});
}
match self.side {
Side::Debit => Ok(minor),
Side::Credit => minor
.checked_neg()
.ok_or(LedgerError::Money(MoneyError::Overflow)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct IdempotencyKey(String);
impl IdempotencyKey {
pub fn new(key: impl Into<String>) -> Result<Self, LedgerError> {
let key = key.into();
check_text("idempotency key", &key, MAX_IDEMPOTENCY_KEY)?;
Ok(Self(key))
}
#[must_use]
pub fn derive(namespace: &str, postings: &[Posting]) -> Self {
let mut hasher = sha2::Sha256::new();
push_field(&mut hasher, "namespace", namespace.as_bytes());
push_normalized_postings(&mut hasher, postings);
Self(hex::encode(hasher.finalize()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for IdempotencyKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Transaction {
key: IdempotencyKey,
postings: Vec<Posting>,
memo: String,
}
impl Transaction {
#[must_use]
pub const fn new(key: IdempotencyKey, postings: Vec<Posting>) -> Self {
Self {
key,
postings,
memo: String::new(),
}
}
#[must_use]
pub fn memo(mut self, memo: impl Into<String>) -> Self {
self.memo = memo.into();
self
}
#[must_use]
pub const fn key(&self) -> &IdempotencyKey {
&self.key
}
#[must_use]
pub fn postings(&self) -> &[Posting] {
&self.postings
}
#[must_use]
pub fn memo_text(&self) -> &str {
&self.memo
}
pub fn validate(&self) -> Result<Validated, LedgerError> {
check_text("idempotency key", self.key.as_str(), MAX_IDEMPOTENCY_KEY)?;
if self.memo.len() > MAX_MEMO {
return Err(LedgerError::InvalidText {
field: "memo",
reason: "is too long",
});
}
check_no_nul("memo", &self.memo)?;
if self.postings.len() < 2 || self.postings.len() > MAX_POSTINGS {
return Err(LedgerError::PostingCount {
count: self.postings.len(),
});
}
let mut currency: Option<CurrencyCode> = None;
let mut debits: i64 = 0;
let mut credits: i64 = 0;
for posting in &self.postings {
check_text("account id", &posting.account_id, MAX_ACCOUNT_ID)?;
let posting_currency = posting.amount.currency();
match currency {
None => currency = Some(posting_currency),
Some(expected) if expected != posting_currency => {
return Err(LedgerError::MixedCurrencies {
expected: expected.code(),
found: posting_currency.code(),
});
}
Some(_) => {}
}
let minor = posting.signed_minor()?;
if minor == 0 {
return Err(LedgerError::ZeroPosting {
account: posting.account_id.clone(),
});
}
if minor > 0 {
debits = debits.checked_add(minor).ok_or(MoneyError::Overflow)?;
} else {
credits = credits.checked_sub(minor).ok_or(MoneyError::Overflow)?;
}
}
let Some(currency) = currency else {
return Err(LedgerError::PostingCount { count: 0 });
};
if debits == 0 || credits == 0 {
return Err(LedgerError::OneSided);
}
if debits != credits {
return Err(LedgerError::Unbalanced {
debits,
credits,
currency: currency.code(),
});
}
Ok(Validated {
currency,
total: debits,
request_hash: self.request_hash(currency),
})
}
fn request_hash(&self, currency: CurrencyCode) -> String {
let mut hasher = sha2::Sha256::new();
push_field(&mut hasher, "currency", currency.code().as_bytes());
push_normalized_postings(&mut hasher, &self.postings);
hex::encode(hasher.finalize())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Validated {
currency: CurrencyCode,
total: i64,
request_hash: String,
}
impl Validated {
#[must_use]
pub const fn currency(&self) -> CurrencyCode {
self.currency
}
#[must_use]
pub const fn total(&self) -> AnyMoney {
AnyMoney::new(self.total, self.currency)
}
#[must_use]
pub fn request_hash(&self) -> &str {
&self.request_hash
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PostedTransaction {
id: String,
key: IdempotencyKey,
currency: CurrencyCode,
memo: String,
postings: Vec<Posting>,
posted_at: String,
}
impl PostedTransaction {
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub const fn key(&self) -> &IdempotencyKey {
&self.key
}
#[must_use]
pub const fn currency(&self) -> CurrencyCode {
self.currency
}
#[must_use]
pub fn memo(&self) -> &str {
&self.memo
}
#[must_use]
pub fn postings(&self) -> &[Posting] {
&self.postings
}
#[must_use]
pub fn posted_at(&self) -> &str {
&self.posted_at
}
#[must_use]
pub fn total(&self) -> AnyMoney {
let total = self
.postings
.iter()
.filter(|posting| posting.is_debit())
.fold(0_i64, |sum, posting| {
sum.saturating_add(posting.amount.minor())
});
AnyMoney::new(total, self.currency)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PostOutcome {
Posted(PostedTransaction),
Replayed(PostedTransaction),
}
impl PostOutcome {
#[must_use]
pub const fn transaction(&self) -> &PostedTransaction {
match self {
Self::Posted(tx) | Self::Replayed(tx) => tx,
}
}
#[must_use]
pub const fn is_posted(&self) -> bool {
matches!(self, Self::Posted(_))
}
#[must_use]
pub const fn is_replayed(&self) -> bool {
matches!(self, Self::Replayed(_))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CurrencyTotal {
currency: CurrencyCode,
total: AnyMoney,
}
impl CurrencyTotal {
#[must_use]
pub const fn currency(self) -> CurrencyCode {
self.currency
}
#[must_use]
pub const fn total(self) -> AnyMoney {
self.total
}
}
fn push_field(hasher: &mut sha2::Sha256, label: &str, value: &[u8]) {
hasher.update(label.as_bytes());
hasher.update(b":");
hasher.update((value.len() as u64).to_be_bytes());
hasher.update(b":");
hasher.update(value);
hasher.update(b";");
}
fn push_normalized_postings(hasher: &mut sha2::Sha256, postings: &[Posting]) {
let mut lines: Vec<(&str, i64, Side, &'static str)> = postings
.iter()
.map(|posting| {
(
posting.account_id.as_str(),
posting.amount.minor(),
posting.side,
posting.amount.currency().code(),
)
})
.collect();
lines.sort_unstable();
push_field(hasher, "count", lines.len().to_string().as_bytes());
for (account, minor, side, currency) in lines {
push_field(hasher, "account", account.as_bytes());
push_field(hasher, "minor", minor.to_string().as_bytes());
push_field(hasher, "side", side.label().as_bytes());
push_field(hasher, "currency", currency.as_bytes());
}
}
fn check_text(field: &'static str, value: &str, max: usize) -> Result<(), LedgerError> {
if value.is_empty() {
return Err(LedgerError::InvalidText {
field,
reason: "is empty",
});
}
if value.len() > max {
return Err(LedgerError::InvalidText {
field,
reason: "is too long",
});
}
check_no_nul(field, value)
}
fn check_no_nul(field: &'static str, value: &str) -> Result<(), LedgerError> {
if value.as_bytes().contains(&0) {
return Err(LedgerError::InvalidText {
field,
reason: "holds a NUL byte",
});
}
Ok(())
}
#[derive(diesel::QueryableByName)]
struct AccountRow {
#[diesel(sql_type = Text)]
id: String,
#[diesel(sql_type = Text)]
currency: String,
#[diesel(sql_type = Bool)]
allow_negative: bool,
}
#[derive(diesel::QueryableByName)]
struct TransactionRow {
#[diesel(sql_type = Text)]
id: String,
#[diesel(sql_type = Text)]
idempotency_key: String,
#[diesel(sql_type = Text)]
request_hash: String,
#[diesel(sql_type = Text)]
currency: String,
#[diesel(sql_type = Text)]
memo: String,
#[diesel(sql_type = Text)]
posted_at: String,
}
#[derive(diesel::QueryableByName)]
struct PostingRow {
#[diesel(sql_type = Text)]
account_id: String,
#[diesel(sql_type = BigInt)]
amount_minor: i64,
#[diesel(sql_type = Text)]
currency: String,
}
#[derive(diesel::QueryableByName)]
struct AccountTotalRow {
#[diesel(sql_type = Text)]
account_id: String,
#[diesel(sql_type = BigInt)]
hi: i64,
#[diesel(sql_type = BigInt)]
lo: i64,
}
#[derive(diesel::QueryableByName)]
struct TotalRow {
#[diesel(sql_type = BigInt)]
hi: i64,
#[diesel(sql_type = BigInt)]
lo: i64,
}
#[derive(diesel::QueryableByName)]
struct CurrencyTotalRow {
#[diesel(sql_type = Text)]
currency: String,
#[diesel(sql_type = BigInt)]
hi: i64,
#[diesel(sql_type = BigInt)]
lo: i64,
}
fn parse_currency(code: &str) -> Result<CurrencyCode, LedgerError> {
CurrencyCode::parse(code).map_err(LedgerError::Money)
}
impl AccountRow {
fn into_account(self) -> Result<Account, LedgerError> {
Ok(Account {
currency: parse_currency(&self.currency)?,
id: self.id,
allow_negative: self.allow_negative,
})
}
}
impl PostingRow {
fn into_posting(self) -> Result<Posting, LedgerError> {
let magnitude = self
.amount_minor
.checked_abs()
.ok_or(MoneyError::Overflow)?;
Ok(Posting {
amount: AnyMoney::new(magnitude, parse_currency(&self.currency)?),
side: Side::of(self.amount_minor),
account_id: self.account_id,
})
}
}
pub async fn ensure_account(
conn: &mut RuntimeConnection,
account: Account,
) -> Result<Account, LedgerError> {
check_text("account id", &account.id, MAX_ACCOUNT_ID)?;
if let Some(stored) = load_account(conn, &account.id).await? {
if stored.currency != account.currency {
return Err(LedgerError::AccountCurrency {
account: account.id,
expected: stored.currency.code(),
found: account.currency.code(),
});
}
return Ok(stored);
}
let sql = format!(
"INSERT INTO {ACCOUNTS_TABLE} (id, currency, allow_negative, created_at) \
VALUES ({}, {}, {}, {NOW}) ON CONFLICT (id) DO NOTHING",
ph(1),
ph(2),
ph(3)
);
diesel::sql_query(sql)
.bind::<Text, _>(account.id.clone())
.bind::<Text, _>(account.currency.code())
.bind::<Bool, _>(account.allow_negative)
.execute(conn)
.await?;
let stored =
load_account(conn, &account.id)
.await?
.ok_or_else(|| LedgerError::UnknownAccount {
id: account.id.clone(),
})?;
if stored.currency != account.currency {
return Err(LedgerError::AccountCurrency {
account: account.id,
expected: stored.currency.code(),
found: account.currency.code(),
});
}
Ok(stored)
}
pub async fn set_allow_negative(
conn: &mut RuntimeConnection,
account_id: &str,
allow_negative: bool,
) -> Result<Account, LedgerError> {
let sql = format!(
"UPDATE {ACCOUNTS_TABLE} SET allow_negative = {} WHERE id = {}",
ph(1),
ph(2)
);
let updated = diesel::sql_query(sql)
.bind::<Bool, _>(allow_negative)
.bind::<Text, _>(account_id)
.execute(conn)
.await?;
if updated == 0 {
return Err(LedgerError::UnknownAccount {
id: account_id.to_owned(),
});
}
load_account(conn, account_id)
.await?
.ok_or_else(|| LedgerError::UnknownAccount {
id: account_id.to_owned(),
})
}
pub async fn account(
conn: &mut RuntimeConnection,
account_id: &str,
) -> Result<Option<Account>, LedgerError> {
load_account(conn, account_id).await
}
async fn load_account(
conn: &mut RuntimeConnection,
account_id: &str,
) -> Result<Option<Account>, LedgerError> {
let sql = format!(
"SELECT id, currency, allow_negative FROM {ACCOUNTS_TABLE} WHERE id = {}",
ph(1)
);
let rows: Vec<AccountRow> = diesel::sql_query(sql)
.bind::<Text, _>(account_id)
.load(conn)
.await?;
rows.into_iter()
.next()
.map(AccountRow::into_account)
.transpose()
}
async fn lock_accounts(
conn: &mut RuntimeConnection,
ids: &[&str],
) -> Result<BTreeMap<String, Account>, LedgerError> {
if ids.is_empty() {
return Ok(BTreeMap::new());
}
let placeholders = (1..=ids.len()).map(ph).collect::<Vec<_>>().join(", ");
let sql = format!(
"SELECT id, currency, allow_negative FROM {ACCOUNTS_TABLE} \
WHERE id IN ({placeholders}) ORDER BY id{FOR_UPDATE}"
);
let mut query = diesel::sql_query(sql).into_boxed();
for id in ids {
query = query.bind::<Text, _>((*id).to_owned());
}
let rows: Vec<AccountRow> = query.load(conn).await?;
rows.into_iter()
.map(|row| {
row.into_account()
.map(|account| (account.id.clone(), account))
})
.collect()
}
pub async fn post(
conn: &mut RuntimeConnection,
transfer: &Transaction,
) -> Result<PostOutcome, LedgerError> {
let checked = transfer.validate()?;
if transaction_depth(conn)? == 0 {
return Err(LedgerError::NotInTransaction);
}
let mut wanted: BTreeSet<&str> = BTreeSet::new();
for posting in transfer.postings() {
wanted.insert(posting.account_id());
}
let ids: Vec<&str> = wanted.iter().copied().collect();
let accounts = lock_accounts(conn, &ids).await?;
for id in &wanted {
let account = accounts
.get(*id)
.ok_or_else(|| LedgerError::UnknownAccount {
id: (*id).to_owned(),
})?;
if account.currency != checked.currency {
return Err(LedgerError::AccountCurrency {
account: account.id.clone(),
expected: account.currency.code(),
found: checked.currency.code(),
});
}
}
if let Some(existing) = load_transaction(conn, transfer.key().as_str()).await? {
if existing.request_hash != *checked.request_hash() {
return Err(LedgerError::KeyReuse {
key: transfer.key().as_str().to_owned(),
});
}
return Ok(PostOutcome::Replayed(existing.transaction));
}
check_resulting_balances(conn, &accounts, transfer).await?;
let id = uuid::Uuid::new_v4().to_string();
write_postings(conn, &id, transfer).await?;
let insert_sql = format!(
"INSERT INTO {TRANSACTIONS_TABLE} \
(id, idempotency_key, request_hash, currency, memo, posted_at) \
VALUES ({}, {}, {}, {}, {}, {NOW}) \
ON CONFLICT (idempotency_key) DO NOTHING",
ph(1),
ph(2),
ph(3),
ph(4),
ph(5)
);
let inserted = diesel::sql_query(insert_sql)
.bind::<Text, _>(id.clone())
.bind::<Text, _>(transfer.key().as_str())
.bind::<Text, _>(checked.request_hash().to_owned())
.bind::<Text, _>(checked.currency.code())
.bind::<Text, _>(transfer.memo_text())
.execute(conn)
.await?;
if inserted == 0 {
if let Some(existing) = load_transaction(conn, transfer.key().as_str()).await?
&& existing.request_hash != *checked.request_hash()
{
return Err(LedgerError::KeyReuse {
key: transfer.key().as_str().to_owned(),
});
}
return Err(LedgerError::Conflict {
key: transfer.key().as_str().to_owned(),
});
}
let stored = load_transaction(conn, transfer.key().as_str())
.await?
.ok_or(LedgerError::Database(diesel::result::Error::NotFound))?;
Ok(PostOutcome::Posted(stored.transaction))
}
fn transaction_depth(conn: &mut RuntimeConnection) -> Result<u32, LedgerError> {
use diesel_async::TransactionManager as _;
type Manager = <RuntimeConnection as diesel_async::AsyncConnection>::TransactionManager;
let depth = Manager::transaction_manager_status_mut(conn).transaction_depth()?;
Ok(depth.map_or(0, std::num::NonZeroU32::get))
}
async fn write_postings(
conn: &mut RuntimeConnection,
transaction_id: &str,
transfer: &Transaction,
) -> Result<(), LedgerError> {
let postings = transfer.postings();
let mut values = String::with_capacity(postings.len().saturating_mul(24));
for i in 0..postings.len() {
if i > 0 {
values.push_str(", ");
}
let base = i.saturating_mul(5);
let _ = write!(
values,
"({}, {}, {}, {}, {}, {NOW})",
ph(base.saturating_add(1)),
ph(base.saturating_add(2)),
ph(base.saturating_add(3)),
ph(base.saturating_add(4)),
ph(base.saturating_add(5)),
);
}
let sql = format!(
"INSERT INTO {POSTINGS_TABLE} \
(transaction_id, seq, account_id, amount_minor, currency, posted_at) \
VALUES {values}"
);
let mut query = diesel::sql_query(sql).into_boxed();
for (index, posting) in postings.iter().enumerate() {
let seq = i64::try_from(index).map_err(|_| LedgerError::PostingCount {
count: postings.len(),
})?;
query = query
.bind::<Text, _>(transaction_id.to_owned())
.bind::<BigInt, _>(seq)
.bind::<Text, _>(posting.account_id().to_owned())
.bind::<BigInt, _>(posting.signed_minor()?)
.bind::<Text, _>(posting.amount().currency().code());
}
query.execute(conn).await?;
Ok(())
}
async fn check_resulting_balances(
conn: &mut RuntimeConnection,
accounts: &BTreeMap<String, Account>,
transfer: &Transaction,
) -> Result<(), LedgerError> {
let ids: Vec<&str> = accounts.keys().map(String::as_str).collect();
let stored = sum_postings_by_account(conn, &ids).await?;
for account in accounts.values() {
let mut delta: i128 = 0;
for posting in transfer.postings() {
if posting.account_id() == account.id {
delta = delta
.checked_add(i128::from(posting.signed_minor()?))
.ok_or(MoneyError::Overflow)?;
}
}
let balance = i128::from(stored.get(&account.id).copied().unwrap_or(0))
.checked_add(delta)
.ok_or(MoneyError::Overflow)?;
let balance = i64::try_from(balance).map_err(|_| MoneyError::Overflow)?;
if balance < 0 && !account.allow_negative {
return Err(LedgerError::NegativeBalance {
account: account.id.clone(),
balance,
currency: account.currency.code(),
});
}
}
Ok(())
}
async fn sum_postings_by_account(
conn: &mut RuntimeConnection,
ids: &[&str],
) -> Result<BTreeMap<String, i64>, LedgerError> {
if ids.is_empty() {
return Ok(BTreeMap::new());
}
let placeholders = (1..=ids.len()).map(ph).collect::<Vec<_>>().join(", ");
let totals = split_sum("amount_minor");
let sql = format!(
"SELECT account_id, {totals} \
FROM {POSTINGS_TABLE} WHERE account_id IN ({placeholders}) GROUP BY account_id"
);
let mut query = diesel::sql_query(sql).into_boxed();
for id in ids {
query = query.bind::<Text, _>((*id).to_owned());
}
let rows: Vec<AccountTotalRow> = query.load(conn).await?;
rows.into_iter()
.map(|row| Ok((row.account_id, join_split(row.hi, row.lo)?)))
.collect()
}
pub async fn balance(
conn: &mut RuntimeConnection,
account_id: &str,
) -> Result<AnyMoney, LedgerError> {
let account =
load_account(conn, account_id)
.await?
.ok_or_else(|| LedgerError::UnknownAccount {
id: account_id.to_owned(),
})?;
let total = sum_postings(conn, account_id).await?;
Ok(AnyMoney::new(total, account.currency))
}
async fn sum_postings(conn: &mut RuntimeConnection, account_id: &str) -> Result<i64, LedgerError> {
let totals = split_sum("amount_minor");
let sql = format!(
"SELECT {totals} FROM {POSTINGS_TABLE} WHERE account_id = {}",
ph(1)
);
let rows: Vec<TotalRow> = diesel::sql_query(sql)
.bind::<Text, _>(account_id)
.load(conn)
.await?;
rows.into_iter()
.next()
.map_or(Ok(0), |row| join_split(row.hi, row.lo))
}
pub async fn transaction_by_key(
conn: &mut RuntimeConnection,
key: &IdempotencyKey,
) -> Result<Option<PostedTransaction>, LedgerError> {
Ok(load_transaction(conn, key.as_str())
.await?
.map(|stored| stored.transaction))
}
struct StoredTransaction {
transaction: PostedTransaction,
request_hash: String,
}
async fn load_transaction(
conn: &mut RuntimeConnection,
key: &str,
) -> Result<Option<StoredTransaction>, LedgerError> {
let sql = format!(
"SELECT id, idempotency_key, request_hash, currency, memo, \
CAST(posted_at AS TEXT) AS posted_at \
FROM {TRANSACTIONS_TABLE} WHERE idempotency_key = {}",
ph(1)
);
let rows: Vec<TransactionRow> = diesel::sql_query(sql)
.bind::<Text, _>(key)
.load(conn)
.await?;
let Some(row) = rows.into_iter().next() else {
return Ok(None);
};
let postings_sql = format!(
"SELECT account_id, amount_minor, currency FROM {POSTINGS_TABLE} \
WHERE transaction_id = {} ORDER BY seq",
ph(1)
);
let posting_rows: Vec<PostingRow> = diesel::sql_query(postings_sql)
.bind::<Text, _>(row.id.clone())
.load(conn)
.await?;
if posting_rows.is_empty() {
return Err(LedgerError::EmptyTransaction { id: row.id });
}
let postings = posting_rows
.into_iter()
.map(PostingRow::into_posting)
.collect::<Result<Vec<_>, _>>()?;
Ok(Some(StoredTransaction {
transaction: PostedTransaction {
id: row.id,
key: IdempotencyKey(row.idempotency_key),
currency: parse_currency(&row.currency)?,
memo: row.memo,
postings,
posted_at: row.posted_at,
},
request_hash: row.request_hash,
}))
}
pub async fn trial_balance(
conn: &mut RuntimeConnection,
) -> Result<Vec<CurrencyTotal>, LedgerError> {
let totals = split_sum("amount_minor");
let sql = format!(
"SELECT currency, {totals} \
FROM {POSTINGS_TABLE} GROUP BY currency ORDER BY currency"
);
let rows: Vec<CurrencyTotalRow> = diesel::sql_query(sql).load(conn).await?;
rows.into_iter()
.map(|row| {
let currency = parse_currency(&row.currency)?;
Ok(CurrencyTotal {
currency,
total: AnyMoney::new(join_split(row.hi, row.lo)?, currency),
})
})
.collect()
}
#[cfg(test)]
mod tests;