package service import ( "errors" "fmt" "time" "accounting-app/internal/models" "accounting-app/internal/repository" "gorm.io/gorm" ) // Repayment service errors var ( ErrRepaymentPlanNotFound = errors.New("repayment plan not found") ErrInstallmentNotFound = errors.New("installment not found") ErrInvalidInstallmentCount = errors.New("installment count must be at least 2") ErrInvalidInstallmentAmount = errors.New("installment amount must be positive") ErrPlanAlreadyExists = errors.New("repayment plan already exists for this bill") ErrBillAlreadyPaid = errors.New("bill is already paid") ErrInvalidRepaymentAmount = errors.New("payment amount must be positive") ErrPaymentExceedsInstallment = errors.New("payment amount exceeds installment amount") ErrInstallmentAlreadyPaid = errors.New("installment is already paid") ) // RepaymentService handles business logic for repayment plans and reminders type RepaymentService struct { repaymentRepo *repository.RepaymentRepository billingRepo *repository.BillingRepository accountRepo *repository.AccountRepository db *gorm.DB } // NewRepaymentService creates a new RepaymentService instance func NewRepaymentService( repaymentRepo *repository.RepaymentRepository, billingRepo *repository.BillingRepository, accountRepo *repository.AccountRepository, db *gorm.DB, ) *RepaymentService { return &RepaymentService{ repaymentRepo: repaymentRepo, billingRepo: billingRepo, accountRepo: accountRepo, db: db, } } // CreateRepaymentPlanInput represents input for creating a repayment plan type CreateRepaymentPlanInput struct { BillID uint `json:"bill_id" binding:"required"` InstallmentCount int `json:"installment_count" binding:"required,min=2"` FirstDueDate time.Time `json:"first_due_date" binding:"required"` } // CreateRepaymentPlan creates a new repayment plan for a bill func (s *RepaymentService) CreateRepaymentPlan(userID uint, input CreateRepaymentPlanInput) (*models.RepaymentPlan, error) { // Validate installment count if input.InstallmentCount < 2 { return nil, ErrInvalidInstallmentCount } // Get the bill bill, err := s.billingRepo.GetByID(userID, input.BillID) if err != nil { if errors.Is(err, repository.ErrBillNotFound) { return nil, fmt.Errorf("bill not found: %w", err) } return nil, fmt.Errorf("failed to get bill: %w", err) } // Check if bill is already paid if bill.Status == models.BillStatusPaid { return nil, ErrBillAlreadyPaid } // Check if plan already exists for this bill existingPlan, err := s.repaymentRepo.GetPlanByBillID(userID, input.BillID) if err != nil && !errors.Is(err, repository.ErrRepaymentPlanNotFound) { return nil, fmt.Errorf("failed to check existing plan: %w", err) } if existingPlan != nil { return nil, ErrPlanAlreadyExists } // Calculate installment amount totalAmount := bill.CurrentBalance installmentAmount := totalAmount / float64(input.InstallmentCount) // Create the plan plan := &models.RepaymentPlan{ UserID: userID, BillID: input.BillID, TotalAmount: totalAmount, RemainingAmount: totalAmount, InstallmentCount: input.InstallmentCount, InstallmentAmount: installmentAmount, Status: models.RepaymentPlanStatusActive, } // Create plan and installments in a transaction err = s.db.Transaction(func(tx *gorm.DB) error { // Create the plan using the transaction if err := tx.Create(plan).Error; err != nil { return fmt.Errorf("failed to create plan: %w", err) } // Create installments currentDueDate := input.FirstDueDate for i := 1; i <= input.InstallmentCount; i++ { // Last installment gets any remaining amount due to rounding amount := installmentAmount if i == input.InstallmentCount { amount = totalAmount - (installmentAmount * float64(input.InstallmentCount-1)) } installment := &models.RepaymentInstallment{ PlanID: plan.ID, DueDate: currentDueDate, Amount: amount, PaidAmount: 0, Status: models.RepaymentInstallmentStatusPending, Sequence: i, } if err := tx.Create(installment).Error; err != nil { return fmt.Errorf("failed to create installment: %w", err) } // Move to next month for next installment currentDueDate = currentDueDate.AddDate(0, 1, 0) } return nil }) if err != nil { return nil, fmt.Errorf("failed to create repayment plan: %w", err) } // Reload plan with installments plan, err = s.repaymentRepo.GetPlanByID(userID, plan.ID) if err != nil { return nil, fmt.Errorf("failed to reload plan: %w", err) } return plan, nil } // GetRepaymentPlan retrieves a repayment plan by ID func (s *RepaymentService) GetRepaymentPlan(userID uint, id uint) (*models.RepaymentPlan, error) { plan, err := s.repaymentRepo.GetPlanByID(userID, id) if err != nil { if errors.Is(err, repository.ErrRepaymentPlanNotFound) { return nil, ErrRepaymentPlanNotFound } return nil, fmt.Errorf("failed to get repayment plan: %w", err) } return plan, nil } // GetRepaymentPlanByBillID retrieves a repayment plan by bill ID func (s *RepaymentService) GetRepaymentPlanByBillID(userID uint, billID uint) (*models.RepaymentPlan, error) { plan, err := s.repaymentRepo.GetPlanByBillID(userID, billID) if err != nil { if errors.Is(err, repository.ErrRepaymentPlanNotFound) { return nil, ErrRepaymentPlanNotFound } return nil, fmt.Errorf("failed to get repayment plan: %w", err) } return plan, nil } // GetActivePlans retrieves all active repayment plans func (s *RepaymentService) GetActivePlans(userID uint) ([]models.RepaymentPlan, error) { plans, err := s.repaymentRepo.GetActivePlans(userID) if err != nil { return nil, fmt.Errorf("failed to get active plans: %w", err) } return plans, nil } // PayInstallmentInput represents input for paying an installment type PayInstallmentInput struct { InstallmentID uint `json:"installment_id" binding:"required"` Amount float64 `json:"amount" binding:"required,gt=0"` FromAccountID uint `json:"from_account_id" binding:"required"` } // PayInstallment processes a payment for an installment func (s *RepaymentService) PayInstallment(userID uint, input PayInstallmentInput) error { // Validate amount if input.Amount <= 0 { return ErrInvalidRepaymentAmount } // Get the installment installment, err := s.repaymentRepo.GetInstallmentByID(input.InstallmentID) if err != nil { if errors.Is(err, repository.ErrInstallmentNotFound) { return ErrInstallmentNotFound } return fmt.Errorf("failed to get installment: %w", err) } // Check if already paid if installment.Status == models.RepaymentInstallmentStatusPaid { return ErrInstallmentAlreadyPaid } // Check if payment amount is valid remainingAmount := installment.Amount - installment.PaidAmount if input.Amount > remainingAmount { return ErrPaymentExceedsInstallment } // Get the from account fromAccount, err := s.accountRepo.GetByID(userID, input.FromAccountID) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return fmt.Errorf("from account not found: %w", err) } return fmt.Errorf("failed to get from account: %w", err) } // Check if from account has sufficient balance (for non-credit accounts) if !models.IsCreditAccountType(fromAccount.Type) && fromAccount.Balance < input.Amount { return fmt.Errorf("insufficient balance in from account") } // Process payment in a transaction err = s.db.Transaction(func(tx *gorm.DB) error { // Update installment installment.PaidAmount += input.Amount now := time.Now() // Mark as paid if fully paid if installment.PaidAmount >= installment.Amount { installment.Status = models.RepaymentInstallmentStatusPaid installment.PaidAt = &now } if err := s.repaymentRepo.UpdateInstallment(installment); err != nil { return err } // Update plan remaining amount plan, err := s.repaymentRepo.GetPlanByID(userID, installment.PlanID) if err != nil { return err } plan.RemainingAmount -= input.Amount // Check if plan is completed if plan.RemainingAmount <= 0 { plan.Status = models.RepaymentPlanStatusCompleted // Mark the bill as paid if err := s.billingRepo.MarkAsPaid(userID, plan.BillID, plan.TotalAmount, now); err != nil { return err } } if err := s.repaymentRepo.UpdatePlan(plan); err != nil { return err } // Update from account balance fromAccount.Balance -= input.Amount if err := s.accountRepo.Update(fromAccount); err != nil { return err } return nil }) if err != nil { return fmt.Errorf("failed to process payment: %w", err) } return nil } // CancelRepaymentPlan cancels a repayment plan func (s *RepaymentService) CancelRepaymentPlan(userID uint, id uint) error { // Get the plan plan, err := s.repaymentRepo.GetPlanByID(userID, id) if err != nil { if errors.Is(err, repository.ErrRepaymentPlanNotFound) { return ErrRepaymentPlanNotFound } return fmt.Errorf("failed to get plan: %w", err) } // Update status to cancelled if err := s.repaymentRepo.UpdatePlanStatus(plan.ID, models.RepaymentPlanStatusCancelled); err != nil { return fmt.Errorf("failed to cancel plan: %w", err) } return nil } // ======================================== // Reminder Management // ======================================== // GenerateRemindersForUpcomingPayments generates reminders for upcoming payments func (s *RepaymentService) GenerateRemindersForUpcomingPayments(userID uint, daysAhead int) ([]models.PaymentReminder, error) { now := time.Now() endDate := now.AddDate(0, 0, daysAhead) var reminders []models.PaymentReminder // Generate reminders for bills without repayment plans bills, err := s.billingRepo.GetBillsDueInRange(userID, now, endDate) if err != nil { return nil, fmt.Errorf("failed to get upcoming bills: %w", err) } for _, bill := range bills { // Check if bill has a repayment plan _, err := s.repaymentRepo.GetPlanByBillID(userID, bill.ID) if err != nil && !errors.Is(err, repository.ErrRepaymentPlanNotFound) { return nil, fmt.Errorf("failed to check repayment plan: %w", err) } // Only create reminder if no repayment plan exists if errors.Is(err, repository.ErrRepaymentPlanNotFound) { daysUntilDue := int(bill.PaymentDueDate.Sub(now).Hours() / 24) message := fmt.Sprintf("Payment due in %d days for %s. Amount: %.2f", daysUntilDue, bill.Account.Name, bill.CurrentBalance) reminder := models.PaymentReminder{ BillID: bill.ID, ReminderDate: now, Message: message, IsRead: false, } if err := s.repaymentRepo.CreateReminder(&reminder); err != nil { return nil, fmt.Errorf("failed to create reminder: %w", err) } reminders = append(reminders, reminder) } } // Generate reminders for upcoming installments installments, err := s.repaymentRepo.GetInstallmentsDueInRange(now, endDate) if err != nil { return nil, fmt.Errorf("failed to get upcoming installments: %w", err) } for _, installment := range installments { daysUntilDue := int(installment.DueDate.Sub(now).Hours() / 24) message := fmt.Sprintf("Installment %d/%d due in %d days for %s. Amount: %.2f", installment.Sequence, installment.Plan.InstallmentCount, daysUntilDue, installment.Plan.Bill.Account.Name, installment.Amount) reminder := models.PaymentReminder{ BillID: installment.Plan.BillID, InstallmentID: &installment.ID, ReminderDate: now, Message: message, IsRead: false, } if err := s.repaymentRepo.CreateReminder(&reminder); err != nil { return nil, fmt.Errorf("failed to create reminder: %w", err) } reminders = append(reminders, reminder) } return reminders, nil } // GetUnreadReminders retrieves all unread payment reminders func (s *RepaymentService) GetUnreadReminders(userID uint) ([]models.PaymentReminder, error) { reminders, err := s.repaymentRepo.GetUnreadReminders(userID) if err != nil { return nil, fmt.Errorf("failed to get unread reminders: %w", err) } return reminders, nil } // MarkReminderAsRead marks a reminder as read func (s *RepaymentService) MarkReminderAsRead(id uint) error { if err := s.repaymentRepo.MarkReminderAsRead(id); err != nil { if errors.Is(err, repository.ErrReminderNotFound) { return fmt.Errorf("reminder not found: %w", err) } return fmt.Errorf("failed to mark reminder as read: %w", err) } return nil } // UpdateOverdueInstallments updates the status of installments that are overdue func (s *RepaymentService) UpdateOverdueInstallments() error { now := time.Now() // Get all pending installments installments, err := s.repaymentRepo.GetPendingInstallments() if err != nil { return fmt.Errorf("failed to get pending installments: %w", err) } for _, installment := range installments { // Check if due date has passed if installment.DueDate.Before(now) { if err := s.repaymentRepo.UpdateInstallmentStatus(installment.ID, models.RepaymentInstallmentStatusOverdue); err != nil { return fmt.Errorf("failed to update installment %d status: %w", installment.ID, err) } } } return nil } // GetDebtSummary returns a summary of all debts across credit accounts type DebtSummary struct { TotalDebt float64 `json:"total_debt"` TotalMinimumPayment float64 `json:"total_minimum_payment"` PendingBillsCount int `json:"pending_bills_count"` OverdueBillsCount int `json:"overdue_bills_count"` ActivePlansCount int `json:"active_plans_count"` AccountDebts []AccountDebt `json:"account_debts"` } type AccountDebt struct { AccountID uint `json:"account_id"` AccountName string `json:"account_name"` CurrentBalance float64 `json:"current_balance"` MinimumPayment float64 `json:"minimum_payment"` NextPaymentDate *time.Time `json:"next_payment_date,omitempty"` HasRepaymentPlan bool `json:"has_repayment_plan"` } // GetDebtSummary retrieves a comprehensive debt summary func (s *RepaymentService) GetDebtSummary(userID uint) (*DebtSummary, error) { summary := &DebtSummary{ AccountDebts: []AccountDebt{}, } // Get all pending bills pendingBills, err := s.billingRepo.GetPendingBills(userID) if err != nil { return nil, fmt.Errorf("failed to get pending bills: %w", err) } summary.PendingBillsCount = len(pendingBills) // Get all overdue bills overdueBills, err := s.billingRepo.GetOverdueBills(userID) if err != nil { return nil, fmt.Errorf("failed to get overdue bills: %w", err) } summary.OverdueBillsCount = len(overdueBills) // Get all active plans activePlans, err := s.repaymentRepo.GetActivePlans(userID) if err != nil { return nil, fmt.Errorf("failed to get active plans: %w", err) } summary.ActivePlansCount = len(activePlans) // Aggregate debt by account accountDebtMap := make(map[uint]*AccountDebt) // Process pending and overdue bills allBills := append(pendingBills, overdueBills...) for _, bill := range allBills { if _, exists := accountDebtMap[bill.AccountID]; !exists { accountDebtMap[bill.AccountID] = &AccountDebt{ AccountID: bill.AccountID, AccountName: bill.Account.Name, } } debt := accountDebtMap[bill.AccountID] debt.CurrentBalance += bill.CurrentBalance debt.MinimumPayment += bill.MinimumPayment // Set next payment date to the earliest due date if debt.NextPaymentDate == nil || bill.PaymentDueDate.Before(*debt.NextPaymentDate) { debt.NextPaymentDate = &bill.PaymentDueDate } // Check if bill has a repayment plan _, err := s.repaymentRepo.GetPlanByBillID(userID, bill.ID) if err == nil { debt.HasRepaymentPlan = true } summary.TotalDebt += bill.CurrentBalance summary.TotalMinimumPayment += bill.MinimumPayment } // Convert map to slice for _, debt := range accountDebtMap { summary.AccountDebts = append(summary.AccountDebts, *debt) } return summary, nil }