This commit is contained in:
parent
13774eec7d
commit
45fcecdc04
29 changed files with 2865 additions and 3867 deletions
|
|
@ -132,18 +132,7 @@ func (s *ExpenseService) GetExpensesWithTagsForSpacePaginated(spaceID string, pa
|
|||
return nil, 0, err
|
||||
}
|
||||
|
||||
totalPages := (total + ExpensesPerPage - 1) / ExpensesPerPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
offset := (page - 1) * ExpensesPerPage
|
||||
page, totalPages, offset := Paginate(page, total, ExpensesPerPage)
|
||||
expenses, err := s.expenseRepo.GetBySpaceIDPaginated(spaceID, ExpensesPerPage, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
|
|
@ -175,18 +164,7 @@ func (s *ExpenseService) GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID
|
|||
return nil, 0, err
|
||||
}
|
||||
|
||||
totalPages := (total + ExpensesPerPage - 1) / ExpensesPerPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
offset := (page - 1) * ExpensesPerPage
|
||||
page, totalPages, offset := Paginate(page, total, ExpensesPerPage)
|
||||
expenses, err := s.expenseRepo.GetBySpaceIDPaginated(spaceID, ExpensesPerPage, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
|
|
|
|||
|
|
@ -180,18 +180,7 @@ func (s *MoneyAccountService) GetTransfersForSpacePaginated(spaceID string, page
|
|||
return nil, 0, err
|
||||
}
|
||||
|
||||
totalPages := (total + TransfersPerPage - 1) / TransfersPerPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
offset := (page - 1) * TransfersPerPage
|
||||
page, totalPages, offset := Paginate(page, total, TransfersPerPage)
|
||||
transfers, err := s.accountRepo.GetTransfersBySpaceIDPaginated(spaceID, TransfersPerPage, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
|
|
|
|||
18
internal/service/pagination.go
Normal file
18
internal/service/pagination.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package service
|
||||
|
||||
// Paginate calculates pagination values from a page number, total count, and page size.
|
||||
// Returns the adjusted page, total pages, and offset for the query.
|
||||
func Paginate(page, total, perPage int) (adjustedPage, totalPages, offset int) {
|
||||
totalPages = (total + perPage - 1) / perPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
offset = (page - 1) * perPage
|
||||
return page, totalPages, offset
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateRecurringDepositDTO struct {
|
||||
SpaceID string
|
||||
AccountID string
|
||||
Amount int
|
||||
Frequency model.Frequency
|
||||
StartDate time.Time
|
||||
EndDate *time.Time
|
||||
Title string
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
type UpdateRecurringDepositDTO struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Amount int
|
||||
Frequency model.Frequency
|
||||
StartDate time.Time
|
||||
EndDate *time.Time
|
||||
Title string
|
||||
}
|
||||
|
||||
type RecurringDepositService struct {
|
||||
recurringRepo repository.RecurringDepositRepository
|
||||
accountRepo repository.MoneyAccountRepository
|
||||
expenseService *ExpenseService
|
||||
profileRepo repository.ProfileRepository
|
||||
spaceRepo repository.SpaceRepository
|
||||
}
|
||||
|
||||
func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService, profileRepo repository.ProfileRepository, spaceRepo repository.SpaceRepository) *RecurringDepositService {
|
||||
return &RecurringDepositService{
|
||||
recurringRepo: recurringRepo,
|
||||
accountRepo: accountRepo,
|
||||
expenseService: expenseService,
|
||||
profileRepo: profileRepo,
|
||||
spaceRepo: spaceRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) CreateRecurringDeposit(dto CreateRecurringDepositDTO) (*model.RecurringDeposit, error) {
|
||||
if dto.Amount <= 0 {
|
||||
return nil, fmt.Errorf("amount must be positive")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rd := &model.RecurringDeposit{
|
||||
ID: uuid.NewString(),
|
||||
SpaceID: dto.SpaceID,
|
||||
AccountID: dto.AccountID,
|
||||
AmountCents: dto.Amount,
|
||||
Frequency: dto.Frequency,
|
||||
StartDate: dto.StartDate,
|
||||
EndDate: dto.EndDate,
|
||||
NextOccurrence: dto.StartDate,
|
||||
IsActive: true,
|
||||
Title: strings.TrimSpace(dto.Title),
|
||||
CreatedBy: dto.CreatedBy,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := s.recurringRepo.Create(rd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rd, nil
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) GetRecurringDeposit(id string) (*model.RecurringDeposit, error) {
|
||||
return s.recurringRepo.GetByID(id)
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) GetRecurringDepositsForSpace(spaceID string) ([]*model.RecurringDeposit, error) {
|
||||
return s.recurringRepo.GetBySpaceID(spaceID)
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) GetRecurringDepositsWithAccountsForSpace(spaceID string) ([]*model.RecurringDepositWithAccount, error) {
|
||||
deposits, err := s.recurringRepo.GetBySpaceID(spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accounts, err := s.accountRepo.GetBySpaceID(spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accountNames := make(map[string]string, len(accounts))
|
||||
for _, acct := range accounts {
|
||||
accountNames[acct.ID] = acct.Name
|
||||
}
|
||||
|
||||
result := make([]*model.RecurringDepositWithAccount, len(deposits))
|
||||
for i, rd := range deposits {
|
||||
result[i] = &model.RecurringDepositWithAccount{
|
||||
RecurringDeposit: *rd,
|
||||
AccountName: accountNames[rd.AccountID],
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) UpdateRecurringDeposit(dto UpdateRecurringDepositDTO) (*model.RecurringDeposit, error) {
|
||||
if dto.Amount <= 0 {
|
||||
return nil, fmt.Errorf("amount must be positive")
|
||||
}
|
||||
|
||||
existing, err := s.recurringRepo.GetByID(dto.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing.AccountID = dto.AccountID
|
||||
existing.AmountCents = dto.Amount
|
||||
existing.Frequency = dto.Frequency
|
||||
existing.StartDate = dto.StartDate
|
||||
existing.EndDate = dto.EndDate
|
||||
existing.Title = strings.TrimSpace(dto.Title)
|
||||
existing.UpdatedAt = time.Now()
|
||||
|
||||
// Recalculate next occurrence if start date moved forward
|
||||
if existing.NextOccurrence.Before(dto.StartDate) {
|
||||
existing.NextOccurrence = dto.StartDate
|
||||
}
|
||||
|
||||
if err := s.recurringRepo.Update(existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) DeleteRecurringDeposit(id string) error {
|
||||
return s.recurringRepo.Delete(id)
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) ToggleRecurringDeposit(id string) (*model.RecurringDeposit, error) {
|
||||
rd, err := s.recurringRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newActive := !rd.IsActive
|
||||
if err := s.recurringRepo.SetActive(id, newActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rd.IsActive = newActive
|
||||
return rd, nil
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) ProcessDueRecurrences(now time.Time) error {
|
||||
dues, err := s.recurringRepo.GetDueRecurrences(now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get due recurring deposits: %w", err)
|
||||
}
|
||||
|
||||
tzCache := make(map[string]*time.Location)
|
||||
for _, rd := range dues {
|
||||
localNow := s.getLocalNow(rd.SpaceID, rd.CreatedBy, now, tzCache)
|
||||
if err := s.processRecurrence(rd, localNow); err != nil {
|
||||
slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) 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 deposits for space: %w", err)
|
||||
}
|
||||
|
||||
tzCache := make(map[string]*time.Location)
|
||||
for _, rd := range dues {
|
||||
localNow := s.getLocalNow(rd.SpaceID, rd.CreatedBy, now, tzCache)
|
||||
if err := s.processRecurrence(rd, localNow); err != nil {
|
||||
slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getLocalNow resolves the effective timezone for a recurring deposit.
|
||||
// Resolution order: space timezone → user profile timezone → UTC.
|
||||
func (s *RecurringDepositService) 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)
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) getAvailableBalance(spaceID string) (int, error) {
|
||||
totalBalance, err := s.expenseService.GetBalanceForSpace(spaceID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get space balance: %w", err)
|
||||
}
|
||||
totalAllocated, err := s.accountRepo.GetTotalAllocatedForSpace(spaceID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get total allocated: %w", err)
|
||||
}
|
||||
return totalBalance - totalAllocated, nil
|
||||
}
|
||||
|
||||
func (s *RecurringDepositService) processRecurrence(rd *model.RecurringDeposit, now time.Time) error {
|
||||
for !rd.NextOccurrence.After(now) {
|
||||
// Check if end_date has been passed
|
||||
if rd.EndDate != nil && rd.NextOccurrence.After(*rd.EndDate) {
|
||||
return s.recurringRepo.Deactivate(rd.ID)
|
||||
}
|
||||
|
||||
// Check available balance
|
||||
availableBalance, err := s.getAvailableBalance(rd.SpaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if availableBalance >= rd.AmountCents {
|
||||
rdID := rd.ID
|
||||
transfer := &model.AccountTransfer{
|
||||
ID: uuid.NewString(),
|
||||
AccountID: rd.AccountID,
|
||||
AmountCents: rd.AmountCents,
|
||||
Direction: model.TransferDirectionDeposit,
|
||||
Note: rd.Title,
|
||||
RecurringDepositID: &rdID,
|
||||
CreatedBy: rd.CreatedBy,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := s.accountRepo.CreateTransfer(transfer); err != nil {
|
||||
return fmt.Errorf("failed to create deposit transfer: %w", err)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("recurring deposit skipped: insufficient available balance",
|
||||
"recurring_deposit_id", rd.ID,
|
||||
"space_id", rd.SpaceID,
|
||||
"needed", rd.AmountCents,
|
||||
"available", availableBalance,
|
||||
)
|
||||
}
|
||||
|
||||
rd.NextOccurrence = AdvanceDate(rd.NextOccurrence, rd.Frequency)
|
||||
}
|
||||
|
||||
// Check if the new next occurrence exceeds end date
|
||||
if rd.EndDate != nil && rd.NextOccurrence.After(*rd.EndDate) {
|
||||
if err := s.recurringRepo.Deactivate(rd.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.recurringRepo.UpdateNextOccurrence(rd.ID, rd.NextOccurrence)
|
||||
}
|
||||
|
|
@ -127,18 +127,7 @@ func (s *ShoppingListService) GetItemsForListPaginated(listID string, page int)
|
|||
return nil, 0, err
|
||||
}
|
||||
|
||||
totalPages := (total + ItemsPerCardPage - 1) / ItemsPerCardPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
offset := (page - 1) * ItemsPerCardPage
|
||||
page, totalPages, offset := Paginate(page, total, ItemsPerCardPage)
|
||||
items, err := s.itemRepo.GetByListIDPaginated(listID, ItemsPerCardPage, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue