package services import ( "FamilyHub/src/domain" "FamilyHub/src/repositories" "FamilyHub/src/utils" "context" "database/sql" "errors" "fmt" "log" "strings" ) type TransactionService interface { Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) Delete(ctx context.Context, id int64) error } type transactionService struct { repo repositories.TransactionRepository activityRepo repositories.ActivityRepository } func NewTransactionService(repo repositories.TransactionRepository, activityRepo repositories.ActivityRepository) TransactionService { return &transactionService{repo: repo, activityRepo: activityRepo} } var ( ErrTransactionNotFound = errors.New("transaction not found") ErrTransactionPatch = errors.New("empty update payload") ErrReceiptLinkConflict = errors.New("receipt_id and detach_receipt cannot be used together") ErrReceiptAlreadyExists = errors.New("receipt already exists") ErrInvalidTransaction = errors.New("type and category are required") ErrReceiptNotFound = errors.New("receipt not found") ErrInvalidAnalytics = errors.New("type must be income or expense") ) func (s *transactionService) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) { log.Printf("transaction create request: payload=%s", utils.ToLogJSON(req)) if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Category) == "" { log.Printf("transaction create failed: err=%v payload=%s", ErrInvalidTransaction, utils.ToLogJSON(req)) return nil, ErrInvalidTransaction } transaction := &domain.Transaction{ FamilyID: req.FamilyID, Description: req.Description, Type: req.Type, DateTime: req.DateTime, Category: req.Category, Amount: req.Amount, CreatedBy: req.CreatedBy, ReceiptID: req.ReceiptID, } if err := s.repo.Create(ctx, transaction); err != nil { if errors.Is(err, repositories.ErrReceiptNotFound) { log.Printf("transaction create failed: err=%v payload=%s", ErrReceiptNotFound, utils.ToLogJSON(req)) return nil, ErrReceiptNotFound } log.Printf("transaction create failed: err=%v payload=%s", err, utils.ToLogJSON(req)) return nil, err } if s.activityRepo != nil { description := fmt.Sprintf("Created transaction %d", transaction.ID) _ = s.activityRepo.Create(ctx, &domain.ActivityLog{ FamilyID: &transaction.FamilyID, UserID: transaction.CreatedBy, Action: "create", EntityType: "transaction", EntityID: &transaction.ID, Description: description, }) } log.Printf("transaction create response: payload=%s", utils.ToLogJSON(transaction)) return transaction, nil } func (s *transactionService) GetByID(ctx context.Context, id int64) (*domain.Transaction, error) { transaction, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } if transaction == nil { return nil, ErrTransactionNotFound } return transaction, nil } func (s *transactionService) List(ctx context.Context, filter domain.TransactionListFilter) ([]*domain.Transaction, error) { if filter.Limit <= 0 { filter.Limit = 50 } if filter.Limit > 200 { filter.Limit = 200 } if filter.Offset < 0 { filter.Offset = 0 } return s.repo.List(ctx, filter) } func (s *transactionService) Analytics(ctx context.Context, filter domain.TransactionAnalyticsFilter) (domain.TransactionAnalytics, error) { if filter.Type != nil { typeValue := strings.TrimSpace(*filter.Type) if typeValue != "income" && typeValue != "expense" { return domain.TransactionAnalytics{}, ErrInvalidAnalytics } filter.Type = &typeValue } return s.repo.Analytics(ctx, filter) } func (s *transactionService) Update(ctx context.Context, id int64, req domain.UpdateTransactionRequest) (*domain.Transaction, error) { if req.ReceiptID != nil && req.DetachReceipt { return nil, ErrReceiptLinkConflict } existing, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } if existing == nil { return nil, ErrTransactionNotFound } if req.Description == nil && req.Type == nil && req.DateTime == nil && req.Category == nil && req.Amount == nil && req.ReceiptID == nil && !req.DetachReceipt { return nil, ErrTransactionPatch } updated := &domain.Transaction{ ID: existing.ID, FamilyID: existing.FamilyID, Description: existing.Description, Type: existing.Type, DateTime: existing.DateTime, Category: existing.Category, Amount: existing.Amount, CreatedAt: existing.CreatedAt, CreatedBy: existing.CreatedBy, ReceiptID: existing.ReceiptID, } if req.Description != nil { updated.Description = req.Description } if req.Type != nil { updated.Type = *req.Type } if req.DateTime != nil { updated.DateTime = *req.DateTime } if req.Category != nil { updated.Category = *req.Category } if req.Amount != nil { updated.Amount = *req.Amount } syncReceipt := false if req.DetachReceipt { updated.ReceiptID = nil syncReceipt = true } if req.ReceiptID != nil { updated.ReceiptID = req.ReceiptID syncReceipt = true } if strings.TrimSpace(updated.Type) == "" || strings.TrimSpace(updated.Category) == "" { return nil, ErrInvalidTransaction } if err := s.repo.Update(ctx, updated, syncReceipt); err != nil { if errors.Is(err, repositories.ErrReceiptNotFound) { return nil, ErrReceiptNotFound } if errors.Is(err, sql.ErrNoRows) { return nil, ErrTransactionNotFound } return nil, err } return s.repo.GetByID(ctx, id) } func (s *transactionService) Delete(ctx context.Context, id int64) error { transaction, err := s.repo.GetByID(ctx, id) if err != nil { return err } if transaction == nil { return ErrTransactionNotFound } return s.repo.Delete(ctx, id) }