From 807316a0901c8234ffbeb85d692b8dd985db0170 Mon Sep 17 00:00:00 2001 From: RunasSudo <runassudo@yingtongli.me> Date: Tue, 27 May 2025 00:22:52 +1000 Subject: [PATCH] Implement income statement report using libdrcr --- src-tauri/src/lib.rs | 40 ++++++++----- src-tauri/src/libdrcr_bridge.rs | 68 +++++++++++++++++++++- src/reports/IncomeStatementReport.vue | 81 ++++----------------------- 3 files changed, 104 insertions(+), 85 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e120918..4c57e3e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,17 +1,17 @@ /* DrCr: Web-based double-entry bookkeeping framework - Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo) - + 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 <https://www.gnu.org/licenses/>. */ @@ -33,20 +33,26 @@ struct AppState { // Filename state #[tauri::command] -async fn get_open_filename(state: State<'_, Mutex<AppState>>) -> Result<Option<String>, tauri_plugin_sql::Error> { +async fn get_open_filename( + state: State<'_, Mutex<AppState>>, +) -> Result<Option<String>, tauri_plugin_sql::Error> { let state = state.lock().await; Ok(state.db_filename.clone()) } #[tauri::command] -async fn set_open_filename(state: State<'_, Mutex<AppState>>, app: AppHandle, filename: Option<String>) -> Result<(), tauri_plugin_sql::Error> { +async fn set_open_filename( + state: State<'_, Mutex<AppState>>, + app: AppHandle, + filename: Option<String>, +) -> Result<(), tauri_plugin_sql::Error> { let mut state = state.lock().await; state.db_filename = filename.clone(); - + // Persist in store let store = app.store("store.json").expect("Error opening store"); store.set("db_filename", filename); - + Ok(()) } @@ -66,15 +72,15 @@ pub fn run() { } else { None } - }, - _ => panic!("Unexpected db_filename in store") + } + _ => panic!("Unexpected db_filename in store"), }; - + app.manage(Mutex::new(AppState { db_filename: db_filename, sql_transactions: Vec::new(), })); - + Ok(()) }) .plugin(tauri_plugin_dialog::init()) @@ -82,9 +88,15 @@ pub fn run() { .plugin(tauri_plugin_sql::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build()) .invoke_handler(tauri::generate_handler![ - get_open_filename, set_open_filename, + get_open_filename, + set_open_filename, libdrcr_bridge::get_balance_sheet, - sql::sql_transaction_begin, sql::sql_transaction_execute, sql::sql_transaction_select, sql::sql_transaction_rollback, sql::sql_transaction_commit + libdrcr_bridge::get_income_statement, + sql::sql_transaction_begin, + sql::sql_transaction_execute, + sql::sql_transaction_select, + sql::sql_transaction_rollback, + sql::sql_transaction_commit ]) .run(tauri::generate_context!()) .expect("Error while running tauri application"); diff --git a/src-tauri/src/libdrcr_bridge.rs b/src-tauri/src/libdrcr_bridge.rs index 1541ae2..ead5a20 100644 --- a/src-tauri/src/libdrcr_bridge.rs +++ b/src-tauri/src/libdrcr_bridge.rs @@ -23,8 +23,8 @@ use libdrcr::reporting::dynamic_report::DynamicReport; use libdrcr::reporting::generate_report; use libdrcr::reporting::steps::register_lookup_fns; use libdrcr::reporting::types::{ - DateArgs, MultipleDateArgs, ReportingContext, ReportingProductId, ReportingProductKind, - VoidArgs, + DateArgs, DateStartDateEndArgs, MultipleDateArgs, MultipleDateStartDateEndArgs, + ReportingContext, ReportingProductId, ReportingProductKind, VoidArgs, }; use tauri::State; use tokio::sync::Mutex; @@ -94,3 +94,67 @@ pub(crate) async fn get_balance_sheet( .await .unwrap() } + +#[tauri::command] +pub(crate) async fn get_income_statement( + state: State<'_, Mutex<AppState>>, + eofy_date: String, + dates: Vec<(String, String)>, +) -> Result<String, ()> { + let state = state.lock().await; + let db_filename = state.db_filename.clone().unwrap(); + + spawn_blocking(move || { + // Connect to database + let db_connection = + DbConnection::connect(format!("sqlite:{}", db_filename.as_str()).as_str()); + + // Initialise ReportingContext + let mut context = ReportingContext::new( + db_connection, + NaiveDate::parse_from_str(eofy_date.as_str(), "%Y-%m-%d").unwrap(), + "$".to_string(), + ); + register_lookup_fns(&mut context); + register_dynamic_builders(&mut context); + + // Get income statement + let mut date_args = Vec::new(); + for (date_start, date_end) in dates.iter() { + date_args.push(DateStartDateEndArgs { + date_start: NaiveDate::parse_from_str(date_start, "%Y-%m-%d").unwrap(), + date_end: NaiveDate::parse_from_str(date_end, "%Y-%m-%d").unwrap(), + }) + } + let targets = vec![ + ReportingProductId { + name: "CalculateIncomeTax", + kind: ReportingProductKind::Transactions, + args: Box::new(VoidArgs {}), + }, + ReportingProductId { + name: "IncomeStatement", + kind: ReportingProductKind::Generic, + args: Box::new(MultipleDateStartDateEndArgs { + dates: date_args.clone(), + }), + }, + ]; + + // Run report + let products = generate_report(targets, &context).unwrap(); + let result = products + .get_or_err(&ReportingProductId { + name: "IncomeStatement", + kind: ReportingProductKind::Generic, + args: Box::new(MultipleDateStartDateEndArgs { dates: date_args }), + }) + .unwrap(); + + let income_statement = result.downcast_ref::<DynamicReport>().unwrap().to_json(); + + Ok(income_statement) + }) + .await + .unwrap() +} diff --git a/src/reports/IncomeStatementReport.vue b/src/reports/IncomeStatementReport.vue index e363ff7..9482490 100644 --- a/src/reports/IncomeStatementReport.vue +++ b/src/reports/IncomeStatementReport.vue @@ -1,6 +1,6 @@ <!-- DrCr: Web-based double-entry bookkeeping framework - Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo) + 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 @@ -16,48 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. --> -<script lang="ts"> - export class IncomeStatementReport extends DynamicReport { - constructor() { - super('Income statement'); - } - - async generate(balances: Map<string, number>) { - const report = this; - this.entries = [ - new Section( - 'Income', - [ - ...await DynamicReport.entriesForKind(balances, 'drcr.income', true), - new Subtotal('Total income', 'total_income', true /* visible */, true /* bordered */) - ] - ), - new Spacer(), - new Section( - 'Expenses', - [ - ...await DynamicReport.entriesForKind(balances, 'drcr.expense'), - new Subtotal('Total expenses', 'total_expenses', true /* visible */, true /* bordered */) - ] - ), - new Spacer(), - new Computed( - 'Net surplus (deficit)', - () => (report.byId('total_income') as Subtotal).quantity - (report.byId('total_expenses') as Subtotal).quantity, - 'net_surplus', - true /* visible */, false /* autoHide */, null /* link */, true /* heading */, true /* bordered */ - ) - ]; - - this.calculate(); - } - } -</script> - -<!-- Report display --> - <template> - <ComparativeDynamicReportComponent :reports="reports" :labels="reportLabels"> + <DynamicReportComponent :report="report"> <div class="my-2 py-2 flex"> <div class="grow flex gap-x-2 items-baseline"> <input type="date" class="bordered-field" v-model.lazy="dtStart"> @@ -75,21 +35,20 @@ </div> </div> </div> - </ComparativeDynamicReportComponent> + </DynamicReportComponent> </template> <script setup lang="ts"> - import { ref, watch } from 'vue'; import dayjs from 'dayjs'; + import { invoke } from '@tauri-apps/api/core'; + import { ref, watch } from 'vue'; - import { Computed, DynamicReport, Section, Spacer, Subtotal } from './base.ts'; + import { DynamicReport } from './base.ts'; import { db } from '../db.ts'; import { ExtendedDatabase } from '../dbutil.ts'; - import ComparativeDynamicReportComponent from '../components/ComparativeDynamicReportComponent.vue'; - import { ReportingStage, ReportingWorkflow } from '../reporting.ts'; + import DynamicReportComponent from '../components/DynamicReportComponent.vue'; - const reports = ref([] as IncomeStatementReport[]); - const reportLabels = ref([] as string[]); + const report = ref(null as DynamicReport | null); const dt = ref(null as string | null); const dtStart = ref(null as string | null); @@ -114,16 +73,14 @@ } async function updateReport(session: ExtendedDatabase) { - const newReportPromises = []; - const newReportLabels = []; + const reportDates = []; for (let i = 0; i < comparePeriods.value; i++) { - let thisReportDt, thisReportDtStart, thisReportLabel; + let thisReportDt, thisReportDtStart; // Get period start and end dates if (compareUnit.value === 'years') { thisReportDt = dayjs(dt.value!).subtract(i, 'year'); thisReportDtStart = dayjs(dtStart.value!).subtract(i, 'year'); - thisReportLabel = dayjs(dt.value!).subtract(i, 'year').format('YYYY'); } else if (compareUnit.value === 'months') { if (dayjs(dt.value!).add(1, 'day').isSame(dayjs(dt.value!).set('date', 1).add(1, 'month'))) { // If dt is the end of a calendar month, then fix each prior dt to be the end of the calendar month @@ -133,28 +90,14 @@ thisReportDt = dayjs(dt.value!).subtract(i, 'month'); thisReportDtStart = dayjs(dtStart.value!).subtract(i, 'month'); } - thisReportLabel = dayjs(dt.value!).subtract(i, 'month').format('YYYY-MM'); } else { throw new Error('Unexpected compareUnit'); } - // Generate reports asynchronously - newReportPromises.push((async () => { - const reportingWorkflow = new ReportingWorkflow(); - await reportingWorkflow.generate(session, thisReportDt.format('YYYY-MM-DD'), thisReportDtStart.format('YYYY-MM-DD')); - return reportingWorkflow.getReportAtStage(ReportingStage.InterimIncomeStatement, IncomeStatementReport) as IncomeStatementReport; - })()); - - if (comparePeriods.value === 1) { - // If only 1 report, the heading is simply "$" - newReportLabels.push(db.metadata.reporting_commodity); - } else { - newReportLabels.push(thisReportLabel); - } + reportDates.push([thisReportDtStart.format('YYYY-MM-DD'), thisReportDt.format('YYYY-MM-DD')]); } - reports.value = await Promise.all(newReportPromises); - reportLabels.value = newReportLabels; + report.value = JSON.parse(await invoke('get_income_statement', { eofyDate: db.metadata.eofy_date, dates: reportDates })); } load();