/*
DrCr: Web-based double-entry bookkeeping framework
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 .
*/
import { invoke } from '@tauri-apps/api/core';
import { resolveResource } from '@tauri-apps/api/path';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { readTextFile } from '@tauri-apps/plugin-fs';
import Database from '@tauri-apps/plugin-sql';
import { reactive } from 'vue';
import { Balance } from './amounts.ts';
import { ExtendedDatabase } from './dbutil.ts';
import { CriticalError } from './error.ts';
export const DB_VERSION = 3; // Should match schema.sql
export const DT_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSS000';
export const db = reactive({
filename: null as (string | null),
// Cached
metadata: {
version: null! as number,
eofy_date: null! as string,
reporting_commodity: null! as string,
dps: null! as number,
},
init: async function(filename: string | null): Promise {
// Set the DB filename and initialise cached data
this.filename = filename;
await invoke('set_open_filename', { 'filename': filename });
if (filename !== null) {
await getCurrentWindow().setTitle('DrCr – ' + filename?.replaceAll('\\', '/').split('/').at(-1));
} else {
await getCurrentWindow().setTitle('DrCr');
}
if (filename !== null) {
const session = await this.load();
// Validate database version
let dbVersion: {value: string}[];
try {
dbVersion = await session.select("SELECT value FROM metadata WHERE key = 'version'");
} catch (err) {
throw new CriticalError('Unable to parse database (SQL error getting metadata.version)', err);
}
if (dbVersion.length === 0) {
throw new CriticalError('Unable to parse database (no metadata.version)');
}
if (dbVersion[0].value !== DB_VERSION.toString()) {
throw new CriticalError('Unsupported database version ' + dbVersion[0].value + ' (expected ' + DB_VERSION + ')');
}
// Initialise cached data
const metadataRaw: {key: string, value: string}[] = await session.select("SELECT * FROM metadata");
const metadataObject = Object.fromEntries(metadataRaw.map((x) => [x.key, x.value]));
this.metadata.version = parseInt(metadataObject.version);
this.metadata.eofy_date = metadataObject.eofy_date;
this.metadata.reporting_commodity = metadataObject.reporting_commodity;
this.metadata.dps = parseInt(metadataObject.amount_dps);
}
},
load: async function(): Promise {
return new ExtendedDatabase(await Database.load('sqlite:' + this.filename));
},
});
export async function createNewDatabase(filename: string, eofy_date: string, reporting_commodity: string, dps: number) {
// Open new SQLite database
const session = new ExtendedDatabase(await Database.load('sqlite:' + filename));
// Read SQL schema
const schemaPath = await resolveResource('schema.sql');
const schemaSql = await readTextFile(schemaPath);
// Execute SQL
const transaction = await session.begin();
await transaction.execute(schemaSql);
// Init metadata
await transaction.execute(
`INSERT INTO metadata (key, value) VALUES (?, ?)`,
['version', DB_VERSION.toString()] // Manually call .toString() to format as int, otherwise sqlx formats as float
);
await transaction.execute(
`INSERT INTO metadata (key, value) VALUES (?, ?)`,
['eofy_date', eofy_date]
);
await transaction.execute(
`INSERT INTO metadata (key, value) VALUES (?, ?)`,
['reporting_commodity', reporting_commodity]
);
await transaction.execute(
`INSERT INTO metadata (key, value) VALUES (?, ?)`,
['amount_dps', dps.toString()] // Manually call .toString() to format as int, otherwise sqlx formats as float
);
await transaction.commit();
}
export function joinedToTransactions(joinedTransactionPostings: JoinedTransactionPosting[]): Transaction[] {
// Group postings into transactions
const transactions: Transaction[] = [];
for (const joinedTransactionPosting of joinedTransactionPostings) {
if (transactions.length === 0 || transactions.at(-1)!.id !== joinedTransactionPosting.transaction_id) {
transactions.push(new Transaction(
joinedTransactionPosting.transaction_id,
joinedTransactionPosting.dt,
joinedTransactionPosting.transaction_description,
[]
));
}
transactions.at(-1)!.postings.push({
id: joinedTransactionPosting.id,
description: joinedTransactionPosting.description,
account: joinedTransactionPosting.account,
quantity: joinedTransactionPosting.quantity,
commodity: joinedTransactionPosting.commodity,
quantity_ascost: joinedTransactionPosting.quantity_ascost,
running_balance: joinedTransactionPosting.running_balance
});
}
return transactions;
}
export function serialiseAmount(quantity: number, commodity: string): string {
// Pretty print the amount for an editable input
if (quantity < 0) {
return '-' + serialiseAmount(-quantity, commodity);
}
// Scale quantity by decimal places
const factor = Math.pow(10, db.metadata.dps);
const wholePart = Math.floor(quantity / factor);
const fracPart = quantity % factor;
const quantityString = wholePart.toString() + '.' + fracPart.toString().padStart(db.metadata.dps, '0');
if (commodity === db.metadata.reporting_commodity) {
return quantityString;
}
if (commodity.length === 1) {
return commodity + quantityString;
}
return quantityString + ' ' + commodity;
}
function parseFloatStrict(quantity: string): number {
// Parses quantity as a float, throwing error on invalid input
if (!/^[0-9]+(\.[0-9]+)?$/.test(quantity)) {
throw new DeserialiseAmountError('Invalid quantity: ' + quantity);
}
return parseFloat(quantity);
}
export function deserialiseAmount(amount: string): { quantity: number, commodity: string } {
const factor = Math.pow(10, db.metadata.dps);
if (amount.length === 0) {
throw new DeserialiseAmountError('Amount cannot be blank');
}
if (amount.charAt(0) < '0' || amount.charAt(0) > '9') {
// Check for single letter commodity
if (amount.length === 1) {
throw new DeserialiseAmountError('Quantity cannot be blank (expected quantity after commodity symbol ' + amount + ')');
}
if (amount.charAt(1) < '0' || amount.charAt(1) > '9') {
throw new DeserialiseAmountError('Invalid quantity: ' + amount + ' (expected quantity after single-letter commodity symbol ' + amount.charAt(0) + ')');
}
let quantity, commodity;
if (amount.indexOf(' ') < 0) {
// No cost base
quantity = Math.round(parseFloatStrict(amount.substring(1)) * factor);
commodity = amount.charAt(0);
} else {
// Cost base specified
quantity = Math.round(parseFloatStrict(amount.substring(1, amount.indexOf(' '))) * factor);
commodity = amount.charAt(0) + amount.substring(amount.indexOf(' '));
}
if (!Number.isSafeInteger(quantity)) { throw new DeserialiseAmountError('Quantity not representable by safe integer: ' + amount); }
return {
'quantity': quantity,
'commodity': commodity
};
}
if (amount.indexOf(' ') < 0) {
// Default commodity
const quantity = Math.round(parseFloatStrict(amount) * factor);
if (!Number.isSafeInteger(quantity)) { throw new DeserialiseAmountError('Quantity not representable by safe integer: ' + amount); }
return {
'quantity': quantity,
commodity: db.metadata.reporting_commodity
};
}
// Must be multi-letter commodity
const quantityStr = amount.substring(0, amount.indexOf(' '));
const quantity = Math.round(parseFloatStrict(quantityStr) * factor)
if (!Number.isSafeInteger(quantity)) { throw new DeserialiseAmountError('Quantity not representable by safe integer: ' + amount); }
const commodity = amount.substring(amount.indexOf(' ') + 1);
return {
'quantity': quantity,
'commodity': commodity
};
}
export class DeserialiseAmountError extends Error {}
// Type definitions
export class Transaction {
constructor(
public id: number | null = null,
public dt: string = '',
public description: string = '',
public postings: Posting[] = [],
) {}
doesBalance(): boolean {
const balance = new Balance();
for (const posting of this.postings) {
balance.add(posting.quantity, posting.commodity);
}
balance.clean();
return balance.amounts.length === 0;
}
}
export interface Posting {
id: number | null,
description: string | null,
account: string,
quantity: number,
commodity: string,
quantity_ascost?: number,
running_balance?: number
}
export interface JoinedTransactionPosting {
transaction_id: number,
dt: string,
transaction_description: string,
id: number,
description: string,
account: string,
quantity: number,
commodity: string,
quantity_ascost?: number,
running_balance?: number
}
export interface StatementLine {
id: number | null,
source_account: string,
dt: string,
description: string,
quantity: number,
balance: number | null,
commodity: string
}