From fbe7e97dc7c9aee7144b3bd73d4d6da01860a4b9 Mon Sep 17 00:00:00 2001 From: efrilm Date: Fri, 2 Oct 2026 23:30:03 +0700 Subject: [PATCH] feat: add limit owner --- internal/constants/budget.go | 3 +- internal/contract/analytics_contract.go | 52 +++++---- internal/contract/category_contract.go | 103 +++++++++++++----- internal/contract/category_contract_test.go | 48 ++++++++ internal/entities/analytics.go | 35 ++++-- internal/entities/category.go | 6 +- internal/mappers/category_mapper.go | 54 +++++---- internal/models/analytics.go | 52 +++++---- internal/models/category.go | 59 +++++----- internal/processor/analytics_processor.go | 64 +++++++++-- .../processor/analytics_processor_test.go | 62 ++++++++++- internal/processor/budget_cutoff_test.go | 8 +- internal/repository/analytics_repository.go | 65 +++++++++-- internal/transformer/analytics_transformer.go | 21 +++- internal/transformer/category_transformer.go | 58 +++++----- internal/validator/category_validator.go | 10 +- ...d_owner_fee_percent_to_categories.down.sql | 1 + ...add_owner_fee_percent_to_categories.up.sql | 4 + 18 files changed, 533 insertions(+), 172 deletions(-) create mode 100644 internal/contract/category_contract_test.go create mode 100644 migrations/000100_add_owner_fee_percent_to_categories.down.sql create mode 100644 migrations/000100_add_owner_fee_percent_to_categories.up.sql diff --git a/internal/constants/budget.go b/internal/constants/budget.go index 5bfa3d3..ac62e5a 100644 --- a/internal/constants/budget.go +++ b/internal/constants/budget.go @@ -1,7 +1,8 @@ package constants // Budget allocation of revenue used by the parent category cut-off report. -// The three shares are expected to add up to 100. +// The three shares are expected to add up to 100. BudgetLimitOwnerPercent is only the +// default: a parent category can override it with categories.owner_fee_percent. const ( BudgetLimitPurchasePercent = 60.0 BudgetLimitOwnerPercent = 20.0 diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 3b48206..97ec2a8 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -266,16 +266,27 @@ type ProductAnalyticsPerParentCategoryResponse struct { } type ProductAnalyticsPerParentCategoryData struct { - ParentCategoryID uuid.UUID `json:"parent_category_id"` - ParentCategoryName string `json:"parent_category_name"` - TotalRevenue float64 `json:"total_revenue"` - TotalQuantity int64 `json:"total_quantity"` - CategoryCount int64 `json:"category_count"` - ProductCount int64 `json:"product_count"` - OrderCount int64 `json:"order_count"` - TotalStandardHpp float64 `json:"total_standard_hpp"` - TotalFifoHpp float64 `json:"total_fifo_hpp"` - TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` + ParentCategoryID uuid.UUID `json:"parent_category_id"` + ParentCategoryName string `json:"parent_category_name"` + OwnerFeePercent float64 `json:"owner_fee_percent"` + SDL float64 `json:"sdl"` + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + CategoryCount int64 `json:"category_count"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` + TotalStandardHpp float64 `json:"total_standard_hpp"` + TotalFifoHpp float64 `json:"total_fifo_hpp"` + TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` + TopProduct *ParentCategoryTopProduct `json:"top_product"` +} + +// ParentCategoryTopProduct is the best-selling product of a parent category by revenue. +type ParentCategoryTopProduct struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + QuantitySold int64 `json:"quantity_sold"` + Revenue float64 `json:"revenue"` } // ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category @@ -302,14 +313,17 @@ type ParentCategoryAnalyticsDetailResponse struct { } type ParentCategoryAnalyticsDetailSummary struct { - TotalRevenue float64 `json:"total_revenue"` - TotalQuantity int64 `json:"total_quantity"` - CategoryCount int64 `json:"category_count"` - ProductCount int64 `json:"product_count"` - OrderCount int64 `json:"order_count"` - TotalStandardHpp float64 `json:"total_standard_hpp"` - TotalFifoHpp float64 `json:"total_fifo_hpp"` - TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` + OwnerFeePercent float64 `json:"owner_fee_percent"` + SDL float64 `json:"sdl"` + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + CategoryCount int64 `json:"category_count"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` + TotalStandardHpp float64 `json:"total_standard_hpp"` + TotalFifoHpp float64 `json:"total_fifo_hpp"` + TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` + TopProduct *ParentCategoryTopProduct `json:"top_product"` } type ParentCategoryAnalyticsDetailData struct { @@ -366,7 +380,7 @@ type BudgetPeriod struct { Revenue float64 `json:"revenue"` OrderCount int64 `json:"order_count"` LimitPurchase float64 `json:"limit_purchase"` - LimitOwner float64 `json:"limit_owner"` + SDL float64 `json:"sdl"` LimitTeam float64 `json:"limit_team"` } diff --git a/internal/contract/category_contract.go b/internal/contract/category_contract.go index 1950207..322f3c1 100644 --- a/internal/contract/category_contract.go +++ b/internal/contract/category_contract.go @@ -1,29 +1,79 @@ package contract import ( + "bytes" + "encoding/json" "time" "github.com/google/uuid" ) type CreateCategoryRequest struct { - Name string `json:"name" validate:"required,min=1,max=255"` - Description *string `json:"description,omitempty"` - BusinessType *string `json:"business_type,omitempty"` - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` - Order *int `json:"order,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Name string `json:"name" validate:"required,min=1,max=255"` + Description *string `json:"description,omitempty"` + BusinessType *string `json:"business_type,omitempty"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` + Order *int `json:"order,omitempty"` + OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } type UpdateCategoryRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` - Description *string `json:"description,omitempty"` - BusinessType *string `json:"business_type,omitempty"` - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` - Order *int `json:"order,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` + Description *string `json:"description,omitempty"` + BusinessType *string `json:"business_type,omitempty"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` + Order *int `json:"order,omitempty"` + OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + + // Set when the field is sent as null (or "" for parent_id), which asks for the + // value to be removed. A field that is left out stays unchanged. + ClearParentID bool `json:"-"` + ClearOwnerFeePercent bool `json:"-"` +} + +// UnmarshalJSON tells an explicit null apart from a field that was left out, so a +// category can be detached from its parent and an owner fee override can be removed. +func (r *UpdateCategoryRequest) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + isEmpty := func(key string, allowEmptyString bool) bool { + value, ok := raw[key] + if !ok { + return false + } + value = bytes.TrimSpace(value) + return bytes.Equal(value, []byte("null")) || (allowEmptyString && bytes.Equal(value, []byte(`""`))) + } + + clearParentID := isEmpty("parent_id", true) + clearOwnerFeePercent := isEmpty("owner_fee_percent", false) + if clearParentID { + // An empty string is not a valid UUID, so keep it away from the decoder + delete(raw, "parent_id") + cleaned, err := json.Marshal(raw) + if err != nil { + return err + } + data = cleaned + } + + type plain UpdateCategoryRequest + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + + *r = UpdateCategoryRequest(decoded) + r.ClearParentID = clearParentID + r.ClearOwnerFeePercent = clearOwnerFeePercent + return nil } type ListCategoriesRequest struct { @@ -39,18 +89,19 @@ type ListCategoriesRequest struct { // Category Response DTOs type CategoryResponse struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` - ParentName *string `json:"parent_name,omitempty"` - Name string `json:"name"` - Description *string `json:"description"` - BusinessType string `json:"business_type"` - Order int `json:"order"` - Metadata map[string]interface{} `json:"metadata"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` + ParentName *string `json:"parent_name,omitempty"` + Name string `json:"name"` + Description *string `json:"description"` + BusinessType string `json:"business_type"` + Order int `json:"order"` + OwnerFeePercent *float64 `json:"owner_fee_percent"` + Metadata map[string]interface{} `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ListCategoriesResponse struct { diff --git a/internal/contract/category_contract_test.go b/internal/contract/category_contract_test.go new file mode 100644 index 0000000..8ef29fa --- /dev/null +++ b/internal/contract/category_contract_test.go @@ -0,0 +1,48 @@ +package contract + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestUpdateCategoryRequestTellsNullFromOmitted(t *testing.T) { + parentID := uuid.New() + + tests := []struct { + name string + body string + wantParentID *uuid.UUID + wantClear bool + wantClearFee bool + wantFee *float64 + wantNameIsSet bool + }{ + {name: "omitted leaves parent unchanged", body: `{"name":"Food"}`, wantNameIsSet: true}, + {name: "null detaches the parent", body: `{"parent_id":null}`, wantClear: true}, + {name: "empty string detaches the parent", body: `{"name":"Food","parent_id":""}`, wantClear: true, wantNameIsSet: true}, + {name: "uuid sets the parent", body: `{"parent_id":"` + parentID.String() + `"}`, wantParentID: &parentID}, + {name: "null removes the owner fee override", body: `{"owner_fee_percent":null}`, wantClearFee: true}, + {name: "number sets the owner fee", body: `{"owner_fee_percent":35}`, wantFee: func() *float64 { v := 35.0; return &v }()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var req UpdateCategoryRequest + require.NoError(t, json.Unmarshal([]byte(tt.body), &req)) + + require.Equal(t, tt.wantParentID, req.ParentID) + require.Equal(t, tt.wantClear, req.ClearParentID) + require.Equal(t, tt.wantClearFee, req.ClearOwnerFeePercent) + require.Equal(t, tt.wantFee, req.OwnerFeePercent) + require.Equal(t, tt.wantNameIsSet, req.Name != nil) + }) + } +} + +func TestUpdateCategoryRequestRejectsInvalidParentID(t *testing.T) { + var req UpdateCategoryRequest + require.Error(t, json.Unmarshal([]byte(`{"parent_id":"not-a-uuid"}`), &req)) +} diff --git a/internal/entities/analytics.go b/internal/entities/analytics.go index 8446e86..2994c8a 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -143,16 +143,29 @@ type ProductAnalyticsPerCategory struct { // ProductAnalyticsPerParentCategory rolls the per-category figures up to the // top-level category. A category without a parent is its own group. type ProductAnalyticsPerParentCategory struct { - ParentCategoryID uuid.UUID `json:"parent_category_id"` - ParentCategoryName string `json:"parent_category_name"` - TotalRevenue float64 `json:"total_revenue"` - TotalQuantity int64 `json:"total_quantity"` - CategoryCount int64 `json:"category_count"` - ProductCount int64 `json:"product_count"` - OrderCount int64 `json:"order_count"` - TotalStandardHpp float64 `json:"total_standard_hpp"` - TotalFifoHpp float64 `json:"total_fifo_hpp"` - TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` + ParentCategoryID uuid.UUID `json:"parent_category_id"` + ParentCategoryName string `json:"parent_category_name"` + // OwnerFeePercent is the owner share of this group's revenue, already resolved to the default + OwnerFeePercent float64 `json:"owner_fee_percent"` + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + CategoryCount int64 `json:"category_count"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` + TotalStandardHpp float64 `json:"total_standard_hpp"` + TotalFifoHpp float64 `json:"total_fifo_hpp"` + TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` + // TopProduct is filled by a separate query, so it is not a scanned column + TopProduct *ParentCategoryTopProduct `gorm:"-" json:"top_product"` +} + +// ParentCategoryTopProduct is the best-selling product of a parent category by revenue. +type ParentCategoryTopProduct struct { + ParentCategoryID uuid.UUID `json:"parent_category_id"` + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + QuantitySold int64 `json:"quantity_sold"` + Revenue float64 `json:"revenue"` } // ParentCategoryAnalyticsDetail is the drill-down for a single parent category: @@ -171,6 +184,8 @@ type BudgetCutOffWeek struct { WeekStart time.Time `json:"week_start"` Revenue float64 `json:"revenue"` OrderCount int64 `json:"order_count"` + // SDL is summed per parent category, each at its own owner fee percent + SDL float64 `json:"sdl"` } // DashboardOverview represents dashboard overview data diff --git a/internal/entities/category.go b/internal/entities/category.go index 3d0aafc..ca85c8f 100644 --- a/internal/entities/category.go +++ b/internal/entities/category.go @@ -41,8 +41,10 @@ type Category struct { Order int `gorm:"default:0" json:"order"` BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"` Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + // OwnerFeePercent overrides the owner's share in the parent category budget report; nil uses the default + OwnerFeePercent *float64 `gorm:"type:numeric(5,2)" json:"owner_fee_percent"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"` diff --git a/internal/mappers/category_mapper.go b/internal/mappers/category_mapper.go index bbfe984..8130bc2 100644 --- a/internal/mappers/category_mapper.go +++ b/internal/mappers/category_mapper.go @@ -59,14 +59,15 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities. } return &entities.Category{ - OrganizationID: req.OrganizationID, - OutletID: req.OutletID, - ParentID: req.ParentID, - Name: req.Name, - Description: req.Description, - Order: req.Order, - BusinessType: "restaurant", - Metadata: metadata, + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + ParentID: req.ParentID, + Name: req.Name, + Description: req.Description, + Order: req.Order, + OwnerFeePercent: req.OwnerFeePercent, + BusinessType: "restaurant", + Metadata: metadata, } } @@ -94,18 +95,19 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons } return &models.CategoryResponse{ - ID: entity.ID, - OrganizationID: entity.OrganizationID, - OutletID: entity.OutletID, - ParentID: entity.ParentID, - ParentName: parentName, - Name: entity.Name, - Description: entity.Description, - ImageURL: imageURL, - Order: entity.Order, - IsActive: true, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + ParentID: entity.ParentID, + ParentName: parentName, + Name: entity.Name, + Description: entity.Description, + ImageURL: imageURL, + Order: entity.Order, + OwnerFeePercent: entity.OwnerFeePercent, + IsActive: true, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, } } @@ -134,11 +136,21 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda entity.Order = *req.Order } + if req.ClearOwnerFeePercent { + entity.OwnerFeePercent = nil + } else if req.OwnerFeePercent != nil { + entity.OwnerFeePercent = req.OwnerFeePercent + } + if req.OutletID != nil { entity.OutletID = req.OutletID } - if req.ParentID != nil { + if req.ClearParentID { + // Drop the preloaded parent too, so the response stops reporting it + entity.ParentID = nil + entity.Parent = nil + } else if req.ParentID != nil { entity.ParentID = req.ParentID } } diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 141dac2..1810dfd 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -306,16 +306,27 @@ type ProductAnalyticsPerParentCategoryResponse struct { } type ProductAnalyticsPerParentCategoryData struct { - ParentCategoryID uuid.UUID `json:"parent_category_id"` - ParentCategoryName string `json:"parent_category_name"` - TotalRevenue float64 `json:"total_revenue"` - TotalQuantity int64 `json:"total_quantity"` - CategoryCount int64 `json:"category_count"` - ProductCount int64 `json:"product_count"` - OrderCount int64 `json:"order_count"` - TotalStandardHpp float64 `json:"total_standard_hpp"` - TotalFifoHpp float64 `json:"total_fifo_hpp"` - TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` + ParentCategoryID uuid.UUID `json:"parent_category_id"` + ParentCategoryName string `json:"parent_category_name"` + OwnerFeePercent float64 `json:"owner_fee_percent"` + SDL float64 `json:"sdl"` + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + CategoryCount int64 `json:"category_count"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` + TotalStandardHpp float64 `json:"total_standard_hpp"` + TotalFifoHpp float64 `json:"total_fifo_hpp"` + TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` + TopProduct *ParentCategoryTopProduct `json:"top_product"` +} + +// ParentCategoryTopProduct is the best-selling product of a parent category by revenue. +type ParentCategoryTopProduct struct { + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + QuantitySold int64 `json:"quantity_sold"` + Revenue float64 `json:"revenue"` } // ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category @@ -342,14 +353,17 @@ type ParentCategoryAnalyticsDetailResponse struct { } type ParentCategoryAnalyticsDetailSummary struct { - TotalRevenue float64 `json:"total_revenue"` - TotalQuantity int64 `json:"total_quantity"` - CategoryCount int64 `json:"category_count"` - ProductCount int64 `json:"product_count"` - OrderCount int64 `json:"order_count"` - TotalStandardHpp float64 `json:"total_standard_hpp"` - TotalFifoHpp float64 `json:"total_fifo_hpp"` - TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` + OwnerFeePercent float64 `json:"owner_fee_percent"` + SDL float64 `json:"sdl"` + TotalRevenue float64 `json:"total_revenue"` + TotalQuantity int64 `json:"total_quantity"` + CategoryCount int64 `json:"category_count"` + ProductCount int64 `json:"product_count"` + OrderCount int64 `json:"order_count"` + TotalStandardHpp float64 `json:"total_standard_hpp"` + TotalFifoHpp float64 `json:"total_fifo_hpp"` + TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"` + TopProduct *ParentCategoryTopProduct `json:"top_product"` } type ParentCategoryAnalyticsDetailData struct { @@ -406,7 +420,7 @@ type BudgetPeriod struct { Revenue float64 `json:"revenue"` OrderCount int64 `json:"order_count"` LimitPurchase float64 `json:"limit_purchase"` - LimitOwner float64 `json:"limit_owner"` + SDL float64 `json:"sdl"` LimitTeam float64 `json:"limit_team"` } diff --git a/internal/models/category.go b/internal/models/category.go index 0d20cd4..7953863 100644 --- a/internal/models/category.go +++ b/internal/models/category.go @@ -20,36 +20,43 @@ type Category struct { } type CreateCategoryRequest struct { - OrganizationID uuid.UUID `validate:"required"` - OutletID *uuid.UUID - ParentID *uuid.UUID - Name string `validate:"required,min=1,max=255"` - Description *string `validate:"omitempty,max=1000"` - ImageURL *string `validate:"omitempty,url"` - Order int `validate:"min=0"` + OrganizationID uuid.UUID `validate:"required"` + OutletID *uuid.UUID + ParentID *uuid.UUID + Name string `validate:"required,min=1,max=255"` + Description *string `validate:"omitempty,max=1000"` + ImageURL *string `validate:"omitempty,url"` + Order int `validate:"min=0"` + OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"` } type UpdateCategoryRequest struct { - Name *string `validate:"omitempty,min=1,max=255"` - Description *string `validate:"omitempty,max=1000"` - ImageURL *string `validate:"omitempty,url"` - OutletID *uuid.UUID - ParentID *uuid.UUID - Order *int `validate:"omitempty,min=0"` - IsActive *bool + Name *string `validate:"omitempty,min=1,max=255"` + Description *string `validate:"omitempty,max=1000"` + ImageURL *string `validate:"omitempty,url"` + OutletID *uuid.UUID + ParentID *uuid.UUID + Order *int `validate:"omitempty,min=0"` + OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"` + IsActive *bool + + // Clear flags remove the value; a nil pointer above only means "leave unchanged" + ClearParentID bool + ClearOwnerFeePercent bool } type CategoryResponse struct { - ID uuid.UUID - OrganizationID uuid.UUID - OutletID *uuid.UUID - ParentID *uuid.UUID - ParentName *string - Name string - Description *string - ImageURL *string - Order int - IsActive bool - CreatedAt time.Time - UpdatedAt time.Time + ID uuid.UUID + OrganizationID uuid.UUID + OutletID *uuid.UUID + ParentID *uuid.UUID + ParentName *string + Name string + Description *string + ImageURL *string + Order int + OwnerFeePercent *float64 + IsActive bool + CreatedAt time.Time + UpdatedAt time.Time } diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 9764551..921015e 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -413,9 +413,22 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerParentCategory(ctx contex // Transform data var resultData []models.ProductAnalyticsPerParentCategoryData for _, data := range analyticsData { + var topProduct *models.ParentCategoryTopProduct + if data.TopProduct != nil { + topProduct = &models.ParentCategoryTopProduct{ + ProductID: data.TopProduct.ProductID, + ProductName: data.TopProduct.ProductName, + QuantitySold: data.TopProduct.QuantitySold, + Revenue: data.TopProduct.Revenue, + } + } + resultData = append(resultData, models.ProductAnalyticsPerParentCategoryData{ + TopProduct: topProduct, ParentCategoryID: data.ParentCategoryID, ParentCategoryName: data.ParentCategoryName, + OwnerFeePercent: data.OwnerFeePercent, + SDL: data.TotalRevenue * data.OwnerFeePercent / 100, TotalRevenue: data.TotalRevenue, TotalQuantity: data.TotalQuantity, CategoryCount: data.CategoryCount, @@ -500,6 +513,9 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co summary := models.ParentCategoryAnalyticsDetailSummary{} if detail.Summary != nil { summary = models.ParentCategoryAnalyticsDetailSummary{ + TopProduct: topProductByRevenue(detail.Products), + OwnerFeePercent: detail.Summary.OwnerFeePercent, + SDL: detail.Summary.TotalRevenue * detail.Summary.OwnerFeePercent / 100, TotalRevenue: detail.Summary.TotalRevenue, TotalQuantity: detail.Summary.TotalQuantity, CategoryCount: detail.Summary.CategoryCount, @@ -515,6 +531,10 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co if err != nil { return nil, err } + // 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 + } return &models.ParentCategoryAnalyticsDetailResponse{ OrganizationID: req.OrganizationID, @@ -530,6 +550,30 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co }, nil } +// topProductByRevenue picks the best-selling product of a parent category. A product +// can span several rows (one per outlet price), so the rows are added up per product +// first. Ties keep the product that was seen first. +func topProductByRevenue(products []*entities.ProductAnalytics) *models.ParentCategoryTopProduct { + totals := make(map[uuid.UUID]*models.ParentCategoryTopProduct) + for _, product := range products { + total, ok := totals[product.ProductID] + if !ok { + total = &models.ParentCategoryTopProduct{ProductID: product.ProductID, ProductName: product.ProductName} + totals[product.ProductID] = total + } + total.QuantitySold += product.QuantitySold + total.Revenue += product.Revenue + } + + var top *models.ParentCategoryTopProduct + for _, product := range products { + if total := totals[product.ProductID]; top == nil || total.Revenue > top.Revenue { + top = total + } + } + return top +} + // startOfWeek returns the Monday 00:00 of the week containing t, in t's own location. func startOfWeek(t time.Time) time.Time { daysSinceMonday := (int(t.Weekday()) + 6) % 7 @@ -542,15 +586,16 @@ 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. -func newBudgetPeriod(start, end time.Time, revenue float64, orderCount int64) models.BudgetPeriod { +// 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. +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, - LimitOwner: revenue * constants.BudgetLimitOwnerPercent / 100, + SDL: sdl, LimitTeam: revenue * constants.BudgetLimitTeamPercent / 100, } } @@ -588,22 +633,24 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza var ( totalRevenue float64 + totalSDL float64 totalOrders int64 monthOrder []string monthAccumulator = map[string]*models.BudgetMonthPeriod{} ) for week := cutOffFrom; !week.After(cutOffTo); week = week.AddDate(0, 0, 7) { - var revenue float64 + var revenue, sdl float64 var orderCount int64 if row, ok := rowsByWeek[week.Format("2006-01-02")]; ok { - revenue, orderCount = row.Revenue, row.OrderCount + revenue, sdl, orderCount = row.Revenue, row.SDL, row.OrderCount } - period := newBudgetPeriod(week, endOfWeek(week), revenue, orderCount) + period := newBudgetPeriod(week, endOfWeek(week), revenue, sdl, orderCount) budget.Weekly = append(budget.Weekly, period) totalRevenue += revenue + totalSDL += sdl totalOrders += orderCount // A week belongs to the month of its Monday, so every week is counted once @@ -618,6 +665,7 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza month.WeekCount++ month.PeriodEnd = period.PeriodEnd month.Revenue += revenue + month.SDL += sdl month.OrderCount += orderCount } @@ -626,11 +674,11 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza budget.Monthly = append(budget.Monthly, models.BudgetMonthPeriod{ Month: month.Month, WeekCount: month.WeekCount, - BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.OrderCount), + BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.SDL, month.OrderCount), }) } - budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalOrders) + budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalSDL, totalOrders) return budget, nil } diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 33f9f79..fe84ea4 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -18,6 +18,7 @@ type analyticsRepositoryStub struct { purchasingResult *entities.PurchasingAnalytics purchasingTeam *entities.PurchaseTeamFilter budgetCutOffWeeks []*entities.BudgetCutOffWeek + parentCategories []*entities.ProductAnalyticsPerParentCategory profitLossResult *entities.ProfitLossAnalytics exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics bankBalances []entities.ExclusiveSummaryBankBalance @@ -49,8 +50,8 @@ func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, u return nil, nil } -func (analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) { - return nil, nil +func (s analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) { + return s.parentCategories, nil } func (analyticsRepositoryStub) GetParentCategoryAnalyticsDetail(context.Context, uuid.UUID, *uuid.UUID, uuid.UUID, time.Time, time.Time) (*entities.ParentCategoryAnalyticsDetail, error) { @@ -604,3 +605,60 @@ func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) { assert.Zero(t, byType["point"].Percentage) assert.Equal(t, int64(30000), byType["point"].PointsUsed) } + +// 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. +func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) { + monday := time.Date(2026, 9, 28, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ + parentCategories: []*entities.ProductAnalyticsPerParentCategory{ + {ParentCategoryName: "Food", OwnerFeePercent: constants.BudgetLimitOwnerPercent, TotalRevenue: 1000}, + {ParentCategoryName: "Drink", OwnerFeePercent: 35, TotalRevenue: 2000}, + }, + budgetCutOffWeeks: []*entities.BudgetCutOffWeek{ + // 1000 at the default 20% plus 2000 at 35% + {WeekStart: monday, Revenue: 3000, OrderCount: 4, SDL: 900}, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetProductAnalyticsPerParentCategory(context.Background(), &models.ProductAnalyticsPerParentCategoryRequest{ + OrganizationID: uuid.New(), + DateFrom: monday, + DateTo: monday.AddDate(0, 0, 6), + }) + + require.NoError(t, err) + require.Len(t, result.Data, 2) + require.Equal(t, float64(20), result.Data[0].OwnerFeePercent) + require.Equal(t, float64(200), result.Data[0].SDL) + require.Equal(t, float64(35), result.Data[1].OwnerFeePercent) + require.Equal(t, float64(700), result.Data[1].SDL) + + 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.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) +} + +// A product sold at several outlet prices comes back as several rows, which must be +// added up before the best seller is picked. +func TestTopProductByRevenueAddsUpRowsOfTheSameProduct(t *testing.T) { + split, single := uuid.New(), uuid.New() + + top := topProductByRevenue([]*entities.ProductAnalytics{ + {ProductID: single, ProductName: "Es Teh", QuantitySold: 5, Revenue: 500}, + {ProductID: split, ProductName: "Nasi Goreng", QuantitySold: 2, Revenue: 300}, + {ProductID: split, ProductName: "Nasi Goreng", QuantitySold: 2, Revenue: 300}, + }) + + require.NotNil(t, top) + require.Equal(t, split, top.ProductID) + require.Equal(t, "Nasi Goreng", top.ProductName) + require.Equal(t, int64(4), top.QuantitySold) + require.Equal(t, float64(600), top.Revenue) + + require.Nil(t, topProductByRevenue(nil)) +} diff --git a/internal/processor/budget_cutoff_test.go b/internal/processor/budget_cutoff_test.go index 7386b4c..3feafa6 100644 --- a/internal/processor/budget_cutoff_test.go +++ b/internal/processor/budget_cutoff_test.go @@ -85,7 +85,7 @@ func TestBuildBudgetCutOffAppliesLimits(t *testing.T) { weekStart := time.Date(2026, 8, 3, 0, 0, 0, 0, loc) stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{ - {WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120}, + {WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120, SDL: 2_500_000}, }} processor := &AnalyticsProcessorImpl{analyticsRepo: stub} @@ -93,16 +93,18 @@ func TestBuildBudgetCutOffAppliesLimits(t *testing.T) { require.NoError(t, err) require.Len(t, budget.Weekly, 1) - // 60 / 20 / 20 of the week's revenue + // 60 / 20 of the week's revenue; the owner limit is whatever the repository + // summed from each parent category's own fee percent week := budget.Weekly[0] require.Equal(t, float64(6_000_000), week.LimitPurchase) - require.Equal(t, float64(2_000_000), week.LimitOwner) + require.Equal(t, float64(2_500_000), week.SDL) require.Equal(t, float64(2_000_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) } func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) { diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index a4fddcb..7082b17 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -600,6 +600,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte Select(` pc.id as parent_category_id, pc.name as parent_category_name, + COALESCE(pc.owner_fee_percent, ?) as owner_fee_percent, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as total_revenue, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity, COUNT(DISTINCT c.id) as category_count, @@ -608,7 +609,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp - `). + `, constants.BudgetLimitOwnerPercent). Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). // Categories without a parent roll up to themselves, so top-level categories still appear @@ -640,11 +641,56 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte query = r.resolveOutletID(query, outletID, "o.outlet_id") err := query. - Group("pc.id, pc.name"). + Group("pc.id, pc.name, pc.owner_fee_percent"). Order("pc.name ASC"). Scan(&results).Error + if err != nil || len(results) == 0 { + return results, err + } - return results, err + // Best-selling product of each parent category. DISTINCT ON keeps the first row per + // parent category, which the ordering makes the one with the highest revenue. + var topProducts []*entities.ParentCategoryTopProduct + topQuery := r.db.WithContext(ctx). + Table("order_items oi"). + Select(` + DISTINCT ON (pc.id) + pc.id as parent_category_id, + p.id as product_id, + p.name as product_name, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as quantity_sold, + COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue + `). + Joins("JOIN products p ON oi.product_id = p.id"). + Joins("JOIN categories c ON p.category_id = c.id"). + Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)"). + Joins("JOIN orders o ON oi.order_id = o.id"). + Where("o.organization_id = ?", organizationID). + Where("o.is_void = ?", false). + Where("o.is_refund = ?", false). + Where("o.payment_status = ?", entities.PaymentStatusCompleted). + Where("oi.status != ?", entities.OrderItemStatusCancelled). + Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo) + + topQuery = r.resolveOutletID(topQuery, outletID, "o.outlet_id") + + err = topQuery. + Group("pc.id, p.id, p.name"). + Order("pc.id, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) DESC, p.name ASC"). + Scan(&topProducts).Error + if err != nil { + return nil, err + } + + topByParent := make(map[uuid.UUID]*entities.ParentCategoryTopProduct, len(topProducts)) + for _, product := range topProducts { + topByParent[product.ParentCategoryID] = product + } + for _, result := range results { + result.TopProduct = topByParent[result.ParentCategoryID] + } + + return results, nil } // movingAverageHppSubquery builds the per-product moving-average HPP lookup shared by @@ -691,12 +737,13 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C // Resolve the category first so the endpoint still identifies the category when it // has no sales in the requested range, and rejects ids from another organization. var parent struct { - ID uuid.UUID - Name string + ID uuid.UUID + Name string + OwnerFeePercent float64 } if err := r.db.WithContext(ctx). Table("categories"). - Select("id, name"). + Select("id, name, COALESCE(owner_fee_percent, ?) as owner_fee_percent", constants.BudgetLimitOwnerPercent). Where("id = ? AND organization_id = ?", parentCategoryID, organizationID). Scan(&parent).Error; err != nil { return nil, err @@ -732,6 +779,7 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C } summary.ParentCategoryID = parent.ID summary.ParentCategoryName = parent.Name + summary.OwnerFeePercent = parent.OwnerFeePercent detail.Summary = summary // Sub-category rows. @@ -810,12 +858,15 @@ func (r *AnalyticsRepositoryImpl) GetBudgetCutOffWeekly(ctx context.Context, org Select(` DATE_TRUNC('week', o.created_at) as week_start, COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue, + COALESCE(SUM((CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END) * COALESCE(pc.owner_fee_percent, ?) / 100.0), 0) as sdl, COUNT(DISTINCT oi.order_id) as order_count - `). + `, constants.BudgetLimitOwnerPercent). // products and categories are joined to keep the scope identical to the report // the block is attached to, even when no parent category filter is applied Joins("JOIN products p ON oi.product_id = p.id"). Joins("JOIN categories c ON p.category_id = c.id"). + // The owner fee lives on the parent category; a category without a parent is its own group + Joins("JOIN categories pc ON pc.id = COALESCE(c.parent_id, c.id)"). Joins("JOIN orders o ON oi.order_id = o.id"). Where("o.organization_id = ?", organizationID). Where("o.is_void = ?", false). diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index a6267cc..bb09349 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -405,6 +405,9 @@ func ProductAnalyticsPerParentCategoryModelToContract(resp *models.ProductAnalyt data = append(data, contract.ProductAnalyticsPerParentCategoryData{ ParentCategoryID: item.ParentCategoryID, ParentCategoryName: item.ParentCategoryName, + OwnerFeePercent: item.OwnerFeePercent, + TopProduct: parentCategoryTopProductModelToContract(item.TopProduct), + SDL: item.SDL, TotalRevenue: item.TotalRevenue, TotalQuantity: item.TotalQuantity, CategoryCount: item.CategoryCount, @@ -427,6 +430,19 @@ func ProductAnalyticsPerParentCategoryModelToContract(resp *models.ProductAnalyt } } +// parentCategoryTopProductModelToContract converts the top product of a parent category to contract +func parentCategoryTopProductModelToContract(product *models.ParentCategoryTopProduct) *contract.ParentCategoryTopProduct { + if product == nil { + return nil + } + return &contract.ParentCategoryTopProduct{ + ProductID: product.ProductID, + ProductName: product.ProductName, + QuantitySold: product.QuantitySold, + Revenue: product.Revenue, + } +} + // budgetPeriodModelToContract converts one budget period to contract func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeriod { return contract.BudgetPeriod{ @@ -435,7 +451,7 @@ func budgetPeriodModelToContract(period models.BudgetPeriod) contract.BudgetPeri Revenue: period.Revenue, OrderCount: period.OrderCount, LimitPurchase: period.LimitPurchase, - LimitOwner: period.LimitOwner, + SDL: period.SDL, LimitTeam: period.LimitTeam, } } @@ -548,6 +564,9 @@ func ParentCategoryAnalyticsDetailModelToContract(resp *models.ParentCategoryAna ParentCategoryID: resp.ParentCategoryID, ParentCategoryName: resp.ParentCategoryName, Summary: contract.ParentCategoryAnalyticsDetailSummary{ + OwnerFeePercent: resp.Summary.OwnerFeePercent, + TopProduct: parentCategoryTopProductModelToContract(resp.Summary.TopProduct), + SDL: resp.Summary.SDL, TotalRevenue: resp.Summary.TotalRevenue, TotalQuantity: resp.Summary.TotalQuantity, CategoryCount: resp.Summary.CategoryCount, diff --git a/internal/transformer/category_transformer.go b/internal/transformer/category_transformer.go index e20b831..1c6abfb 100644 --- a/internal/transformer/category_transformer.go +++ b/internal/transformer/category_transformer.go @@ -12,25 +12,30 @@ func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.C order = *req.Order } return &models.CreateCategoryRequest{ - OrganizationID: apctx.OrganizationID, - OutletID: req.OutletID, - ParentID: req.ParentID, - Name: req.Name, - Description: req.Description, - ImageURL: nil, - Order: order, + OrganizationID: apctx.OrganizationID, + OutletID: req.OutletID, + ParentID: req.ParentID, + Name: req.Name, + Description: req.Description, + ImageURL: nil, + Order: order, + OwnerFeePercent: req.OwnerFeePercent, } } func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.UpdateCategoryRequest { return &models.UpdateCategoryRequest{ - Name: req.Name, - Description: req.Description, - ImageURL: nil, - OutletID: req.OutletID, - ParentID: req.ParentID, - Order: req.Order, - IsActive: nil, + Name: req.Name, + Description: req.Description, + ImageURL: nil, + OutletID: req.OutletID, + ParentID: req.ParentID, + Order: req.Order, + OwnerFeePercent: req.OwnerFeePercent, + IsActive: nil, + + ClearParentID: req.ClearParentID, + ClearOwnerFeePercent: req.ClearOwnerFeePercent, } } @@ -40,18 +45,19 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat } return &contract.CategoryResponse{ - ID: cat.ID, - OrganizationID: cat.OrganizationID, - OutletID: cat.OutletID, - ParentID: cat.ParentID, - ParentName: cat.ParentName, - Name: cat.Name, - Description: cat.Description, - BusinessType: "restaurant", - Order: cat.Order, - Metadata: map[string]interface{}{}, - CreatedAt: cat.CreatedAt, - UpdatedAt: cat.UpdatedAt, + ID: cat.ID, + OrganizationID: cat.OrganizationID, + OutletID: cat.OutletID, + ParentID: cat.ParentID, + ParentName: cat.ParentName, + Name: cat.Name, + Description: cat.Description, + BusinessType: "restaurant", + Order: cat.Order, + OwnerFeePercent: cat.OwnerFeePercent, + Metadata: map[string]interface{}{}, + CreatedAt: cat.CreatedAt, + UpdatedAt: cat.UpdatedAt, } } diff --git a/internal/validator/category_validator.go b/internal/validator/category_validator.go index 125ed6e..a390035 100644 --- a/internal/validator/category_validator.go +++ b/internal/validator/category_validator.go @@ -37,6 +37,10 @@ func (v *CategoryValidatorImpl) ValidateCreateCategoryRequest(req *contract.Crea return errors.New("description cannot exceed 1000 characters"), constants.MalformedFieldErrorCode } + if req.OwnerFeePercent != nil && (*req.OwnerFeePercent < 0 || *req.OwnerFeePercent > 100) { + return errors.New("owner_fee_percent must be between 0 and 100"), constants.MalformedFieldErrorCode + } + if req.BusinessType != nil && strings.TrimSpace(*req.BusinessType) != "" { validBusinessTypes := map[string]bool{ "restaurant": true, @@ -59,7 +63,7 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda } // At least one field should be provided for update - if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil { + if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.ParentID == nil && req.Metadata == nil && req.OwnerFeePercent == nil && req.Order == nil && req.OutletID == nil && !req.ClearParentID && !req.ClearOwnerFeePercent { return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode } @@ -76,6 +80,10 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda return errors.New("description cannot exceed 1000 characters"), constants.MalformedFieldErrorCode } + if req.OwnerFeePercent != nil && (*req.OwnerFeePercent < 0 || *req.OwnerFeePercent > 100) { + return errors.New("owner_fee_percent must be between 0 and 100"), constants.MalformedFieldErrorCode + } + if req.BusinessType != nil && strings.TrimSpace(*req.BusinessType) != "" { validBusinessTypes := map[string]bool{ "restaurant": true, diff --git a/migrations/000100_add_owner_fee_percent_to_categories.down.sql b/migrations/000100_add_owner_fee_percent_to_categories.down.sql new file mode 100644 index 0000000..2273a14 --- /dev/null +++ b/migrations/000100_add_owner_fee_percent_to_categories.down.sql @@ -0,0 +1 @@ +ALTER TABLE categories DROP COLUMN owner_fee_percent; diff --git a/migrations/000100_add_owner_fee_percent_to_categories.up.sql b/migrations/000100_add_owner_fee_percent_to_categories.up.sql new file mode 100644 index 0000000..d370739 --- /dev/null +++ b/migrations/000100_add_owner_fee_percent_to_categories.up.sql @@ -0,0 +1,4 @@ +-- The owner's share of revenue in the parent category budget report. NULL keeps the +-- default share, so only the parent categories with a different fee carry a value. +ALTER TABLE categories ADD COLUMN owner_fee_percent NUMERIC(5,2) + CONSTRAINT chk_categories_owner_fee_percent CHECK (owner_fee_percent >= 0 AND owner_fee_percent <= 100);