V3.15
This commit is contained in:
+49
-7
@@ -6,13 +6,16 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__filename); const app=express();
|
||||
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 validScenarioTypes=new Set(['income','expense','transfer']);
|
||||
const emptyData=()=>({version:6,settings:{includePeriodicInBalance:true},accounts:[],categories:[],incomes:[],expenses:[],transfers:[],scenarios:[]});
|
||||
const emptyData=()=>({version:7,settings:{includePeriodicInBalance:true},accounts:[],categories:[],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 }; }
|
||||
function normalizeCategorizedMovement(item={}) { const normalized=normalizeMovement(item); return { ...normalized, categoryId: typeof item.categoryId==='string' && item.categoryId ? item.categoryId : null }; }
|
||||
function normalizeExpense(item={}) { return { ...normalizeCategorizedMovement(item), pushoverReminder: item.pushoverReminder===true }; }
|
||||
function normalizeScenarioAdjustment(item={}) {
|
||||
const type=validScenarioTypes.has(item.type)?item.type:'expense';
|
||||
const base={id:item.id,name:item.name,type,amount:Number(item.amount||0)};
|
||||
@@ -23,12 +26,12 @@ function normalizeScenario(item={}) { return {id:item.id,name:item.name,createdA
|
||||
function normalizeData(input){
|
||||
if(!input||typeof input!=='object')return input;
|
||||
return {
|
||||
version:6,
|
||||
version:7,
|
||||
settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false},
|
||||
accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts,
|
||||
categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[],
|
||||
incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeCategorizedMovement):input.incomes,
|
||||
expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeCategorizedMovement):input.expenses,
|
||||
expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeExpense):input.expenses,
|
||||
transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers,
|
||||
scenarios:Array.isArray(input.scenarios)?input.scenarios.map(normalizeScenario):[],
|
||||
};
|
||||
@@ -41,7 +44,7 @@ function validateData(input){
|
||||
for(const a of input.accounts){if(!a.id||typeof a.id!=='string')throw new Error('Konto ohne gültige ID.');if(!a.name||typeof a.name!=='string')throw new Error('Konto ohne Namen.');if(accountIds.has(a.id))throw new Error('Doppelte Konto-ID.');accountIds.add(a.id);}
|
||||
const categoryIds=new Set(); const categoryNames=new Set();
|
||||
for(const c of input.categories){if(!c.id||typeof c.id!=='string')throw new Error('Kategorie ohne gültige ID.');if(!c.name||typeof c.name!=='string'||!c.name.trim())throw new Error('Kategorie ohne Namen.');if(categoryIds.has(c.id))throw new Error('Doppelte Kategorie-ID.');const normalizedName=c.name.trim().toLocaleLowerCase('de-DE');if(categoryNames.has(normalizedName))throw new Error(`Kategorie '${c.name}' existiert bereits.`);categoryIds.add(c.id);categoryNames.add(normalizedName);}
|
||||
for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(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}'.`);}
|
||||
for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(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}'.`);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 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}'.`);}
|
||||
const scenarioIds=new Set();
|
||||
for(const s of input.scenarios){
|
||||
@@ -60,8 +63,47 @@ function validateData(input){
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pushoverKeyPattern=/^[A-Za-z0-9]{30}$/;
|
||||
function maskKey(value=''){return value?`${value.slice(0,4)}••••••${value.slice(-4)}`:'';}
|
||||
async function readJsonFile(file,fallback){try{return JSON.parse(await fs.readFile(file,'utf8'));}catch(error){if(error.code==='ENOENT')return fallback;throw error;}}
|
||||
async function writePrivateJson(file,value){await fs.mkdir(path.dirname(file),{recursive:true});const tmp=`${file}.${process.pid}.${crypto.randomUUID()}.tmp`;await fs.writeFile(tmp,JSON.stringify(value,null,2)+'\n',{encoding:'utf8',mode:0o600});await fs.rename(tmp,file);}
|
||||
async function readPushoverConfig(){const value=await readJsonFile(pushoverFile,null);if(!value||!pushoverKeyPattern.test(value.token||'')||!pushoverKeyPattern.test(value.user||''))return null;return {token:value.token,user:value.user};}
|
||||
async function savePushoverConfig(input){const token=String(input?.token||'').trim();const user=String(input?.user||'').trim();if(!pushoverKeyPattern.test(token))throw new Error('Der Pushover Application API Token muss 30 alphanumerische Zeichen enthalten.');if(!pushoverKeyPattern.test(user))throw new Error('Der Pushover User/Group Key muss 30 alphanumerische Zeichen enthalten.');await writePrivateJson(pushoverFile,{token,user,updatedAt:new Date().toISOString()});return {configured:true,userMasked:maskKey(user)};}
|
||||
async function sendPushover(config,{title,message}){
|
||||
const body=new URLSearchParams({token:config.token,user:config.user,title,message});
|
||||
const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.15'},body});
|
||||
let payload={}; try{payload=await response.json();}catch{}
|
||||
if(!response.ok||payload.status!==1)throw new Error(Array.isArray(payload.errors)?payload.errors.join(' '):`Pushover-API Fehler (${response.status})`);
|
||||
return payload;
|
||||
}
|
||||
function occursInReminderMonth(item,monthIndex){
|
||||
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;
|
||||
const start=dueMonth-1; const step=interval==='quarterly'?3:interval==='semiannual'?6:12;
|
||||
return monthIndex>=start&&(monthIndex-start)%step===0;
|
||||
}
|
||||
function reminderMessage(data,expense,now){
|
||||
const account=data.accounts.find(a=>a.id===expense.accountId)?.name||'Unbekanntes Konto';
|
||||
const amount=new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(Number(expense.amount||0));
|
||||
const month=new Intl.DateTimeFormat('de-DE',{month:'long'}).format(now);
|
||||
return {title:`FixFin · ${expense.name}`,message:`Ausgabe im ${month}: ${amount}\nKonto: ${account}\nBitte prüfen, ob eine Umbuchung nötig ist.`};
|
||||
}
|
||||
async function runPushoverReminderCheck(){
|
||||
const now=new Date(); if(now.getDate()!==1||now.getHours()<reminderHour)return;
|
||||
const config=await readPushoverConfig(); if(!config)return;
|
||||
const data=await readData(); const state=await readJsonFile(pushoverStateFile,{sent:{}}); if(!state.sent||typeof state.sent!=='object')state.sent={};
|
||||
const monthKey=`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`; let changed=false;
|
||||
for(const expense of data.expenses.filter(x=>x.pushoverReminder===true&&occursInReminderMonth(x,now.getMonth()))){
|
||||
const key=`${monthKey}:${expense.id}`; if(state.sent[key])continue;
|
||||
try{await sendPushover(config,reminderMessage(data,expense,now));state.sent[key]=new Date().toISOString();changed=true;console.log(`[Pushover] Reminder gesendet: ${expense.name}`);}catch(error){console.error(`[Pushover] Reminder fehlgeschlagen (${expense.name}):`,error.message);}
|
||||
}
|
||||
const keepAfter=new Date(now.getFullYear()-2,now.getMonth(),1); for(const key of Object.keys(state.sent)){const ym=key.slice(0,7);const [y,m]=ym.split('-').map(Number);if(Number.isInteger(y)&&Number.isInteger(m)&&new Date(y,m-1,1)<keepAfter){delete state.sent[key];changed=true;}}
|
||||
if(changed)await writePrivateJson(pushoverStateFile,state);
|
||||
}
|
||||
|
||||
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||[])];if(parsed.version!==6||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))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||[])];if(parsed.version!==7||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||(parsed.expenses||[]).some(x=>!Object.hasOwn(x,'pushoverReminder'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))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);}});
|
||||
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}`);});
|
||||
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/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);
|
||||
|
||||
Reference in New Issue
Block a user