init
This commit is contained in:
506
internal/service/repayment_service.go
Normal file
506
internal/service/repayment_service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user