feat: update profit sharing

This commit is contained in:
efrilm
2026-10-04 21:54:13 +07:00
parent b59b7b4ddd
commit 117e3529aa
23 changed files with 521 additions and 95 deletions
+12 -12
View File
@@ -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)
+11 -11
View File
@@ -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)
+12 -5
View File
@@ -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 {
+64
View File
@@ -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")
}