Implement income statement report using libdrcr

This commit is contained in:
RunasSudo 2025-05-27 00:22:52 +10:00
parent a967c87dab
commit 807316a090
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
3 changed files with 104 additions and 85 deletions

View File

@ -1,17 +1,17 @@
/* /*
DrCr: Web-based double-entry bookkeeping framework DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222024 Lee Yingtong Li (RunasSudo) Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify 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 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 the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License 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/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@ -33,20 +33,26 @@ struct AppState {
// Filename state // Filename state
#[tauri::command] #[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; let state = state.lock().await;
Ok(state.db_filename.clone()) Ok(state.db_filename.clone())
} }
#[tauri::command] #[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; let mut state = state.lock().await;
state.db_filename = filename.clone(); state.db_filename = filename.clone();
// Persist in store // Persist in store
let store = app.store("store.json").expect("Error opening store"); let store = app.store("store.json").expect("Error opening store");
store.set("db_filename", filename); store.set("db_filename", filename);
Ok(()) Ok(())
} }
@ -66,15 +72,15 @@ pub fn run() {
} else { } else {
None None
} }
}, }
_ => panic!("Unexpected db_filename in store") _ => panic!("Unexpected db_filename in store"),
}; };
app.manage(Mutex::new(AppState { app.manage(Mutex::new(AppState {
db_filename: db_filename, db_filename: db_filename,
sql_transactions: Vec::new(), sql_transactions: Vec::new(),
})); }));
Ok(()) Ok(())
}) })
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@ -82,9 +88,15 @@ pub fn run() {
.plugin(tauri_plugin_sql::Builder::new().build()) .plugin(tauri_plugin_sql::Builder::new().build())
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_open_filename, set_open_filename, get_open_filename,
set_open_filename,
libdrcr_bridge::get_balance_sheet, 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!()) .run(tauri::generate_context!())
.expect("Error while running tauri application"); .expect("Error while running tauri application");

View File

@ -23,8 +23,8 @@ use libdrcr::reporting::dynamic_report::DynamicReport;
use libdrcr::reporting::generate_report; use libdrcr::reporting::generate_report;
use libdrcr::reporting::steps::register_lookup_fns; use libdrcr::reporting::steps::register_lookup_fns;
use libdrcr::reporting::types::{ use libdrcr::reporting::types::{
DateArgs, MultipleDateArgs, ReportingContext, ReportingProductId, ReportingProductKind, DateArgs, DateStartDateEndArgs, MultipleDateArgs, MultipleDateStartDateEndArgs,
VoidArgs, ReportingContext, ReportingProductId, ReportingProductKind, VoidArgs,
}; };
use tauri::State; use tauri::State;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@ -94,3 +94,67 @@ pub(crate) async fn get_balance_sheet(
.await .await
.unwrap() .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()
}

View File

@ -1,6 +1,6 @@
<!-- <!--
DrCr: Web-based double-entry bookkeeping framework DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222025 Lee Yingtong Li (RunasSudo) Copyright (C) 2022-2025 Lee Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify 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 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/>. 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> <template>
<ComparativeDynamicReportComponent :reports="reports" :labels="reportLabels"> <DynamicReportComponent :report="report">
<div class="my-2 py-2 flex"> <div class="my-2 py-2 flex">
<div class="grow flex gap-x-2 items-baseline"> <div class="grow flex gap-x-2 items-baseline">
<input type="date" class="bordered-field" v-model.lazy="dtStart"> <input type="date" class="bordered-field" v-model.lazy="dtStart">
@ -75,21 +35,20 @@
</div> </div>
</div> </div>
</div> </div>
</ComparativeDynamicReportComponent> </DynamicReportComponent>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue';
import dayjs from 'dayjs'; 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 { db } from '../db.ts';
import { ExtendedDatabase } from '../dbutil.ts'; import { ExtendedDatabase } from '../dbutil.ts';
import ComparativeDynamicReportComponent from '../components/ComparativeDynamicReportComponent.vue'; import DynamicReportComponent from '../components/DynamicReportComponent.vue';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
const reports = ref([] as IncomeStatementReport[]); const report = ref(null as DynamicReport | null);
const reportLabels = ref([] as string[]);
const dt = ref(null as string | null); const dt = ref(null as string | null);
const dtStart = ref(null as string | null); const dtStart = ref(null as string | null);
@ -114,16 +73,14 @@
} }
async function updateReport(session: ExtendedDatabase) { async function updateReport(session: ExtendedDatabase) {
const newReportPromises = []; const reportDates = [];
const newReportLabels = [];
for (let i = 0; i < comparePeriods.value; i++) { for (let i = 0; i < comparePeriods.value; i++) {
let thisReportDt, thisReportDtStart, thisReportLabel; let thisReportDt, thisReportDtStart;
// Get period start and end dates // Get period start and end dates
if (compareUnit.value === 'years') { if (compareUnit.value === 'years') {
thisReportDt = dayjs(dt.value!).subtract(i, 'year'); thisReportDt = dayjs(dt.value!).subtract(i, 'year');
thisReportDtStart = dayjs(dtStart.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') { } else if (compareUnit.value === 'months') {
if (dayjs(dt.value!).add(1, 'day').isSame(dayjs(dt.value!).set('date', 1).add(1, 'month'))) { 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 // 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'); thisReportDt = dayjs(dt.value!).subtract(i, 'month');
thisReportDtStart = dayjs(dtStart.value!).subtract(i, 'month'); thisReportDtStart = dayjs(dtStart.value!).subtract(i, 'month');
} }
thisReportLabel = dayjs(dt.value!).subtract(i, 'month').format('YYYY-MM');
} else { } else {
throw new Error('Unexpected compareUnit'); throw new Error('Unexpected compareUnit');
} }
// Generate reports asynchronously reportDates.push([thisReportDtStart.format('YYYY-MM-DD'), thisReportDt.format('YYYY-MM-DD')]);
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);
}
} }
reports.value = await Promise.all(newReportPromises); report.value = JSON.parse(await invoke('get_income_statement', { eofyDate: db.metadata.eofy_date, dates: reportDates }));
reportLabels.value = newReportLabels;
} }
load(); load();