Implement income statement report using libdrcr
This commit is contained in:
parent
a967c87dab
commit
807316a090
@ -1,17 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
DrCr: Web-based double-entry bookkeeping framework
|
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
|
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");
|
||||||
|
@ -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()
|
||||||
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<!--
|
<!--
|
||||||
DrCr: Web-based double-entry bookkeeping framework
|
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
|
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();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user