diff --git a/internal/app/app.go b/internal/app/app.go index fbb32dd..6a0f4d1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -396,7 +396,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo), notificationProcessor: buildNotificationProcessor(cfg, repos), productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo), - expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo), + expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo), } } diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index b769d2e..348cbd3 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -19,10 +19,11 @@ type CreateExpenseRequest struct { } type CreateExpenseItemRequest struct { - ChartOfAccountID string `json:"chart_of_account_id" validate:"required"` - Item string `json:"item" validate:"required"` - Description *string `json:"description,omitempty"` - Amount float64 `json:"amount" validate:"required"` + ChartOfAccountID string `json:"chart_of_account_id" validate:"required"` + PurchaseCategoryID string `json:"purchase_category_id" validate:"required"` + Item string `json:"item" validate:"required"` + Description *string `json:"description,omitempty"` + Amount float64 `json:"amount" validate:"required"` } type UpdateExpenseRequest struct { @@ -39,10 +40,11 @@ type UpdateExpenseRequest struct { } type UpdateExpenseItemRequest struct { - ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` - Item *string `json:"item,omitempty"` - Description *string `json:"description,omitempty"` - Amount *float64 `json:"amount,omitempty"` + ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` + PurchaseCategoryID *string `json:"purchase_category_id,omitempty"` + Item *string `json:"item,omitempty"` + Description *string `json:"description,omitempty"` + Amount *float64 `json:"amount,omitempty"` } type ExpenseResponse struct { @@ -63,15 +65,19 @@ type ExpenseResponse struct { } type ExpenseItemResponse struct { - ID uuid.UUID `json:"id"` - ExpenseID uuid.UUID `json:"expense_id"` - ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` - ChartOfAccountName string `json:"chart_of_account_name,omitempty"` - Item string `json:"item"` - Description *string `json:"description"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + ExpenseID uuid.UUID `json:"expense_id"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + ChartOfAccountName string `json:"chart_of_account_name,omitempty"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + PurchaseCategoryName string `json:"purchase_category_name,omitempty"` + PurchaseCategoryType string `json:"purchase_category_type,omitempty"` + PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"` + Item string `json:"item"` + Description *string `json:"description"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ListExpenseRequest struct { @@ -100,15 +106,16 @@ type ExpenseAnalyticsRequest struct { } type ExpenseAnalyticsResponse struct { - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - DateFrom time.Time `json:"date_from"` - DateTo time.Time `json:"date_to"` - GroupBy string `json:"group_by"` - Summary ExpenseAnalyticsSummary `json:"summary"` - Data []ExpenseAnalyticsData `json:"data"` - CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"` - ItemData []ExpenseAnalyticsItemData `json:"item_data"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + GroupBy string `json:"group_by"` + Summary ExpenseAnalyticsSummary `json:"summary"` + Data []ExpenseAnalyticsData `json:"data"` + CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"` + ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"` + ItemData []ExpenseAnalyticsItemData `json:"item_data"` } type ExpenseAnalyticsSummary struct { @@ -130,6 +137,15 @@ type ExpenseAnalyticsData struct { } type ExpenseAnalyticsCategoryData struct { + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + PurchaseCategoryName string `json:"purchase_category_name"` + PurchaseCategoryType string `json:"purchase_category_type"` + TotalAmount float64 `json:"total_amount"` + ExpenseCount int64 `json:"expense_count"` + ItemCount int64 `json:"item_count"` +} + +type ExpenseAnalyticsChartOfAccountData struct { ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountName string `json:"chart_of_account_name"` TotalAmount float64 `json:"total_amount"` diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 137157f..9f391fe 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -29,10 +29,11 @@ type Expense struct { } type ExpenseAnalytics struct { - Summary ExpenseAnalyticsSummary - Data []ExpenseAnalyticsData - CategoryData []ExpenseAnalyticsCategoryData - ItemData []ExpenseAnalyticsItemData + Summary ExpenseAnalyticsSummary + Data []ExpenseAnalyticsData + CategoryData []ExpenseAnalyticsCategoryData + ChartOfAccountData []ExpenseAnalyticsChartOfAccountData + ItemData []ExpenseAnalyticsItemData } type ExpenseAnalyticsSummary struct { @@ -54,6 +55,15 @@ type ExpenseAnalyticsData struct { } type ExpenseAnalyticsCategoryData struct { + PurchaseCategoryID uuid.UUID + PurchaseCategoryName string + PurchaseCategoryType string + TotalAmount float64 + ExpenseCount int64 + ItemCount int64 +} + +type ExpenseAnalyticsChartOfAccountData struct { ChartOfAccountID uuid.UUID ChartOfAccountName string TotalAmount float64 diff --git a/internal/entities/expense_item.go b/internal/entities/expense_item.go index f5bd7fc..f6ebbed 100644 --- a/internal/entities/expense_item.go +++ b/internal/entities/expense_item.go @@ -9,17 +9,19 @@ import ( ) type ExpenseItem struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"` - ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"` - Item string `gorm:"not null;size:255" json:"item"` - Description *string `gorm:"type:text" json:"description"` - Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"` + ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"` + PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id"` + Item string `gorm:"not null;size:255" json:"item"` + Description *string `gorm:"type:text" json:"description"` + Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"` - ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"` + Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"` + ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"` + PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"` } func (e *ExpenseItem) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go index 34015d4..c3b1e43 100644 --- a/internal/mappers/expense_mapper.go +++ b/internal/mappers/expense_mapper.go @@ -95,20 +95,27 @@ func ExpenseItemEntityToResponse(entity *entities.ExpenseItem) *models.ExpenseIt } response := &models.ExpenseItemResponse{ - ID: entity.ID, - ExpenseID: entity.ExpenseID, - ChartOfAccountID: entity.ChartOfAccountID, - Item: entity.Item, - Description: entity.Description, - Amount: entity.Amount, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, + ID: entity.ID, + ExpenseID: entity.ExpenseID, + ChartOfAccountID: entity.ChartOfAccountID, + PurchaseCategoryID: entity.PurchaseCategoryID, + Item: entity.Item, + Description: entity.Description, + Amount: entity.Amount, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, } if entity.ChartOfAccount != nil { response.ChartOfAccountName = entity.ChartOfAccount.Name } + if entity.PurchaseCategory != nil { + response.PurchaseCategoryName = entity.PurchaseCategory.Name + response.PurchaseCategoryType = string(entity.PurchaseCategory.Type) + response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory) + } + return response } diff --git a/internal/models/expense.go b/internal/models/expense.go index 57c08d0..859ed69 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -23,14 +23,15 @@ type Expense struct { } type ExpenseItem struct { - ID uuid.UUID `json:"id"` - ExpenseID uuid.UUID `json:"expense_id"` - ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` - Item string `json:"item"` - Description *string `json:"description"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + ExpenseID uuid.UUID `json:"expense_id"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + Item string `json:"item"` + Description *string `json:"description"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ExpenseResponse struct { @@ -51,15 +52,19 @@ type ExpenseResponse struct { } type ExpenseItemResponse struct { - ID uuid.UUID `json:"id"` - ExpenseID uuid.UUID `json:"expense_id"` - ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` - ChartOfAccountName string `json:"chart_of_account_name,omitempty"` - Item string `json:"item"` - Description *string `json:"description"` - Amount float64 `json:"amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + ExpenseID uuid.UUID `json:"expense_id"` + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + ChartOfAccountName string `json:"chart_of_account_name,omitempty"` + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + PurchaseCategoryName string `json:"purchase_category_name,omitempty"` + PurchaseCategoryType string `json:"purchase_category_type,omitempty"` + PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"` + Item string `json:"item"` + Description *string `json:"description"` + Amount float64 `json:"amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateExpenseRequest struct { @@ -75,10 +80,11 @@ type CreateExpenseRequest struct { } type CreateExpenseItemRequest struct { - ChartOfAccountID string `json:"chart_of_account_id"` - Item string `json:"item"` - Description *string `json:"description,omitempty"` - Amount float64 `json:"amount"` + ChartOfAccountID string `json:"chart_of_account_id"` + PurchaseCategoryID string `json:"purchase_category_id"` + Item string `json:"item"` + Description *string `json:"description,omitempty"` + Amount float64 `json:"amount"` } type UpdateExpenseRequest struct { @@ -95,10 +101,11 @@ type UpdateExpenseRequest struct { } type UpdateExpenseItemRequest struct { - ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` - Item *string `json:"item,omitempty"` - Description *string `json:"description,omitempty"` - Amount *float64 `json:"amount,omitempty"` + ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` + PurchaseCategoryID *string `json:"purchase_category_id,omitempty"` + Item *string `json:"item,omitempty"` + Description *string `json:"description,omitempty"` + Amount *float64 `json:"amount,omitempty"` } type ListExpenseRequest struct { @@ -128,15 +135,16 @@ type ExpenseAnalyticsRequest struct { } type ExpenseAnalyticsResponse struct { - OrganizationID uuid.UUID `json:"organization_id"` - OutletID *uuid.UUID `json:"outlet_id,omitempty"` - DateFrom time.Time `json:"date_from"` - DateTo time.Time `json:"date_to"` - GroupBy string `json:"group_by"` - Summary ExpenseAnalyticsSummary `json:"summary"` - Data []ExpenseAnalyticsData `json:"data"` - CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"` - ItemData []ExpenseAnalyticsItemData `json:"item_data"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` + GroupBy string `json:"group_by"` + Summary ExpenseAnalyticsSummary `json:"summary"` + Data []ExpenseAnalyticsData `json:"data"` + CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"` + ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"` + ItemData []ExpenseAnalyticsItemData `json:"item_data"` } type ExpenseAnalyticsSummary struct { @@ -158,6 +166,15 @@ type ExpenseAnalyticsData struct { } type ExpenseAnalyticsCategoryData struct { + PurchaseCategoryID uuid.UUID `json:"purchase_category_id"` + PurchaseCategoryName string `json:"purchase_category_name"` + PurchaseCategoryType string `json:"purchase_category_type"` + TotalAmount float64 `json:"total_amount"` + ExpenseCount int64 `json:"expense_count"` + ItemCount int64 `json:"item_count"` +} + +type ExpenseAnalyticsChartOfAccountData struct { ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountName string `json:"chart_of_account_name"` TotalAmount float64 `json:"total_amount"` diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 2141ebe..0b8ab86 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -23,12 +23,14 @@ type ExpenseProcessor interface { } type ExpenseProcessorImpl struct { - expenseRepo ExpenseRepository + expenseRepo ExpenseRepository + purchaseCategoryRepo PurchaseCategoryRepository } -func NewExpenseProcessorImpl(expenseRepo ExpenseRepository) *ExpenseProcessorImpl { +func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl { return &ExpenseProcessorImpl{ - expenseRepo: expenseRepo, + expenseRepo: expenseRepo, + purchaseCategoryRepo: purchaseCategoryRepo, } } @@ -48,6 +50,30 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID status = *req.Status } + items := make([]entities.ExpenseItem, len(req.Items)) + for i, itemReq := range req.Items { + chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID) + if err != nil { + return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err) + } + + purchaseCategoryID, err := uuid.Parse(itemReq.PurchaseCategoryID) + if err != nil { + return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err) + } + if err := p.validateNonInventoryPurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil { + return nil, err + } + + items[i] = entities.ExpenseItem{ + ChartOfAccountID: chartOfAccountID, + PurchaseCategoryID: purchaseCategoryID, + Item: itemReq.Item, + Description: itemReq.Description, + Amount: itemReq.Amount, + } + } + expenseEntity := &entities.Expense{ OrganizationID: organizationID, OutletID: outletID, @@ -65,21 +91,10 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID return nil, fmt.Errorf("failed to create expense: %w", err) } - for _, itemReq := range req.Items { - chartOfAccountID, err := uuid.Parse(itemReq.ChartOfAccountID) - if err != nil { - return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err) - } + for i := range items { + items[i].ExpenseID = expenseEntity.ID - itemEntity := &entities.ExpenseItem{ - ExpenseID: expenseEntity.ID, - ChartOfAccountID: chartOfAccountID, - Item: itemReq.Item, - Description: itemReq.Description, - Amount: itemReq.Amount, - } - - err = p.expenseRepo.CreateItem(ctx, itemEntity) + err = p.expenseRepo.CreateItem(ctx, &items[i]) if err != nil { return nil, fmt.Errorf("failed to create expense item: %w", err) } @@ -135,13 +150,10 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati expenseEntity.Reserved1 = req.Reserved1 } + var items []entities.ExpenseItem if req.Items != nil { - err = p.expenseRepo.DeleteItemsByExpenseID(ctx, expenseEntity.ID) - if err != nil { - return nil, fmt.Errorf("failed to delete existing items: %w", err) - } - - for _, itemReq := range req.Items { + items = make([]entities.ExpenseItem, len(req.Items)) + for i, itemReq := range req.Items { chartOfAccountID := uuid.Nil if itemReq.ChartOfAccountID != nil { chartOfAccountID, err = uuid.Parse(*itemReq.ChartOfAccountID) @@ -150,6 +162,17 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati } } + if itemReq.PurchaseCategoryID == nil { + return nil, fmt.Errorf("purchase_category_id is required for item") + } + purchaseCategoryID, err := uuid.Parse(*itemReq.PurchaseCategoryID) + if err != nil { + return nil, fmt.Errorf("invalid purchase_category_id for item: %w", err) + } + if err := p.validateNonInventoryPurchaseCategory(ctx, purchaseCategoryID, organizationID); err != nil { + return nil, err + } + amount := 0.0 if itemReq.Amount != nil { amount = *itemReq.Amount @@ -159,15 +182,23 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati item = *itemReq.Item } - itemEntity := &entities.ExpenseItem{ - ExpenseID: expenseEntity.ID, - ChartOfAccountID: chartOfAccountID, - Item: item, - Description: itemReq.Description, - Amount: amount, + items[i] = entities.ExpenseItem{ + ExpenseID: expenseEntity.ID, + ChartOfAccountID: chartOfAccountID, + PurchaseCategoryID: purchaseCategoryID, + Item: item, + Description: itemReq.Description, + Amount: amount, } + } - err = p.expenseRepo.CreateItem(ctx, itemEntity) + err = p.expenseRepo.DeleteItemsByExpenseID(ctx, expenseEntity.ID) + if err != nil { + return nil, fmt.Errorf("failed to delete existing items: %w", err) + } + + for i := range items { + err = p.expenseRepo.CreateItem(ctx, &items[i]) if err != nil { return nil, fmt.Errorf("failed to create expense item: %w", err) } @@ -252,6 +283,18 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod categoryData := make([]models.ExpenseAnalyticsCategoryData, len(result.CategoryData)) for i, item := range result.CategoryData { categoryData[i] = models.ExpenseAnalyticsCategoryData{ + PurchaseCategoryID: item.PurchaseCategoryID, + PurchaseCategoryName: item.PurchaseCategoryName, + PurchaseCategoryType: item.PurchaseCategoryType, + TotalAmount: item.TotalAmount, + ExpenseCount: item.ExpenseCount, + ItemCount: item.ItemCount, + } + } + + chartOfAccountData := make([]models.ExpenseAnalyticsChartOfAccountData, len(result.ChartOfAccountData)) + for i, item := range result.ChartOfAccountData { + chartOfAccountData[i] = models.ExpenseAnalyticsChartOfAccountData{ ChartOfAccountID: item.ChartOfAccountID, ChartOfAccountName: item.ChartOfAccountName, TotalAmount: item.TotalAmount, @@ -284,8 +327,26 @@ func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *mod TotalCategories: result.Summary.TotalCategories, TotalItems: result.Summary.TotalItems, }, - Data: data, - CategoryData: categoryData, - ItemData: itemData, + Data: data, + CategoryData: categoryData, + ChartOfAccountData: chartOfAccountData, + ItemData: itemData, }, nil } + +func (p *ExpenseProcessorImpl) validateNonInventoryPurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID) error { + category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID) + if err != nil { + return fmt.Errorf("purchase category not found: %w", err) + } + + if !category.IsActive { + return fmt.Errorf("purchase category is inactive") + } + + if category.Type != entities.PurchaseCategoryTypeNonInventory { + return fmt.Errorf("purchase category must be non_inventory") + } + + return nil +} diff --git a/internal/processor/expense_processor_test.go b/internal/processor/expense_processor_test.go index b42fed7..e0a4435 100644 --- a/internal/processor/expense_processor_test.go +++ b/internal/processor/expense_processor_test.go @@ -18,6 +18,45 @@ type expenseRepositoryCaptureStub struct { analytics *entities.ExpenseAnalytics } +type expensePurchaseCategoryRepositoryStub struct { + category *entities.PurchaseCategory +} + +func (*expensePurchaseCategoryRepositoryStub) Create(context.Context, *entities.PurchaseCategory) error { + return nil +} + +func (s *expensePurchaseCategoryRepositoryStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.PurchaseCategory, error) { + return s.category, nil +} + +func (*expensePurchaseCategoryRepositoryStub) Update(context.Context, *entities.PurchaseCategory) error { + return nil +} + +func (*expensePurchaseCategoryRepositoryStub) SoftDelete(context.Context, uuid.UUID, uuid.UUID) error { + return nil +} + +func (*expensePurchaseCategoryRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.PurchaseCategory, int64, error) { + return nil, 0, nil +} + +func (*expensePurchaseCategoryRepositoryStub) ExistsByCode(context.Context, uuid.UUID, string, *uuid.UUID) (bool, error) { + return false, nil +} + +func newExpensePurchaseCategoryRepo(categoryID uuid.UUID, categoryType entities.PurchaseCategoryType) *expensePurchaseCategoryRepositoryStub { + return &expensePurchaseCategoryRepositoryStub{ + category: &entities.PurchaseCategory{ + ID: categoryID, + Name: "Operational", + Type: categoryType, + IsActive: true, + }, + } +} + func (s *expenseRepositoryCaptureStub) Create(_ context.Context, expense *entities.Expense) error { if expense.ID == uuid.Nil { expense.ID = uuid.New() @@ -62,7 +101,8 @@ func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uui func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { repo := &expenseRepositoryCaptureStub{} - p := NewExpenseProcessorImpl(repo) + purchaseCategoryID := uuid.New() + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) chartOfAccountID := uuid.New() resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -73,9 +113,10 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { Total: 10000, Items: []models.CreateExpenseItemRequest{ { - ChartOfAccountID: chartOfAccountID.String(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: chartOfAccountID.String(), + PurchaseCategoryID: purchaseCategoryID.String(), + Item: "Cleaning supplies", + Amount: 10000, }, }, }) @@ -84,13 +125,15 @@ func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { require.NotNil(t, resp) require.Len(t, repo.createdItems, 1) require.Equal(t, "Cleaning supplies", repo.createdItems[0].Item) + require.Equal(t, purchaseCategoryID, repo.createdItems[0].PurchaseCategoryID) require.Len(t, resp.Items, 1) require.Equal(t, "Cleaning supplies", resp.Items[0].Item) } func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { repo := &expenseRepositoryCaptureStub{} - p := NewExpenseProcessorImpl(repo) + purchaseCategoryID := uuid.New() + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ Receiver: "Cashier", @@ -100,9 +143,10 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { Total: 10000, Items: []models.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: purchaseCategoryID.String(), + Item: "Cleaning supplies", + Amount: 10000, }, }, }) @@ -115,7 +159,8 @@ func TestExpenseProcessorCreateDefaultsStatusToDraft(t *testing.T) { func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { repo := &expenseRepositoryCaptureStub{} - p := NewExpenseProcessorImpl(repo) + purchaseCategoryID := uuid.New() + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) status := "approved" resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ @@ -127,9 +172,10 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { Total: 10000, Items: []models.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: purchaseCategoryID.String(), + Item: "Cleaning supplies", + Amount: 10000, }, }, }) @@ -140,8 +186,35 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { require.Equal(t, "approved", resp.Status) } +func TestExpenseProcessorCreateRejectsRawMaterialPurchaseCategory(t *testing.T) { + repo := &expenseRepositoryCaptureStub{} + purchaseCategoryID := uuid.New() + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeRawMaterial)) + + resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ + Receiver: "Cashier", + TransactionDate: "2026-05-29", + CodeNumber: "EXP-001", + OutletID: uuid.NewString(), + Total: 10000, + Items: []models.CreateExpenseItemRequest{ + { + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: purchaseCategoryID.String(), + Item: "Cleaning supplies", + Amount: 10000, + }, + }, + }) + + require.Error(t, err) + require.Nil(t, resp) + require.Contains(t, err.Error(), "non_inventory") +} + func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) { coaID := uuid.New() + purchaseCategoryID := uuid.New() outletID := uuid.New() now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) repo := &expenseRepositoryCaptureStub{ @@ -165,6 +238,16 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te }, }, CategoryData: []entities.ExpenseAnalyticsCategoryData{ + { + PurchaseCategoryID: purchaseCategoryID, + PurchaseCategoryName: "Operational Supplies", + PurchaseCategoryType: "non_inventory", + TotalAmount: 100000, + ExpenseCount: 2, + ItemCount: 2, + }, + }, + ChartOfAccountData: []entities.ExpenseAnalyticsChartOfAccountData{ { ChartOfAccountID: coaID, ChartOfAccountName: "Operational", @@ -183,7 +266,7 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te }, }, } - p := NewExpenseProcessorImpl(repo) + p := NewExpenseProcessorImpl(repo, newExpensePurchaseCategoryRepo(purchaseCategoryID, entities.PurchaseCategoryTypeNonInventory)) resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{ OrganizationID: uuid.New(), @@ -200,7 +283,9 @@ func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *te require.Len(t, resp.Data, 1) require.Equal(t, int64(2), resp.Data[0].ExpenseCount) require.Len(t, resp.CategoryData, 1) - require.Equal(t, coaID, resp.CategoryData[0].ChartOfAccountID) + require.Equal(t, purchaseCategoryID, resp.CategoryData[0].PurchaseCategoryID) + require.Len(t, resp.ChartOfAccountData, 1) + require.Equal(t, coaID, resp.ChartOfAccountData[0].ChartOfAccountID) require.Len(t, resp.ItemData, 1) require.Equal(t, "Cleaning supplies", resp.ItemData[0].Item) } diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index 4877243..bf557af 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -30,6 +30,7 @@ func (r *ExpenseRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*ent var expense entities.Expense err := r.db.WithContext(ctx). Preload("Items.ChartOfAccount"). + Preload("Items.PurchaseCategory"). First(&expense, "id = ?", id).Error if err != nil { return nil, err @@ -41,6 +42,7 @@ func (r *ExpenseRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id var expense entities.Expense err := r.db.WithContext(ctx). Preload("Items.ChartOfAccount"). + Preload("Items.PurchaseCategory"). Where("id = ? AND organization_id = ?", id, organizationID). First(&expense).Error if err != nil { @@ -107,6 +109,7 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU err := query. Preload("Items.ChartOfAccount"). + Preload("Items.PurchaseCategory"). Order("created_at DESC"). Limit(limit). Offset(offset). @@ -139,7 +142,7 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID Table("expense_items ei"). Select(` COUNT(ei.id) as total_items, - COUNT(DISTINCT ei.chart_of_account_id) as total_categories + COUNT(DISTINCT ei.purchase_category_id) as total_categories `). Joins("JOIN expenses e ON ei.expense_id = e.id"). Where("e.organization_id = ?", organizationID). @@ -174,7 +177,7 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID COALESCE(SUM(item_counts.categories), 0) as categories `). Joins(`LEFT JOIN ( - SELECT expense_id, COUNT(id) as items, COUNT(DISTINCT chart_of_account_id) as categories + SELECT expense_id, COUNT(id) as items, COUNT(DISTINCT purchase_category_id) as categories FROM expense_items GROUP BY expense_id ) item_counts ON item_counts.expense_id = e.id`). @@ -192,6 +195,32 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID var categoryData []entities.ExpenseAnalyticsCategoryData categoryQuery := r.db.WithContext(ctx). + Table("expense_items ei"). + Select(` + pc.id as purchase_category_id, + pc.name as purchase_category_name, + pc.type as purchase_category_type, + COALESCE(SUM(ei.amount), 0) as total_amount, + COUNT(DISTINCT e.id) as expense_count, + COUNT(ei.id) as item_count + `). + Joins("JOIN expenses e ON ei.expense_id = e.id"). + Joins("JOIN purchase_categories pc ON ei.purchase_category_id = pc.id"). + Where("e.organization_id = ?", organizationID). + Where("pc.type = ?", entities.PurchaseCategoryTypeNonInventory). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo). + Group("pc.id, pc.name, pc.type"). + Order("total_amount DESC") + if outletID != nil { + categoryQuery = categoryQuery.Where("e.outlet_id = ?", *outletID) + } + if err := categoryQuery.Scan(&categoryData).Error; err != nil { + return nil, err + } + + var chartOfAccountData []entities.ExpenseAnalyticsChartOfAccountData + chartOfAccountQuery := r.db.WithContext(ctx). Table("expense_items ei"). Select(` COALESCE(parent_coa.id, coa.id) as chart_of_account_id, @@ -209,9 +238,9 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID Group("COALESCE(parent_coa.id, coa.id), COALESCE(parent_coa.name, coa.name, 'Lain-lain')"). Order("total_amount DESC") if outletID != nil { - categoryQuery = categoryQuery.Where("e.outlet_id = ?", *outletID) + chartOfAccountQuery = chartOfAccountQuery.Where("e.outlet_id = ?", *outletID) } - if err := categoryQuery.Scan(&categoryData).Error; err != nil { + if err := chartOfAccountQuery.Scan(&chartOfAccountData).Error; err != nil { return nil, err } @@ -239,10 +268,11 @@ func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID } return &entities.ExpenseAnalytics{ - Summary: summary, - Data: data, - CategoryData: categoryData, - ItemData: itemData, + Summary: summary, + Data: data, + CategoryData: categoryData, + ChartOfAccountData: chartOfAccountData, + ItemData: itemData, }, nil } diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index 6f1fbf6..bc8606c 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -27,10 +27,11 @@ func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.Cre func CreateExpenseItemRequestToModel(req *contract.CreateExpenseItemRequest) models.CreateExpenseItemRequest { return models.CreateExpenseItemRequest{ - ChartOfAccountID: req.ChartOfAccountID, - Item: req.Item, - Description: req.Description, - Amount: req.Amount, + ChartOfAccountID: req.ChartOfAccountID, + PurchaseCategoryID: req.PurchaseCategoryID, + Item: req.Item, + Description: req.Description, + Amount: req.Amount, } } @@ -60,10 +61,11 @@ func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.Upd func UpdateExpenseItemRequestToModel(req *contract.UpdateExpenseItemRequest) models.UpdateExpenseItemRequest { return models.UpdateExpenseItemRequest{ - ChartOfAccountID: req.ChartOfAccountID, - Item: req.Item, - Description: req.Description, - Amount: req.Amount, + ChartOfAccountID: req.ChartOfAccountID, + PurchaseCategoryID: req.PurchaseCategoryID, + Item: req.Item, + Description: req.Description, + Amount: req.Amount, } } @@ -109,15 +111,19 @@ func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.E func ExpenseItemModelResponseToResponse(item *models.ExpenseItemResponse) contract.ExpenseItemResponse { return contract.ExpenseItemResponse{ - ID: item.ID, - ExpenseID: item.ExpenseID, - ChartOfAccountID: item.ChartOfAccountID, - ChartOfAccountName: item.ChartOfAccountName, - Item: item.Item, - Description: item.Description, - Amount: item.Amount, - CreatedAt: item.CreatedAt, - UpdatedAt: item.UpdatedAt, + ID: item.ID, + ExpenseID: item.ExpenseID, + ChartOfAccountID: item.ChartOfAccountID, + ChartOfAccountName: item.ChartOfAccountName, + PurchaseCategoryID: item.PurchaseCategoryID, + PurchaseCategoryName: item.PurchaseCategoryName, + PurchaseCategoryType: item.PurchaseCategoryType, + PurchaseCategory: PurchaseCategoryModelResponseToResponse(item.PurchaseCategory), + Item: item.Item, + Description: item.Description, + Amount: item.Amount, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, } } @@ -176,6 +182,18 @@ func ExpenseAnalyticsModelToContract(resp *models.ExpenseAnalyticsResponse) *con categoryData := make([]contract.ExpenseAnalyticsCategoryData, len(resp.CategoryData)) for i, item := range resp.CategoryData { categoryData[i] = contract.ExpenseAnalyticsCategoryData{ + PurchaseCategoryID: item.PurchaseCategoryID, + PurchaseCategoryName: item.PurchaseCategoryName, + PurchaseCategoryType: item.PurchaseCategoryType, + TotalAmount: item.TotalAmount, + ExpenseCount: item.ExpenseCount, + ItemCount: item.ItemCount, + } + } + + chartOfAccountData := make([]contract.ExpenseAnalyticsChartOfAccountData, len(resp.ChartOfAccountData)) + for i, item := range resp.ChartOfAccountData { + chartOfAccountData[i] = contract.ExpenseAnalyticsChartOfAccountData{ ChartOfAccountID: item.ChartOfAccountID, ChartOfAccountName: item.ChartOfAccountName, TotalAmount: item.TotalAmount, @@ -208,8 +226,9 @@ func ExpenseAnalyticsModelToContract(resp *models.ExpenseAnalyticsResponse) *con TotalCategories: resp.Summary.TotalCategories, TotalItems: resp.Summary.TotalItems, }, - Data: data, - CategoryData: categoryData, - ItemData: itemData, + Data: data, + CategoryData: categoryData, + ChartOfAccountData: chartOfAccountData, + ItemData: itemData, } } diff --git a/internal/validator/expense_validator.go b/internal/validator/expense_validator.go index c9306eb..f5be379 100644 --- a/internal/validator/expense_validator.go +++ b/internal/validator/expense_validator.go @@ -68,12 +68,18 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create if strings.TrimSpace(item.ChartOfAccountID) == "" { return fmt.Errorf("item %d: chart_of_account_id is required", i), constants.MissingFieldErrorCode } + if strings.TrimSpace(item.PurchaseCategoryID) == "" { + return fmt.Errorf("item %d: purchase_category_id is required", i), constants.MissingFieldErrorCode + } if strings.TrimSpace(item.Item) == "" { return fmt.Errorf("item %d: item is required", i), constants.MissingFieldErrorCode } if _, err := uuid.Parse(item.ChartOfAccountID); err != nil { return fmt.Errorf("item %d: chart_of_account_id must be a valid UUID", i), constants.MalformedFieldErrorCode } + if _, err := uuid.Parse(item.PurchaseCategoryID); err != nil { + return fmt.Errorf("item %d: purchase_category_id must be a valid UUID", i), constants.MalformedFieldErrorCode + } if item.Amount <= 0 { return fmt.Errorf("item %d: amount must be greater than 0", i), constants.MalformedFieldErrorCode } @@ -126,6 +132,15 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update return fmt.Errorf("item %d: chart_of_account_id must be a valid UUID", i), constants.MalformedFieldErrorCode } } + if item.PurchaseCategoryID == nil { + return fmt.Errorf("item %d: purchase_category_id is required", i), constants.MissingFieldErrorCode + } + if strings.TrimSpace(*item.PurchaseCategoryID) == "" { + return fmt.Errorf("item %d: purchase_category_id cannot be empty", i), constants.MalformedFieldErrorCode + } + if _, err := uuid.Parse(*item.PurchaseCategoryID); err != nil { + return fmt.Errorf("item %d: purchase_category_id must be a valid UUID", i), constants.MalformedFieldErrorCode + } if item.Item != nil && strings.TrimSpace(*item.Item) == "" { return fmt.Errorf("item %d: item cannot be empty", i), constants.MalformedFieldErrorCode } diff --git a/internal/validator/expense_validator_test.go b/internal/validator/expense_validator_test.go index d9ae15d..8729b8f 100644 --- a/internal/validator/expense_validator_test.go +++ b/internal/validator/expense_validator_test.go @@ -21,8 +21,9 @@ func TestExpenseValidatorCreateRequiresItemName(t *testing.T) { Total: 10000, Items: []contract.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: uuid.NewString(), + Amount: 10000, }, }, } @@ -45,9 +46,10 @@ func TestExpenseValidatorCreateDoesNotRequireHeaderExpenseName(t *testing.T) { Total: 10000, Items: []contract.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, }, }, } @@ -71,9 +73,10 @@ func TestExpenseValidatorCreateAllowsValidOptionalStatus(t *testing.T) { Total: 10000, Items: []contract.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, }, }, } @@ -97,9 +100,10 @@ func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) { Total: 10000, Items: []contract.CreateExpenseItemRequest{ { - ChartOfAccountID: uuid.NewString(), - Item: "Cleaning supplies", - Amount: 10000, + ChartOfAccountID: uuid.NewString(), + PurchaseCategoryID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, }, }, } @@ -114,10 +118,11 @@ func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) { func TestExpenseValidatorUpdateRejectsEmptyItemNameWhenProvided(t *testing.T) { v := NewExpenseValidator() empty := " " + purchaseCategoryID := uuid.NewString() req := &contract.UpdateExpenseRequest{ Items: []contract.UpdateExpenseItemRequest{ - {Item: &empty}, + {PurchaseCategoryID: &purchaseCategoryID, Item: &empty}, }, } diff --git a/migrations/000079_add_purchase_category_to_expense_items.down.sql b/migrations/000079_add_purchase_category_to_expense_items.down.sql new file mode 100644 index 0000000..06940fa --- /dev/null +++ b/migrations/000079_add_purchase_category_to_expense_items.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_expense_items_purchase_category_id; +ALTER TABLE expense_items DROP COLUMN IF EXISTS purchase_category_id; diff --git a/migrations/000079_add_purchase_category_to_expense_items.up.sql b/migrations/000079_add_purchase_category_to_expense_items.up.sql new file mode 100644 index 0000000..d1e1abd --- /dev/null +++ b/migrations/000079_add_purchase_category_to_expense_items.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE expense_items +ADD COLUMN IF NOT EXISTS purchase_category_id UUID REFERENCES purchase_categories(id) ON DELETE RESTRICT; + +CREATE INDEX IF NOT EXISTS idx_expense_items_purchase_category_id +ON expense_items(purchase_category_id);