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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CashAdvanceRepository interface {
|
||||
Create(ctx context.Context, cashAdvance *entities.CashAdvance) error
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*entities.CashAdvance, error)
|
||||
GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.CashAdvance, error)
|
||||
GetByCodeNumber(ctx context.Context, codeNumber string, organizationID uuid.UUID) (*entities.CashAdvance, error)
|
||||
Update(ctx context.Context, cashAdvance *entities.CashAdvance) error
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.CashAdvance, int64, error)
|
||||
ListSettlements(ctx context.Context, cashAdvanceID uuid.UUID) ([]*entities.CashAdvanceSettlement, error)
|
||||
CountSettlements(ctx context.Context, cashAdvanceID uuid.UUID) (int64, error)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package processor
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
@@ -25,12 +26,14 @@ type ExpenseProcessor interface {
|
||||
type ExpenseProcessorImpl struct {
|
||||
expenseRepo ExpenseRepository
|
||||
purchaseCategoryRepo PurchaseCategoryRepository
|
||||
cashAdvanceRepo CashAdvanceRepository
|
||||
}
|
||||
|
||||
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl {
|
||||
func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository, cashAdvanceRepo CashAdvanceRepository) *ExpenseProcessorImpl {
|
||||
return &ExpenseProcessorImpl{
|
||||
expenseRepo: expenseRepo,
|
||||
purchaseCategoryRepo: purchaseCategoryRepo,
|
||||
cashAdvanceRepo: cashAdvanceRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +53,11 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
|
||||
status = *req.Status
|
||||
}
|
||||
|
||||
cashAdvanceID, err := p.resolveExpenseCashAdvance(ctx, organizationID, outletID, req.CashAdvanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]entities.ExpenseItem, len(req.Items))
|
||||
for i, itemReq := range req.Items {
|
||||
chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID)
|
||||
@@ -84,6 +92,7 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID
|
||||
Description: req.Description,
|
||||
Tax: req.Tax,
|
||||
Total: req.Total,
|
||||
CashAdvanceID: cashAdvanceID,
|
||||
}
|
||||
|
||||
err = p.expenseRepo.Create(ctx, expenseEntity)
|
||||
@@ -149,6 +158,14 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati
|
||||
if req.Reserved1 != nil {
|
||||
expenseEntity.Reserved1 = req.Reserved1
|
||||
}
|
||||
// An empty cash_advance_id unlinks the expense; omitting the field leaves it alone.
|
||||
if req.CashAdvanceID != nil {
|
||||
cashAdvanceID, err := p.resolveExpenseCashAdvance(ctx, organizationID, expenseEntity.OutletID, req.CashAdvanceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expenseEntity.CashAdvanceID = cashAdvanceID
|
||||
}
|
||||
|
||||
var items []entities.ExpenseItem
|
||||
if req.Items != nil {
|
||||
@@ -334,6 +351,25 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveExpenseCashAdvance checks the expense may be charged to the cash advance it names.
|
||||
// An empty value means no cash advance at all, which is how an update unlinks one.
|
||||
func (p *ExpenseProcessorImpl) resolveExpenseCashAdvance(ctx context.Context, organizationID, outletID uuid.UUID, raw *string) (*uuid.UUID, error) {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cashAdvanceID, err := uuid.Parse(strings.TrimSpace(*raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid cash_advance_id: %w", err)
|
||||
}
|
||||
|
||||
if _, err := resolveSpendingCashAdvance(ctx, p.cashAdvanceRepo, cashAdvanceID, organizationID, &outletID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cashAdvanceID, nil
|
||||
}
|
||||
|
||||
func (p *ExpenseProcessorImpl) validateExpensePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error {
|
||||
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
||||
if err != nil {
|
||||
|
||||
@@ -99,10 +99,40 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui
|
||||
return nil
|
||||
}
|
||||
|
||||
// Expenses in these tests are paid straight out of the drawer, so nothing here
|
||||
// reaches the cash advance repository.
|
||||
type expenseCashAdvanceRepositoryStub struct{}
|
||||
|
||||
func (*expenseCashAdvanceRepositoryStub) Create(context.Context, *entities.CashAdvance) error {
|
||||
return nil
|
||||
}
|
||||
func (*expenseCashAdvanceRepositoryStub) GetByID(context.Context, uuid.UUID) (*entities.CashAdvance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*expenseCashAdvanceRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.CashAdvance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*expenseCashAdvanceRepositoryStub) GetByCodeNumber(context.Context, string, uuid.UUID) (*entities.CashAdvance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*expenseCashAdvanceRepositoryStub) Update(context.Context, *entities.CashAdvance) error {
|
||||
return nil
|
||||
}
|
||||
func (*expenseCashAdvanceRepositoryStub) Delete(context.Context, uuid.UUID) error { return nil }
|
||||
func (*expenseCashAdvanceRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.CashAdvance, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (*expenseCashAdvanceRepositoryStub) ListSettlements(context.Context, uuid.UUID) ([]*entities.CashAdvanceSettlement, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*expenseCashAdvanceRepositoryStub) CountSettlements(context.Context, uuid.UUID) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
|
||||
repo := &expenseRepositoryCaptureStub{}
|
||||
purchaseCategoryID := uuid.New()
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{})
|
||||
chartOfAccountID := uuid.New()
|
||||
|
||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||
@@ -133,7 +163,7 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) {
|
||||
func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
|
||||
repo := &expenseRepositoryCaptureStub{}
|
||||
purchaseCategoryID := uuid.New()
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{})
|
||||
|
||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||
Receiver: "Cashier",
|
||||
@@ -160,7 +190,7 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) {
|
||||
func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
|
||||
repo := &expenseRepositoryCaptureStub{}
|
||||
purchaseCategoryID := uuid.New()
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{})
|
||||
status := "approved"
|
||||
|
||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||
@@ -189,7 +219,7 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) {
|
||||
func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) {
|
||||
repo := &expenseRepositoryCaptureStub{}
|
||||
purchaseCategoryID := uuid.New()
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial))
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial), &expenseCashAdvanceRepositoryStub{})
|
||||
|
||||
resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{
|
||||
Receiver: "Cashier",
|
||||
@@ -266,7 +296,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te
|
||||
},
|
||||
},
|
||||
}
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense))
|
||||
p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeExpense), &expenseCashAdvanceRepositoryStub{})
|
||||
|
||||
resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -25,13 +23,14 @@ type PurchaseOrderProcessor interface {
|
||||
}
|
||||
|
||||
type PurchaseOrderProcessorImpl struct {
|
||||
purchaseOrderRepo PurchaseOrderRepository
|
||||
vendorRepo VendorRepository
|
||||
ingredientRepo IngredientRepository
|
||||
purchaseCategoryRepo PurchaseCategoryRepository
|
||||
categoryRepo CategoryRepository
|
||||
unitRepo UnitRepository
|
||||
fileRepo FileRepository
|
||||
purchaseOrderRepo PurchaseOrderRepository
|
||||
vendorRepo VendorRepository
|
||||
ingredientRepo IngredientRepository
|
||||
purchaseCategoryRepo PurchaseCategoryRepository
|
||||
categoryRepo CategoryRepository
|
||||
cashAdvanceRepo CashAdvanceRepository
|
||||
unitRepo UnitRepository
|
||||
fileRepo FileRepository
|
||||
// Kept wired but currently unused: purchase orders are a record of spending
|
||||
// only, so nothing here moves stock or converts units. These stay so that
|
||||
// tying purchases back to inventory is a change in one place.
|
||||
@@ -45,6 +44,7 @@ func NewPurchaseOrderProcessorImpl(
|
||||
ingredientRepo IngredientRepository,
|
||||
purchaseCategoryRepo PurchaseCategoryRepository,
|
||||
categoryRepo CategoryRepository,
|
||||
cashAdvanceRepo CashAdvanceRepository,
|
||||
unitRepo UnitRepository,
|
||||
fileRepo FileRepository,
|
||||
inventoryMovementService InventoryMovementService,
|
||||
@@ -56,6 +56,7 @@ func NewPurchaseOrderProcessorImpl(
|
||||
ingredientRepo: ingredientRepo,
|
||||
purchaseCategoryRepo: purchaseCategoryRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
cashAdvanceRepo: cashAdvanceRepo,
|
||||
unitRepo: unitRepo,
|
||||
fileRepo: fileRepo,
|
||||
inventoryMovementService: inventoryMovementService,
|
||||
@@ -77,6 +78,11 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
return nil, err
|
||||
}
|
||||
|
||||
teamScope, teamCategoryID, err = p.applyCashAdvance(ctx, organizationID, outletID, req.CashAdvanceID, teamScope, teamCategoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if PO number already exists in organization
|
||||
existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID)
|
||||
if err == nil && existingPO != nil {
|
||||
@@ -140,6 +146,7 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
TotalAmount: totalAmount,
|
||||
TeamScope: teamScope,
|
||||
TeamCategoryID: teamCategoryID,
|
||||
CashAdvanceID: req.CashAdvanceID,
|
||||
}
|
||||
|
||||
if req.Status != nil {
|
||||
@@ -247,6 +254,26 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
||||
poEntity.TeamCategoryID = teamCategoryID
|
||||
}
|
||||
|
||||
// An all-zero cash advance id unlinks the purchase; omitting the field leaves it alone.
|
||||
if req.CashAdvanceID != nil {
|
||||
if *req.CashAdvanceID == uuid.Nil {
|
||||
poEntity.CashAdvanceID = nil
|
||||
} else {
|
||||
poEntity.CashAdvanceID = req.CashAdvanceID
|
||||
}
|
||||
}
|
||||
|
||||
// Recheck the pairing whenever either side moved: a purchase can end up on a
|
||||
// cash advance belonging to another team otherwise.
|
||||
if poEntity.CashAdvanceID != nil && (req.CashAdvanceID != nil || req.TeamScope != nil) {
|
||||
teamScope, teamCategoryID, err := p.applyCashAdvance(ctx, organizationID, poEntity.OutletID, poEntity.CashAdvanceID, poEntity.TeamScope, poEntity.TeamCategoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
poEntity.TeamScope = teamScope
|
||||
poEntity.TeamCategoryID = teamCategoryID
|
||||
}
|
||||
|
||||
// Update items if provided
|
||||
if req.Items != nil {
|
||||
totalAmount := 0.0
|
||||
@@ -467,77 +494,49 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
||||
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
|
||||
}
|
||||
|
||||
// ListPurchaseTeams returns the teams a purchase can be charged to: the parent
|
||||
// categories of the outlet in scope, followed by Pusat. Pusat has no category row,
|
||||
// so it is appended here rather than read from the database.
|
||||
// ListPurchaseTeams returns the teams a purchase can be charged to. Cash advances are
|
||||
// charged to the same teams, so the list itself is built in one shared place.
|
||||
func (p *PurchaseOrderProcessorImpl) ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
|
||||
categories, err := p.categoryRepo.ListParentCategories(ctx, organizationID, outletID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list parent categories: %w", err)
|
||||
}
|
||||
|
||||
teams := make([]models.PurchaseTeam, 0, len(categories)+1)
|
||||
for _, category := range categories {
|
||||
categoryID := category.ID
|
||||
teams = append(teams, models.PurchaseTeam{
|
||||
Scope: constants.PurchaseTeamScopeCategory,
|
||||
CategoryID: &categoryID,
|
||||
Name: category.Name,
|
||||
})
|
||||
}
|
||||
|
||||
teams = append(teams, models.PurchaseTeam{
|
||||
Scope: constants.PurchaseTeamScopeCentral,
|
||||
Name: constants.PurchaseTeamCentralName,
|
||||
})
|
||||
|
||||
return &models.ListPurchaseTeamsResponse{Teams: teams}, nil
|
||||
return listTeams(ctx, p.categoryRepo, organizationID, outletID)
|
||||
}
|
||||
|
||||
// resolvePurchaseTeam turns a requested team into the scope/category pair stored on
|
||||
// the purchase order, mirroring the database check constraint. A nil or empty scope
|
||||
// leaves the purchase without a team, which is deliberately different from Pusat.
|
||||
// Which outlet's Pusat a purchase belongs to comes from the purchase order's outlet,
|
||||
// so 'central' needs nothing stored beyond the scope itself.
|
||||
// the purchase order. A nil or empty scope leaves the purchase without a team, which
|
||||
// is deliberately different from Pusat.
|
||||
func (p *PurchaseOrderProcessorImpl) resolvePurchaseTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (*string, *uuid.UUID, error) {
|
||||
if scope == nil {
|
||||
return nil, nil, nil
|
||||
return resolveTeamSelection(ctx, p.categoryRepo, organizationID, outletID, scope, categoryID)
|
||||
}
|
||||
|
||||
// applyCashAdvance checks a purchase may be charged to the cash advance it names, and returns
|
||||
// the team it should carry. A purchase paid out of a team's cash belongs to that
|
||||
// team, so an unassigned purchase inherits it and an assigned one has to agree.
|
||||
func (p *PurchaseOrderProcessorImpl) applyCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID, cashAdvanceID *uuid.UUID, teamScope *string, teamCategoryID *uuid.UUID) (*string, *uuid.UUID, error) {
|
||||
if cashAdvanceID == nil {
|
||||
return teamScope, teamCategoryID, nil
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(*scope) {
|
||||
case "":
|
||||
return nil, nil, nil
|
||||
|
||||
case constants.PurchaseTeamScopeCentral:
|
||||
resolved := constants.PurchaseTeamScopeCentral
|
||||
return &resolved, nil, nil
|
||||
|
||||
case constants.PurchaseTeamScopeCategory:
|
||||
if categoryID == nil {
|
||||
return nil, nil, fmt.Errorf("team_category_id is required when team_scope is category")
|
||||
}
|
||||
|
||||
category, err := p.categoryRepo.GetByID(ctx, *categoryID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("team category not found: %w", err)
|
||||
}
|
||||
if category.OrganizationID != organizationID {
|
||||
return nil, nil, fmt.Errorf("team category does not belong to this organization")
|
||||
}
|
||||
if category.ParentID != nil {
|
||||
return nil, nil, fmt.Errorf("team must be a parent category")
|
||||
}
|
||||
// Categories without an outlet are shared, so only an outlet-specific
|
||||
// category has to match the outlet the purchase is booked against.
|
||||
if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID {
|
||||
return nil, nil, fmt.Errorf("team category belongs to a different outlet")
|
||||
}
|
||||
|
||||
resolved := constants.PurchaseTeamScopeCategory
|
||||
return &resolved, &category.ID, nil
|
||||
cashAdvance, err := resolveSpendingCashAdvance(ctx, p.cashAdvanceRepo, *cashAdvanceID, organizationID, outletID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("team_scope must be one of: category, central")
|
||||
if teamScope == nil {
|
||||
scope := cashAdvance.TeamScope
|
||||
return &scope, cashAdvance.TeamCategoryID, nil
|
||||
}
|
||||
|
||||
if *teamScope != cashAdvance.TeamScope || !sameUUID(teamCategoryID, cashAdvance.TeamCategoryID) {
|
||||
return nil, nil, fmt.Errorf("purchase order team must match the team cash advance %s was issued to", cashAdvance.CodeNumber)
|
||||
}
|
||||
|
||||
return teamScope, teamCategoryID, nil
|
||||
}
|
||||
|
||||
func sameUUID(a, b *uuid.UUID) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Teams are the parent product categories, plus Pusat for spending that belongs to
|
||||
// no single team. Both purchase orders and cash advances are charged to one, so the rules
|
||||
// for picking and storing a team live here rather than in either processor.
|
||||
|
||||
// listTeams returns the teams money can be charged to: the parent categories of the
|
||||
// outlet in scope, followed by Pusat. Pusat has no category row, so it is appended
|
||||
// here rather than read from the database.
|
||||
func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
|
||||
categories, err := categoryRepo.ListParentCategories(ctx, organizationID, outletID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list parent categories: %w", err)
|
||||
}
|
||||
|
||||
teams := make([]models.PurchaseTeam, 0, len(categories)+1)
|
||||
for _, category := range categories {
|
||||
categoryID := category.ID
|
||||
teams = append(teams, models.PurchaseTeam{
|
||||
Scope: constants.PurchaseTeamScopeCategory,
|
||||
CategoryID: &categoryID,
|
||||
Name: category.Name,
|
||||
})
|
||||
}
|
||||
|
||||
teams = append(teams, models.PurchaseTeam{
|
||||
Scope: constants.PurchaseTeamScopeCentral,
|
||||
Name: constants.PurchaseTeamCentralName,
|
||||
})
|
||||
|
||||
return &models.ListPurchaseTeamsResponse{Teams: teams}, nil
|
||||
}
|
||||
|
||||
// resolveTeamSelection turns a requested team into the scope/category pair that gets
|
||||
// stored, mirroring the database check constraint. A nil or empty scope means no
|
||||
// team, which is deliberately different from Pusat — callers that require a team
|
||||
// reject that case before getting here. Which outlet's Pusat it is comes from the
|
||||
// record's own outlet, so 'central' needs nothing stored beyond the scope itself.
|
||||
func resolveTeamSelection(ctx context.Context, categoryRepo CategoryRepository, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (*string, *uuid.UUID, error) {
|
||||
if scope == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(*scope) {
|
||||
case "":
|
||||
return nil, nil, nil
|
||||
|
||||
case constants.PurchaseTeamScopeCentral:
|
||||
resolved := constants.PurchaseTeamScopeCentral
|
||||
return &resolved, nil, nil
|
||||
|
||||
case constants.PurchaseTeamScopeCategory:
|
||||
if categoryID == nil {
|
||||
return nil, nil, fmt.Errorf("team_category_id is required when team_scope is category")
|
||||
}
|
||||
|
||||
category, err := categoryRepo.GetByID(ctx, *categoryID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("team category not found: %w", err)
|
||||
}
|
||||
if category.OrganizationID != organizationID {
|
||||
return nil, nil, fmt.Errorf("team category does not belong to this organization")
|
||||
}
|
||||
if category.ParentID != nil {
|
||||
return nil, nil, fmt.Errorf("team must be a parent category")
|
||||
}
|
||||
// Categories without an outlet are shared, so only an outlet-specific
|
||||
// category has to match the outlet the record is booked against.
|
||||
if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID {
|
||||
return nil, nil, fmt.Errorf("team category belongs to a different outlet")
|
||||
}
|
||||
|
||||
resolved := constants.PurchaseTeamScopeCategory
|
||||
return &resolved, &category.ID, nil
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("team_scope must be one of: category, central")
|
||||
}
|
||||
Reference in New Issue
Block a user