Files
AtreidesAssetsFree/server/data/interest-schedules.js
T
2026-10-29 11:57:29 +01:00

194 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}