mirror of
https://github.com/DumbWareio/DumbBudget.git
synced 2026-02-20 01:00:57 +08:00
Fixed issue #25 where recurring transactions set for the end of a month (like the 31st) weren't correctly calculating the appropriate date in shorter months. Changes: - Improved the monthly recurring transaction logic to always use the last day of the month when the original transaction was on the last day of its month - Fixed date rollover issues that caused transactions to appear on incorrect dates (e.g., Jan 31 -> Mar 3 instead of Feb 28/29) - Added special handling for months with fewer days by using a safe approach that avoids JavaScript's automatic date adjustments - Fixed edge cases for all months regardless of their length (30/31 days) The solution now correctly handles all cases: - Jan 31 -> Feb 28/29 -> Mar 31 -> Apr 30 -> May 31 - Apr 30 -> May 31 -> Jun 30 - And preserves the "last day of month" intent when appropriate
1060 lines
38 KiB
JavaScript
1060 lines
38 KiB
JavaScript
const dotenv = require('dotenv').config();
|
|
const express = require('express');
|
|
const session = require('express-session');
|
|
const helmet = require('helmet');
|
|
const crypto = require('crypto');
|
|
const path = require('path');
|
|
const cookieParser = require('cookie-parser');
|
|
const fs = require('fs').promises;
|
|
const cors = require('cors');
|
|
const { getCorsOptions, originValidationMiddleware } = require('./scripts/cors');
|
|
const { generatePWAManifest } = require('./scripts/pwa-manifest-generator');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
const NODE_ENV = process.env.NODE_ENV || 'production';
|
|
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
|
|
const SITE_TITLE = process.env.SITE_TITLE || 'DumbBudget';
|
|
const INSTANCE_NAME = process.env.INSTANCE_NAME || '';
|
|
const SITE_INSTANCE_TITLE = INSTANCE_NAME ? `${SITE_TITLE} - ${INSTANCE_NAME}` : SITE_TITLE;
|
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
const ASSETS_DIR = path.join(PUBLIC_DIR, 'assets');
|
|
|
|
// Get the project name from package.json to use for the PIN environment variable
|
|
const projectName = require('./package.json').name.toUpperCase().replace(/-/g, '_');
|
|
const PIN = process.env[`${projectName}_PIN`];
|
|
|
|
// Ensure data directory exists
|
|
const DATA_DIR = path.join(__dirname, 'data');
|
|
const TRANSACTIONS_FILE = path.join(DATA_DIR, 'transactions.json');
|
|
|
|
// Debug logging setup
|
|
const DEBUG = process.env.DEBUG === 'TRUE';
|
|
function debugLog(...args) {
|
|
if (DEBUG) {
|
|
console.log('[DEBUG]', ...args);
|
|
}
|
|
}
|
|
|
|
// Add logging to BASE_PATH extraction
|
|
const BASE_PATH = (() => {
|
|
if (!process.env.BASE_URL) {
|
|
debugLog('No BASE_URL set, using empty base path');
|
|
return '';
|
|
}
|
|
try {
|
|
const url = new URL(process.env.BASE_URL);
|
|
const path = url.pathname.replace(/\/$/, ''); // Remove trailing slash
|
|
debugLog('Extracted base path:', path);
|
|
return path;
|
|
} catch {
|
|
// If BASE_URL is just a path (e.g. /budget)
|
|
const path = process.env.BASE_URL.replace(/\/$/, '');
|
|
debugLog('Using BASE_URL as path:', path);
|
|
return path;
|
|
}
|
|
})();
|
|
|
|
async function ensureDataDir() {
|
|
try {
|
|
await fs.access(DATA_DIR);
|
|
} catch {
|
|
await fs.mkdir(DATA_DIR);
|
|
}
|
|
}
|
|
|
|
async function loadTransactions() {
|
|
try {
|
|
await fs.access(TRANSACTIONS_FILE);
|
|
const data = await fs.readFile(TRANSACTIONS_FILE, 'utf8');
|
|
return JSON.parse(data);
|
|
} catch (error) {
|
|
// If file doesn't exist or is invalid, return empty structure
|
|
return {
|
|
[new Date().toISOString().slice(0, 7)]: {
|
|
income: [],
|
|
expenses: []
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
async function saveTransactions(transactions) {
|
|
// Ensure data directory exists before saving
|
|
await ensureDataDir();
|
|
await fs.writeFile(TRANSACTIONS_FILE, JSON.stringify(transactions, null, 2));
|
|
}
|
|
|
|
// Initialize data directory
|
|
ensureDataDir().catch(console.error);
|
|
|
|
// Log whether PIN protection is enabled
|
|
if (!PIN || PIN.trim() === '') {
|
|
console.log('PIN protection is disabled');
|
|
} else {
|
|
console.log('PIN protection is enabled');
|
|
}
|
|
|
|
// Brute force protection
|
|
const loginAttempts = new Map();
|
|
const MAX_ATTEMPTS = 5;
|
|
const LOCKOUT_TIME = 15 * 60 * 1000; // 15 minutes in milliseconds
|
|
|
|
function resetAttempts(ip) {
|
|
loginAttempts.delete(ip);
|
|
}
|
|
|
|
function isLockedOut(ip) {
|
|
const attempts = loginAttempts.get(ip);
|
|
if (!attempts) return false;
|
|
|
|
if (attempts.count >= MAX_ATTEMPTS) {
|
|
const timeElapsed = Date.now() - attempts.lastAttempt;
|
|
if (timeElapsed < LOCKOUT_TIME) {
|
|
return true;
|
|
}
|
|
resetAttempts(ip);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function recordAttempt(ip) {
|
|
const attempts = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
|
|
attempts.count += 1;
|
|
attempts.lastAttempt = Date.now();
|
|
loginAttempts.set(ip, attempts);
|
|
}
|
|
|
|
generatePWAManifest(SITE_INSTANCE_TITLE);
|
|
|
|
// Middleware
|
|
// Trust proxy - required for secure cookies behind a reverse proxy
|
|
app.set('trust proxy', 1);
|
|
|
|
// Cors Setup
|
|
const corsOptions = getCorsOptions(BASE_URL);
|
|
app.use(cors(corsOptions));
|
|
|
|
// Security middleware - minimal configuration like DumbDrop
|
|
app.use(helmet({
|
|
contentSecurityPolicy: false,
|
|
crossOriginEmbedderPolicy: false,
|
|
crossOriginOpenerPolicy: false,
|
|
crossOriginResourcePolicy: false,
|
|
originAgentCluster: false,
|
|
dnsPrefetchControl: false,
|
|
frameguard: false,
|
|
hsts: false,
|
|
ieNoOpen: false,
|
|
noSniff: false,
|
|
permittedCrossDomainPolicies: false,
|
|
referrerPolicy: false,
|
|
xssFilter: false
|
|
}));
|
|
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
|
|
// Session configuration - simplified like DumbDrop
|
|
app.use(session({
|
|
secret: crypto.randomBytes(32).toString('hex'),
|
|
resave: false,
|
|
saveUninitialized: true,
|
|
cookie: {
|
|
secure: false,
|
|
httpOnly: true,
|
|
sameSite: 'lax'
|
|
}
|
|
}));
|
|
|
|
// Constant-time PIN comparison to prevent timing attacks
|
|
function verifyPin(storedPin, providedPin) {
|
|
if (!storedPin || !providedPin) return false;
|
|
if (storedPin.length !== providedPin.length) return false;
|
|
|
|
try {
|
|
return crypto.timingSafeEqual(
|
|
Buffer.from(storedPin),
|
|
Buffer.from(providedPin)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Add logging to authentication middleware
|
|
const authMiddleware = (req, res, next) => {
|
|
debugLog('Auth middleware for path:', req.path);
|
|
// If no PIN is set, bypass authentication
|
|
if (!PIN || PIN.trim() === '') {
|
|
debugLog('PIN protection disabled, bypassing auth');
|
|
return next();
|
|
}
|
|
|
|
// Check if user is authenticated via session
|
|
if (!req.session.authenticated) {
|
|
debugLog('User not authenticated, redirecting to login');
|
|
return res.redirect(BASE_PATH + '/login');
|
|
}
|
|
debugLog('User authenticated, proceeding');
|
|
next();
|
|
};
|
|
|
|
// Mount all routes under BASE_PATH
|
|
app.use(BASE_PATH, express.static('public', { index: false }));
|
|
|
|
// Routes
|
|
app.get(BASE_PATH + '/', [originValidationMiddleware, authMiddleware], (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
});
|
|
|
|
// Serve the pwa/asset manifest
|
|
app.get('/asset-manifest.json', (req, res) => {
|
|
// generated in pwa-manifest-generator and fetched from service-worker.js
|
|
res.sendFile(path.join(ASSETS_DIR, 'asset-manifest.json'));
|
|
});
|
|
app.get('/manifest.json', (req, res) => {
|
|
res.sendFile(path.join(ASSETS_DIR, 'manifest.json'));
|
|
});
|
|
|
|
app.get('/managers/toast', (req, res) => {
|
|
res.sendFile(path.join(PUBLIC_DIR, 'managers', 'toast.js'));
|
|
});
|
|
|
|
|
|
app.get(BASE_PATH + '/login', (req, res) => {
|
|
if (!PIN || PIN.trim() === '') {
|
|
return res.redirect(BASE_PATH + '/');
|
|
}
|
|
if (req.session.authenticated) {
|
|
return res.redirect(BASE_PATH + '/');
|
|
}
|
|
res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
|
});
|
|
|
|
app.get(BASE_PATH + '/api/config', (req, res) => {
|
|
const instanceName = SITE_INSTANCE_TITLE;
|
|
res.json({ instanceName: instanceName });
|
|
});
|
|
|
|
app.get(BASE_PATH + '/pin-length', (req, res) => {
|
|
// If no PIN is set, return 0 length
|
|
if (!PIN || PIN.trim() === '') {
|
|
return res.json({ length: 0 });
|
|
}
|
|
res.json({ length: PIN.length });
|
|
});
|
|
|
|
app.post(BASE_PATH + '/verify-pin', (req, res) => {
|
|
// If no PIN is set, authentication is successful
|
|
if (!PIN || PIN.trim() === '') {
|
|
req.session.authenticated = true;
|
|
return res.status(200).json({ success: true });
|
|
}
|
|
|
|
const ip = req.ip;
|
|
|
|
// Check if IP is locked out
|
|
if (isLockedOut(ip)) {
|
|
const attempts = loginAttempts.get(ip);
|
|
const timeLeft = Math.ceil((LOCKOUT_TIME - (Date.now() - attempts.lastAttempt)) / 1000 / 60);
|
|
return res.status(429).json({
|
|
error: `Too many attempts. Please try again in ${timeLeft} minutes.`
|
|
});
|
|
}
|
|
|
|
const { pin } = req.body;
|
|
|
|
if (!pin || typeof pin !== 'string') {
|
|
return res.status(400).json({ error: 'Invalid PIN format' });
|
|
}
|
|
|
|
// Add artificial delay to further prevent timing attacks
|
|
const delay = crypto.randomInt(50, 150);
|
|
setTimeout(() => {
|
|
if (verifyPin(PIN, pin)) {
|
|
// Reset attempts on successful login
|
|
resetAttempts(ip);
|
|
|
|
// Set authentication in session
|
|
req.session.authenticated = true;
|
|
|
|
// Set secure cookie
|
|
res.cookie(`${projectName}_PIN`, pin, {
|
|
httpOnly: true,
|
|
secure: req.secure || (BASE_URL.startsWith('https') && NODE_ENV === 'production'),
|
|
sameSite: 'strict',
|
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
|
});
|
|
|
|
res.status(200).json({ success: true });
|
|
} else {
|
|
// Record failed attempt
|
|
recordAttempt(ip);
|
|
|
|
const attempts = loginAttempts.get(ip);
|
|
const attemptsLeft = MAX_ATTEMPTS - attempts.count;
|
|
|
|
res.status(401).json({
|
|
error: 'Invalid PIN',
|
|
attemptsLeft: Math.max(0, attemptsLeft)
|
|
});
|
|
}
|
|
}, delay);
|
|
});
|
|
|
|
// Cleanup old lockouts periodically
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [ip, attempts] of loginAttempts.entries()) {
|
|
if (now - attempts.lastAttempt >= LOCKOUT_TIME) {
|
|
loginAttempts.delete(ip);
|
|
}
|
|
}
|
|
}, 60000); // Clean up every minute
|
|
|
|
// Helper function to get transactions within date range
|
|
async function getTransactionsInRange(startDate, endDate) {
|
|
const transactions = await loadTransactions();
|
|
const allTransactions = [];
|
|
const recurringTransactions = [];
|
|
|
|
// Collect all transactions within the date range
|
|
Object.values(transactions).forEach(month => {
|
|
// Safely handle income transactions
|
|
if (month && Array.isArray(month.income)) {
|
|
month.income.forEach(t => {
|
|
if (t.recurring?.pattern) {
|
|
// For recurring transactions, only add to recurring array
|
|
recurringTransactions.push({ ...t, type: 'income' });
|
|
} else if (t.date >= startDate && t.date <= endDate) {
|
|
// For non-recurring, add to all transactions if in range
|
|
allTransactions.push({ ...t, type: 'income' });
|
|
}
|
|
});
|
|
}
|
|
|
|
// Safely handle expense transactions
|
|
if (month && Array.isArray(month.expenses)) {
|
|
month.expenses.forEach(t => {
|
|
if (t.recurring?.pattern) {
|
|
// For recurring transactions, only add to recurring array
|
|
recurringTransactions.push({ ...t, type: 'expense' });
|
|
} else if (t.date >= startDate && t.date <= endDate) {
|
|
// For non-recurring, add to all transactions if in range
|
|
allTransactions.push({ ...t, type: 'expense' });
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Generate recurring instances
|
|
const recurringInstances = [];
|
|
for (const transaction of recurringTransactions) {
|
|
recurringInstances.push(...generateRecurringInstances(transaction, startDate, endDate));
|
|
}
|
|
|
|
// Combine all transactions and instances
|
|
return [...allTransactions, ...recurringInstances]
|
|
.sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
}
|
|
|
|
// API Routes - all under BASE_PATH
|
|
app.post(BASE_PATH + '/api/transactions', authMiddleware, async (req, res) => {
|
|
try {
|
|
const { type, amount, description, category, date, recurring } = req.body;
|
|
|
|
// Basic validation
|
|
if (!type || !amount || !description || !date) {
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
|
}
|
|
if (type !== 'income' && type !== 'expense') {
|
|
return res.status(400).json({ error: 'Invalid transaction type' });
|
|
}
|
|
if (type === 'expense' && !category) {
|
|
return res.status(400).json({ error: 'Category required for expenses' });
|
|
}
|
|
|
|
// Validate recurring pattern if present
|
|
if (recurring?.pattern) {
|
|
const isValidRegular = /every (\d+) (day|week|month|year)s?(?: on (\w+))?/.test(recurring.pattern);
|
|
const isValidMonthDay = /every (\d+)(?:st|nd|rd|th) of the month/.test(recurring.pattern);
|
|
|
|
if (!isValidRegular && !isValidMonthDay) {
|
|
return res.status(400).json({ error: 'Invalid recurring pattern format' });
|
|
}
|
|
if (recurring.until && isNaN(new Date(recurring.until).getTime())) {
|
|
return res.status(400).json({ error: 'Invalid until date format' });
|
|
}
|
|
}
|
|
|
|
// For recurring transactions with a weekday pattern, adjust the date to the first occurrence
|
|
let adjustedDate = date;
|
|
if (recurring?.pattern) {
|
|
const pattern = parseRecurringPattern(recurring.pattern);
|
|
if (pattern.unit === 'week' && pattern.dayOfWeek) {
|
|
const [year, month, day] = date.split('-');
|
|
const selectedDate = new Date(year, month - 1, day);
|
|
const weekdays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
const targetDay = weekdays.indexOf(pattern.dayOfWeek);
|
|
const currentDay = selectedDate.getDay();
|
|
|
|
// Calculate days to add to reach the target weekday
|
|
let daysToAdd = targetDay - currentDay;
|
|
if (daysToAdd < 0) {
|
|
daysToAdd += 7; // Move to next week if target day has passed
|
|
}
|
|
|
|
// Adjust the date
|
|
selectedDate.setDate(selectedDate.getDate() + daysToAdd);
|
|
adjustedDate = selectedDate.toISOString().split('T')[0];
|
|
} else if (pattern.unit === 'monthday') {
|
|
// For day-of-month pattern, adjust to the first occurrence
|
|
const [year, month] = date.split('-');
|
|
const selectedDate = new Date(year, month - 1, pattern.dayOfMonth);
|
|
|
|
// If the selected day has passed in the current month, move to next month
|
|
if (selectedDate < new Date(date)) {
|
|
selectedDate.setMonth(selectedDate.getMonth() + 1);
|
|
}
|
|
|
|
adjustedDate = selectedDate.toISOString().split('T')[0];
|
|
}
|
|
}
|
|
|
|
const transactions = await loadTransactions() || {};
|
|
const [year, month] = adjustedDate.split('-');
|
|
const key = `${year}-${month}`;
|
|
|
|
// Initialize month structure if it doesn't exist
|
|
if (!transactions[key]) {
|
|
transactions[key] = {
|
|
income: [],
|
|
expenses: []
|
|
};
|
|
}
|
|
|
|
// Ensure arrays exist
|
|
if (!Array.isArray(transactions[key].income)) {
|
|
transactions[key].income = [];
|
|
}
|
|
if (!Array.isArray(transactions[key].expenses)) {
|
|
transactions[key].expenses = [];
|
|
}
|
|
|
|
// Add transaction
|
|
const newTransaction = {
|
|
id: crypto.randomUUID(),
|
|
amount: parseFloat(amount),
|
|
description,
|
|
date: adjustedDate
|
|
};
|
|
|
|
// Add recurring information if present
|
|
if (recurring?.pattern) {
|
|
newTransaction.recurring = {
|
|
pattern: recurring.pattern,
|
|
until: recurring.until || null
|
|
};
|
|
}
|
|
|
|
if (type === 'expense') {
|
|
newTransaction.category = category;
|
|
transactions[key].expenses.push(newTransaction);
|
|
} else {
|
|
transactions[key].income.push(newTransaction);
|
|
}
|
|
|
|
await saveTransactions(transactions);
|
|
res.status(201).json(newTransaction);
|
|
} catch (error) {
|
|
console.error('Error adding transaction:', error);
|
|
res.status(500).json({ error: 'Failed to add transaction' });
|
|
}
|
|
});
|
|
|
|
app.get(BASE_PATH + '/api/transactions/:year/:month', authMiddleware, async (req, res) => {
|
|
try {
|
|
const { year, month } = req.params;
|
|
const key = `${year}-${month.padStart(2, '0')}`;
|
|
const transactions = await loadTransactions();
|
|
|
|
const monthData = transactions[key] || { income: [], expenses: [] };
|
|
|
|
// Combine and sort transactions by date
|
|
const allTransactions = [
|
|
...monthData.income.map(t => ({ ...t, type: 'income' })),
|
|
...monthData.expenses.map(t => ({ ...t, type: 'expense' }))
|
|
].sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
|
|
res.json(allTransactions);
|
|
} catch (error) {
|
|
console.error('Error fetching transactions:', error);
|
|
res.status(500).json({ error: 'Failed to fetch transactions' });
|
|
}
|
|
});
|
|
|
|
app.get(BASE_PATH + '/api/totals/:year/:month', authMiddleware, async (req, res) => {
|
|
try {
|
|
const { year, month } = req.params;
|
|
const key = `${year}-${month.padStart(2, '0')}`;
|
|
const transactions = await loadTransactions();
|
|
|
|
const monthData = transactions[key] || { income: [], expenses: [] };
|
|
|
|
const totals = {
|
|
income: monthData.income.reduce((sum, t) => sum + t.amount, 0),
|
|
expenses: monthData.expenses.reduce((sum, t) => sum + t.amount, 0),
|
|
balance: 0
|
|
};
|
|
|
|
totals.balance = totals.income - totals.expenses;
|
|
|
|
res.json(totals);
|
|
} catch (error) {
|
|
console.error('Error calculating totals:', error);
|
|
res.status(500).json({ error: 'Failed to calculate totals' });
|
|
}
|
|
});
|
|
|
|
// Helper function to parse recurring pattern
|
|
function parseRecurringPattern(pattern) {
|
|
// Try matching the existing pattern first
|
|
const weeklyMatches = pattern.match(/every (\d+) (day|week|month|year)s?(?: on (\w+))?/);
|
|
|
|
// Try matching the "Nth of month" pattern
|
|
const monthlyDayMatches = pattern.match(/every (\d+)(?:st|nd|rd|th) of the month/);
|
|
|
|
if (weeklyMatches) {
|
|
const [_, interval, unit, dayOfWeek] = weeklyMatches;
|
|
return {
|
|
interval: parseInt(interval),
|
|
unit: unit.toLowerCase(),
|
|
dayOfWeek: dayOfWeek ? dayOfWeek.toLowerCase() : null
|
|
};
|
|
} else if (monthlyDayMatches) {
|
|
const [_, dayOfMonth] = monthlyDayMatches;
|
|
return {
|
|
interval: 1,
|
|
unit: 'monthday',
|
|
dayOfMonth: parseInt(dayOfMonth)
|
|
};
|
|
}
|
|
|
|
throw new Error('Invalid recurring pattern');
|
|
}
|
|
|
|
// Helper function to generate recurring instances
|
|
function generateRecurringInstances(transaction, startDate, endDate) {
|
|
if (!transaction.recurring?.pattern) return [];
|
|
|
|
const instances = [];
|
|
const pattern = parseRecurringPattern(transaction.recurring.pattern);
|
|
|
|
// Convert dates to Date objects for easier manipulation
|
|
const [tYear, tMonth, tDay] = transaction.date.split('-');
|
|
let currentDate = new Date(tYear, tMonth - 1, tDay);
|
|
|
|
const [sYear, sMonth, sDay] = startDate.split('-');
|
|
const rangeStart = new Date(sYear, sMonth - 1, sDay);
|
|
|
|
const [eYear, eMonth, eDay] = endDate.split('-');
|
|
const rangeEnd = new Date(eYear, eMonth - 1, eDay);
|
|
|
|
const until = transaction.recurring.until
|
|
? (() => {
|
|
const [uYear, uMonth, uDay] = transaction.recurring.until.split('-');
|
|
return new Date(uYear, uMonth - 1, uDay);
|
|
})()
|
|
: rangeEnd;
|
|
|
|
// Handle "Nth of month" pattern
|
|
if (pattern.unit === 'monthday') {
|
|
// Set the initial date to the first occurrence
|
|
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), pattern.dayOfMonth);
|
|
if (currentDate < new Date(tYear, tMonth - 1, tDay)) {
|
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
|
}
|
|
}
|
|
// For weekly patterns, ensure we start on the correct day of the week
|
|
else if (pattern.unit === 'week' && pattern.dayOfWeek) {
|
|
const weekdays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
const targetDay = weekdays.indexOf(pattern.dayOfWeek);
|
|
const currentDay = currentDate.getDay();
|
|
|
|
// Calculate days to add to reach the target weekday
|
|
let daysToAdd = targetDay - currentDay;
|
|
if (daysToAdd < 0) {
|
|
daysToAdd += 7; // Move to next week if target day has passed
|
|
}
|
|
|
|
// Adjust the start date to the first occurrence
|
|
currentDate.setDate(currentDate.getDate() + daysToAdd);
|
|
|
|
// For intervals greater than 1, we need to ensure we're starting on the correct week
|
|
if (pattern.interval > 1) {
|
|
// Calculate weeks since the start date
|
|
const weeksSinceStart = Math.floor((currentDate - rangeStart) / (7 * 24 * 60 * 60 * 1000));
|
|
const remainingWeeks = weeksSinceStart % pattern.interval;
|
|
if (remainingWeeks !== 0) {
|
|
// Move forward to the next valid week
|
|
currentDate.setDate(currentDate.getDate() + (pattern.interval - remainingWeeks) * 7);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Track dates we've already added to prevent duplicates
|
|
const addedDates = new Set();
|
|
|
|
// Generate instances
|
|
while (currentDate <= until) {
|
|
const dateStr = currentDate.toISOString().split('T')[0];
|
|
|
|
// Only add if it falls within range and isn't a duplicate
|
|
if (currentDate >= rangeStart &&
|
|
currentDate <= rangeEnd &&
|
|
!addedDates.has(dateStr)) {
|
|
|
|
instances.push({
|
|
...transaction,
|
|
id: `${transaction.id}-${dateStr}`,
|
|
date: dateStr,
|
|
isRecurringInstance: true,
|
|
recurringParentId: transaction.id
|
|
});
|
|
|
|
addedDates.add(dateStr);
|
|
}
|
|
|
|
// Advance to next occurrence based on interval and unit
|
|
switch (pattern.unit) {
|
|
case 'day':
|
|
currentDate.setDate(currentDate.getDate() + pattern.interval);
|
|
break;
|
|
case 'week':
|
|
currentDate.setDate(currentDate.getDate() + (pattern.interval * 7));
|
|
break;
|
|
case 'month':
|
|
// Don't modify the current date yet
|
|
// Check if we need to handle end-of-month special case
|
|
const originalDay = parseInt(transaction.date.split('-')[2]);
|
|
const daysInCurrentMonth = new Date(
|
|
currentDate.getFullYear(),
|
|
currentDate.getMonth() + 1,
|
|
0
|
|
).getDate();
|
|
|
|
// Check if the original transaction was on the last day of its month
|
|
const isLastDayOfMonth = originalDay >= daysInCurrentMonth;
|
|
|
|
// Get the number of days in the target month (after adding interval)
|
|
const daysInTargetMonth = new Date(
|
|
currentDate.getFullYear(),
|
|
currentDate.getMonth() + pattern.interval + 1,
|
|
0
|
|
).getDate();
|
|
|
|
// First, create a new date for next month with a safe day value (1)
|
|
const nextMonth = new Date(currentDate);
|
|
nextMonth.setDate(1); // Set to first of month to avoid rollover
|
|
nextMonth.setMonth(nextMonth.getMonth() + pattern.interval);
|
|
|
|
if (isLastDayOfMonth) {
|
|
// If original was last day of month, set to last day of target month
|
|
nextMonth.setDate(daysInTargetMonth);
|
|
} else if (originalDay > daysInTargetMonth) {
|
|
// If original day doesn't exist in target month, use last day
|
|
nextMonth.setDate(daysInTargetMonth);
|
|
} else {
|
|
// Otherwise use the same day of month
|
|
nextMonth.setDate(originalDay);
|
|
}
|
|
|
|
// Update current date with our safely constructed date
|
|
currentDate = nextMonth;
|
|
break;
|
|
case 'year':
|
|
currentDate.setFullYear(currentDate.getFullYear() + pattern.interval);
|
|
break;
|
|
case 'monthday':
|
|
// For monthday pattern, move to next month then set the day
|
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
|
|
|
// Get the last day of the target month
|
|
const lastDayOfMonth = new Date(
|
|
currentDate.getFullYear(),
|
|
currentDate.getMonth() + 1,
|
|
0
|
|
).getDate();
|
|
|
|
// If the desired day exceeds the last day, use the last day instead
|
|
if (pattern.dayOfMonth > lastDayOfMonth) {
|
|
currentDate.setDate(lastDayOfMonth);
|
|
} else {
|
|
currentDate.setDate(pattern.dayOfMonth);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return instances;
|
|
}
|
|
|
|
// Update the range endpoint to include recurring instances
|
|
app.get(BASE_PATH + '/api/transactions/range', authMiddleware, async (req, res) => {
|
|
try {
|
|
const { start, end } = req.query;
|
|
if (!start || !end) {
|
|
return res.status(400).json({ error: 'Start and end dates are required' });
|
|
}
|
|
|
|
// Get transactions with recurring instances already included
|
|
const transactions = await getTransactionsInRange(start, end);
|
|
|
|
// Sort by date
|
|
transactions.sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
|
|
res.json(transactions);
|
|
} catch (error) {
|
|
console.error('Error fetching transactions:', error);
|
|
res.status(500).json({ error: 'Failed to fetch transactions' });
|
|
}
|
|
});
|
|
|
|
app.get(BASE_PATH + '/api/totals/range', authMiddleware, async (req, res) => {
|
|
try {
|
|
const { start, end } = req.query;
|
|
if (!start || !end) {
|
|
return res.status(400).json({ error: 'Start and end dates are required' });
|
|
}
|
|
|
|
const transactions = await getTransactionsInRange(start, end);
|
|
|
|
const totals = {
|
|
income: transactions
|
|
.filter(t => t.type === 'income')
|
|
.reduce((sum, t) => sum + t.amount, 0),
|
|
expenses: transactions
|
|
.filter(t => t.type === 'expense')
|
|
.reduce((sum, t) => sum + t.amount, 0),
|
|
balance: 0
|
|
};
|
|
|
|
totals.balance = totals.income - totals.expenses;
|
|
|
|
res.json(totals);
|
|
} catch (error) {
|
|
console.error('Error calculating totals:', error);
|
|
res.status(500).json({ error: 'Failed to calculate totals' });
|
|
}
|
|
});
|
|
|
|
app.get(BASE_PATH + '/api/export/:year/:month', authMiddleware, async (req, res) => {
|
|
try {
|
|
const { year, month } = req.params;
|
|
const key = `${year}-${month.padStart(2, '0')}`;
|
|
const transactions = await loadTransactions();
|
|
|
|
const monthData = transactions[key] || { income: [], expenses: [] };
|
|
|
|
// Combine all transactions
|
|
const allTransactions = [
|
|
...monthData.income.map(t => ({ ...t, type: 'income' })),
|
|
...monthData.expenses.map(t => ({ ...t, type: 'expense' }))
|
|
].sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
|
|
// Convert to CSV
|
|
const csvRows = ['Date,Type,Category,Description,Amount'];
|
|
allTransactions.forEach(t => {
|
|
csvRows.push(`${t.date},${t.type},${t.category || ''},${t.description},${t.amount}`);
|
|
});
|
|
|
|
res.setHeader('Content-Type', 'text/csv');
|
|
res.setHeader('Content-Disposition', `attachment; filename=transactions-${key}.csv`);
|
|
res.send(csvRows.join('\n'));
|
|
} catch (error) {
|
|
console.error('Error exporting transactions:', error);
|
|
res.status(500).json({ error: 'Failed to export transactions' });
|
|
}
|
|
});
|
|
|
|
app.get(BASE_PATH + '/api/export/range', authMiddleware, async (req, res) => {
|
|
try {
|
|
const { start, end } = req.query;
|
|
if (!start || !end) {
|
|
return res.status(400).json({ error: 'Start and end dates are required' });
|
|
}
|
|
|
|
const transactions = await getTransactionsInRange(start, end);
|
|
|
|
// Convert to CSV with specified format
|
|
const csvRows = ['Category,Date,Description,Value'];
|
|
transactions.forEach(t => {
|
|
const category = t.type === 'income' ? 'Income' : t.category;
|
|
const value = t.type === 'income' ? t.amount : -t.amount;
|
|
// Escape description to handle commas and quotes
|
|
const escapedDescription = t.description.replace(/"/g, '""');
|
|
const formattedDescription = escapedDescription.includes(',') ? `"${escapedDescription}"` : escapedDescription;
|
|
|
|
csvRows.push(`${category},${t.date},${formattedDescription},${value}`);
|
|
});
|
|
|
|
res.setHeader('Content-Type', 'text/csv');
|
|
res.setHeader('Content-Disposition', `attachment; filename=transactions-${start}-to-${end}.csv`);
|
|
res.send(csvRows.join('\n'));
|
|
} catch (error) {
|
|
console.error('Error exporting transactions:', error);
|
|
res.status(500).json({ error: 'Failed to export transactions' });
|
|
}
|
|
});
|
|
|
|
app.put(BASE_PATH + '/api/transactions/:id', authMiddleware, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { type, amount, description, category, date, recurring } = req.body;
|
|
|
|
// Basic validation
|
|
if (!type || !amount || !description || !date) {
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
|
}
|
|
if (type !== 'income' && type !== 'expense') {
|
|
return res.status(400).json({ error: 'Invalid transaction type' });
|
|
}
|
|
if (type === 'expense' && !category) {
|
|
return res.status(400).json({ error: 'Category required for expenses' });
|
|
}
|
|
|
|
const transactions = await loadTransactions();
|
|
let found = false;
|
|
|
|
// Find and update the transaction
|
|
for (const key of Object.keys(transactions)) {
|
|
const monthData = transactions[key];
|
|
|
|
// Check in income array
|
|
const incomeIndex = monthData.income.findIndex(t => t.id === id);
|
|
if (incomeIndex !== -1) {
|
|
// If type changed, move to expenses
|
|
if (type === 'expense') {
|
|
const transaction = monthData.income.splice(incomeIndex, 1)[0];
|
|
transaction.category = category;
|
|
monthData.expenses.push({
|
|
...transaction,
|
|
amount: parseFloat(amount),
|
|
description,
|
|
date,
|
|
recurring: recurring || null
|
|
});
|
|
} else {
|
|
monthData.income[incomeIndex] = {
|
|
...monthData.income[incomeIndex],
|
|
amount: parseFloat(amount),
|
|
description,
|
|
date,
|
|
recurring: recurring || null
|
|
};
|
|
}
|
|
found = true;
|
|
break;
|
|
}
|
|
|
|
// Check in expenses array
|
|
const expenseIndex = monthData.expenses.findIndex(t => t.id === id);
|
|
if (expenseIndex !== -1) {
|
|
// If type changed, move to income
|
|
if (type === 'income') {
|
|
const transaction = monthData.expenses.splice(expenseIndex, 1)[0];
|
|
delete transaction.category;
|
|
monthData.income.push({
|
|
...transaction,
|
|
amount: parseFloat(amount),
|
|
description,
|
|
date,
|
|
recurring: recurring || null
|
|
});
|
|
} else {
|
|
monthData.expenses[expenseIndex] = {
|
|
...monthData.expenses[expenseIndex],
|
|
amount: parseFloat(amount),
|
|
description,
|
|
category,
|
|
date,
|
|
recurring: recurring || null
|
|
};
|
|
}
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
return res.status(404).json({ error: 'Transaction not found' });
|
|
}
|
|
|
|
await saveTransactions(transactions);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error updating transaction:', error);
|
|
res.status(500).json({ error: 'Failed to update transaction' });
|
|
}
|
|
});
|
|
|
|
app.delete(BASE_PATH + '/api/transactions/:id', authMiddleware, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const transactions = await loadTransactions();
|
|
let found = false;
|
|
|
|
// Check if this is a recurring instance
|
|
const isRecurringInstance = id.includes('-');
|
|
const parentId = isRecurringInstance ? id.split('-')[0] : id;
|
|
|
|
// Find and delete the transaction
|
|
for (const key of Object.keys(transactions)) {
|
|
const monthData = transactions[key];
|
|
|
|
// Check in income array
|
|
const incomeIndex = monthData.income.findIndex(t =>
|
|
t.id === parentId || t.id === id
|
|
);
|
|
if (incomeIndex !== -1) {
|
|
monthData.income.splice(incomeIndex, 1);
|
|
found = true;
|
|
break;
|
|
}
|
|
|
|
// Check in expenses array
|
|
const expenseIndex = monthData.expenses.findIndex(t =>
|
|
t.id === parentId || t.id === id
|
|
);
|
|
if (expenseIndex !== -1) {
|
|
monthData.expenses.splice(expenseIndex, 1);
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
return res.status(404).json({ error: 'Transaction not found' });
|
|
}
|
|
|
|
await saveTransactions(transactions);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error deleting transaction:', error);
|
|
res.status(500).json({ error: 'Failed to delete transaction' });
|
|
}
|
|
});
|
|
|
|
// Supported currencies list - must match client-side list
|
|
const SUPPORTED_CURRENCIES = [
|
|
'USD', 'EUR', 'GBP', 'JPY', 'AUD',
|
|
'CAD', 'CHF', 'CNY', 'HKD', 'NZD',
|
|
'MXN', 'RUB', 'SGD', 'KRW', 'INR',
|
|
'BRL', 'ZAR', 'TRY', 'PLN', 'SEK',
|
|
'NOK', 'DKK', 'IDR', 'PHP'
|
|
];
|
|
|
|
// Get current currency setting
|
|
app.get(BASE_PATH + '/api/settings/currency', authMiddleware, (req, res) => {
|
|
const currency = process.env.CURRENCY || 'USD';
|
|
if (!SUPPORTED_CURRENCIES.includes(currency)) {
|
|
return res.status(200).json({ currency: 'USD' });
|
|
}
|
|
res.status(200).json({ currency });
|
|
});
|
|
|
|
// Get list of supported currencies
|
|
app.get(BASE_PATH + '/api/settings/supported-currencies', authMiddleware, (req, res) => {
|
|
res.status(200).json({ currencies: SUPPORTED_CURRENCIES });
|
|
});
|
|
|
|
// Add logging to config endpoint
|
|
app.get(BASE_PATH + '/config.js', (req, res) => {
|
|
debugLog('Serving config.js with BASE_PATH:', BASE_PATH);
|
|
res.type('application/javascript');
|
|
res.send(`window.appConfig = {
|
|
debug: ${DEBUG},
|
|
basePath: '${BASE_PATH}',
|
|
title: '${SITE_INSTANCE_TITLE}'
|
|
};`);
|
|
});
|
|
|
|
// API Authentication middleware for DumbCal
|
|
const apiAuthMiddleware = (req, res, next) => {
|
|
const authHeader = req.headers.authorization;
|
|
|
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
return res.status(401).json({ error: 'Missing authorization header' });
|
|
}
|
|
|
|
const token = authHeader.split(' ')[1];
|
|
if (token !== process.env.DUMB_SECRET) {
|
|
return res.status(401).json({ error: 'Invalid authorization token' });
|
|
}
|
|
|
|
next();
|
|
};
|
|
|
|
// Calendar API endpoint
|
|
app.get(BASE_PATH + '/api/calendar/transactions', apiAuthMiddleware, async (req, res) => {
|
|
try {
|
|
const { start_date, end_date } = req.query;
|
|
|
|
// Validate date parameters
|
|
if (!start_date || !end_date) {
|
|
return res.status(400).json({ error: 'Missing start_date or end_date parameter' });
|
|
}
|
|
|
|
// Validate date format
|
|
const startDate = new Date(start_date);
|
|
const endDate = new Date(end_date);
|
|
|
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
|
return res.status(400).json({ error: 'Invalid date format. Use YYYY-MM-DD' });
|
|
}
|
|
|
|
// Load transactions
|
|
const allTransactions = await loadTransactions();
|
|
|
|
// Filter transactions within date range
|
|
const filteredTransactions = [];
|
|
|
|
for (const [month, data] of Object.entries(allTransactions)) {
|
|
const monthDate = new Date(month + '-01');
|
|
|
|
if (monthDate >= startDate && monthDate <= endDate) {
|
|
// Add income transactions
|
|
data.income.forEach(transaction => {
|
|
filteredTransactions.push({
|
|
type: 'income',
|
|
...transaction,
|
|
amount: parseFloat(transaction.amount)
|
|
});
|
|
});
|
|
|
|
// Add expense transactions
|
|
data.expenses.forEach(transaction => {
|
|
filteredTransactions.push({
|
|
type: 'expense',
|
|
...transaction,
|
|
amount: parseFloat(transaction.amount)
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
res.json({ transactions: filteredTransactions });
|
|
} catch (error) {
|
|
console.error('Calendar API error:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// Add logging to server startup
|
|
app.listen(PORT, () => {
|
|
console.log(`Server running on ${BASE_URL}`);
|
|
debugLog('Debug mode enabled');
|
|
debugLog('Base path:', BASE_PATH);
|
|
});
|