This commit is contained in:
2026-08-20 19:22:46 +02:00
parent 647cbbbdd0
commit 686b3da3cf
5 changed files with 67 additions and 24 deletions
+7 -6
View File
@@ -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);
+4 -2
View File
@@ -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;