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; }