package service import ( "fmt" "time" "accounting-app/internal/models" "accounting-app/internal/repository" "github.com/jung-kurt/gofpdf" ) // PDFExportService handles PDF export functionality type PDFExportService struct { reportRepo *repository.ReportRepository transactionRepo *repository.TransactionRepository exchangeRateRepo *repository.ExchangeRateRepository } // NewPDFExportService creates a new PDFExportService instance func NewPDFExportService(reportRepo *repository.ReportRepository, transactionRepo *repository.TransactionRepository, exchangeRateRepo *repository.ExchangeRateRepository) *PDFExportService { return &PDFExportService{ reportRepo: reportRepo, transactionRepo: transactionRepo, exchangeRateRepo: exchangeRateRepo, } } // ExportReportRequest represents the request for exporting a report type ExportReportRequest struct { StartDate time.Time EndDate time.Time TargetCurrency *models.Currency IncludeCharts bool } // ExportReportToPDF generates a PDF report with transaction details and summary statistics func (s *PDFExportService) ExportReportToPDF(userID uint, req ExportReportRequest) ([]byte, error) { // Create new PDF document pdf := gofpdf.New("P", "mm", "A4", "") pdf.SetMargins(15, 15, 15) pdf.AddPage() // Add Chinese font support (using built-in fonts for now) pdf.SetFont("Arial", "B", 16) // Add title pdf.CellFormat(0, 10, "Financial Report", "", 1, "C", false, 0, "") pdf.Ln(5) // Add report period pdf.SetFont("Arial", "", 10) periodText := fmt.Sprintf("Period: %s to %s", req.StartDate.Format("2006-01-02"), req.EndDate.Format("2006-01-02")) pdf.CellFormat(0, 6, periodText, "", 1, "L", false, 0, "") pdf.CellFormat(0, 6, fmt.Sprintf("Generated: %s", time.Now().Format("2006-01-02 15:04:05")), "", 1, "L", false, 0, "") pdf.Ln(5) // Get transaction summary 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) } // Add summary section if err := s.addSummarySection(pdf, summary, req.TargetCurrency); err != nil { return nil, fmt.Errorf("failed to add summary section: %w", err) } // Get category summary for expenses 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) } // Add category breakdown section if err := s.addCategoryBreakdownSection(pdf, categoryExpenseSummary, "Expense by Category"); err != nil { return nil, fmt.Errorf("failed to add category breakdown section: %w", err) } // Get category summary for income 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) } // Add income category breakdown section if err := s.addCategoryBreakdownSection(pdf, categoryIncomeSummary, "Income by Category"); err != nil { return nil, fmt.Errorf("failed to add income category breakdown section: %w", err) } // Get transaction details transactions, err := s.transactionRepo.GetByDateRange(userID, req.StartDate, req.EndDate) if err != nil { return nil, fmt.Errorf("failed to get transactions: %w", err) } // Add transaction details section if err := s.addTransactionDetailsSection(pdf, transactions); err != nil { return nil, fmt.Errorf("failed to add transaction details section: %w", err) } // Output PDF to bytes var buf []byte w := &bytesWriter{buf: &buf} err = pdf.Output(w) if err != nil { return nil, fmt.Errorf("failed to generate PDF: %w", err) } return buf, nil } // bytesWriter is a helper to write PDF output to a byte slice type bytesWriter struct { buf *[]byte } func (w *bytesWriter) Write(p []byte) (n int, err error) { *w.buf = append(*w.buf, p...) return len(p), nil } // addSummarySection adds the summary statistics section to the PDF func (s *PDFExportService) addSummarySection(pdf *gofpdf.Fpdf, summary *summaryData, targetCurrency *models.Currency) error { pdf.SetFont("Arial", "B", 12) pdf.CellFormat(0, 8, "Summary Statistics", "", 1, "L", false, 0, "") pdf.Ln(2) // Draw summary box pdf.SetFont("Arial", "", 10) pdf.SetFillColor(240, 240, 240) currencySymbol := "$" if targetCurrency != nil { currencySymbol = getCurrencySymbol(*targetCurrency) } // Total Income pdf.CellFormat(90, 8, "Total Income:", "1", 0, "L", true, 0, "") pdf.CellFormat(90, 8, fmt.Sprintf("%s %.2f", currencySymbol, summary.TotalIncome), "1", 1, "R", true, 0, "") // Total Expense pdf.CellFormat(90, 8, "Total Expense:", "1", 0, "L", true, 0, "") pdf.CellFormat(90, 8, fmt.Sprintf("%s %.2f", currencySymbol, summary.TotalExpense), "1", 1, "R", true, 0, "") // Balance pdf.SetFont("Arial", "B", 10) pdf.CellFormat(90, 8, "Balance:", "1", 0, "L", true, 0, "") pdf.CellFormat(90, 8, fmt.Sprintf("%s %.2f", currencySymbol, summary.Balance), "1", 1, "R", true, 0, "") pdf.Ln(5) return nil } // addCategoryBreakdownSection adds the category breakdown section to the PDF func (s *PDFExportService) addCategoryBreakdownSection(pdf *gofpdf.Fpdf, categories []categoryData, title string) error { if len(categories) == 0 { return nil } pdf.SetFont("Arial", "B", 12) pdf.CellFormat(0, 8, title, "", 1, "L", false, 0, "") pdf.Ln(2) // Table header pdf.SetFont("Arial", "B", 9) pdf.SetFillColor(200, 200, 200) pdf.CellFormat(80, 7, "Category", "1", 0, "L", true, 0, "") pdf.CellFormat(40, 7, "Amount", "1", 0, "R", true, 0, "") pdf.CellFormat(30, 7, "Count", "1", 0, "R", true, 0, "") pdf.CellFormat(30, 7, "Percentage", "1", 1, "R", true, 0, "") // Table rows pdf.SetFont("Arial", "", 9) pdf.SetFillColor(255, 255, 255) for _, cat := range categories { pdf.CellFormat(80, 6, cat.CategoryName, "1", 0, "L", false, 0, "") pdf.CellFormat(40, 6, fmt.Sprintf("%.2f", cat.TotalAmount), "1", 0, "R", false, 0, "") pdf.CellFormat(30, 6, fmt.Sprintf("%d", cat.Count), "1", 0, "R", false, 0, "") pdf.CellFormat(30, 6, fmt.Sprintf("%.1f%%", cat.Percentage), "1", 1, "R", false, 0, "") } pdf.Ln(5) return nil } // addTransactionDetailsSection adds the transaction details section to the PDF func (s *PDFExportService) addTransactionDetailsSection(pdf *gofpdf.Fpdf, transactions []models.Transaction) error { if len(transactions) == 0 { return nil } // Add new page for transaction details pdf.AddPage() pdf.SetFont("Arial", "B", 12) pdf.CellFormat(0, 8, "Transaction Details", "", 1, "L", false, 0, "") pdf.Ln(2) // Table header pdf.SetFont("Arial", "B", 8) pdf.SetFillColor(200, 200, 200) pdf.CellFormat(25, 6, "Date", "1", 0, "L", true, 0, "") pdf.CellFormat(20, 6, "Type", "1", 0, "L", true, 0, "") pdf.CellFormat(35, 6, "Category", "1", 0, "L", true, 0, "") pdf.CellFormat(30, 6, "Amount", "1", 0, "R", true, 0, "") pdf.CellFormat(70, 6, "Note", "1", 1, "L", true, 0, "") // Table rows pdf.SetFont("Arial", "", 7) pdf.SetFillColor(255, 255, 255) for _, txn := range transactions { // Check if we need a new page if pdf.GetY() > 270 { pdf.AddPage() // Repeat header pdf.SetFont("Arial", "B", 8) pdf.SetFillColor(200, 200, 200) pdf.CellFormat(25, 6, "Date", "1", 0, "L", true, 0, "") pdf.CellFormat(20, 6, "Type", "1", 0, "L", true, 0, "") pdf.CellFormat(35, 6, "Category", "1", 0, "L", true, 0, "") pdf.CellFormat(30, 6, "Amount", "1", 0, "R", true, 0, "") pdf.CellFormat(70, 6, "Note", "1", 1, "L", true, 0, "") pdf.SetFont("Arial", "", 7) pdf.SetFillColor(255, 255, 255) } date := txn.TransactionDate.Format("2006-01-02") txnType := string(txn.Type) categoryName := "" if txn.Category.Name != "" { categoryName = txn.Category.Name } amount := fmt.Sprintf("%.2f", txn.Amount) note := txn.Note if len(note) > 40 { note = note[:37] + "..." } pdf.CellFormat(25, 6, date, "1", 0, "L", false, 0, "") pdf.CellFormat(20, 6, txnType, "1", 0, "L", false, 0, "") pdf.CellFormat(35, 6, categoryName, "1", 0, "L", false, 0, "") pdf.CellFormat(30, 6, amount, "1", 0, "R", false, 0, "") pdf.CellFormat(70, 6, note, "1", 1, "L", false, 0, "") } return nil } // summaryData holds summary statistics type summaryData struct { TotalIncome float64 TotalExpense float64 Balance float64 } // categoryData holds category breakdown data type categoryData struct { CategoryName string TotalAmount float64 Count int64 Percentage float64 } // getTransactionSummary retrieves transaction summary for the report func (s *PDFExportService) 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 func (s *PDFExportService) 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 } // getCurrencySymbol returns the symbol for a currency func getCurrencySymbol(currency models.Currency) string { switch currency { case models.CurrencyCNY: return "¥" case models.CurrencyUSD: return "$" case models.CurrencyEUR: return "€" case models.CurrencyJPY: return "¥" case models.CurrencyGBP: return "£" case models.CurrencyHKD: return "HK$" default: return "" } }