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
|
### BL-05 — Budget: Wiederkehrende Buchungen automatisch generieren
|
||||||
|
|
||||||
**Status:** Offen
|
**Status:** Erledigt (v0.3.0)
|
||||||
**Aufwand:** S (1–2 Tage)
|
**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.
|
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]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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
|
- 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
|
## [0.2.1] - 2026-03-30
|
||||||
|
|||||||
@@ -267,13 +267,14 @@ function renderEntries() {
|
|||||||
const indClass = isIncome ? 'budget-entry__indicator--income' : 'budget-entry__indicator--expenses';
|
const indClass = isIncome ? 'budget-entry__indicator--income' : 'budget-entry__indicator--expenses';
|
||||||
const sign = isIncome ? '+' : '';
|
const sign = isIncome ? '+' : '';
|
||||||
const date = formatEntryDate(e.date);
|
const date = formatEntryDate(e.date);
|
||||||
|
const recurTag = e.is_recurring ? ' 🔁' : (e.recurrence_parent_id ? ' ↩' : '');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="budget-entry" data-id="${e.id}">
|
<div class="budget-entry" data-id="${e.id}">
|
||||||
<div class="budget-entry__indicator ${indClass}"></div>
|
<div class="budget-entry__indicator ${indClass}"></div>
|
||||||
<div class="budget-entry__body">
|
<div class="budget-entry__body">
|
||||||
<div class="budget-entry__title">${escHtml(e.title)}</div>
|
<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>
|
||||||
<div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</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">
|
<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);
|
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);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+66
-4
@@ -11,6 +11,56 @@ const router = express.Router();
|
|||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const { str, oneOf, date, num, rrule, collectErrors, MAX_TITLE, MONTH_RE } = require('../middleware/validate');
|
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 = [
|
const VALID_CATEGORIES = [
|
||||||
'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität',
|
'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität',
|
||||||
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
|
'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges',
|
||||||
@@ -144,6 +194,8 @@ router.get('/', (req, res) => {
|
|||||||
if (!MONTH_RE.test(month))
|
if (!MONTH_RE.test(month))
|
||||||
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
|
return res.status(400).json({ error: 'month muss YYYY-MM sein', code: 400 });
|
||||||
|
|
||||||
|
generateRecurringInstances(db.get(), month);
|
||||||
|
|
||||||
const from = `${month}-01`;
|
const from = `${month}-01`;
|
||||||
const to = `${month}-31`;
|
const to = `${month}-31`;
|
||||||
let sql = `
|
let sql = `
|
||||||
@@ -267,10 +319,20 @@ router.put('/:id', (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id, 10);
|
const id = parseInt(req.params.id, 10);
|
||||||
const result = db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(id);
|
const entry = db.get().prepare('SELECT * FROM budget_entries WHERE id = ?').get(id);
|
||||||
if (result.changes === 0)
|
if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden', code: 404 });
|
||||||
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();
|
res.status(204).end();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[budget/DELETE /:id]', err);
|
console.error('[budget/DELETE /:id]', err);
|
||||||
|
|||||||
Reference in New Issue
Block a user