Files

194 lines
7.6 KiB
JavaScript
Raw Permalink Normal View History

2026-10-30 12:28:48 +01:00
// Generated, do not edit. sha256:a56d0789a1d23362a10302d4a28be7384372f8aa39766c83b8df92fa4456f11e
2026-10-29 11:57:29 +01:00
import { mkdir, readdir, rename, rm, readFile, writeFile, copyFile, stat } from 'node:fs/promises';
import { join, sep, resolve } from 'node:path';
import {
installDir, portfolioDir, archiveDir, assertValidName, sameName,
activeName, writeActiveName, listPortfolios,
} from './portfolios.mjs';
2026-10-30 12:28:48 +01:00
import { run, peekDatabase } from './db/conn.mjs';
2026-10-29 11:57:29 +01:00
import { createDatabase } from './db.js';
import { CONFIG } from '../shared/config.js';
import { fail } from '../shared/app-error.js';
import { capacityReached } from './license.mjs';
const BUREN = ['-wal', '-shm'];
const HANDLE_SLEUTEL = 'ai_handle_secret';
const bestaat = async (pad) => { try { await stat(pad); return true; } catch { return false; } };
function eisNietActief(naam, sleutel) {
if (sameName(naam, activeName())) {
fail(sleutel, { naam },
`'${naam}' is the portfolio that is open now; switch to another one first.`);
}
}
function eisBestaat(naam) {
const gevonden = listPortfolios().find(p => sameName(p.naam, naam));
if (!gevonden) fail('portfolios.unknownPortfolio', { naam }, `There is no portfolio '${naam}'.`);
return gevonden;
}
function eisVrijeNaam(naam) {
assertValidName(naam);
const botsing = listPortfolios().find(p => sameName(p.naam, naam));
if (botsing) {
fail('portfolios.nameInUse', { naam: botsing.naam },
`A portfolio '${botsing.naam}' already exists; names that differ only in case count as the same.`);
}
return naam;
}
export async function createPortfolio(naam, { kopieVan = null, baseCurrency = null } = {}) {
if (kopieVan && baseCurrency) {
throw new Error('A copy keeps the unit of account of its source; do not pass baseCurrency with kopieVan.');
}
2026-10-30 12:28:48 +01:00
const max = capacityReached('portfolios', listPortfolios().filter(p => !p.demo).length);
2026-10-29 11:57:29 +01:00
if (max !== null) {
fail('portfolios.maxFree', { max },
`The free version holds ${max} portfolio(s). A licence lifts that; nothing is removed in the meantime.`);
}
eisVrijeNaam(naam);
const bron = kopieVan ? eisBestaat(kopieVan) : null;
const doel = portfolioDir(naam);
await mkdir(doel, { recursive: true });
try {
if (!bron) {
createDatabase(join(doel, 'db.sqlite'), { baseCurrency: baseCurrency ?? CONFIG.PORTFOLIO.DEFAULT_BASE_CURRENCY });
return { naam, gekopieerdVan: null, database: true, geheimen: false };
}
const database = await kopieerDatabase(bron.naam, doel);
const geheimen = await kopieerGeheimen(bron.naam, doel);
return { naam, gekopieerdVan: bron.naam, database, geheimen };
} catch (e) {
await rm(doel, { recursive: true, force: true });
throw e;
}
}
async function kopieerDatabase(bronNaam, doelMap) {
const doel = join(doelMap, 'db.sqlite');
if (sameName(bronNaam, activeName())) {
run(`VACUUM INTO '${doel.replace(/'/g, "''")}'`, []);
return true;
}
const bron = join(portfolioDir(bronNaam), 'db.sqlite');
if (!await bestaat(bron)) return false;
await copyFile(bron, doel);
for (const b of BUREN) if (await bestaat(bron + b)) await copyFile(bron + b, doel + b);
return true;
}
async function kopieerGeheimen(bronNaam, doelMap) {
const bron = join(portfolioDir(bronNaam), 'secrets.json');
if (!await bestaat(bron)) return false;
const store = JSON.parse(await readFile(bron, 'utf-8'));
delete store[HANDLE_SLEUTEL];
await writeFile(join(doelMap, 'secrets.json'), JSON.stringify(store, null, 2), { mode: 0o600 });
return true;
}
export function activatePortfolio(naam) {
const doel = eisBestaat(naam);
if (sameName(doel.naam, activeName())) {
return { naam: doel.naam, gewisseld: false, supervised: isSupervised() };
}
writeActiveName(doel.naam);
return { naam: doel.naam, gewisseld: true, supervised: isSupervised() };
}
export const isSupervised = () => process.env.ASSETAPP_SUPERVISED === '1';
export async function renamePortfolio(vanNaam, naarNaam) {
const bron = eisBestaat(vanNaam);
eisNietActief(bron.naam, 'portfolios.activeCannotRename');
if (!sameName(bron.naam, naarNaam)) eisVrijeNaam(naarNaam);
else assertValidName(naarNaam);
await rename(portfolioDir(bron.naam), portfolioDir(naarNaam));
return { van: bron.naam, naar: naarNaam };
}
export async function archivePortfolio(naam) {
const bron = eisBestaat(naam);
eisNietActief(bron.naam, 'portfolios.activeCannotArchive');
await mkdir(archiveDir(), { recursive: true });
const doelNaam = await vrijeArchiefNaam(bron.naam);
await rename(portfolioDir(bron.naam), join(archiveDir(), doelNaam));
return { naam: bron.naam, archiefNaam: doelNaam, map: join(archiveDir(), doelNaam) };
}
async function vrijeArchiefNaam(naam) {
if (!await bestaat(join(archiveDir(), naam))) return naam;
for (let n = 2; n < 1000; n++) {
const kandidaat = `${naam} (${n})`;
if (!await bestaat(join(archiveDir(), kandidaat))) return kandidaat;
}
fail('portfolios.archiveFull', { naam }, `The archive is full of directories named '${naam}'.`);
}
export async function listArchived() {
let entries;
try { entries = await readdir(archiveDir(), { withFileTypes: true }); }
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
const uit = [];
for (const e of entries) {
if (!e.isDirectory()) continue;
const map = join(archiveDir(), e.name);
let gearchiveerd = null;
try { gearchiveerd = (await stat(map)).mtime.toISOString(); } catch { }
uit.push({ naam: e.name, map, gearchiveerd });
}
return uit.sort((a, b) => String(b.gearchiveerd).localeCompare(String(a.gearchiveerd)));
}
export async function purgeArchived(naam) {
assertValidName(naam.replace(/ \(\d+\)$/, ''));
const map = resolve(join(archiveDir(), naam));
if (!map.startsWith(resolve(archiveDir()) + sep)) {
throw new Error(`'${naam}' points outside the archive.`);
}
if (!await bestaat(map)) fail('portfolios.notInArchive', { naam }, `There is no '${naam}' in the archive.`);
await rm(map, { recursive: true, force: true });
return { naam, map };
}
2026-10-30 12:28:48 +01:00
function keyFigures(naam) {
const pad = join(portfolioDir(naam), 'db.sqlite');
const perSoort = rijen => Object.fromEntries(rijen.map(r => [r.kind, r.n]));
try {
return peekDatabase(pad, ({ one, all }) => {
const backup = one(`SELECT value FROM Preference WHERE scope = 'app' AND key = 'last_backup_at'`);
const eerste = [
one(`SELECT MIN(occurredAt) AS d FROM Event WHERE type <> 'reservation'`).d,
one(`SELECT MIN(rateAt) AS d FROM Valuation WHERE accountId IS NOT NULL`).d,
].filter(Boolean).sort()[0] || null;
const portfolio = one(`SELECT baseCurrency, createdAt FROM Portfolio WHERE id = 1`);
return {
munt: portfolio?.baseCurrency ?? null,
aangemaakt: portfolio?.createdAt ?? null,
boekingen: one(`SELECT COUNT(*) AS n FROM Event WHERE type <> 'reservation'`).n,
balansen: one(`SELECT COUNT(*) AS n FROM Valuation`).n,
assets: perSoort(all(`SELECT kind, COUNT(*) AS n FROM Asset WHERE id IN (
SELECT assetId FROM Event WHERE type <> 'reservation' UNION SELECT assetId FROM Valuation)
GROUP BY kind`)),
accounts: perSoort(all(`SELECT kind, COUNT(*) AS n FROM Account WHERE isActive = 1 GROUP BY kind`)),
eersteJaar: eerste ? Number(eerste.slice(0, 4)) : null,
laatsteBackup: (backup && JSON.parse(backup.value)) || null,
};
});
} catch {
return null;
}
}
2026-10-29 11:57:29 +01:00
export async function overviewPortfolios() {
return {
actief: activeName(),
installatie: installDir(),
2026-10-30 12:28:48 +01:00
portefeuilles: listPortfolios().map(p => ({ ...p, kerncijfers: p.heeftDatabase ? keyFigures(p.naam) : null })),
2026-10-29 11:57:29 +01:00
archief: await listArchived(),
};
}