feat: update profit sharing
This commit is contained in:
@@ -545,6 +545,7 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
// The block covers a single parent category, so it reports that category's own fee
|
||||
if detail.Summary != nil {
|
||||
budget.Percentages.Owner = detail.Summary.OwnerFeePercent
|
||||
budget.Percentages.Team = 100 - detail.Summary.OwnerFeePercent
|
||||
}
|
||||
|
||||
return &models.ParentCategoryAnalyticsDetailResponse{
|
||||
@@ -597,17 +598,17 @@ func endOfWeek(t time.Time) time.Time {
|
||||
return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond)
|
||||
}
|
||||
|
||||
// newBudgetPeriod splits a period's revenue into the spending limits. The owner limit
|
||||
// is passed in because a parent category can carry its own owner fee percent.
|
||||
// newBudgetPeriod splits a period's revenue between the owner and the team. The owner
|
||||
// share is passed in because a parent category can carry its own owner fee percent,
|
||||
// and the team takes whatever the owner does not.
|
||||
func newBudgetPeriod(start, end time.Time, revenue, sdl float64, orderCount int64) models.BudgetPeriod {
|
||||
return models.BudgetPeriod{
|
||||
PeriodStart: start,
|
||||
PeriodEnd: end,
|
||||
Revenue: revenue,
|
||||
OrderCount: orderCount,
|
||||
LimitPurchase: revenue * constants.BudgetLimitPurchasePercent / 100,
|
||||
SDL: sdl,
|
||||
LimitTeam: revenue * constants.BudgetLimitTeamPercent / 100,
|
||||
PeriodStart: start,
|
||||
PeriodEnd: end,
|
||||
Revenue: revenue,
|
||||
OrderCount: orderCount,
|
||||
SDL: sdl,
|
||||
LimitTeam: revenue - sdl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,9 +622,8 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
||||
|
||||
budget := models.BudgetCutOff{
|
||||
Percentages: models.BudgetPercentages{
|
||||
Purchase: constants.BudgetLimitPurchasePercent,
|
||||
Owner: constants.BudgetLimitOwnerPercent,
|
||||
Team: constants.BudgetLimitTeamPercent,
|
||||
Owner: constants.BudgetLimitOwnerPercent,
|
||||
Team: 100 - constants.BudgetLimitOwnerPercent,
|
||||
},
|
||||
CutOffFrom: cutOffFrom,
|
||||
CutOffTo: cutOffTo,
|
||||
|
||||
@@ -607,7 +607,7 @@ func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) {
|
||||
}
|
||||
|
||||
// A parent category with its own owner fee percent moves the owner limit away from the
|
||||
// default share, while purchase and team stay on the default split of revenue.
|
||||
// default share, and the team limit shrinks by the same amount.
|
||||
func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) {
|
||||
monday := time.Date(2026, 9, 28, 0, 0, 0, 0, time.UTC)
|
||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||
@@ -636,8 +636,9 @@ func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) {
|
||||
|
||||
require.Len(t, result.Budget.Weekly, 1)
|
||||
require.Equal(t, float64(900), result.Budget.Weekly[0].SDL)
|
||||
require.Equal(t, float64(1800), result.Budget.Weekly[0].LimitPurchase)
|
||||
require.Equal(t, float64(600), result.Budget.Weekly[0].LimitTeam)
|
||||
require.Equal(t, float64(2100), result.Budget.Weekly[0].LimitTeam)
|
||||
require.Equal(t, float64(20), result.Budget.Percentages.Owner)
|
||||
require.Equal(t, float64(80), result.Budget.Percentages.Team)
|
||||
require.Len(t, result.Budget.Monthly, 1)
|
||||
require.Equal(t, float64(900), result.Budget.Monthly[0].SDL)
|
||||
require.Equal(t, float64(900), result.Budget.Total.SDL)
|
||||
|
||||
@@ -93,18 +93,17 @@ func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, budget.Weekly, 1)
|
||||
|
||||
// 60 / 20 of the week's revenue; the owner limit is whatever the repository
|
||||
// summed from each parent category's own fee percent
|
||||
// The owner limit is whatever the repository summed from each parent category's
|
||||
// own fee percent, and the team takes the rest of the week's revenue
|
||||
week := budget.Weekly[0]
|
||||
require.Equal(t, float64(6_000_000), week.LimitPurchase)
|
||||
require.Equal(t, float64(2_500_000), week.SDL)
|
||||
require.Equal(t, float64(2_000_000), week.LimitTeam)
|
||||
require.Equal(t, float64(7_500_000), week.LimitTeam)
|
||||
require.Equal(t, int64(120), week.OrderCount)
|
||||
|
||||
// Totals mirror the single week
|
||||
require.Equal(t, week.Revenue, budget.Total.Revenue)
|
||||
require.Equal(t, week.LimitPurchase, budget.Total.LimitPurchase)
|
||||
require.Equal(t, week.SDL, budget.Total.SDL)
|
||||
require.Equal(t, week.LimitTeam, budget.Total.LimitTeam)
|
||||
}
|
||||
|
||||
func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
||||
@@ -112,8 +111,8 @@ func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
||||
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||
|
||||
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100},
|
||||
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60},
|
||||
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100, SDL: 2_000_000},
|
||||
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60, SDL: 1_000_000},
|
||||
}}
|
||||
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||
|
||||
@@ -127,9 +126,10 @@ func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
||||
require.Equal(t, float64(15_000_000), month.Revenue)
|
||||
require.Equal(t, int64(160), month.OrderCount)
|
||||
|
||||
// The month limit is the accumulation of its weeks
|
||||
require.Equal(t, float64(9_000_000), month.LimitPurchase)
|
||||
require.Equal(t, budget.Weekly[0].LimitPurchase+budget.Weekly[1].LimitPurchase, month.LimitPurchase)
|
||||
// The month limits are the accumulation of its weeks
|
||||
require.Equal(t, float64(3_000_000), month.SDL)
|
||||
require.Equal(t, float64(12_000_000), month.LimitTeam)
|
||||
require.Equal(t, budget.Weekly[0].LimitTeam+budget.Weekly[1].LimitTeam, month.LimitTeam)
|
||||
}
|
||||
|
||||
func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
|
||||
@@ -147,7 +147,7 @@ func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
|
||||
|
||||
require.Len(t, budget.Weekly, 3)
|
||||
require.Zero(t, budget.Weekly[0].Revenue)
|
||||
require.Zero(t, budget.Weekly[0].LimitPurchase)
|
||||
require.Zero(t, budget.Weekly[0].LimitTeam)
|
||||
require.Zero(t, budget.Weekly[1].Revenue)
|
||||
require.Equal(t, float64(4_000_000), budget.Weekly[2].Revenue)
|
||||
require.Equal(t, float64(4_000_000), budget.Total.Revenue)
|
||||
|
||||
@@ -11,12 +11,13 @@ import (
|
||||
"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.
|
||||
// Teams are the parent product categories flagged is_team, 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
|
||||
// listTeams returns the teams money can be charged to: the team 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)
|
||||
@@ -26,6 +27,9 @@ func listTeams(ctx context.Context, categoryRepo CategoryRepository, organizatio
|
||||
|
||||
teams := make([]models.PurchaseTeam, 0, len(categories)+1)
|
||||
for _, category := range categories {
|
||||
if !category.IsTeam {
|
||||
continue
|
||||
}
|
||||
categoryID := category.ID
|
||||
teams = append(teams, models.PurchaseTeam{
|
||||
Scope: constants.PurchaseTeamScopeCategory,
|
||||
@@ -75,6 +79,9 @@ func resolveTeamSelection(ctx context.Context, categoryRepo CategoryRepository,
|
||||
if category.ParentID != nil {
|
||||
return nil, nil, fmt.Errorf("team must be a parent category")
|
||||
}
|
||||
if !category.IsTeam {
|
||||
return nil, nil, fmt.Errorf("category %s is not a team", category.Name)
|
||||
}
|
||||
// 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 {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// teamCategoryRepositoryStub serves the two lookups the team rules make. The embedded
|
||||
// interface is left nil, so any other call panics and shows up in the test.
|
||||
type teamCategoryRepositoryStub struct {
|
||||
CategoryRepository
|
||||
categories []*entities.Category
|
||||
}
|
||||
|
||||
func (s *teamCategoryRepositoryStub) ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error) {
|
||||
return s.categories, nil
|
||||
}
|
||||
|
||||
func (s *teamCategoryRepositoryStub) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
|
||||
for _, category := range s.categories {
|
||||
if category.ID == id {
|
||||
return category, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("record not found")
|
||||
}
|
||||
|
||||
func TestListTeamsSkipsParentCategoriesThatAreNotTeams(t *testing.T) {
|
||||
organizationID := uuid.New()
|
||||
food := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Food", IsTeam: true}
|
||||
merch := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Merchandise", IsTeam: false}
|
||||
repo := &teamCategoryRepositoryStub{categories: []*entities.Category{food, merch}}
|
||||
|
||||
result, err := listTeams(context.Background(), repo, organizationID, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, result.Teams, 2)
|
||||
require.Equal(t, "Food", result.Teams[0].Name)
|
||||
require.Equal(t, &food.ID, result.Teams[0].CategoryID)
|
||||
require.Equal(t, constants.PurchaseTeamCentralName, result.Teams[1].Name)
|
||||
}
|
||||
|
||||
func TestResolveTeamSelectionRejectsParentCategoryThatIsNotATeam(t *testing.T) {
|
||||
organizationID := uuid.New()
|
||||
food := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Food", IsTeam: true}
|
||||
merch := &entities.Category{ID: uuid.New(), OrganizationID: organizationID, Name: "Merchandise", IsTeam: false}
|
||||
repo := &teamCategoryRepositoryStub{categories: []*entities.Category{food, merch}}
|
||||
scope := constants.PurchaseTeamScopeCategory
|
||||
|
||||
resolvedScope, resolvedID, err := resolveTeamSelection(context.Background(), repo, organizationID, nil, &scope, &food.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, constants.PurchaseTeamScopeCategory, *resolvedScope)
|
||||
require.Equal(t, food.ID, *resolvedID)
|
||||
|
||||
_, _, err = resolveTeamSelection(context.Background(), repo, organizationID, nil, &scope, &merch.ID)
|
||||
require.EqualError(t, err, "category Merchandise is not a team")
|
||||
}
|
||||
Reference in New Issue
Block a user