From 4f1db1268822c3b2d676395436e839131988ff11 Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Tue, 27 May 2025 23:48:40 +1000 Subject: [PATCH] Implement getting transactions from database --- src/db.rs | 48 +++++- src/lib.rs | 1 + src/main.rs | 59 +++++++- src/reporting/calculator.rs | 82 +++++++---- src/reporting/steps.rs | 287 ++++++++++++++++++++++++++++++++++-- src/reporting/types.rs | 22 ++- src/serde.rs | 62 ++++++++ src/transaction.rs | 16 +- 8 files changed, 530 insertions(+), 47 deletions(-) create mode 100644 src/serde.rs diff --git a/src/db.rs b/src/db.rs index 684d1f1..c51a1dc 100644 --- a/src/db.rs +++ b/src/db.rs @@ -24,6 +24,7 @@ use sqlx::{Connection, Row, SqliteConnection}; use crate::account_config::AccountConfiguration; use crate::statements::StatementLine; +use crate::transaction::{Posting, Transaction, TransactionWithPostings}; use crate::{util::format_date, QuantityInt}; pub struct DbConnection { @@ -90,7 +91,7 @@ impl DbConnection { let mut connection = self.connect().await; let rows = sqlx::query( - "-- Get last transaction for each account + "-- Get last transaction for each account WITH max_dt_by_account AS ( SELECT account, max(dt) AS max_dt FROM joined_transactions @@ -117,13 +118,56 @@ impl DbConnection { balances } + /// Get transactions from the database + pub async fn get_transactions(&self) -> Vec { + let mut connection = self.connect().await; + + let rows = sqlx::query( + "SELECT transaction_id, dt, transaction_description, id, description, account, quantity, commodity, quantity_ascost + FROM transactions_with_quantity_ascost + ORDER BY dt, transaction_id, id" + ).fetch_all(&mut connection).await.expect("SQL error"); + + // Un-flatten transaction list + let mut transactions: Vec = Vec::new(); + + for row in rows { + if transactions.is_empty() + || transactions.last().unwrap().transaction.id != row.get("transaction_id") + { + // New transaction + transactions.push(TransactionWithPostings { + transaction: Transaction { + id: row.get("transaction_id"), + dt: NaiveDateTime::parse_from_str(row.get("dt"), "%Y-%m-%d %H:%M:%S.%6f") + .expect("Invalid transactions.dt"), + description: row.get("transaction_description"), + }, + postings: Vec::new(), + }); + } + + transactions.last_mut().unwrap().postings.push(Posting { + id: row.get("id"), + transaction_id: row.get("transaction_id"), + description: row.get("description"), + account: row.get("account"), + quantity: row.get("quantity"), + commodity: row.get("commodity"), + quantity_ascost: row.get("quantity_ascost"), + }); + } + + transactions + } + /// Get unreconciled statement lines from the database pub async fn get_unreconciled_statement_lines(&self) -> Vec { let mut connection = self.connect().await; let rows = sqlx::query( // On testing, JOIN is much faster than WHERE NOT EXISTS - "SELECT statement_lines.* FROM statement_lines + "SELECT statement_lines.* FROM statement_lines LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id WHERE statement_line_reconciliations.id IS NULL" ).map(|r: SqliteRow| StatementLine { diff --git a/src/lib.rs b/src/lib.rs index a5404eb..3a658cd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod account_config; pub mod db; pub mod reporting; pub mod transaction; +pub mod serde; pub mod statements; pub mod util; diff --git a/src/main.rs b/src/main.rs index 0dfcdc4..79acabb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,13 +26,13 @@ use libdrcr::reporting::dynamic_report::DynamicReport; use libdrcr::reporting::generate_report; use libdrcr::reporting::steps::register_lookup_fns; use libdrcr::reporting::types::{ - DateArgs, DateStartDateEndArgs, MultipleDateArgs, ReportingContext, ReportingProductId, - ReportingProductKind, VoidArgs, + DateArgs, DateStartDateEndArgs, MultipleDateArgs, MultipleDateStartDateEndArgs, + ReportingContext, ReportingProductId, ReportingProductKind, VoidArgs, }; #[tokio::main] async fn main() { - const YEAR: i32 = 2023; + const YEAR: i32 = 2025; // Connect to database let db_connection = DbConnection::new("sqlite:drcr_testing.db").await; @@ -56,6 +56,13 @@ async fn main() { kind: ReportingProductKind::Transactions, args: Box::new(VoidArgs {}), }, + // ReportingProductId { + // name: "AllTransactionsExceptEarningsToEquity", + // kind: ReportingProductKind::Transactions, + // args: Box::new(DateArgs { + // date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + // }), + // }, ReportingProductId { name: "BalanceSheet", kind: ReportingProductKind::Generic, @@ -65,6 +72,16 @@ async fn main() { }], }), }, + ReportingProductId { + name: "IncomeStatement", + kind: ReportingProductKind::Generic, + args: Box::new(MultipleDateStartDateEndArgs { + dates: vec![DateStartDateEndArgs { + date_start: NaiveDate::from_ymd_opt(YEAR - 1, 7, 1).unwrap(), + date_end: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }], + }), + }, ]; let (sorted_steps, dependencies) = steps_for_targets(targets, &context).unwrap(); @@ -181,4 +198,40 @@ async fn main() { "{}", result.downcast_ref::().unwrap().to_json() ); + + // Get all transactions + + /*let targets = vec![ + ReportingProductId { + name: "CalculateIncomeTax", + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::Transactions, + args: Box::new(DateArgs { + date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }), + }, + ]; + + let products = generate_report(targets, Arc::clone(&context)) + .await + .unwrap(); + let result = products + .get_or_err(&ReportingProductId { + name: "AllTransactionsExceptEarningsToEquity", + kind: ReportingProductKind::Transactions, + args: Box::new(DateArgs { + date: NaiveDate::from_ymd_opt(YEAR, 6, 30).unwrap(), + }), + }) + .unwrap(); + + println!("All transactions:"); + println!( + "{}", + result.downcast_ref::().unwrap().to_json() + );*/ } diff --git a/src/reporting/calculator.rs b/src/reporting/calculator.rs index 410aff2..06549f7 100644 --- a/src/reporting/calculator.rs +++ b/src/reporting/calculator.rs @@ -134,6 +134,31 @@ fn build_step_for_product( } HasStepOrCanBuild::CanLookup(from_args_fn) => { new_step = from_args_fn(product.args.clone()); + + // Check new step meets the dependency + if new_step.id().name != product.name { + panic!( + "Unexpected step returned from lookup function (expected name {}, got {})", + product.name, + new_step.id().name + ); + } + if new_step.id().args != product.args { + panic!( + "Unexpected step returned from lookup function {} (expected args {:?}, got {:?})", + product.name, + product.args, + new_step.id().args + ); + } + if !new_step.id().product_kinds.contains(&product.kind) { + panic!( + "Unexpected step returned from lookup function {} (expected kind {:?}, got {:?})", + product.name, + product.kind, + new_step.id().product_kinds + ); + } } HasStepOrCanBuild::CanBuild(builder) => { new_step = (builder.build)( @@ -144,37 +169,40 @@ fn build_step_for_product( &dependencies, &context, ); + + // Check new step meets the dependency + if new_step.id().name != product.name { + panic!( + "Unexpected step returned from builder {} (expected name {}, got {})", + builder.name, + product.name, + new_step.id().name + ); + } + if new_step.id().args != product.args { + panic!( + "Unexpected step returned from builder {} for {} (expected args {:?}, got {:?})", + builder.name, + product.name, + product.args, + new_step.id().args + ); + } + if !new_step.id().product_kinds.contains(&product.kind) { + panic!( + "Unexpected step returned from builder {} for {} (expected kind {:?}, got {:?})", + builder.name, + product.name, + product.kind, + new_step.id().product_kinds + ); + } } HasStepOrCanBuild::None => { return None; } } - // Check new step meets the dependency - if new_step.id().name != product.name { - panic!( - "Unexpected step returned from lookup function (expected name {}, got {})", - product.name, - new_step.id().name - ); - } - if new_step.id().args != product.args { - panic!( - "Unexpected step returned from lookup function {} (expected args {:?}, got {:?})", - product.name, - product.args, - new_step.id().args - ); - } - if !new_step.id().product_kinds.contains(&product.kind) { - panic!( - "Unexpected step returned from lookup function {} (expected kind {:?}, got {:?})", - product.name, - product.kind, - new_step.id().product_kinds - ); - } - Some(new_step) } @@ -231,6 +259,10 @@ pub fn steps_for_targets( dependencies.add_dependency(new_step.id(), dependency); } new_step.init_graph(&steps, &mut dependencies, &context); + } else { + return Err(ReportingCalculationError::NoStepForProduct { + message: format!("No step builds target product {}", target), + }); } } } diff --git a/src/reporting/steps.rs b/src/reporting/steps.rs index 893026a..c28a70f 100644 --- a/src/reporting/steps.rs +++ b/src/reporting/steps.rs @@ -48,30 +48,100 @@ use super::types::{ /// Call [ReportingContext::register_lookup_fn] for all steps provided by this module pub fn register_lookup_fns(context: &mut ReportingContext) { AllTransactionsExceptEarningsToEquity::register_lookup_fn(context); + AllTransactionsExceptEarningsToEquityBalances::register_lookup_fn(context); AllTransactionsIncludingEarningsToEquity::register_lookup_fn(context); BalanceSheet::register_lookup_fn(context); CalculateIncomeTax::register_lookup_fn(context); CombineOrdinaryTransactions::register_lookup_fn(context); + CombineOrdinaryTransactionsBalances::register_lookup_fn(context); CurrentYearEarningsToEquity::register_lookup_fn(context); DBBalances::register_lookup_fn(context); + DBTransactions::register_lookup_fn(context); IncomeStatement::register_lookup_fn(context); PostUnreconciledStatementLines::register_lookup_fn(context); RetainedEarningsToEquity::register_lookup_fn(context); TrialBalance::register_lookup_fn(context); } -/// Target representing all transactions except charging current year and retained earnings to equity +/// Target representing all transactions except charging current year and retained earnings to equity (returns transaction list) /// /// By default, this is [CombineOrdinaryTransactions] and, if requested, [CalculateIncomeTax]. /// /// Used as the basis for the income statement. #[derive(Debug)] pub struct AllTransactionsExceptEarningsToEquity { + pub args: DateArgs, +} + +impl AllTransactionsExceptEarningsToEquity { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "AllTransactionsExceptEarningsToEquity", + &[ReportingProductKind::Transactions], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(AllTransactionsExceptEarningsToEquity { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for AllTransactionsExceptEarningsToEquity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for AllTransactionsExceptEarningsToEquity { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "AllTransactionsExceptEarningsToEquity", + product_kinds: &[ReportingProductKind::Transactions], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + // AllTransactionsExceptEarningsToEquity always depends on CombineOrdinaryTransactions at least + vec![ReportingProductId { + name: "CombineOrdinaryTransactions", + kind: ReportingProductKind::Transactions, + args: Box::new(self.args.clone()), + }] + } + + async fn execute( + &self, + _context: &ReportingContext, + _steps: &Vec>, + dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + combine_transactions_of_all_dependencies(self.id(), dependencies, products).await + } +} + +/// Target representing all transactions except charging current year and retained earnings to equity (returns balances) +/// +/// By default, this is [CombineOrdinaryTransactions] and, if requested, [CalculateIncomeTax]. +/// +/// Used as the basis for the income statement. +#[derive(Debug)] +pub struct AllTransactionsExceptEarningsToEquityBalances { pub product_kinds: &'static [ReportingProductKind; 1], // Must have single member - represented as static array for compatibility with ReportingStepId pub args: Box, } -impl AllTransactionsExceptEarningsToEquity { +impl AllTransactionsExceptEarningsToEquityBalances { fn register_lookup_fn(context: &mut ReportingContext) { context.register_lookup_fn( "AllTransactionsExceptEarningsToEquity", @@ -96,21 +166,21 @@ impl AllTransactionsExceptEarningsToEquity { product_kinds: &'static [ReportingProductKind; 1], args: Box, ) -> Box { - Box::new(AllTransactionsExceptEarningsToEquity { + Box::new(AllTransactionsExceptEarningsToEquityBalances { product_kinds, args, }) } } -impl Display for AllTransactionsExceptEarningsToEquity { +impl Display for AllTransactionsExceptEarningsToEquityBalances { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_fmt(format_args!("{}", self.id())) } } #[async_trait] -impl ReportingStep for AllTransactionsExceptEarningsToEquity { +impl ReportingStep for AllTransactionsExceptEarningsToEquityBalances { fn id(&self) -> ReportingStepId { ReportingStepId { name: "AllTransactionsExceptEarningsToEquity", @@ -141,6 +211,7 @@ impl ReportingStep for AllTransactionsExceptEarningsToEquity { let step_dependencies = dependencies.dependencies_for_step(&self.id()); // Identify the product_kind dependency most recently generated + // TODO: Make this deterministic - parallel execution may cause the order to vary let product_kind = self.product_kinds[0]; for (product_id, product) in products.map().iter().rev() { @@ -502,8 +573,8 @@ impl CalculateIncomeTax { ); } - fn takes_args(_args: &Box) -> bool { - true + fn takes_args(args: &Box) -> bool { + args.is::() } fn from_args(_args: Box) -> Box { @@ -546,14 +617,20 @@ impl ReportingStep for CalculateIncomeTax { _context: &ReportingContext, ) { for other in steps { - if let Some(other) = other.downcast_ref::() { + if let Some(other) = + other.downcast_ref::() + { // AllTransactionsExceptEarningsToEquity depends on CalculateIncomeTax dependencies.add_dependency( other.id(), ReportingProductId { name: self.id().name, kind: other.product_kinds[0], - args: other.id().args, + args: if other.product_kinds[0] == ReportingProductKind::Transactions { + Box::new(VoidArgs {}) + } else { + other.id().args + }, }, ); } @@ -586,9 +663,9 @@ impl ReportingStep for CalculateIncomeTax { } } -/// Combines all steps producing ordinary transactions +/// Combines all steps producing ordinary transactions (returns transaction list) /// -/// By default, these are [DBBalances] and [PostUnreconciledStatementLines] +/// By default, these are [DBTransactions] and [PostUnreconciledStatementLines]. #[derive(Debug)] pub struct CombineOrdinaryTransactions { pub args: DateArgs, @@ -598,7 +675,7 @@ impl CombineOrdinaryTransactions { fn register_lookup_fn(context: &mut ReportingContext) { context.register_lookup_fn( "CombineOrdinaryTransactions", - &[ReportingProductKind::BalancesAt], + &[ReportingProductKind::Transactions], Self::takes_args, Self::from_args, ); @@ -623,6 +700,79 @@ impl Display for CombineOrdinaryTransactions { #[async_trait] impl ReportingStep for CombineOrdinaryTransactions { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "CombineOrdinaryTransactions", + product_kinds: &[ReportingProductKind::Transactions], + args: Box::new(self.args.clone()), + } + } + + fn requires(&self, _context: &ReportingContext) -> Vec { + vec![ + // CombineOrdinaryTransactions depends on DBTransactions + ReportingProductId { + name: "DBTransactions", + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + // CombineOrdinaryTransactions depends on PostUnreconciledStatementLines + ReportingProductId { + name: "PostUnreconciledStatementLines", + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + ] + } + + async fn execute( + &self, + _context: &ReportingContext, + _steps: &Vec>, + dependencies: &ReportingGraphDependencies, + products: &RwLock, + ) -> Result { + combine_transactions_of_all_dependencies(self.id(), dependencies, products).await + } +} + +/// Combines all steps producing ordinary transactions (returns balances) +/// +/// By default, these are [DBBalances] and [PostUnreconciledStatementLines]. +#[derive(Debug)] +pub struct CombineOrdinaryTransactionsBalances { + pub args: DateArgs, +} + +impl CombineOrdinaryTransactionsBalances { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "CombineOrdinaryTransactions", + &[ReportingProductKind::BalancesAt], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(args: Box) -> Box { + Box::new(CombineOrdinaryTransactionsBalances { + args: *args.downcast().unwrap(), + }) + } +} + +impl Display for CombineOrdinaryTransactionsBalances { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for CombineOrdinaryTransactionsBalances { fn id(&self) -> ReportingStepId { ReportingStepId { name: "CombineOrdinaryTransactions", @@ -795,6 +945,7 @@ impl ReportingStep for CurrentYearEarningsToEquity { account: account.clone(), quantity: -balance, commodity: context.reporting_commodity.clone(), + quantity_ascost: None, }, Posting { id: None, @@ -803,6 +954,7 @@ impl ReportingStep for CurrentYearEarningsToEquity { account: "Current Year Earnings".to_string(), quantity: *balance, commodity: context.reporting_commodity.clone(), + quantity_ascost: None, }, ], }) @@ -893,6 +1045,71 @@ impl ReportingStep for DBBalances { } } +/// Look up transactions from the database +#[derive(Debug)] +pub struct DBTransactions {} + +impl DBTransactions { + fn register_lookup_fn(context: &mut ReportingContext) { + context.register_lookup_fn( + "DBTransactions", + &[ReportingProductKind::Transactions], + Self::takes_args, + Self::from_args, + ); + } + + fn takes_args(args: &Box) -> bool { + args.is::() + } + + fn from_args(_args: Box) -> Box { + Box::new(DBTransactions {}) + } +} + +impl Display for DBTransactions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}", self.id())) + } +} + +#[async_trait] +impl ReportingStep for DBTransactions { + fn id(&self) -> ReportingStepId { + ReportingStepId { + name: "DBTransactions", + product_kinds: &[ReportingProductKind::Transactions], + args: Box::new(VoidArgs {}), + } + } + + async fn execute( + &self, + context: &ReportingContext, + _steps: &Vec>, + _dependencies: &ReportingGraphDependencies, + _products: &RwLock, + ) -> Result { + // Get transactions from DB + let transactions = Transactions { + transactions: context.db_connection.get_transactions().await, + }; + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: self.id().name, + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + Box::new(transactions), + ); + Ok(result) + } +} + /// Generates an income statement [DynamicReport] #[derive(Debug)] pub struct IncomeStatement { @@ -1150,6 +1367,7 @@ impl ReportingStep for PostUnreconciledStatementLines { account: line.source_account.clone(), quantity: line.quantity, commodity: line.commodity.clone(), + quantity_ascost: None, }, Posting { id: None, @@ -1158,6 +1376,7 @@ impl ReportingStep for PostUnreconciledStatementLines { account: unclassified_account.to_string(), quantity: -line.quantity, commodity: line.commodity.clone(), + quantity_ascost: None, }, ], }); @@ -1286,6 +1505,7 @@ impl ReportingStep for RetainedEarningsToEquity { account: account.clone(), quantity: -balance, commodity: context.reporting_commodity.clone(), + quantity_ascost: None, }, Posting { id: None, @@ -1294,6 +1514,7 @@ impl ReportingStep for RetainedEarningsToEquity { account: "Retained Earnings".to_string(), quantity: *balance, commodity: context.reporting_commodity.clone(), + quantity_ascost: None, }, ], }) @@ -1464,3 +1685,45 @@ impl ReportingStep for TrialBalance { Ok(result) } } + +/// Combines the transactions of all dependencies and returns [Transactions] as [ReportingProducts] for the given step +/// +/// Used to implement [CombineOrdinaryTransactions] and [AllTransactionsExceptEarningsToEquity]. +async fn combine_transactions_of_all_dependencies( + step_id: ReportingStepId, + dependencies: &ReportingGraphDependencies, + products: &RwLock, +) -> Result { + let products = products.read().await; + + // Combine transactions of all dependencies + + let mut transactions = Transactions { + transactions: Vec::new(), + }; + + for dependency in dependencies.dependencies_for_step(&step_id) { + let dependency_transactions = &products + .get_or_err(&dependency.product)? + .downcast_ref::() + .unwrap() + .transactions; + + for transaction in dependency_transactions.iter() { + transactions.transactions.push(transaction.clone()); + } + } + + // Store result + let mut result = ReportingProducts::new(); + result.insert( + ReportingProductId { + name: step_id.name, + kind: ReportingProductKind::Transactions, + args: step_id.args, + }, + Box::new(transactions), + ); + + Ok(result) +} diff --git a/src/reporting/types.rs b/src/reporting/types.rs index 253f25b..06d648e 100644 --- a/src/reporting/types.rs +++ b/src/reporting/types.rs @@ -27,6 +27,7 @@ use dyn_clone::DynClone; use dyn_eq::DynEq; use dyn_hash::DynHash; use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use crate::db::DbConnection; @@ -167,11 +168,18 @@ downcast_rs::impl_downcast!(ReportingProduct); dyn_clone::clone_trait_object!(ReportingProduct); /// Records a list of transactions generated by a [ReportingStep] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct Transactions { pub transactions: Vec, } +impl Transactions { + /// Serialise the product (as JSON) using serde + pub fn to_json(&self) -> String { + serde_json::to_string(&self.transactions).unwrap() + } +} + impl ReportingProduct for Transactions {} /// Records cumulative account balances at a particular point in time @@ -234,6 +242,18 @@ impl ReportingProducts { }), } } + + pub fn get_owned_or_err( + mut self, + key: &ReportingProductId, + ) -> Result, ReportingExecutionError> { + match self.map.swap_remove(key) { + Some(value) => Ok(value), + None => Err(ReportingExecutionError::DependencyNotAvailable { + message: format!("Product {} not available when expected", key), + }), + } + } } impl Display for ReportingProducts { diff --git a/src/serde.rs b/src/serde.rs new file mode 100644 index 0000000..bbeaafb --- /dev/null +++ b/src/serde.rs @@ -0,0 +1,62 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +/// Serialises [chrono::NaiveDateTime] in database format +/// +/// Use as `#[serde(with = "crate::serde::naivedatetime_to_js")]`, etc. +pub mod naivedatetime_to_js { + use std::fmt; + + use chrono::NaiveDateTime; + use serde::{ + de::{self, Unexpected, Visitor}, + Deserializer, Serializer, + }; + + pub(crate) fn serialize( + dt: &NaiveDateTime, + serializer: S, + ) -> Result { + serializer.serialize_str(&dt.format("%Y-%m-%d %H:%M:%S%.6f").to_string()) + } + + struct DateVisitor; + impl<'de> Visitor<'de> for DateVisitor { + type Value = NaiveDateTime; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "a date string") + } + + fn visit_str(self, s: &str) -> Result + where + E: de::Error, + { + match NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.6f") { + Ok(dt) => Ok(dt), + Err(_) => Err(de::Error::invalid_value(Unexpected::Str(s), &self)), + } + } + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result { + deserializer.deserialize_str(DateVisitor) + } +} diff --git a/src/transaction.rs b/src/transaction.rs index f887cbc..4386fbc 100644 --- a/src/transaction.rs +++ b/src/transaction.rs @@ -19,23 +19,26 @@ use std::collections::HashMap; use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; use crate::QuantityInt; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct Transaction { pub id: Option, + #[serde(with = "crate::serde::naivedatetime_to_js")] pub dt: NaiveDateTime, pub description: String, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct TransactionWithPostings { + #[serde(flatten)] pub transaction: Transaction, pub postings: Vec, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct Posting { pub id: Option, pub transaction_id: Option, @@ -43,9 +46,14 @@ pub struct Posting { pub account: String, pub quantity: QuantityInt, pub commodity: String, + pub quantity_ascost: Option, + //pub running_balance: Option, } -pub(crate) fn update_balances_from_transactions<'a, I: Iterator>( +pub(crate) fn update_balances_from_transactions< + 'a, + I: Iterator, +>( balances: &mut HashMap, transactions: I, ) {