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:
+1
-1
@@ -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 (1–2 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user