feat: cash advance
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user