323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
/*
|
|
DrCr: 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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 { asCost } 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,
|
|
plugins: null! as string[],
|
|
},
|
|
|
|
init: async function(filename: string | null): Promise<void> {
|
|
// 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);
|
|
this.metadata.plugins = metadataObject.plugins.length > 0 ? metadataObject.plugins.split(';') : [];
|
|
}
|
|
},
|
|
|
|
load: async function(): Promise<ExtendedDatabase> {
|
|
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.execute(
|
|
`INSERT INTO metadata (key, value) VALUES (?, ?)`,
|
|
['plugins', '']
|
|
);
|
|
|
|
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 validateCommodity(commodity: string) {
|
|
// Validate that the commodity is correctly formed
|
|
const commodityParts = commodity.split(' ');
|
|
if (commodityParts.length > 2) {
|
|
throw new DeserialiseAmountError('Invalid commodity (more spaces than expected): ' + commodity);
|
|
}
|
|
if (commodityParts.length === 2) {
|
|
// Validate that the second part is a cost basis
|
|
if (commodityParts[1].startsWith('{{') && commodityParts[1].endsWith('}}')) {
|
|
const costBase = commodityParts[1].substring(2, commodityParts[1].length - 2);
|
|
parseFloatStrict(costBase);
|
|
} else if (commodityParts[1].startsWith('{') && commodityParts[1].endsWith('}')) {
|
|
const costBase = commodityParts[1].substring(1, commodityParts[1].length - 1);
|
|
parseFloatStrict(costBase);
|
|
} else {
|
|
throw new DeserialiseAmountError('Invalid cost base: ' + commodityParts[1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
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); }
|
|
validateCommodity(commodity);
|
|
|
|
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);
|
|
validateCommodity(commodity);
|
|
|
|
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 {
|
|
let total = 0;
|
|
for (const posting of this.postings) {
|
|
total += asCost(posting.quantity, posting.commodity);
|
|
}
|
|
return total === 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
|
|
}
|