librelad 875a60f90f LibrePortal v0.1.0 — initial release
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.

Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-21 20:37:54 +01:00

159 lines
4.7 KiB
TypeScript

import { RRule, rrulestr } from "rrule";
import type { RecurringRule, Transaction } from "@/db/schema";
export type ProjectedTransaction = {
id: string;
bankAccountId: string;
categoryId: string | null;
recurringRuleId: string;
date: string;
amountMinor: number;
description: string;
status: "projected";
source: "recurring";
};
function toISODate(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function parseISODate(s: string): Date {
const [y, m, d] = s.split("-").map(Number);
return new Date(Date.UTC(y, (m ?? 1) - 1, d ?? 1));
}
export function rruleFromRule(rule: Pick<RecurringRule, "rrule" | "startDate" | "endDate">): RRule {
const dtstart = parseISODate(rule.startDate);
const until = rule.endDate ? parseISODate(rule.endDate) : undefined;
const trimmed = rule.rrule.trim();
if (trimmed.startsWith("RRULE:") || trimmed.startsWith("DTSTART")) {
const r = rrulestr(trimmed) as RRule;
return new RRule({ ...r.options, dtstart, until: until ?? r.options.until });
}
const opts = RRule.parseString(trimmed);
return new RRule({ ...opts, dtstart, until: until ?? opts.until });
}
export function generateOccurrences(
rule: Pick<RecurringRule, "rrule" | "startDate" | "endDate">,
from: Date,
to: Date,
): Date[] {
const r = rruleFromRule(rule);
return r.between(from, to, true);
}
export function projectTransactionsFromRule(
rule: RecurringRule,
from: Date,
to: Date,
postedKeyset: Set<string> = new Set(),
): ProjectedTransaction[] {
if (!rule.isActive) return [];
const dates = generateOccurrences(rule, from, to);
return dates
.map((d) => {
const dateStr = toISODate(d);
const dedupKey = `${rule.id}|${dateStr}`;
if (postedKeyset.has(dedupKey)) return null;
return {
id: `proj_${rule.id}_${dateStr}`,
bankAccountId: rule.bankAccountId,
categoryId: rule.categoryId,
recurringRuleId: rule.id,
date: dateStr,
amountMinor: rule.amountMinor,
description: rule.description,
status: "projected" as const,
source: "recurring" as const,
} satisfies ProjectedTransaction;
})
.filter((x): x is ProjectedTransaction => x !== null);
}
export type LedgerRow = {
id: string;
bankAccountId: string;
categoryId: string | null;
recurringRuleId: string | null;
date: string;
amountMinor: number;
description: string;
status: "posted" | "projected" | "cancelled";
source: string;
runningBalanceMinor: number;
};
export function buildLedger(
posted: Transaction[],
rules: RecurringRule[],
from: Date,
to: Date,
openingBalanceByAccount: Record<string, number>,
): LedgerRow[] {
const postedKeyset = new Set(
posted
.filter((t) => t.recurringRuleId)
.map((t) => `${t.recurringRuleId}|${t.date}`),
);
const projected = rules.flatMap((r) =>
projectTransactionsFromRule(r, from, to, postedKeyset),
);
const all = [
...posted.map((p) => ({
id: p.id,
bankAccountId: p.bankAccountId,
categoryId: p.categoryId,
recurringRuleId: p.recurringRuleId,
date: p.date,
amountMinor: p.amountMinor,
description: p.description,
status: p.status,
source: p.source,
})),
...projected.map((p) => ({
id: p.id,
bankAccountId: p.bankAccountId,
categoryId: p.categoryId,
recurringRuleId: p.recurringRuleId,
date: p.date,
amountMinor: p.amountMinor,
description: p.description,
status: p.status as "projected",
source: p.source as string,
})),
].sort((a, b) => {
if (a.date < b.date) return -1;
if (a.date > b.date) return 1;
if (a.status === "posted" && b.status !== "posted") return -1;
if (a.status !== "posted" && b.status === "posted") return 1;
return 0;
});
const balances: Record<string, number> = { ...openingBalanceByAccount };
return all.map((row) => {
const prev = balances[row.bankAccountId] ?? 0;
const next = row.status === "cancelled" ? prev : prev + row.amountMinor;
balances[row.bankAccountId] = next;
return { ...row, runningBalanceMinor: next };
});
}
export const RRULE_PRESETS: Array<{ label: string; rrule: string }> = [
{ label: "Daily", rrule: "FREQ=DAILY" },
{ label: "Weekly (Mon)", rrule: "FREQ=WEEKLY;BYDAY=MO" },
{ label: "Weekly (Fri)", rrule: "FREQ=WEEKLY;BYDAY=FR" },
{ label: "Every 2 weeks", rrule: "FREQ=WEEKLY;INTERVAL=2" },
{ label: "Monthly (same day)", rrule: "FREQ=MONTHLY" },
{ label: "Monthly (1st)", rrule: "FREQ=MONTHLY;BYMONTHDAY=1" },
{ label: "Monthly (last day)", rrule: "FREQ=MONTHLY;BYMONTHDAY=-1" },
{ label: "Yearly", rrule: "FREQ=YEARLY" },
];