Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -0,0 +1,193 @@
|
||||
// Generated, do not edit. sha256:1f3b140e78bb63390abe586ae0f82dd70eb59c31c977547efd2e278b927a1a2f
|
||||
import * as db from '../db.js';
|
||||
import { localDate } from '../../shared/time.js';
|
||||
import { ENUMS } from '../../shared/config.js';
|
||||
import { fail } from '../../shared/app-error.js';
|
||||
import { assertEnum, nowIso } from './_shared.js';
|
||||
import { EVENT_JOIN } from './events.js';
|
||||
import { trackDelete, trackUpdate } from './change-log.js';
|
||||
import { emitChange } from './_events.js';
|
||||
|
||||
export function listInterestForAccount(accountId) {
|
||||
return db.all(`${EVENT_JOIN} WHERE e.type = 'interest' AND e.toAccountId = ? ORDER BY e.occurredAt DESC, e.id DESC`, [accountId]);
|
||||
}
|
||||
export function listInterest() {
|
||||
const rows = db.all(`${EVENT_JOIN} WHERE e.type = 'interest' ORDER BY e.occurredAt DESC, e.id DESC`);
|
||||
const schedules = listAllSchedules();
|
||||
const links = creditLinks();
|
||||
for (const r of rows) {
|
||||
const s = attributeSchedule(r, schedules, links);
|
||||
r.scheduleId = s ? s.id : null;
|
||||
r.schedule_label = s ? scheduleLabel(s) : null;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function listAllSchedules() {
|
||||
return db.all(`
|
||||
SELECT s.*, ac.name AS account_name,
|
||||
pa.symbol AS principal_symbol, pa.decimals AS principal_decimals,
|
||||
pa.unit AS principal_unit, pa.subunit AS principal_subunit, pa.displayUnit AS principal_display_unit,
|
||||
po.symbol AS payout_symbol, po.decimals AS payout_decimals,
|
||||
po.unit AS payout_unit, po.subunit AS payout_subunit, po.displayUnit AS payout_display_unit
|
||||
FROM InterestSchedule s
|
||||
JOIN Account ac ON ac.id = s.accountId
|
||||
LEFT JOIN Asset pa ON pa.id = s.principalAssetId
|
||||
JOIN Asset po ON po.id = s.payoutAssetId
|
||||
ORDER BY s.startsOn DESC, s.id DESC`);
|
||||
}
|
||||
|
||||
export function scheduleLabel(s) {
|
||||
const period = `${s.startsOn}–${s.endsOn || 'heden'}`;
|
||||
const kind = s.rateKind === 'variable' ? 'variabel' : `${+(s.annualRate * 100).toFixed(4)}%`;
|
||||
return `${s.account_name} · ${s.payout_symbol} · ${kind} · ${period}`;
|
||||
}
|
||||
|
||||
export function realizedForSchedule(s, asofIso, sinceIso = null) {
|
||||
const stored = listSchedulesForAccount(s.accountId);
|
||||
const schedules = stored.some(o => o.id === s.id) ? stored.map(o => (o.id === s.id ? s : o)) : [...stored, s];
|
||||
const links = creditLinks();
|
||||
return db.all(`SELECT id, occurredAt, toAccountId, assetId, amount FROM Event
|
||||
WHERE type = 'interest' AND toAccountId = ? AND assetId = ? AND occurredAt >= ? AND occurredAt <= ?`,
|
||||
[s.accountId, s.payoutAssetId, sinceIso || '', `${asofIso}T23:59:59`])
|
||||
.filter(ev => attributeSchedule(ev, schedules, links) === s)
|
||||
.reduce((n, ev) => n + ev.amount, 0);
|
||||
}
|
||||
|
||||
function creditLinks() {
|
||||
return new Map(db.all(`SELECT eventId, scheduleId FROM InterestCredit WHERE eventId IS NOT NULL`)
|
||||
.map(c => [c.eventId, c.scheduleId]));
|
||||
}
|
||||
|
||||
function attributeSchedule(ev, schedules, links) {
|
||||
const linkedId = links.get(ev.id);
|
||||
const linked = linkedId != null ? schedules.find(s => s.id === linkedId) : null;
|
||||
if (linked) return linked;
|
||||
const date = localDate(ev.occurredAt);
|
||||
let best = null;
|
||||
for (const s of schedules) {
|
||||
if (s.accountId !== ev.toAccountId || s.payoutAssetId !== ev.assetId) continue;
|
||||
if (date < s.startsOn || (s.endsOn && date > s.endsOn)) continue;
|
||||
if (!best || s.startsOn > best.startsOn) best = s;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
export function listSchedulesForAccount(accountId) {
|
||||
return db.all(`
|
||||
SELECT s.*,
|
||||
pa.symbol AS principal_symbol, pa.decimals AS principal_decimals,
|
||||
pa.unit AS principal_unit, pa.subunit AS principal_subunit, pa.displayUnit AS principal_display_unit,
|
||||
po.symbol AS payout_symbol, po.decimals AS payout_decimals,
|
||||
po.unit AS payout_unit, po.subunit AS payout_subunit, po.displayUnit AS payout_display_unit
|
||||
FROM InterestSchedule s
|
||||
LEFT JOIN Asset pa ON pa.id = s.principalAssetId
|
||||
JOIN Asset po ON po.id = s.payoutAssetId
|
||||
WHERE s.accountId = ?
|
||||
ORDER BY s.startsOn DESC, s.id DESC`, [accountId]);
|
||||
}
|
||||
function assertSchedule(s) {
|
||||
assertEnum('rateKind', ENUMS.INTEREST_RATE_KIND, s.rateKind);
|
||||
if (!s.payoutAssetId) fail('accounts.schPayoutRequired', null, 'A schedule needs a payout asset.');
|
||||
if (!s.startsOn) fail('accounts.schStartRequired', null, 'A schedule needs a start date.');
|
||||
if (s.endsOn && s.endsOn < s.startsOn) fail('accounts.schEndBeforeStart', null, 'The end date precedes the start date.');
|
||||
if (s.rateKind === 'variable') {
|
||||
if (s.principalAssetId != null || s.principalAmount != null || s.annualRate != null) throw new Error('A schedule on "manual or import" has no principal or rate: the app computes nothing there.');
|
||||
if (s.creditCadence != null || s.creditWeekday != null || s.creditDayOfMonth != null) throw new Error('A schedule on "manual or import" has no credit cadence: the interest comes from outside.');
|
||||
if (s.compounding) throw new Error('A schedule on "manual or import" has no interest on interest: the app computes nothing there.');
|
||||
return;
|
||||
}
|
||||
if (s.compounding) {
|
||||
if (s.principalAmount == null) fail('interest.compoundingNeedsPrincipal', null, 'Interest on interest needs a fixed principal; a principal that follows the balance already grows by every credit.');
|
||||
if (s.principalAssetId !== s.payoutAssetId) fail('interest.compoundingNeedsSameAsset', null, 'Interest on interest needs the payout in the principal asset.');
|
||||
}
|
||||
if (!s.principalAssetId) fail('accounts.schAssetRequired', null, 'A schedule needs a principal asset.');
|
||||
if (s.principalAmount != null && !(s.principalAmount > 0)) {
|
||||
fail('interest.principalPositive', null, 'The principal has to be greater than 0; leave it empty to follow the account balance.');
|
||||
}
|
||||
if (!(s.annualRate >= 0)) fail('interest.rateNotNegative', null, 'The annual rate may not be negative.');
|
||||
assertEnum('creditCadence', ENUMS.INTEREST_CADENCE, s.creditCadence);
|
||||
if (s.creditCadence === 'weekly') {
|
||||
if (!Number.isInteger(s.creditWeekday) || s.creditWeekday < 0 || s.creditWeekday > 6) fail('interest.weekdayRequired', null, 'Pick a weekday for the weekly credit.');
|
||||
if (s.creditDayOfMonth != null) throw new Error('A weekly schedule has no day of the month.');
|
||||
} else if (s.creditCadence === 'monthly') {
|
||||
if (!Number.isInteger(s.creditDayOfMonth) || s.creditDayOfMonth < 1 || s.creditDayOfMonth > 31) fail('interest.dayOfMonthRequired', null, 'Pick a day of the month for the monthly credit.');
|
||||
if (s.creditWeekday != null) throw new Error('A monthly schedule has no weekday.');
|
||||
} else if (s.creditWeekday != null || s.creditDayOfMonth != null) {
|
||||
throw new Error('This cadence uses neither a weekday nor a day of the month.');
|
||||
}
|
||||
}
|
||||
export async function createSchedule(s) {
|
||||
assertSchedule(s);
|
||||
db.run(`INSERT INTO InterestSchedule
|
||||
(accountId, rateKind, principalAssetId, payoutAssetId, principalAmount, annualRate, startsOn, endsOn, creditCadence, creditWeekday, creditDayOfMonth, compounding, note, createdAt)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[s.accountId, s.rateKind, s.principalAssetId ?? null, s.payoutAssetId, s.principalAmount ?? null, s.annualRate ?? null,
|
||||
s.startsOn, s.endsOn ?? null, s.creditCadence ?? null, s.creditWeekday ?? null, s.creditDayOfMonth ?? null,
|
||||
s.compounding ? 1 : 0, s.note ?? null, nowIso()]);
|
||||
const id = db.lastId();
|
||||
await db.persist();
|
||||
emitChange('ledger-changed');
|
||||
return id;
|
||||
}
|
||||
export async function updateSchedule(id, s) {
|
||||
assertSchedule(s);
|
||||
if (!s.accountId) fail('interest.schAccountRequired', null, 'A schedule needs an account.');
|
||||
trackUpdate('InterestSchedule', id, () =>
|
||||
db.run(`UPDATE InterestSchedule SET
|
||||
accountId = ?, rateKind = ?, principalAssetId = ?, payoutAssetId = ?, principalAmount = ?, annualRate = ?,
|
||||
startsOn = ?, endsOn = ?, creditCadence = ?, creditWeekday = ?, creditDayOfMonth = ?, compounding = ?, note = ?
|
||||
WHERE id = ?`,
|
||||
[s.accountId, s.rateKind, s.principalAssetId ?? null, s.payoutAssetId, s.principalAmount ?? null, s.annualRate ?? null,
|
||||
s.startsOn, s.endsOn ?? null, s.creditCadence ?? null, s.creditWeekday ?? null, s.creditDayOfMonth ?? null,
|
||||
s.compounding ? 1 : 0, s.note ?? null, id]));
|
||||
await db.persist();
|
||||
emitChange('ledger-changed');
|
||||
}
|
||||
export async function deleteSchedule(id) {
|
||||
trackDelete('InterestSchedule', id, () => db.run(`DELETE FROM InterestSchedule WHERE id = ?`, [id]));
|
||||
await db.persist();
|
||||
emitChange('ledger-changed');
|
||||
}
|
||||
|
||||
export function listPeriods(scheduleId) {
|
||||
return db.all(`SELECT * FROM InterestPeriod WHERE scheduleId = ? ORDER BY startsOn ASC`, [scheduleId]);
|
||||
}
|
||||
|
||||
function assertPeriod(scheduleId, p) {
|
||||
const s = db.one(`SELECT startsOn, endsOn, rateKind FROM InterestSchedule WHERE id = ?`, [scheduleId]);
|
||||
if (!s) fail('interest.unknownSchedule', null, 'Unknown interest schedule.');
|
||||
if (s.rateKind !== 'fixed') throw new Error('A schedule on "manual or import" has no rate, and therefore no interest periods.');
|
||||
if (!p.startsOn) fail('interest.periodStartRequired', null, 'A period needs a start date.');
|
||||
if (p.startsOn <= s.startsOn) {
|
||||
fail('interest.periodStartBeforeSchedule', { start: s.startsOn },
|
||||
`The start date has to be after the start of the schedule (${s.startsOn}); the rate from that day on is on the schedule itself.`);
|
||||
}
|
||||
if (s.endsOn && p.startsOn > s.endsOn) {
|
||||
fail('interest.periodStartAfterEnd', { eind: s.endsOn },
|
||||
`The start date is after the end of the schedule (${s.endsOn}).`);
|
||||
}
|
||||
if (!(p.annualRate >= 0)) fail('interest.rateNotNegative', null, 'The annual rate may not be negative.');
|
||||
}
|
||||
|
||||
export async function createPeriod(scheduleId, p) {
|
||||
assertPeriod(scheduleId, p);
|
||||
db.run(`INSERT INTO InterestPeriod (scheduleId, startsOn, annualRate) VALUES (?,?,?)`,
|
||||
[scheduleId, p.startsOn, p.annualRate]);
|
||||
const id = db.lastId();
|
||||
await db.persist();
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function updatePeriod(id, p) {
|
||||
const bestaand = db.one(`SELECT scheduleId FROM InterestPeriod WHERE id = ?`, [id]);
|
||||
if (!bestaand) fail('interest.unknownPeriod', null, 'Unknown interest period.');
|
||||
assertPeriod(bestaand.scheduleId, p);
|
||||
trackUpdate('InterestPeriod', id, () =>
|
||||
db.run(`UPDATE InterestPeriod SET startsOn = ?, annualRate = ? WHERE id = ?`, [p.startsOn, p.annualRate, id]));
|
||||
await db.persist();
|
||||
}
|
||||
|
||||
export async function deletePeriod(id) {
|
||||
trackDelete('InterestPeriod', id, () => db.run(`DELETE FROM InterestPeriod WHERE id = ?`, [id]));
|
||||
await db.persist();
|
||||
}
|
||||
Reference in new issue
Block a user