Lines
68.89 %
Functions
17.96 %
Branches
100 %
use finance::{split::Split, tag::Tag};
use sqlx::types::Uuid;
use std::{collections::HashMap, fmt::Debug};
use supp_macro::command;
use super::{CmdError, CmdResult};
use crate::{command::FinanceEntity, config::ConfigError, error::ServerError, user::User};
command! {
ListSplits {
#[required]
user_id: Uuid,
#[optional]
account: Uuid,
transaction: Uuid,
} => {
let user = User { id: user_id };
let mut conn = user.get_connection().await.map_err(|err| {
log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
ConfigError::DB
})?;
// Ensure at least one filter is provided
if account.is_none() && transaction.is_none() {
return Err(CmdError::Args(
"At least one filter (account or transaction) is required".to_string(),
));
}
// Execute the appropriate query based on provided filters
let splits = match (account, transaction) {
(Some(account_id), Some(tx_id)) => {
// Filter by both account and transaction
sqlx::query_file_as!(
Split,
"sql/select/splits/by_account_and_transaction.sql",
account_id,
tx_id
)
.fetch_all(&mut *conn)
.await?
(Some(account_id), None) => {
// Filter by account only
"sql/select/splits/by_account.sql",
account_id
(None, Some(tx_id)) => {
// Filter by transaction only
"sql/select/splits/by_transaction.sql",
_ => unreachable!(), // We already checked this case above
};
// Convert Split objects to tagged entities
let mut split_entities = Vec::new();
for split in splits {
// Get tags for this split
let tags: HashMap<String, FinanceEntity> = sqlx::query_file!(
"sql/select/tags/by_split.sql",
split.id
.into_iter()
.map(|row| {
(
row.tag_name.clone(),
FinanceEntity::Tag(Tag {
id: row.id,
tag_name: row.tag_name,
tag_value: row.tag_value,
description: row.description,
}),
})
.collect();
split_entities.push((FinanceEntity::Split(split), tags));
Ok(Some(CmdResult::TaggedEntities {
entities: split_entities,
pagination: None,
}))
// Idempotent set: replace any existing (split, tag_name) link with
// `tag_value`. Mirrors `SetAccountTag` so script-side `set-split-tag`
// reads as the obvious analog.
SetSplitTag {
split_id: Uuid,
tag_name: String,
tag_value: String,
description: String,
let desc = description.and_then(|text| {
if text.trim().is_empty() {
None
} else {
Some(text)
});
let tag = Tag {
id: Uuid::new_v4(),
tag_name,
tag_value,
description: desc,
user.set_split_tag(split_id, &tag)
.await
.map_err(map_server_err)?;
Ok(Some(CmdResult::String("ok".to_string())))
// Looks up a single tag value by name on a split. Returns the
// `tag_value` as `CmdResult::String("")` when the tag isn't set —
// keeps the wasm contract simple (`string` return type, no `Option`
// boxing).
GetSplitTag {
let tags = user
.get_split_tags(split_id)
let value = tags
.find(|t| t.tag_name == tag_name)
.map(|t| t.tag_value)
.unwrap_or_default();
Ok(Some(CmdResult::String(value)))
/// Bridge from `ServerError` to the command-layer `CmdError`. The
/// underlying tag-CRUD helpers live in `crate::tag::user` and surface
/// failures as `ServerError`; commands need `CmdError` to flow into
/// `command!`'s `Result<...>`. Pulled out so SetSplitTag and
/// GetSplitTag don't duplicate the conversion boilerplate.
fn map_server_err(err: ServerError) -> CmdError {
CmdError::Args(format!("{err:?}"))
#[cfg(test)]
mod command_tests {
use super::*;
use crate::{
command::{
account::CreateAccount, commodity::CreateCommodity, transaction::CreateTransaction,
},
db::DB_POOL,
use sqlx::{PgPool, types::chrono::Utc};
use supp_macro::local_db_sqlx_test;
use tokio::sync::OnceCell;
/// Context for keeping environment intact
static CONTEXT: OnceCell<()> = OnceCell::const_new();
static USER: OnceCell<User> = OnceCell::const_new();
async fn setup() {
CONTEXT
.get_or_init(|| async {
#[cfg(feature = "testlog")]
let _ = env_logger::builder()
.is_test(true)
.filter_level(log::LevelFilter::Trace)
.try_init();
.await;
USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
#[local_db_sqlx_test]
async fn test_list_splits(pool: PgPool) -> anyhow::Result<()> {
let user = USER.get().unwrap();
user.commit()
.expect("Failed to commit user to database");
// First create a commodity
let commodity_result = CreateCommodity::new()
.symbol("TST".to_string())
.name("Test Commodity".to_string())
.user_id(user.id)
.run()
.await?;
// Get the commodity ID
let commodity_id = if let Some(CmdResult::String(id)) = commodity_result {
uuid::Uuid::parse_str(&id)?
panic!("Expected commodity ID string result");
// Create two accounts
let account1 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
CreateAccount::new()
.name("Account 1".to_string())
{
account
panic!("Expected account entity result");
let account2 = if let Some(CmdResult::Entity(FinanceEntity::Account(account))) =
.name("Account 2".to_string())
// Create a transaction between the accounts
let tx_id = Uuid::new_v4();
let now = Utc::now();
let split1_id = Uuid::new_v4();
let split1 = Split {
id: split1_id,
tx_id,
account_id: account1.id,
commodity_id,
value_num: -100,
value_denom: 1,
reconcile_state: None,
reconcile_date: None,
lot_id: None,
let split2_id = Uuid::new_v4();
let split2 = Split {
id: split2_id,
account_id: account2.id,
value_num: 100,
let splits = vec![FinanceEntity::Split(split1), FinanceEntity::Split(split2)];
CreateTransaction::new()
.splits(splits)
.id(tx_id)
.post_date(now)
.enter_date(now)
.note("Test transaction".to_string())
// List splits for account1
if let Some(CmdResult::TaggedEntities { entities, .. }) = ListSplits::new()
.account(account1.id)
assert_eq!(entities.len(), 1, "Expected one split for account1");
let (entity, _tags) = &entities[0];
if let FinanceEntity::Split(split) = entity {
assert_eq!(split.id, split1_id);
assert_eq!(split.account_id, account1.id);
assert_eq!(split.value_num, -100);
assert_eq!(split.value_denom, 1);
panic!("Expected Split entity");
panic!("Expected TaggedEntities result");
// List splits for account2
.account(account2.id)
assert_eq!(entities.len(), 1, "Expected one split for account2");
assert_eq!(split.id, split2_id);
assert_eq!(split.account_id, account2.id);
assert_eq!(split.value_num, 100);
// List splits for non-existent account
.account(Uuid::new_v4())
assert_eq!(
entities.len(),
0,
"Expected no splits for non-existent account"
);
async fn test_list_splits_by_transaction(pool: PgPool) -> anyhow::Result<()> {
.symbol("TRX".to_string())
.name("Transaction Test Commodity".to_string())
.name("TxTest Account 1".to_string())
.name("TxTest Account 2".to_string())
value_num: -200,
value_num: 200,
.note("Transaction test".to_string())
.transaction(tx_id)
assert_eq!(entities.len(), 2, "Expected two splits for transaction");
let split_ids: Vec<Uuid> = entities
.iter()
.filter_map(|(entity, _)| {
if let FinanceEntity::Split(s) = entity {
Some(s.id)
assert!(split_ids.contains(&split1_id));
assert!(split_ids.contains(&split2_id));
.transaction(Uuid::new_v4())
"Expected no splits for non-existent transaction"