package service import ( "errors" "fmt" "time" "accounting-app/internal/models" "accounting-app/internal/repository" "gorm.io/gorm" ) // Billing service errors var ( ErrBillNotFound = errors.New("bill not found") ErrNotCreditAccount = errors.New("account is not a credit card account") ErrBillingDateNotSet = errors.New("billing date not set for credit card account") ErrPaymentDateNotSet = errors.New("payment date not set for credit card account") ErrBillAlreadyExists = errors.New("bill already exists for this billing date") ErrInvalidPaymentAmount = errors.New("payment amount must be positive") ErrPaymentExceedsBill = errors.New("payment amount exceeds bill balance") ) // BillingService handles business logic for credit card billing type BillingService struct { billingRepo *repository.BillingRepository accountRepo *repository.AccountRepository transactionRepo *repository.TransactionRepository db *gorm.DB } // NewBillingService creates a new BillingService instance func NewBillingService( billingRepo *repository.BillingRepository, accountRepo *repository.AccountRepository, transactionRepo *repository.TransactionRepository, db *gorm.DB, ) *BillingService { return &BillingService{ billingRepo: billingRepo, accountRepo: accountRepo, transactionRepo: transactionRepo, db: db, } } // GenerateBill generates a bill for a credit card account for a specific billing date func (s *BillingService) GenerateBill(userID uint, accountID uint, billingDate time.Time) (*models.CreditCardBill, error) { // Get the account account, err := s.accountRepo.GetByID(userID, accountID) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return nil, fmt.Errorf("account not found: %w", err) } return nil, fmt.Errorf("failed to get account: %w", err) } // Validate that this is a credit card account if account.Type != models.AccountTypeCreditCard { return nil, ErrNotCreditAccount } // Validate billing and payment dates are set if account.BillingDate == nil { return nil, ErrBillingDateNotSet } if account.PaymentDate == nil { return nil, ErrPaymentDateNotSet } // Check if bill already exists for this billing date exists, err := s.billingRepo.ExistsByAccountAndBillingDate(userID, accountID, billingDate) if err != nil { return nil, fmt.Errorf("failed to check bill existence: %w", err) } if exists { return nil, ErrBillAlreadyExists } // Calculate the billing cycle period // Previous billing date to current billing date previousBillingDate := s.calculatePreviousBillingDate(billingDate, *account.BillingDate) // Get previous bill to get the previous balance var previousBalance float64 previousBill, err := s.billingRepo.GetLatestByAccountID(userID, accountID) if err != nil && !errors.Is(err, repository.ErrBillNotFound) { return nil, fmt.Errorf("failed to get previous bill: %w", err) } if previousBill != nil { previousBalance = previousBill.CurrentBalance } // Calculate total spending in this billing cycle // Get all expense transactions in the billing cycle totalSpending, err := s.calculateTotalSpending(userID, accountID, previousBillingDate, billingDate) if err != nil { return nil, fmt.Errorf("failed to calculate total spending: %w", err) } // Calculate total payments in this billing cycle totalPayment, err := s.calculateTotalPayments(userID, accountID, previousBillingDate, billingDate) if err != nil { return nil, fmt.Errorf("failed to calculate total payments: %w", err) } // Calculate current balance // Current Balance = Previous Balance + Total Spending - Total Payments currentBalance := previousBalance + totalSpending - totalPayment // Calculate minimum payment (typically 10% of balance or a minimum amount) minimumPayment := s.calculateMinimumPayment(currentBalance) // Calculate payment due date paymentDueDate := s.calculatePaymentDueDate(billingDate, *account.PaymentDate) // Create the bill bill := &models.CreditCardBill{ UserID: userID, AccountID: accountID, BillingDate: billingDate, PaymentDueDate: paymentDueDate, PreviousBalance: previousBalance, TotalSpending: totalSpending, TotalPayment: totalPayment, CurrentBalance: currentBalance, MinimumPayment: minimumPayment, Status: models.BillStatusPending, PaidAmount: 0, } // Save the bill if err := s.billingRepo.Create(bill); err != nil { return nil, fmt.Errorf("failed to create bill: %w", err) } return bill, nil } // GenerateBillsForDueAccounts generates bills for all credit card accounts that have reached their billing date func (s *BillingService) GenerateBillsForDueAccounts(userID uint, currentDate time.Time) ([]models.CreditCardBill, error) { // Get all credit card accounts accounts, err := s.accountRepo.GetByType(userID, models.AccountTypeCreditCard) if err != nil { return nil, fmt.Errorf("failed to get credit card accounts: %w", err) } var generatedBills []models.CreditCardBill for _, account := range accounts { // Skip if billing date is not set if account.BillingDate == nil { continue } // Check if billing date matches current date's day if currentDate.Day() == *account.BillingDate { // Check if bill already exists for this month billingDate := time.Date(currentDate.Year(), currentDate.Month(), *account.BillingDate, 0, 0, 0, 0, currentDate.Location()) exists, err := s.billingRepo.ExistsByAccountAndBillingDate(userID, account.ID, billingDate) if err != nil { return nil, fmt.Errorf("failed to check bill existence for account %d: %w", account.ID, err) } if !exists { // Generate bill bill, err := s.GenerateBill(userID, account.ID, billingDate) if err != nil { return nil, fmt.Errorf("failed to generate bill for account %d: %w", account.ID, err) } generatedBills = append(generatedBills, *bill) } } } return generatedBills, nil } // GetBillsByAccountID retrieves all bills for a specific account func (s *BillingService) GetBillsByAccountID(userID uint, accountID uint) ([]models.CreditCardBill, error) { bills, err := s.billingRepo.GetByAccountID(userID, accountID) if err != nil { return nil, fmt.Errorf("failed to get bills: %w", err) } return bills, nil } // GetBillByID retrieves a bill by its ID func (s *BillingService) GetBillByID(userID uint, id uint) (*models.CreditCardBill, error) { bill, err := s.billingRepo.GetByID(userID, id) if err != nil { if errors.Is(err, repository.ErrBillNotFound) { return nil, ErrBillNotFound } return nil, fmt.Errorf("failed to get bill: %w", err) } return bill, nil } // GetPendingBills retrieves all pending bills func (s *BillingService) GetPendingBills(userID uint) ([]models.CreditCardBill, error) { bills, err := s.billingRepo.GetPendingBills(userID) if err != nil { return nil, fmt.Errorf("failed to get pending bills: %w", err) } return bills, nil } // GetUpcomingDueBills retrieves bills that are due within the next N days func (s *BillingService) GetUpcomingDueBills(userID uint, daysAhead int) ([]models.CreditCardBill, error) { now := time.Now() endDate := now.AddDate(0, 0, daysAhead) bills, err := s.billingRepo.GetBillsDueInRange(userID, now, endDate) if err != nil { return nil, fmt.Errorf("failed to get upcoming due bills: %w", err) } return bills, nil } // UpdateOverdueBills updates the status of bills that are overdue func (s *BillingService) UpdateOverdueBills(userID uint) error { now := time.Now() // Get all pending bills pendingBills, err := s.billingRepo.GetPendingBills(userID) if err != nil { return fmt.Errorf("failed to get pending bills: %w", err) } for _, bill := range pendingBills { // Check if payment due date has passed if bill.PaymentDueDate.Before(now) { if err := s.billingRepo.UpdateStatus(userID, bill.ID, models.BillStatusOverdue); err != nil { return fmt.Errorf("failed to update bill %d status: %w", bill.ID, err) } } } return nil } // calculatePreviousBillingDate calculates the previous billing date func (s *BillingService) calculatePreviousBillingDate(currentBillingDate time.Time, billingDay int) time.Time { // Go back one month previousMonth := currentBillingDate.AddDate(0, -1, 0) // Set to the billing day year, month, _ := previousMonth.Date() previousBillingDate := time.Date(year, month, billingDay, 0, 0, 0, 0, currentBillingDate.Location()) // Handle case where billing day doesn't exist in the month (e.g., Feb 30) if previousBillingDate.Month() != month { // Use last day of the month previousBillingDate = time.Date(year, month+1, 0, 0, 0, 0, 0, currentBillingDate.Location()) } return previousBillingDate } // calculatePaymentDueDate calculates the payment due date based on billing date and payment day func (s *BillingService) calculatePaymentDueDate(billingDate time.Time, paymentDay int) time.Time { // Payment is typically in the same month or next month year, month, _ := billingDate.Date() // Try same month first paymentDate := time.Date(year, month, paymentDay, 0, 0, 0, 0, billingDate.Location()) // If payment date is before or equal to billing date, move to next month if paymentDate.Before(billingDate) || paymentDate.Equal(billingDate) { paymentDate = paymentDate.AddDate(0, 1, 0) } // Handle case where payment day doesn't exist in the month if paymentDate.Month() != month && paymentDate.Month() != month+1 { // Use last day of the target month paymentDate = time.Date(year, month+1, 0, 0, 0, 0, 0, billingDate.Location()) } return paymentDate } // calculateTotalSpending calculates total spending in a billing cycle func (s *BillingService) calculateTotalSpending(userID uint, accountID uint, startDate, endDate time.Time) (float64, error) { // Get all expense transactions for this account in the date range transactions, err := s.transactionRepo.GetByDateRange(userID, startDate, endDate) if err != nil { return 0, fmt.Errorf("failed to get transactions: %w", err) } var totalSpending float64 for _, txn := range transactions { // Only count expenses from this account if txn.AccountID == accountID && txn.Type == models.TransactionTypeExpense { totalSpending += txn.Amount } } return totalSpending, nil } // calculateTotalPayments calculates total payments made in a billing cycle func (s *BillingService) calculateTotalPayments(userID uint, accountID uint, startDate, endDate time.Time) (float64, error) { // Get all income transactions for this account in the date range // (payments to credit card are recorded as income to the credit card account) transactions, err := s.transactionRepo.GetByDateRange(userID, startDate, endDate) if err != nil { return 0, fmt.Errorf("failed to get transactions: %w", err) } var totalPayments float64 for _, txn := range transactions { // Count income transactions to this account (payments) if txn.AccountID == accountID && txn.Type == models.TransactionTypeIncome { totalPayments += txn.Amount } // Also count transfers to this account as payments if txn.ToAccountID != nil && *txn.ToAccountID == accountID && txn.Type == models.TransactionTypeTransfer { totalPayments += txn.Amount } } return totalPayments, nil } // calculateMinimumPayment calculates the minimum payment required // Typically 10% of balance or a minimum amount (e.g., 50) func (s *BillingService) calculateMinimumPayment(balance float64) float64 { if balance <= 0 { return 0 } // Calculate 10% of balance minPayment := balance * 0.1 // Set a minimum floor (e.g., 50) const minFloor = 50.0 if minPayment < minFloor && balance >= minFloor { minPayment = minFloor } // If balance is less than minimum floor, minimum payment is the full balance if balance < minFloor { minPayment = balance } return minPayment } // GetCurrentBillingCycle returns the start and end dates of the current billing cycle for an account func (s *BillingService) GetCurrentBillingCycle(userID uint, accountID uint) (startDate, endDate time.Time, err error) { // Get the account account, err := s.accountRepo.GetByID(userID, accountID) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return time.Time{}, time.Time{}, fmt.Errorf("account not found: %w", err) } return time.Time{}, time.Time{}, fmt.Errorf("failed to get account: %w", err) } // Validate that this is a credit card account if account.Type != models.AccountTypeCreditCard { return time.Time{}, time.Time{}, ErrNotCreditAccount } // Validate billing date is set if account.BillingDate == nil { return time.Time{}, time.Time{}, ErrBillingDateNotSet } now := time.Now() billingDay := *account.BillingDate // Calculate current billing date year, month, day := now.Date() currentBillingDate := time.Date(year, month, billingDay, 0, 0, 0, 0, now.Location()) // If we haven't reached this month's billing date yet, the cycle started last month if day < billingDay { currentBillingDate = currentBillingDate.AddDate(0, -1, 0) } // Previous billing date is one month before previousBillingDate := s.calculatePreviousBillingDate(currentBillingDate, billingDay) return previousBillingDate, currentBillingDate, nil }