Moved accumulation calculations into DB triggers
This commit is contained in:
parent
e336292db4
commit
79ef859fbe
9 changed files with 50 additions and 281 deletions
|
|
@ -14,14 +14,6 @@ CREATE TABLE IF NOT EXISTS accounts(
|
||||||
FOREIGN KEY (user) REFERENCES users(user_id)
|
FOREIGN KEY (user) REFERENCES users(user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS account_snapshot(
|
|
||||||
account INTEGER,
|
|
||||||
datestamp DATE,
|
|
||||||
amount INT,
|
|
||||||
FOREIGN KEY (account) REFERENCES accounts(account_id),
|
|
||||||
PRIMARY KEY (account, datestamp)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS categories (
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
category_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
category_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
|
|
@ -41,13 +33,49 @@ CREATE TABLE IF NOT EXISTS transactions (
|
||||||
transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
account INTEGER,
|
account INTEGER,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
transaction_timestamp DATETIME,
|
tx_date DATETIME,
|
||||||
category INTEGER,
|
category INTEGER,
|
||||||
amount INTEGER,
|
amount INTEGER,
|
||||||
|
accumulated INTEGER DEFAULT 0,
|
||||||
|
tx_order INTEGER DEFAULT 0,
|
||||||
hash TEXT,
|
hash TEXT,
|
||||||
FOREIGN KEY (account) REFERENCES accounts(account_id),
|
FOREIGN KEY (account) REFERENCES accounts(account_id),
|
||||||
FOREIGN KEY (category) REFERENCES categories(category_id)
|
FOREIGN KEY (category) REFERENCES categories(category_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_transactions_ts ON transactions(account, transaction_timestamp);
|
CREATE TRIGGER tx_insert AFTER INSERT ON transactions
|
||||||
|
BEGIN
|
||||||
|
UPDATE transactions
|
||||||
|
SET accumulated=old.acc+NEW.amount
|
||||||
|
FROM (
|
||||||
|
SELECT COALESCE(max(accumulated), 0) AS acc
|
||||||
|
FROM transactions
|
||||||
|
WHERE date <= NEW.date
|
||||||
|
ORDER BY tx_order DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS old
|
||||||
|
WHERE id=NEW.id;
|
||||||
|
|
||||||
|
UPDATE transactions
|
||||||
|
SET tx_order=old.tx_order+1 FROM (
|
||||||
|
SELECT COALESCE(max(tx_order), 0) as tx_order
|
||||||
|
FROM tx WHERE date=NEW.date
|
||||||
|
) AS old
|
||||||
|
WHERE id=NEW.id;
|
||||||
|
|
||||||
|
UPDATE transactions SET accumulated=calc.acc+NEW.accumulated FROM (
|
||||||
|
SELECT tx.id, (
|
||||||
|
SUM(amount) OVER (
|
||||||
|
ORDER BY date, tx_order
|
||||||
|
ROWS BETWEEN
|
||||||
|
UNBOUNDED PRECEDING
|
||||||
|
AND CURRENT ROW
|
||||||
|
)
|
||||||
|
) acc
|
||||||
|
FROM transactions tx
|
||||||
|
WHERE date > NEW.date OR id=NEW.id;
|
||||||
|
)
|
||||||
|
WHERE transactions.id=calc.id;
|
||||||
|
END;
|
||||||
|
CREATE INDEX idx_transactions_ts ON transactions(account, tx_date);
|
||||||
CREATE INDEX idx_transactions_hash ON transactions(hash);
|
CREATE INDEX idx_transactions_hash ON transactions(hash);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
mod account;
|
pub mod account;
|
||||||
pub mod categories;
|
pub mod categories;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
pub mod transaction;
|
pub mod transaction;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
||||||
pub use account::{Account, AccountSnapshot};
|
|
||||||
pub use transaction::Transaction;
|
|
||||||
|
|
|
||||||
|
|
@ -1,199 +1,8 @@
|
||||||
use chrono::{prelude::*, Duration, DurationRound};
|
use chrono::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{FromRow, Result, SqlitePool};
|
use sqlx::{FromRow, Result, SqlitePool};
|
||||||
|
|
||||||
use super::{rules::Rule, Transaction};
|
use super::{rules::Rule, transaction::Transaction};
|
||||||
|
|
||||||
#[derive(FromRow, Serialize, Deserialize, Clone, Debug)]
|
|
||||||
pub struct AccountSnapshot {
|
|
||||||
account: i32,
|
|
||||||
datestamp: DateTime<Utc>,
|
|
||||||
amount: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccountSnapshot {
|
|
||||||
pub async fn get(
|
|
||||||
pool: &SqlitePool,
|
|
||||||
account: i32,
|
|
||||||
date: DateTime<Utc>,
|
|
||||||
) -> Result<AccountSnapshot> {
|
|
||||||
sqlx::query("SELECT * FROM account_snapshot WHERE account=? AND datestamp=?")
|
|
||||||
.bind(account)
|
|
||||||
.bind(date)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.and_then(|r| AccountSnapshot::from_row(&r))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_last(
|
|
||||||
pool: &SqlitePool,
|
|
||||||
account: i32,
|
|
||||||
date: DateTime<Utc>,
|
|
||||||
) -> Result<AccountSnapshot> {
|
|
||||||
sqlx::query("SELECT * FROM account_snapshot WHERE account=? AND datestamp<=? LIMIT 1")
|
|
||||||
.bind(account)
|
|
||||||
.bind(date)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.and_then(|r| AccountSnapshot::from_row(&r))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list(
|
|
||||||
pool: &SqlitePool,
|
|
||||||
account: i32,
|
|
||||||
limit: Option<i32>,
|
|
||||||
offset: Option<i32>,
|
|
||||||
asc: bool,
|
|
||||||
) -> sqlx::Result<Vec<AccountSnapshot>> {
|
|
||||||
let mut query = sqlx::QueryBuilder::new("SELECT * FROM account_snapshot WHERE account=");
|
|
||||||
query.push_bind(account);
|
|
||||||
|
|
||||||
if let Some(limit) = limit {
|
|
||||||
query.push(" LIMIT ");
|
|
||||||
query.push_bind(limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(offset) = offset {
|
|
||||||
query.push(" OFFSET ");
|
|
||||||
query.push_bind(offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
if asc {
|
|
||||||
query.push(" ORDER BY datestamp ASC");
|
|
||||||
} else {
|
|
||||||
query.push(" ORDER BY datestamp DESC");
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows = query.build().fetch_all(pool).await?;
|
|
||||||
|
|
||||||
let mut res = Vec::new();
|
|
||||||
for r in rows.iter() {
|
|
||||||
res.push(AccountSnapshot::from_row(r)?);
|
|
||||||
}
|
|
||||||
Ok(res)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_by_date(
|
|
||||||
pool: &SqlitePool,
|
|
||||||
account: i32,
|
|
||||||
after: Option<DateTime<Utc>>,
|
|
||||||
before: Option<DateTime<Utc>>,
|
|
||||||
limit: Option<i32>,
|
|
||||||
asc: bool,
|
|
||||||
) -> sqlx::Result<Vec<AccountSnapshot>> {
|
|
||||||
let mut query = sqlx::QueryBuilder::new("SELECT * FROM account_snapshot WHERE account=");
|
|
||||||
query.push_bind(account);
|
|
||||||
|
|
||||||
if let Some(after) = after {
|
|
||||||
query.push(" AND datestamp >= ");
|
|
||||||
query.push_bind(after);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(before) = before {
|
|
||||||
query.push(" AND datestamp < ");
|
|
||||||
query.push_bind(before);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(limit) = limit {
|
|
||||||
query.push(" LIMIT ");
|
|
||||||
query.push_bind(limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
if asc {
|
|
||||||
query.push(" ORDER BY datestamp ASC");
|
|
||||||
} else {
|
|
||||||
query.push(" ORDER BY datestamp DESC");
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows = query.build().fetch_all(pool).await?;
|
|
||||||
|
|
||||||
let mut res = Vec::new();
|
|
||||||
for r in rows.iter() {
|
|
||||||
res.push(AccountSnapshot::from_row(r)?);
|
|
||||||
}
|
|
||||||
Ok(res)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_by_dates(
|
|
||||||
pool: &SqlitePool,
|
|
||||||
account: i32,
|
|
||||||
after: Option<DateTime<Utc>>,
|
|
||||||
before: Option<DateTime<Utc>>,
|
|
||||||
) -> sqlx::Result<()> {
|
|
||||||
if after.is_none() && before.is_none() {
|
|
||||||
return Err(sqlx::Error::RowNotFound);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut query = sqlx::QueryBuilder::new("DELETE FROM account_snapshot WHERE account=");
|
|
||||||
query.push_bind(account);
|
|
||||||
|
|
||||||
if let Some(after) = after {
|
|
||||||
query.push(" AND datestamp >= ");
|
|
||||||
query.push_bind(after);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(before) = before {
|
|
||||||
query.push(" AND datestamp < ");
|
|
||||||
query.push_bind(before);
|
|
||||||
}
|
|
||||||
|
|
||||||
query.build().execute(pool).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn insert(&self, pool: &SqlitePool) -> sqlx::Result<()> {
|
|
||||||
sqlx::query("INSERT INTO account_snapshot(account, datestamp, amount) VALUES(?,?,?)")
|
|
||||||
.bind(self.account)
|
|
||||||
.bind(self.datestamp)
|
|
||||||
.bind(self.amount)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_next(&self, pool: &SqlitePool) -> sqlx::Result<Option<AccountSnapshot>> {
|
|
||||||
let date_next = match Transaction::list_by_date(
|
|
||||||
pool,
|
|
||||||
self.account,
|
|
||||||
Some(self.datestamp + Duration::days(1)),
|
|
||||||
None,
|
|
||||||
Some(1),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.first()
|
|
||||||
{
|
|
||||||
Some(tx) => tx.get_timestamp(),
|
|
||||||
None => {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.duration_trunc(chrono::Duration::days(1))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"Starting date: {:?}, ending date: {:?}",
|
|
||||||
self.datestamp, date_next
|
|
||||||
);
|
|
||||||
|
|
||||||
let tx_list = Transaction::list_by_date(
|
|
||||||
pool,
|
|
||||||
self.account,
|
|
||||||
Some(self.datestamp),
|
|
||||||
Some(date_next),
|
|
||||||
None,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Some(AccountSnapshot {
|
|
||||||
datestamp: date_next,
|
|
||||||
account: self.account,
|
|
||||||
amount: self.amount + tx_list.iter().fold(0, |acc, tx| acc + tx.get_amount()),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(FromRow, Serialize, Deserialize, Debug)]
|
#[derive(FromRow, Serialize, Deserialize, Debug)]
|
||||||
pub struct Account {
|
pub struct Account {
|
||||||
|
|
@ -254,55 +63,6 @@ impl Account {
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn recalculate_snapshots(
|
|
||||||
&self,
|
|
||||||
pool: &SqlitePool,
|
|
||||||
from: Option<DateTime<Utc>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut snap = match from {
|
|
||||||
Some(f) => {
|
|
||||||
let snapshot = AccountSnapshot::list_by_date(
|
|
||||||
pool,
|
|
||||||
self.get_id(),
|
|
||||||
None,
|
|
||||||
Some(f),
|
|
||||||
Some(1),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if snapshot.is_empty() {
|
|
||||||
AccountSnapshot {
|
|
||||||
account: self.account_id,
|
|
||||||
datestamp: Utc.timestamp_opt(0, 0).unwrap(),
|
|
||||||
amount: 0,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
snapshot.first().unwrap().clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => AccountSnapshot {
|
|
||||||
account: self.account_id,
|
|
||||||
datestamp: Utc.timestamp_opt(0, 0).unwrap(),
|
|
||||||
amount: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
AccountSnapshot::delete_by_dates(
|
|
||||||
pool,
|
|
||||||
self.get_id(),
|
|
||||||
Some(snap.datestamp + Duration::hours(12)),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
while let Some(next) = snap.get_next(pool).await? {
|
|
||||||
next.insert(pool).await?;
|
|
||||||
snap = next;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn recategorize_transactions(
|
pub async fn recategorize_transactions(
|
||||||
&self,
|
&self,
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ pub struct Transaction {
|
||||||
transaction_timestamp: DateTime<Utc>,
|
transaction_timestamp: DateTime<Utc>,
|
||||||
category: Option<i32>,
|
category: Option<i32>,
|
||||||
amount: i32,
|
amount: i32,
|
||||||
|
accumulated: i32,
|
||||||
#[serde(default, skip_serializing)]
|
#[serde(default, skip_serializing)]
|
||||||
hash: Option<String>,
|
hash: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,6 @@ async fn main() {
|
||||||
"/accounts/id/:id/transaction",
|
"/accounts/id/:id/transaction",
|
||||||
get(routes::api::transactions::list),
|
get(routes::api::transactions::list),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/accounts/id/:id/update",
|
|
||||||
post(routes::api::accounts::snapshot_update),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/accounts/id/:id/recategorize",
|
"/accounts/id/:id/recategorize",
|
||||||
post(routes::api::accounts::recategorize),
|
post(routes::api::accounts::recategorize),
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use serde::Deserialize;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::users::UserToken;
|
use crate::users::UserToken;
|
||||||
use accounters::models::Account;
|
use accounters::models::account::Account;
|
||||||
|
|
||||||
pub async fn account_get(
|
pub async fn account_get(
|
||||||
State(db): State<Arc<SqlitePool>>,
|
State(db): State<Arc<SqlitePool>>,
|
||||||
|
|
@ -51,22 +51,6 @@ pub async fn account_list(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn snapshot_update(
|
|
||||||
State(db): State<Arc<SqlitePool>>,
|
|
||||||
uid: UserToken,
|
|
||||||
Path(account): Path<i32>,
|
|
||||||
) -> (StatusCode, String) {
|
|
||||||
let account = Account::get_by_id(db.as_ref(), account).await.unwrap();
|
|
||||||
if account.get_user() != uid.user_id {
|
|
||||||
return (StatusCode::UNAUTHORIZED, String::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
match account.recalculate_snapshots(db.as_ref(), None).await {
|
|
||||||
Ok(_) => (StatusCode::OK, String::new()),
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn recategorize(
|
pub async fn recategorize(
|
||||||
State(db): State<Arc<SqlitePool>>,
|
State(db): State<Arc<SqlitePool>>,
|
||||||
uid: UserToken,
|
uid: UserToken,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use hyper::StatusCode;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use accounters::models::{transaction::TxConflictResolutionMode, Transaction};
|
use accounters::models::transaction::{Transaction, TxConflictResolutionMode};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct TransactionContent {
|
pub struct TransactionContent {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use sqlx::SqlitePool;
|
||||||
use tera::{Context, Tera};
|
use tera::{Context, Tera};
|
||||||
|
|
||||||
use crate::users::UserToken;
|
use crate::users::UserToken;
|
||||||
use accounters::models::{Account, Transaction};
|
use accounters::models::{account::Account, transaction::Transaction};
|
||||||
|
|
||||||
pub mod account;
|
pub mod account;
|
||||||
pub mod categories;
|
pub mod categories;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ use sqlx::SqlitePool;
|
||||||
use tera::{Context, Tera};
|
use tera::{Context, Tera};
|
||||||
|
|
||||||
use crate::users::UserToken;
|
use crate::users::UserToken;
|
||||||
use accounters::models::{transaction::TxConflictResolutionMode, Account, Transaction};
|
use accounters::models::{
|
||||||
|
account::Account,
|
||||||
|
transaction::{Transaction, TxConflictResolutionMode},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct AccountViewParams {
|
pub struct AccountViewParams {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue