feat: cash advance
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// Amounts are money rounded to two decimals, so anything under half a cent apart
|
||||
// is the same figure. Comparing them directly would leave an advance a hundredth of
|
||||
// a rupiah short of settled.
|
||||
const cashAdvanceAmountEpsilon = 0.005
|
||||
|
||||
// cashAdvanceTeamFromEntity renders the team an advance was handed to. Unlike a
|
||||
// purchase order there is always one, so this never returns nil. The category name
|
||||
// is only filled in when TeamCategory was preloaded.
|
||||
func cashAdvanceTeamFromEntity(entity *entities.CashAdvance) *models.PurchaseTeam {
|
||||
team := &models.PurchaseTeam{Scope: entity.TeamScope}
|
||||
|
||||
switch entity.TeamScope {
|
||||
case constants.PurchaseTeamScopeCentral:
|
||||
team.Name = constants.PurchaseTeamCentralName
|
||||
case constants.PurchaseTeamScopeCategory:
|
||||
team.CategoryID = entity.TeamCategoryID
|
||||
if entity.TeamCategory != nil {
|
||||
team.Name = entity.TeamCategory.Name
|
||||
}
|
||||
}
|
||||
|
||||
return team
|
||||
}
|
||||
|
||||
// cashAdvanceSettlementStatus reads the money rather than any stored flag: cash is
|
||||
// accounted for by spending charged to the advance plus what was handed back. A
|
||||
// team that overspent still counts as settled — the excess is owed back to them.
|
||||
func cashAdvanceSettlementStatus(amount, settled, returned float64) string {
|
||||
accounted := settled + returned
|
||||
|
||||
switch {
|
||||
case accounted <= cashAdvanceAmountEpsilon:
|
||||
return constants.CashAdvanceSettlementOpen
|
||||
case amount-accounted > cashAdvanceAmountEpsilon:
|
||||
return constants.CashAdvanceSettlementPartial
|
||||
default:
|
||||
return constants.CashAdvanceSettlementSettled
|
||||
}
|
||||
}
|
||||
|
||||
func CashAdvanceEntityToModel(entity *entities.CashAdvance) *models.CashAdvance {
|
||||
if entity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.CashAdvance{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
CodeNumber: entity.CodeNumber,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
Amount: entity.Amount,
|
||||
ReturnedAmount: entity.ReturnedAmount,
|
||||
IssuedDate: entity.IssuedDate,
|
||||
DueDate: entity.DueDate,
|
||||
Status: entity.Status,
|
||||
Description: entity.Description,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func CashAdvanceEntityToResponse(entity *entities.CashAdvance) *models.CashAdvanceResponse {
|
||||
if entity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.CashAdvanceResponse{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
CodeNumber: entity.CodeNumber,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
Amount: entity.Amount,
|
||||
SettledAmount: entity.SettledAmount,
|
||||
ReturnedAmount: entity.ReturnedAmount,
|
||||
RemainingAmount: entity.Amount - entity.SettledAmount - entity.ReturnedAmount,
|
||||
SettlementStatus: cashAdvanceSettlementStatus(entity.Amount, entity.SettledAmount, entity.ReturnedAmount),
|
||||
IssuedDate: entity.IssuedDate,
|
||||
DueDate: entity.DueDate,
|
||||
Status: entity.Status,
|
||||
Description: entity.Description,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
Team: cashAdvanceTeamFromEntity(entity),
|
||||
}
|
||||
}
|
||||
|
||||
func CashAdvanceEntitiesToResponses(entities []*entities.CashAdvance) []*models.CashAdvanceResponse {
|
||||
if entities == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
responses := make([]*models.CashAdvanceResponse, len(entities))
|
||||
for i, entity := range entities {
|
||||
responses[i] = CashAdvanceEntityToResponse(entity)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
func CashAdvanceSettlementEntitiesToModels(settlements []*entities.CashAdvanceSettlement) []models.CashAdvanceSettlement {
|
||||
if settlements == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]models.CashAdvanceSettlement, len(settlements))
|
||||
for i, settlement := range settlements {
|
||||
result[i] = models.CashAdvanceSettlement{
|
||||
Type: settlement.Type,
|
||||
ID: settlement.ID,
|
||||
Number: settlement.Number,
|
||||
Date: settlement.Date,
|
||||
Amount: settlement.Amount,
|
||||
Status: settlement.Status,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCashAdvanceSettlementStatusFollowsTheMoney(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
amount float64
|
||||
settled float64
|
||||
returned float64
|
||||
expectedStatus string
|
||||
expectedRemaining float64
|
||||
}{
|
||||
{
|
||||
name: "nothing spent or returned is still open",
|
||||
amount: 500000,
|
||||
expectedStatus: constants.CashAdvanceSettlementOpen,
|
||||
expectedRemaining: 500000,
|
||||
},
|
||||
{
|
||||
name: "some spending leaves it partial",
|
||||
amount: 500000,
|
||||
settled: 200000,
|
||||
expectedStatus: constants.CashAdvanceSettlementPartial,
|
||||
expectedRemaining: 300000,
|
||||
},
|
||||
{
|
||||
name: "cash handed back counts the same as spending",
|
||||
amount: 500000,
|
||||
settled: 300000,
|
||||
returned: 100000,
|
||||
expectedStatus: constants.CashAdvanceSettlementPartial,
|
||||
expectedRemaining: 100000,
|
||||
},
|
||||
{
|
||||
name: "spending plus cash back covering the advance settles it",
|
||||
amount: 500000,
|
||||
settled: 420000,
|
||||
returned: 80000,
|
||||
expectedStatus: constants.CashAdvanceSettlementSettled,
|
||||
expectedRemaining: 0,
|
||||
},
|
||||
{
|
||||
// The team paid the difference out of pocket, so the outlet owes them.
|
||||
name: "overspending settles the cash advance and goes negative",
|
||||
amount: 500000,
|
||||
settled: 620000,
|
||||
expectedStatus: constants.CashAdvanceSettlementSettled,
|
||||
expectedRemaining: -120000,
|
||||
},
|
||||
{
|
||||
// Two decimals of money should not leave a cash advance a fraction short.
|
||||
name: "a rounding crumb short still settles",
|
||||
amount: 100000,
|
||||
settled: 99999.999,
|
||||
expectedStatus: constants.CashAdvanceSettlementSettled,
|
||||
expectedRemaining: 0.001,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
response := CashAdvanceEntityToResponse(&entities.CashAdvance{
|
||||
ID: uuid.New(),
|
||||
TeamScope: constants.PurchaseTeamScopeCentral,
|
||||
Amount: tt.amount,
|
||||
SettledAmount: tt.settled,
|
||||
ReturnedAmount: tt.returned,
|
||||
})
|
||||
|
||||
require.Equal(t, tt.expectedStatus, response.SettlementStatus)
|
||||
require.InDelta(t, tt.expectedRemaining, response.RemainingAmount, 0.0001)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCashAdvanceTeamAlwaysRendered(t *testing.T) {
|
||||
categoryID := uuid.New()
|
||||
|
||||
central := CashAdvanceEntityToResponse(&entities.CashAdvance{TeamScope: constants.PurchaseTeamScopeCentral})
|
||||
require.NotNil(t, central.Team)
|
||||
require.Equal(t, constants.PurchaseTeamCentralName, central.Team.Name)
|
||||
require.Nil(t, central.Team.CategoryID)
|
||||
|
||||
category := CashAdvanceEntityToResponse(&entities.CashAdvance{
|
||||
TeamScope: constants.PurchaseTeamScopeCategory,
|
||||
TeamCategoryID: &categoryID,
|
||||
TeamCategory: &entities.Category{ID: categoryID, Name: "Dapur"},
|
||||
})
|
||||
require.NotNil(t, category.Team)
|
||||
require.Equal(t, "Dapur", category.Team.Name)
|
||||
require.Equal(t, &categoryID, category.Team.CategoryID)
|
||||
}
|
||||
@@ -66,6 +66,7 @@ func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse {
|
||||
Tax: entity.Tax,
|
||||
Total: entity.Total,
|
||||
Reserved1: entity.Reserved1,
|
||||
CashAdvanceID: entity.CashAdvanceID,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
|
||||
TotalAmount: entity.TotalAmount,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
CashAdvanceID: entity.CashAdvanceID,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
@@ -71,6 +72,7 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
|
||||
TotalAmount: model.TotalAmount,
|
||||
TeamScope: model.TeamScope,
|
||||
TeamCategoryID: model.TeamCategoryID,
|
||||
CashAdvanceID: model.CashAdvanceID,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
}
|
||||
@@ -95,6 +97,7 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
|
||||
TotalAmount: entity.TotalAmount,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
CashAdvanceID: entity.CashAdvanceID,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
Team: purchaseTeamFromEntity(entity),
|
||||
|
||||
Reference in New Issue
Block a user