feat(budget): auto-generate recurring entry instances per month

Adds schema migration v3 (recurrence_parent_id column + budget_recurrence_skipped
table). On every GET /api/v1/budget, the server checks all recurring originals
(is_recurring=1, no parent) and creates missing instances for the requested month
using the same day-of-month (clamped to the last day). Deleted instances are
recorded in budget_recurrence_skipped so they are not recreated on the next visit.
Generated instances are shown with a ↩ indicator in the transaction list.

Closes BL-05.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas
2026-03-31 10:13:37 +02:00
parent 26d3d12a22
commit 82e5b2cd85
5 changed files with 86 additions and 6 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ Die Sync-Services `server/services/google-calendar.js` und `server/services/appl
### BL-05 — Budget: Wiederkehrende Buchungen automatisch generieren
**Status:** Offen
**Status:** Erledigt (v0.3.0)
**Aufwand:** S (12 Tage)
Das Budget-Formular hat eine „Wiederkehrend"-Checkbox und speichert `is_recurring = 1`. Es fehlt jedoch die automatische Generierung der Folgebuchungen. Derzeit muss der Nutzer jede Buchung manuell eintragen.
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Budget: recurring entries auto-generate instances for each viewed month; instances deleted by the user are skipped permanently via `budget_recurrence_skipped` table; generated instances are marked with ↩ in the transaction list
- Budget: month-over-month comparison in summary cards — each card (Einnahmen, Ausgaben, Saldo) shows a trend line (▲/▼ + delta amount vs. previous month); previous month summary is fetched in parallel with current month
## [0.2.1] - 2026-03-30
+2 -1
View File
@@ -267,13 +267,14 @@ function renderEntries() {
const indClass = isIncome ? 'budget-entry__indicator--income' : 'budget-entry__indicator--expenses';
const sign = isIncome ? '+' : '';
const date = formatEntryDate(e.date);
const recurTag = e.is_recurring ? ' 🔁' : (e.recurrence_parent_id ? ' ↩' : '');
return `
<div class="budget-entry" data-id="${e.id}">
<div class="budget-entry__indicator ${indClass}"></div>
<div class="budget-entry__body">
<div class="budget-entry__title">${escHtml(e.title)}</div>
<div class="budget-entry__meta">${date} · ${escHtml(e.category)}${e.is_recurring ? ' 🔁' : ''}</div>
<div class="budget-entry__meta">${date} · ${escHtml(e.category)}${recurTag}</div>
</div>
<div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</div>
<button class="budget-entry__delete" data-action="delete" data-id="${e.id}" aria-label="Eintrag löschen">
+16
View File
@@ -278,6 +278,22 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id);
`,
},
{
version: 3,
description: 'Wiederkehrende Budget-Einträge: parent-Referenz und Skip-Tabelle',
up: `
ALTER TABLE budget_entries ADD COLUMN recurrence_parent_id INTEGER
REFERENCES budget_entries(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS budget_recurrence_skipped (
parent_id INTEGER NOT NULL REFERENCES budget_entries(id) ON DELETE CASCADE,
month TEXT NOT NULL,
PRIMARY KEY (parent_id, month)
);
CREATE INDEX IF NOT EXISTS idx_budget_parent ON budget_entries(recurrence_parent_id);
`,
},
];
/**
+65 -3
View File
@@ -11,6 +11,56 @@ const router = express.Router();
const db = require('../db');
const { str, oneOf, date, num, rrule, collectErrors, MAX_TITLE, MONTH_RE } = require('../middleware/validate');
// --------------------------------------------------------
// Wiederkehrende Einträge: fehlende Instanzen für einen Monat erzeugen
// --------------------------------------------------------
/**
* Erstellt fehlende Instanzen wiederkehrender Budget-Einträge für den angefragten Monat.
* Läuft idempotent — bereits vorhandene oder explizit übersprungene Instanzen werden ignoriert.
* @param {import('better-sqlite3').Database} database
* @param {string} month YYYY-MM
*/
function generateRecurringInstances(database, month) {
const [y, m] = month.split('-').map(Number);
const monthStart = `${month}-01`;
const monthEnd = `${month}-31`;
// Alle Serien-Originale, die vor diesem Monat begonnen haben
const originals = database.prepare(`
SELECT * FROM budget_entries
WHERE is_recurring = 1 AND recurrence_parent_id IS NULL
AND strftime('%Y-%m', date) < ?
`).all(month);
for (const orig of originals) {
// Übersprungener Monat?
const skipped = database.prepare(
'SELECT 1 FROM budget_recurrence_skipped WHERE parent_id = ? AND month = ?'
).get(orig.id, month);
if (skipped) continue;
// Instanz schon vorhanden?
const existing = database.prepare(`
SELECT id FROM budget_entries
WHERE recurrence_parent_id = ? AND date BETWEEN ? AND ?
`).get(orig.id, monthStart, monthEnd);
if (existing) continue;
// Datum berechnen: gleicher Tag, am letzten Tag des Monats gekappt
const origDay = parseInt(orig.date.split('-')[2], 10);
const lastDay = new Date(y, m, 0).getDate();
const instanceDay = Math.min(origDay, lastDay);
const instanceDate = `${month}-${String(instanceDay).padStart(2, '0')}`;
database.prepare(`
INSERT INTO budget_entries
(title, amount, category, date, is_recurring, recurrence_parent_id, created_by)
VALUES (?, ?, ?, ?, 0, ?, ?)
`).run(orig.title, orig.amount, orig.category, instanceDate, orig.id, orig.created_by);
}
}
const VALID_CATEGORIES = [
'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität',
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
@@ -144,6 +194,8 @@ router.get('/', (req, res) => {
if (!MONTH_RE.test(month))
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
generateRecurringInstances(db.get(), month);
const from = `${month}-01`;
const to = `${month}-31`;
let sql = `
@@ -268,9 +320,19 @@ router.put('/:id', (req, res) => {
router.delete('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const result = db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id);
if (result.changes === 0)
return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 });
const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id);
if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 });
db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id);
// Wenn eine Instanz gelöscht wird: Monat als übersprungen markieren
if (entry.recurrence_parent_id) {
const month = entry.date.slice(0, 7);
db.get().prepare(
'INSERT OR IGNORE INTO budget_recurrence_skipped (parent_id, month) VALUES (?, ?)'
).run(entry.recurrence_parent_id, month);
}
res.status(204).end();
} catch (err) {
console.error('[budget/DELETE /:id]', err);