feat(savings): Add savings module with categories, transactions, recurring plans

- Categories: regular, deposits, credits, recurring, multi-user, accounts
- Transactions: deposits and withdrawals with user tracking
- Recurring plans: monthly payment obligations per user
- Stats: overdues calculation with allocation algorithm
- Excludes is_account categories from total sums
- Documentation: docs/SAVINGS.md
This commit is contained in:
Cosmo
2026-02-16 06:48:09 +00:00
parent 9e90aa6d95
commit 2a50e50771
18 changed files with 2910 additions and 162 deletions

View File

@@ -2,18 +2,26 @@ package service
import (
"database/sql"
"errors"
"time"
"github.com/daniil/homelab-api/internal/model"
"github.com/daniil/homelab-api/internal/repository"
)
var ErrFutureDate = errors.New("cannot log habit for future date")
var ErrAlreadyLogged = errors.New("habit already logged for this date")
type HabitService struct {
habitRepo *repository.HabitRepository
habitRepo *repository.HabitRepository
freezeRepo *repository.HabitFreezeRepository
}
func NewHabitService(habitRepo *repository.HabitRepository) *HabitService {
return &HabitService{habitRepo: habitRepo}
func NewHabitService(habitRepo *repository.HabitRepository, freezeRepo *repository.HabitFreezeRepository) *HabitService {
return &HabitService{
habitRepo: habitRepo,
freezeRepo: freezeRepo,
}
}
func (s *HabitService) Create(userID int64, req *model.CreateHabitRequest) (*model.Habit, error) {
@@ -32,6 +40,17 @@ func (s *HabitService) Create(userID int64, req *model.CreateHabitRequest) (*mod
habit.ReminderTime = sql.NullString{String: *req.ReminderTime, Valid: true}
}
// Handle start_date - default to today if not provided
if req.StartDate != nil && *req.StartDate != "" {
parsed, err := time.Parse("2006-01-02", *req.StartDate)
if err == nil {
habit.StartDate = sql.NullTime{Time: parsed, Valid: true}
}
} else {
// Default to today
habit.StartDate = sql.NullTime{Time: time.Now().Truncate(24 * time.Hour), Valid: true}
}
if err := s.habitRepo.Create(habit); err != nil {
return nil, err
}
@@ -89,6 +108,16 @@ func (s *HabitService) Update(id, userID int64, req *model.UpdateHabitRequest) (
habit.ReminderTime = sql.NullString{String: *req.ReminderTime, Valid: true}
}
}
if req.StartDate != nil {
if *req.StartDate == "" {
habit.StartDate = sql.NullTime{Valid: false}
} else {
parsed, err := time.Parse("2006-01-02", *req.StartDate)
if err == nil {
habit.StartDate = sql.NullTime{Time: parsed, Valid: true}
}
}
}
if req.IsArchived != nil {
habit.IsArchived = *req.IsArchived
}
@@ -111,13 +140,29 @@ func (s *HabitService) Log(habitID, userID int64, req *model.LogHabitRequest) (*
return nil, err
}
date := time.Now().Truncate(24 * time.Hour)
today := time.Now().Truncate(24 * time.Hour)
date := today
if req.Date != "" {
parsed, err := time.Parse("2006-01-02", req.Date)
if err != nil {
return nil, err
}
date = parsed
date = parsed.Truncate(24 * time.Hour)
}
// Validate: cannot log for future date
if date.After(today) {
return nil, ErrFutureDate
}
// Check if already logged for this date
alreadyLogged, err := s.habitRepo.IsHabitCompletedOnDate(habitID, userID, date)
if err != nil {
return nil, err
}
if alreadyLogged {
return nil, ErrAlreadyLogged
}
log := &model.HabitLog{
@@ -160,11 +205,20 @@ func (s *HabitService) DeleteLog(logID, userID int64) error {
func (s *HabitService) GetHabitStats(habitID, userID int64) (*model.HabitStats, error) {
// Verify habit exists and belongs to user
if _, err := s.habitRepo.GetByID(habitID, userID); err != nil {
habit, err := s.habitRepo.GetByID(habitID, userID)
if err != nil {
return nil, err
}
return s.habitRepo.GetStats(habitID, userID)
stats, err := s.habitRepo.GetStats(habitID, userID)
if err != nil {
return nil, err
}
// Recalculate completion percentage with frozen days excluded
stats.CompletionPct = s.calculateCompletionPctWithFreezes(habit, stats.TotalLogs)
return stats, nil
}
func (s *HabitService) GetOverallStats(userID int64) (*model.OverallStats, error) {
@@ -190,6 +244,75 @@ func (s *HabitService) GetOverallStats(userID int64) (*model.OverallStats, error
}, nil
}
// calculateCompletionPctWithFreezes calculates completion % excluding frozen days
func (s *HabitService) calculateCompletionPctWithFreezes(habit *model.Habit, totalLogs int) float64 {
if totalLogs == 0 {
return 0
}
// Use start_date if set, otherwise use created_at
var startDate time.Time
if habit.StartDate.Valid {
startDate = habit.StartDate.Time.Truncate(24 * time.Hour)
} else {
startDate = habit.CreatedAt.Truncate(24 * time.Hour)
}
today := time.Now().Truncate(24 * time.Hour)
// Get frozen days count for this habit
frozenDays, err := s.freezeRepo.CountFrozenDaysInRange(habit.ID, startDate, today)
if err != nil {
frozenDays = 0
}
expectedCount := 0
// For interval habits, calculate expected differently
if (habit.Frequency == "interval" || habit.Frequency == "custom") && habit.TargetCount > 0 {
// Expected = (days since start - frozen days) / interval + 1
totalDays := int(today.Sub(startDate).Hours()/24) + 1 - frozenDays
if totalDays <= 0 {
return 100
}
expectedCount = (totalDays / habit.TargetCount) + 1
} else {
for d := startDate; !d.After(today); d = d.AddDate(0, 0, 1) {
// Check if this day is frozen
frozen, _ := s.freezeRepo.IsHabitFrozenOnDate(habit.ID, d)
if frozen {
continue
}
if habit.Frequency == "daily" {
expectedCount++
} else if habit.Frequency == "weekly" && len(habit.TargetDays) > 0 {
weekday := int(d.Weekday())
if weekday == 0 {
weekday = 7
}
for _, td := range habit.TargetDays {
if td == weekday {
expectedCount++
break
}
}
} else {
expectedCount++
}
}
}
if expectedCount == 0 {
return 100
}
pct := float64(totalLogs) / float64(expectedCount) * 100
if pct > 100 {
pct = 100
}
return pct
}
func defaultString(val, def string) string {
if val == "" {
return def

View File

@@ -18,12 +18,14 @@ func NewTaskService(taskRepo *repository.TaskRepository) *TaskService {
func (s *TaskService) Create(userID int64, req *model.CreateTaskRequest) (*model.Task, error) {
task := &model.Task{
UserID: userID,
Title: req.Title,
Description: req.Description,
Icon: defaultString(req.Icon, "📋"),
Color: defaultString(req.Color, "#6B7280"),
Priority: req.Priority,
UserID: userID,
Title: req.Title,
Description: req.Description,
Icon: defaultString(req.Icon, "📋"),
Color: defaultString(req.Color, "#6B7280"),
Priority: req.Priority,
IsRecurring: req.IsRecurring,
RecurrenceInterval: defaultInt(req.RecurrenceInterval, 1),
}
if req.DueDate != nil && *req.DueDate != "" {
@@ -37,6 +39,17 @@ func (s *TaskService) Create(userID int64, req *model.CreateTaskRequest) (*model
task.ReminderTime = sql.NullString{String: *req.ReminderTime, Valid: true}
}
if req.RecurrenceType != nil && *req.RecurrenceType != "" {
task.RecurrenceType = sql.NullString{String: *req.RecurrenceType, Valid: true}
}
if req.RecurrenceEndDate != nil && *req.RecurrenceEndDate != "" {
parsed, err := time.Parse("2006-01-02", *req.RecurrenceEndDate)
if err == nil {
task.RecurrenceEndDate = sql.NullTime{Time: parsed, Valid: true}
}
}
if err := s.taskRepo.Create(task); err != nil {
return nil, err
}
@@ -110,6 +123,31 @@ func (s *TaskService) Update(id, userID int64, req *model.UpdateTaskRequest) (*m
}
}
// Handle recurring fields
if req.IsRecurring != nil {
task.IsRecurring = *req.IsRecurring
}
if req.RecurrenceType != nil {
if *req.RecurrenceType == "" {
task.RecurrenceType = sql.NullString{Valid: false}
} else {
task.RecurrenceType = sql.NullString{String: *req.RecurrenceType, Valid: true}
}
}
if req.RecurrenceInterval != nil {
task.RecurrenceInterval = *req.RecurrenceInterval
}
if req.RecurrenceEndDate != nil {
if *req.RecurrenceEndDate == "" {
task.RecurrenceEndDate = sql.NullTime{Valid: false}
} else {
parsed, err := time.Parse("2006-01-02", *req.RecurrenceEndDate)
if err == nil {
task.RecurrenceEndDate = sql.NullTime{Time: parsed, Valid: true}
}
}
}
if err := s.taskRepo.Update(task); err != nil {
return nil, err
}
@@ -123,15 +161,78 @@ func (s *TaskService) Delete(id, userID int64) error {
}
func (s *TaskService) Complete(id, userID int64) (*model.Task, error) {
// First, get the task to check if it's recurring
task, err := s.taskRepo.GetByID(id, userID)
if err != nil {
return nil, err
}
// Complete the current task
if err := s.taskRepo.Complete(id, userID); err != nil {
return nil, err
}
// If task is recurring, create the next occurrence
if task.IsRecurring && task.RecurrenceType.Valid && task.DueDate.Valid {
s.createNextRecurrence(task)
}
return s.taskRepo.GetByID(id, userID)
}
func (s *TaskService) createNextRecurrence(task *model.Task) {
// Calculate next due date based on recurrence type
var nextDueDate time.Time
interval := task.RecurrenceInterval
if interval < 1 {
interval = 1
}
currentDue := task.DueDate.Time
switch task.RecurrenceType.String {
case "daily":
nextDueDate = currentDue.AddDate(0, 0, interval)
case "weekly":
nextDueDate = currentDue.AddDate(0, 0, 7*interval)
case "monthly":
nextDueDate = currentDue.AddDate(0, interval, 0)
case "custom":
nextDueDate = currentDue.AddDate(0, 0, interval)
default:
return // Unknown recurrence type, don't create
}
// Check if next date is past the end date
if task.RecurrenceEndDate.Valid && nextDueDate.After(task.RecurrenceEndDate.Time) {
return // Don't create task past end date
}
// Create the next task
nextTask := &model.Task{
UserID: task.UserID,
Title: task.Title,
Description: task.Description,
Icon: task.Icon,
Color: task.Color,
Priority: task.Priority,
DueDate: sql.NullTime{Time: nextDueDate, Valid: true},
ReminderTime: task.ReminderTime,
IsRecurring: true,
RecurrenceType: task.RecurrenceType,
RecurrenceInterval: task.RecurrenceInterval,
RecurrenceEndDate: task.RecurrenceEndDate,
ParentTaskID: sql.NullInt64{Int64: task.ID, Valid: true},
}
// Silently create, ignore errors
s.taskRepo.Create(nextTask)
}
func (s *TaskService) Uncomplete(id, userID int64) (*model.Task, error) {
if err := s.taskRepo.Uncomplete(id, userID); err != nil {
return nil, err
}
return s.taskRepo.GetByID(id, userID)
}