This commit is contained in:
sebseb7
2025-07-19 21:58:07 +02:00
commit 102a4ec9ff
37 changed files with 14258 additions and 0 deletions

82
src/config/database.js Normal file
View File

@@ -0,0 +1,82 @@
const sql = require('mssql');
const config = {
server: process.env.DB_SERVER,
database: process.env.DB_DATABASE,
user: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
port: parseInt(process.env.DB_PORT) || 1433,
options: {
encrypt: false, // Disable encryption to avoid TLS warnings with IP addresses
trustServerCertificate: true,
enableArithAbort: true,
},
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30000,
},
};
let poolPromise;
const getPool = () => {
if (!poolPromise) {
poolPromise = new sql.ConnectionPool(config).connect().then(pool => {
console.log('✅ Connected to MSSQL database');
return pool;
}).catch(err => {
console.error('❌ Database connection failed:', err);
poolPromise = null;
throw err;
});
}
return poolPromise;
};
const testConnection = async () => {
try {
if (!process.env.DB_SERVER) {
console.log('⚠️ Database configuration not found. Application will run without database.');
return false;
}
const pool = await getPool();
const result = await pool.request().query('SELECT 1 as test');
console.log('✅ Database connection test successful');
return true;
} catch (error) {
console.error('❌ Database connection test failed:', error.message);
return false;
}
};
const executeQuery = async (query, params = {}) => {
if (!process.env.DB_SERVER) {
throw new Error('Database not configured');
}
try {
const pool = await getPool();
const request = pool.request();
// Add parameters to the request
Object.keys(params).forEach(key => {
request.input(key, params[key]);
});
const result = await request.query(query);
return result;
} catch (error) {
console.error('Database query error:', error);
throw error;
}
};
module.exports = {
config,
getPool,
testConnection,
executeQuery,
sql,
};

36
src/database/schema.sql Normal file
View File

@@ -0,0 +1,36 @@
-- FibDash Database Schema
-- Run these commands in your MSSQL database
-- Create Users table
CREATE TABLE Users (
id INT IDENTITY(1,1) PRIMARY KEY,
google_id NVARCHAR(255) UNIQUE NOT NULL,
email NVARCHAR(255) UNIQUE NOT NULL,
name NVARCHAR(255) NOT NULL,
picture NVARCHAR(500),
created_at DATETIME2 DEFAULT GETDATE(),
last_login DATETIME2,
is_active BIT DEFAULT 1
);
-- Create UserPreferences table
CREATE TABLE UserPreferences (
id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT NOT NULL,
theme NVARCHAR(50) DEFAULT 'light',
language NVARCHAR(10) DEFAULT 'en',
notifications_enabled BIT DEFAULT 1,
created_at DATETIME2 DEFAULT GETDATE(),
updated_at DATETIME2 DEFAULT GETDATE(),
FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE
);
-- Create indexes for better performance
CREATE INDEX IX_Users_Email ON Users(email);
CREATE INDEX IX_Users_GoogleId ON Users(google_id);
CREATE INDEX IX_UserPreferences_UserId ON UserPreferences(user_id);
-- Insert sample data (optional)
-- Note: This will only work after you have real Google user data
-- INSERT INTO Users (google_id, email, name, picture)
-- VALUES ('sample_google_id', 'user@example.com', 'Lorem Ipsum User', 'https://example.com/picture.jpg');

78
src/index.js Normal file
View File

@@ -0,0 +1,78 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
require('dotenv').config();
const authRoutes = require('./routes/auth');
const dashboardRoutes = require('./routes/dashboard');
const adminRoutes = require('./routes/admin');
const dataRoutes = require('./routes/data');
const dbConfig = require('./config/database');
const app = express();
const PORT = process.env.PORT || 5000;
// Middleware
app.use(cors());
app.use(express.json());
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/data', dataRoutes);
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({ status: 'OK', timestamp: new Date().toISOString() });
});
// Debug login page (development only)
if (process.env.NODE_ENV !== 'production') {
app.get('/debug-login', (req, res) => {
res.sendFile(path.join(__dirname, '../debug-login.html'));
});
}
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
// Serve static files from dist directory
app.use(express.static(path.join(__dirname, '../dist'), {
maxAge: '1y', // Cache static assets for 1 year
etag: true,
}));
// Handle client-side routing - serve index.html for all non-API routes
app.get('*', (req, res, next) => {
// Skip API routes
if (req.path.startsWith('/api/')) {
return next();
}
res.sendFile(path.join(__dirname, '../dist/index.html'));
});
console.log('📦 Serving static files from dist/ directory');
} else {
console.log('🔧 Development mode - static files served by webpack-dev-server');
}
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err);
res.status(500).json({ error: 'Internal server error' });
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
app.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT}`);
console.log(`📊 Dashboard: http://localhost:3000`);
console.log(`🔗 API: http://localhost:${PORT}/api`);
// Test database connection
const { testConnection } = require('./config/database');
testConnection();
});

46
src/middleware/auth.js Normal file
View File

@@ -0,0 +1,46 @@
const jwt = require('jsonwebtoken');
const { isEmailAuthorized } = require('./emailAuth');
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, process.env.JWT_SECRET || 'fallback_secret', (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid token' });
}
// Additional check: ensure the user's email is still authorized
if (!isEmailAuthorized(user.email)) {
return res.status(403).json({
error: 'Access denied',
message: 'Your email address is no longer authorized to access this application'
});
}
req.user = user;
next();
});
};
const generateToken = (user) => {
return jwt.sign(
{
id: user.id,
email: user.email,
name: user.name,
picture: user.picture,
},
process.env.JWT_SECRET || 'fallback_secret',
{ expiresIn: '24h' }
);
};
module.exports = {
authenticateToken,
generateToken,
};

View File

@@ -0,0 +1,48 @@
const checkAuthorizedEmail = (req, res, next) => {
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
// If no authorized emails are configured, deny all users
if (!authorizedEmails || authorizedEmails.trim() === '') {
return res.status(403).json({
error: 'Access denied',
message: 'No authorized users configured. Contact administrator.'
});
}
const emailList = authorizedEmails.split(',').map(email => email.trim().toLowerCase());
const userEmail = req.user?.email?.toLowerCase();
if (!userEmail) {
return res.status(401).json({
error: 'User email not found',
message: 'Authentication required'
});
}
if (!emailList.includes(userEmail)) {
return res.status(403).json({
error: 'Access denied',
message: 'Your email address is not authorized to access this application'
});
}
next();
};
const isEmailAuthorized = (email) => {
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
// If no authorized emails are configured, deny all users
if (!authorizedEmails || authorizedEmails.trim() === '') {
return false;
}
const emailList = authorizedEmails.split(',').map(e => e.trim().toLowerCase());
const userEmail = email.toLowerCase();
return emailList.includes(userEmail);
};
module.exports = {
checkAuthorizedEmail,
isEmailAuthorized,
};

110
src/routes/admin.js Normal file
View File

@@ -0,0 +1,110 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const { checkAuthorizedEmail } = require('../middleware/emailAuth');
const fs = require('fs');
const path = require('path');
const router = express.Router();
// Check if user is admin (first email in the list or specific admin email)
const checkAdminAccess = (req, res, next) => {
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
if (!authorizedEmails || authorizedEmails.trim() === '') {
return res.status(403).json({ error: 'No authorized emails configured' });
}
const emailList = authorizedEmails.split(',').map(email => email.trim().toLowerCase());
const userEmail = req.user?.email?.toLowerCase();
// First email in the list is considered admin, or check for specific admin emails
const adminEmails = [emailList[0]]; // First email is admin
if (!adminEmails.includes(userEmail)) {
return res.status(403).json({
error: 'Admin access required',
message: 'Only administrators can access this resource'
});
}
next();
};
// Get current authorized emails (admin only)
router.get('/authorized-emails', authenticateToken, checkAdminAccess, (req, res) => {
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
if (!authorizedEmails) {
return res.json({ emails: [] });
}
const emailList = authorizedEmails.split(',').map(email => email.trim());
res.json({ emails: emailList });
});
// Add authorized email (admin only)
router.post('/authorized-emails', authenticateToken, checkAdminAccess, (req, res) => {
const { email } = req.body;
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Valid email address required' });
}
const authorizedEmails = process.env.AUTHORIZED_EMAILS || '';
const emailList = authorizedEmails.split(',').map(e => e.trim()).filter(e => e);
const newEmail = email.trim().toLowerCase();
if (emailList.map(e => e.toLowerCase()).includes(newEmail)) {
return res.status(400).json({ error: 'Email already authorized' });
}
emailList.push(email.trim());
// Note: This only updates the runtime environment variable
// For persistent changes, you'd need to update the .env file
process.env.AUTHORIZED_EMAILS = emailList.join(',');
res.json({
message: 'Email added successfully',
emails: emailList,
note: 'Changes are temporary. Update .env file for permanent changes.'
});
});
// Remove authorized email (admin only)
router.delete('/authorized-emails/:email', authenticateToken, checkAdminAccess, (req, res) => {
const emailToRemove = req.params.email.toLowerCase();
const authorizedEmails = process.env.AUTHORIZED_EMAILS || '';
const emailList = authorizedEmails.split(',').map(e => e.trim()).filter(e => e);
const filteredEmails = emailList.filter(email => email.toLowerCase() !== emailToRemove);
if (filteredEmails.length === emailList.length) {
return res.status(404).json({ error: 'Email not found in authorized list' });
}
// Don't allow removing the last admin email
if (filteredEmails.length === 0) {
return res.status(400).json({ error: 'Cannot remove all authorized emails' });
}
// Note: This only updates the runtime environment variable
process.env.AUTHORIZED_EMAILS = filteredEmails.join(',');
res.json({
message: 'Email removed successfully',
emails: filteredEmails,
note: 'Changes are temporary. Update .env file for permanent changes.'
});
});
// Get system info (admin only)
router.get('/system-info', authenticateToken, checkAdminAccess, (req, res) => {
res.json({
authorizedEmailsConfigured: !!process.env.AUTHORIZED_EMAILS,
totalAuthorizedEmails: process.env.AUTHORIZED_EMAILS ? process.env.AUTHORIZED_EMAILS.split(',').length : 0,
currentUser: req.user.email,
isAdmin: true,
environment: process.env.NODE_ENV || 'development'
});
});
module.exports = router;

133
src/routes/auth.js Normal file
View File

