package processor import ( "context" "fmt" "strings" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" "github.com/google/uuid" ) type CashAdvanceProcessor interface { CreateCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreateCashAdvanceRequest) (*models.CashAdvanceResponse, error) UpdateCashAdvance(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateCashAdvanceRequest) (*models.CashAdvanceResponse, error) DeleteCashAdvance(ctx context.Context, id, organizationID uuid.UUID) error GetCashAdvanceByID(ctx context.Context, id, organizationID uuid.UUID) (*models.CashAdvanceResponse, error) ListCashAdvances(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.CashAdvanceResponse, int, error) UpdateCashAdvanceStatus(ctx context.Context, id, organizationID uuid.UUID, status string) (*models.CashAdvanceResponse, error) ListCashAdvanceTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) } type CashAdvanceProcessorImpl struct { cashAdvanceRepo CashAdvanceRepository categoryRepo CategoryRepository } func NewCashAdvanceProcessorImpl(cashAdvanceRepo CashAdvanceRepository, categoryRepo CategoryRepository) *CashAdvanceProcessorImpl { return &CashAdvanceProcessorImpl{ cashAdvanceRepo: cashAdvanceRepo, categoryRepo: categoryRepo, } } func (p *CashAdvanceProcessorImpl) CreateCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreateCashAdvanceRequest) (*models.CashAdvanceResponse, error) { // The cash leaves one drawer, so the outlet has to be known: either the caller // named it or it comes from the outlet they are signed in to. resolvedOutletID := req.OutletID if resolvedOutletID == nil { resolvedOutletID = outletID } if resolvedOutletID == nil || *resolvedOutletID == uuid.Nil { return nil, fmt.Errorf("outlet_id is required") } teamScope, teamCategoryID, err := p.resolveCashAdvanceTeam(ctx, organizationID, resolvedOutletID, &req.TeamScope, req.TeamCategoryID) if err != nil { return nil, err } existing, err := p.cashAdvanceRepo.GetByCodeNumber(ctx, req.CodeNumber, organizationID) if err == nil && existing != nil { return nil, fmt.Errorf("cash advance with code number %s already exists in this organization", req.CodeNumber) } status := constants.CashAdvanceStatusDraft if req.Status != nil { status = *req.Status } cashAdvance := &entities.CashAdvance{ OrganizationID: organizationID, OutletID: *resolvedOutletID, CodeNumber: req.CodeNumber, TeamScope: teamScope, TeamCategoryID: teamCategoryID, Amount: req.Amount, IssuedDate: req.IssuedDate, DueDate: req.DueDate, Status: status, Description: req.Description, } if err := p.cashAdvanceRepo.Create(ctx, cashAdvance); err != nil { return nil, fmt.Errorf("failed to create cash advance: %w", err) } created, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID) if err != nil { return nil, fmt.Errorf("failed to get created cash advance: %w", err) } return mappers.CashAdvanceEntityToResponse(created), nil } func (p *CashAdvanceProcessorImpl) UpdateCashAdvance(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateCashAdvanceRequest) (*models.CashAdvanceResponse, error) { cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID) if err != nil { return nil, fmt.Errorf("cash advance not found: %w", err) } if req.CodeNumber != nil && *req.CodeNumber != cashAdvance.CodeNumber { existing, err := p.cashAdvanceRepo.GetByCodeNumber(ctx, *req.CodeNumber, organizationID) if err == nil && existing != nil { return nil, fmt.Errorf("cash advance with code number %s already exists in this organization", *req.CodeNumber) } cashAdvance.CodeNumber = *req.CodeNumber } if req.TeamScope != nil { teamScope, teamCategoryID, err := p.resolveCashAdvanceTeam(ctx, organizationID, &cashAdvance.OutletID, req.TeamScope, req.TeamCategoryID) if err != nil { return nil, err } cashAdvance.TeamScope = teamScope cashAdvance.TeamCategoryID = teamCategoryID } if req.Amount != nil { cashAdvance.Amount = *req.Amount } if req.ReturnedAmount != nil { cashAdvance.ReturnedAmount = *req.ReturnedAmount } if req.IssuedDate != nil { cashAdvance.IssuedDate = *req.IssuedDate } if req.DueDate != nil { cashAdvance.DueDate = req.DueDate } if req.Status != nil { if err := p.guardStatusChange(ctx, cashAdvance, *req.Status); err != nil { return nil, err } cashAdvance.Status = *req.Status } if req.Description != nil { cashAdvance.Description = req.Description } // Cash handed back can only ever be part of the cash handed out. if cashAdvance.ReturnedAmount > cashAdvance.Amount { return nil, fmt.Errorf("returned_amount cannot be greater than the cash advance amount") } if err := p.cashAdvanceRepo.Update(ctx, cashAdvance); err != nil { return nil, fmt.Errorf("failed to update cash advance: %w", err) } updated, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID) if err != nil { return nil, fmt.Errorf("failed to get updated cash advance: %w", err) } return mappers.CashAdvanceEntityToResponse(updated), nil } func (p *CashAdvanceProcessorImpl) DeleteCashAdvance(ctx context.Context, id, organizationID uuid.UUID) error { if _, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID); err != nil { return fmt.Errorf("cash advance not found: %w", err) } // The foreign keys would refuse this anyway, but not in words anyone can act on. count, err := p.cashAdvanceRepo.CountSettlements(ctx, id) if err != nil { return fmt.Errorf("failed to check cash advance settlements: %w", err) } if count > 0 { return fmt.Errorf("cash advance cannot be deleted because %d purchase orders or expenses are charged to it", count) } if err := p.cashAdvanceRepo.Delete(ctx, id); err != nil { return fmt.Errorf("failed to delete cash advance: %w", err) } return nil } func (p *CashAdvanceProcessorImpl) GetCashAdvanceByID(ctx context.Context, id, organizationID uuid.UUID) (*models.CashAdvanceResponse, error) { cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID) if err != nil { return nil, fmt.Errorf("cash advance not found: %w", err) } response := mappers.CashAdvanceEntityToResponse(cashAdvance) // The detail view is where someone checks a cash advance off, so it carries the // spending behind the settled figure. The list deliberately does not. settlements, err := p.cashAdvanceRepo.ListSettlements(ctx, id) if err != nil { return nil, fmt.Errorf("failed to list cash advance settlements: %w", err) } response.Settlements = mappers.CashAdvanceSettlementEntitiesToModels(settlements) return response, nil } func (p *CashAdvanceProcessorImpl) ListCashAdvances(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.CashAdvanceResponse, int, error) { offset := (page - 1) * limit cashAdvances, total, err := p.cashAdvanceRepo.List(ctx, organizationID, filters, limit, offset) if err != nil { return nil, 0, fmt.Errorf("failed to list cash advances: %w", err) } responses := mappers.CashAdvanceEntitiesToResponses(cashAdvances) totalPages := int((total + int64(limit) - 1) / int64(limit)) return responses, totalPages, nil } func (p *CashAdvanceProcessorImpl) UpdateCashAdvanceStatus(ctx context.Context, id, organizationID uuid.UUID, status string) (*models.CashAdvanceResponse, error) { cashAdvance, err := p.cashAdvanceRepo.GetByIDAndOrganizationID(ctx, id, organizationID) if err != nil { return nil, fmt.Errorf("cash advance not found: %w", err) } if !constants.IsValidCashAdvanceStatus(status) { return nil, fmt.Errorf("status must be one of: %s", strings.Join(constants.GetAllCashAdvanceStatuses(), ", ")) } if err := p.guardStatusChange(ctx, cashAdvance, status); err != nil { return nil, err } cashAdvance.Status = status if err := p.cashAdvanceRepo.Update(ctx, cashAdvance); err != nil { return nil, fmt.Errorf("failed to update cash advance status: %w", err) } updated, err := p.cashAdvanceRepo.GetByID(ctx, cashAdvance.ID) if err != nil { return nil, fmt.Errorf("failed to get updated cash advance: %w", err) } return mappers.CashAdvanceEntityToResponse(updated), nil } func (p *CashAdvanceProcessorImpl) ListCashAdvanceTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) { return listTeams(ctx, p.categoryRepo, organizationID, outletID) } // guardStatusChange refuses to withdraw an advance that spending already points at. // Rejecting or cancelling it would leave those purchases claiming to have been paid // out of cash the books say never went out. func (p *CashAdvanceProcessorImpl) guardStatusChange(ctx context.Context, cashAdvance *entities.CashAdvance, status string) error { if status != constants.CashAdvanceStatusRejected && status != constants.CashAdvanceStatusCancelled { return nil } count, err := p.cashAdvanceRepo.CountSettlements(ctx, cashAdvance.ID) if err != nil { return fmt.Errorf("failed to check cash advance settlements: %w", err) } if count > 0 { return fmt.Errorf("cash advance cannot be %s because %d purchase orders or expenses are charged to it", status, count) } return nil } // resolveCashAdvanceTeam is resolveTeamSelection with the one rule an advance adds: // the cash is handed to a team, so there is no such thing as one without a team. func (p *CashAdvanceProcessorImpl) resolveCashAdvanceTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (string, *uuid.UUID, error) { resolvedScope, resolvedCategoryID, err := resolveTeamSelection(ctx, p.categoryRepo, organizationID, outletID, scope, categoryID) if err != nil { return "", nil, err } if resolvedScope == nil { return "", nil, fmt.Errorf("team_scope is required") } return *resolvedScope, resolvedCategoryID, nil } // resolveSpendingCashAdvance checks that a purchase order or expense may be charged // to the advance it names: same organization and outlet, and the money actually // approved to leave the drawer. Draft or cancelled advances cannot be spent against. func resolveSpendingCashAdvance(ctx context.Context, cashAdvanceRepo CashAdvanceRepository, cashAdvanceID, organizationID uuid.UUID, outletID *uuid.UUID) (*entities.CashAdvance, error) { cashAdvance, err := cashAdvanceRepo.GetByIDAndOrganizationID(ctx, cashAdvanceID, organizationID) if err != nil { return nil, fmt.Errorf("cash advance not found: %w", err) } if cashAdvance.Status != constants.CashAdvanceStatusApproved { return nil, fmt.Errorf("cash advance %s is %s, only an approved cash advance can be spent against", cashAdvance.CodeNumber, cashAdvance.Status) } if outletID != nil && *outletID != uuid.Nil && cashAdvance.OutletID != *outletID { return nil, fmt.Errorf("cash advance %s belongs to a different outlet", cashAdvance.CodeNumber) } return cashAdvance, nil }