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,506 @@
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
}