@@ -0,0 +1,133 @@
const express = require('express');
const { OAuth2Client } = require('google-auth-library');
const { generateToken, authenticateToken } = require('../middleware/auth');
const { executeQuery } = require('../config/database');
const { isEmailAuthorized } = require('../middleware/emailAuth');
const router = express.Router();
const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
// Google OAuth login
router.post('/google', async (req, res) => {
try {
const { token } = req.body;
console.log('🔍 Login attempt with token:', token ? 'Present' : 'Missing');
if (!token) {
console.log('❌ No token provided');
return res.status(400).json({ error: 'Token is required' });
}
// Verify Google token
console.log('🔐 Verifying Google token...');
const ticket = await client.verifyIdToken({
idToken: token,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
const googleId = payload['sub'];
const email = payload['email'];
const name = payload['name'];
const picture = payload['picture'];
console.log(`👤 Google token verified for: ${email}`);
// Check if email is authorized
const authorized = isEmailAuthorized(email);
console.log(`🔒 Email authorization check for ${email}: ${authorized ? 'ALLOWED' : 'DENIED'}`);
if (!authorized) {
console.log(`❌ Access denied for ${email}`);
return res.status(403).json({
error: 'Access denied',
message: 'Your email address is not authorized to access this application'
});
}
// Check if user exists in database (optional - auth works without DB)
let user;
try {
// Only try database operations if DB is configured
if (process.env.DB_SERVER) {
const userResult = await executeQuery(
'SELECT * FROM Users WHERE email = @email',
{ email }
);
if (userResult.recordset.length > 0) {
// User exists, update last login
user = userResult.recordset[0];
await executeQuery(
'UPDATE Users SET last_login = GETDATE(), picture = @picture WHERE id = @id',
{ picture, id: user.id }
);
} else {
// Create new user
const insertResult = await executeQuery(
`INSERT INTO Users (google_id, email, name, picture, created_at, last_login)
OUTPUT INSERTED.*
VALUES (@googleId, @email, @name, @picture, GETDATE(), GETDATE())`,
{ googleId, email, name, picture }
);
user = insertResult.recordset[0];
}
console.log('✅ Database operations completed successfully');
} else {
console.log('⚠️ No database configured, using fallback user object');
throw new Error('No database configured');
}
} catch (dbError) {
console.error('Database error during authentication:', dbError.message);
// Fallback: create user object without database
user = {
id: googleId,
email,
name,
picture,
google_id: googleId,
};
console.log('✅ Using fallback user object (no database)');
}
// Generate JWT token
const jwtToken = generateToken(user);
res.json({
success: true,
token: jwtToken,
user: {
id: user.id,
email: user.email,
name: user.name,
picture: user.picture,
},
});
} catch (error) {
console.error('Google authentication error:', error);
res.status(401).json({ error: 'Invalid Google token' });
}
});
// Verify JWT token
router.get('/verify', authenticateToken, async (req, res) => {
try {
// Token is already verified by middleware
res.json({
success: true,
user: req.user,
});
} catch (error) {
console.error('Token verification error:', error);
res.status(401).json({ error: 'Invalid token' });
}
});
// Logout (client-side token removal)
router.post('/logout', authenticateToken, (req, res) => {
res.json({ success: true, message: 'Logged out successfully' });
});
module.exports = router;

103
src/routes/dashboard.js Normal file
View File

@@ -0,0 +1,103 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const { executeQuery } = require('../config/database');
const { checkAuthorizedEmail } = require('../middleware/emailAuth');
const router = express.Router();
// Get dashboard data
router.get('/', authenticateToken, async (req, res) => {
try {
let dashboardData = {
stats: [
{ title: 'Total Users', value: 'N/A', icon: 'PeopleIcon', color: '#1976d2' },
{ title: 'Revenue', value: 'N/A', icon: 'TrendingUpIcon', color: '#388e3c' },
{ title: 'Reports', value: 'N/A', icon: 'AssessmentIcon', color: '#f57c00' },
{ title: 'Growth', value: 'N/A', icon: 'TimelineIcon', color: '#7b1fa2' },
],
recentActivity: []
};
try {
// Only try database operations if configured
if (process.env.DB_SERVER) {
// Try to fetch real data from database
const userCountResult = await executeQuery('SELECT COUNT(*) as count FROM Users');
const userCount = userCountResult.recordset[0]?.count || 0;
// Update stats with real data
dashboardData.stats[0].value = userCount.toString();
// Fetch recent activity
const activityResult = await executeQuery(`
SELECT TOP 10
CONCAT('User ', name, ' logged in') as description,
FORMAT(last_login, 'yyyy-MM-dd HH:mm') as timestamp
FROM Users
WHERE last_login IS NOT NULL
ORDER BY last_login DESC
`);
dashboardData.recentActivity = activityResult.recordset || [];
console.log('✅ Dashboard data loaded from database');
} else {
console.log('⚠️ No database configured, using mock dashboard data');
// Update with mock data
dashboardData.stats[0].value = '1';
dashboardData.stats[1].value = '$0';
dashboardData.stats[2].value = '0';
dashboardData.stats[3].value = '0%';
dashboardData.recentActivity = [
{ description: 'System started without database', timestamp: new Date().toISOString().slice(0, 16) }
];
}
} catch (dbError) {
console.error('Database query error in dashboard:', dbError.message);
// Keep fallback data
console.log('✅ Using fallback dashboard data');
}
res.json(dashboardData);
} catch (error) {
console.error('Dashboard error:', error);
res.status(500).json({ error: 'Failed to fetch dashboard data' });
}
});
// Get user-specific data
router.get('/user', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
let userData = {
profile: req.user,
preferences: {},
activity: []
};
try {
// Fetch user preferences from database
const prefsResult = await executeQuery(
'SELECT * FROM UserPreferences WHERE user_id = @userId',
{ userId }
);
if (prefsResult.recordset.length > 0) {
userData.preferences = prefsResult.recordset[0];
}
} catch (dbError) {
console.error('Database query error for user data:', dbError);
}
res.json(userData);
} catch (error) {
console.error('User data error:', error);
res.status(500).json({ error: 'Failed to fetch user data' });
}
});
module.exports = router;

