diff --git a/README.md b/README.md index e2eb9ee..4310941 100644 --- a/README.md +++ b/README.md @@ -138,3 +138,7 @@ Die Hauptnavigation bleibt bei fünf Punkten, die fachlichen Unterseiten sind ab - **Mehr → Einstellungen & Daten**: Benachrichtigungen sowie Backup/Import und Datenstand auf getrennten Tabs. Die Kategorienverwaltung wurde vollständig aus der Statistik entfernt. Kategorieauswertungen bleiben dort erhalten. Der bisherige reine Kategorie-Zähler in der Statistik wurde durch einen zur aktuellen Konto-/Personenauswahl passenden Saldo- bzw. Nettoflusswert ersetzt. Die Änderung betrifft nur Navigation und UI-Struktur; das JSON-Datenmodell bleibt bei `version: 11`. + +### v3.30 – einmalige geplante Positionen + +Eingänge, Ausgänge und Transfers unterstützen zusätzlich den Intervalltyp **Einmalig**. Dafür wird ein konkretes Datum (`oneTimeDate`) hinterlegt. Die Position wird nur im betreffenden Monat der Jahresplanung, Statistiken und Kontoauszüge berücksichtigt und nicht auf einen Monatsdurchschnitt verteilt. Einmalige Ausgaben können wie andere Ausgaben per Pushover im Zielmonat erinnert werden. diff --git a/public/sw.js b/public/sw.js index d605803..b28177e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const SHELL_CACHE='fixfin-shell-v3.29'; +const SHELL_CACHE='fixfin-shell-v3.30'; const DATA_CACHE='fixfin-data-v1'; const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png']; diff --git a/server/index.js b/server/index.js index a759343..2bb81c6 100644 --- a/server/index.js +++ b/server/index.js @@ -9,14 +9,14 @@ const __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__ const port=Number(process.env.PORT||3000); const dataFile=process.env.DATA_FILE||'/app/data/finance.json'; const dataDir=path.dirname(dataFile); const backupFile=`${dataFile}.bak`; const pushoverFile=process.env.PUSHOVER_FILE||path.join(dataDir,'pushover.json'); const pushoverStateFile=process.env.PUSHOVER_STATE_FILE||path.join(dataDir,'pushover-state.json'); const reminderHour=Math.min(23,Math.max(0,Number(process.env.PUSHOVER_REMINDER_HOUR||8))); const PUSHOVER_ENDPOINT='https://api.pushover.net/1/messages.json'; -const validIntervals=new Set(['monthly','quarterly','semiannual','yearly']); +const validIntervals=new Set(['monthly','quarterly','semiannual','yearly','once']); const validScenarioTypes=new Set(['income','expense','transfer']); const validAccountOwnerships=new Set(['personal','shared','unassigned']); const validAccountFunctions=new Set(['giro','reserve','investment','building','other']); const emptyData=()=>({version:11,settings:{includePeriodicInBalance:true},accounts:[],categories:[],persons:[],incomes:[],expenses:[],transfers:[],scenarios:[]}); const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value); -function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null, endDate: typeof item.endDate==='string' && item.endDate ? item.endDate : null }; } +function normalizeMovement(item={}) { const interval=validIntervals.has(item.interval)?item.interval:'monthly'; return { ...item, interval, dueMonth: interval!=='monthly'&&interval!=='once'&&Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null, oneTimeDate: interval==='once'&&typeof item.oneTimeDate==='string'&&item.oneTimeDate ? item.oneTimeDate : null, endDate: interval!=='once'&&typeof item.endDate==='string'&&item.endDate ? item.endDate : null }; } function normalizeAmountChanges(changes){return Array.isArray(changes)?changes.map(c=>({id:typeof c.id==='string'&&c.id?c.id:crypto.randomUUID(),effectiveFrom:typeof c.effectiveFrom==='string'?c.effectiveFrom:'',amount:Number(c.amount||0)})).sort((a,b)=>a.effectiveFrom.localeCompare(b.effectiveFrom)):[];} function normalizePersonIds(item={}) { const raw=Array.isArray(item.personIds)?item.personIds:(typeof item.personId==='string'&&item.personId?[item.personId]:[]); @@ -57,7 +57,7 @@ function normalizeData(input){ }; } function isValidDateString(value){if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}$/.test(value))return false;const d=new Date(`${value}T00:00:00Z`);return !Number.isNaN(d.getTime())&&d.toISOString().slice(0,10)===value;} -function validateMovement(item){if(!item.id||typeof item.id!=='string')throw new Error('Eintrag ohne gültige ID.');if(!item.name||typeof item.name!=='string')throw new Error('Eintrag ohne Namen.');if(!isFiniteNumber(item.amount)||item.amount<0)throw new Error(`Ungültiger Betrag bei '${item.name}'.`);if(!validIntervals.has(item.interval))throw new Error(`Ungültiges Intervall bei '${item.name}'.`);if(item.dueMonth!==null&&item.dueMonth!==undefined&&(!Number.isInteger(item.dueMonth)||item.dueMonth<1||item.dueMonth>12))throw new Error(`Ungültiger Monat bei '${item.name}'.`);if(item.endDate!==null&&item.endDate!==undefined&&!isValidDateString(item.endDate))throw new Error(`Ungültiges Enddatum bei '${item.name}'.`);} +function validateMovement(item){if(!item.id||typeof item.id!=='string')throw new Error('Eintrag ohne gültige ID.');if(!item.name||typeof item.name!=='string')throw new Error('Eintrag ohne Namen.');if(!isFiniteNumber(item.amount)||item.amount<0)throw new Error(`Ungültiger Betrag bei '${item.name}'.`);if(!validIntervals.has(item.interval))throw new Error(`Ungültiges Intervall bei '${item.name}'.`);if(item.interval==='once'&&(!item.oneTimeDate||!isValidDateString(item.oneTimeDate)))throw new Error(`Bitte ein gültiges Datum für die einmalige Position '${item.name}' setzen.`);if(item.dueMonth!==null&&item.dueMonth!==undefined&&(!Number.isInteger(item.dueMonth)||item.dueMonth<1||item.dueMonth>12))throw new Error(`Ungültiger Monat bei '${item.name}'.`);if(item.endDate!==null&&item.endDate!==undefined&&!isValidDateString(item.endDate))throw new Error(`Ungültiges Enddatum bei '${item.name}'.`);} function validateAmountChanges(item){if(!Array.isArray(item.amountChanges))throw new Error(`Ungültige Betragsänderungen bei '${item.name}'.`);const dates=new Set();for(const change of item.amountChanges){if(!change.id||typeof change.id!=='string')throw new Error(`Betragsänderung ohne ID bei '${item.name}'.`);if(!isValidDateString(change.effectiveFrom))throw new Error(`Ungültiges Änderungsdatum bei '${item.name}'.`);if(!isFiniteNumber(change.amount)||change.amount<0)throw new Error(`Ungültiger Änderungsbetrag bei '${item.name}'.`);if(item.endDate&&change.effectiveFrom.slice(0,7)>item.endDate.slice(0,7))throw new Error(`Betragsänderung bei '${item.name}' liegt nach dem Enddatum.`);if(dates.has(change.effectiveFrom))throw new Error(`Für '${item.name}' existieren zwei Änderungen am selben Datum.`);dates.add(change.effectiveFrom);}} function validatePersonAssignments(item,personIds,label='Eintrag'){ if(!Array.isArray(item.personIds))throw new Error(`Ungültige Personenzuordnung bei '${item.name||label}'.`); @@ -87,7 +87,7 @@ function validateData(input){ if(a.ownership==='shared'&&a.ownerPersonIds.length<2)throw new Error(`Gemeinschaftskonto '${a.name}' braucht mindestens zwei Eigentümer.`); if(a.ownership==='unassigned'&&a.ownerPersonIds.length)throw new Error(`Nicht zugeordnetes Konto '${a.name}' darf keine Eigentümer enthalten.`); } - for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);validateAmountChanges(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);validatePersonAssignments(item,personIds);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);} + for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);validateAmountChanges(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);validatePersonAssignments(item,personIds);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&item.interval!=='once'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval==='once'&&!item.oneTimeDate)throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Datum gesetzt sein.`);} for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);validatePersonAssignments(t,personIds,'Transfer');} const scenarioIds=new Set(); for(const s of input.scenarios){ @@ -122,9 +122,10 @@ async function sendPushover(config,{title,message}){ return payload; } function monthKey(year,monthIndex){return `${year}-${String(monthIndex+1).padStart(2,'0')}`;} -function itemActiveInMonth(item,year,monthIndex){const end=typeof item.endDate==='string'?item.endDate.slice(0,7):'';return !end||monthKey(year,monthIndex)<=end;} +function itemActiveInMonth(item,year,monthIndex){if((item?.interval||'monthly')==='once'){return String(item.oneTimeDate||'').slice(0,7)===monthKey(year,monthIndex);}const end=typeof item.endDate==='string'?item.endDate.slice(0,7):'';return !end||monthKey(year,monthIndex)<=end;} function amountInMonth(item,year,monthIndex){const key=monthKey(year,monthIndex);let amount=Number(item.amount||0);let best='';for(const change of item.amountChanges||[]){const mk=String(change.effectiveFrom||'').slice(0,7);if(mk&&mk<=key&&mk>=best){best=mk;amount=Number(change.amount||0);}}return amount;} function occursInReminderMonth(item,year,monthIndex){ + if((item?.interval||'monthly')==='once')return String(item.oneTimeDate||'').slice(0,7)===monthKey(year,monthIndex); if(!itemActiveInMonth(item,year,monthIndex))return false; const interval=item.interval||'monthly'; if(interval==='monthly')return true; const dueMonth=Number(item.dueMonth); if(!Number.isInteger(dueMonth)||dueMonth<1||dueMonth>12)return false; @@ -151,7 +152,7 @@ async function runPushoverReminderCheck(){ } async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}} -async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];const scenarioAdjustments=(parsed.scenarios||[]).flatMap(s=>s.adjustments||[]);if(parsed.version!==11||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.persons)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||movements.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||scenarioAdjustments.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')||!Object.hasOwn(a,'ownership')||!Array.isArray(a.ownerPersonIds)||!Object.hasOwn(a,'accountFunction')))await atomicWrite(data,true);return data;} +async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];const scenarioAdjustments=(parsed.scenarios||[]).flatMap(s=>s.adjustments||[]);if(parsed.version!==11||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.persons)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||movements.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||scenarioAdjustments.some(x=>!Array.isArray(x.personIds)||Object.hasOwn(x,'personId'))||(parsed.incomes||[]).some(x=>!Array.isArray(x.amountChanges))||(parsed.expenses||[]).some(x=>!Array.isArray(x.amountChanges)||!Object.hasOwn(x,'pushoverReminder'))||movements.some(x=>!Object.hasOwn(x,'endDate'))||movements.some(x=>x.interval==='once'&&!Object.hasOwn(x,'oneTimeDate'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')||!Object.hasOwn(a,'ownership')||!Array.isArray(a.ownerPersonIds)||!Object.hasOwn(a,'accountFunction')))await atomicWrite(data,true);return data;} async function atomicWrite(input,makeBackup=true){const data=normalizeData(input);validateData(data);await fs.mkdir(dataDir,{recursive:true});const tmp=`${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;const payload=JSON.stringify(data,null,2)+'\n';if(makeBackup){try{await fs.copyFile(dataFile,backupFile);}catch(error){if(error.code!=='ENOENT')throw error;}}await fs.writeFile(tmp,payload,{encoding:'utf8',mode:0o600});await fs.rename(tmp,dataFile);} app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}});app.get('/api/report.xlsx',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const personId=typeof req.query.person==='string'&&req.query.person?req.query.person:'all';const includeTransfers=req.query.transfers!=='0';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');if(personId!=='all'&&!data.persons.some(p=>p.id===personId))throw new Error('Bitte eine gültige Person für den Kontoauszug auswählen.');const buffer=await createExcelReport(data,{year,accountId,personId,includeTransfers});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.xlsx"`);res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet').send(Buffer.from(buffer));}catch(e){next(e);}});app.get('/api/report.pdf',async(req,res,next)=>{try{const data=await readData();const year=Number(req.query.year)||new Date().getFullYear();const accountId=typeof req.query.account==='string'?req.query.account:'';const personId=typeof req.query.person==='string'&&req.query.person?req.query.person:'all';const includeTransfers=req.query.transfers!=='0';const account=data.accounts.find(a=>a.id===accountId);if(!account)throw new Error('Bitte ein Konto für den Kontoauszug auswählen.');if(personId!=='all'&&!data.persons.some(p=>p.id===personId))throw new Error('Bitte eine gültige Person für den Kontoauszug auswählen.');const buffer=await createPdfReport(data,{year,accountId,personId,includeTransfers});const suffix=account.name.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');const personSuffix=personId==='all'?'':`-${data.persons.find(p=>p.id===personId)?.name||'Person'}`.replace(/[^a-zA-Z0-9äöüÄÖÜß_-]+/g,'-');res.setHeader('Content-Disposition',`attachment; filename="fixfin-kontoauszug-${year}-${suffix}${personSuffix}.pdf"`);res.type('application/pdf').send(buffer);}catch(e){next(e);}});app.get('/api/pushover/config',async(_req,res,next)=>{try{const config=await readPushoverConfig();res.json(config?{configured:true,userMasked:maskKey(config.user)}:{configured:false,userMasked:''});}catch(e){next(e);}});app.put('/api/pushover/config',async(req,res,next)=>{try{res.json(await savePushoverConfig(req.body));}catch(e){next(e);}});app.delete('/api/pushover/config',async(_req,res,next)=>{try{await fs.rm(pushoverFile,{force:true});res.json({configured:false,userMasked:''});}catch(e){next(e);}});app.post('/api/pushover/test',async(_req,res,next)=>{try{const config=await readPushoverConfig();if(!config)throw new Error('Pushover ist noch nicht konfiguriert.');await sendPushover(config,{title:'FixFin · Test',message:'Pushover ist erfolgreich mit FixFin verbunden.'});res.json({ok:true});}catch(e){next(e);}}); const dist=path.resolve(__dirname,'../dist');app.use(express.static(dist));app.use((_req,res)=>res.sendFile(path.join(dist,'index.html')));app.use((error,_req,res,_next)=>{console.error(error);res.status(400).json({error:error.message||'Unbekannter Fehler'});});await ensureDataFile();app.listen(port,'0.0.0.0',()=>{console.log(`FixFin läuft auf Port ${port}`);console.log(`Datendatei: ${dataFile}`);console.log(`Pushover-Reminder: Prüfung am 1. des Monats ab ${String(reminderHour).padStart(2,'0')}:00 Uhr (${process.env.TZ||'System-Zeitzone'})`);});setTimeout(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),5000);setInterval(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),15*60*1000); diff --git a/server/reports.js b/server/reports.js index 0f29824..329f14e 100644 --- a/server/reports.js +++ b/server/reports.js @@ -2,13 +2,15 @@ import ExcelJS from 'exceljs'; import PDFDocument from 'pdfkit'; const MONTHS=['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; -const INTERVALS={monthly:{label:'Monatlich',step:1},quarterly:{label:'Vierteljährlich',step:3},semiannual:{label:'Halbjährlich',step:6},yearly:{label:'Jährlich',step:12}}; +const INTERVALS={monthly:{label:'Monatlich',step:1},quarterly:{label:'Vierteljährlich',step:3},semiannual:{label:'Halbjährlich',step:6},yearly:{label:'Jährlich',step:12},once:{label:'Einmalig',step:0}}; const money=new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}); function monthKey(year,monthIndex){return `${year}-${String(monthIndex+1).padStart(2,'0')}`;} function dateMonth(value){return typeof value==='string'&&/^\d{4}-\d{2}-\d{2}$/.test(value)?value.slice(0,7):null;} -function activeInMonth(item,year,monthIndex){const key=monthKey(year,monthIndex);const end=dateMonth(item.endDate);return !end||key<=end;} +function isOnce(item){return (item?.interval||'monthly')==='once';} +function activeInMonth(item,year,monthIndex){if(isOnce(item)){return dateMonth(item.oneTimeDate)===monthKey(year,monthIndex);}const key=monthKey(year,monthIndex);const end=dateMonth(item.endDate);return !end||key<=end;} function occursInMonth(item,year,monthIndex){ + if(isOnce(item))return dateMonth(item.oneTimeDate)===monthKey(year,monthIndex); if(!activeInMonth(item,year,monthIndex))return false; const interval=item.interval||'monthly'; if(interval==='monthly')return true; const dueMonth=Number(item.dueMonth); if(!Number.isInteger(dueMonth)||dueMonth<1||dueMonth>12)return false; diff --git a/src/main.jsx b/src/main.jsx index 51d5646..e92171c 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -9,6 +9,7 @@ const intervals = { quarterly: { label: 'Vierteljährlich', divisor: 3, step: 3 }, semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 }, yearly: { label: 'Jährlich', divisor: 12, step: 12 }, + once: { label: 'Einmalig', divisor: 0, step: 0 }, }; const emptyData = { version: 11, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], persons: [], incomes: [], expenses: [], transfers: [], scenarios: [] }; const accountFunctions = { @@ -57,21 +58,44 @@ function uid() { } function monthKey(year, monthIndex) { return `${year}-${String(monthIndex + 1).padStart(2, '0')}`; } function dateMonth(value) { return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value.slice(0, 7) : null; } -function itemActiveInMonth(item, year, monthIndex) { const end = dateMonth(item.endDate); return !end || monthKey(year, monthIndex) <= end; } +function isOnce(item) { return (item?.interval || 'monthly') === 'once'; } +function itemActiveInMonth(item, year, monthIndex) { + if (isOnce(item)) { + const when = dateMonth(item.oneTimeDate); + if (!when) return false; + return monthKey(year, monthIndex) === when; + } + const end = dateMonth(item.endDate); + return !end || monthKey(year, monthIndex) <= end; +} function effectiveAmount(item, year = new Date().getFullYear(), monthIndex = new Date().getMonth()) { const key = monthKey(year, monthIndex); let amount = Number(item.amount || 0); let best = ''; for (const change of item.amountChanges || []) { const mk = dateMonth(change.effectiveFrom); if (mk && mk <= key && mk >= best) { best = mk; amount = Number(change.amount || 0); } } return amount; } -function monthlyEquivalent(item, year = new Date().getFullYear(), monthIndex = new Date().getMonth()) { return itemActiveInMonth(item, year, monthIndex) ? effectiveAmount(item, year, monthIndex) / (intervals[item.interval]?.divisor || 1) : 0; } -function annualEquivalent(item) { return monthlyEquivalent(item) * 12; } -function isPeriodic(item) { return (item.interval || 'monthly') !== 'monthly'; } +function monthlyEquivalent(item, year = new Date().getFullYear(), monthIndex = new Date().getMonth()) { + if (!itemActiveInMonth(item, year, monthIndex)) return 0; + if (isOnce(item)) return occursInMonth(item, monthIndex, year) ? effectiveAmount(item, year, monthIndex) : 0; + return effectiveAmount(item, year, monthIndex) / (intervals[item.interval]?.divisor || 1); +} +function annualEquivalent(item, year = new Date().getFullYear()) { + if (isOnce(item)) { + const when = dateMonth(item.oneTimeDate); + if (!when || !when.startsWith(`${year}-`)) return 0; + return effectiveAmount(item, year, Number(when.slice(5, 7)) - 1); + } + return monthlyEquivalent(item, year, new Date().getMonth()) * 12; +} +function isPeriodic(item) { return !['monthly', 'once'].includes(item.interval || 'monthly'); } function balanceAmount(item, includePeriodic) { - const now = new Date(); if (!itemActiveInMonth(item, now.getFullYear(), now.getMonth())) return 0; + const now = new Date(); + if (!itemActiveInMonth(item, now.getFullYear(), now.getMonth())) return 0; + if (isOnce(item)) return occursInMonth(item, now.getMonth(), now.getFullYear()) ? effectiveAmount(item, now.getFullYear(), now.getMonth()) : 0; if (!isPeriodic(item)) return effectiveAmount(item, now.getFullYear(), now.getMonth()); return includePeriodic ? monthlyEquivalent(item, now.getFullYear(), now.getMonth()) : 0; } function occursInMonth(item, monthIndex, year = new Date().getFullYear()) { + if (isOnce(item)) return dateMonth(item.oneTimeDate) === monthKey(year, monthIndex); if (!itemActiveInMonth(item, year, monthIndex)) return false; const interval = item.interval || 'monthly'; if (interval === 'monthly') return true; @@ -502,6 +526,15 @@ function reserveOverview(data) { } function formatDate(value){if(!value)return '';const d=new Date(`${value}T12:00:00`);return Number.isNaN(d.getTime())?value:new Intl.DateTimeFormat('de-DE',{dateStyle:'medium'}).format(d);} +function movementDueText(item){ + if((item?.interval||'monthly')==='monthly')return 'Jeden Monat'; + if((item?.interval||'monthly')==='once')return item.oneTimeDate?formatDate(item.oneTimeDate):'Bitte Datum setzen'; + return item.dueMonth?months[Number(item.dueMonth)-1]:'Nicht festgelegt'; +} +function movementAmountHint(item){ + if((item?.interval||'monthly')==='once')return item.oneTimeDate?`einmalig am ${formatDate(item.oneTimeDate)}`:'einmalig'; + return `${formatMoney(monthlyEquivalent(item))} / Monat`; +} function buildWarnings(data, includeTransfers = true){ const now=new Date();const year=now.getFullYear();const currentMonth=now.getMonth();const horizon=new Date(now);horizon.setDate(horizon.getDate()+90);const warnings=[]; for(const account of data.accounts){const f=annualForecast(data,account.id,year,'all',includeTransfers);const negatives=f.rows.slice(currentMonth).filter(r=>r.balance<0);if(negatives.length){const worst=negatives.reduce((a,b)=>b.balancecategories.find(c=>c.id===id)?.name||'Ohne Kategorie'; const personLabelLocal=item=>{const ids=personIdsOf(item);return ids.length?ids.map(id=>persons.find(p=>p.id===id)?.name||'Unbekannt').join(', '):'Ohne Person';}; const isIncome=type==='incomes'; - const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; + const dueText=movementDueText; const currentAmount=x=>effectiveAmount(x); return

