package service import ( "fmt" "time" "accounting-app/internal/models" "accounting-app/internal/repository" "github.com/xuri/excelize/v2" ) // ExcelExportService handles Excel export functionality type ExcelExportService struct { reportRepo *repository.ReportRepository transactionRepo *repository.TransactionRepository exchangeRateRepo *repository.ExchangeRateRepository } // NewExcelExportService creates a new ExcelExportService instance func NewExcelExportService(reportRepo *repository.ReportRepository, transactionRepo *repository.TransactionRepository, exchangeRateRepo *repository.ExchangeRateRepository) *ExcelExportService { return &ExcelExportService{ reportRepo: reportRepo, transactionRepo: transactionRepo, exchangeRateRepo: exchangeRateRepo, } } // ExportReportToExcel generates an Excel report with transaction details and summary statistics func (s *ExcelExportService) ExportReportToExcel(userID uint, req ExportReportRequest) ([]byte, error) { // Create new Excel file f := excelize.NewFile() defer f.Close() // Create sheets summarySheet := "Summary" transactionsSheet := "Transactions" categoriesSheet := "Categories" // Rename default sheet to Summary f.SetSheetName("Sheet1", summarySheet) // Create other sheets _, err := f.NewSheet(transactionsSheet) if err != nil { return nil, fmt.Errorf("failed to create transactions sheet: %w", err) } _, err = f.NewSheet(categoriesSheet) if err != nil { return nil, fmt.Errorf("failed to create categories sheet: %w", err) } // Get data summary, err := s.getTransactionSummary(userID, req.StartDate, req.EndDate, req.TargetCurrency) if err != nil { return nil, fmt.Errorf("failed to get transaction summary: %w", err) } categoryExpenseSummary, err := s.getCategorySummary(userID, req.StartDate, req.EndDate, models.TransactionTypeExpense, req.TargetCurrency) if err != nil { return nil, fmt.Errorf("failed to get category expense summary: %w", err) } categoryIncomeSummary, err := s.getCategorySummary(userID, req.StartDate, req.EndDate, models.TransactionTypeIncome, req.TargetCurrency) if err != nil { return nil, fmt.Errorf("failed to get category income summary: %w", err) } transactions, err := s.transactionRepo.GetByDateRange(userID, req.StartDate, req.EndDate) if err != nil { return nil, fmt.Errorf("failed to get transactions: %w", err) } // Populate sheets if err := s.populateSummarySheet(f, summarySheet, summary, req); err != nil { return nil, fmt.Errorf("failed to populate summary sheet: %w", err) } if err := s.populateTransactionsSheet(f, transactionsSheet, transactions); err != nil { return nil, fmt.Errorf("failed to populate transactions sheet: %w", err) } if err := s.populateCategoriesSheet(f, categoriesSheet, categoryExpenseSummary, categoryIncomeSummary); err != nil { return nil, fmt.Errorf("failed to populate categories sheet: %w", err) } // Set active sheet to Summary f.SetActiveSheet(0) // Save to buffer buf, err := f.WriteToBuffer() if err != nil { return nil, fmt.Errorf("failed to write Excel file: %w", err) } return buf.Bytes(), nil } // populateSummarySheet populates the summary sheet with report metadata and summary statistics func (s *ExcelExportService) populateSummarySheet(f *excelize.File, sheetName string, summary *summaryData, req ExportReportRequest) error { // Define styles titleStyle, err := f.NewStyle(&excelize.Style{ Font: &excelize.Font{ Bold: true, Size: 16, }, Alignment: &excelize.Alignment{ Horizontal: "center", Vertical: "center", }, }) if err != nil { return err } headerStyle, err := f.NewStyle(&excelize.Style{ Font: &excelize.Font{ Bold: true, Size: 12, }, Fill: excelize.Fill{ Type: "pattern", Color: []string{"#D3D3D3"}, Pattern: 1, }, Alignment: &excelize.Alignment{ Horizontal: "left", Vertical: "center", }, }) if err != nil { return err } valueStyle, err := f.NewStyle(&excelize.Style{ Alignment: &excelize.Alignment{ Horizontal: "right", Vertical: "center", }, NumFmt: 2, // 0.00 format }) if err != nil { return err } // Set column widths f.SetColWidth(sheetName, "A", "A", 25) f.SetColWidth(sheetName, "B", "B", 20) // Title f.SetCellValue(sheetName, "A1", "Financial Report") f.SetCellStyle(sheetName, "A1", "B1", titleStyle) f.MergeCell(sheetName, "A1", "B1") // Report period row := 3 f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Report Period:") f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), fmt.Sprintf("%s to %s", req.StartDate.Format("2006-01-02"), req.EndDate.Format("2006-01-02"))) row++ // Generated date f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Generated:") f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), time.Now().Format("2006-01-02 15:04:05")) row++ // Currency currencyStr := "Mixed" if req.TargetCurrency != nil { currencyStr = string(*req.TargetCurrency) } f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Currency:") f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), currencyStr) row += 2 // Summary statistics header f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Summary Statistics") f.SetCellStyle(sheetName, fmt.Sprintf("A%d", row), fmt.Sprintf("B%d", row), headerStyle) f.MergeCell(sheetName, fmt.Sprintf("A%d", row), fmt.Sprintf("B%d", row)) row++ // Total Income f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Total Income") f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), summary.TotalIncome) f.SetCellStyle(sheetName, fmt.Sprintf("B%d", row), fmt.Sprintf("B%d", row), valueStyle) row++ // Total Expense f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Total Expense") f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), summary.TotalExpense) f.SetCellStyle(sheetName, fmt.Sprintf("B%d", row), fmt.Sprintf("B%d", row), valueStyle) row++ // Balance f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Balance") f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), summary.Balance) f.SetCellStyle(sheetName, fmt.Sprintf("B%d", row), fmt.Sprintf("B%d", row), valueStyle) // Apply bold style to balance balanceStyle, err := f.NewStyle(&excelize.Style{ Font: &excelize.Font{ Bold: true, }, Alignment: &excelize.Alignment{ Horizontal: "right", Vertical: "center", }, NumFmt: 2, }) if err != nil { return err } f.SetCellStyle(sheetName, fmt.Sprintf("A%d", row), fmt.Sprintf("B%d", row), balanceStyle) return nil } // populateTransactionsSheet populates the transactions sheet with transaction details func (s *ExcelExportService) populateTransactionsSheet(f *excelize.File, sheetName string, transactions []models.Transaction) error { // Define styles headerStyle, err := f.NewStyle(&excelize.Style{ Fill: excelize.Fill{ Type: "pattern", Color: []string{"#4472C4"}, Pattern: 1, }, Font: &excelize.Font{ Bold: true, Color: "#FFFFFF", }, Alignment: &excelize.Alignment{ Horizontal: "center", Vertical: "center", }, Border: []excelize.Border{ {Type: "left", Color: "#000000", Style: 1}, {Type: "top", Color: "#000000", Style: 1}, {Type: "bottom", Color: "#000000", Style: 1}, {Type: "right", Color: "#000000", Style: 1}, }, }) if err != nil { return err } // Set column widths f.SetColWidth(sheetName, "A", "A", 12) // Date f.SetColWidth(sheetName, "B", "B", 10) // Type f.SetColWidth(sheetName, "C", "C", 20) // Category f.SetColWidth(sheetName, "D", "D", 20) // Account f.SetColWidth(sheetName, "E", "E", 12) // Amount f.SetColWidth(sheetName, "F", "F", 10) // Currency f.SetColWidth(sheetName, "G", "G", 40) // Note // Headers headers := []string{"Date", "Type", "Category", "Account", "Amount", "Currency", "Note"} for i, header := range headers { cell := fmt.Sprintf("%s1", string(rune('A'+i))) f.SetCellValue(sheetName, cell, header) f.SetCellStyle(sheetName, cell, cell, headerStyle) } // Data rows for i, txn := range transactions { row := i + 2 // Date f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), txn.TransactionDate.Format("2006-01-02")) // Type f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), string(txn.Type)) // Category categoryName := "" if txn.Category.Name != "" { categoryName = txn.Category.Name } f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), categoryName) // Account accountName := "" if txn.Account.Name != "" { accountName = txn.Account.Name } f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), accountName) // Amount f.SetCellValue(sheetName, fmt.Sprintf("E%d", row), txn.Amount) // Currency f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), string(txn.Currency)) // Note f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), txn.Note) } // Apply table style if len(transactions) > 0 { lastRow := len(transactions) + 1 // Add borders to all cells for row := 2; row <= lastRow; row++ { for col := 'A'; col <= 'G'; col++ { cell := fmt.Sprintf("%c%d", col, row) style, _ := f.NewStyle(&excelize.Style{ Border: []excelize.Border{ {Type: "left", Color: "#D3D3D3", Style: 1}, {Type: "top", Color: "#D3D3D3", Style: 1}, {Type: "bottom", Color: "#D3D3D3", Style: 1}, {Type: "right", Color: "#D3D3D3", Style: 1}, }, }) f.SetCellStyle(sheetName, cell, cell, style) } } // Format amount column amountStyle, _ := f.NewStyle(&excelize.Style{ NumFmt: 2, // 0.00 format Border: []excelize.Border{ {Type: "left", Color: "#D3D3D3", Style: 1}, {Type: "top", Color: "#D3D3D3", Style: 1}, {Type: "bottom", Color: "#D3D3D3", Style: 1}, {Type: "right", Color: "#D3D3D3", Style: 1}, }, }) f.SetCellStyle(sheetName, "E2", fmt.Sprintf("E%d", lastRow), amountStyle) } // Freeze header row f.SetPanes(sheetName, &excelize.Panes{ Freeze: true, XSplit: 0, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft", }) return nil } // populateCategoriesSheet populates the categories sheet with category breakdown func (s *ExcelExportService) populateCategoriesSheet(f *excelize.File, sheetName string, expenseCategories, incomeCategories []categoryData) error { // Define styles titleStyle, err := f.NewStyle(&excelize.Style{ Font: &excelize.Font{ Bold: true, Size: 14, }, Alignment: &excelize.Alignment{ Horizontal: "left", Vertical: "center", }, }) if err != nil { return err } headerStyle, err := f.NewStyle(&excelize.Style{ Font: &excelize.Font{ Bold: true, Color: "#FFFFFF", }, Fill: excelize.Fill{ Type: "pattern", Color: []string{"#4472C4"}, Pattern: 1, }, Alignment: &excelize.Alignment{ Horizontal: "center", Vertical: "center", }, Border: []excelize.Border{ {Type: "left", Color: "#000000", Style: 1}, {Type: "top", Color: "#000000", Style: 1}, {Type: "bottom", Color: "#000000", Style: 1}, {Type: "right", Color: "#000000", Style: 1}, }, }) if err != nil { return err } // Set column widths f.SetColWidth(sheetName, "A", "A", 25) f.SetColWidth(sheetName, "B", "B", 15) f.SetColWidth(sheetName, "C", "C", 12) f.SetColWidth(sheetName, "D", "D", 15) row := 1 // Expense Categories Section if len(expenseCategories) > 0 { f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Expense by Category") f.SetCellStyle(sheetName, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), titleStyle) row++ // Headers headers := []string{"Category", "Amount", "Count", "Percentage"} for i, header := range headers { cell := fmt.Sprintf("%s%d", string(rune('A'+i)), row) f.SetCellValue(sheetName, cell, header) f.SetCellStyle(sheetName, cell, cell, headerStyle) } row++ // Data for _, cat := range expenseCategories { f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), cat.CategoryName) f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), cat.TotalAmount) f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), cat.Count) f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), fmt.Sprintf("%.1f%%", cat.Percentage)) // Apply borders for col := 'A'; col <= 'D'; col++ { cell := fmt.Sprintf("%c%d", col, row) style, _ := f.NewStyle(&excelize.Style{ Border: []excelize.Border{ {Type: "left", Color: "#D3D3D3", Style: 1}, {Type: "top", Color: "#D3D3D3", Style: 1}, {Type: "bottom", Color: "#D3D3D3", Style: 1}, {Type: "right", Color: "#D3D3D3", Style: 1}, }, }) f.SetCellStyle(sheetName, cell, cell, style) } // Format amount column amountStyle, _ := f.NewStyle(&excelize.Style{ NumFmt: 2, Border: []excelize.Border{ {Type: "left", Color: "#D3D3D3", Style: 1}, {Type: "top", Color: "#D3D3D3", Style: 1}, {Type: "bottom", Color: "#D3D3D3", Style: 1}, {Type: "right", Color: "#D3D3D3", Style: 1}, }, }) f.SetCellStyle(sheetName, fmt.Sprintf("B%d", row), fmt.Sprintf("B%d", row), amountStyle) row++ } row += 2 } // Income Categories Section if len(incomeCategories) > 0 { f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Income by Category") f.SetCellStyle(sheetName, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), titleStyle) row++ // Headers headers := []string{"Category", "Amount", "Count", "Percentage"} for i, header := range headers { cell := fmt.Sprintf("%s%d", string(rune('A'+i)), row) f.SetCellValue(sheetName, cell, header) f.SetCellStyle(sheetName, cell, cell, headerStyle) } row++ // Data for _, cat := range incomeCategories { f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), cat.CategoryName) f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), cat.TotalAmount) f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), cat.Count) f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), fmt.Sprintf("%.1f%%", cat.Percentage)) // Apply borders for col := 'A'; col <= 'D'; col++ { cell := fmt.Sprintf("%c%d", col, row) style, _ := f.NewStyle(&excelize.Style{ Border: []excelize.Border{ {Type: "left", Color: "#D3D3D3", Style: 1}, {Type: "top", Color: "#D3D3D3", Style: 1}, {Type: "bottom", Color: "#D3D3D3", Style: 1}, {Type: "right", Color: "#D3D3D3", Style: 1}, }, }) f.SetCellStyle(sheetName, cell, cell, style) } // Format amount column amountStyle, _ := f.NewStyle(&excelize.Style{ NumFmt: 2, Border: []excelize.Border{ {Type: "left", Color: "#D3D3D3", Style: 1}, {Type: "top", Color: "#D3D3D3", Style: 1}, {Type: "bottom", Color: "#D3D3D3", Style: 1}, {Type: "right", Color: "#D3D3D3", Style: 1}, }, }) f.SetCellStyle(sheetName, fmt.Sprintf("B%d", row), fmt.Sprintf("B%d", row), amountStyle) row++ } } return nil } // getTransactionSummary retrieves transaction summary for the report (reusing from PDF service) func (s *ExcelExportService) getTransactionSummary(userID uint, startDate, endDate time.Time, targetCurrency *models.Currency) (*summaryData, error) { summaries, err := s.reportRepo.GetTransactionSummaryByCurrency(userID, startDate, endDate) if err != nil { return nil, err } result := &summaryData{} // If target currency is specified, convert all to that currency if targetCurrency != nil { for _, summary := range summaries { if summary.Currency == *targetCurrency { result.TotalIncome += summary.TotalIncome result.TotalExpense += summary.TotalExpense } else { // Get exchange rate rate, err := s.exchangeRateRepo.GetByCurrencyPairAndDate(summary.Currency, *targetCurrency, time.Now()) if err != nil { // Try inverse rate inverseRate, inverseErr := s.exchangeRateRepo.GetByCurrencyPairAndDate(*targetCurrency, summary.Currency, time.Now()) if inverseErr != nil { // If no rate found, skip this currency continue } rate = &models.ExchangeRate{ FromCurrency: summary.Currency, ToCurrency: *targetCurrency, Rate: 1.0 / inverseRate.Rate, } } result.TotalIncome += summary.TotalIncome * rate.Rate result.TotalExpense += summary.TotalExpense * rate.Rate } } } else { // No target currency, just sum all (assuming same currency or user doesn't care) for _, summary := range summaries { result.TotalIncome += summary.TotalIncome result.TotalExpense += summary.TotalExpense } } result.Balance = result.TotalIncome - result.TotalExpense return result, nil } // getCategorySummary retrieves category summary for the report (reusing from PDF service) func (s *ExcelExportService) getCategorySummary(userID uint, startDate, endDate time.Time, txnType models.TransactionType, targetCurrency *models.Currency) ([]categoryData, error) { summaries, err := s.reportRepo.GetCategorySummaryByCurrency(userID, startDate, endDate, txnType) if err != nil { return nil, err } // Group by category and convert currency if needed categoryMap := make(map[uint]*categoryData) for _, summary := range summaries { if categoryMap[summary.CategoryID] == nil { categoryMap[summary.CategoryID] = &categoryData{ CategoryName: summary.CategoryName, TotalAmount: 0, Count: 0, } } amount := summary.TotalAmount if targetCurrency != nil && summary.Currency != *targetCurrency { // Get exchange rate rate, err := s.exchangeRateRepo.GetByCurrencyPairAndDate(summary.Currency, *targetCurrency, time.Now()) if err != nil { // Try inverse rate inverseRate, inverseErr := s.exchangeRateRepo.GetByCurrencyPairAndDate(*targetCurrency, summary.Currency, time.Now()) if inverseErr != nil { // If no rate found, skip this entry continue } rate = &models.ExchangeRate{ FromCurrency: summary.Currency, ToCurrency: *targetCurrency, Rate: 1.0 / inverseRate.Rate, } } amount = summary.TotalAmount * rate.Rate } categoryMap[summary.CategoryID].TotalAmount += amount categoryMap[summary.CategoryID].Count += summary.Count } // Convert map to slice and calculate percentages result := make([]categoryData, 0, len(categoryMap)) var total float64 for _, cat := range categoryMap { total += cat.TotalAmount result = append(result, *cat) } // Calculate percentages for i := range result { if total > 0 { result[i].Percentage = (result[i].TotalAmount / total) * 100 } } return result, nil }