352
src/routes/data.js Normal file
View File

@@ -0,0 +1,352 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const { authenticateToken } = require('../middleware/auth');
const router = express.Router();
// Parse CSV data
const parseCSV = () => {
try {
const csvPath = path.join(__dirname, '../../data.csv');
const csvData = fs.readFileSync(csvPath, 'utf8');
const lines = csvData.split('\n');
const headers = lines[0].split(';').map(h => h.replace(/"/g, ''));
const transactions = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
// Parse CSV line (handle semicolon-separated values with quotes)
const values = [];
let current = '';
let inQuotes = false;
for (let j = 0; j < line.length; j++) {
const char = line[j];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ';' && !inQuotes) {
values.push(current);
current = '';
} else {
current += char;
}
}
values.push(current); // Add last value
if (values.length >= headers.length) {
const transaction = {};
headers.forEach((header, index) => {
transaction[header] = values[index] || '';
});
// Parse date and amount
if (transaction['Buchungstag']) {
const dateParts = transaction['Buchungstag'].split('.');
if (dateParts.length === 3) {
// Convert DD.MM.YY to proper date
const day = dateParts[0];
const month = dateParts[1];
const year = '20' + dateParts[2]; // Assuming 20xx
transaction.parsedDate = new Date(year, month - 1, day);
transaction.monthYear = `${year}-${month.padStart(2, '0')}`;
}
}
// Parse amount
if (transaction['Betrag']) {
const amount = transaction['Betrag'].replace(',', '.').replace(/[^-0-9.]/g, '');
transaction.numericAmount = parseFloat(amount) || 0;
}
transactions.push(transaction);
}
}
return transactions;
} catch (error) {
console.error('Error parsing CSV:', error);
return [];
}
};
// Get available months
router.get('/months', authenticateToken, (req, res) => {
try {
const transactions = parseCSV();
const months = [...new Set(transactions
.filter(t => t.monthYear)
.map(t => t.monthYear)
)].sort().reverse(); // Newest first
res.json({ months });
} catch (error) {
console.error('Error getting months:', error);
res.status(500).json({ error: 'Failed to load months' });
}
});
// Get database transactions for JTL comparison
const getJTLTransactions = async () => {
try {
const { executeQuery } = require('../config/database');
const query = `
SELECT
cKonto, cKontozusatz, cName, dBuchungsdatum,
tZahlungsabgleichUmsatz.kZahlungsabgleichUmsatz,
cVerwendungszweck, fBetrag, tUmsatzKontierung.data
FROM [eazybusiness].[dbo].[tZahlungsabgleichUmsatz]
LEFT JOIN tUmsatzKontierung ON (tUmsatzKontierung.kZahlungsabgleichUmsatz = tZahlungsabgleichUmsatz.kZahlungsabgleichUmsatz)
ORDER BY dBuchungsdatum desc, tZahlungsabgleichUmsatz.kZahlungsabgleichUmsatz desc
`;
const result = await executeQuery(query);
return result.recordset || [];
} catch (error) {
console.error('Error fetching JTL transactions:', error);
return [];
}
};
// Get transactions for a specific month
router.get('/transactions/:monthYear', authenticateToken, async (req, res) => {
try {
const { monthYear } = req.params;
const transactions = parseCSV();
const monthTransactions = transactions
.filter(t => t.monthYear === monthYear)
.sort((a, b) => b.parsedDate - a.parsedDate); // Newest first
// Get JTL transactions for comparison
let jtlTransactions = [];
try {
jtlTransactions = await getJTLTransactions();
} catch (error) {
console.log('JTL database not available, continuing without JTL data');
}
// Filter JTL transactions for the selected month
const [year, month] = monthYear.split('-');
const jtlMonthTransactions = jtlTransactions.filter(jtl => {
const jtlDate = new Date(jtl.dBuchungsdatum);
return jtlDate.getFullYear() === parseInt(year) &&
jtlDate.getMonth() === parseInt(month) - 1;
});
// Add JTL status to each CSV transaction
const transactionsWithJTL = monthTransactions.map(transaction => {
// Try to match by amount and date (approximate matching)
const amount = transaction.numericAmount;
const transactionDate = transaction.parsedDate;
const jtlMatch = jtlMonthTransactions.find(jtl => {
const jtlAmount = parseFloat(jtl.fBetrag) || 0;
const jtlDate = new Date(jtl.dBuchungsdatum);
// Match by amount (exact) and date (same day)
const amountMatch = Math.abs(amount - jtlAmount) < 0.01;
const dateMatch = transactionDate && jtlDate &&
transactionDate.getFullYear() === jtlDate.getFullYear() &&
transactionDate.getMonth() === jtlDate.getMonth() &&
transactionDate.getDate() === jtlDate.getDate();
return amountMatch && dateMatch;
});
return {
...transaction,
hasJTL: !!jtlMatch,
jtlId: jtlMatch ? jtlMatch.kZahlungsabgleichUmsatz : null,
isFromCSV: true
};
});
// Find JTL transactions that don't have CSV matches (red rows)
const unmatchedJTLTransactions = jtlMonthTransactions
.filter(jtl => {
const jtlAmount = parseFloat(jtl.fBetrag) || 0;
const jtlDate = new Date(jtl.dBuchungsdatum);
// Check if this JTL transaction has a CSV match
const hasCSVMatch = monthTransactions.some(transaction => {
const amount = transaction.numericAmount;
const transactionDate = transaction.parsedDate;
const amountMatch = Math.abs(amount - jtlAmount) < 0.01;
const dateMatch = transactionDate && jtlDate &&
transactionDate.getFullYear() === jtlDate.getFullYear() &&
transactionDate.getMonth() === jtlDate.getMonth() &&
transactionDate.getDate() === jtlDate.getDate();
return amountMatch && dateMatch;
});
return !hasCSVMatch;
})
.map(jtl => ({
// Convert JTL format to CSV-like format for display
'Buchungstag': new Date(jtl.dBuchungsdatum).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit'
}),
'Verwendungszweck': jtl.cVerwendungszweck || '',
'Buchungstext': 'JTL Transaction',
'Beguenstigter/Zahlungspflichtiger': jtl.cName || '',
'Betrag': jtl.fBetrag ? jtl.fBetrag.toString().replace('.', ',') : '0,00',
numericAmount: parseFloat(jtl.fBetrag) || 0,
parsedDate: new Date(jtl.dBuchungsdatum),
monthYear: monthYear,
hasJTL: true,
jtlId: jtl.kZahlungsabgleichUmsatz,
isFromCSV: false,
isJTLOnly: true
}));
// Combine CSV and JTL-only transactions
const allTransactions = [...transactionsWithJTL, ...unmatchedJTLTransactions]
.sort((a, b) => b.parsedDate - a.parsedDate);
// Calculate summary
const summary = {
totalTransactions: allTransactions.length,
totalIncome: allTransactions
.filter(t => t.numericAmount > 0)
.reduce((sum, t) => sum + t.numericAmount, 0),
totalExpenses: allTransactions
.filter(t => t.numericAmount < 0)
.reduce((sum, t) => sum + Math.abs(t.numericAmount), 0),
netAmount: allTransactions.reduce((sum, t) => sum + t.numericAmount, 0),
jtlMatches: allTransactions.filter(t => t.hasJTL && t.isFromCSV).length,
jtlMissing: allTransactions.filter(t => !t.hasJTL && t.isFromCSV).length,
jtlOnly: allTransactions.filter(t => t.isJTLOnly).length,
csvOnly: allTransactions.filter(t => !t.hasJTL && t.isFromCSV).length
};
res.json({
transactions: allTransactions,
summary,
monthYear
});
} catch (error) {
console.error('Error getting transactions:', error);
res.status(500).json({ error: 'Failed to load transactions' });
}
});
// DATEV export functionality
const buildDatevHeader = (periodStart, periodEnd) => {
const ts = new Date().toISOString().replace(/[-T:\.Z]/g, '').slice(0, 17); // yyyymmddHHMMSSfff
const meta = {
consultant: 1001,
client: 10001,
fyStart: periodStart.slice(0, 4) + '0101', // fiscal year start
accLength: 4,
description: 'Bank Statement Export',
currency: 'EUR'
};
return [
'"EXTF"', 700, 21, '"Buchungsstapel"', 12, ts,
'', '', '', '', // 710 spare
meta.consultant, meta.client, // 11, 12
meta.fyStart, meta.accLength, // 13, 14
periodStart, periodEnd, // 15, 16
'"' + meta.description + '"',
'AM', 1, 0, 1, meta.currency
].join(';');
};
const DATEV_COLS = [
'Umsatz (ohne Soll/Haben-Kz)', 'Soll/Haben-Kennzeichen', 'WKZ Umsatz',
'Kurs', 'Basis-Umsatz', 'WKZ Basis-Umsatz', 'Konto',
'Gegenkonto (ohne BU-Schlüssel)', 'BU-Schlüssel', 'Belegdatum',
'Belegfeld 1', 'Belegfeld 2', 'Skonto', 'Buchungstext',
'Postensperre', 'Diverse Adressnummer', 'Geschäftspartnerbank',
'Sachverhalt', 'Zinssperre', 'Beleglink'
].join(';');
const formatDatevAmount = (amount) => {
return Math.abs(amount).toFixed(2).replace('.', ',');
};
const formatDatevDate = (dateString) => {
if (!dateString) return '';
const parts = dateString.split('.');
if (parts.length === 3) {
const day = parts[0].padStart(2, '0');
const month = parts[1].padStart(2, '0');
return day + month;
}
return '';
};
const quote = (str, maxLen = 60) => {
if (!str) return '""';
return '"' + str.slice(0, maxLen).replace(/"/g, '""') + '"';
};
// DATEV export endpoint
router.get('/datev/:monthYear', authenticateToken, async (req, res) => {
try {
const { monthYear } = req.params;
const [year, month] = monthYear.split('-');
// Get transactions for the month
const transactions = parseCSV();
const monthTransactions = transactions
.filter(t => t.monthYear === monthYear)
.sort((a, b) => a.parsedDate - b.parsedDate); // Oldest first for DATEV
if (!monthTransactions.length) {
return res.status(404).json({ error: 'No transactions found for this month' });
}
// Build DATEV format
const periodStart = `${year}${month.padStart(2, '0')}01`;
const periodEnd = new Date(year, month, 0).toISOString().slice(0, 10).replace(/-/g, '');
const header = buildDatevHeader(periodStart, periodEnd);
const rows = monthTransactions.map((transaction, index) => {
const amount = Math.abs(transaction.numericAmount);
const isDebit = transaction.numericAmount < 0 ? 'S' : 'H'; // S = Soll (debit), H = Haben (credit)
return [
formatDatevAmount(amount), // #1 Umsatz
isDebit, // #2 Soll/Haben
quote('EUR', 3), // #3 WKZ Umsatz
'', '', '', // #4-6 (no FX)
'1200', // #7 Konto (Bank account)
transaction.numericAmount < 0 ? '4000' : '8400', // #8 Gegenkonto (expense/income)
'', // #9 BU-Schlüssel
formatDatevDate(transaction['Buchungstag']), // #10 Belegdatum
quote((index + 1).toString(), 36), // #11 Belegfeld 1 (sequential number)
'', '', // #12, #13
quote(transaction['Verwendungszweck'] || transaction['Buchungstext'], 60), // #14 Buchungstext
'', '', '', '', '', // #15-19 unused
'' // #20 Beleglink
].join(';');
});
const csv = [header, DATEV_COLS, ...rows].join('\r\n');
// Set headers for file download
const filename = `DATEV_${year}_${month.padStart(2, '0')}.csv`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Type', 'text/csv; charset=latin1');
// Convert to latin1 encoding for DATEV compatibility
const buffer = Buffer.from(csv, 'utf8');
res.send(buffer);
} catch (error) {
console.error('Error generating DATEV export:', error);
res.status(500).json({ error: 'Failed to generate DATEV export' });
}
});
module.exports = router;