Die Abo-Logik ist jetzt deutlich sauberer: beim Anlegen gibt es ein Startdatum, der Server leitet daraus Monatsraten für den gewählten Zeitraum ab, Budgets rechnen mit dem periodischen Gesamtbetrag, und Abo-Ausgaben erscheinen als aufklappbare Gruppe statt als aufgeblähte Liste. Das steckt vor allem in page.tsx, recurring-expenses.ts, route.ts, dashboard-types.ts und der Migration migration.sql. Backup/Import und Audit-Restore kennen das neue Feld ebenfalls.
91 lines
2.6 KiB
TypeScript
91 lines
2.6 KiB
TypeScript
export type RecurringOccurrence = {
|
|
id: string;
|
|
label: string;
|
|
dueAt: string;
|
|
amount: number;
|
|
};
|
|
|
|
const monthLabelFormatter = new Intl.DateTimeFormat("de-DE", {
|
|
month: "long",
|
|
year: "numeric"
|
|
});
|
|
|
|
function toDate(value: string | Date) {
|
|
return value instanceof Date ? value : new Date(value);
|
|
}
|
|
|
|
function startOfUtcDay(date: Date) {
|
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0));
|
|
}
|
|
|
|
function endOfUtcDay(date: Date) {
|
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999));
|
|
}
|
|
|
|
function startOfUtcMonth(date: Date) {
|
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
|
|
}
|
|
|
|
function addUtcMonths(date: Date, months: number) {
|
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, 0, 0, 0, 0));
|
|
}
|
|
|
|
function daysInUtcMonth(year: number, monthIndex: number) {
|
|
return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
|
}
|
|
|
|
function buildDueDate(monthCursor: Date, recurrenceStartAt: Date) {
|
|
const year = monthCursor.getUTCFullYear();
|
|
const monthIndex = monthCursor.getUTCMonth();
|
|
const day = Math.min(recurrenceStartAt.getUTCDate(), daysInUtcMonth(year, monthIndex));
|
|
|
|
return new Date(Date.UTC(year, monthIndex, day, 12, 0, 0, 0));
|
|
}
|
|
|
|
export function buildRecurringOccurrences({
|
|
expenseId,
|
|
amount,
|
|
recurrenceStartAt,
|
|
periodStartsAt,
|
|
periodEndsAt
|
|
}: {
|
|
expenseId: string;
|
|
amount: number;
|
|
recurrenceStartAt: string | Date;
|
|
periodStartsAt: string | Date;
|
|
periodEndsAt: string | Date;
|
|
}) {
|
|
const seriesStart = startOfUtcDay(toDate(recurrenceStartAt));
|
|
const periodStart = startOfUtcDay(toDate(periodStartsAt));
|
|
const periodEnd = endOfUtcDay(toDate(periodEndsAt));
|
|
|
|
if (seriesStart > periodEnd) {
|
|
return [] as RecurringOccurrence[];
|
|
}
|
|
|
|
const occurrences: RecurringOccurrence[] = [];
|
|
let cursor = startOfUtcMonth(seriesStart > periodStart ? seriesStart : periodStart);
|
|
const lastMonth = startOfUtcMonth(periodEnd);
|
|
|
|
while (cursor <= lastMonth) {
|
|
const dueAt = buildDueDate(cursor, seriesStart);
|
|
|
|
if (dueAt >= seriesStart && dueAt >= periodStart && dueAt <= periodEnd) {
|
|
occurrences.push({
|
|
id: `${expenseId}-${cursor.getUTCFullYear()}-${String(cursor.getUTCMonth() + 1).padStart(2, "0")}`,
|
|
label: monthLabelFormatter.format(dueAt),
|
|
dueAt: dueAt.toISOString(),
|
|
amount
|
|
});
|
|
}
|
|
|
|
cursor = addUtcMonths(cursor, 1);
|
|
}
|
|
|
|
return occurrences;
|
|
}
|
|
|
|
export function getExpensePeriodAmount(amount: number, recurrence: "NONE" | "MONTHLY", occurrenceCount: number) {
|
|
return recurrence === "MONTHLY" ? amount * occurrenceCount : amount;
|
|
}
|