feat: recurring expenses and reports
This commit is contained in:
parent
cda4f61939
commit
9e6ff67a87
23 changed files with 2943 additions and 56 deletions
169
internal/service/budget.go
Normal file
169
internal/service/budget.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateBudgetDTO struct {
|
||||
SpaceID string
|
||||
TagID string
|
||||
Amount int
|
||||
Period model.BudgetPeriod
|
||||
StartDate time.Time
|
||||
EndDate *time.Time
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
type UpdateBudgetDTO struct {
|
||||
ID string
|
||||
TagID string
|
||||
Amount int
|
||||
Period model.BudgetPeriod
|
||||
StartDate time.Time
|
||||
EndDate *time.Time
|
||||
}
|
||||
|
||||
type BudgetService struct {
|
||||
budgetRepo repository.BudgetRepository
|
||||
}
|
||||
|
||||
func NewBudgetService(budgetRepo repository.BudgetRepository) *BudgetService {
|
||||
return &BudgetService{budgetRepo: budgetRepo}
|
||||
}
|
||||
|
||||
func (s *BudgetService) CreateBudget(dto CreateBudgetDTO) (*model.Budget, error) {
|
||||
if dto.Amount <= 0 {
|
||||
return nil, fmt.Errorf("budget amount must be positive")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
budget := &model.Budget{
|
||||
ID: uuid.NewString(),
|
||||
SpaceID: dto.SpaceID,
|
||||
TagID: dto.TagID,
|
||||
AmountCents: dto.Amount,
|
||||
Period: dto.Period,
|
||||
StartDate: dto.StartDate,
|
||||
EndDate: dto.EndDate,
|
||||
IsActive: true,
|
||||
CreatedBy: dto.CreatedBy,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := s.budgetRepo.Create(budget); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return budget, nil
|
||||
}
|
||||
|
||||
func (s *BudgetService) GetBudget(id string) (*model.Budget, error) {
|
||||
return s.budgetRepo.GetByID(id)
|
||||
}
|
||||
|
||||
func (s *BudgetService) GetBudgetsWithSpent(spaceID string, tags []*model.Tag) ([]*model.BudgetWithSpent, error) {
|
||||
budgets, err := s.budgetRepo.GetBySpaceID(spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tagMap := make(map[string]*model.Tag)
|
||||
for _, t := range tags {
|
||||
tagMap[t.ID] = t
|
||||
}
|
||||
|
||||
result := make([]*model.BudgetWithSpent, 0, len(budgets))
|
||||
for _, b := range budgets {
|
||||
start, end := GetCurrentPeriodBounds(b.Period, time.Now())
|
||||
spent, err := s.budgetRepo.GetSpentForBudget(spaceID, b.TagID, start, end)
|
||||
if err != nil {
|
||||
spent = 0
|
||||
}
|
||||
|
||||
var percentage float64
|
||||
if b.AmountCents > 0 {
|
||||
percentage = float64(spent) / float64(b.AmountCents) * 100
|
||||
}
|
||||
|
||||
var status model.BudgetStatus
|
||||
switch {
|
||||
case percentage > 100:
|
||||
status = model.BudgetStatusOver
|
||||
case percentage >= 75:
|
||||
status = model.BudgetStatusWarning
|
||||
default:
|
||||
status = model.BudgetStatusOnTrack
|
||||
}
|
||||
|
||||
bws := &model.BudgetWithSpent{
|
||||
Budget: *b,
|
||||
SpentCents: spent,
|
||||
Percentage: percentage,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if tag, ok := tagMap[b.TagID]; ok {
|
||||
bws.TagName = tag.Name
|
||||
bws.TagColor = tag.Color
|
||||
}
|
||||
|
||||
result = append(result, bws)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *BudgetService) UpdateBudget(dto UpdateBudgetDTO) (*model.Budget, error) {
|
||||
if dto.Amount <= 0 {
|
||||
return nil, fmt.Errorf("budget amount must be positive")
|
||||
}
|
||||
|
||||
existing, err := s.budgetRepo.GetByID(dto.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing.TagID = dto.TagID
|
||||
existing.AmountCents = dto.Amount
|
||||
existing.Period = dto.Period
|
||||
existing.StartDate = dto.StartDate
|
||||
existing.EndDate = dto.EndDate
|
||||
existing.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.budgetRepo.Update(existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *BudgetService) DeleteBudget(id string) error {
|
||||
return s.budgetRepo.Delete(id)
|
||||
}
|
||||
|
||||
func GetCurrentPeriodBounds(period model.BudgetPeriod, now time.Time) (time.Time, time.Time) {
|
||||
switch period {
|
||||
case model.BudgetPeriodWeekly:
|
||||
weekday := int(now.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
start := now.AddDate(0, 0, -(weekday - 1))
|
||||
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, now.Location())
|
||||
end := start.AddDate(0, 0, 6)
|
||||
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, now.Location())
|
||||
return start, end
|
||||
case model.BudgetPeriodYearly:
|
||||
start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
|
||||
end := time.Date(now.Year(), 12, 31, 23, 59, 59, 0, now.Location())
|
||||
return start, end
|
||||
default: // monthly
|
||||
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
end := start.AddDate(0, 1, -1)
|
||||
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, now.Location())
|
||||
return start, end
|
||||
}
|
||||
}
|
||||
265
internal/service/recurring_expense.go
Normal file
265
internal/service/recurring_expense.go
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
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 CreateRecurringExpenseDTO struct {
|
||||
SpaceID string
|
||||
UserID string
|
||||
Description string
|
||||
Amount int
|
||||
Type model.ExpenseType
|
||||
PaymentMethodID *string
|
||||
Frequency model.Frequency
|
||||
StartDate time.Time
|
||||
EndDate *time.Time
|
||||
TagIDs []string
|
||||
}
|
||||
|
||||
type UpdateRecurringExpenseDTO struct {
|
||||
ID string
|
||||
Description string
|
||||
Amount int
|
||||
Type model.ExpenseType
|
||||
PaymentMethodID *string
|
||||
Frequency model.Frequency
|
||||
StartDate time.Time
|
||||
EndDate *time.Time
|
||||
TagIDs []string
|
||||
}
|
||||
|
||||
type RecurringExpenseService struct {
|
||||
recurringRepo repository.RecurringExpenseRepository
|
||||
expenseRepo repository.ExpenseRepository
|
||||
}
|
||||
|
||||
func NewRecurringExpenseService(recurringRepo repository.RecurringExpenseRepository, expenseRepo repository.ExpenseRepository) *RecurringExpenseService {
|
||||
return &RecurringExpenseService{
|
||||
recurringRepo: recurringRepo,
|
||||
expenseRepo: expenseRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RecurringExpenseService) CreateRecurringExpense(dto CreateRecurringExpenseDTO) (*model.RecurringExpense, error) {
|
||||
if dto.Description == "" {
|
||||
return nil, fmt.Errorf("description cannot be empty")
|
||||
}
|
||||
if dto.Amount <= 0 {
|
||||
return nil, fmt.Errorf("amount must be positive")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
re := &model.RecurringExpense{
|
||||
ID: uuid.NewString(),
|
||||
SpaceID: dto.SpaceID,
|
||||
CreatedBy: dto.UserID,
|
||||
Description: dto.Description,
|
||||
AmountCents: dto.Amount,
|
||||
Type: dto.Type,
|
||||
PaymentMethodID: dto.PaymentMethodID,
|
||||
Frequency: dto.Frequency,
|
||||
StartDate: dto.StartDate,
|
||||
EndDate: dto.EndDate,
|
||||
NextOccurrence: dto.StartDate,
|
||||
IsActive: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := s.recurringRepo.Create(re, dto.TagIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return re, nil
|
||||
}
|
||||
|
||||
func (s *RecurringExpenseService) GetRecurringExpense(id string) (*model.RecurringExpense, error) {
|
||||
return s.recurringRepo.GetByID(id)
|
||||
}
|
||||
|
||||
func (s *RecurringExpenseService) GetRecurringExpensesForSpace(spaceID string) ([]*model.RecurringExpense, error) {
|
||||
return s.recurringRepo.GetBySpaceID(spaceID)
|
||||
}
|
||||
|
||||
func (s *RecurringExpenseService) GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID string) ([]*model.RecurringExpenseWithTagsAndMethod, error) {
|
||||
recs, err := s.recurringRepo.GetBySpaceID(spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]string, len(recs))
|
||||
for i, re := range recs {
|
||||
ids[i] = re.ID
|
||||
}
|
||||
|
||||
tagsMap, err := s.recurringRepo.GetTagsByRecurringExpenseIDs(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
methodsMap, err := s.recurringRepo.GetPaymentMethodsByRecurringExpenseIDs(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*model.RecurringExpenseWithTagsAndMethod, len(recs))
|
||||
for i, re := range recs {
|
||||
result[i] = &model.RecurringExpenseWithTagsAndMethod{
|
||||
RecurringExpense: *re,
|
||||
Tags: tagsMap[re.ID],
|
||||
PaymentMethod: methodsMap[re.ID],
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *RecurringExpenseService) UpdateRecurringExpense(dto UpdateRecurringExpenseDTO) (*model.RecurringExpense, error) {
|
||||
if dto.Description == "" {
|
||||
return nil, fmt.Errorf("description cannot be empty")
|
||||
}
|
||||
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.Description = dto.Description
|
||||
existing.AmountCents = dto.Amount
|
||||
existing.Type = dto.Type
|
||||
existing.PaymentMethodID = dto.PaymentMethodID
|
||||
existing.Frequency = dto.Frequency
|
||||
existing.StartDate = dto.StartDate
|
||||
existing.EndDate = dto.EndDate
|
||||
existing.UpdatedAt = time.Now()
|
||||
|
||||
// Recalculate next occurrence if frequency or start changed
|
||||
if existing.NextOccurrence.Before(dto.StartDate) {
|
||||
existing.NextOccurrence = dto.StartDate
|
||||
}
|
||||
|
||||
if err := s.recurringRepo.Update(existing, dto.TagIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *RecurringExpenseService) DeleteRecurringExpense(id string) error {
|
||||
return s.recurringRepo.Delete(id)
|
||||
}
|
||||
|
||||
func (s *RecurringExpenseService) ToggleRecurringExpense(id string) (*model.RecurringExpense, error) {
|
||||
re, err := s.recurringRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newActive := !re.IsActive
|
||||
if err := s.recurringRepo.SetActive(id, newActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
re.IsActive = newActive
|
||||
return re, nil
|
||||
}
|
||||
|
||||
func (s *RecurringExpenseService) ProcessDueRecurrences(now time.Time) error {
|
||||
dues, err := s.recurringRepo.GetDueRecurrences(now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get due recurrences: %w", err)
|
||||
}
|
||||
|
||||
for _, re := range dues {
|
||||
if err := s.processRecurrence(re, now); err != nil {
|
||||
slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecurringExpenseService) ProcessDueRecurrencesForSpace(spaceID string, now time.Time) error {
|
||||
dues, err := s.recurringRepo.GetDueRecurrencesForSpace(spaceID, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get due recurrences for space: %w", err)
|
||||
}
|
||||
|
||||
for _, re := range dues {
|
||||
if err := s.processRecurrence(re, now); err != nil {
|
||||
slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RecurringExpenseService) processRecurrence(re *model.RecurringExpense, now time.Time) error {
|
||||
// Get tag IDs for this recurring expense
|
||||
tagsMap, err := s.recurringRepo.GetTagsByRecurringExpenseIDs([]string{re.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var tagIDs []string
|
||||
for _, t := range tagsMap[re.ID] {
|
||||
tagIDs = append(tagIDs, t.ID)
|
||||
}
|
||||
|
||||
// Generate expenses for each missed occurrence up to now
|
||||
for !re.NextOccurrence.After(now) {
|
||||
// Check if end_date has been passed
|
||||
if re.EndDate != nil && re.NextOccurrence.After(*re.EndDate) {
|
||||
return s.recurringRepo.Deactivate(re.ID)
|
||||
}
|
||||
|
||||
expense := &model.Expense{
|
||||
ID: uuid.NewString(),
|
||||
SpaceID: re.SpaceID,
|
||||
CreatedBy: re.CreatedBy,
|
||||
Description: re.Description,
|
||||
AmountCents: re.AmountCents,
|
||||
Type: re.Type,
|
||||
Date: re.NextOccurrence,
|
||||
PaymentMethodID: re.PaymentMethodID,
|
||||
RecurringExpenseID: &re.ID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.expenseRepo.Create(expense, tagIDs, nil); err != nil {
|
||||
return fmt.Errorf("failed to create expense from recurring: %w", err)
|
||||
}
|
||||
|
||||
re.NextOccurrence = AdvanceDate(re.NextOccurrence, re.Frequency)
|
||||
}
|
||||
|
||||
// Check if the new next occurrence exceeds end date
|
||||
if re.EndDate != nil && re.NextOccurrence.After(*re.EndDate) {
|
||||
if err := s.recurringRepo.Deactivate(re.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.recurringRepo.UpdateNextOccurrence(re.ID, re.NextOccurrence)
|
||||
}
|
||||
|
||||
func AdvanceDate(date time.Time, freq model.Frequency) time.Time {
|
||||
switch freq {
|
||||
case model.FrequencyDaily:
|
||||
return date.AddDate(0, 0, 1)
|
||||
case model.FrequencyWeekly:
|
||||
return date.AddDate(0, 0, 7)
|
||||
case model.FrequencyBiweekly:
|
||||
return date.AddDate(0, 0, 14)
|
||||
case model.FrequencyMonthly:
|
||||
return date.AddDate(0, 1, 0)
|
||||
case model.FrequencyYearly:
|
||||
return date.AddDate(1, 0, 0)
|
||||
default:
|
||||
return date.AddDate(0, 1, 0)
|
||||
}
|
||||
}
|
||||
99
internal/service/report.go
Normal file
99
internal/service/report.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||
)
|
||||
|
||||
type ReportService struct {
|
||||
expenseRepo repository.ExpenseRepository
|
||||
}
|
||||
|
||||
func NewReportService(expenseRepo repository.ExpenseRepository) *ReportService {
|
||||
return &ReportService{expenseRepo: expenseRepo}
|
||||
}
|
||||
|
||||
type DateRange struct {
|
||||
Label string
|
||||
Key string
|
||||
From time.Time
|
||||
To time.Time
|
||||
}
|
||||
|
||||
func (s *ReportService) GetSpendingReport(spaceID string, from, to time.Time) (*model.SpendingReport, error) {
|
||||
byTag, err := s.expenseRepo.GetExpensesByTag(spaceID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
daily, err := s.expenseRepo.GetDailySpending(spaceID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
monthly, err := s.expenseRepo.GetMonthlySpending(spaceID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
topExpenses, err := s.expenseRepo.GetTopExpenses(spaceID, from, to, 10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get tags and payment methods for top expenses
|
||||
ids := make([]string, len(topExpenses))
|
||||
for i, e := range topExpenses {
|
||||
ids[i] = e.ID
|
||||
}
|
||||
|
||||
tagsMap, _ := s.expenseRepo.GetTagsByExpenseIDs(ids)
|
||||
methodsMap, _ := s.expenseRepo.GetPaymentMethodsByExpenseIDs(ids)
|
||||
|
||||
topWithTags := make([]*model.ExpenseWithTagsAndMethod, len(topExpenses))
|
||||
for i, e := range topExpenses {
|
||||
topWithTags[i] = &model.ExpenseWithTagsAndMethod{
|
||||
Expense: *e,
|
||||
Tags: tagsMap[e.ID],
|
||||
PaymentMethod: methodsMap[e.ID],
|
||||
}
|
||||
}
|
||||
|
||||
totalIncome, totalExpenses, err := s.expenseRepo.GetIncomeVsExpenseSummary(spaceID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.SpendingReport{
|
||||
ByTag: byTag,
|
||||
DailySpending: daily,
|
||||
MonthlySpending: monthly,
|
||||
TopExpenses: topWithTags,
|
||||
TotalIncome: totalIncome,
|
||||
TotalExpenses: totalExpenses,
|
||||
NetBalance: totalIncome - totalExpenses,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetPresetDateRanges(now time.Time) []DateRange {
|
||||
thisMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
thisMonthEnd := thisMonthStart.AddDate(0, 1, -1)
|
||||
thisMonthEnd = time.Date(thisMonthEnd.Year(), thisMonthEnd.Month(), thisMonthEnd.Day(), 23, 59, 59, 0, now.Location())
|
||||
|
||||
lastMonthStart := thisMonthStart.AddDate(0, -1, 0)
|
||||
lastMonthEnd := thisMonthStart.AddDate(0, 0, -1)
|
||||
lastMonthEnd = time.Date(lastMonthEnd.Year(), lastMonthEnd.Month(), lastMonthEnd.Day(), 23, 59, 59, 0, now.Location())
|
||||
|
||||
last3MonthsStart := thisMonthStart.AddDate(0, -2, 0)
|
||||
|
||||
yearStart := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
|
||||
|
||||
return []DateRange{
|
||||
{Label: "This Month", Key: "this_month", From: thisMonthStart, To: thisMonthEnd},
|
||||
{Label: "Last Month", Key: "last_month", From: lastMonthStart, To: lastMonthEnd},
|
||||
{Label: "Last 3 Months", Key: "last_3_months", From: last3MonthsStart, To: thisMonthEnd},
|
||||
{Label: "This Year", Key: "this_year", From: yearStart, To: thisMonthEnd},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue