389 lines
13 KiB
Go
389 lines
13 KiB
Go
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
|
|
}
|