Files
2026-10-29 11:57:29 +01:00

234 lines
9.0 KiB
JavaScript

// Generated, do not edit. sha256:c2a988d6416e302a0ad7224bc98a3cd265bc5cc9cadb0dce809af179af082299
import * as db from '../db.js';
import { nowIso, todayIso, daysBetween, isoToDate, dateToIso, addDaysIso, weekdayOf, clampDom } from './_shared.js';
import { createEvent, eventDaysByAccount } from './events.js';
import { listSelectableAccounts } from './accounts.js';
import { positionAt } from './positions.js';
import { listSchedulesForAccount, listPeriods, realizedForSchedule } from './interest-schedules.js';
import { anchorDaysBetween } from './valuations.js';
import { toUtcIso } from '../../shared/time.js';
import { fail } from '../../shared/app-error.js';
export function realizedInterestSub(accountId, assetId, fromIso, toIso) {
const r = db.one(`SELECT COALESCE(SUM(amount),0) AS s FROM Event
WHERE type = 'interest' AND toAccountId = ? AND assetId = ? AND occurredAt >= ? AND occurredAt < ?`,
[accountId, assetId, fromIso, toIso]);
return r ? r.s : 0;
}
function weeklyDates(startIso, endIso, weekday) {
const out = [];
let cur = addDaysIso(startIso, 1), guard = 0;
while (weekdayOf(cur) !== weekday && guard++ < 7) cur = addDaysIso(cur, 1);
for (; cur <= endIso; cur = addDaysIso(cur, 7)) out.push(cur);
return out;
}
function monthlyDates(startIso, endIso, dom) {
const out = [];
const s = isoToDate(startIso);
let y = s.getUTCFullYear(), m = s.getUTCMonth();
for (;;) {
const iso = dateToIso(new Date(Date.UTC(y, m, clampDom(y, m, dom))));
if (iso > endIso) break;
if (iso > startIso) out.push(iso);
if (++m > 11) { m = 0; y++; }
}
return out;
}
function buildPeriods(dates, startIso) {
const periods = [];
let prev = startIso;
for (const d of dates) { periods.push({ periodStart: prev, periodEnd: d }); prev = d; }
return periods;
}
function allCreditingPeriods(s, horizonIso = todayIso()) {
const periods = cadencePeriods(s, horizonIso);
if (s.endsOn && s.creditCadence !== 'term_end') {
const last = periods.length ? periods[periods.length - 1].periodEnd : s.startsOn;
if (last < s.endsOn) periods.push({ periodStart: last, periodEnd: s.endsOn });
}
return periods;
}
function cadencePeriods(s, horizonIso) {
const eind = s.endsOn || horizonIso;
if (s.creditCadence === 'term_end') return s.endsOn ? [{ periodStart: s.startsOn, periodEnd: s.endsOn }] : [];
if (s.creditCadence === 'annual') {
const periods = [];
const startYear = Number(s.startsOn.slice(0, 4));
for (let y = startYear; ; y++) {
const periodEnd = `${y + 1}-01-01`;
if (periodEnd > eind) break;
periods.push({ periodStart: y === startYear ? s.startsOn : `${y}-01-01`, periodEnd: periodEnd });
}
return periods;
}
if (s.creditCadence === 'quarterly') {
const periods = [];
const s0 = isoToDate(s.startsOn);
let y = s0.getUTCFullYear();
let m = Math.floor(s0.getUTCMonth() / 3) * 3;
let prev = s.startsOn;
for (;;) {
m += 3;
if (m > 11) { m -= 12; y++; }
const periodEnd = dateToIso(new Date(Date.UTC(y, m, 1)));
if (periodEnd > eind) break;
periods.push({ periodStart: prev, periodEnd });
prev = periodEnd;
}
return periods;
}
const dates = s.creditCadence === 'weekly'
? weeklyDates(s.startsOn, eind, s.creditWeekday)
: monthlyDates(s.startsOn, eind, s.creditDayOfMonth);
return buildPeriods(dates, s.startsOn);
}
export function scheduleForecast(s, asofIso = todayIso()) {
const realizedSub = realizedForSchedule(s, asofIso);
const sameAsset = s.principalAssetId === s.payoutAssetId;
const periods = allCreditingPeriods(s, addDaysIso(asofIso, 400));
const amount = periodAmounts(s, periods);
const later = periods.filter(p => p.periodEnd > asofIso);
const upcoming = (s.endsOn ? later : later.slice(0, 1)).map(p => ({ ...p, projectedSub: amount(p) }));
if (!s.endsOn || s.principalAmount == null) {
return { termTotalSub: null, realizedSub, remainingSub: null, remainingCount: null, sameAsset, upcoming };
}
const termTotalSub = periods.reduce((n, p) => n + amount(p), 0);
const remainingSub = Math.max(0, termTotalSub - realizedSub);
return { termTotalSub, realizedSub, remainingSub, remainingCount: upcoming.length, sameAsset, upcoming };
}
function periodAmounts(s, periods) {
if (!s.compounding) return p => projectedSubForPeriod(s, p);
const byEnd = new Map();
let accrued = 0;
for (const p of periods) {
const sub = projectedSubForPeriod(s, p, accrued);
byEnd.set(p.periodEnd, sub);
accrued += sub;
}
return p => byEnd.get(p.periodEnd);
}
function creditingPeriods(s, asofIso) {
return allCreditingPeriods(s).filter(p => p.periodEnd <= asofIso);
}
function rateSlices(s, vanIso, totIso) {
const perioden = listPeriods(s.id);
const wissels = perioden.filter(p => p.startsOn > vanIso && p.startsOn < totIso);
const eerder = perioden.filter(p => p.startsOn <= vanIso);
let rate = eerder.length ? eerder[eerder.length - 1].annualRate : s.annualRate;
const uit = [];
let van = vanIso;
for (const w of wissels) {
uit.push({ van, tot: w.startsOn, rate });
van = w.startsOn;
rate = w.annualRate;
}
uit.push({ van, tot: totIso, rate });
return uit;
}
function principalMainVoor(s, opDag) {
if (s.principalAmount != null) return s.principalAmount / Math.pow(10, s.principal_decimals);
const moment = toUtcIso(`${opDag}T23:59:59`);
return positionAt(s.accountId, s.principalAssetId, moment) / Math.pow(10, s.principal_decimals);
}
function saldoDagen(s, vanDag, totDag) {
const dagen = new Set([
...eventDaysByAccount(s.accountId, s.principalAssetId, vanDag, totDag),
...anchorDaysBetween(s.accountId, s.principalAssetId, vanDag, totDag),
]);
return [...dagen].sort();
}
function projectedSubForPeriod(s, p, accruedSub = 0) {
const stukken = [];
for (const deel of rateSlices(s, p.periodStart, p.periodEnd)) {
if (s.principalAmount != null) { stukken.push(deel); continue; }
let van = deel.van;
for (const dag of saldoDagen(s, deel.van, deel.tot)) {
stukken.push({ van, tot: dag, rate: deel.rate });
van = dag;
}
stukken.push({ van, tot: deel.tot, rate: deel.rate });
}
const accrued = accruedSub / Math.pow(10, s.payout_decimals);
const som = stukken.reduce((n, deel) =>
n + (principalMainVoor(s, deel.van) + accrued) * deel.rate * Math.max(0, daysBetween(deel.van, deel.tot) / 365), 0);
return Math.round(som * Math.pow(10, s.payout_decimals));
}
function getCredit(scheduleId, periodEnd) {
return db.one(`SELECT * FROM InterestCredit WHERE scheduleId = ? AND periodEnd = ?`, [scheduleId, periodEnd]) || null;
}
export function dueCreditsForAccount(accountId) {
const today = todayIso();
const out = [];
for (const s of listSchedulesForAccount(accountId)) {
if (s.rateKind !== 'fixed') continue;
const periods = creditingPeriods(s, today);
const amount = periodAmounts(s, periods);
for (const p of periods) {
if (getCredit(s.id, p.periodEnd)) continue;
if (realizedForSchedule(s, p.periodEnd, addDaysIso(p.periodStart, 1)) > 0) continue;
const projected = amount(p);
if (projected === 0) continue;
out.push({
scheduleId: s.id, accountId: accountId, payoutAssetId: s.payoutAssetId,
payout_symbol: s.payout_symbol, payout_decimals: s.payout_decimals,
payout_unit: s.payout_unit, payout_subunit: s.payout_subunit, payout_display_unit: s.payout_display_unit,
periodStart: p.periodStart, periodEnd: p.periodEnd, projected_sub: projected,
});
}
}
return out;
}
export function dueCreditsAll() {
const out = [];
for (const a of listSelectableAccounts()) {
for (const d of dueCreditsForAccount(a.id)) out.push({ ...d, account_name: a.name });
}
return out;
}
function olderOpenDue(due) {
return dueCreditsForAccount(due.accountId)
.filter(d => d.periodEnd < due.periodEnd)
.sort((a, b) => a.periodEnd.localeCompare(b.periodEnd))[0] || null;
}
export async function bookDueCredit(due, amountSub) {
const ouder = olderOpenDue(due);
if (ouder) {
fail('interest.bookOlderFirst', { start: ouder.periodStart, eind: ouder.periodEnd },
`Book the credit of ${ouder.periodStart} to ${ouder.periodEnd} first: each credit raises the `
+ 'balance the next one is computed on.');
}
const eventId = await createEvent({
assetId: due.payoutAssetId, type: 'interest', occurredAt: toUtcIso(`${due.periodEnd}T00:00:00`),
amount: amountSub, fiatPrice: null, fiatFee: null, unitFee: null,
fromAccountId: null, toAccountId: due.accountId, source: 'manual',
importReference: null, externalAddress: null, note: null,
});
db.run(`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, eventId, status, createdAt)
VALUES (?,?,?,?, 'booked', ?)`,
[due.scheduleId, due.periodStart, due.periodEnd, eventId, nowIso()]);
await db.persist();
return eventId;
}
export async function dismissDueCredit(due) {
db.run(`INSERT INTO InterestCredit (scheduleId, periodStart, periodEnd, eventId, status, createdAt)
VALUES (?,?,?, NULL, 'dismissed', ?)`,
[due.scheduleId, due.periodStart, due.periodEnd, nowIso()]);
await db.persist();
}