{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}

Nur Basisinfos bleiben sichtbar. Details und Aktionen öffnen sich je Bewegung per Akkordeon.

{items.length===0?:
{items.map(x=>
-
{x.name}
{categoryNameLocal(x.categoryId)}{personLabelLocal(x)}{accountNameLocal(x.accountId)}{intervals[x.interval||'monthly'].label}{x.interval!=='monthly'&&{dueText(x)}}{!isIncome&&x.pushoverReminder&&Reminder}{(x.amountChanges||[]).length>0&&{x.amountChanges.length} geplant}{x.endDate&&bis {formatDate(x.endDate)}}
{isIncome?'+':'−'}{formatMoney(currentAmount(x))}{formatMoney(monthlyEquivalent(x))} / Monat
-
Personen{personLabelLocal(x)}
Konto{accountNameLocal(x.accountId)}
Kategorie{categoryNameLocal(x.categoryId)}
Intervall{intervals[x.interval||'monthly'].label}
Fälligkeit{dueText(x)}
Ø / Monat{formatMoney(monthlyEquivalent(x))}
{x.endDate&&
Enddatum{formatDate(x.endDate)}
}{!isIncome&&x.pushoverReminder&&
ErinnerungAm 1. des Fälligkeitsmonats
}
{(x.amountChanges||[]).length>0&&
Geplante Betragsänderungen{x.amountChanges.map(c=>
ab {formatDate(c.effectiveFrom)}{formatMoney(c.amount)}
)}
}
+
{x.name}
{categoryNameLocal(x.categoryId)}{personLabelLocal(x)}{accountNameLocal(x.accountId)}{intervals[x.interval||'monthly'].label}{x.interval!=='monthly'&&{dueText(x)}}{!isIncome&&x.pushoverReminder&&Reminder}{(x.amountChanges||[]).length>0&&{x.amountChanges.length} geplant}{x.endDate&&bis {formatDate(x.endDate)}}
{isIncome?'+':'−'}{formatMoney(currentAmount(x))}{movementAmountHint(x)}
+
Personen{personLabelLocal(x)}
Konto{accountNameLocal(x.accountId)}
Kategorie{categoryNameLocal(x.categoryId)}
Intervall{intervals[x.interval||'monthly'].label}
Fälligkeit{dueText(x)}
{x.interval==='once'?'Einmalig':'Ø / Monat'}{x.interval==='once'?formatMoney(effectiveAmount(x)):formatMoney(monthlyEquivalent(x))}
{x.endDate&&
Enddatum{formatDate(x.endDate)}
}{!isIncome&&x.pushoverReminder&&
Erinnerung{x.interval==='once'?'Am 1. des Zielmonats':'Am 1. des Fälligkeitsmonats'}
}
{(x.amountChanges||[]).length>0&&
Geplante Betragsänderungen{x.amountChanges.map(c=>
ab {formatDate(c.effectiveFrom)}{formatMoney(c.amount)}
)}
}
)}
}
; } @@ -781,10 +814,10 @@ function Transfers({data,onEdit,onDuplicate,onDelete}){ const accountNameLocal=id=>data.accounts.find(a=>a.id===id)?.name||'Unbekannt'; const categoryNameLocal=id=>data.categories.find(c=>c.id===id)?.name||'Ohne Kategorie'; const personLabelLocal=item=>transferPeopleLabel(data,item); - const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; + const dueText=movementDueText; return

Fixe Transfers

Quelle, Ziel, Betrag und Einordnung bleiben direkt sichtbar. Weitere Details und Aktionen liegen im Akkordeon.

{data.transfers.length===0?:
{data.transfers.map(x=>{const cls=transferClassification(data,x);return
-
{x.name}{accountNameLocal(x.fromAccountId)} {accountNameLocal(x.toAccountId)}
{cls.label}{personLabelLocal(x)}{categoryNameLocal(x.categoryId)}{intervals[x.interval||'monthly'].label}{x.interval!=='monthly'&&{dueText(x)}}{x.endDate&&bis {formatDate(x.endDate)}}
{formatMoney(effectiveAmount(x))}{formatMoney(monthlyEquivalent(x))} / Monat
-
Von{accountNameLocal(x.fromAccountId)}
Nach{accountNameLocal(x.toAccountId)}
Personen{personLabelLocal(x)}
Einordnung{cls.description}
Kategorie{categoryNameLocal(x.categoryId)}
Intervall{intervals[x.interval||'monthly'].label}
Fälligkeit{dueText(x)}
Ø / Monat{formatMoney(monthlyEquivalent(x))}
{x.endDate&&
Enddatum{formatDate(x.endDate)}
}
+
{x.name}{accountNameLocal(x.fromAccountId)} {accountNameLocal(x.toAccountId)}
{cls.label}{personLabelLocal(x)}{categoryNameLocal(x.categoryId)}{intervals[x.interval||'monthly'].label}{x.interval!=='monthly'&&{dueText(x)}}{x.endDate&&bis {formatDate(x.endDate)}}
{formatMoney(effectiveAmount(x))}{movementAmountHint(x)}
+
Von{accountNameLocal(x.fromAccountId)}
Nach{accountNameLocal(x.toAccountId)}
Personen{personLabelLocal(x)}
Einordnung{cls.description}
Kategorie{categoryNameLocal(x.categoryId)}
Intervall{intervals[x.interval||'monthly'].label}
Fälligkeit{dueText(x)}
{x.interval==='once'?'Einmalig':'Ø / Monat'}{x.interval==='once'?formatMoney(effectiveAmount(x)):formatMoney(monthlyEquivalent(x))}
{x.endDate&&
Enddatum{formatDate(x.endDate)}
}
})}
}
; } @@ -1027,6 +1060,7 @@ function EditorDialog({dialog,data,onClose,onSave}){ [toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''), [interval,setInterval]=useState(item?.interval||'monthly'), [dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''), + [oneTimeDate,setOneTimeDate]=useState(item?.oneTimeDate||''), [categoryId,setCategoryId]=useState(item?.categoryId||''), [newCategoryName,setNewCategoryName]=useState(''), [personIds,setPersonIds]=useState(personIdsOf(item)), @@ -1054,14 +1088,16 @@ function EditorDialog({dialog,data,onClose,onSave}){ if(type==='accounts'&&accountOwnership==='shared'&&accountOwnerPersonIds.length<2)return setLocalError('Ein Gemeinschaftskonto braucht mindestens zwei Personen als Eigentümer.'); if(['incomes','expenses','transfers'].includes(type)&&personIds.length===0&&!newPersonName.trim())return setLocalError('Bitte mindestens eine Person auswählen oder direkt neu anlegen.'); if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.'); - if(type==='expenses'&&pushoverReminder&&interval!=='monthly'&&!dueMonth)return setLocalError('Für den Reminder bitte einen Fälligkeitsmonat festlegen.'); + if(interval==='once'&&!oneTimeDate)return setLocalError('Bitte ein Datum für die einmalige Position festlegen.'); + if(type==='expenses'&&pushoverReminder&&interval!=='monthly'&&interval!=='once'&&!dueMonth)return setLocalError('Für den Reminder bitte einen Fälligkeitsmonat festlegen.'); + if(type==='expenses'&&pushoverReminder&&interval==='once'&&!oneTimeDate)return setLocalError('Für den Reminder bitte das Zieldatum festlegen.'); if(endDate&&amountChanges.some(c=>c.effectiveFrom.slice(0,7)>endDate.slice(0,7)))return setLocalError('Eine geplante Betragsänderung liegt nach dem Enddatum.'); let next=structuredClone(data); const id=editing?item.id:uid(); let value; if(type==='accounts') value={id,name:name.trim(),ownership:accountOwnership,ownerPersonIds:[...new Set(accountOwnerPersonIds)],accountFunction}; else if(type==='persons') { const duplicate=(next.persons||[]).some(p=>p.id!==item?.id&&p.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); if(duplicate)return setLocalError('Diese Person existiert bereits.'); value={id,name:name.trim()}; } else if(type==='categories') { const duplicate=next.categories.some(c=>c.id!==item?.id&&c.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); if(duplicate)return setLocalError('Diese Kategorie existiert bereits.'); value={id,name:name.trim()}; } else { - const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null),endDate:endDate||null}; + const common={id,name:name.trim(),amount:numeric,interval,dueMonth:['monthly','once'].includes(interval)?null:(dueMonth?Number(dueMonth):null),oneTimeDate:interval==='once'?(oneTimeDate||null):null,endDate:interval==='once'?null:(endDate||null)}; let selectedCategoryId=categoryId||null; if(categoryId==='__new__'){const categoryNameValue=newCategoryName.trim();if(!categoryNameValue)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedCategoryId=existing.id;else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryNameValue}); }} let selectedPersonIds=[...new Set(personIds)]; @@ -1084,14 +1120,14 @@ function EditorDialog({dialog,data,onClose,onSave}){ const simpleType=type==='accounts'||type==='categories'||type==='persons'; return
e.target===e.currentTarget&&onClose()}>
{dialog.duplicate?'DUPLIZIEREN':editing?'BEARBEITEN':'NEU'}

{labels[type]}

{localError&&
{localError}
} {type==='accounts'&&
{accountOwnership==='personal'?:}
} - {!simpleType&&<>{interval!=='monthly'&&}} + {!simpleType&&<>{interval==='once'&&}{interval!=='monthly'&&interval!=='once'&&}{interval!=='once'&&}} {(type==='incomes'||type==='expenses'||type==='transfers')&&<>{categoryId==='__new__'&&}} {(type==='incomes'||type==='expenses'||type==='transfers')&&<> {!showNewPerson?:
} } {(type==='incomes'||type==='expenses')&&} - {(type==='incomes'||type==='expenses')&&
Geplante BetragsänderungenEin neuer Betrag gilt ab dem Monat des gewählten Datums. Der Basisbetrag oben bleibt für frühere Monate erhalten.
{amountChanges.length}
{amountChanges.length>0&&
{amountChanges.map(c=>
ab {formatDate(c.effectiveFrom)}{formatMoney(c.amount)}
)}
}
} + {(type==='incomes'||type==='expenses')&&interval!=='once'&&
Geplante BetragsänderungenEin neuer Betrag gilt ab dem Monat des gewählten Datums. Der Basisbetrag oben bleibt für frühere Monate erhalten.
{amountChanges.length}
{amountChanges.length>0&&
{amountChanges.map(c=>
ab {formatDate(c.effectiveFrom)}{formatMoney(c.amount)}
)}
}
} {type==='expenses'&&} {type==='transfers'&&<>}
; }