From 094e8b2a476da26322e496cd0ee8ac23f75ce3f9 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 3 Jun 2026 14:56:27 +0700 Subject: [PATCH] Add expense analytics --- internal/contract/expense_contract.go | 52 +++++++ internal/entities/expense.go | 40 ++++++ internal/handler/expense_handler.go | 28 ++++ internal/models/expense.go | 53 +++++++ .../processor/analytics_processor_test.go | 3 + internal/processor/expense_processor.go | 68 +++++++++ internal/processor/expense_processor_test.go | 70 ++++++++++ internal/processor/expense_repository.go | 2 + internal/repository/expense_repository.go | 132 ++++++++++++++++++ internal/router/router.go | 1 + internal/service/expense_service.go | 22 +++ internal/transformer/expense_transformer.go | 79 +++++++++++ 12 files changed, 550 insertions(+) diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index a8ff07e..b769d2e 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -91,3 +91,55 @@ type ListExpenseResponse struct { Limit int `json:"limit"` TotalPages int `json:"total_pages"` } + +type ExpenseAnalyticsRequest struct { + OutletID *string `form:"outlet_id,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` +} + +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"` +} + +type ExpenseAnalyticsSummary struct { + TotalExpenses float64 `json:"total_expenses"` + TotalExpenseCount int64 `json:"total_expense_count"` + TotalTax float64 `json:"total_tax"` + AverageExpenseValue float64 `json:"average_expense_value"` + TotalCategories int64 `json:"total_categories"` + TotalItems int64 `json:"total_items"` +} + +type ExpenseAnalyticsData struct { + Date time.Time `json:"date"` + Expenses float64 `json:"expenses"` + ExpenseCount int64 `json:"expense_count"` + Tax float64 `json:"tax"` + Items int64 `json:"items"` + Categories int64 `json:"categories"` +} + +type ExpenseAnalyticsCategoryData struct { + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + ChartOfAccountName string `json:"chart_of_account_name"` + TotalAmount float64 `json:"total_amount"` + ExpenseCount int64 `json:"expense_count"` + ItemCount int64 `json:"item_count"` +} + +type ExpenseAnalyticsItemData struct { + Item string `json:"item"` + TotalAmount float64 `json:"total_amount"` + ExpenseCount int64 `json:"expense_count"` + ItemCount int64 `json:"item_count"` +} diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 93d9789..137157f 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -28,6 +28,46 @@ type Expense struct { Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"` } +type ExpenseAnalytics struct { + Summary ExpenseAnalyticsSummary + Data []ExpenseAnalyticsData + CategoryData []ExpenseAnalyticsCategoryData + ItemData []ExpenseAnalyticsItemData +} + +type ExpenseAnalyticsSummary struct { + TotalExpenses float64 + TotalExpenseCount int64 + TotalTax float64 + AverageExpenseValue float64 + TotalCategories int64 + TotalItems int64 +} + +type ExpenseAnalyticsData struct { + Date time.Time + Expenses float64 + ExpenseCount int64 + Tax float64 + Items int64 + Categories int64 +} + +type ExpenseAnalyticsCategoryData struct { + ChartOfAccountID uuid.UUID + ChartOfAccountName string + TotalAmount float64 + ExpenseCount int64 + ItemCount int64 +} + +type ExpenseAnalyticsItemData struct { + Item string + TotalAmount float64 + ExpenseCount int64 + ItemCount int64 +} + func (e *Expense) BeforeCreate(tx *gorm.DB) error { if e.ID == uuid.Nil { e.ID = uuid.New() diff --git a/internal/handler/expense_handler.go b/internal/handler/expense_handler.go index c09cc79..36ad959 100644 --- a/internal/handler/expense_handler.go +++ b/internal/handler/expense_handler.go @@ -199,3 +199,31 @@ func (h *ExpenseHandler) ListExpenses(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::ListExpenses") } + +func (h *ExpenseHandler) GetExpenseAnalytics(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.ExpenseAnalyticsRequest + if err := c.ShouldBindQuery(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpenseAnalytics -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpenseAnalytics") + return + } + + if contextInfo.OutletID != uuid.Nil { + outletID := contextInfo.OutletID.String() + req.OutletID = &outletID + } else if outletID := c.Query("outlet_id"); outletID != "" { + req.OutletID = &outletID + } + + expenseResponse := h.expenseService.GetExpenseAnalytics(ctx, contextInfo, &req) + if expenseResponse.HasErrors() { + errorResp := expenseResponse.GetErrors()[0] + logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpenseAnalytics -> Failed to get expense analytics from service") + } + + util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpenseAnalytics") +} diff --git a/internal/models/expense.go b/internal/models/expense.go index 52a34e5..57c08d0 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -118,3 +118,56 @@ type ListExpenseResponse struct { Limit int `json:"limit"` TotalPages int `json:"total_pages"` } + +type ExpenseAnalyticsRequest 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"` +} + +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"` +} + +type ExpenseAnalyticsSummary struct { + TotalExpenses float64 `json:"total_expenses"` + TotalExpenseCount int64 `json:"total_expense_count"` + TotalTax float64 `json:"total_tax"` + AverageExpenseValue float64 `json:"average_expense_value"` + TotalCategories int64 `json:"total_categories"` + TotalItems int64 `json:"total_items"` +} + +type ExpenseAnalyticsData struct { + Date time.Time `json:"date"` + Expenses float64 `json:"expenses"` + ExpenseCount int64 `json:"expense_count"` + Tax float64 `json:"tax"` + Items int64 `json:"items"` + Categories int64 `json:"categories"` +} + +type ExpenseAnalyticsCategoryData struct { + ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` + ChartOfAccountName string `json:"chart_of_account_name"` + TotalAmount float64 `json:"total_amount"` + ExpenseCount int64 `json:"expense_count"` + ItemCount int64 `json:"item_count"` +} + +type ExpenseAnalyticsItemData struct { + Item string `json:"item"` + TotalAmount float64 `json:"total_amount"` + ExpenseCount int64 `json:"expense_count"` + ItemCount int64 `json:"item_count"` +} diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 78fe121..0c46b4d 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -61,6 +61,9 @@ func (expenseRepositoryStub) Delete(context.Context, uuid.UUID) error { func (expenseRepositoryStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) { return nil, 0, nil } +func (expenseRepositoryStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) { + return nil, nil +} func (expenseRepositoryStub) CreateItem(context.Context, *entities.ExpenseItem) error { return nil } func (expenseRepositoryStub) DeleteItemsByExpenseID(context.Context, uuid.UUID) error { return nil } diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index 4e0557b..2141ebe 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -19,6 +19,7 @@ type ExpenseProcessor interface { DeleteExpense(ctx context.Context, id, organizationID uuid.UUID) error GetExpenseByID(ctx context.Context, id, organizationID uuid.UUID) (*models.ExpenseResponse, error) ListExpenses(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.ExpenseResponse, int, error) + GetExpenseAnalytics(ctx context.Context, req *models.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsResponse, error) } type ExpenseProcessorImpl struct { @@ -221,3 +222,70 @@ func (p *ExpenseProcessorImpl) ListExpenses(ctx context.Context, organizationID return expenseResponses, totalPages, nil } + +func (p *ExpenseProcessorImpl) GetExpenseAnalytics(ctx context.Context, req *models.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsResponse, error) { + if req.DateFrom.After(req.DateTo) { + return nil, fmt.Errorf("date_from cannot be after date_to") + } + + if req.GroupBy == "" { + req.GroupBy = "day" + } + + result, err := p.expenseRepo.GetAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) + if err != nil { + return nil, fmt.Errorf("failed to get expense analytics: %w", err) + } + + data := make([]models.ExpenseAnalyticsData, len(result.Data)) + for i, item := range result.Data { + data[i] = models.ExpenseAnalyticsData{ + Date: item.Date, + Expenses: item.Expenses, + ExpenseCount: item.ExpenseCount, + Tax: item.Tax, + Items: item.Items, + Categories: item.Categories, + } + } + + categoryData := make([]models.ExpenseAnalyticsCategoryData, len(result.CategoryData)) + for i, item := range result.CategoryData { + categoryData[i] = models.ExpenseAnalyticsCategoryData{ + ChartOfAccountID: item.ChartOfAccountID, + ChartOfAccountName: item.ChartOfAccountName, + TotalAmount: item.TotalAmount, + ExpenseCount: item.ExpenseCount, + ItemCount: item.ItemCount, + } + } + + itemData := make([]models.ExpenseAnalyticsItemData, len(result.ItemData)) + for i, item := range result.ItemData { + itemData[i] = models.ExpenseAnalyticsItemData{ + Item: item.Item, + TotalAmount: item.TotalAmount, + ExpenseCount: item.ExpenseCount, + ItemCount: item.ItemCount, + } + } + + return &models.ExpenseAnalyticsResponse{ + OrganizationID: req.OrganizationID, + OutletID: req.OutletID, + DateFrom: req.DateFrom, + DateTo: req.DateTo, + GroupBy: req.GroupBy, + Summary: models.ExpenseAnalyticsSummary{ + TotalExpenses: result.Summary.TotalExpenses, + TotalExpenseCount: result.Summary.TotalExpenseCount, + TotalTax: result.Summary.TotalTax, + AverageExpenseValue: result.Summary.AverageExpenseValue, + TotalCategories: result.Summary.TotalCategories, + TotalItems: result.Summary.TotalItems, + }, + Data: data, + CategoryData: categoryData, + ItemData: itemData, + }, nil +} diff --git a/internal/processor/expense_processor_test.go b/internal/processor/expense_processor_test.go index 7afb0e7..b42fed7 100644 --- a/internal/processor/expense_processor_test.go +++ b/internal/processor/expense_processor_test.go @@ -3,6 +3,7 @@ package processor import ( "context" "testing" + "time" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" @@ -14,6 +15,7 @@ import ( type expenseRepositoryCaptureStub struct { createdExpense *entities.Expense createdItems []*entities.ExpenseItem + analytics *entities.ExpenseAnalytics } func (s *expenseRepositoryCaptureStub) Create(_ context.Context, expense *entities.Expense) error { @@ -44,6 +46,9 @@ func (*expenseRepositoryCaptureStub) Delete(context.Context, uuid.UUID) error func (*expenseRepositoryCaptureStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) { return nil, 0, nil } +func (s *expenseRepositoryCaptureStub) GetAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ExpenseAnalytics, error) { + return s.analytics, nil +} func (s *expenseRepositoryCaptureStub) CreateItem(_ context.Context, item *entities.ExpenseItem) error { if item.ID == uuid.Nil { item.ID = uuid.New() @@ -134,3 +139,68 @@ func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { require.Equal(t, "approved", repo.createdExpense.Status) require.Equal(t, "approved", resp.Status) } + +func TestExpenseProcessorGetExpenseAnalyticsDefaultsGroupByAndMapsResponse(t *testing.T) { + coaID := uuid.New() + outletID := uuid.New() + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + repo := &expenseRepositoryCaptureStub{ + analytics: &entities.ExpenseAnalytics{ + Summary: entities.ExpenseAnalyticsSummary{ + TotalExpenses: 100000, + TotalExpenseCount: 2, + TotalTax: 10000, + AverageExpenseValue: 50000, + TotalCategories: 1, + TotalItems: 2, + }, + Data: []entities.ExpenseAnalyticsData{ + { + Date: now, + Expenses: 100000, + ExpenseCount: 2, + Tax: 10000, + Items: 2, + Categories: 1, + }, + }, + CategoryData: []entities.ExpenseAnalyticsCategoryData{ + { + ChartOfAccountID: coaID, + ChartOfAccountName: "Operational", + TotalAmount: 100000, + ExpenseCount: 2, + ItemCount: 2, + }, + }, + ItemData: []entities.ExpenseAnalyticsItemData{ + { + Item: "Cleaning supplies", + TotalAmount: 100000, + ExpenseCount: 2, + ItemCount: 2, + }, + }, + }, + } + p := NewExpenseProcessorImpl(repo) + + resp, err := p.GetExpenseAnalytics(context.Background(), &models.ExpenseAnalyticsRequest{ + OrganizationID: uuid.New(), + OutletID: &outletID, + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, "day", resp.GroupBy) + require.Equal(t, &outletID, resp.OutletID) + require.Equal(t, float64(100000), resp.Summary.TotalExpenses) + 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.Len(t, resp.ItemData, 1) + require.Equal(t, "Cleaning supplies", resp.ItemData[0].Item) +} diff --git a/internal/processor/expense_repository.go b/internal/processor/expense_repository.go index baefbc2..2b96c0b 100644 --- a/internal/processor/expense_repository.go +++ b/internal/processor/expense_repository.go @@ -3,6 +3,7 @@ package processor import ( "apskel-pos-be/internal/entities" "context" + "time" "github.com/google/uuid" ) @@ -14,6 +15,7 @@ type ExpenseRepository interface { Update(ctx context.Context, expense *entities.Expense) error Delete(ctx context.Context, id uuid.UUID) error List(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, limit, offset int) ([]*entities.Expense, int64, error) + GetAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ExpenseAnalytics, error) CreateItem(ctx context.Context, item *entities.ExpenseItem) error DeleteItemsByExpenseID(ctx context.Context, expenseID uuid.UUID) error } diff --git a/internal/repository/expense_repository.go b/internal/repository/expense_repository.go index 335d0eb..4877243 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -114,6 +114,138 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU return expenses, total, err } +func (r *ExpenseRepositoryImpl) GetAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ExpenseAnalytics, error) { + var summary entities.ExpenseAnalyticsSummary + + summaryQuery := r.db.WithContext(ctx). + Table("expenses e"). + Select(` + COALESCE(SUM(e.total), 0) as total_expenses, + COUNT(e.id) as total_expense_count, + COALESCE(SUM(e.tax), 0) as total_tax, + COALESCE(AVG(e.total), 0) as average_expense_value + `). + Where("e.organization_id = ?", organizationID). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) + if outletID != nil { + summaryQuery = summaryQuery.Where("e.outlet_id = ?", *outletID) + } + if err := summaryQuery.Scan(&summary).Error; err != nil { + return nil, err + } + + countsQuery := r.db.WithContext(ctx). + Table("expense_items ei"). + Select(` + COUNT(ei.id) as total_items, + COUNT(DISTINCT ei.chart_of_account_id) as total_categories + `). + Joins("JOIN expenses e ON ei.expense_id = e.id"). + Where("e.organization_id = ?", organizationID). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo) + if outletID != nil { + countsQuery = countsQuery.Where("e.outlet_id = ?", *outletID) + } + if err := countsQuery.Scan(&summary).Error; err != nil { + return nil, err + } + + dateFormat := "DATE_TRUNC('day', e.transaction_date)" + switch groupBy { + case "hour": + dateFormat = "DATE_TRUNC('hour', e.transaction_date)" + case "week": + dateFormat = "DATE_TRUNC('week', e.transaction_date)" + case "month": + dateFormat = "DATE_TRUNC('month', e.transaction_date)" + } + + var data []entities.ExpenseAnalyticsData + dataQuery := r.db.WithContext(ctx). + Table("expenses e"). + Select(` + `+dateFormat+` as date, + COALESCE(SUM(e.total), 0) as expenses, + COUNT(e.id) as expense_count, + COALESCE(SUM(e.tax), 0) as tax, + COALESCE(SUM(item_counts.items), 0) as items, + 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 + FROM expense_items + GROUP BY expense_id + ) item_counts ON item_counts.expense_id = e.id`). + Where("e.organization_id = ?", organizationID). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo). + Group(dateFormat). + Order(dateFormat) + if outletID != nil { + dataQuery = dataQuery.Where("e.outlet_id = ?", *outletID) + } + if err := dataQuery.Scan(&data).Error; err != nil { + return nil, err + } + + var categoryData []entities.ExpenseAnalyticsCategoryData + categoryQuery := r.db.WithContext(ctx). + Table("expense_items ei"). + Select(` + COALESCE(parent_coa.id, coa.id) as chart_of_account_id, + COALESCE(parent_coa.name, coa.name, 'Lain-lain') as chart_of_account_name, + 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 chart_of_accounts coa ON ei.chart_of_account_id = coa.id"). + Joins("LEFT JOIN chart_of_accounts parent_coa ON coa.parent_id = parent_coa.id"). + Where("e.organization_id = ?", organizationID). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo). + 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) + } + if err := categoryQuery.Scan(&categoryData).Error; err != nil { + return nil, err + } + + var itemData []entities.ExpenseAnalyticsItemData + itemQuery := r.db.WithContext(ctx). + Table("expense_items ei"). + Select(` + COALESCE(NULLIF(ei.item, ''), ei.description, coa.name) as item, + 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 chart_of_accounts coa ON ei.chart_of_account_id = coa.id"). + Where("e.organization_id = ?", organizationID). + Where("e.status = ?", "approved"). + Where("e.transaction_date >= ? AND e.transaction_date <= ?", dateFrom, dateTo). + Group("COALESCE(NULLIF(ei.item, ''), ei.description, coa.name)"). + Order("total_amount DESC") + if outletID != nil { + itemQuery = itemQuery.Where("e.outlet_id = ?", *outletID) + } + if err := itemQuery.Scan(&itemData).Error; err != nil { + return nil, err + } + + return &entities.ExpenseAnalytics{ + Summary: summary, + Data: data, + CategoryData: categoryData, + ItemData: itemData, + }, nil +} + func (r *ExpenseRepositoryImpl) CreateItem(ctx context.Context, item *entities.ExpenseItem) error { return r.db.WithContext(ctx).Create(item).Error } diff --git a/internal/router/router.go b/internal/router/router.go index 4febd1b..f9fdee7 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -451,6 +451,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { { expenses.POST("", r.expenseHandler.CreateExpense) expenses.GET("", r.expenseHandler.ListExpenses) + expenses.GET("/analytics", r.expenseHandler.GetExpenseAnalytics) expenses.GET("/:id", r.expenseHandler.GetExpense) expenses.PUT("/:id", r.expenseHandler.UpdateExpense) expenses.DELETE("/:id", r.expenseHandler.DeleteExpense) diff --git a/internal/service/expense_service.go b/internal/service/expense_service.go index bb8a417..9e937b4 100644 --- a/internal/service/expense_service.go +++ b/internal/service/expense_service.go @@ -19,6 +19,7 @@ type ExpenseService interface { DeleteExpense(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response GetExpenseByID(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response ListExpenses(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ListExpenseRequest) *contract.Response + GetExpenseAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ExpenseAnalyticsRequest) *contract.Response } type ExpenseServiceImpl struct { @@ -126,3 +127,24 @@ func (s *ExpenseServiceImpl) ListExpenses(ctx context.Context, apctx *appcontext return contract.BuildSuccessResponse(response) } + +func (s *ExpenseServiceImpl) GetExpenseAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.ExpenseAnalyticsRequest) *contract.Response { + modelReq, err := transformer.ExpenseAnalyticsRequestToModel(req) + if err != nil { + errorResp := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + modelReq.OrganizationID = apctx.OrganizationID + if apctx.OutletID != uuid.Nil { + modelReq.OutletID = &apctx.OutletID + } + + response, err := s.expenseProcessor.GetExpenseAnalytics(ctx, modelReq) + if err != nil { + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ExpenseServiceEntity, err.Error()) + return contract.BuildErrorResponse([]*contract.ResponseError{errorResp}) + } + + return contract.BuildSuccessResponse(transformer.ExpenseAnalyticsModelToContract(response)) +} diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index 5b3c47c..6f1fbf6 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -3,6 +3,7 @@ package transformer import ( "apskel-pos-be/internal/contract" "apskel-pos-be/internal/models" + "apskel-pos-be/internal/util" ) func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.CreateExpenseRequest { @@ -134,3 +135,81 @@ func ExpenseModelResponsesToResponses(expenses []*models.ExpenseResponse) []cont } return responses } + +func ExpenseAnalyticsRequestToModel(req *contract.ExpenseAnalyticsRequest) (*models.ExpenseAnalyticsRequest, error) { + dateFrom, dateTo, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo) + if err != nil { + return nil, err + } + + modelReq := &models.ExpenseAnalyticsRequest{ + OutletID: parseOutletID(req.OutletID), + GroupBy: req.GroupBy, + } + if dateFrom != nil { + modelReq.DateFrom = *dateFrom + } + if dateTo != nil { + modelReq.DateTo = *dateTo + } + + return modelReq, nil +} + +func ExpenseAnalyticsModelToContract(resp *models.ExpenseAnalyticsResponse) *contract.ExpenseAnalyticsResponse { + if resp == nil { + return nil + } + + data := make([]contract.ExpenseAnalyticsData, len(resp.Data)) + for i, item := range resp.Data { + data[i] = contract.ExpenseAnalyticsData{ + Date: item.Date, + Expenses: item.Expenses, + ExpenseCount: item.ExpenseCount, + Tax: item.Tax, + Items: item.Items, + Categories: item.Categories, + } + } + + categoryData := make([]contract.ExpenseAnalyticsCategoryData, len(resp.CategoryData)) + for i, item := range resp.CategoryData { + categoryData[i] = contract.ExpenseAnalyticsCategoryData{ + ChartOfAccountID: item.ChartOfAccountID, + ChartOfAccountName: item.ChartOfAccountName, + TotalAmount: item.TotalAmount, + ExpenseCount: item.ExpenseCount, + ItemCount: item.ItemCount, + } + } + + itemData := make([]contract.ExpenseAnalyticsItemData, len(resp.ItemData)) + for i, item := range resp.ItemData { + itemData[i] = contract.ExpenseAnalyticsItemData{ + Item: item.Item, + TotalAmount: item.TotalAmount, + ExpenseCount: item.ExpenseCount, + ItemCount: item.ItemCount, + } + } + + return &contract.ExpenseAnalyticsResponse{ + OrganizationID: resp.OrganizationID, + OutletID: resp.OutletID, + DateFrom: resp.DateFrom, + DateTo: resp.DateTo, + GroupBy: resp.GroupBy, + Summary: contract.ExpenseAnalyticsSummary{ + TotalExpenses: resp.Summary.TotalExpenses, + TotalExpenseCount: resp.Summary.TotalExpenseCount, + TotalTax: resp.Summary.TotalTax, + AverageExpenseValue: resp.Summary.AverageExpenseValue, + TotalCategories: resp.Summary.TotalCategories, + TotalItems: resp.Summary.TotalItems, + }, + Data: data, + CategoryData: categoryData, + ItemData: itemData, + } +}