This commit is contained in:
2026-01-25 21:59:00 +08:00
parent 7fd537bef3
commit 4cad3f0250
118 changed files with 30473 additions and 0 deletions

View File

@@ -0,0 +1,388 @@
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
}