feat: cash advance

This commit is contained in:
Efril
2026-08-13 14:38:28 +07:00
parent e6078e3c0b
commit 2c6864147b
36 changed files with 2355 additions and 186 deletions
@@ -0,0 +1,286 @@
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
}