From 7d69a17ea146932858bed45098bc60ec662c6602 Mon Sep 17 00:00:00 2001 From: Satyam Pandey Date: Fri, 23 Jan 2026 17:49:41 +0530 Subject: [PATCH] feat: Add Tax Calculator & Financial Report Generator #186 - Add TaxProfile, TaxCategory, FinancialReport models - Implement taxService with multi-country tax brackets (India/US) - Add old vs new regime comparison for India - Implement reportService for 7 report types - Add pdfService for PDF export with PDFKit - Create tax and reports REST API routes - Add taxValidator middleware for request validation - Create frontend tax-reports.js manager class - Add tax-reports.html with calculator UI - Update server.js with new routes - Add comprehensive TAX_REPORTS.md documentation Features: - Tax calculation with surcharge and cess - Deduction tracking (80C, 80D, etc.) - Income statement, P&L, expense summary reports - Monthly comparison and annual summary - PDF download with professional formatting --- TAX_REPORTS.md | 342 ++++++++++++++ middleware/taxValidator.js | 138 ++++++ models/FinancialReport.js | 198 ++++++++ models/TaxCategory.js | 235 ++++++++++ models/TaxProfile.js | 185 ++++++++ public/tax-reports.html | 919 +++++++++++++++++++++++++++++++++++++ public/tax-reports.js | 879 +++++++++++++++++++++++++++++++++++ routes/reports.js | 283 ++++++++++++ routes/tax.js | 265 +++++++++++ server.js | 2 + services/pdfService.js | 603 ++++++++++++++++++++++++ services/reportService.js | 602 ++++++++++++++++++++++++ services/taxService.js | 426 +++++++++++++++++ 13 files changed, 5077 insertions(+) create mode 100644 TAX_REPORTS.md create mode 100644 middleware/taxValidator.js create mode 100644 models/FinancialReport.js create mode 100644 models/TaxCategory.js create mode 100644 models/TaxProfile.js create mode 100644 public/tax-reports.html create mode 100644 public/tax-reports.js create mode 100644 routes/reports.js create mode 100644 routes/tax.js create mode 100644 services/pdfService.js create mode 100644 services/reportService.js create mode 100644 services/taxService.js diff --git a/TAX_REPORTS.md b/TAX_REPORTS.md new file mode 100644 index 0000000..9f4f3b1 --- /dev/null +++ b/TAX_REPORTS.md @@ -0,0 +1,342 @@ +# Tax Calculator & Financial Report Generator + +## Overview + +The Tax Calculator & Financial Report Generator feature provides comprehensive tax estimation and financial reporting capabilities for ExpenseFlow users. It includes tax calculations for multiple countries (India, US), regime comparison, tax-deductible expense tracking, and PDF report generation. + +## Features + +### 1. Tax Calculator +- **Multi-country support**: India (Old & New Regime), US tax brackets +- **Automatic tax calculation**: Based on income and expenses +- **Deduction tracking**: Track tax-deductible expenses (80C, 80D, etc.) +- **Surcharge & Cess**: Automatic calculation for high-income earners +- **Regime comparison**: Compare old vs new tax regime to find optimal choice + +### 2. Financial Reports +- **Income Statement**: Summary of income and expenses with savings rate +- **Expense Summary**: Detailed breakdown by category with trends +- **Profit & Loss Statement**: Traditional P&L format +- **Tax Report**: Tax liability with deductions breakdown +- **Category Breakdown**: In-depth category analysis +- **Monthly Comparison**: Month-over-month trends +- **Annual Summary**: Comprehensive yearly overview + +### 3. PDF Export +- Professional PDF reports using PDFKit +- Branded headers with ExpenseFlow logo +- Detailed tables and breakdowns +- Download or share reports + +## API Endpoints + +### Tax Endpoints + +#### GET /api/tax/profile +Get user's tax profile for a specific year. + +Query Parameters: +- `taxYear` (optional): Tax year (default: current year) + +Response: +```json +{ + "success": true, + "data": { + "country": "IN", + "regime": "new", + "taxBrackets": [...], + "standardDeduction": 75000, + "availableDeductions": [...] + } +} +``` + +#### PUT /api/tax/profile +Update tax profile settings. + +Request Body: +```json +{ + "country": "IN", + "regime": "new", + "tdsDeducted": 50000, + "advanceTaxPaid": 25000, + "customDeductions": [ + { + "name": "PPF Investment", + "section": "80C", + "amount": 150000 + } + ] +} +``` + +#### GET /api/tax/calculate +Calculate tax liability for a tax year. + +Query Parameters: +- `taxYear` (optional): Tax year to calculate + +Response: +```json +{ + "success": true, + "data": { + "grossIncome": 1500000, + "standardDeduction": 75000, + "totalDeductions": 225000, + "taxableIncome": 1200000, + "baseTax": 108000, + "surcharge": 0, + "cess": 4320, + "totalTax": 112320, + "effectiveRate": 7.49, + "taxPayable": 37320 + } +} +``` + +#### GET /api/tax/compare-regimes +Compare old vs new tax regime. + +Response: +```json +{ + "success": true, + "data": { + "newRegime": { + "taxableIncome": 1200000, + "totalTax": 112320, + "effectiveRate": 7.49 + }, + "oldRegime": { + "taxableIncome": 950000, + "totalTax": 89180, + "effectiveRate": 5.94 + }, + "recommendation": "old", + "savings": 23140, + "message": "Old regime saves you ₹23,140" + } +} +``` + +#### GET /api/tax/summary +Get tax summary for dashboard display. + +#### GET /api/tax/deductible-categories +Get list of tax-deductible expense categories. + +#### POST /api/tax/auto-tag +Auto-tag an expense as tax-deductible. + +Request Body: +```json +{ + "description": "Health insurance premium", + "category": "healthcare", + "amount": 25000 +} +``` + +### Report Endpoints + +#### POST /api/reports/generate +Generate a financial report. + +Request Body: +```json +{ + "reportType": "expense_summary", + "startDate": "2024-01-01", + "endDate": "2024-12-31", + "currency": "INR" +} +``` + +Report Types: +- `income_statement` +- `expense_summary` +- `profit_loss` +- `tax_report` +- `category_breakdown` +- `monthly_comparison` +- `annual_summary` + +#### GET /api/reports +Get list of user's reports. + +Query Parameters: +- `page` (optional): Page number (default: 1) +- `limit` (optional): Items per page (default: 10) +- `reportType` (optional): Filter by report type +- `status` (optional): Filter by status (ready, processing, failed) + +#### GET /api/reports/:id +Get a specific report by ID. + +#### GET /api/reports/:id/pdf +Download report as PDF. + +#### DELETE /api/reports/:id +Delete a report. + +#### POST /api/reports/quick/:type +Generate quick reports with preset date ranges. + +Types: +- `this-month` +- `last-month` +- `this-quarter` +- `this-year` +- `last-year` +- `financial-year` (Indian FY: April to March) + +## Tax Brackets + +### India - New Regime (FY 2024-25) +| Income Slab | Rate | +|------------|------| +| ₹0 - ₹3,00,000 | 0% | +| ₹3,00,001 - ₹7,00,000 | 5% | +| ₹7,00,001 - ₹10,00,000 | 10% | +| ₹10,00,001 - ₹12,00,000 | 15% | +| ₹12,00,001 - ₹15,00,000 | 20% | +| Above ₹15,00,000 | 30% | + +### India - Old Regime +| Income Slab | Rate | +|------------|------| +| ₹0 - ₹2,50,000 | 0% | +| ₹2,50,001 - ₹5,00,000 | 5% | +| ₹5,00,001 - ₹10,00,000 | 20% | +| Above ₹10,00,000 | 30% | + +### Surcharge (India) +- 10% for income > ₹50 Lakhs +- 15% for income > ₹1 Crore +- 25% for income > ₹2 Crore +- 37% for income > ₹5 Crore + +### Health & Education Cess +4% on total tax + surcharge + +## Deduction Sections (India) + +| Section | Description | Max Limit | +|---------|-------------|-----------| +| 80C | Investments (PPF, ELSS, LIC, etc.) | ₹1,50,000 | +| 80CCD(1B) | Additional NPS contribution | ₹50,000 | +| 80D | Health insurance premium | ₹25,000 - ₹1,00,000 | +| 80E | Education loan interest | No limit | +| 80G | Donations | Varies | +| 80TTA | Savings account interest | ₹10,000 | +| HRA | House Rent Allowance | As per rules | + +## Data Models + +### TaxProfile +Stores user's tax configuration including country, regime, brackets, and deductions. + +### TaxCategory +Maps expense categories to tax-deductible sections with deduction percentages. + +### FinancialReport +Stores generated reports with all breakdown data for quick retrieval. + +## Usage Examples + +### Calculate Tax +```javascript +// Client-side +const response = await fetch('/api/tax/calculate?taxYear=2024', { + headers: { 'Authorization': `Bearer ${token}` } +}); +const { data } = await response.json(); +console.log(`Tax payable: ₹${data.taxPayable}`); +``` + +### Generate Report +```javascript +const response = await fetch('/api/reports/generate', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + reportType: 'annual_summary', + startDate: '2024-04-01', + endDate: '2025-03-31' + }) +}); +``` + +### Download PDF +```javascript +const response = await fetch(`/api/reports/${reportId}/pdf`, { + headers: { 'Authorization': `Bearer ${token}` } +}); +const blob = await response.blob(); +const url = window.URL.createObjectURL(blob); +// Trigger download +``` + +## Frontend Integration + +Include the tax-reports.js script and use the TaxReportsManager class: + +```html + + +``` + +## Files Created + +### Models +- `models/TaxProfile.js` - Tax profile schema and default brackets +- `models/TaxCategory.js` - Tax category mappings +- `models/FinancialReport.js` - Report storage schema + +### Services +- `services/taxService.js` - Tax calculation logic +- `services/reportService.js` - Report generation +- `services/pdfService.js` - PDF generation with PDFKit + +### Routes +- `routes/tax.js` - Tax API endpoints +- `routes/reports.js` - Reports API endpoints + +### Middleware +- `middleware/taxValidator.js` - Request validation + +### Frontend +- `public/tax-reports.js` - Client-side manager +- `public/tax-reports.html` - Tax calculator UI + +## Notes + +1. **Tax Calculations**: This feature provides estimates only. Users should consult tax professionals for actual filing. + +2. **Financial Year**: Indian financial year runs April to March. The system handles this automatically. + +3. **PDF Generation**: Uses PDFKit library (already included in package.json). + +4. **Currency Support**: Supports INR, USD, EUR, GBP for reports. + +5. **Performance**: Reports are cached in MongoDB for quick retrieval. Regenerate for updated data. diff --git a/middleware/taxValidator.js b/middleware/taxValidator.js new file mode 100644 index 0000000..f6f5044 --- /dev/null +++ b/middleware/taxValidator.js @@ -0,0 +1,138 @@ +const Joi = require('joi'); + +// Tax profile schema +const taxProfileSchema = Joi.object({ + country: Joi.string().valid('IN', 'US', 'UK', 'CA', 'AU').default('IN'), + regime: Joi.string().valid('old', 'new').default('new'), + filingStatus: Joi.string().valid('single', 'married_joint', 'married_separate', 'head_of_household').default('single'), + standardDeduction: Joi.number().min(0), + tdsDeducted: Joi.number().min(0).default(0), + advanceTaxPaid: Joi.number().min(0).default(0), + customDeductions: Joi.array().items( + Joi.object({ + name: Joi.string().required().max(100), + section: Joi.string().required().max(20), + amount: Joi.number().min(0).required(), + description: Joi.string().max(500) + }) + ) +}); + +// Tax calculation options +const taxCalculationSchema = Joi.object({ + taxYear: Joi.number().integer().min(2020).max(new Date().getFullYear() + 1), + customDeductions: Joi.array().items( + Joi.object({ + name: Joi.string().required(), + section: Joi.string(), + amount: Joi.number().min(0).required() + }) + ) +}); + +// Report generation schema +const reportGenerationSchema = Joi.object({ + reportType: Joi.string().valid( + 'income_statement', + 'expense_summary', + 'profit_loss', + 'tax_report', + 'category_breakdown', + 'monthly_comparison', + 'annual_summary' + ).required(), + startDate: Joi.date().iso(), + endDate: Joi.date().iso().min(Joi.ref('startDate')), + currency: Joi.string().valid('INR', 'USD', 'EUR', 'GBP').default('INR'), + includeForecasts: Joi.boolean().default(false), + workspaceId: Joi.string().regex(/^[a-fA-F0-9]{24}$/) +}); + +// Report list query schema +const reportListSchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(50).default(10), + reportType: Joi.string().valid( + 'income_statement', + 'expense_summary', + 'profit_loss', + 'tax_report', + 'category_breakdown', + 'monthly_comparison', + 'annual_summary' + ), + status: Joi.string().valid('ready', 'processing', 'failed') +}); + +// Deduction category schema +const deductionCategorySchema = Joi.object({ + code: Joi.string().required().max(20), + name: Joi.string().required().max(100), + description: Joi.string().max(500), + section: Joi.string().max(20), + maxDeductionLimit: Joi.number().min(0), + type: Joi.string().valid('deductible', 'partially_deductible', 'non_deductible').default('deductible'), + deductiblePercentage: Joi.number().min(0).max(100).default(100), + keywords: Joi.array().items(Joi.string()), + categoryMappings: Joi.array().items( + Joi.object({ + expenseCategory: Joi.string().required(), + deductiblePercentage: Joi.number().min(0).max(100).default(100) + }) + ) +}); + +// Expense tax tagging schema +const expenseTaxTagSchema = Joi.object({ + expenseId: Joi.string().regex(/^[a-fA-F0-9]{24}$/).required(), + isTaxDeductible: Joi.boolean().required(), + taxCategory: Joi.string().max(50), + section: Joi.string().max(20), + deductiblePercentage: Joi.number().min(0).max(100) +}); + +// Validation middleware factory +const validate = (schema, property = 'body') => { + return (req, res, next) => { + const { error, value } = schema.validate(req[property], { + abortEarly: false, + stripUnknown: true + }); + + if (error) { + const errors = error.details.map(detail => ({ + field: detail.path.join('.'), + message: detail.message + })); + + return res.status(400).json({ + success: false, + error: 'Validation failed', + details: errors + }); + } + + req[property] = value; + next(); + }; +}; + +// Middleware exports +module.exports = { + validateTaxProfile: validate(taxProfileSchema), + validateTaxCalculation: validate(taxCalculationSchema), + validateReportGeneration: validate(reportGenerationSchema), + validateReportList: validate(reportListSchema, 'query'), + validateDeductionCategory: validate(deductionCategorySchema), + validateExpenseTaxTag: validate(expenseTaxTagSchema), + + // Schema exports for reuse + schemas: { + taxProfileSchema, + taxCalculationSchema, + reportGenerationSchema, + reportListSchema, + deductionCategorySchema, + expenseTaxTagSchema + } +}; diff --git a/models/FinancialReport.js b/models/FinancialReport.js new file mode 100644 index 0000000..251ae14 --- /dev/null +++ b/models/FinancialReport.js @@ -0,0 +1,198 @@ +const mongoose = require('mongoose'); + +const reportSectionSchema = new mongoose.Schema({ + title: String, + data: mongoose.Schema.Types.Mixed, + chartType: { + type: String, + enum: ['bar', 'pie', 'line', 'table', 'summary', 'none'] + } +}, { _id: false }); + +const financialReportSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + reportType: { + type: String, + required: true, + enum: [ + 'income_statement', + 'profit_loss', + 'expense_summary', + 'tax_summary', + 'monthly_report', + 'quarterly_report', + 'annual_report', + 'custom' + ] + }, + title: { + type: String, + required: true, + trim: true, + maxlength: 200 + }, + description: { + type: String, + trim: true, + maxlength: 500 + }, + dateRange: { + startDate: { + type: Date, + required: true + }, + endDate: { + type: Date, + required: true + } + }, + period: { + type: String, + enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly', 'custom'], + default: 'monthly' + }, + currency: { + type: String, + default: 'INR', + uppercase: true + }, + summary: { + totalIncome: { + type: Number, + default: 0 + }, + totalExpenses: { + type: Number, + default: 0 + }, + netAmount: { + type: Number, + default: 0 + }, + savingsRate: { + type: Number, + default: 0 + }, + taxableIncome: { + type: Number, + default: 0 + }, + estimatedTax: { + type: Number, + default: 0 + }, + deductibleExpenses: { + type: Number, + default: 0 + } + }, + incomeBreakdown: [{ + category: String, + amount: Number, + percentage: Number, + count: Number + }], + expenseBreakdown: [{ + category: String, + amount: Number, + percentage: Number, + count: Number, + taxDeductible: Boolean, + deductibleAmount: Number + }], + monthlyTrends: [{ + month: String, + year: Number, + income: Number, + expenses: Number, + net: Number + }], + taxDeductions: [{ + section: String, + name: String, + amount: Number, + maxLimit: Number, + utilized: Number + }], + topExpenses: [{ + description: String, + amount: Number, + category: String, + date: Date + }], + sections: [reportSectionSchema], + metadata: { + generatedAt: { + type: Date, + default: Date.now + }, + generationTime: Number, // in milliseconds + expenseCount: Number, + incomeCount: Number, + version: { + type: String, + default: '1.0' + } + }, + pdfUrl: { + type: String, + default: null + }, + pdfGeneratedAt: Date, + isArchived: { + type: Boolean, + default: false + }, + tags: [{ + type: String, + trim: true + }] +}, { + timestamps: true +}); + +// Indexes +financialReportSchema.index({ user: 1, reportType: 1, createdAt: -1 }); +financialReportSchema.index({ user: 1, 'dateRange.startDate': 1, 'dateRange.endDate': 1 }); +financialReportSchema.index({ user: 1, isArchived: 1 }); + +// Virtual for formatted date range +financialReportSchema.virtual('formattedDateRange').get(function() { + const start = this.dateRange.startDate.toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric' + }); + const end = this.dateRange.endDate.toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric' + }); + return `${start} - ${end}`; +}); + +// Method to calculate health score +financialReportSchema.methods.calculateHealthScore = function() { + const savingsRate = this.summary.savingsRate; + const expenseRatio = this.summary.totalIncome > 0 + ? (this.summary.totalExpenses / this.summary.totalIncome) * 100 + : 100; + + let score = 50; // Base score + + // Savings rate contribution (up to 30 points) + if (savingsRate >= 30) score += 30; + else if (savingsRate >= 20) score += 25; + else if (savingsRate >= 10) score += 15; + else if (savingsRate > 0) score += 5; + + // Expense ratio contribution (up to 20 points) + if (expenseRatio <= 50) score += 20; + else if (expenseRatio <= 70) score += 15; + else if (expenseRatio <= 90) score += 10; + else if (expenseRatio <= 100) score += 5; + + return Math.min(100, Math.max(0, score)); +}; + +module.exports = mongoose.model('FinancialReport', financialReportSchema); diff --git a/models/TaxCategory.js b/models/TaxCategory.js new file mode 100644 index 0000000..65ca296 --- /dev/null +++ b/models/TaxCategory.js @@ -0,0 +1,235 @@ +const mongoose = require('mongoose'); + +const taxCategoryMappingSchema = new mongoose.Schema({ + expenseCategory: { + type: String, + required: true + }, + taxCategory: { + type: String, + required: true + }, + deductionCode: { + type: String, + trim: true + }, + deductiblePercentage: { + type: Number, + default: 100, + min: 0, + max: 100 + } +}, { _id: false }); + +const taxCategorySchema = new mongoose.Schema({ + code: { + type: String, + required: true, + unique: true, + trim: true + }, + name: { + type: String, + required: true, + trim: true + }, + description: { + type: String, + trim: true + }, + type: { + type: String, + required: true, + enum: ['deductible', 'non_deductible', 'partially_deductible', 'income', 'exempt'] + }, + country: { + type: String, + required: true, + default: 'IN', + uppercase: true + }, + applicableTo: [{ + type: String, + enum: ['individual', 'business', 'self_employed', 'all'] + }], + categoryMappings: [taxCategoryMappingSchema], + maxDeductionLimit: { + type: Number, + default: null + }, + requiresDocumentation: { + type: Boolean, + default: true + }, + section: { + type: String, + trim: true + }, + keywords: [{ + type: String, + lowercase: true + }], + isActive: { + type: Boolean, + default: true + } +}, { + timestamps: true +}); + +// Indexes +taxCategorySchema.index({ code: 1, country: 1 }); +taxCategorySchema.index({ type: 1, country: 1 }); +taxCategorySchema.index({ keywords: 1 }); + +// Static method to get default tax categories +taxCategorySchema.statics.getDefaultCategories = function(country = 'IN') { + const categories = [ + // Indian Tax Categories + { + code: 'BUSINESS_EXPENSE', + name: 'Business Expenses', + description: 'Expenses incurred for business operations', + type: 'deductible', + country: 'IN', + applicableTo: ['business', 'self_employed'], + section: '37(1)', + keywords: ['business', 'office', 'supplies', 'equipment'], + categoryMappings: [ + { expenseCategory: 'utilities', taxCategory: 'BUSINESS_EXPENSE', deductionCode: '37', deductiblePercentage: 100 }, + { expenseCategory: 'transport', taxCategory: 'BUSINESS_EXPENSE', deductionCode: '37', deductiblePercentage: 50 } + ] + }, + { + code: 'MEDICAL_EXPENSE', + name: 'Medical Expenses', + description: 'Healthcare and medical treatment costs', + type: 'deductible', + country: 'IN', + applicableTo: ['all'], + section: '80D/80DDB', + maxDeductionLimit: 100000, + keywords: ['medical', 'health', 'hospital', 'medicine', 'doctor', 'healthcare'], + categoryMappings: [ + { expenseCategory: 'healthcare', taxCategory: 'MEDICAL_EXPENSE', deductionCode: '80D', deductiblePercentage: 100 } + ] + }, + { + code: 'EDUCATION_EXPENSE', + name: 'Education Expenses', + description: 'Educational and professional development costs', + type: 'deductible', + country: 'IN', + applicableTo: ['all'], + section: '80E', + keywords: ['education', 'tuition', 'course', 'training', 'school', 'college'], + categoryMappings: [ + { expenseCategory: 'other', taxCategory: 'EDUCATION_EXPENSE', deductionCode: '80E', deductiblePercentage: 100 } + ] + }, + { + code: 'CHARITABLE_DONATION', + name: 'Charitable Donations', + description: 'Donations to approved charitable organizations', + type: 'deductible', + country: 'IN', + applicableTo: ['all'], + section: '80G', + keywords: ['donation', 'charity', 'ngo', 'trust', 'foundation'], + categoryMappings: [ + { expenseCategory: 'other', taxCategory: 'CHARITABLE_DONATION', deductionCode: '80G', deductiblePercentage: 50 } + ] + }, + { + code: 'HOME_OFFICE', + name: 'Home Office Expenses', + description: 'Expenses for home-based work setup', + type: 'partially_deductible', + country: 'IN', + applicableTo: ['self_employed', 'business'], + section: '37(1)', + keywords: ['home office', 'work from home', 'internet', 'furniture'], + categoryMappings: [ + { expenseCategory: 'utilities', taxCategory: 'HOME_OFFICE', deductionCode: '37', deductiblePercentage: 40 } + ] + }, + { + code: 'TRAVEL_BUSINESS', + name: 'Business Travel', + description: 'Travel expenses for business purposes', + type: 'deductible', + country: 'IN', + applicableTo: ['business', 'self_employed'], + section: '37(1)', + keywords: ['travel', 'flight', 'hotel', 'business trip', 'conference'], + categoryMappings: [ + { expenseCategory: 'transport', taxCategory: 'TRAVEL_BUSINESS', deductionCode: '37', deductiblePercentage: 100 } + ] + }, + { + code: 'PERSONAL_EXPENSE', + name: 'Personal Expenses', + description: 'Non-deductible personal expenses', + type: 'non_deductible', + country: 'IN', + applicableTo: ['all'], + keywords: ['personal', 'food', 'entertainment', 'shopping'], + categoryMappings: [ + { expenseCategory: 'food', taxCategory: 'PERSONAL_EXPENSE', deductionCode: null, deductiblePercentage: 0 }, + { expenseCategory: 'entertainment', taxCategory: 'PERSONAL_EXPENSE', deductionCode: null, deductiblePercentage: 0 }, + { expenseCategory: 'shopping', taxCategory: 'PERSONAL_EXPENSE', deductionCode: null, deductiblePercentage: 0 } + ] + }, + { + code: 'INSURANCE_PREMIUM', + name: 'Insurance Premiums', + description: 'Life and health insurance premiums', + type: 'deductible', + country: 'IN', + applicableTo: ['all'], + section: '80C/80D', + maxDeductionLimit: 150000, + keywords: ['insurance', 'premium', 'life insurance', 'health insurance'], + categoryMappings: [] + }, + // US Tax Categories + { + code: 'US_BUSINESS_EXPENSE', + name: 'Business Expenses', + description: 'Ordinary and necessary business expenses', + type: 'deductible', + country: 'US', + applicableTo: ['business', 'self_employed'], + keywords: ['business', 'office', 'supplies'], + categoryMappings: [ + { expenseCategory: 'utilities', taxCategory: 'US_BUSINESS_EXPENSE', deductiblePercentage: 100 } + ] + }, + { + code: 'US_MEDICAL', + name: 'Medical Expenses', + description: 'Medical expenses exceeding 7.5% of AGI', + type: 'partially_deductible', + country: 'US', + applicableTo: ['all'], + keywords: ['medical', 'health', 'doctor', 'hospital'], + categoryMappings: [ + { expenseCategory: 'healthcare', taxCategory: 'US_MEDICAL', deductiblePercentage: 100 } + ] + }, + { + code: 'US_CHARITY', + name: 'Charitable Contributions', + description: 'Donations to qualified organizations', + type: 'deductible', + country: 'US', + applicableTo: ['all'], + keywords: ['charity', 'donation', 'nonprofit'], + categoryMappings: [] + } + ]; + + return categories.filter(c => c.country === country || country === 'ALL'); +}; + +module.exports = mongoose.model('TaxCategory', taxCategorySchema); diff --git a/models/TaxProfile.js b/models/TaxProfile.js new file mode 100644 index 0000000..fd1e969 --- /dev/null +++ b/models/TaxProfile.js @@ -0,0 +1,185 @@ +const mongoose = require('mongoose'); + +const taxBracketSchema = new mongoose.Schema({ + minIncome: { + type: Number, + required: true, + min: 0 + }, + maxIncome: { + type: Number, + default: null // null means no upper limit + }, + rate: { + type: Number, + required: true, + min: 0, + max: 100 + }, + fixedAmount: { + type: Number, + default: 0 + } +}, { _id: false }); + +const deductionSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + trim: true + }, + code: { + type: String, + required: true, + trim: true + }, + maxLimit: { + type: Number, + default: null // null means no limit + }, + description: String +}, { _id: false }); + +const taxProfileSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + taxYear: { + type: Number, + required: true + }, + country: { + type: String, + required: true, + default: 'IN', + uppercase: true + }, + region: { + type: String, + trim: true + }, + regime: { + type: String, + enum: ['old', 'new', 'default'], + default: 'new' + }, + filingStatus: { + type: String, + enum: ['individual', 'married_jointly', 'married_separately', 'head_of_household', 'business'], + default: 'individual' + }, + employmentType: { + type: String, + enum: ['salaried', 'self_employed', 'business', 'freelancer', 'other'], + default: 'salaried' + }, + taxBrackets: [taxBracketSchema], + standardDeduction: { + type: Number, + default: 50000 // Default for India + }, + availableDeductions: [deductionSchema], + customDeductions: [{ + name: String, + amount: Number, + section: String + }], + estimatedTaxCredits: { + type: Number, + default: 0 + }, + advanceTaxPaid: { + type: Number, + default: 0 + }, + tdsDeducted: { + type: Number, + default: 0 + }, + isActive: { + type: Boolean, + default: true + } +}, { + timestamps: true +}); + +// Compound index for user and tax year +taxProfileSchema.index({ user: 1, taxYear: 1 }, { unique: true }); + +// Initialize default Indian tax brackets (New Regime FY 2024-25) +taxProfileSchema.statics.getDefaultBrackets = function(country = 'IN', regime = 'new') { + if (country === 'IN') { + if (regime === 'new') { + return [ + { minIncome: 0, maxIncome: 300000, rate: 0, fixedAmount: 0 }, + { minIncome: 300001, maxIncome: 700000, rate: 5, fixedAmount: 0 }, + { minIncome: 700001, maxIncome: 1000000, rate: 10, fixedAmount: 20000 }, + { minIncome: 1000001, maxIncome: 1200000, rate: 15, fixedAmount: 50000 }, + { minIncome: 1200001, maxIncome: 1500000, rate: 20, fixedAmount: 80000 }, + { minIncome: 1500001, maxIncome: null, rate: 30, fixedAmount: 140000 } + ]; + } else { + // Old Regime + return [ + { minIncome: 0, maxIncome: 250000, rate: 0, fixedAmount: 0 }, + { minIncome: 250001, maxIncome: 500000, rate: 5, fixedAmount: 0 }, + { minIncome: 500001, maxIncome: 1000000, rate: 20, fixedAmount: 12500 }, + { minIncome: 1000001, maxIncome: null, rate: 30, fixedAmount: 112500 } + ]; + } + } + + // US Tax Brackets (2024) - Simplified + if (country === 'US') { + return [ + { minIncome: 0, maxIncome: 11600, rate: 10, fixedAmount: 0 }, + { minIncome: 11601, maxIncome: 47150, rate: 12, fixedAmount: 1160 }, + { minIncome: 47151, maxIncome: 100525, rate: 22, fixedAmount: 5426 }, + { minIncome: 100526, maxIncome: 191950, rate: 24, fixedAmount: 17168 }, + { minIncome: 191951, maxIncome: 243725, rate: 32, fixedAmount: 39110 }, + { minIncome: 243726, maxIncome: 609350, rate: 35, fixedAmount: 55678 }, + { minIncome: 609351, maxIncome: null, rate: 37, fixedAmount: 183647 } + ]; + } + + // Default progressive brackets + return [ + { minIncome: 0, maxIncome: 50000, rate: 10, fixedAmount: 0 }, + { minIncome: 50001, maxIncome: 100000, rate: 20, fixedAmount: 5000 }, + { minIncome: 100001, maxIncome: null, rate: 30, fixedAmount: 15000 } + ]; +}; + +// Get default deductions for a country +taxProfileSchema.statics.getDefaultDeductions = function(country = 'IN') { + if (country === 'IN') { + return [ + { name: 'Section 80C Investments', code: '80C', maxLimit: 150000, description: 'PPF, ELSS, Life Insurance, etc.' }, + { name: 'Health Insurance Premium', code: '80D', maxLimit: 75000, description: 'Self and family health insurance' }, + { name: 'Education Loan Interest', code: '80E', maxLimit: null, description: 'Interest on education loan' }, + { name: 'Home Loan Interest', code: '24B', maxLimit: 200000, description: 'Interest on home loan' }, + { name: 'Donations', code: '80G', maxLimit: null, description: 'Charitable donations' }, + { name: 'NPS Contribution', code: '80CCD', maxLimit: 50000, description: 'National Pension Scheme' }, + { name: 'Medical Expenses', code: '80DDB', maxLimit: 100000, description: 'Specified diseases treatment' }, + { name: 'Rent Paid (HRA)', code: '10(13A)', maxLimit: null, description: 'House Rent Allowance' } + ]; + } + + if (country === 'US') { + return [ + { name: 'Standard Deduction', code: 'STD', maxLimit: 14600, description: 'Standard deduction for single filers' }, + { name: 'Mortgage Interest', code: 'MORT', maxLimit: 750000, description: 'Interest on home mortgage' }, + { name: 'State and Local Taxes', code: 'SALT', maxLimit: 10000, description: 'State and local tax deduction' }, + { name: 'Charitable Contributions', code: 'CHAR', maxLimit: null, description: 'Donations to qualified organizations' }, + { name: 'Medical Expenses', code: 'MED', maxLimit: null, description: 'Exceeding 7.5% of AGI' }, + { name: 'Student Loan Interest', code: 'STUD', maxLimit: 2500, description: 'Interest on student loans' } + ]; + } + + return []; +}; + +module.exports = mongoose.model('TaxProfile', taxProfileSchema); diff --git a/public/tax-reports.html b/public/tax-reports.html new file mode 100644 index 0000000..6fa978a --- /dev/null +++ b/public/tax-reports.html @@ -0,0 +1,919 @@ + + + + + + Tax Calculator & Reports - ExpenseFlow + + + + +
+ + +
+ + + +
+ + +
+
+

Tax Summary

+
+
+

Loading tax calculation...

+
+
+
+ +
+

Compare Tax Regimes

+

Compare Old vs New tax regime to find the best option for you

+ +
+
+ +
+

Tax Deductions

+

Your tax-deductible expenses for the financial year

+
+
+

Track tax-deductible expenses to reduce your tax liability

+
+
+
+
+ + +
+
+

Generate Report

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ Quick Reports: + + + + + +
+
+ +
+

Report Preview

+
+
+

Select or generate a report to view

+
+
+
+ +
+

Previous Reports

+
+
+

No reports generated yet

+
+
+
+
+ + +
+
+

Tax Profile Settings

+
+
+ + +
+
+ +
+ Old + + New +
+
+
+ + ₹0.00 +
+
+
+ +
+

TDS & Advance Tax

+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Custom Deductions

+

Add custom deductions like 80C investments, HRA, LTA, etc.

+
+
+

No custom deductions added

+ +
+
+
+
+
+ + + + + diff --git a/public/tax-reports.js b/public/tax-reports.js new file mode 100644 index 0000000..8b69153 --- /dev/null +++ b/public/tax-reports.js @@ -0,0 +1,879 @@ +/** + * Tax Calculator & Reports Client-Side Manager + * Handles tax calculations, report generation, and PDF exports + */ +class TaxReportsManager { + constructor() { + this.baseUrl = '/api'; + this.currentTaxYear = new Date().getFullYear(); + this.profile = null; + this.reports = []; + this.init(); + } + + async init() { + await this.loadTaxProfile(); + this.setupEventListeners(); + } + + /** + * Setup event listeners + */ + setupEventListeners() { + // Tax year selector + const yearSelector = document.getElementById('tax-year-selector'); + if (yearSelector) { + yearSelector.addEventListener('change', (e) => { + this.currentTaxYear = parseInt(e.target.value); + this.refreshAll(); + }); + } + + // Regime toggle + const regimeToggle = document.getElementById('regime-toggle'); + if (regimeToggle) { + regimeToggle.addEventListener('change', (e) => { + this.updateRegime(e.target.checked ? 'new' : 'old'); + }); + } + + // Report generation form + const reportForm = document.getElementById('report-form'); + if (reportForm) { + reportForm.addEventListener('submit', (e) => { + e.preventDefault(); + this.handleReportGeneration(e.target); + }); + } + + // Quick report buttons + document.querySelectorAll('[data-quick-report]').forEach(btn => { + btn.addEventListener('click', () => { + this.generateQuickReport(btn.dataset.quickReport, btn.dataset.reportType); + }); + }); + } + + /** + * Get auth token + */ + getToken() { + return localStorage.getItem('token'); + } + + /** + * Make authenticated request + */ + async request(url, options = {}) { + const token = this.getToken(); + if (!token) { + window.location.href = '/login.html'; + return; + } + + const config = { + ...options, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...options.headers + } + }; + + const response = await fetch(`${this.baseUrl}${url}`, config); + + if (response.status === 401) { + localStorage.removeItem('token'); + window.location.href = '/login.html'; + return; + } + + return response; + } + + // ==================== TAX PROFILE ==================== + + /** + * Load tax profile + */ + async loadTaxProfile() { + try { + const response = await this.request(`/tax/profile?taxYear=${this.currentTaxYear}`); + const data = await response.json(); + + if (data.success) { + this.profile = data.data; + this.updateProfileUI(); + } + } catch (error) { + console.error('Failed to load tax profile:', error); + this.showNotification('Failed to load tax profile', 'error'); + } + } + + /** + * Update tax profile + */ + async updateProfile(updates) { + try { + const response = await this.request(`/tax/profile?taxYear=${this.currentTaxYear}`, { + method: 'PUT', + body: JSON.stringify(updates) + }); + const data = await response.json(); + + if (data.success) { + this.profile = data.data; + this.updateProfileUI(); + this.showNotification('Tax profile updated', 'success'); + await this.calculateTax(); + } + } catch (error) { + console.error('Failed to update profile:', error); + this.showNotification('Failed to update profile', 'error'); + } + } + + /** + * Update profile UI + */ + updateProfileUI() { + if (!this.profile) return; + + const elements = { + 'profile-country': this.profile.country, + 'profile-regime': this.profile.regime, + 'profile-standard-deduction': this.formatCurrency(this.profile.standardDeduction), + 'profile-tds': this.formatCurrency(this.profile.tdsDeducted), + 'profile-advance-tax': this.formatCurrency(this.profile.advanceTaxPaid) + }; + + Object.entries(elements).forEach(([id, value]) => { + const el = document.getElementById(id); + if (el) el.textContent = value; + }); + + // Update regime toggle + const regimeToggle = document.getElementById('regime-toggle'); + if (regimeToggle) { + regimeToggle.checked = this.profile.regime === 'new'; + } + } + + /** + * Update tax regime + */ + async updateRegime(regime) { + await this.updateProfile({ regime }); + } + + // ==================== TAX CALCULATION ==================== + + /** + * Calculate tax + */ + async calculateTax(customDeductions = []) { + try { + this.showLoading('tax-calculation'); + + const response = await this.request('/tax/calculate', { + method: 'POST', + body: JSON.stringify({ + taxYear: this.currentTaxYear, + customDeductions + }) + }); + const data = await response.json(); + + if (data.success) { + this.displayTaxCalculation(data.data); + } + } catch (error) { + console.error('Tax calculation error:', error); + this.showNotification('Failed to calculate tax', 'error'); + } finally { + this.hideLoading('tax-calculation'); + } + } + + /** + * Display tax calculation + */ + displayTaxCalculation(calc) { + const container = document.getElementById('tax-calculation-result'); + if (!container) return; + + container.innerHTML = ` +
+
+

Gross Income

+

${this.formatCurrency(calc.grossIncome)}

+
+
+

Total Deductions

+

${this.formatCurrency(calc.totalDeductions)}

+
+
+

Taxable Income

+

${this.formatCurrency(calc.taxableIncome)}

+
+
+

Total Tax

+

${this.formatCurrency(calc.totalTax)}

+ Effective Rate: ${calc.effectiveRate}% +
+
+ +
+

Tax Breakdown

+ + + + + + + + + + + ${calc.taxCalculation.map(t => ` + + + + + + + `).join('')} + + + + + + + + + + + + + + + + + + + +
Income SlabRateTaxable AmountTax
${t.range}${t.rate}%${this.formatCurrency(t.taxableAmount)}${this.formatCurrency(t.tax)}
Base Tax${this.formatCurrency(calc.baseTax)}
Surcharge${this.formatCurrency(calc.surcharge)}
Health & Education Cess (4%)${this.formatCurrency(calc.cess)}
Total Tax Liability${this.formatCurrency(calc.totalTax)}
+
+ +
+
+

${calc.taxPayable > 0 ? 'Tax Payable' : 'Tax Refund'}

+

${this.formatCurrency(calc.taxPayable > 0 ? calc.taxPayable : calc.taxRefund)}

+ After TDS (${this.formatCurrency(calc.tdsDeducted)}) and Advance Tax (${this.formatCurrency(calc.advanceTaxPaid)}) +
+
+ + ${calc.deductions.length > 0 ? ` +
+

Deductions Summary

+ + + + + + + + + + + + ${calc.deductions.map(d => ` + + + + + + + + `).join('')} + +
DeductionSectionClaimedLimitAllowed
${d.name}${d.section || '-'}${this.formatCurrency(d.claimed)}${d.limit ? this.formatCurrency(d.limit) : 'No limit'}${this.formatCurrency(d.allowed)}
+
+ ` : ''} + `; + } + + /** + * Compare tax regimes + */ + async compareRegimes() { + try { + this.showLoading('regime-comparison'); + + const response = await this.request(`/tax/compare-regimes?taxYear=${this.currentTaxYear}`); + const data = await response.json(); + + if (data.success) { + this.displayRegimeComparison(data.data); + } + } catch (error) { + console.error('Regime comparison error:', error); + this.showNotification('Failed to compare regimes', 'error'); + } finally { + this.hideLoading('regime-comparison'); + } + } + + /** + * Display regime comparison + */ + displayRegimeComparison(comparison) { + const container = document.getElementById('regime-comparison'); + if (!container) return; + + container.innerHTML = ` +
+
+

New Regime

+
+

Taxable Income: ${this.formatCurrency(comparison.newRegime.taxableIncome)}

+

Deductions: ${this.formatCurrency(comparison.newRegime.deductions)}

+

Tax: ${this.formatCurrency(comparison.newRegime.totalTax)}

+

Effective Rate: ${comparison.newRegime.effectiveRate}%

+
+ ${comparison.recommendation === 'new' ? 'Recommended' : ''} +
+
+

Old Regime

+
+

Taxable Income: ${this.formatCurrency(comparison.oldRegime.taxableIncome)}

+

Deductions: ${this.formatCurrency(comparison.oldRegime.deductions)}

+

Tax: ${this.formatCurrency(comparison.oldRegime.totalTax)}

+

Effective Rate: ${comparison.oldRegime.effectiveRate}%

+
+ ${comparison.recommendation === 'old' ? 'Recommended' : ''} +
+
+
+

${comparison.message}

+
+ `; + } + + // ==================== REPORTS ==================== + + /** + * Generate report + */ + async generateReport(reportType, startDate, endDate, currency = 'INR') { + try { + this.showLoading('report-generation'); + + const response = await this.request('/reports/generate', { + method: 'POST', + body: JSON.stringify({ + reportType, + startDate, + endDate, + currency + }) + }); + const data = await response.json(); + + if (data.success) { + this.showNotification('Report generated successfully', 'success'); + await this.loadReports(); + this.displayReport(data.data); + return data.data; + } + } catch (error) { + console.error('Report generation error:', error); + this.showNotification('Failed to generate report', 'error'); + } finally { + this.hideLoading('report-generation'); + } + } + + /** + * Handle report form submission + */ + async handleReportGeneration(form) { + const formData = new FormData(form); + await this.generateReport( + formData.get('reportType'), + formData.get('startDate'), + formData.get('endDate'), + formData.get('currency') || 'INR' + ); + } + + /** + * Generate quick report + */ + async generateQuickReport(period, reportType = 'expense_summary') { + try { + this.showLoading('report-generation'); + + const response = await this.request(`/reports/quick/${period}`, { + method: 'POST', + body: JSON.stringify({ reportType }) + }); + const data = await response.json(); + + if (data.success) { + this.showNotification(`${period} report generated`, 'success'); + await this.loadReports(); + this.displayReport(data.data); + } + } catch (error) { + console.error('Quick report error:', error); + this.showNotification('Failed to generate quick report', 'error'); + } finally { + this.hideLoading('report-generation'); + } + } + + /** + * Load user's reports + */ + async loadReports(page = 1, limit = 10) { + try { + const response = await this.request(`/reports?page=${page}&limit=${limit}`); + const data = await response.json(); + + if (data.success) { + this.reports = data.data; + this.displayReportsList(data.data, data.pagination); + } + } catch (error) { + console.error('Failed to load reports:', error); + } + } + + /** + * Display reports list + */ + displayReportsList(reports, pagination) { + const container = document.getElementById('reports-list'); + if (!container) return; + + if (reports.length === 0) { + container.innerHTML = ` +
+

No reports generated yet

+

Generate your first financial report above

+
+ `; + return; + } + + container.innerHTML = ` + + + + + + + + + + + + ${reports.map(report => ` + + + + + + + + `).join('')} + +
ReportTypePeriodGeneratedActions
${report.title}${this.formatReportType(report.reportType)}${this.formatDateRange(report.dateRange)}${this.formatDate(report.generatedAt)} + + + +
+ + ${pagination && pagination.pages > 1 ? ` + + ` : ''} + `; + } + + /** + * View report + */ + async viewReport(reportId) { + try { + const response = await this.request(`/reports/${reportId}`); + const data = await response.json(); + + if (data.success) { + this.displayReport(data.data); + } + } catch (error) { + console.error('Failed to load report:', error); + this.showNotification('Failed to load report', 'error'); + } + } + + /** + * Display report + */ + displayReport(report) { + const container = document.getElementById('report-view'); + if (!container) return; + + let content = ` +
+

${report.title}

+

${this.formatDateRange(report.dateRange)}

+
+ +
+
+ `; + + // Summary section + content += ` +
+
+

Total Income

+

${this.formatCurrency(report.totalIncome)}

+
+
+

Total Expenses

+

${this.formatCurrency(report.totalExpenses)}

+
+
+

Net ${report.netIncome >= 0 ? 'Savings' : 'Loss'}

+

${this.formatCurrency(Math.abs(report.netIncome))}

+
+
+ `; + + // Income breakdown + if (report.incomeBreakdown && report.incomeBreakdown.length > 0) { + content += this.renderBreakdownTable('Income Breakdown', report.incomeBreakdown, 'income'); + } + + // Expense breakdown + if (report.expenseBreakdown && report.expenseBreakdown.length > 0) { + content += this.renderBreakdownTable('Expense Breakdown', report.expenseBreakdown, 'expense'); + } + + // Monthly trends + if (report.monthlyTrends && report.monthlyTrends.length > 0) { + content += this.renderMonthlyTrends(report.monthlyTrends); + } + + // Tax summary + if (report.taxSummary) { + content += this.renderTaxSummary(report.taxSummary); + } + + container.innerHTML = content; + } + + /** + * Render breakdown table + */ + renderBreakdownTable(title, data, type) { + return ` +
+

${title}

+ + + + + + + + + + + ${data.map(item => ` + + + + + + + `).join('')} + +
CategoryAmount% of TotalCount
${this.capitalize(item.category)}${this.formatCurrency(item.amount)}${item.percentage || '-'}%${item.count}
+
+ `; + } + + /** + * Render monthly trends + */ + renderMonthlyTrends(trends) { + return ` + + `; + } + + /** + * Render tax summary + */ + renderTaxSummary(summary) { + return ` +
+

Tax Summary

+
+
+ Gross Income + ${this.formatCurrency(summary.grossIncome)} +
+
+ Total Deductions + ${this.formatCurrency(summary.totalDeductions)} +
+
+ Taxable Income + ${this.formatCurrency(summary.taxableIncome)} +
+
+ Tax Liability + ${this.formatCurrency(summary.taxLiability)} +
+
+ Effective Rate + ${summary.effectiveRate}% +
+
+ Tax Regime + ${this.capitalize(summary.regime)} +
+
+
+ `; + } + + /** + * Download report as PDF + */ + async downloadPDF(reportId) { + try { + this.showNotification('Generating PDF...', 'info'); + + const response = await this.request(`/reports/${reportId}/pdf`); + + if (!response.ok) { + throw new Error('Failed to generate PDF'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `report_${reportId}.pdf`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + this.showNotification('PDF downloaded', 'success'); + } catch (error) { + console.error('PDF download error:', error); + this.showNotification('Failed to download PDF', 'error'); + } + } + + /** + * Delete report + */ + async deleteReport(reportId) { + if (!confirm('Are you sure you want to delete this report?')) return; + + try { + const response = await this.request(`/reports/${reportId}`, { + method: 'DELETE' + }); + const data = await response.json(); + + if (data.success) { + this.showNotification('Report deleted', 'success'); + await this.loadReports(); + + // Clear report view if viewing deleted report + const container = document.getElementById('report-view'); + if (container) container.innerHTML = ''; + } + } catch (error) { + console.error('Delete report error:', error); + this.showNotification('Failed to delete report', 'error'); + } + } + + // ==================== UTILITIES ==================== + + /** + * Refresh all data + */ + async refreshAll() { + await Promise.all([ + this.loadTaxProfile(), + this.calculateTax(), + this.loadReports() + ]); + } + + /** + * Format currency + */ + formatCurrency(amount, currency = 'INR') { + const symbols = { INR: '₹', USD: '$', EUR: '€', GBP: '£' }; + const symbol = symbols[currency] || currency; + const formatted = Math.abs(amount || 0).toLocaleString('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + return amount < 0 ? `-${symbol}${formatted}` : `${symbol}${formatted}`; + } + + /** + * Format date + */ + formatDate(dateString) { + if (!dateString) return '-'; + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } + + /** + * Format date range + */ + formatDateRange(range) { + if (!range) return '-'; + return `${this.formatDate(range.startDate)} - ${this.formatDate(range.endDate)}`; + } + + /** + * Format report type + */ + formatReportType(type) { + const types = { + income_statement: 'Income Statement', + expense_summary: 'Expense Summary', + profit_loss: 'P&L', + tax_report: 'Tax Report', + category_breakdown: 'Categories', + monthly_comparison: 'Monthly', + annual_summary: 'Annual' + }; + return types[type] || type; + } + + /** + * Capitalize string + */ + capitalize(str) { + if (!str) return ''; + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); + } + + /** + * Show loading state + */ + showLoading(elementId) { + const el = document.getElementById(elementId); + if (el) { + el.classList.add('loading'); + el.dataset.originalContent = el.innerHTML; + el.innerHTML = '
'; + } + } + + /** + * Hide loading state + */ + hideLoading(elementId) { + const el = document.getElementById(elementId); + if (el) { + el.classList.remove('loading'); + } + } + + /** + * Show notification + */ + showNotification(message, type = 'info') { + const container = document.getElementById('notification-container') || this.createNotificationContainer(); + + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + notification.innerHTML = ` + ${message} + + `; + + container.appendChild(notification); + + setTimeout(() => notification.remove(), 5000); + } + + /** + * Create notification container + */ + createNotificationContainer() { + const container = document.createElement('div'); + container.id = 'notification-container'; + document.body.appendChild(container); + return container; + } +} + +// Initialize on DOM ready +let taxReports; +document.addEventListener('DOMContentLoaded', () => { + taxReports = new TaxReportsManager(); +}); + +// Export for module usage +if (typeof module !== 'undefined' && module.exports) { + module.exports = TaxReportsManager; +} diff --git a/routes/reports.js b/routes/reports.js new file mode 100644 index 0000000..17efae4 --- /dev/null +++ b/routes/reports.js @@ -0,0 +1,283 @@ +const express = require('express'); +const router = express.Router(); +const auth = require('../middleware/auth'); +const rateLimit = require('../middleware/rateLimit'); +const reportService = require('../services/reportService'); +const pdfService = require('../services/pdfService'); +const { validateReportGeneration, validateReportList } = require('../middleware/taxValidator'); + +/** + * @route POST /api/reports/generate + * @desc Generate a financial report + * @access Private + */ +router.post('/generate', auth, validateReportGeneration, async (req, res) => { + try { + const { + reportType, + startDate, + endDate, + currency, + includeForecasts, + workspaceId + } = req.body; + + const report = await reportService.generateReport(req.user.id, reportType, { + startDate, + endDate, + currency, + includeForecasts, + workspaceId + }); + + res.status(201).json({ + success: true, + data: report, + message: 'Report generated successfully' + }); + } catch (error) { + console.error('Report generation error:', error); + res.status(500).json({ + success: false, + error: 'Failed to generate report' + }); + } +}); + +/** + * @route GET /api/reports + * @desc Get user's reports + * @access Private + */ +router.get('/', auth, validateReportList, async (req, res) => { + try { + const { page, limit, reportType, status } = req.query; + + const result = await reportService.getUserReports(req.user.id, { + page: parseInt(page) || 1, + limit: parseInt(limit) || 10, + reportType, + status + }); + + res.json({ + success: true, + data: result.reports, + pagination: result.pagination + }); + } catch (error) { + console.error('Get reports error:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch reports' + }); + } +}); + +/** + * @route GET /api/reports/:id + * @desc Get report by ID + * @access Private + */ +router.get('/:id', auth, async (req, res) => { + try { + const report = await reportService.getReportById(req.params.id, req.user.id); + + res.json({ + success: true, + data: report + }); + } catch (error) { + console.error('Get report error:', error); + if (error.message === 'Report not found') { + return res.status(404).json({ + success: false, + error: 'Report not found' + }); + } + res.status(500).json({ + success: false, + error: 'Failed to fetch report' + }); + } +}); + +/** + * @route GET /api/reports/:id/pdf + * @desc Download report as PDF + * @access Private + */ +router.get('/:id/pdf', auth, async (req, res) => { + try { + const pdfBuffer = await pdfService.generatePDFForReport(req.params.id, req.user.id); + + // Get report for filename + const report = await reportService.getReportById(req.params.id, req.user.id); + const filename = `${report.title.replace(/[^a-zA-Z0-9]/g, '_')}.pdf`; + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Length', pdfBuffer.length); + + res.send(pdfBuffer); + } catch (error) { + console.error('PDF generation error:', error); + if (error.message === 'Report not found or not ready') { + return res.status(404).json({ + success: false, + error: 'Report not found or not ready' + }); + } + res.status(500).json({ + success: false, + error: 'Failed to generate PDF' + }); + } +}); + +/** + * @route DELETE /api/reports/:id + * @desc Delete a report + * @access Private + */ +router.delete('/:id', auth, async (req, res) => { + try { + await reportService.deleteReport(req.params.id, req.user.id); + + res.json({ + success: true, + message: 'Report deleted successfully' + }); + } catch (error) { + console.error('Delete report error:', error); + if (error.message === 'Report not found') { + return res.status(404).json({ + success: false, + error: 'Report not found' + }); + } + res.status(500).json({ + success: false, + error: 'Failed to delete report' + }); + } +}); + +/** + * @route POST /api/reports/quick/:type + * @desc Generate quick report (preset date ranges) + * @access Private + */ +router.post('/quick/:type', auth, async (req, res) => { + try { + const { type } = req.params; + const { reportType = 'expense_summary' } = req.body; + + let startDate, endDate; + const now = new Date(); + + switch (type) { + case 'this-month': + startDate = new Date(now.getFullYear(), now.getMonth(), 1); + endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0); + break; + case 'last-month': + startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1); + endDate = new Date(now.getFullYear(), now.getMonth(), 0); + break; + case 'this-quarter': + const quarter = Math.floor(now.getMonth() / 3); + startDate = new Date(now.getFullYear(), quarter * 3, 1); + endDate = new Date(now.getFullYear(), (quarter + 1) * 3, 0); + break; + case 'this-year': + startDate = new Date(now.getFullYear(), 0, 1); + endDate = new Date(now.getFullYear(), 11, 31); + break; + case 'last-year': + startDate = new Date(now.getFullYear() - 1, 0, 1); + endDate = new Date(now.getFullYear() - 1, 11, 31); + break; + case 'financial-year': + // Indian FY (April to March) + const fyStart = now.getMonth() >= 3 ? now.getFullYear() : now.getFullYear() - 1; + startDate = new Date(fyStart, 3, 1); + endDate = new Date(fyStart + 1, 2, 31); + break; + default: + return res.status(400).json({ + success: false, + error: 'Invalid quick report type. Use: this-month, last-month, this-quarter, this-year, last-year, financial-year' + }); + } + + const report = await reportService.generateReport(req.user.id, reportType, { + startDate, + endDate + }); + + res.status(201).json({ + success: true, + data: report, + message: `${type} report generated successfully` + }); + } catch (error) { + console.error('Quick report error:', error); + res.status(500).json({ + success: false, + error: 'Failed to generate quick report' + }); + } +}); + +/** + * @route GET /api/reports/types/available + * @desc Get available report types + * @access Private + */ +router.get('/types/available', auth, (req, res) => { + const reportTypes = [ + { + type: 'income_statement', + name: 'Income Statement', + description: 'Summary of income and expenses with savings rate' + }, + { + type: 'expense_summary', + name: 'Expense Summary', + description: 'Detailed breakdown of expenses by category' + }, + { + type: 'profit_loss', + name: 'Profit & Loss Statement', + description: 'Traditional P&L format with operating and discretionary expenses' + }, + { + type: 'tax_report', + name: 'Tax Report', + description: 'Tax liability calculation with deductions breakdown' + }, + { + type: 'category_breakdown', + name: 'Category Breakdown', + description: 'Detailed analysis of income and expenses by category' + }, + { + type: 'monthly_comparison', + name: 'Monthly Comparison', + description: 'Month-over-month financial trends and growth rates' + }, + { + type: 'annual_summary', + name: 'Annual Summary', + description: 'Comprehensive yearly financial overview' + } + ]; + + res.json({ + success: true, + data: reportTypes + }); +}); + +module.exports = router; diff --git a/routes/tax.js b/routes/tax.js new file mode 100644 index 0000000..3df90a1 --- /dev/null +++ b/routes/tax.js @@ -0,0 +1,265 @@ +const express = require('express'); +const router = express.Router(); +const auth = require('../middleware/auth'); +const rateLimit = require('../middleware/rateLimit'); +const taxService = require('../services/taxService'); +const { + validateTaxProfile, + validateTaxCalculation, + validateDeductionCategory, + validateExpenseTaxTag +} = require('../middleware/taxValidator'); + +/** + * @route GET /api/tax/profile + * @desc Get user's tax profile for current year + * @access Private + */ +router.get('/profile', auth, async (req, res) => { + try { + const taxYear = parseInt(req.query.taxYear) || new Date().getFullYear(); + const profile = await taxService.getOrCreateProfile(req.user.id, taxYear); + + res.json({ + success: true, + data: profile + }); + } catch (error) { + console.error('Get tax profile error:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch tax profile' + }); + } +}); + +/** + * @route PUT /api/tax/profile + * @desc Update tax profile + * @access Private + */ +router.put('/profile', auth, validateTaxProfile, async (req, res) => { + try { + const taxYear = parseInt(req.query.taxYear) || new Date().getFullYear(); + const profile = await taxService.updateProfile(req.user.id, taxYear, req.body); + + res.json({ + success: true, + data: profile, + message: 'Tax profile updated successfully' + }); + } catch (error) { + console.error('Update tax profile error:', error); + res.status(500).json({ + success: false, + error: 'Failed to update tax profile' + }); + } +}); + +/** + * @route GET /api/tax/calculate + * @desc Calculate tax liability + * @access Private + */ +router.get('/calculate', auth, async (req, res) => { + try { + const taxYear = parseInt(req.query.taxYear) || new Date().getFullYear(); + const options = {}; + + if (req.query.customDeductions) { + options.customDeductions = JSON.parse(req.query.customDeductions); + } + + const calculation = await taxService.calculateTax(req.user.id, taxYear, options); + + res.json({ + success: true, + data: calculation + }); + } catch (error) { + console.error('Tax calculation error:', error); + res.status(500).json({ + success: false, + error: 'Failed to calculate tax' + }); + } +}); + +/** + * @route POST /api/tax/calculate + * @desc Calculate tax with custom deductions + * @access Private + */ +router.post('/calculate', auth, validateTaxCalculation, async (req, res) => { + try { + const { taxYear = new Date().getFullYear(), customDeductions = [] } = req.body; + + const calculation = await taxService.calculateTax(req.user.id, taxYear, { + customDeductions + }); + + res.json({ + success: true, + data: calculation + }); + } catch (error) { + console.error('Tax calculation error:', error); + res.status(500).json({ + success: false, + error: 'Failed to calculate tax' + }); + } +}); + +/** + * @route GET /api/tax/compare-regimes + * @desc Compare old vs new tax regime + * @access Private + */ +router.get('/compare-regimes', auth, async (req, res) => { + try { + const taxYear = parseInt(req.query.taxYear) || new Date().getFullYear(); + const comparison = await taxService.compareRegimes(req.user.id, taxYear); + + res.json({ + success: true, + data: comparison + }); + } catch (error) { + console.error('Regime comparison error:', error); + res.status(500).json({ + success: false, + error: 'Failed to compare tax regimes' + }); + } +}); + +/** + * @route GET /api/tax/summary + * @desc Get tax summary for dashboard + * @access Private + */ +router.get('/summary', auth, async (req, res) => { + try { + const taxYear = parseInt(req.query.taxYear) || new Date().getFullYear(); + const summary = await taxService.getTaxSummary(req.user.id, taxYear); + + res.json({ + success: true, + data: summary + }); + } catch (error) { + console.error('Tax summary error:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch tax summary' + }); + } +}); + +/** + * @route GET /api/tax/deductible-categories + * @desc Get tax-deductible expense categories + * @access Private + */ +router.get('/deductible-categories', auth, async (req, res) => { + try { + const country = req.query.country || 'IN'; + const categories = await taxService.getDeductibleCategories(country); + + res.json({ + success: true, + data: categories + }); + } catch (error) { + console.error('Get deductible categories error:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch deductible categories' + }); + } +}); + +/** + * @route POST /api/tax/auto-tag + * @desc Auto-tag expense as tax-deductible + * @access Private + */ +router.post('/auto-tag', auth, async (req, res) => { + try { + const { description, category, amount } = req.body; + + const expense = { description, category, amount }; + const taxInfo = await taxService.autoTagExpense(expense); + + res.json({ + success: true, + data: taxInfo + }); + } catch (error) { + console.error('Auto-tag expense error:', error); + res.status(500).json({ + success: false, + error: 'Failed to auto-tag expense' + }); + } +}); + +/** + * @route GET /api/tax/deductible-expenses + * @desc Get all tax-deductible expenses for a period + * @access Private + */ +router.get('/deductible-expenses', auth, async (req, res) => { + try { + const taxYear = parseInt(req.query.taxYear) || new Date().getFullYear(); + + // Get date range for tax year (Indian FY: April to March) + const startDate = new Date(taxYear, 3, 1); // April 1 + const endDate = new Date(taxYear + 1, 2, 31); // March 31 + + const expenses = await taxService.getDeductibleExpenses(req.user.id, startDate, endDate); + + res.json({ + success: true, + data: { + taxYear, + period: { startDate, endDate }, + expenses, + totalDeductible: expenses.reduce((sum, e) => sum + e.deductibleAmount, 0) + } + }); + } catch (error) { + console.error('Get deductible expenses error:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch deductible expenses' + }); + } +}); + +/** + * @route POST /api/tax/initialize-categories + * @desc Initialize default tax categories (admin only) + * @access Private + */ +router.post('/initialize-categories', auth, async (req, res) => { + try { + const country = req.body.country || 'IN'; + await taxService.initializeDefaultCategories(country); + + res.json({ + success: true, + message: `Default tax categories initialized for ${country}` + }); + } catch (error) { + console.error('Initialize categories error:', error); + res.status(500).json({ + success: false, + error: 'Failed to initialize tax categories' + }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index d73e1f7..fc4e6b3 100644 --- a/server.js +++ b/server.js @@ -160,6 +160,8 @@ app.use('/api/currency', require('./routes/currency')); app.use('/api/groups', require('./routes/groups')); app.use('/api/splits', require('./routes/splits')); app.use('/api/workspaces', require('./routes/workspaces')); +app.use('/api/tax', require('./routes/tax')); +app.use('/api/reports', require('./routes/reports')); // Root route to serve the UI app.get('/', (req, res) => { diff --git a/services/pdfService.js b/services/pdfService.js new file mode 100644 index 0000000..09ff33b --- /dev/null +++ b/services/pdfService.js @@ -0,0 +1,603 @@ +const PDFDocument = require('pdfkit'); +const FinancialReport = require('../models/FinancialReport'); + +class PDFService { + /** + * Generate PDF from report + */ + async generatePDF(report) { + return new Promise((resolve, reject) => { + try { + const doc = new PDFDocument({ + size: 'A4', + margins: { top: 50, bottom: 50, left: 50, right: 50 }, + info: { + Title: report.title, + Author: 'ExpenseFlow', + Subject: 'Financial Report', + Creator: 'ExpenseFlow Report Generator' + } + }); + + const chunks = []; + doc.on('data', chunk => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + // Generate content based on report type + this.addHeader(doc, report); + + switch (report.reportType) { + case 'income_statement': + this.addIncomeStatement(doc, report); + break; + case 'expense_summary': + this.addExpenseSummary(doc, report); + break; + case 'profit_loss': + this.addProfitLoss(doc, report); + break; + case 'tax_report': + this.addTaxReport(doc, report); + break; + case 'category_breakdown': + this.addCategoryBreakdown(doc, report); + break; + case 'monthly_comparison': + this.addMonthlyComparison(doc, report); + break; + case 'annual_summary': + this.addAnnualSummary(doc, report); + break; + default: + this.addGenericReport(doc, report); + } + + this.addFooter(doc); + doc.end(); + } catch (error) { + reject(error); + } + }); + } + + /** + * Add header to PDF + */ + addHeader(doc, report) { + // Logo/Brand area + doc.fontSize(24) + .fillColor('#2563eb') + .text('ExpenseFlow', { align: 'center' }); + + doc.moveDown(0.5); + + // Report title + doc.fontSize(18) + .fillColor('#1f2937') + .text(report.title, { align: 'center' }); + + // Date range + const startDate = new Date(report.dateRange.startDate).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + const endDate = new Date(report.dateRange.endDate).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + + doc.fontSize(10) + .fillColor('#6b7280') + .text(`Period: ${startDate} - ${endDate}`, { align: 'center' }); + + doc.moveDown(); + this.addDivider(doc); + doc.moveDown(); + } + + /** + * Add footer to PDF + */ + addFooter(doc) { + const pages = doc.bufferedPageRange(); + for (let i = 0; i < pages.count; i++) { + doc.switchToPage(i); + + // Footer line + doc.strokeColor('#e5e7eb') + .lineWidth(1) + .moveTo(50, doc.page.height - 50) + .lineTo(doc.page.width - 50, doc.page.height - 50) + .stroke(); + + // Page number + doc.fontSize(8) + .fillColor('#9ca3af') + .text( + `Page ${i + 1} of ${pages.count}`, + 50, + doc.page.height - 35, + { align: 'center' } + ); + + // Generated timestamp + doc.text( + `Generated on ${new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} by ExpenseFlow`, + 50, + doc.page.height - 35, + { align: 'right' } + ); + } + } + + /** + * Add income statement content + */ + addIncomeStatement(doc, report) { + // Summary section + this.addSectionTitle(doc, 'Financial Summary'); + + const summaryData = [ + ['Gross Income', this.formatCurrency(report.totalIncome, report.currency)], + ['Total Expenses', this.formatCurrency(report.totalExpenses, report.currency)], + ['Net Income', this.formatCurrency(report.netIncome, report.currency)], + ['Savings Rate', `${report.savingsRate}%`] + ]; + + this.addTable(doc, summaryData); + doc.moveDown(); + + // Income breakdown + if (report.incomeBreakdown && report.incomeBreakdown.length > 0) { + this.addSectionTitle(doc, 'Income Breakdown'); + const incomeData = report.incomeBreakdown.map(i => [ + this.capitalize(i.category), + this.formatCurrency(i.amount, report.currency), + `${i.count} transactions` + ]); + this.addTable(doc, incomeData, ['Category', 'Amount', 'Transactions']); + doc.moveDown(); + } + + // Expense breakdown + if (report.expenseBreakdown && report.expenseBreakdown.length > 0) { + this.addSectionTitle(doc, 'Expense Breakdown'); + const expenseData = report.expenseBreakdown.map(e => [ + this.capitalize(e.category), + this.formatCurrency(e.amount, report.currency), + `${e.count} transactions` + ]); + this.addTable(doc, expenseData, ['Category', 'Amount', 'Transactions']); + } + } + + /** + * Add expense summary content + */ + addExpenseSummary(doc, report) { + // Summary stats + this.addSectionTitle(doc, 'Expense Overview'); + + const summaryData = [ + ['Total Expenses', this.formatCurrency(report.totalExpenses, report.currency)], + ['Average Monthly', this.formatCurrency(report.averageMonthlyExpense || 0, report.currency)] + ]; + + this.addTable(doc, summaryData); + doc.moveDown(); + + // Category breakdown + if (report.expenseBreakdown && report.expenseBreakdown.length > 0) { + this.addSectionTitle(doc, 'Expense by Category'); + const categoryData = report.expenseBreakdown.map(e => [ + this.capitalize(e.category), + this.formatCurrency(e.amount, report.currency), + `${e.percentage || 0}%`, + `${e.count} txns` + ]); + this.addTable(doc, categoryData, ['Category', 'Amount', '% of Total', 'Count']); + doc.moveDown(); + } + + // Top expenses + if (report.topExpenses && report.topExpenses.length > 0) { + this.addSectionTitle(doc, 'Top 10 Expenses'); + const topData = report.topExpenses.map(e => [ + e.description.substring(0, 30), + this.capitalize(e.category), + this.formatCurrency(e.amount, report.currency) + ]); + this.addTable(doc, topData, ['Description', 'Category', 'Amount']); + } + } + + /** + * Add profit/loss content + */ + addProfitLoss(doc, report) { + // P&L Summary + this.addSectionTitle(doc, 'Profit & Loss Statement'); + + doc.fontSize(12) + .fillColor('#1f2937'); + + // Revenue section + doc.font('Helvetica-Bold').text('REVENUE'); + doc.font('Helvetica'); + + if (report.incomeBreakdown) { + for (const income of report.incomeBreakdown) { + doc.text(` ${this.capitalize(income.category)}`, { continued: true }) + .text(this.formatCurrency(income.amount, report.currency), { align: 'right' }); + } + } + + doc.moveDown(0.5); + doc.font('Helvetica-Bold') + .text('Total Revenue', { continued: true }) + .text(this.formatCurrency(report.totalIncome, report.currency), { align: 'right' }); + + doc.moveDown(); + this.addDivider(doc); + doc.moveDown(); + + // Expenses section + doc.font('Helvetica-Bold').text('EXPENSES'); + doc.font('Helvetica'); + + if (report.summary) { + if (report.summary.operatingExpenses > 0) { + doc.text(' Operating Expenses', { continued: true }) + .text(this.formatCurrency(report.summary.operatingExpenses, report.currency), { align: 'right' }); + } + if (report.summary.discretionaryExpenses > 0) { + doc.text(' Discretionary Expenses', { continued: true }) + .text(this.formatCurrency(report.summary.discretionaryExpenses, report.currency), { align: 'right' }); + } + if (report.summary.essentialExpenses > 0) { + doc.text(' Essential Expenses', { continued: true }) + .text(this.formatCurrency(report.summary.essentialExpenses, report.currency), { align: 'right' }); + } + if (report.summary.otherExpenses > 0) { + doc.text(' Other Expenses', { continued: true }) + .text(this.formatCurrency(report.summary.otherExpenses, report.currency), { align: 'right' }); + } + } + + doc.moveDown(0.5); + doc.font('Helvetica-Bold') + .text('Total Expenses', { continued: true }) + .text(this.formatCurrency(report.totalExpenses, report.currency), { align: 'right' }); + + doc.moveDown(); + this.addDivider(doc, '#2563eb', 2); + doc.moveDown(); + + // Net Income + const netColor = report.netIncome >= 0 ? '#059669' : '#dc2626'; + doc.font('Helvetica-Bold') + .fontSize(14) + .fillColor(netColor) + .text('NET INCOME', { continued: true }) + .text(this.formatCurrency(report.netIncome, report.currency), { align: 'right' }); + + doc.fillColor('#1f2937'); + + if (report.summary?.profitMargin) { + doc.moveDown() + .fontSize(10) + .fillColor('#6b7280') + .text(`Profit Margin: ${report.summary.profitMargin}%`, { align: 'right' }); + } + } + + /** + * Add tax report content + */ + addTaxReport(doc, report) { + this.addSectionTitle(doc, 'Tax Summary'); + + if (report.taxSummary) { + const taxData = [ + ['Gross Income', this.formatCurrency(report.taxSummary.grossIncome, report.currency)], + ['Total Deductions', this.formatCurrency(report.taxSummary.totalDeductions, report.currency)], + ['Taxable Income', this.formatCurrency(report.taxSummary.taxableIncome, report.currency)], + ['Tax Liability', this.formatCurrency(report.taxSummary.taxLiability, report.currency)], + ['Effective Tax Rate', `${report.taxSummary.effectiveRate}%`], + ['Tax Regime', this.capitalize(report.taxSummary.regime)] + ]; + + this.addTable(doc, taxData); + doc.moveDown(); + } + + // Tax deductions + if (report.taxDeductions && report.taxDeductions.length > 0) { + this.addSectionTitle(doc, 'Tax Deductions'); + const deductionData = report.taxDeductions.map(d => [ + this.capitalize(d.category), + d.section || 'N/A', + this.formatCurrency(d.amount, report.currency), + this.formatCurrency(d.deductible, report.currency) + ]); + this.addTable(doc, deductionData, ['Category', 'Section', 'Total', 'Deductible']); + } + + // Disclaimer + doc.moveDown(2); + doc.fontSize(8) + .fillColor('#6b7280') + .text('Disclaimer: This report is for informational purposes only and should not be considered as professional tax advice. Please consult a qualified tax professional for accurate tax planning and filing.', { + align: 'justify' + }); + } + + /** + * Add category breakdown content + */ + addCategoryBreakdown(doc, report) { + // Income categories + if (report.incomeBreakdown && report.incomeBreakdown.length > 0) { + this.addSectionTitle(doc, 'Income by Category'); + const incomeData = report.incomeBreakdown.map(i => [ + this.capitalize(i.category), + this.formatCurrency(i.amount, report.currency), + `${i.percentage || 0}%` + ]); + this.addTable(doc, incomeData, ['Category', 'Amount', '% of Total']); + doc.moveDown(); + } + + // Expense categories + if (report.expenseBreakdown && report.expenseBreakdown.length > 0) { + this.addSectionTitle(doc, 'Expenses by Category'); + const expenseData = report.expenseBreakdown.map(e => [ + this.capitalize(e.category), + this.formatCurrency(e.amount, report.currency), + `${e.percentage || 0}%`, + `Avg: ${this.formatCurrency(e.avgAmount || 0, report.currency)}` + ]); + this.addTable(doc, expenseData, ['Category', 'Amount', '% of Total', 'Average']); + } + + // Summary + doc.moveDown(); + this.addDivider(doc); + doc.moveDown(); + + doc.font('Helvetica-Bold') + .text('Total Income: ', { continued: true }) + .font('Helvetica') + .text(this.formatCurrency(report.totalIncome, report.currency)); + + doc.font('Helvetica-Bold') + .text('Total Expenses: ', { continued: true }) + .font('Helvetica') + .text(this.formatCurrency(report.totalExpenses, report.currency)); + + const netColor = report.netIncome >= 0 ? '#059669' : '#dc2626'; + doc.font('Helvetica-Bold') + .fillColor(netColor) + .text('Net Income: ', { continued: true }) + .font('Helvetica') + .text(this.formatCurrency(report.netIncome, report.currency)); + } + + /** + * Add monthly comparison content + */ + addMonthlyComparison(doc, report) { + this.addSectionTitle(doc, 'Monthly Financial Overview'); + + if (report.monthlyTrends && report.monthlyTrends.length > 0) { + const monthlyData = report.monthlyTrends.map(m => [ + m.month, + this.formatCurrency(m.income, report.currency), + this.formatCurrency(m.expenses, report.currency), + this.formatCurrency(m.netSavings, report.currency) + ]); + this.addTable(doc, monthlyData, ['Month', 'Income', 'Expenses', 'Net Savings']); + } + + doc.moveDown(); + + // Summary stats + this.addSectionTitle(doc, 'Period Summary'); + const summaryData = [ + ['Total Income', this.formatCurrency(report.totalIncome, report.currency)], + ['Total Expenses', this.formatCurrency(report.totalExpenses, report.currency)], + ['Net Income', this.formatCurrency(report.netIncome, report.currency)], + ['Avg Monthly Income', this.formatCurrency(report.averageMonthlyIncome || 0, report.currency)], + ['Avg Monthly Expense', this.formatCurrency(report.averageMonthlyExpense || 0, report.currency)] + ]; + this.addTable(doc, summaryData); + } + + /** + * Add annual summary content + */ + addAnnualSummary(doc, report) { + // Key metrics + this.addSectionTitle(doc, 'Annual Financial Summary'); + + const summaryData = [ + ['Total Income', this.formatCurrency(report.totalIncome, report.currency)], + ['Total Expenses', this.formatCurrency(report.totalExpenses, report.currency)], + ['Net Savings', this.formatCurrency(report.netIncome, report.currency)], + ['Savings Rate', `${report.savingsRate}%`] + ]; + this.addTable(doc, summaryData); + doc.moveDown(); + + // Monthly averages + if (report.summary) { + this.addSectionTitle(doc, 'Monthly Averages'); + const avgData = [ + ['Average Monthly Income', this.formatCurrency(report.summary.averageMonthlyIncome || 0, report.currency)], + ['Average Monthly Expense', this.formatCurrency(report.summary.averageMonthlyExpense || 0, report.currency)], + ['Highest Expense Month', `${report.summary.highestExpenseMonth || 'N/A'} (${this.formatCurrency(report.summary.highestExpenseAmount || 0, report.currency)})`], + ['Lowest Expense Month', `${report.summary.lowestExpenseMonth || 'N/A'} (${this.formatCurrency(report.summary.lowestExpenseAmount || 0, report.currency)})`] + ]; + this.addTable(doc, avgData); + doc.moveDown(); + } + + // Category breakdown + if (report.expenseBreakdown && report.expenseBreakdown.length > 0) { + this.addSectionTitle(doc, 'Top Expense Categories'); + const top5 = report.expenseBreakdown.slice(0, 5); + const categoryData = top5.map(e => [ + this.capitalize(e.category), + this.formatCurrency(e.amount, report.currency), + `${e.percentage || 0}%` + ]); + this.addTable(doc, categoryData, ['Category', 'Amount', '% of Total']); + } + } + + /** + * Add generic report content + */ + addGenericReport(doc, report) { + doc.fontSize(12) + .text(`Report Type: ${report.reportType}`); + doc.moveDown(); + + if (report.totalIncome !== undefined) { + doc.text(`Total Income: ${this.formatCurrency(report.totalIncome, report.currency)}`); + } + if (report.totalExpenses !== undefined) { + doc.text(`Total Expenses: ${this.formatCurrency(report.totalExpenses, report.currency)}`); + } + if (report.netIncome !== undefined) { + doc.text(`Net Income: ${this.formatCurrency(report.netIncome, report.currency)}`); + } + } + + /** + * Helper: Add section title + */ + addSectionTitle(doc, title) { + doc.fontSize(14) + .font('Helvetica-Bold') + .fillColor('#1f2937') + .text(title); + doc.moveDown(0.5); + doc.font('Helvetica'); + } + + /** + * Helper: Add divider line + */ + addDivider(doc, color = '#e5e7eb', width = 1) { + doc.strokeColor(color) + .lineWidth(width) + .moveTo(50, doc.y) + .lineTo(doc.page.width - 50, doc.y) + .stroke(); + } + + /** + * Helper: Add table + */ + addTable(doc, rows, headers = null) { + const startX = 50; + const colWidth = (doc.page.width - 100) / (headers ? headers.length : rows[0]?.length || 2); + let y = doc.y; + + // Headers + if (headers) { + doc.font('Helvetica-Bold') + .fontSize(10) + .fillColor('#4b5563'); + + headers.forEach((header, i) => { + doc.text(header, startX + (i * colWidth), y, { + width: colWidth, + align: i === 0 ? 'left' : 'right' + }); + }); + + y = doc.y + 5; + doc.strokeColor('#e5e7eb') + .lineWidth(0.5) + .moveTo(startX, y) + .lineTo(doc.page.width - 50, y) + .stroke(); + y += 10; + } + + // Rows + doc.font('Helvetica') + .fontSize(10) + .fillColor('#1f2937'); + + for (const row of rows) { + const rowY = y; + row.forEach((cell, i) => { + doc.text(String(cell), startX + (i * colWidth), rowY, { + width: colWidth, + align: i === 0 ? 'left' : 'right' + }); + }); + y = doc.y + 5; + + // Check for page break + if (y > doc.page.height - 100) { + doc.addPage(); + y = 50; + } + } + + doc.y = y; + } + + /** + * Helper: Format currency + */ + formatCurrency(amount, currency = 'INR') { + const symbols = { INR: '₹', USD: '$', EUR: '€', GBP: '£' }; + const symbol = symbols[currency] || currency; + const formatted = Math.abs(amount).toLocaleString('en-IN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + return amount < 0 ? `-${symbol}${formatted}` : `${symbol}${formatted}`; + } + + /** + * Helper: Capitalize string + */ + capitalize(str) { + if (!str) return ''; + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); + } + + /** + * Generate PDF for report ID + */ + async generatePDFForReport(reportId, userId) { + const report = await FinancialReport.findOne({ + _id: reportId, + user: userId, + status: 'ready' + }); + + if (!report) { + throw new Error('Report not found or not ready'); + } + + return this.generatePDF(report); + } +} + +module.exports = new PDFService(); diff --git a/services/reportService.js b/services/reportService.js new file mode 100644 index 0000000..d08541d --- /dev/null +++ b/services/reportService.js @@ -0,0 +1,602 @@ +const FinancialReport = require('../models/FinancialReport'); +const Expense = require('../models/Expense'); +const TaxProfile = require('../models/TaxProfile'); +const taxService = require('./taxService'); +const mongoose = require('mongoose'); + +class ReportService { + /** + * Generate financial report + */ + async generateReport(userId, reportType, options = {}) { + const { + startDate = new Date(new Date().getFullYear(), 0, 1), + endDate = new Date(), + currency = 'INR', + includeForecasts = false, + workspaceId = null + } = options; + + const start = new Date(startDate); + const end = new Date(endDate); + + // Check for existing report + const existingReport = await FinancialReport.findOne({ + user: userId, + reportType, + 'dateRange.startDate': start, + 'dateRange.endDate': end, + status: { $in: ['ready', 'processing'] } + }); + + if (existingReport && existingReport.status === 'ready') { + return existingReport; + } + + // Create new report + const report = new FinancialReport({ + user: userId, + reportType, + title: this.generateTitle(reportType, start, end), + dateRange: { startDate: start, endDate: end }, + currency, + workspace: workspaceId, + status: 'processing' + }); + + await report.save(); + + try { + // Generate report data based on type + let reportData; + switch (reportType) { + case 'income_statement': + reportData = await this.generateIncomeStatement(userId, start, end); + break; + case 'expense_summary': + reportData = await this.generateExpenseSummary(userId, start, end); + break; + case 'profit_loss': + reportData = await this.generateProfitLoss(userId, start, end); + break; + case 'tax_report': + reportData = await this.generateTaxReport(userId, start, end); + break; + case 'category_breakdown': + reportData = await this.generateCategoryBreakdown(userId, start, end); + break; + case 'monthly_comparison': + reportData = await this.generateMonthlyComparison(userId, start, end); + break; + case 'annual_summary': + reportData = await this.generateAnnualSummary(userId, start, end); + break; + default: + throw new Error(`Unknown report type: ${reportType}`); + } + + // Update report with data + Object.assign(report, reportData); + report.status = 'ready'; + report.generatedAt = new Date(); + + await report.save(); + return report; + } catch (error) { + report.status = 'failed'; + report.error = error.message; + await report.save(); + throw error; + } + } + + /** + * Generate income statement + */ + async generateIncomeStatement(userId, startDate, endDate) { + const [income, expenses] = await Promise.all([ + Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + type: 'income', + date: { $gte: startDate, $lte: endDate } + } + }, + { + $group: { + _id: '$category', + total: { $sum: '$amount' }, + count: { $sum: 1 } + } + } + ]), + Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + type: 'expense', + date: { $gte: startDate, $lte: endDate } + } + }, + { + $group: { + _id: '$category', + total: { $sum: '$amount' }, + count: { $sum: 1 } + } + } + ]) + ]); + + const totalIncome = income.reduce((sum, i) => sum + i.total, 0); + const totalExpenses = expenses.reduce((sum, e) => sum + e.total, 0); + + return { + incomeBreakdown: income.map(i => ({ + category: i._id || 'Other', + amount: i.total, + count: i.count + })), + expenseBreakdown: expenses.map(e => ({ + category: e._id || 'Other', + amount: e.total, + count: e.count + })), + totalIncome, + totalExpenses, + netIncome: totalIncome - totalExpenses, + savingsRate: totalIncome > 0 ? ((totalIncome - totalExpenses) / totalIncome * 100).toFixed(2) : 0, + summary: { + grossIncome: totalIncome, + operatingExpenses: totalExpenses, + netProfit: totalIncome - totalExpenses, + profitMargin: totalIncome > 0 ? ((totalIncome - totalExpenses) / totalIncome * 100).toFixed(2) : 0 + } + }; + } + + /** + * Generate expense summary + */ + async generateExpenseSummary(userId, startDate, endDate) { + const [categoryBreakdown, monthlyTrends, topExpenses] = await Promise.all([ + Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + type: 'expense', + date: { $gte: startDate, $lte: endDate } + } + }, + { + $group: { + _id: '$category', + total: { $sum: '$amount' }, + count: { $sum: 1 }, + avgAmount: { $avg: '$amount' } + } + }, + { $sort: { total: -1 } } + ]), + Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + type: 'expense', + date: { $gte: startDate, $lte: endDate } + } + }, + { + $group: { + _id: { + year: { $year: '$date' }, + month: { $month: '$date' } + }, + total: { $sum: '$amount' }, + count: { $sum: 1 } + } + }, + { $sort: { '_id.year': 1, '_id.month': 1 } } + ]), + Expense.find({ + user: userId, + type: 'expense', + date: { $gte: startDate, $lte: endDate } + }) + .sort({ amount: -1 }) + .limit(10) + .select('description amount category date') + ]); + + const totalExpenses = categoryBreakdown.reduce((sum, c) => sum + c.total, 0); + + return { + expenseBreakdown: categoryBreakdown.map(c => ({ + category: c._id || 'Other', + amount: c.total, + count: c.count, + avgAmount: Math.round(c.avgAmount), + percentage: totalExpenses > 0 ? ((c.total / totalExpenses) * 100).toFixed(2) : 0 + })), + totalExpenses, + monthlyTrends: monthlyTrends.map(m => ({ + month: `${m._id.year}-${String(m._id.month).padStart(2, '0')}`, + income: 0, + expenses: m.total, + netSavings: -m.total, + transactionCount: m.count + })), + topExpenses: topExpenses.map(e => ({ + description: e.description, + amount: e.amount, + category: e.category, + date: e.date + })), + averageMonthlyExpense: monthlyTrends.length > 0 + ? totalExpenses / monthlyTrends.length + : 0 + }; + } + + /** + * Generate profit/loss statement + */ + async generateProfitLoss(userId, startDate, endDate) { + const data = await Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + date: { $gte: startDate, $lte: endDate } + } + }, + { + $group: { + _id: { + type: '$type', + category: '$category' + }, + total: { $sum: '$amount' }, + count: { $sum: 1 } + } + } + ]); + + const income = data.filter(d => d._id.type === 'income'); + const expenses = data.filter(d => d._id.type === 'expense'); + + const totalIncome = income.reduce((sum, i) => sum + i.total, 0); + const totalExpenses = expenses.reduce((sum, e) => sum + e.total, 0); + const netIncome = totalIncome - totalExpenses; + + // Categorize expenses + const operatingExpenses = expenses + .filter(e => ['utilities', 'transport', 'food'].includes(e._id.category)) + .reduce((sum, e) => sum + e.total, 0); + + const discretionaryExpenses = expenses + .filter(e => ['shopping', 'entertainment'].includes(e._id.category)) + .reduce((sum, e) => sum + e.total, 0); + + const essentialExpenses = expenses + .filter(e => ['healthcare'].includes(e._id.category)) + .reduce((sum, e) => sum + e.total, 0); + + return { + incomeBreakdown: income.map(i => ({ + category: i._id.category || 'Other', + amount: i.total, + count: i.count + })), + expenseBreakdown: expenses.map(e => ({ + category: e._id.category || 'Other', + amount: e.total, + count: e.count + })), + totalIncome, + totalExpenses, + netIncome, + summary: { + grossIncome: totalIncome, + operatingExpenses, + discretionaryExpenses, + essentialExpenses, + otherExpenses: totalExpenses - operatingExpenses - discretionaryExpenses - essentialExpenses, + netProfit: netIncome, + profitMargin: totalIncome > 0 ? ((netIncome / totalIncome) * 100).toFixed(2) : 0 + } + }; + } + + /** + * Generate tax report + */ + async generateTaxReport(userId, startDate, endDate) { + const taxYear = startDate.getFullYear(); + + // Get tax calculation + const taxCalc = await taxService.calculateTax(userId, taxYear); + + // Get deductible expenses + const deductibleExpenses = await taxService.getDeductibleExpenses(userId, startDate, endDate); + + return { + totalIncome: taxCalc.grossIncome, + totalExpenses: 0, // Will be filled from expense data + netIncome: taxCalc.taxableIncome, + taxDeductions: deductibleExpenses.map(d => ({ + category: d.category, + section: d.section, + amount: d.totalAmount, + deductible: d.deductibleAmount + })), + taxSummary: { + grossIncome: taxCalc.grossIncome, + totalDeductions: taxCalc.totalDeductions, + taxableIncome: taxCalc.taxableIncome, + taxLiability: taxCalc.totalTax, + effectiveRate: taxCalc.effectiveRate, + regime: taxCalc.regime + } + }; + } + + /** + * Generate category breakdown + */ + async generateCategoryBreakdown(userId, startDate, endDate) { + const breakdown = await Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + date: { $gte: startDate, $lte: endDate } + } + }, + { + $group: { + _id: { + type: '$type', + category: '$category' + }, + total: { $sum: '$amount' }, + count: { $sum: 1 }, + avgAmount: { $avg: '$amount' }, + minAmount: { $min: '$amount' }, + maxAmount: { $max: '$amount' } + } + }, + { $sort: { total: -1 } } + ]); + + const incomeData = breakdown.filter(b => b._id.type === 'income'); + const expenseData = breakdown.filter(b => b._id.type === 'expense'); + + const totalIncome = incomeData.reduce((sum, i) => sum + i.total, 0); + const totalExpenses = expenseData.reduce((sum, e) => sum + e.total, 0); + + return { + incomeBreakdown: incomeData.map(i => ({ + category: i._id.category || 'Other', + amount: i.total, + count: i.count, + percentage: totalIncome > 0 ? ((i.total / totalIncome) * 100).toFixed(2) : 0 + })), + expenseBreakdown: expenseData.map(e => ({ + category: e._id.category || 'Other', + amount: e.total, + count: e.count, + avgAmount: Math.round(e.avgAmount), + minAmount: e.minAmount, + maxAmount: e.maxAmount, + percentage: totalExpenses > 0 ? ((e.total / totalExpenses) * 100).toFixed(2) : 0 + })), + totalIncome, + totalExpenses, + netIncome: totalIncome - totalExpenses + }; + } + + /** + * Generate monthly comparison + */ + async generateMonthlyComparison(userId, startDate, endDate) { + const monthlyData = await Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + date: { $gte: startDate, $lte: endDate } + } + }, + { + $group: { + _id: { + year: { $year: '$date' }, + month: { $month: '$date' }, + type: '$type' + }, + total: { $sum: '$amount' }, + count: { $sum: 1 } + } + }, + { $sort: { '_id.year': 1, '_id.month': 1 } } + ]); + + // Transform into monthly trends + const monthlyMap = new Map(); + + for (const data of monthlyData) { + const key = `${data._id.year}-${String(data._id.month).padStart(2, '0')}`; + if (!monthlyMap.has(key)) { + monthlyMap.set(key, { + month: key, + income: 0, + expenses: 0, + netSavings: 0, + transactionCount: 0 + }); + } + + const entry = monthlyMap.get(key); + if (data._id.type === 'income') { + entry.income = data.total; + } else { + entry.expenses = data.total; + } + entry.transactionCount += data.count; + entry.netSavings = entry.income - entry.expenses; + } + + const monthlyTrends = Array.from(monthlyMap.values()); + + const totalIncome = monthlyTrends.reduce((sum, m) => sum + m.income, 0); + const totalExpenses = monthlyTrends.reduce((sum, m) => sum + m.expenses, 0); + + // Calculate growth rates + for (let i = 1; i < monthlyTrends.length; i++) { + const prev = monthlyTrends[i - 1]; + const curr = monthlyTrends[i]; + + curr.incomeGrowth = prev.income > 0 + ? ((curr.income - prev.income) / prev.income * 100).toFixed(2) + : 0; + curr.expenseGrowth = prev.expenses > 0 + ? ((curr.expenses - prev.expenses) / prev.expenses * 100).toFixed(2) + : 0; + } + + return { + monthlyTrends, + totalIncome, + totalExpenses, + netIncome: totalIncome - totalExpenses, + averageMonthlyIncome: monthlyTrends.length > 0 ? totalIncome / monthlyTrends.length : 0, + averageMonthlyExpense: monthlyTrends.length > 0 ? totalExpenses / monthlyTrends.length : 0 + }; + } + + /** + * Generate annual summary + */ + async generateAnnualSummary(userId, startDate, endDate) { + const [incomeStatement, categoryBreakdown, monthlyComp] = await Promise.all([ + this.generateIncomeStatement(userId, startDate, endDate), + this.generateCategoryBreakdown(userId, startDate, endDate), + this.generateMonthlyComparison(userId, startDate, endDate) + ]); + + // Find highest and lowest months + const months = monthlyComp.monthlyTrends; + const highestExpenseMonth = months.reduce((max, m) => m.expenses > max.expenses ? m : max, months[0] || { expenses: 0 }); + const lowestExpenseMonth = months.reduce((min, m) => m.expenses < min.expenses ? m : min, months[0] || { expenses: 0 }); + + return { + totalIncome: incomeStatement.totalIncome, + totalExpenses: incomeStatement.totalExpenses, + netIncome: incomeStatement.netIncome, + savingsRate: incomeStatement.savingsRate, + incomeBreakdown: incomeStatement.incomeBreakdown, + expenseBreakdown: categoryBreakdown.expenseBreakdown, + monthlyTrends: monthlyComp.monthlyTrends, + summary: { + ...incomeStatement.summary, + averageMonthlyIncome: monthlyComp.averageMonthlyIncome, + averageMonthlyExpense: monthlyComp.averageMonthlyExpense, + highestExpenseMonth: highestExpenseMonth?.month, + highestExpenseAmount: highestExpenseMonth?.expenses || 0, + lowestExpenseMonth: lowestExpenseMonth?.month, + lowestExpenseAmount: lowestExpenseMonth?.expenses || 0 + } + }; + } + + /** + * Get user's reports + */ + async getUserReports(userId, options = {}) { + const { + page = 1, + limit = 10, + reportType, + status = 'ready' + } = options; + + const query = { user: userId }; + if (reportType) query.reportType = reportType; + if (status) query.status = status; + + const [reports, total] = await Promise.all([ + FinancialReport.find(query) + .sort({ generatedAt: -1 }) + .skip((page - 1) * limit) + .limit(limit) + .lean(), + FinancialReport.countDocuments(query) + ]); + + return { + reports, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit) + } + }; + } + + /** + * Get report by ID + */ + async getReportById(reportId, userId) { + const report = await FinancialReport.findOne({ + _id: reportId, + user: userId + }); + + if (!report) { + throw new Error('Report not found'); + } + + return report; + } + + /** + * Delete report + */ + async deleteReport(reportId, userId) { + const result = await FinancialReport.deleteOne({ + _id: reportId, + user: userId + }); + + if (result.deletedCount === 0) { + throw new Error('Report not found'); + } + + return { success: true }; + } + + /** + * Generate report title + */ + generateTitle(reportType, startDate, endDate) { + const typeNames = { + income_statement: 'Income Statement', + expense_summary: 'Expense Summary', + profit_loss: 'Profit & Loss Statement', + tax_report: 'Tax Report', + category_breakdown: 'Category Breakdown', + monthly_comparison: 'Monthly Comparison', + annual_summary: 'Annual Summary' + }; + + const formatDate = (date) => date.toLocaleDateString('en-US', { + month: 'short', + year: 'numeric' + }); + + return `${typeNames[reportType] || reportType} - ${formatDate(startDate)} to ${formatDate(endDate)}`; + } +} + +module.exports = new ReportService(); diff --git a/services/taxService.js b/services/taxService.js new file mode 100644 index 0000000..e371022 --- /dev/null +++ b/services/taxService.js @@ -0,0 +1,426 @@ +const TaxProfile = require('../models/TaxProfile'); +const TaxCategory = require('../models/TaxCategory'); +const Expense = require('../models/Expense'); +const mongoose = require('mongoose'); + +class TaxService { + /** + * Get or create tax profile for user + */ + async getOrCreateProfile(userId, taxYear = new Date().getFullYear()) { + let profile = await TaxProfile.findOne({ user: userId, taxYear }); + + if (!profile) { + const defaultBrackets = TaxProfile.getDefaultBrackets('IN', 'new'); + const defaultDeductions = TaxProfile.getDefaultDeductions('IN'); + + profile = new TaxProfile({ + user: userId, + taxYear, + country: 'IN', + regime: 'new', + taxBrackets: defaultBrackets, + availableDeductions: defaultDeductions + }); + + await profile.save(); + } + + return profile; + } + + /** + * Update tax profile + */ + async updateProfile(userId, taxYear, updates) { + const profile = await this.getOrCreateProfile(userId, taxYear); + + // If country or regime changes, update brackets and deductions + if (updates.country && updates.country !== profile.country) { + updates.taxBrackets = TaxProfile.getDefaultBrackets(updates.country, updates.regime || 'new'); + updates.availableDeductions = TaxProfile.getDefaultDeductions(updates.country); + } else if (updates.regime && updates.regime !== profile.regime) { + updates.taxBrackets = TaxProfile.getDefaultBrackets(profile.country, updates.regime); + } + + Object.assign(profile, updates); + await profile.save(); + + return profile; + } + + /** + * Calculate tax liability + */ + async calculateTax(userId, taxYear = new Date().getFullYear(), options = {}) { + const profile = await this.getOrCreateProfile(userId, taxYear); + + // Get date range for tax year + const startDate = new Date(taxYear, 3, 1); // April 1 (Indian FY) + const endDate = new Date(taxYear + 1, 2, 31); // March 31 + + // Get all income and expenses for the year + const [incomeData, expenseData] = await Promise.all([ + Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + type: 'income', + date: { $gte: startDate, $lte: endDate } + } + }, + { + $group: { + _id: null, + totalIncome: { $sum: '$amount' }, + count: { $sum: 1 } + } + } + ]), + this.getDeductibleExpenses(userId, startDate, endDate) + ]); + + const grossIncome = incomeData[0]?.totalIncome || 0; + + // Calculate deductions + const deductions = this.calculateDeductions(profile, expenseData, options.customDeductions || []); + + // Calculate taxable income + let taxableIncome = grossIncome - profile.standardDeduction - deductions.total; + taxableIncome = Math.max(0, taxableIncome); + + // Calculate tax using brackets + const taxCalculation = this.calculateTaxFromBrackets(taxableIncome, profile.taxBrackets); + + // Add surcharge and cess + const surcharge = this.calculateSurcharge(taxCalculation.tax, taxableIncome, profile.country); + const cess = (taxCalculation.tax + surcharge) * 0.04; // 4% Health and Education Cess + + const totalTax = taxCalculation.tax + surcharge + cess; + + // Calculate effective tax rate + const effectiveRate = grossIncome > 0 ? (totalTax / grossIncome) * 100 : 0; + + // Calculate tax payable after TDS and advance tax + const taxPayable = Math.max(0, totalTax - profile.tdsDeducted - profile.advanceTaxPaid); + + return { + taxYear, + country: profile.country, + regime: profile.regime, + grossIncome, + standardDeduction: profile.standardDeduction, + deductions: deductions.breakdown, + totalDeductions: deductions.total + profile.standardDeduction, + taxableIncome, + taxCalculation: taxCalculation.breakdown, + baseTax: taxCalculation.tax, + surcharge, + cess, + totalTax, + effectiveRate: Math.round(effectiveRate * 100) / 100, + tdsDeducted: profile.tdsDeducted, + advanceTaxPaid: profile.advanceTaxPaid, + taxPayable, + taxRefund: taxPayable < 0 ? Math.abs(taxPayable) : 0 + }; + } + + /** + * Calculate tax from brackets + */ + calculateTaxFromBrackets(taxableIncome, brackets) { + let remainingIncome = taxableIncome; + let totalTax = 0; + const breakdown = []; + + for (const bracket of brackets) { + if (remainingIncome <= 0) break; + + const bracketMin = bracket.minIncome; + const bracketMax = bracket.maxIncome || Infinity; + + if (taxableIncome > bracketMin) { + const taxableInBracket = Math.min( + remainingIncome, + bracketMax - bracketMin + 1 + ); + + if (taxableInBracket > 0) { + const taxInBracket = (taxableInBracket * bracket.rate) / 100; + totalTax += taxInBracket; + + breakdown.push({ + range: bracket.maxIncome + ? `₹${bracketMin.toLocaleString()} - ₹${bracket.maxIncome.toLocaleString()}` + : `Above ₹${bracketMin.toLocaleString()}`, + rate: bracket.rate, + taxableAmount: taxableInBracket, + tax: taxInBracket + }); + + remainingIncome -= taxableInBracket; + } + } + } + + return { tax: totalTax, breakdown }; + } + + /** + * Calculate surcharge based on income + */ + calculateSurcharge(baseTax, taxableIncome, country) { + if (country !== 'IN') return 0; + + if (taxableIncome > 50000000) return baseTax * 0.37; // 37% for > 5 Cr + if (taxableIncome > 20000000) return baseTax * 0.25; // 25% for > 2 Cr + if (taxableIncome > 10000000) return baseTax * 0.15; // 15% for > 1 Cr + if (taxableIncome > 5000000) return baseTax * 0.10; // 10% for > 50L + + return 0; + } + + /** + * Get deductible expenses + */ + async getDeductibleExpenses(userId, startDate, endDate) { + const taxCategories = await TaxCategory.find({ + type: { $in: ['deductible', 'partially_deductible'] }, + isActive: true + }); + + const expenses = await Expense.aggregate([ + { + $match: { + user: new mongoose.Types.ObjectId(userId), + type: 'expense', + date: { $gte: startDate, $lte: endDate } + } + }, + { + $group: { + _id: '$category', + total: { $sum: '$amount' }, + count: { $sum: 1 }, + expenses: { $push: { description: '$description', amount: '$amount', date: '$date' } } + } + } + ]); + + const deductibleExpenses = []; + + for (const expense of expenses) { + // Find matching tax category + const taxCategory = taxCategories.find(tc => + tc.categoryMappings.some(cm => cm.expenseCategory === expense._id) + ); + + if (taxCategory) { + const mapping = taxCategory.categoryMappings.find(cm => cm.expenseCategory === expense._id); + const deductibleAmount = (expense.total * (mapping?.deductiblePercentage || 0)) / 100; + + deductibleExpenses.push({ + category: expense._id, + totalAmount: expense.total, + deductibleAmount, + deductiblePercentage: mapping?.deductiblePercentage || 0, + taxCategory: taxCategory.name, + section: taxCategory.section, + count: expense.count + }); + } + } + + return deductibleExpenses; + } + + /** + * Calculate all deductions + */ + calculateDeductions(profile, expenseData, customDeductions = []) { + const breakdown = []; + let total = 0; + + // Calculate deductions from expense categories + for (const expense of expenseData) { + if (expense.deductibleAmount > 0) { + // Check if there's a limit for this deduction + const deduction = profile.availableDeductions.find(d => d.code === expense.section); + const limit = deduction?.maxLimit || Infinity; + const actualDeduction = Math.min(expense.deductibleAmount, limit); + + breakdown.push({ + name: expense.taxCategory, + section: expense.section, + claimed: expense.deductibleAmount, + limit: limit === Infinity ? null : limit, + allowed: actualDeduction + }); + + total += actualDeduction; + } + } + + // Add custom deductions from profile + for (const custom of profile.customDeductions || []) { + const deduction = profile.availableDeductions.find(d => d.code === custom.section); + const limit = deduction?.maxLimit || Infinity; + const actualDeduction = Math.min(custom.amount, limit); + + breakdown.push({ + name: custom.name, + section: custom.section, + claimed: custom.amount, + limit: limit === Infinity ? null : limit, + allowed: actualDeduction + }); + + total += actualDeduction; + } + + // Add additional custom deductions passed in options + for (const custom of customDeductions) { + breakdown.push({ + name: custom.name, + section: custom.section || 'Custom', + claimed: custom.amount, + limit: null, + allowed: custom.amount + }); + + total += custom.amount; + } + + return { breakdown, total }; + } + + /** + * Get tax-deductible expense categories for tagging + */ + async getDeductibleCategories(country = 'IN') { + const categories = await TaxCategory.find({ + country, + type: { $in: ['deductible', 'partially_deductible'] }, + isActive: true + }).select('code name description section maxDeductionLimit keywords categoryMappings'); + + return categories; + } + + /** + * Auto-tag expense as tax-deductible + */ + async autoTagExpense(expense) { + const categories = await TaxCategory.find({ + isActive: true, + type: { $in: ['deductible', 'partially_deductible'] } + }); + + const description = expense.description.toLowerCase(); + const category = expense.category; + + for (const taxCat of categories) { + // Check keywords + const keywordMatch = taxCat.keywords.some(kw => description.includes(kw)); + + // Check category mapping + const categoryMatch = taxCat.categoryMappings.some(cm => cm.expenseCategory === category); + + if (keywordMatch || categoryMatch) { + return { + isTaxDeductible: true, + taxCategory: taxCat.code, + taxCategoryName: taxCat.name, + section: taxCat.section, + deductiblePercentage: taxCat.categoryMappings.find(cm => cm.expenseCategory === category)?.deductiblePercentage || 100 + }; + } + } + + return { isTaxDeductible: false }; + } + + /** + * Get tax summary for dashboard + */ + async getTaxSummary(userId, taxYear = new Date().getFullYear()) { + const taxCalc = await this.calculateTax(userId, taxYear); + + return { + taxYear, + grossIncome: taxCalc.grossIncome, + totalDeductions: taxCalc.totalDeductions, + taxableIncome: taxCalc.taxableIncome, + estimatedTax: taxCalc.totalTax, + effectiveRate: taxCalc.effectiveRate, + taxPaid: taxCalc.tdsDeducted + taxCalc.advanceTaxPaid, + taxDue: taxCalc.taxPayable, + topDeductions: taxCalc.deductions.slice(0, 5) + }; + } + + /** + * Compare tax between old and new regime + */ + async compareRegimes(userId, taxYear = new Date().getFullYear()) { + const profile = await this.getOrCreateProfile(userId, taxYear); + const originalRegime = profile.regime; + + // Calculate for new regime + profile.regime = 'new'; + profile.taxBrackets = TaxProfile.getDefaultBrackets('IN', 'new'); + const newRegimeTax = await this.calculateTax(userId, taxYear); + + // Calculate for old regime + profile.regime = 'old'; + profile.taxBrackets = TaxProfile.getDefaultBrackets('IN', 'old'); + const oldRegimeTax = await this.calculateTax(userId, taxYear); + + // Restore original regime + profile.regime = originalRegime; + profile.taxBrackets = TaxProfile.getDefaultBrackets('IN', originalRegime); + await profile.save(); + + const savings = oldRegimeTax.totalTax - newRegimeTax.totalTax; + + return { + newRegime: { + taxableIncome: newRegimeTax.taxableIncome, + totalTax: newRegimeTax.totalTax, + effectiveRate: newRegimeTax.effectiveRate, + deductions: newRegimeTax.totalDeductions + }, + oldRegime: { + taxableIncome: oldRegimeTax.taxableIncome, + totalTax: oldRegimeTax.totalTax, + effectiveRate: oldRegimeTax.effectiveRate, + deductions: oldRegimeTax.totalDeductions + }, + recommendation: savings > 0 ? 'new' : 'old', + savings: Math.abs(savings), + message: savings > 0 + ? `New regime saves you ₹${Math.abs(savings).toLocaleString()}` + : `Old regime saves you ₹${Math.abs(savings).toLocaleString()}` + }; + } + + /** + * Initialize default tax categories + */ + async initializeDefaultCategories(country = 'IN') { + const categories = TaxCategory.getDefaultCategories(country); + + for (const category of categories) { + await TaxCategory.findOneAndUpdate( + { code: category.code }, + category, + { upsert: true, new: true } + ); + } + + console.log(`Default tax categories initialized for ${country}`); + } +} + +module.exports = new TaxService();