Add subscription management system with website URL functionality
- Complete subscription tracking with monthly/annual billing cycles - Dashboard integration with simplified card-based layout - Website URL fields with clickable links for easy service access - Comprehensive form validation and error handling - Database schema with proper website_url column support - Responsive design with mobile-friendly interface - Export functionality for subscription data management - Real-time dashboard summaries showing total costs and service counts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
711826010f
commit
8a3631d5f5
351
index.html
351
index.html
@ -41,6 +41,14 @@
|
||||
<span class="menu-icon">📋</span>
|
||||
<span class="menu-text">Trade History</span>
|
||||
</li>
|
||||
<li class="menu-item" data-page="subscriptions">
|
||||
<span class="menu-icon">📱</span>
|
||||
<span class="menu-text">Subscriptions</span>
|
||||
</li>
|
||||
<li class="menu-item" data-page="add-subscription">
|
||||
<span class="menu-icon">➕</span>
|
||||
<span class="menu-text">Add Subscription</span>
|
||||
</li>
|
||||
<li class="menu-item menu-separator" data-page="cgt-settings">
|
||||
<span class="menu-icon">🧮</span>
|
||||
<span class="menu-text">CGT Settings</span>
|
||||
@ -94,95 +102,45 @@
|
||||
|
||||
<!-- Dashboard Page -->
|
||||
<div id="dashboard-page" class="page">
|
||||
<div class="dashboard-grid">
|
||||
<div class="dashboard-card total-value">
|
||||
<h3>Current Portfolio Value</h3>
|
||||
<div class="metric-value" id="dashboard-current-value">€0.00</div>
|
||||
<div class="metric-detail" id="dashboard-last-updated">Using cost basis</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card total-gains">
|
||||
<h3>Total Gains/Losses</h3>
|
||||
<div class="metric-value" id="dashboard-total-gains">€0.00</div>
|
||||
<div class="metric-change" id="dashboard-gains-percentage">0.0%</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card monthly-investment">
|
||||
<h3>Monthly Investment</h3>
|
||||
<div class="metric-value" id="monthly-investment">€0.00</div>
|
||||
<div class="metric-detail" id="monthly-trades">0 trades</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card yearly-investment">
|
||||
<h3>Yearly Investment</h3>
|
||||
<div class="metric-value" id="yearly-investment">€0.00</div>
|
||||
<div class="metric-detail" id="yearly-trades">0 trades</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card total-shares">
|
||||
<h3>Total Shares</h3>
|
||||
<div class="metric-value" id="total-shares">0</div>
|
||||
<div class="metric-detail" id="unique-etfs">0 ETFs</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card cost-basis">
|
||||
<h3>Total Investment</h3>
|
||||
<div class="metric-value" id="dashboard-cost-basis">€0.00</div>
|
||||
<div class="metric-detail" id="dashboard-avg-return">Avg return: 0.0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Holdings Card -->
|
||||
<div class="total-holdings-section">
|
||||
<div class="total-holdings-card">
|
||||
<h3>Total Holdings</h3>
|
||||
<div class="holdings-breakdown">
|
||||
<div class="holdings-item">
|
||||
<span class="holdings-label">Portfolio Value:</span>
|
||||
<span class="holdings-value" id="total-holdings-portfolio">€0.00</span>
|
||||
</div>
|
||||
<div class="holdings-item">
|
||||
<span class="holdings-label">Cash Savings:</span>
|
||||
<span class="holdings-value" id="total-holdings-cash">€0.00</span>
|
||||
</div>
|
||||
<div class="holdings-divider"></div>
|
||||
<div class="holdings-total">
|
||||
<span class="holdings-label">Total Value:</span>
|
||||
<span class="holdings-value total" id="total-holdings-combined">€0.00</span>
|
||||
</div>
|
||||
<div class="dashboard-summary">
|
||||
<div class="summary-card portfolio-summary" onclick="app.navigateToPage('portfolio')">
|
||||
<div class="summary-header">
|
||||
<h3>Portfolio</h3>
|
||||
</div>
|
||||
<div class="summary-content">
|
||||
<div class="primary-value" id="dashboard-current-value">€0.00</div>
|
||||
<div class="secondary-value" id="dashboard-total-gains">€0.00</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cash-breakdown" id="cash-breakdown" style="display: none;">
|
||||
<h3>Cash Savings Summary</h3>
|
||||
<div class="cash-summary-overview">
|
||||
<div class="cash-total-card">
|
||||
<div class="cash-amount-line" id="dashboard-cash-eur-line">
|
||||
<span class="currency-label">EUR:</span>
|
||||
<span class="amount" id="dashboard-cash-eur">€0.00</span>
|
||||
</div>
|
||||
<div class="cash-amount-line" id="dashboard-cash-usd-line" style="display: none;">
|
||||
<span class="currency-label">USD:</span>
|
||||
<span class="amount" id="dashboard-cash-usd">$0.00</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-card cash-summary" onclick="app.navigateToPage('cash-accounts')">
|
||||
<div class="summary-header">
|
||||
<h3>Cash</h3>
|
||||
</div>
|
||||
<div class="cash-stats-card">
|
||||
<span class="stat-detail" id="dashboard-account-count">0 accounts</span>
|
||||
<span class="stat-detail" id="dashboard-avg-interest-display" style="display: none;">Avg: 0.0%</span>
|
||||
<div class="summary-content">
|
||||
<div class="primary-value" id="dashboard-cash-total">€0.00</div>
|
||||
<div class="secondary-value" id="dashboard-account-count">0 accounts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cash-accounts-breakdown" class="breakdown-list">
|
||||
<div id="dashboard-cash-accounts-list">
|
||||
<p class="no-data">No cash accounts yet</p>
|
||||
|
||||
<div class="summary-card subscriptions-summary" onclick="app.navigateToPage('subscriptions')">
|
||||
<div class="summary-header">
|
||||
<h3>Subscriptions</h3>
|
||||
</div>
|
||||
<div class="summary-content">
|
||||
<div class="primary-value" id="dashboard-subscription-monthly">€0.00/mo</div>
|
||||
<div class="secondary-value" id="dashboard-subscription-count">0 services</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="etf-breakdown">
|
||||
<h3>ETF Breakdown</h3>
|
||||
<div id="etf-breakdown-list" class="breakdown-list">
|
||||
<p class="no-data">No ETF positions yet</p>
|
||||
|
||||
<div class="summary-card total-summary">
|
||||
<div class="summary-header">
|
||||
<h3>Total Value</h3>
|
||||
</div>
|
||||
<div class="summary-content">
|
||||
<div class="primary-value total" id="dashboard-total-combined">€0.00</div>
|
||||
<div class="secondary-value" id="dashboard-performance">0.0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -863,6 +821,235 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \<br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subscriptions Page -->
|
||||
<div id="subscriptions-page" class="page">
|
||||
<div class="subscriptions-container">
|
||||
<h2>Subscription Management</h2>
|
||||
<div class="subscription-intro">
|
||||
<p>Track and manage your recurring subscriptions. Monitor monthly and annual spending across different services.</p>
|
||||
</div>
|
||||
|
||||
<div class="subscription-summary-cards">
|
||||
<div class="subscription-summary-card total-monthly">
|
||||
<h3>Monthly Total</h3>
|
||||
<div class="subscription-amounts">
|
||||
<div class="amount-line">
|
||||
<span class="amount" id="monthly-total">€0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subscription-summary-card total-annual">
|
||||
<h3>Annual Total</h3>
|
||||
<div class="subscription-amounts">
|
||||
<div class="amount-line">
|
||||
<span class="amount" id="annual-total">€0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subscription-summary-card active-count">
|
||||
<h3>Active Subscriptions</h3>
|
||||
<div class="subscription-amounts">
|
||||
<div class="amount-line">
|
||||
<span class="amount" id="total-subscriptions">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subscriptions-controls">
|
||||
<button onclick="app.showPage('add-subscription')" class="add-subscription-btn">Add New Subscription</button>
|
||||
<button onclick="app.exportSubscriptions()" class="export-btn">Export Subscriptions</button>
|
||||
</div>
|
||||
|
||||
<div class="subscriptions-list-section">
|
||||
<h3>Your Subscriptions</h3>
|
||||
<div id="subscriptions-list" class="subscriptions-list">
|
||||
<p class="no-data">Loading subscriptions...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Subscription Modal -->
|
||||
<div id="edit-subscription-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<h3>Edit Subscription</h3>
|
||||
<form id="edit-subscription-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-subscription-service-name">Service Name *</label>
|
||||
<input type="text" id="edit-subscription-service-name" name="serviceName" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-subscription-category">Category</label>
|
||||
<select id="edit-subscription-category" name="category">
|
||||
<option value="streaming">Streaming</option>
|
||||
<option value="software">Software</option>
|
||||
<option value="news">News & Media</option>
|
||||
<option value="gaming">Gaming</option>
|
||||
<option value="fitness">Fitness</option>
|
||||
<option value="productivity">Productivity</option>
|
||||
<option value="storage">Cloud Storage</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-subscription-billing-cycle">Billing Cycle *</label>
|
||||
<select id="edit-subscription-billing-cycle" name="billingCycle" required>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="annual">Annual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-subscription-currency">Currency</label>
|
||||
<select id="edit-subscription-currency" name="currency">
|
||||
<option value="EUR">EUR (€)</option>
|
||||
<option value="USD">USD ($)</option>
|
||||
<option value="GBP">GBP (£)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-subscription-monthly-price">Monthly Price</label>
|
||||
<input type="number" id="edit-subscription-monthly-price" name="monthlyPrice" step="0.01" min="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-subscription-annual-price">Annual Price</label>
|
||||
<input type="number" id="edit-subscription-annual-price" name="annualPrice" step="0.01" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-subscription-start-date">Start Date *</label>
|
||||
<input type="date" id="edit-subscription-start-date" name="startDate" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-subscription-end-date">End Date (optional)</label>
|
||||
<input type="date" id="edit-subscription-end-date" name="endDate">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="edit-subscription-free-trial">Free Trial Days</label>
|
||||
<input type="number" id="edit-subscription-free-trial" name="freeTrialDays" min="0" value="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-subscription-website-url">Website URL (optional)</label>
|
||||
<input type="url" id="edit-subscription-website-url" name="websiteUrl" placeholder="https://example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-subscription-notes">Notes (optional)</label>
|
||||
<textarea id="edit-subscription-notes" name="notes" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="save-btn">Save Changes</button>
|
||||
<button type="button" class="cancel-btn" onclick="app.closeEditSubscriptionModal()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Subscription Page -->
|
||||
<div id="add-subscription-page" class="page">
|
||||
<div class="add-subscription-container">
|
||||
<h2>Add New Subscription</h2>
|
||||
|
||||
<form id="subscription-form" class="subscription-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subscription-service-name">Service Name *</label>
|
||||
<input type="text" id="subscription-service-name" name="serviceName" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subscription-category">Category</label>
|
||||
<select id="subscription-category" name="category">
|
||||
<option value="streaming">Streaming</option>
|
||||
<option value="software">Software</option>
|
||||
<option value="news">News & Media</option>
|
||||
<option value="gaming">Gaming</option>
|
||||
<option value="fitness">Fitness</option>
|
||||
<option value="productivity">Productivity</option>
|
||||
<option value="storage">Cloud Storage</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subscription-billing-cycle">Billing Cycle *</label>
|
||||
<select id="subscription-billing-cycle" name="billingCycle" required>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="annual">Annual</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subscription-currency">Currency</label>
|
||||
<select id="subscription-currency" name="currency">
|
||||
<option value="EUR">EUR (€)</option>
|
||||
<option value="USD">USD ($)</option>
|
||||
<option value="GBP">GBP (£)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subscription-monthly-price">Monthly Price</label>
|
||||
<input type="number" id="subscription-monthly-price" name="monthlyPrice" step="0.01" min="0">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subscription-annual-price">Annual Price</label>
|
||||
<input type="number" id="subscription-annual-price" name="annualPrice" step="0.01" min="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subscription-start-date">Start Date *</label>
|
||||
<input type="date" id="subscription-start-date" name="startDate" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subscription-end-date">End Date (optional)</label>
|
||||
<input type="date" id="subscription-end-date" name="endDate">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subscription-free-trial">Free Trial Days</label>
|
||||
<input type="number" id="subscription-free-trial" name="freeTrialDays" min="0" value="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subscription-website-url">Website URL (optional)</label>
|
||||
<input type="url" id="subscription-website-url" name="websiteUrl" placeholder="https://example.com">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subscription-notes">Notes (optional)</label>
|
||||
<textarea id="subscription-notes" name="notes" rows="3" placeholder="Additional notes about this subscription..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary">Add Subscription</button>
|
||||
<button type="button" class="btn-secondary" onclick="app.showPage('subscriptions')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
644
script.js
644
script.js
@ -33,10 +33,20 @@ class ETFTradeTracker {
|
||||
const form = document.getElementById('trade-form');
|
||||
const clearBtn = document.getElementById('clear-trades');
|
||||
const exportBtn = document.getElementById('export-trades');
|
||||
const subscriptionForm = document.getElementById('subscription-form');
|
||||
|
||||
form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||
clearBtn.addEventListener('click', () => this.clearAllTrades());
|
||||
exportBtn.addEventListener('click', () => this.exportTrades());
|
||||
|
||||
if (subscriptionForm) {
|
||||
subscriptionForm.addEventListener('submit', (e) => this.handleSubscriptionSubmit(e));
|
||||
}
|
||||
|
||||
const editSubscriptionForm = document.getElementById('edit-subscription-form');
|
||||
if (editSubscriptionForm) {
|
||||
editSubscriptionForm.addEventListener('submit', (e) => this.handleEditSubscriptionSubmit(e));
|
||||
}
|
||||
}
|
||||
|
||||
bindNavigation() {
|
||||
@ -47,16 +57,7 @@ class ETFTradeTracker {
|
||||
menuItems.forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const page = item.dataset.page;
|
||||
this.showPage(page);
|
||||
|
||||
// Update active menu item
|
||||
menuItems.forEach(mi => mi.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
|
||||
// Hide sidebar on mobile after selection
|
||||
if (window.innerWidth <= 768) {
|
||||
sidebar.classList.remove('open');
|
||||
}
|
||||
this.navigateToPage(page);
|
||||
});
|
||||
});
|
||||
|
||||
@ -74,6 +75,25 @@ class ETFTradeTracker {
|
||||
});
|
||||
}
|
||||
|
||||
navigateToPage(pageId) {
|
||||
const menuItems = document.querySelectorAll('.menu-item');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
|
||||
// Show the page
|
||||
this.showPage(pageId);
|
||||
|
||||
// Update active menu item
|
||||
menuItems.forEach(mi => mi.classList.remove('active'));
|
||||
const targetMenuItem = document.querySelector(`[data-page="${pageId}"]`);
|
||||
if (targetMenuItem) {
|
||||
targetMenuItem.classList.add('active');
|
||||
}
|
||||
|
||||
// Hide sidebar on mobile after selection
|
||||
if (window.innerWidth <= 768) {
|
||||
sidebar.classList.remove('open');
|
||||
}
|
||||
}
|
||||
|
||||
showPage(pageId) {
|
||||
const pages = document.querySelectorAll('.page');
|
||||
@ -99,6 +119,8 @@ class ETFTradeTracker {
|
||||
'tokens': 'API Tokens',
|
||||
'cash-accounts': 'Cash Accounts',
|
||||
'add-transfer': 'Add Transfer',
|
||||
'subscriptions': 'Subscriptions',
|
||||
'add-subscription': 'Add Subscription',
|
||||
'admin': 'Admin Panel'
|
||||
};
|
||||
|
||||
@ -117,6 +139,10 @@ class ETFTradeTracker {
|
||||
this.renderCashAccountsPage();
|
||||
} else if (pageId === 'add-transfer') {
|
||||
this.renderAddTransferPage();
|
||||
} else if (pageId === 'subscriptions') {
|
||||
this.renderSubscriptionsPage();
|
||||
} else if (pageId === 'add-subscription') {
|
||||
this.renderAddSubscriptionPage();
|
||||
} else if (pageId === 'admin') {
|
||||
this.renderAdminPage();
|
||||
}
|
||||
@ -627,9 +653,121 @@ class ETFTradeTracker {
|
||||
}
|
||||
|
||||
updateDashboard() {
|
||||
this.calculateDashboardMetrics();
|
||||
this.renderETFBreakdown();
|
||||
this.loadDashboardCashData();
|
||||
this.calculateSimplifiedDashboard();
|
||||
}
|
||||
|
||||
async calculateSimplifiedDashboard() {
|
||||
try {
|
||||
// Calculate portfolio totals
|
||||
const portfolioData = this.calculatePortfolioTotals();
|
||||
|
||||
// Load cash and subscription data in parallel
|
||||
const [cashData, subscriptionData] = await Promise.all([
|
||||
this.fetchCashSummary(),
|
||||
this.fetchSubscriptionsSummary()
|
||||
]);
|
||||
|
||||
// Update dashboard with calculated data
|
||||
this.updateDashboardSummary(portfolioData, cashData, subscriptionData);
|
||||
} catch (error) {
|
||||
console.error('Error updating dashboard:', error);
|
||||
}
|
||||
}
|
||||
|
||||
calculatePortfolioTotals() {
|
||||
let totalCostBasis = 0;
|
||||
let totalCurrentValue = 0;
|
||||
let hasCurrentPrices = false;
|
||||
|
||||
const etfMap = this.getActiveETFPositions();
|
||||
etfMap.forEach((etf, symbol) => {
|
||||
totalCostBasis += etf.totalValue;
|
||||
|
||||
const currentPrice = this.currentPrices.get(symbol);
|
||||
if (currentPrice) {
|
||||
totalCurrentValue += etf.shares * currentPrice;
|
||||
hasCurrentPrices = true;
|
||||
} else {
|
||||
totalCurrentValue += etf.totalValue;
|
||||
}
|
||||
});
|
||||
|
||||
const totalGains = totalCurrentValue - totalCostBasis;
|
||||
const gainsPercentage = totalCostBasis > 0 ? ((totalGains / totalCostBasis) * 100) : 0;
|
||||
|
||||
return {
|
||||
currentValue: totalCurrentValue,
|
||||
totalGains,
|
||||
gainsPercentage,
|
||||
hasCurrentPrices
|
||||
};
|
||||
}
|
||||
|
||||
async fetchCashSummary() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/cash-summary`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching cash summary:', error);
|
||||
}
|
||||
return { total_eur: 0, total_usd: 0, account_count: 0 };
|
||||
}
|
||||
|
||||
async fetchSubscriptionsSummary() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/subscriptions-summary`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Subscription summary data:', data); // Debug log
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscriptions summary:', error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
updateDashboardSummary(portfolioData, cashData, subscriptionData) {
|
||||
// Portfolio card - force EUR display
|
||||
document.getElementById('dashboard-current-value').textContent = this.formatCurrency(portfolioData.currentValue, 'EUR');
|
||||
document.getElementById('dashboard-total-gains').textContent =
|
||||
`${portfolioData.totalGains >= 0 ? '+' : ''}${this.formatCurrency(portfolioData.totalGains, 'EUR')}`;
|
||||
|
||||
// Cash card - force EUR display
|
||||
const totalCash = cashData.total_eur + (cashData.total_usd * 1.1); // Simple EUR conversion
|
||||
document.getElementById('dashboard-cash-total').textContent = this.formatCurrency(totalCash, 'EUR');
|
||||
document.getElementById('dashboard-account-count').textContent = `${cashData.account_count} accounts`;
|
||||
|
||||
// Subscriptions card - using summary data structure
|
||||
let monthlyTotal = 0;
|
||||
let subscriptionCount = 0;
|
||||
|
||||
if (subscriptionData && Array.isArray(subscriptionData) && subscriptionData.length > 0) {
|
||||
// If we get an array of summary objects (grouped by currency)
|
||||
subscriptionData.forEach(summary => {
|
||||
monthlyTotal += summary.total_monthly_cost || 0;
|
||||
subscriptionCount += summary.total_subscriptions || 0;
|
||||
});
|
||||
} else if (subscriptionData && subscriptionData.total_monthly_cost !== undefined) {
|
||||
// If we get a single summary object
|
||||
monthlyTotal = subscriptionData.total_monthly_cost || 0;
|
||||
subscriptionCount = subscriptionData.total_subscriptions || 0;
|
||||
}
|
||||
|
||||
document.getElementById('dashboard-subscription-monthly').textContent = this.formatCurrency(monthlyTotal, 'EUR') + '/mo';
|
||||
document.getElementById('dashboard-subscription-count').textContent = `${subscriptionCount} services`;
|
||||
|
||||
// Total value card - force EUR display
|
||||
const totalValue = portfolioData.currentValue + totalCash;
|
||||
document.getElementById('dashboard-total-combined').textContent = this.formatCurrency(totalValue, 'EUR');
|
||||
document.getElementById('dashboard-performance').textContent =
|
||||
`${portfolioData.gainsPercentage >= 0 ? '+' : ''}${portfolioData.gainsPercentage.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
updateDashboardColors(totalGains) {
|
||||
@ -2912,6 +3050,486 @@ class ETFTradeTracker {
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Subscription Management Functions
|
||||
async renderSubscriptionsPage() {
|
||||
await this.loadSubscriptions();
|
||||
await this.loadSubscriptionsSummary();
|
||||
}
|
||||
|
||||
async renderAddSubscriptionPage() {
|
||||
// Set default date to today
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('subscription-start-date').value = today;
|
||||
}
|
||||
|
||||
async loadSubscriptions() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/subscriptions`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch subscriptions');
|
||||
}
|
||||
|
||||
const subscriptions = await response.json();
|
||||
this.renderSubscriptionsList(subscriptions);
|
||||
} catch (error) {
|
||||
console.error('Error loading subscriptions:', error);
|
||||
this.showNotification('Failed to load subscriptions', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadSubscriptionsSummary() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/subscriptions-summary`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch subscriptions summary');
|
||||
}
|
||||
|
||||
const summary = await response.json();
|
||||
this.renderSubscriptionsSummary(summary);
|
||||
} catch (error) {
|
||||
console.error('Error loading subscriptions summary:', error);
|
||||
}
|
||||
}
|
||||
|
||||
renderSubscriptionsList(subscriptions) {
|
||||
const container = document.getElementById('subscriptions-list');
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
container.innerHTML = '<p class="no-data">No subscriptions recorded yet. Add your first subscription above!</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionsHTML = subscriptions.map(subscription => this.createSubscriptionHTML(subscription)).join('');
|
||||
container.innerHTML = subscriptionsHTML;
|
||||
}
|
||||
|
||||
createSubscriptionHTML(subscription) {
|
||||
const monthlyPrice = subscription.billingCycle === 'monthly'
|
||||
? subscription.monthlyPrice
|
||||
: (subscription.annualPrice / 12);
|
||||
|
||||
const currency = this.getCurrencySymbol(subscription.currency);
|
||||
const nextBilling = this.calculateNextBilling(subscription.startDate, subscription.billingCycle);
|
||||
const startDate = new Date(subscription.startDate).toLocaleDateString();
|
||||
|
||||
return `
|
||||
<div class="subscription-item ${subscription.billingCycle}" data-id="${subscription.id}">
|
||||
<div class="subscription-header">
|
||||
<span class="service-name">${this.escapeHtml(subscription.serviceName)}</span>
|
||||
<span class="billing-cycle-badge ${subscription.billingCycle}">${subscription.billingCycle.toUpperCase()}</span>
|
||||
<div class="subscription-header-right">
|
||||
<span class="price">${currency}${monthlyPrice.toFixed(2)}/month</span>
|
||||
${subscription.website_url ? `<a href="${subscription.website_url}" target="_blank" rel="noopener noreferrer" class="website-link" title="Visit ${subscription.serviceName}">🔗</a>` : ''}
|
||||
<div class="subscription-actions">
|
||||
<button class="edit-btn" onclick="app.openEditSubscriptionModal('${subscription.id}')">Edit</button>
|
||||
<button class="delete-btn" onclick="app.deleteSubscription('${subscription.id}')">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subscription-details">
|
||||
<div class="subscription-info">
|
||||
${subscription.billingCycle === 'annual' ? `<span class="annual-price">(${currency}${subscription.annualPrice.toFixed(2)}/year)</span>` : ''}
|
||||
<span class="category">${subscription.category}</span>
|
||||
${subscription.freeTrialDays > 0 ? `<span class="free-trial">Free trial: ${subscription.freeTrialDays} days</span>` : ''}
|
||||
</div>
|
||||
<div class="subscription-dates">
|
||||
<span class="start-date">Started: ${startDate}</span>
|
||||
<span class="next-billing">Next billing: ${nextBilling}</span>
|
||||
${subscription.endDate ? `<span class="end-date">Ends: ${new Date(subscription.endDate).toLocaleDateString()}</span>` : ''}
|
||||
</div>
|
||||
${subscription.notes ? `<div class="subscription-notes">${this.escapeHtml(subscription.notes)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderSubscriptionsSummary(summary) {
|
||||
const monthlyTotal = document.getElementById('monthly-total');
|
||||
const annualTotal = document.getElementById('annual-total');
|
||||
const totalSubscriptions = document.getElementById('total-subscriptions');
|
||||
|
||||
if (summary.length > 0) {
|
||||
let totalMonthly = 0;
|
||||
let totalAnnual = 0;
|
||||
let totalSubs = 0;
|
||||
|
||||
summary.forEach(s => {
|
||||
totalMonthly += s.total_monthly_cost || 0;
|
||||
totalAnnual += s.total_annual_cost || 0;
|
||||
totalSubs += s.total_subscriptions || 0;
|
||||
});
|
||||
|
||||
monthlyTotal.textContent = `€${totalMonthly.toFixed(2)}`;
|
||||
annualTotal.textContent = `€${totalAnnual.toFixed(2)}`;
|
||||
totalSubscriptions.textContent = totalSubs;
|
||||
} else {
|
||||
monthlyTotal.textContent = '€0.00';
|
||||
annualTotal.textContent = '€0.00';
|
||||
totalSubscriptions.textContent = '0';
|
||||
}
|
||||
}
|
||||
|
||||
async handleSubscriptionSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const subscriptionData = {
|
||||
serviceName: formData.get('serviceName'),
|
||||
category: formData.get('category'),
|
||||
billingCycle: formData.get('billingCycle'),
|
||||
currency: formData.get('currency'),
|
||||
monthlyPrice: formData.get('monthlyPrice') ? parseFloat(formData.get('monthlyPrice')) : null,
|
||||
annualPrice: formData.get('annualPrice') ? parseFloat(formData.get('annualPrice')) : null,
|
||||
startDate: formData.get('startDate'),
|
||||
endDate: formData.get('endDate') || null,
|
||||
freeTrialDays: parseInt(formData.get('freeTrialDays')) || 0,
|
||||
notes: formData.get('notes'),
|
||||
websiteUrl: formData.get('websiteUrl')
|
||||
};
|
||||
|
||||
if (!subscriptionData.monthlyPrice && !subscriptionData.annualPrice) {
|
||||
this.showNotification('Please provide either monthly or annual price', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/subscriptions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(subscriptionData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to add subscription');
|
||||
}
|
||||
|
||||
this.showNotification('Subscription added successfully!', 'success');
|
||||
e.target.reset();
|
||||
this.showPage('subscriptions');
|
||||
this.updateDashboard(); // Refresh dashboard data
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding subscription:', error);
|
||||
this.showNotification(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSubscription(subscriptionId) {
|
||||
if (confirm('Are you sure you want to delete this subscription?')) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/subscriptions/${subscriptionId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to delete subscription');
|
||||
}
|
||||
|
||||
await this.loadSubscriptions();
|
||||
await this.loadSubscriptionsSummary();
|
||||
this.updateDashboard(); // Refresh dashboard data
|
||||
this.showNotification('Subscription deleted successfully!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting subscription:', error);
|
||||
this.showNotification(error.message || 'Failed to delete subscription', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription Modal Functions
|
||||
openEditSubscriptionModal(subscriptionId) {
|
||||
// First fetch the subscription data
|
||||
this.fetchSubscriptions().then(subscriptions => {
|
||||
const subscription = subscriptions.find(s => s.id == subscriptionId);
|
||||
if (!subscription) {
|
||||
this.showNotification('Subscription not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate the form
|
||||
document.getElementById('edit-subscription-service-name').value = subscription.serviceName;
|
||||
document.getElementById('edit-subscription-category').value = subscription.category;
|
||||
document.getElementById('edit-subscription-billing-cycle').value = subscription.billingCycle;
|
||||
document.getElementById('edit-subscription-currency').value = subscription.currency;
|
||||
document.getElementById('edit-subscription-monthly-price').value = subscription.monthlyPrice || '';
|
||||
document.getElementById('edit-subscription-annual-price').value = subscription.annualPrice || '';
|
||||
document.getElementById('edit-subscription-start-date').value = subscription.startDate;
|
||||
document.getElementById('edit-subscription-end-date').value = subscription.endDate || '';
|
||||
document.getElementById('edit-subscription-free-trial').value = subscription.freeTrialDays;
|
||||
document.getElementById('edit-subscription-notes').value = subscription.notes || '';
|
||||
document.getElementById('edit-subscription-website-url').value = subscription.website_url || '';
|
||||
|
||||
// Store the subscription ID for later use
|
||||
const form = document.getElementById('edit-subscription-form');
|
||||
form.dataset.subscriptionId = subscriptionId;
|
||||
|
||||
// Show the modal
|
||||
document.getElementById('edit-subscription-modal').style.display = 'block';
|
||||
}).catch(error => {
|
||||
console.error('Error fetching subscription for edit:', error);
|
||||
this.showNotification('Failed to load subscription data', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
closeEditSubscriptionModal() {
|
||||
document.getElementById('edit-subscription-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async handleEditSubscriptionSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const subscriptionId = e.target.dataset.subscriptionId;
|
||||
const formData = new FormData(e.target);
|
||||
const subscriptionData = {
|
||||
serviceName: formData.get('serviceName'),
|
||||
category: formData.get('category'),
|
||||
billingCycle: formData.get('billingCycle'),
|
||||
currency: formData.get('currency'),
|
||||
monthlyPrice: formData.get('monthlyPrice') ? parseFloat(formData.get('monthlyPrice')) : null,
|
||||
annualPrice: formData.get('annualPrice') ? parseFloat(formData.get('annualPrice')) : null,
|
||||
startDate: formData.get('startDate'),
|
||||
endDate: formData.get('endDate') || null,
|
||||
freeTrialDays: parseInt(formData.get('freeTrialDays')) || 0,
|
||||
notes: formData.get('notes'),
|
||||
websiteUrl: formData.get('websiteUrl')
|
||||
};
|
||||
|
||||
|
||||
if (!subscriptionData.monthlyPrice && !subscriptionData.annualPrice) {
|
||||
this.showNotification('Please provide either monthly or annual price', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/subscriptions/${subscriptionId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(subscriptionData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to update subscription');
|
||||
}
|
||||
|
||||
this.closeEditSubscriptionModal();
|
||||
await this.loadSubscriptions();
|
||||
await this.loadSubscriptionsSummary();
|
||||
this.updateDashboard();
|
||||
this.showNotification('Subscription updated successfully!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating subscription:', error);
|
||||
this.showNotification(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
exportSubscriptions() {
|
||||
// First fetch all subscriptions
|
||||
this.fetchSubscriptions().then(subscriptions => {
|
||||
if (subscriptions.length === 0) {
|
||||
this.showNotification('No subscriptions to export', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const dataStr = JSON.stringify(subscriptions, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(dataBlob);
|
||||
link.download = `subscriptions_${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
this.showNotification('Subscriptions exported successfully!', 'success');
|
||||
}).catch(error => {
|
||||
console.error('Error exporting subscriptions:', error);
|
||||
this.showNotification('Failed to export subscriptions', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
calculateNextBilling(startDate, billingCycle) {
|
||||
const start = new Date(startDate);
|
||||
const today = new Date();
|
||||
|
||||
let nextBilling = new Date(start);
|
||||
|
||||
while (nextBilling <= today) {
|
||||
if (billingCycle === 'monthly') {
|
||||
nextBilling.setMonth(nextBilling.getMonth() + 1);
|
||||
} else {
|
||||
nextBilling.setFullYear(nextBilling.getFullYear() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return nextBilling.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Dashboard Subscription Functions
|
||||
async loadDashboardSubscriptionData() {
|
||||
try {
|
||||
const [subscriptions, summary] = await Promise.all([
|
||||
this.fetchSubscriptions(),
|
||||
this.fetchSubscriptionsSummary()
|
||||
]);
|
||||
|
||||
this.updateDashboardSubscriptionSummary(summary);
|
||||
this.renderDashboardSubscriptions(subscriptions);
|
||||
|
||||
// Update the holdings section
|
||||
this.updateHoldingsSubscriptions(summary);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard subscription data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchSubscriptions() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/subscriptions`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
return response.ok ? await response.json() : [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscriptions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async fetchSubscriptionsSummary() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/subscriptions-summary`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
return response.ok ? await response.json() : [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscriptions summary:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
updateDashboardSubscriptionSummary(summary) {
|
||||
const monthlyTotal = document.getElementById('dashboard-subscription-monthly');
|
||||
const annualTotal = document.getElementById('dashboard-subscription-annual');
|
||||
const subscriptionCount = document.getElementById('dashboard-subscription-count');
|
||||
const nextBilling = document.getElementById('dashboard-next-billing');
|
||||
|
||||
if (summary.length > 0) {
|
||||
let totalMonthly = 0;
|
||||
let totalAnnual = 0;
|
||||
let totalSubs = 0;
|
||||
|
||||
summary.forEach(s => {
|
||||
totalMonthly += s.total_monthly_cost || 0;
|
||||
totalAnnual += s.total_annual_cost || 0;
|
||||
totalSubs += s.total_subscriptions || 0;
|
||||
});
|
||||
|
||||
if (monthlyTotal) monthlyTotal.textContent = `€${totalMonthly.toFixed(2)}`;
|
||||
if (annualTotal) annualTotal.textContent = `€${totalAnnual.toFixed(2)}`;
|
||||
if (subscriptionCount) subscriptionCount.textContent = `${totalSubs} subscription${totalSubs !== 1 ? 's' : ''}`;
|
||||
|
||||
// Show subscription breakdown if there are subscriptions
|
||||
const subscriptionBreakdown = document.getElementById('subscription-breakdown');
|
||||
if (subscriptionBreakdown && totalSubs > 0) {
|
||||
subscriptionBreakdown.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
if (monthlyTotal) monthlyTotal.textContent = '€0.00';
|
||||
if (annualTotal) annualTotal.textContent = '€0.00';
|
||||
if (subscriptionCount) subscriptionCount.textContent = '0 subscriptions';
|
||||
if (nextBilling) nextBilling.textContent = 'Next billing: --';
|
||||
}
|
||||
}
|
||||
|
||||
renderDashboardSubscriptions(subscriptions) {
|
||||
const container = document.getElementById('dashboard-subscriptions-list');
|
||||
if (!container) return;
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
container.innerHTML = '<p class="no-data">No subscriptions yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show top 5 subscriptions by monthly cost
|
||||
const sortedSubscriptions = subscriptions
|
||||
.map(sub => ({
|
||||
...sub,
|
||||
monthlyEquivalent: sub.billingCycle === 'monthly'
|
||||
? sub.monthlyPrice
|
||||
: (sub.annualPrice / 12)
|
||||
}))
|
||||
.sort((a, b) => b.monthlyEquivalent - a.monthlyEquivalent)
|
||||
.slice(0, 5);
|
||||
|
||||
let nextBillingDate = null;
|
||||
let nextBillingService = '';
|
||||
|
||||
const subscriptionItems = sortedSubscriptions.map(subscription => {
|
||||
const currency = this.getCurrencySymbol(subscription.currency);
|
||||
const nextBilling = this.calculateNextBilling(subscription.startDate, subscription.billingCycle);
|
||||
|
||||
// Track the nearest billing date
|
||||
const billingDateObj = new Date(nextBilling.split('/').reverse().join('-'));
|
||||
if (!nextBillingDate || billingDateObj < nextBillingDate) {
|
||||
nextBillingDate = billingDateObj;
|
||||
nextBillingService = subscription.serviceName;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="subscription-breakdown-item" style="border-left: 3px solid #6f42c1; cursor: pointer;" onclick="app.showPage('subscriptions')">
|
||||
<div class="subscription-name">${this.escapeHtml(subscription.serviceName)}</div>
|
||||
<div class="subscription-details">
|
||||
<span class="subscription-price">${currency}${subscription.monthlyEquivalent.toFixed(2)}/month</span>
|
||||
<span class="subscription-cycle">${subscription.billingCycle}</span>
|
||||
<span class="subscription-category">${subscription.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = subscriptionItems;
|
||||
|
||||
// Update next billing information
|
||||
const nextBillingElement = document.getElementById('dashboard-next-billing');
|
||||
if (nextBillingElement && nextBillingDate) {
|
||||
const billingText = `Next billing: ${nextBillingService} on ${nextBillingDate.toLocaleDateString()}`;
|
||||
nextBillingElement.textContent = billingText;
|
||||
}
|
||||
}
|
||||
|
||||
updateHoldingsSubscriptions(summary) {
|
||||
const subscriptionsValue = document.getElementById('total-holdings-subscriptions');
|
||||
if (!subscriptionsValue) return;
|
||||
|
||||
if (summary.length > 0) {
|
||||
let totalMonthly = 0;
|
||||
summary.forEach(s => {
|
||||
totalMonthly += s.total_monthly_cost || 0;
|
||||
});
|
||||
subscriptionsValue.textContent = `€${totalMonthly.toFixed(2)}`;
|
||||
} else {
|
||||
subscriptionsValue.textContent = '€0.00';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const app = new ETFTradeTracker();
|
||||
315
server.js
315
server.js
@ -235,7 +235,7 @@ function initializeDatabase() {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
`;
|
||||
|
||||
|
||||
db.run(createPriceHistoryTableSQL, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating price history table:', err.message);
|
||||
@ -243,6 +243,47 @@ function initializeDatabase() {
|
||||
console.log('Price history table ready');
|
||||
}
|
||||
});
|
||||
|
||||
// Create subscriptions table
|
||||
const createSubscriptionsTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
service_name TEXT NOT NULL,
|
||||
monthly_price REAL CHECK (monthly_price > 0),
|
||||
annual_price REAL CHECK (annual_price > 0),
|
||||
billing_cycle TEXT NOT NULL CHECK (billing_cycle IN ('monthly', 'annual')),
|
||||
currency TEXT NOT NULL DEFAULT 'EUR' CHECK (currency IN ('EUR', 'USD', 'GBP')),
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
free_trial_days INTEGER DEFAULT 0 CHECK (free_trial_days >= 0),
|
||||
category TEXT DEFAULT 'other',
|
||||
notes TEXT,
|
||||
website_url TEXT,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CHECK (monthly_price IS NOT NULL OR annual_price IS NOT NULL)
|
||||
)
|
||||
`;
|
||||
|
||||
db.run(createSubscriptionsTableSQL, (err) => {
|
||||
if (err) {
|
||||
console.error('Error creating subscriptions table:', err.message);
|
||||
} else {
|
||||
console.log('Subscriptions table ready');
|
||||
|
||||
// Add website_url column if it doesn't exist (for existing databases)
|
||||
db.run(`ALTER TABLE subscriptions ADD COLUMN website_url TEXT`, (alterErr) => {
|
||||
if (alterErr && !alterErr.message.includes('duplicate column name')) {
|
||||
console.error('Error adding website_url column:', alterErr.message);
|
||||
} else if (!alterErr) {
|
||||
console.log('Added website_url column to subscriptions table');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createDefaultAdmin() {
|
||||
@ -1321,6 +1362,278 @@ app.get('/api/latest-prices', requireAuthOrToken, (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Subscriptions Management endpoints
|
||||
app.get('/api/subscriptions', requireAuthOrToken, (req, res) => {
|
||||
const sql = `
|
||||
SELECT id, service_name, monthly_price, annual_price, billing_cycle, currency,
|
||||
start_date, end_date, free_trial_days, category, notes, website_url, is_active,
|
||||
created_at, updated_at
|
||||
FROM subscriptions
|
||||
WHERE user_id = ? AND is_active = 1
|
||||
ORDER BY service_name ASC
|
||||
`;
|
||||
|
||||
db.all(sql, [req.session.userId], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching subscriptions:', err.message);
|
||||
res.status(500).json({ error: 'Failed to fetch subscriptions' });
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptions = rows.map(row => ({
|
||||
id: row.id,
|
||||
serviceName: row.service_name,
|
||||
monthlyPrice: row.monthly_price,
|
||||
annualPrice: row.annual_price,
|
||||
billingCycle: row.billing_cycle,
|
||||
currency: row.currency,
|
||||
startDate: row.start_date,
|
||||
endDate: row.end_date,
|
||||
freeTrialDays: row.free_trial_days,
|
||||
category: row.category,
|
||||
notes: row.notes,
|
||||
website_url: row.website_url,
|
||||
isActive: row.is_active === 1,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
}));
|
||||
|
||||
res.json(subscriptions);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/subscriptions', requireAuthOrToken, (req, res) => {
|
||||
const {
|
||||
serviceName,
|
||||
monthlyPrice,
|
||||
annualPrice,
|
||||
billingCycle,
|
||||
currency = 'EUR',
|
||||
startDate,
|
||||
endDate,
|
||||
freeTrialDays = 0,
|
||||
category = 'other',
|
||||
notes = '',
|
||||
websiteUrl = ''
|
||||
} = req.body;
|
||||
|
||||
if (!serviceName || !billingCycle || !startDate) {
|
||||
return res.status(400).json({ error: 'Service name, billing cycle, and start date are required' });
|
||||
}
|
||||
|
||||
if (!['monthly', 'annual'].includes(billingCycle)) {
|
||||
return res.status(400).json({ error: 'Billing cycle must be monthly or annual' });
|
||||
}
|
||||
|
||||
if (!['EUR', 'USD', 'GBP'].includes(currency)) {
|
||||
return res.status(400).json({ error: 'Currency must be EUR, USD, or GBP' });
|
||||
}
|
||||
|
||||
if (!monthlyPrice && !annualPrice) {
|
||||
return res.status(400).json({ error: 'Either monthly price or annual price must be provided' });
|
||||
}
|
||||
|
||||
if (monthlyPrice && monthlyPrice <= 0) {
|
||||
return res.status(400).json({ error: 'Monthly price must be greater than 0' });
|
||||
}
|
||||
|
||||
if (annualPrice && annualPrice <= 0) {
|
||||
return res.status(400).json({ error: 'Annual price must be greater than 0' });
|
||||
}
|
||||
|
||||
if (freeTrialDays < 0) {
|
||||
return res.status(400).json({ error: 'Free trial days cannot be negative' });
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO subscriptions (
|
||||
user_id, service_name, monthly_price, annual_price, billing_cycle, currency,
|
||||
start_date, end_date, free_trial_days, category, notes, website_url
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
db.run(sql, [
|
||||
req.session.userId,
|
||||
serviceName.trim(),
|
||||
monthlyPrice || null,
|
||||
annualPrice || null,
|
||||
billingCycle,
|
||||
currency,
|
||||
startDate,
|
||||
endDate || null,
|
||||
freeTrialDays,
|
||||
category.trim(),
|
||||
notes.trim(),
|
||||
websiteUrl ? websiteUrl.trim() : null
|
||||
], function(err) {
|
||||
if (err) {
|
||||
console.error('Error creating subscription:', err.message);
|
||||
res.status(500).json({ error: 'Failed to create subscription' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
id: this.lastID,
|
||||
serviceName: serviceName.trim(),
|
||||
monthlyPrice,
|
||||
annualPrice,
|
||||
billingCycle,
|
||||
currency,
|
||||
startDate,
|
||||
endDate,
|
||||
freeTrialDays,
|
||||
category: category.trim(),
|
||||
notes: notes.trim(),
|
||||
message: 'Subscription created successfully'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/subscriptions/:id', requireAuthOrToken, (req, res) => {
|
||||
const subscriptionId = req.params.id;
|
||||
const {
|
||||
serviceName,
|
||||
monthlyPrice,
|
||||
annualPrice,
|
||||
billingCycle,
|
||||
currency,
|
||||
startDate,
|
||||
endDate,
|
||||
freeTrialDays = 0,
|
||||
category = 'other',
|
||||
notes = '',
|
||||
websiteUrl = ''
|
||||
} = req.body;
|
||||
|
||||
|
||||
if (!subscriptionId || isNaN(subscriptionId)) {
|
||||
return res.status(400).json({ error: 'Invalid subscription ID' });
|
||||
}
|
||||
|
||||
if (!serviceName || !billingCycle || !startDate) {
|
||||
return res.status(400).json({ error: 'Service name, billing cycle, and start date are required' });
|
||||
}
|
||||
|
||||
if (!['monthly', 'annual'].includes(billingCycle)) {
|
||||
return res.status(400).json({ error: 'Billing cycle must be monthly or annual' });
|
||||
}
|
||||
|
||||
if (!['EUR', 'USD', 'GBP'].includes(currency)) {
|
||||
return res.status(400).json({ error: 'Currency must be EUR, USD, or GBP' });
|
||||
}
|
||||
|
||||
if (!monthlyPrice && !annualPrice) {
|
||||
return res.status(400).json({ error: 'Either monthly price or annual price must be provided' });
|
||||
}
|
||||
|
||||
const sql = `
|
||||
UPDATE subscriptions
|
||||
SET service_name = ?, monthly_price = ?, annual_price = ?, billing_cycle = ?,
|
||||
currency = ?, start_date = ?, end_date = ?, free_trial_days = ?,
|
||||
category = ?, notes = ?, website_url = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
`;
|
||||
|
||||
const websiteUrlValue = websiteUrl ? websiteUrl.trim() : null;
|
||||
|
||||
db.run(sql, [
|
||||
serviceName.trim(),
|
||||
monthlyPrice || null,
|
||||
annualPrice || null,
|
||||
billingCycle,
|
||||
currency,
|
||||
startDate,
|
||||
endDate || null,
|
||||
freeTrialDays,
|
||||
category.trim(),
|
||||
notes.trim(),
|
||||
websiteUrlValue,
|
||||
subscriptionId,
|
||||
req.session.userId
|
||||
], function(err) {
|
||||
if (err) {
|
||||
console.error('Error updating subscription:', err.message);
|
||||
res.status(500).json({ error: 'Failed to update subscription' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.changes === 0) {
|
||||
res.status(404).json({ error: 'Subscription not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ message: 'Subscription updated successfully' });
|
||||
});
|
||||
});
|
||||
|
||||
app.delete('/api/subscriptions/:id', requireAuthOrToken, (req, res) => {
|
||||
const subscriptionId = req.params.id;
|
||||
|
||||
if (!subscriptionId || isNaN(subscriptionId)) {
|
||||
return res.status(400).json({ error: 'Invalid subscription ID' });
|
||||
}
|
||||
|
||||
const sql = 'UPDATE subscriptions SET is_active = 0 WHERE id = ? AND user_id = ?';
|
||||
|
||||
db.run(sql, [subscriptionId, req.session.userId], function(err) {
|
||||
if (err) {
|
||||
console.error('Error deleting subscription:', err.message);
|
||||
res.status(500).json({ error: 'Failed to delete subscription' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.changes === 0) {
|
||||
res.status(404).json({ error: 'Subscription not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ message: 'Subscription deleted successfully' });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/subscriptions-summary', requireAuthOrToken, (req, res) => {
|
||||
const sql = `
|
||||
SELECT
|
||||
COUNT(*) as total_subscriptions,
|
||||
SUM(CASE
|
||||
WHEN billing_cycle = 'monthly' AND monthly_price IS NOT NULL THEN monthly_price
|
||||
WHEN billing_cycle = 'annual' AND annual_price IS NOT NULL THEN annual_price / 12
|
||||
ELSE 0
|
||||
END) as total_monthly_cost,
|
||||
SUM(CASE
|
||||
WHEN billing_cycle = 'annual' AND annual_price IS NOT NULL THEN annual_price
|
||||
WHEN billing_cycle = 'monthly' AND monthly_price IS NOT NULL THEN monthly_price * 12
|
||||
ELSE 0
|
||||
END) as total_annual_cost,
|
||||
currency,
|
||||
COUNT(CASE WHEN billing_cycle = 'monthly' THEN 1 END) as monthly_subscriptions,
|
||||
COUNT(CASE WHEN billing_cycle = 'annual' THEN 1 END) as annual_subscriptions
|
||||
FROM subscriptions
|
||||
WHERE user_id = ? AND is_active = 1
|
||||
GROUP BY currency
|
||||
`;
|
||||
|
||||
db.all(sql, [req.session.userId], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Error fetching subscriptions summary:', err.message);
|
||||
res.status(500).json({ error: 'Failed to fetch subscriptions summary' });
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = rows.length ? rows : [{
|
||||
total_subscriptions: 0,
|
||||
total_monthly_cost: 0,
|
||||
total_annual_cost: 0,
|
||||
currency: 'EUR',
|
||||
monthly_subscriptions: 0,
|
||||
annual_subscriptions: 0
|
||||
}];
|
||||
|
||||
res.json(summary);
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'index.html'));
|
||||
});
|
||||
|
||||
1656
styles.css
1656
styles.css
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user