From 1a3debfd7442acc6dd480659bbedaac1b2aa6863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:49:23 +0200 Subject: [PATCH] Financial foundation #12 --- ...05_replace-finance-mvp-with-foundation.sql | 182 +++++++++++++++ .../src/domains/finance/accounts/mod.rs | 41 ++++ .../src/domains/finance/balances/mod.rs | 36 +++ .../src/domains/finance/budgets/mod.rs | 42 ++++ .../src/domains/finance/categories/mod.rs | 30 +++ .../src/domains/finance/dashboard/mod.rs | 20 ++ .../backend/src/domains/finance/ledger/mod.rs | 86 +++++++ services/backend/src/domains/finance/mod.rs | 7 + .../src/domains/finance/recurring/mod.rs | 56 +++++ .../backend/src/domains/finance/repository.rs | 210 +++++++++++++----- 10 files changed, 654 insertions(+), 56 deletions(-) create mode 100644 i12e/postgres/migrations/0005_replace-finance-mvp-with-foundation.sql create mode 100644 services/backend/src/domains/finance/accounts/mod.rs create mode 100644 services/backend/src/domains/finance/balances/mod.rs create mode 100644 services/backend/src/domains/finance/budgets/mod.rs create mode 100644 services/backend/src/domains/finance/categories/mod.rs create mode 100644 services/backend/src/domains/finance/dashboard/mod.rs create mode 100644 services/backend/src/domains/finance/ledger/mod.rs create mode 100644 services/backend/src/domains/finance/recurring/mod.rs diff --git a/i12e/postgres/migrations/0005_replace-finance-mvp-with-foundation.sql b/i12e/postgres/migrations/0005_replace-finance-mvp-with-foundation.sql new file mode 100644 index 0000000..d135ca5 --- /dev/null +++ b/i12e/postgres/migrations/0005_replace-finance-mvp-with-foundation.sql @@ -0,0 +1,182 @@ +DROP TABLE IF EXISTS service_finance.transactions; + +CREATE TABLE service_finance.financial_accounts ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + name TEXT NOT NULL CHECK (btrim(name) <> ''), + account_type TEXT NOT NULL CHECK ( + account_type IN ('cash', 'bank', 'credit', 'loan') + ), + primary_currency_code CHAR(3) NOT NULL CHECK (primary_currency_code ~ '^[A-Z]{3}$'), + display_order INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), + archived_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (id, primary_currency_code), + CHECK ( + (status = 'archived' AND archived_at IS NOT NULL) + OR (status = 'active' AND archived_at IS NULL) + ) +); + +CREATE INDEX idx_finance_financial_accounts_active_order + ON service_finance.financial_accounts (status, display_order, name, id); + +CREATE TABLE service_finance.categories ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + parent_category_id UUID NULL REFERENCES service_finance.categories(id), + name TEXT NOT NULL CHECK (btrim(name) <> ''), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), + archived_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (parent_category_id IS NULL OR parent_category_id <> id), + CHECK ( + (status = 'archived' AND archived_at IS NOT NULL) + OR (status = 'active' AND archived_at IS NULL) + ) +); + +CREATE UNIQUE INDEX idx_finance_categories_parent_name + ON service_finance.categories (COALESCE(parent_category_id, '00000000-0000-0000-0000-000000000000'::uuid), lower(name)); + +CREATE INDEX idx_finance_categories_active_parent + ON service_finance.categories (status, parent_category_id, name, id); + +CREATE TABLE service_finance.ledger_entries ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + entry_kind TEXT NOT NULL CHECK ( + entry_kind IN ('income', 'expense', 'expense_reversal', 'transfer') + ), + entry_status TEXT NOT NULL DEFAULT 'confirmed' CHECK (entry_status IN ('candidate', 'confirmed', 'dismissed')), + candidate_kind TEXT NULL CHECK (candidate_kind IN ('imported', 'recurring')), + financial_account_id UUID NULL, + category_id UUID NULL REFERENCES service_finance.categories(id), + transfer_account_id UUID NULL, + recurring_plan_id UUID NULL, + transaction_date DATE NOT NULL, + description TEXT NOT NULL CHECK (btrim(description) <> ''), + note TEXT NULL CHECK (note IS NULL OR btrim(note) <> ''), + amount_minor_units BIGINT NOT NULL CHECK (amount_minor_units > 0), + currency_code CHAR(3) NOT NULL CHECK (currency_code ~ '^[A-Z]{3}$'), + source_type TEXT NOT NULL DEFAULT 'manual' CHECK (source_type IN ('manual', 'source', 'system')), + related_ledger_entry_id UUID NULL REFERENCES service_finance.ledger_entries(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (id, currency_code), + FOREIGN KEY (financial_account_id) REFERENCES service_finance.financial_accounts(id), + FOREIGN KEY (financial_account_id, currency_code) REFERENCES service_finance.financial_accounts(id, primary_currency_code), + FOREIGN KEY (transfer_account_id) REFERENCES service_finance.financial_accounts(id), + FOREIGN KEY (transfer_account_id, currency_code) REFERENCES service_finance.financial_accounts(id, primary_currency_code), + CHECK ( + ( + entry_kind IN ('income', 'expense', 'expense_reversal') + AND financial_account_id IS NOT NULL + AND transfer_account_id IS NULL + ) + OR ( + entry_kind = 'transfer' + AND financial_account_id IS NOT NULL + AND transfer_account_id IS NOT NULL + AND financial_account_id <> transfer_account_id + AND category_id IS NULL + ) + ) +); + +CREATE TABLE service_finance.ledger_entry_sources ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + source_kind TEXT NOT NULL CHECK (source_kind IN ('imported', 'recurring', 'manual', 'system')), + name TEXT NULL CHECK (name IS NULL OR btrim(name) <> ''), + payload_json JSONB NULL, + payload_blob BYTEA NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (payload_json IS NOT NULL OR payload_blob IS NOT NULL) +); + +ALTER TABLE service_finance.ledger_entries + ADD COLUMN source_id UUID NULL REFERENCES service_finance.ledger_entry_sources(id); + +CREATE INDEX idx_finance_ledger_entries_date + ON service_finance.ledger_entries (transaction_date DESC, created_at DESC, id DESC) + WHERE entry_status = 'confirmed'; + +CREATE INDEX idx_finance_ledger_entries_account_date + ON service_finance.ledger_entries (financial_account_id, transaction_date DESC, id DESC) + WHERE entry_status = 'confirmed' AND financial_account_id IS NOT NULL; + +CREATE INDEX idx_finance_ledger_entries_category_date + ON service_finance.ledger_entries (category_id, transaction_date DESC, id DESC) + WHERE entry_status = 'confirmed' AND category_id IS NOT NULL; + +CREATE INDEX idx_finance_ledger_entries_candidates + ON service_finance.ledger_entries (entry_status, transaction_date DESC, id) + WHERE entry_status IN ('candidate', 'dismissed'); + +CREATE TABLE service_finance.balance_snapshots ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + financial_account_id UUID NOT NULL, + snapshot_date DATE NOT NULL, + balance_minor_units BIGINT NOT NULL, + currency_code CHAR(3) NOT NULL CHECK (currency_code ~ '^[A-Z]{3}$'), + note TEXT NULL CHECK (note IS NULL OR btrim(note) <> ''), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (financial_account_id) REFERENCES service_finance.financial_accounts(id), + FOREIGN KEY (financial_account_id, currency_code) REFERENCES service_finance.financial_accounts(id, primary_currency_code), + UNIQUE (financial_account_id, snapshot_date) +); + +CREATE TABLE service_finance.budgets ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + budget_month DATE NOT NULL CHECK (date_trunc('month', budget_month)::date = budget_month), + currency_code CHAR(3) NOT NULL CHECK (currency_code ~ '^[A-Z]{3}$'), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), + archived_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (budget_month, currency_code), + UNIQUE (id, currency_code), + CHECK ( + (status = 'archived' AND archived_at IS NOT NULL) + OR (status = 'active' AND archived_at IS NULL) + ) +); + +CREATE TABLE service_finance.budget_allocations ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + budget_id UUID NOT NULL, + category_id UUID NOT NULL REFERENCES service_finance.categories(id), + planned_amount_minor_units BIGINT NOT NULL CHECK (planned_amount_minor_units >= 0), + currency_code CHAR(3) NOT NULL CHECK (currency_code ~ '^[A-Z]{3}$'), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (budget_id) REFERENCES service_finance.budgets(id) ON DELETE CASCADE, + FOREIGN KEY (budget_id, currency_code) REFERENCES service_finance.budgets(id, currency_code), + UNIQUE (budget_id, category_id) +); + +CREATE TABLE service_finance.recurring_plans ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + plan_kind TEXT NOT NULL CHECK (plan_kind IN ('expected_income', 'expected_expense', 'expected_transfer')), + schedule_kind TEXT NOT NULL CHECK (schedule_kind IN ('weekly', 'monthly', 'yearly')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')), + amount_minor_units BIGINT NOT NULL CHECK (amount_minor_units > 0), + currency_code CHAR(3) NOT NULL CHECK (currency_code ~ '^[A-Z]{3}$'), + source_account_id UUID NULL REFERENCES service_finance.financial_accounts(id), + destination_account_id UUID NULL REFERENCES service_finance.financial_accounts(id), + category_id UUID NULL REFERENCES service_finance.categories(id), + description TEXT NOT NULL CHECK (btrim(description) <> ''), + next_due_date DATE NOT NULL, + reminder_lead_days INTEGER NOT NULL DEFAULT 0 CHECK (reminder_lead_days >= 0), + archived_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK ( + (status = 'archived' AND archived_at IS NOT NULL) + OR (status = 'active' AND archived_at IS NULL) + ) +); + +CREATE INDEX idx_finance_recurring_plans_due + ON service_finance.recurring_plans (status, next_due_date, id); diff --git a/services/backend/src/domains/finance/accounts/mod.rs b/services/backend/src/domains/finance/accounts/mod.rs new file mode 100644 index 0000000..e16889f --- /dev/null +++ b/services/backend/src/domains/finance/accounts/mod.rs @@ -0,0 +1,41 @@ +#![allow(dead_code)] + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::ApiError; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FinancialAccountType { + Cash, + Bank, + Credit, + Loan, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FinancialAccountStatus { + Active, + Archived, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FinancialAccount { + pub id: String, + pub name: String, + pub account_type: FinancialAccountType, + pub primary_currency_code: String, + pub display_order: i32, + pub status: FinancialAccountStatus, + pub archived_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[async_trait::async_trait] +pub trait FinancialAccountRepository: Send + Sync { + async fn list_financial_accounts(&self) -> Result, ApiError>; +} diff --git a/services/backend/src/domains/finance/balances/mod.rs b/services/backend/src/domains/finance/balances/mod.rs new file mode 100644 index 0000000..a7a999c --- /dev/null +++ b/services/backend/src/domains/finance/balances/mod.rs @@ -0,0 +1,36 @@ +#![allow(dead_code)] + +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::ApiError; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BalanceSnapshot { + pub id: String, + pub financial_account_id: String, + pub snapshot_date: NaiveDate, + pub balance_minor_units: i64, + pub currency_code: String, + pub note: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReconciliationDifference { + pub financial_account_id: String, + pub snapshot_id: String, + pub difference_minor_units: i64, + pub currency_code: String, +} + +#[async_trait::async_trait] +pub trait BalanceRepository: Send + Sync { + async fn list_balance_snapshots( + &self, + financial_account_id: &str, + ) -> Result, ApiError>; +} diff --git a/services/backend/src/domains/finance/budgets/mod.rs b/services/backend/src/domains/finance/budgets/mod.rs new file mode 100644 index 0000000..17ee032 --- /dev/null +++ b/services/backend/src/domains/finance/budgets/mod.rs @@ -0,0 +1,42 @@ +#![allow(dead_code)] + +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::ApiError; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BudgetStatus { + Active, + Archived, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Budget { + pub id: String, + pub budget_month: NaiveDate, + pub currency_code: String, + pub status: BudgetStatus, + pub archived_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BudgetAllocation { + pub id: String, + pub budget_id: String, + pub category_id: String, + pub planned_amount_minor_units: i64, + pub currency_code: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[async_trait::async_trait] +pub trait BudgetRepository: Send + Sync { + async fn list_budgets(&self) -> Result, ApiError>; +} diff --git a/services/backend/src/domains/finance/categories/mod.rs b/services/backend/src/domains/finance/categories/mod.rs new file mode 100644 index 0000000..0d09610 --- /dev/null +++ b/services/backend/src/domains/finance/categories/mod.rs @@ -0,0 +1,30 @@ +#![allow(dead_code)] + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::ApiError; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CategoryStatus { + Active, + Archived, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Category { + pub id: String, + pub parent_category_id: Option, + pub name: String, + pub status: CategoryStatus, + pub archived_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[async_trait::async_trait] +pub trait CategoryRepository: Send + Sync { + async fn list_categories(&self) -> Result, ApiError>; +} diff --git a/services/backend/src/domains/finance/dashboard/mod.rs b/services/backend/src/domains/finance/dashboard/mod.rs new file mode 100644 index 0000000..b1ab2f2 --- /dev/null +++ b/services/backend/src/domains/finance/dashboard/mod.rs @@ -0,0 +1,20 @@ +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; + +use crate::error::ApiError; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FinanceDashboardSummary { + pub net_worth_minor_units: i64, + pub currency_code: String, + pub month_income_minor_units: i64, + pub month_expense_minor_units: i64, + pub pending_reminder_count: usize, +} + +#[async_trait::async_trait] +pub trait FinanceDashboardRepository: Send + Sync { + async fn load_dashboard_summary(&self) -> Result; +} diff --git a/services/backend/src/domains/finance/ledger/mod.rs b/services/backend/src/domains/finance/ledger/mod.rs new file mode 100644 index 0000000..9c9edfc --- /dev/null +++ b/services/backend/src/domains/finance/ledger/mod.rs @@ -0,0 +1,86 @@ +#![allow(dead_code)] + +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::ApiError; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LedgerEntryKind { + Income, + Expense, + ExpenseReversal, + Transfer, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LedgerEntryStatus { + Candidate, + Confirmed, + Dismissed, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LedgerEntryCandidateKind { + Imported, + Recurring, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LedgerEntrySourceType { + Manual, + Source, + System, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LedgerEntrySourceKind { + Imported, + Recurring, + Manual, + System, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LedgerEntry { + pub id: String, + pub entry_kind: LedgerEntryKind, + pub entry_status: LedgerEntryStatus, + pub candidate_kind: Option, + pub financial_account_id: Option, + pub category_id: Option, + pub transfer_account_id: Option, + pub recurring_plan_id: Option, + pub source_id: Option, + pub transaction_date: NaiveDate, + pub description: String, + pub note: Option, + pub amount_minor_units: i64, + pub currency_code: String, + pub source_type: LedgerEntrySourceType, + pub related_ledger_entry_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LedgerEntrySource { + pub id: String, + pub source_kind: LedgerEntrySourceKind, + pub name: Option, + pub payload_json: Option, + pub payload_blob: Option>, + pub created_at: DateTime, +} + +#[async_trait::async_trait] +pub trait LedgerRepository: Send + Sync { + async fn list_ledger_entries(&self) -> Result, ApiError>; +} diff --git a/services/backend/src/domains/finance/mod.rs b/services/backend/src/domains/finance/mod.rs index 003c5e3..375e5b2 100644 --- a/services/backend/src/domains/finance/mod.rs +++ b/services/backend/src/domains/finance/mod.rs @@ -1,7 +1,14 @@ +pub mod accounts; +pub mod balances; +pub mod budgets; +pub mod categories; pub mod contracts; +pub mod dashboard; pub mod http; +pub mod ledger; pub mod model; pub mod repository; +pub mod recurring; pub mod service; #[cfg(test)] diff --git a/services/backend/src/domains/finance/recurring/mod.rs b/services/backend/src/domains/finance/recurring/mod.rs new file mode 100644 index 0000000..c840d92 --- /dev/null +++ b/services/backend/src/domains/finance/recurring/mod.rs @@ -0,0 +1,56 @@ +#![allow(dead_code)] + +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::ApiError; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub enum RecurringPlanKind { + #[serde(rename = "expected_income")] + Income, + #[serde(rename = "expected_expense")] + Expense, + #[serde(rename = "expected_transfer")] + Transfer, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RecurringScheduleKind { + Weekly, + Monthly, + Yearly, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RecurringPlanStatus { + Active, + Archived, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RecurringPlan { + pub id: String, + pub plan_kind: RecurringPlanKind, + pub schedule_kind: RecurringScheduleKind, + pub status: RecurringPlanStatus, + pub amount_minor_units: i64, + pub currency_code: String, + pub source_account_id: Option, + pub destination_account_id: Option, + pub category_id: Option, + pub description: String, + pub next_due_date: NaiveDate, + pub reminder_lead_days: i32, + pub archived_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[async_trait::async_trait] +pub trait RecurringRepository: Send + Sync { + async fn list_recurring_plans(&self) -> Result, ApiError>; +} diff --git a/services/backend/src/domains/finance/repository.rs b/services/backend/src/domains/finance/repository.rs index b66456c..bca3ec8 100644 --- a/services/backend/src/domains/finance/repository.rs +++ b/services/backend/src/domains/finance/repository.rs @@ -16,72 +16,152 @@ const DB_CONNECT_RETRY_DELAY: Duration = Duration::from_secs(1); const SELECT_TRANSACTIONS_SQL: &str = r#" SELECT - id::text, - direction, - transaction_date, - description, - category, - note, - amount_minor_units, - currency_code, - created_at, - updated_at -FROM service_finance.transactions -WHERE transaction_date >= $1 - AND transaction_date < $2 + ledger_entries.id::text, + ledger_entries.entry_kind AS direction, + ledger_entries.transaction_date, + ledger_entries.description, + categories.name AS category, + ledger_entries.note, + ledger_entries.amount_minor_units, + ledger_entries.currency_code, + ledger_entries.created_at, + ledger_entries.updated_at +FROM service_finance.ledger_entries +LEFT JOIN service_finance.categories + ON categories.id = ledger_entries.category_id +WHERE ledger_entries.transaction_date >= $1 + AND ledger_entries.transaction_date < $2 + AND ledger_entries.entry_kind IN ('income', 'expense') + AND ledger_entries.entry_status = 'confirmed' ORDER BY transaction_date DESC, created_at DESC, id DESC "#; +const INSERT_CATEGORY_SQL: &str = r#" +INSERT INTO service_finance.categories (name) +VALUES ($1) +ON CONFLICT ( + (COALESCE(parent_category_id, '00000000-0000-0000-0000-000000000000'::uuid)), + lower(name) +) +DO UPDATE SET name = EXCLUDED.name, updated_at = now() +"#; + const INSERT_TRANSACTION_SQL: &str = r#" -INSERT INTO service_finance.transactions ( - direction, - transaction_date, - description, - category, - note, - amount_minor_units +WITH inserted_entry AS ( + INSERT INTO service_finance.ledger_entries ( + entry_kind, + entry_status, + source_type, + category_id, + transaction_date, + description, + note, + amount_minor_units, + currency_code + ) + SELECT + $1, + 'confirmed', + 'manual', + categories.id, + $2, + $3, + $4, + $5, + 'EUR' + FROM service_finance.categories + WHERE $6::text IS NOT NULL + AND categories.parent_category_id IS NULL + AND lower(categories.name) = lower($6::text) + UNION ALL + SELECT $1, 'confirmed', 'manual', NULL, $2, $3, $4, $5, 'EUR' + WHERE $6::text IS NULL + RETURNING * ) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING - id::text, - direction, - transaction_date, - description, - category, - note, - amount_minor_units, - currency_code, - created_at, - updated_at +SELECT + inserted_entry.id::text, + inserted_entry.entry_kind AS direction, + inserted_entry.transaction_date, + inserted_entry.description, + categories.name AS category, + inserted_entry.note, + inserted_entry.amount_minor_units, + inserted_entry.currency_code, + inserted_entry.created_at, + inserted_entry.updated_at +FROM inserted_entry +LEFT JOIN service_finance.categories + ON categories.id = inserted_entry.category_id "#; const UPDATE_TRANSACTION_SQL: &str = r#" -UPDATE service_finance.transactions -SET - direction = $2, - transaction_date = $3, - description = $4, - category = $5, - note = $6, - amount_minor_units = $7, - updated_at = now() -WHERE id = $1::uuid -RETURNING - id::text, - direction, - transaction_date, - description, - category, - note, - amount_minor_units, - currency_code, - created_at, - updated_at +WITH updated_entry AS ( + UPDATE service_finance.ledger_entries + SET + entry_kind = $2, + entry_status = 'confirmed', + source_type = 'manual', + category_id = categories.id, + transaction_date = $3, + description = $4, + note = $5, + amount_minor_units = $6, + currency_code = 'EUR', + updated_at = now() + FROM service_finance.categories + WHERE ledger_entries.id = $1::uuid + AND ledger_entries.entry_kind IN ('income', 'expense') + AND ledger_entries.entry_status = 'confirmed' + AND $7::text IS NOT NULL + AND categories.parent_category_id IS NULL + AND lower(categories.name) = lower($7::text) + RETURNING * +), +updated_uncategorized_entry AS ( + UPDATE service_finance.ledger_entries + SET + entry_kind = $2, + entry_status = 'confirmed', + source_type = 'manual', + category_id = NULL, + transaction_date = $3, + description = $4, + note = $5, + amount_minor_units = $6, + currency_code = 'EUR', + updated_at = now() + WHERE ledger_entries.id = $1::uuid + AND entry_kind IN ('income', 'expense') + AND entry_status = 'confirmed' + AND $7::text IS NULL + RETURNING * +), +selected_entry AS ( + SELECT * FROM updated_entry + UNION ALL + SELECT * FROM updated_uncategorized_entry +) +SELECT + selected_entry.id::text, + selected_entry.entry_kind AS direction, + selected_entry.transaction_date, + selected_entry.description, + categories.name AS category, + selected_entry.note, + selected_entry.amount_minor_units, + selected_entry.currency_code, + selected_entry.created_at, + selected_entry.updated_at +FROM selected_entry +LEFT JOIN service_finance.categories + ON categories.id = selected_entry.category_id "#; const DELETE_TRANSACTION_SQL: &str = r#" -DELETE FROM service_finance.transactions +DELETE FROM service_finance.ledger_entries WHERE id = $1::uuid + AND entry_kind IN ('income', 'expense') + AND entry_status = 'confirmed' "#; #[derive(Clone)] @@ -164,6 +244,8 @@ impl FinanceRepository { &self, draft: &TransactionDraft, ) -> Result { + self.ensure_category(&draft.category).await?; + let row = self .client .query_one( @@ -172,9 +254,9 @@ impl FinanceRepository { &draft.direction.as_str(), &draft.transaction_date, &draft.description, - &draft.category, &draft.note, &draft.amount_minor_units, + &draft.category, ], ) .await @@ -190,6 +272,8 @@ impl FinanceRepository { id: &str, draft: &TransactionDraft, ) -> Result { + self.ensure_category(&draft.category).await?; + let row = self .client .query_opt( @@ -199,9 +283,9 @@ impl FinanceRepository { &draft.direction.as_str(), &draft.transaction_date, &draft.description, - &draft.category, &draft.note, &draft.amount_minor_units, + &draft.category, ], ) .await @@ -239,6 +323,20 @@ impl FinanceRepository { Ok(()) } + + async fn ensure_category(&self, category: &Option) -> Result<(), ApiError> { + let Some(category) = category else { + return Ok(()); + }; + + self + .client + .execute(INSERT_CATEGORY_SQL, &[category]) + .await + .map_err(|error| ApiError::Internal(format!("Failed to prepare finance category: {error}")))?; + + Ok(()) + } } fn row_to_transaction(row: &Row) -> Result {