233 lines
9.0 KiB
JavaScript
233 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();
|
||
|
|
}
|