feat: loans
This commit is contained in:
parent
f05c36e44f
commit
ac7296b06e
20 changed files with 3191 additions and 4 deletions
195
internal/service/loan.go
Normal file
195
internal/service/loan.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateLoanDTO struct {
|
||||
SpaceID string
|
||||
UserID string
|
||||
Name string
|
||||
Description string
|
||||
OriginalAmount int
|
||||
InterestRateBps int
|
||||
StartDate time.Time
|
||||
EndDate *time.Time
|
||||
}
|
||||
|
||||
type UpdateLoanDTO struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
OriginalAmount int
|
||||
InterestRateBps int
|
||||
StartDate time.Time
|
||||
EndDate *time.Time
|
||||
}
|
||||
|
||||
const LoansPerPage = 25
|
||||
|
||||
type LoanService struct {
|
||||
loanRepo repository.LoanRepository
|
||||
receiptRepo repository.ReceiptRepository
|
||||
}
|
||||
|
||||
func NewLoanService(loanRepo repository.LoanRepository, receiptRepo repository.ReceiptRepository) *LoanService {
|
||||
return &LoanService{
|
||||
loanRepo: loanRepo,
|
||||
receiptRepo: receiptRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LoanService) CreateLoan(dto CreateLoanDTO) (*model.Loan, error) {
|
||||
if dto.Name == "" {
|
||||
return nil, fmt.Errorf("loan name cannot be empty")
|
||||
}
|
||||
if dto.OriginalAmount <= 0 {
|
||||
return nil, fmt.Errorf("amount must be positive")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
loan := &model.Loan{
|
||||
ID: uuid.NewString(),
|
||||
SpaceID: dto.SpaceID,
|
||||
Name: dto.Name,
|
||||
Description: dto.Description,
|
||||
OriginalAmountCents: dto.OriginalAmount,
|
||||
InterestRateBps: dto.InterestRateBps,
|
||||
StartDate: dto.StartDate,
|
||||
EndDate: dto.EndDate,
|
||||
IsPaidOff: false,
|
||||
CreatedBy: dto.UserID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := s.loanRepo.Create(loan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loan, nil
|
||||
}
|
||||
|
||||
func (s *LoanService) GetLoan(id string) (*model.Loan, error) {
|
||||
return s.loanRepo.GetByID(id)
|
||||
}
|
||||
|
||||
func (s *LoanService) GetLoanWithSummary(id string) (*model.LoanWithPaymentSummary, error) {
|
||||
loan, err := s.loanRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
receiptCount, err := s.loanRepo.GetReceiptCountForLoan(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.LoanWithPaymentSummary{
|
||||
Loan: *loan,
|
||||
TotalPaidCents: totalPaid,
|
||||
RemainingCents: loan.OriginalAmountCents - totalPaid,
|
||||
ReceiptCount: receiptCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *LoanService) GetLoansWithSummaryForSpace(spaceID string) ([]*model.LoanWithPaymentSummary, error) {
|
||||
loans, err := s.loanRepo.GetBySpaceID(spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.attachSummaries(loans)
|
||||
}
|
||||
|
||||
func (s *LoanService) GetLoansWithSummaryForSpacePaginated(spaceID string, page int) ([]*model.LoanWithPaymentSummary, int, error) {
|
||||
total, err := s.loanRepo.CountBySpaceID(spaceID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
totalPages := (total + LoansPerPage - 1) / LoansPerPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
offset := (page - 1) * LoansPerPage
|
||||
loans, err := s.loanRepo.GetBySpaceIDPaginated(spaceID, LoansPerPage, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
result, err := s.attachSummaries(loans)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return result, totalPages, nil
|
||||
}
|
||||
|
||||
func (s *LoanService) attachSummaries(loans []*model.Loan) ([]*model.LoanWithPaymentSummary, error) {
|
||||
result := make([]*model.LoanWithPaymentSummary, len(loans))
|
||||
for i, loan := range loans {
|
||||
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(loan.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receiptCount, err := s.loanRepo.GetReceiptCountForLoan(loan.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i] = &model.LoanWithPaymentSummary{
|
||||
Loan: *loan,
|
||||
TotalPaidCents: totalPaid,
|
||||
RemainingCents: loan.OriginalAmountCents - totalPaid,
|
||||
ReceiptCount: receiptCount,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *LoanService) UpdateLoan(dto UpdateLoanDTO) (*model.Loan, error) {
|
||||
if dto.Name == "" {
|
||||
return nil, fmt.Errorf("loan name cannot be empty")
|
||||
}
|
||||
if dto.OriginalAmount <= 0 {
|
||||
return nil, fmt.Errorf("amount must be positive")
|
||||
}
|
||||
|
||||
existing, err := s.loanRepo.GetByID(dto.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing.Name = dto.Name
|
||||
existing.Description = dto.Description
|
||||
existing.OriginalAmountCents = dto.OriginalAmount
|
||||
existing.InterestRateBps = dto.InterestRateBps
|
||||
existing.StartDate = dto.StartDate
|
||||
existing.EndDate = dto.EndDate
|
||||
existing.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.loanRepo.Update(existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *LoanService) DeleteLoan(id string) error {
|
||||
return s.loanRepo.Delete(id)
|
||||
}
|
||||
317
internal/service/receipt.go
Normal file
317
internal/service/receipt.go
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FundingSourceDTO struct {
|
||||
SourceType model.FundingSourceType
|
||||
AccountID string
|
||||
Amount int
|
||||
}
|
||||
|
||||
type CreateReceiptDTO struct {
|
||||
LoanID string
|
||||
SpaceID string
|
||||
UserID string
|
||||
Description string
|
||||
TotalAmount int
|
||||
Date time.Time
|
||||
FundingSources []FundingSourceDTO
|
||||
RecurringReceiptID *string
|
||||
}
|
||||
|
||||
type UpdateReceiptDTO struct {
|
||||
ID string
|
||||
SpaceID string
|
||||
UserID string
|
||||
Description string
|
||||
TotalAmount int
|
||||
Date time.Time
|
||||
FundingSources []FundingSourceDTO
|
||||
}
|
||||
|
||||
const ReceiptsPerPage = 25
|
||||
|
||||
type ReceiptService struct {
|
||||
receiptRepo repository.ReceiptRepository
|
||||
loanRepo repository.LoanRepository
|
||||
accountRepo repository.MoneyAccountRepository
|
||||
}
|
||||
|
||||
func NewReceiptService(
|
||||
receiptRepo repository.ReceiptRepository,
|
||||
loanRepo repository.LoanRepository,
|
||||
accountRepo repository.MoneyAccountRepository,
|
||||
) *ReceiptService {
|
||||
return &ReceiptService{
|
||||
receiptRepo: receiptRepo,
|
||||
loanRepo: loanRepo,
|
||||
accountRepo: accountRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ReceiptService) CreateReceipt(dto CreateReceiptDTO) (*model.ReceiptWithSources, error) {
|
||||
if dto.TotalAmount <= 0 {
|
||||
return nil, fmt.Errorf("amount must be positive")
|
||||
}
|
||||
if len(dto.FundingSources) == 0 {
|
||||
return nil, fmt.Errorf("at least one funding source is required")
|
||||
}
|
||||
|
||||
// Validate funding sources sum to total
|
||||
var sum int
|
||||
for _, src := range dto.FundingSources {
|
||||
if src.Amount <= 0 {
|
||||
return nil, fmt.Errorf("each funding source amount must be positive")
|
||||
}
|
||||
sum += src.Amount
|
||||
}
|
||||
if sum != dto.TotalAmount {
|
||||
return nil, fmt.Errorf("funding source amounts (%d) must equal total amount (%d)", sum, dto.TotalAmount)
|
||||
}
|
||||
|
||||
// Validate loan exists and is not paid off
|
||||
loan, err := s.loanRepo.GetByID(dto.LoanID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loan not found: %w", err)
|
||||
}
|
||||
if loan.IsPaidOff {
|
||||
return nil, fmt.Errorf("loan is already paid off")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
receipt := &model.Receipt{
|
||||
ID: uuid.NewString(),
|
||||
LoanID: dto.LoanID,
|
||||
SpaceID: dto.SpaceID,
|
||||
Description: dto.Description,
|
||||
TotalAmountCents: dto.TotalAmount,
|
||||
Date: dto.Date,
|
||||
RecurringReceiptID: dto.RecurringReceiptID,
|
||||
CreatedBy: dto.UserID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
sources, balanceExpense, accountTransfers := s.buildLinkedRecords(receipt, dto.FundingSources, dto.SpaceID, dto.UserID, dto.Description, dto.Date)
|
||||
|
||||
if err := s.receiptRepo.CreateWithSources(receipt, sources, balanceExpense, accountTransfers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if loan is now fully paid off
|
||||
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(dto.LoanID)
|
||||
if err == nil && totalPaid >= loan.OriginalAmountCents {
|
||||
_ = s.loanRepo.SetPaidOff(loan.ID, true)
|
||||
}
|
||||
|
||||
return &model.ReceiptWithSources{Receipt: *receipt, Sources: sources}, nil
|
||||
}
|
||||
|
||||
func (s *ReceiptService) buildLinkedRecords(
|
||||
receipt *model.Receipt,
|
||||
fundingSources []FundingSourceDTO,
|
||||
spaceID, userID, description string,
|
||||
date time.Time,
|
||||
) ([]model.ReceiptFundingSource, *model.Expense, []*model.AccountTransfer) {
|
||||
now := time.Now()
|
||||
var sources []model.ReceiptFundingSource
|
||||
var balanceExpense *model.Expense
|
||||
var accountTransfers []*model.AccountTransfer
|
||||
|
||||
for _, src := range fundingSources {
|
||||
fs := model.ReceiptFundingSource{
|
||||
ID: uuid.NewString(),
|
||||
ReceiptID: receipt.ID,
|
||||
SourceType: src.SourceType,
|
||||
AmountCents: src.Amount,
|
||||
}
|
||||
|
||||
if src.SourceType == model.FundingSourceBalance {
|
||||
expense := &model.Expense{
|
||||
ID: uuid.NewString(),
|
||||
SpaceID: spaceID,
|
||||
CreatedBy: userID,
|
||||
Description: fmt.Sprintf("Loan payment: %s", description),
|
||||
AmountCents: src.Amount,
|
||||
Type: model.ExpenseTypeExpense,
|
||||
Date: date,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
balanceExpense = expense
|
||||
fs.LinkedExpenseID = &expense.ID
|
||||
} else {
|
||||
acctID := src.AccountID
|
||||
fs.AccountID = &acctID
|
||||
transfer := &model.AccountTransfer{
|
||||
ID: uuid.NewString(),
|
||||
AccountID: src.AccountID,
|
||||
AmountCents: src.Amount,
|
||||
Direction: model.TransferDirectionWithdrawal,
|
||||
Note: fmt.Sprintf("Loan payment: %s", description),
|
||||
CreatedBy: userID,
|
||||
CreatedAt: now,
|
||||
}
|
||||
accountTransfers = append(accountTransfers, transfer)
|
||||
fs.LinkedTransferID = &transfer.ID
|
||||
}
|
||||
|
||||
sources = append(sources, fs)
|
||||
}
|
||||
|
||||
return sources, balanceExpense, accountTransfers
|
||||
}
|
||||
|
||||
func (s *ReceiptService) GetReceipt(id string) (*model.ReceiptWithSourcesAndAccounts, error) {
|
||||
receipt, err := s.receiptRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sourcesMap, err := s.receiptRepo.GetFundingSourcesWithAccountsByReceiptIDs([]string{id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.ReceiptWithSourcesAndAccounts{
|
||||
Receipt: *receipt,
|
||||
Sources: sourcesMap[id],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ReceiptService) GetReceiptsForLoanPaginated(loanID string, page int) ([]*model.ReceiptWithSourcesAndAccounts, int, error) {
|
||||
total, err := s.receiptRepo.CountByLoanID(loanID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
totalPages := (total + ReceiptsPerPage - 1) / ReceiptsPerPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
offset := (page - 1) * ReceiptsPerPage
|
||||
receipts, err := s.receiptRepo.GetByLoanIDPaginated(loanID, ReceiptsPerPage, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return s.attachSources(receipts, totalPages)
|
||||
}
|
||||
|
||||
func (s *ReceiptService) attachSources(receipts []*model.Receipt, totalPages int) ([]*model.ReceiptWithSourcesAndAccounts, int, error) {
|
||||
ids := make([]string, len(receipts))
|
||||
for i, r := range receipts {
|
||||
ids[i] = r.ID
|
||||
}
|
||||
|
||||
sourcesMap, err := s.receiptRepo.GetFundingSourcesWithAccountsByReceiptIDs(ids)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
result := make([]*model.ReceiptWithSourcesAndAccounts, len(receipts))
|
||||
for i, r := range receipts {
|
||||
result[i] = &model.ReceiptWithSourcesAndAccounts{
|
||||
Receipt: *r,
|
||||
Sources: sourcesMap[r.ID],
|
||||
}
|
||||
}
|
||||
return result, totalPages, nil
|
||||
}
|
||||
|
||||
func (s *ReceiptService) DeleteReceipt(id string, spaceID string) error {
|
||||
receipt, err := s.receiptRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if receipt.SpaceID != spaceID {
|
||||
return fmt.Errorf("receipt not found")
|
||||
}
|
||||
|
||||
if err := s.receiptRepo.DeleteWithReversal(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if loan should be un-marked as paid off
|
||||
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(receipt.LoanID)
|
||||
if err != nil {
|
||||
return nil // receipt deleted successfully, paid-off check is best-effort
|
||||
}
|
||||
loan, err := s.loanRepo.GetByID(receipt.LoanID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if loan.IsPaidOff && totalPaid < loan.OriginalAmountCents {
|
||||
_ = s.loanRepo.SetPaidOff(loan.ID, false)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ReceiptService) UpdateReceipt(dto UpdateReceiptDTO) (*model.ReceiptWithSources, error) {
|
||||
if dto.TotalAmount <= 0 {
|
||||
return nil, fmt.Errorf("amount must be positive")
|
||||
}
|
||||
if len(dto.FundingSources) == 0 {
|
||||
return nil, fmt.Errorf("at least one funding source is required")
|
||||
}
|
||||
|
||||
var sum int
|
||||
for _, src := range dto.FundingSources {
|
||||
if src.Amount <= 0 {
|
||||
return nil, fmt.Errorf("each funding source amount must be positive")
|
||||
}
|
||||
sum += src.Amount
|
||||
}
|
||||
if sum != dto.TotalAmount {
|
||||
return nil, fmt.Errorf("funding source amounts (%d) must equal total amount (%d)", sum, dto.TotalAmount)
|
||||
}
|
||||
|
||||
existing, err := s.receiptRepo.GetByID(dto.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing.SpaceID != dto.SpaceID {
|
||||
return nil, fmt.Errorf("receipt not found")
|
||||
}
|
||||
|
||||
existing.Description = dto.Description
|
||||
existing.TotalAmountCents = dto.TotalAmount
|
||||
existing.Date = dto.Date
|
||||
existing.UpdatedAt = time.Now()
|
||||
|
||||
sources, balanceExpense, accountTransfers := s.buildLinkedRecords(existing, dto.FundingSources, dto.SpaceID, dto.UserID, dto.Description, dto.Date)
|
||||
|
||||
if err := s.receiptRepo.UpdateWithSources(existing, sources, balanceExpense, accountTransfers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Re-check paid-off status
|
||||
loan, err := s.loanRepo.GetByID(existing.LoanID)
|
||||
if err == nil {
|
||||
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(existing.LoanID)
|
||||
if err == nil {
|
||||
if totalPaid >= loan.OriginalAmountCents && !loan.IsPaidOff {
|
||||
_ = s.loanRepo.SetPaidOff(loan.ID, true)
|
||||
} else if totalPaid < loan.OriginalAmountCents && loan.IsPaidOff {
|
||||
_ = s.loanRepo.SetPaidOff(loan.ID, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &model.ReceiptWithSources{Receipt: *existing, Sources: sources}, nil
|
||||
}
|
||||
323
internal/service/recurring_receipt.go
Normal file
323
internal/service/recurring_receipt.go
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateRecurringReceiptDTO struct {
|
||||
LoanID string
|
||||
SpaceID string
|
||||
UserID string
|
||||
Description string
|
||||
TotalAmount int
|
||||
Frequency model.Frequency
|
||||
StartDate time.Time
|
||||
EndDate *time.Time
|
||||
FundingSources []FundingSourceDTO
|
||||
}
|
||||
|
||||
type UpdateRecurringReceiptDTO struct {
|
||||
ID string
|
||||
Description string
|
||||
TotalAmount int
|
||||
Frequency model.Frequency
|
||||
StartDate time.Time
|
||||
EndDate *time.Time
|
||||
FundingSources []FundingSourceDTO
|
||||
}
|
||||
|
||||
type RecurringReceiptService struct {
|
||||
recurringRepo repository.RecurringReceiptRepository
|
||||
receiptService *ReceiptService
|
||||
loanRepo repository.LoanRepository
|
||||
profileRepo repository.ProfileRepository
|
||||
spaceRepo repository.SpaceRepository
|
||||
}
|
||||
|
||||
func NewRecurringReceiptService(
|
||||
recurringRepo repository.RecurringReceiptRepository,
|
||||
receiptService *ReceiptService,
|
||||
loanRepo repository.LoanRepository,
|
||||
profileRepo repository.ProfileRepository,
|
||||
spaceRepo repository.SpaceRepository,
|
||||
) *RecurringReceiptService {
|
||||
return &RecurringReceiptService{
|
||||
recurringRepo: recurringRepo,
|
||||
receiptService: receiptService,
|
||||
loanRepo: loanRepo,
|
||||
profileRepo: profileRepo,
|
||||
spaceRepo: spaceRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RecurringReceiptService) CreateRecurringReceipt(dto CreateRecurringReceiptDTO) (*model.RecurringReceiptWithSources, error) {
|
||||
if dto.TotalAmount <= 0 {
|
||||
return nil, fmt.Errorf("amount must be positive")
|
||||
}
|
||||
if len(dto.FundingSources) == 0 {
|
||||
return nil, fmt.Errorf("at least one funding source is required")
|
||||
}
|
||||
|
||||
var sum int
|
||||
for _, src := range dto.FundingSources {
|
||||
sum += src.Amount
|
||||
}
|
||||
if sum != dto.TotalAmount {
|
||||
return nil, fmt.Errorf("funding source amounts must equal total amount")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rr := &model.RecurringReceipt{
|
||||
ID: uuid.NewString(),
|
||||
LoanID: dto.LoanID,
|
||||
SpaceID: dto.SpaceID,
|
||||
Description: dto.Description,
|
||||
TotalAmountCents: dto.TotalAmount,
|
||||
Frequency: dto.Frequency,
|
||||
StartDate: dto.StartDate,
|
||||
EndDate: dto.EndDate,
|
||||
NextOccurrence: dto.StartDate,
|
||||
IsActive: true,
|
||||
CreatedBy: dto.UserID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
sources := make([]model.RecurringReceiptSource, len(dto.FundingSources))
|
||||
for i, src := range dto.FundingSources {
|
||||
sources[i] = model.RecurringReceiptSource{
|
||||
ID: uuid.NewString(),
|
||||
RecurringReceiptID: rr.ID,
|
||||
SourceType: src.SourceType,
|
||||
AmountCents: src.Amount,
|
||||
}
|
||||
if src.SourceType == model.FundingSourceAccount {
|
||||
acctID := src.AccountID
|
||||
sources[i].AccountID = &acctID
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.recurringRepo.Create(rr, sources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.RecurringReceiptWithSources{
|
||||
RecurringReceipt: *rr,
|
||||
Sources: sources,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RecurringReceiptService) GetRecurringReceipt(id string) (*model.RecurringReceipt, error) {
|
||||
return s.recurringRepo.GetByID(id)
|
||||
}
|
||||
|
||||
func (s *RecurringReceiptService) GetRecurringReceiptsForLoan(loanID string) ([]*model.RecurringReceipt, error) {
|
||||
return s.recurringRepo.GetByLoanID(loanID)
|
||||
}
|
||||
|
||||
func (s *RecurringReceiptService) GetRecurringReceiptsWithSourcesForLoan(loanID string) ([]*model.RecurringReceiptWithSources, error) {
|
||||
rrs, err := s.recurringRepo.GetByLoanID(loanID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*model.RecurringReceiptWithSources, len(rrs))
|
||||
for i, rr := range rrs {
|
||||
sources, err := s.recurringRepo.GetSourcesByRecurringReceiptID(rr.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i] = &model.RecurringReceiptWithSources{
|
||||
RecurringReceipt: *rr,
|
||||
Sources: sources,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *RecurringReceiptService) UpdateRecurringReceipt(dto UpdateRecurringReceiptDTO) (*model.RecurringReceipt, error) {
|
||||
if dto.TotalAmount <= 0 {
|
||||
return nil, fmt.Errorf("amount must be positive")
|
||||
}
|
||||
|
||||
existing, err := s.recurringRepo.GetByID(dto.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing.Description = dto.Description
|
||||
existing.TotalAmountCents = dto.TotalAmount
|
||||
existing.Frequency = dto.Frequency
|
||||
existing.StartDate = dto.StartDate
|
||||
existing.EndDate = dto.EndDate
|
||||
existing.UpdatedAt = time.Now()
|
||||
|
||||
if existing.NextOccurrence.Before(dto.StartDate) {
|
||||
existing.NextOccurrence = dto.StartDate
|
||||
}
|
||||
|
||||
sources := make([]model.RecurringReceiptSource, len(dto.FundingSources))
|
||||
for i, src := range dto.FundingSources {
|
||||
sources[i] = model.RecurringReceiptSource{
|
||||
ID: uuid.NewString(),
|
||||
RecurringReceiptID: existing.ID,
|
||||
SourceType: src.SourceType,
|
||||
AmountCents: src.Amount,
|
||||
}
|
||||
if src.SourceType == model.FundingSourceAccount {
|
||||
acctID := src.AccountID
|
||||
sources[i].AccountID = &acctID
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.recurringRepo.Update(existing, sources); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *RecurringReceiptService) DeleteRecurringReceipt(id string) error {
|
||||
return s.recurringRepo.Delete(id)
|
||||
}
|
||||
|
||||
func (s *RecurringReceiptService) ToggleRecurringReceipt(id string) (*model.RecurringReceipt, error) {
|
||||
rr, err := s.recurringRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newActive := !rr.IsActive
|
||||
if err := s.recurringRepo.SetActive(id, newActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rr.IsActive = newActive
|
||||
return rr, nil
|
||||
}
|
||||
|
||||
func (s *RecurringReceiptService) ProcessDueRecurrences(now time.Time) error {
|
||||
dues, err := s.recurringRepo.GetDueRecurrences(now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get due recurring receipts: %w", err)
|
||||
}
|
||||
|
||||
tzCache := make(map[string]*time.Location)
|
||||
for _, rr := range dues {
|
||||
localNow := s.getLocalNow(rr.SpaceID, rr.CreatedBy, now, tzCache)
|
||||
if err := s.processRecurrence(rr, localNow); err != nil {
|
||||
slog.Error("failed to process recurring receipt", "id", rr.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecurringReceiptService) ProcessDueRecurrencesForSpace(spaceID string, now time.Time) error {
|
||||
dues, err := s.recurringRepo.GetDueRecurrencesForSpace(spaceID, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get due recurring receipts for space: %w", err)
|
||||
}
|
||||
|
||||
tzCache := make(map[string]*time.Location)
|
||||
for _, rr := range dues {
|
||||
localNow := s.getLocalNow(rr.SpaceID, rr.CreatedBy, now, tzCache)
|
||||
if err := s.processRecurrence(rr, localNow); err != nil {
|
||||
slog.Error("failed to process recurring receipt", "id", rr.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecurringReceiptService) processRecurrence(rr *model.RecurringReceipt, now time.Time) error {
|
||||
sources, err := s.recurringRepo.GetSourcesByRecurringReceiptID(rr.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for !rr.NextOccurrence.After(now) {
|
||||
if rr.EndDate != nil && rr.NextOccurrence.After(*rr.EndDate) {
|
||||
return s.recurringRepo.Deactivate(rr.ID)
|
||||
}
|
||||
|
||||
// Check if loan is already paid off
|
||||
loan, err := s.loanRepo.GetByID(rr.LoanID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get loan: %w", err)
|
||||
}
|
||||
if loan.IsPaidOff {
|
||||
return s.recurringRepo.Deactivate(rr.ID)
|
||||
}
|
||||
|
||||
// Build funding source DTOs from template
|
||||
fundingSources := make([]FundingSourceDTO, len(sources))
|
||||
for i, src := range sources {
|
||||
accountID := ""
|
||||
if src.AccountID != nil {
|
||||
accountID = *src.AccountID
|
||||
}
|
||||
fundingSources[i] = FundingSourceDTO{
|
||||
SourceType: src.SourceType,
|
||||
AccountID: accountID,
|
||||
Amount: src.AmountCents,
|
||||
}
|
||||
}
|
||||
|
||||
rrID := rr.ID
|
||||
dto := CreateReceiptDTO{
|
||||
LoanID: rr.LoanID,
|
||||
SpaceID: rr.SpaceID,
|
||||
UserID: rr.CreatedBy,
|
||||
Description: rr.Description,
|
||||
TotalAmount: rr.TotalAmountCents,
|
||||
Date: rr.NextOccurrence,
|
||||
FundingSources: fundingSources,
|
||||
RecurringReceiptID: &rrID,
|
||||
}
|
||||
|
||||
if _, err := s.receiptService.CreateReceipt(dto); err != nil {
|
||||
slog.Warn("recurring receipt skipped", "id", rr.ID, "error", err)
|
||||
}
|
||||
|
||||
rr.NextOccurrence = AdvanceDate(rr.NextOccurrence, rr.Frequency)
|
||||
}
|
||||
|
||||
if rr.EndDate != nil && rr.NextOccurrence.After(*rr.EndDate) {
|
||||
if err := s.recurringRepo.Deactivate(rr.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.recurringRepo.UpdateNextOccurrence(rr.ID, rr.NextOccurrence)
|
||||
}
|
||||
|
||||
func (s *RecurringReceiptService) getLocalNow(spaceID, userID string, now time.Time, cache map[string]*time.Location) time.Time {
|
||||
spaceKey := "space:" + spaceID
|
||||
if loc, ok := cache[spaceKey]; ok {
|
||||
return now.In(loc)
|
||||
}
|
||||
|
||||
space, err := s.spaceRepo.ByID(spaceID)
|
||||
if err == nil && space != nil {
|
||||
if loc := space.Location(); loc != nil {
|
||||
cache[spaceKey] = loc
|
||||
return now.In(loc)
|
||||
}
|
||||
}
|
||||
|
||||
userKey := "user:" + userID
|
||||
if loc, ok := cache[userKey]; ok {
|
||||
return now.In(loc)
|
||||
}
|
||||
|
||||
loc := time.UTC
|
||||
profile, err := s.profileRepo.ByUserID(userID)
|
||||
if err == nil && profile != nil {
|
||||
loc = profile.Location()
|
||||
}
|
||||
cache[userKey] = loc
|
||||
return now.In(loc)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue