package processor import ( "context" "fmt" "time" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/mappers" "apskel-pos-be/internal/models" "github.com/google/uuid" ) type ExpenseProcessor interface { CreateExpense(ctx context.Context, organizationID uuid.UUID, req *models.CreateExpenseRequest) (*models.ExpenseResponse, error) UpdateExpense(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateExpenseRequest) (*models.ExpenseResponse, error) 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 { expenseRepo ExpenseRepository purchaseCategoryRepo PurchaseCategoryRepository } func NewExpenseProcessorImpl(expenseRepo ExpenseRepository, purchaseCategoryRepo PurchaseCategoryRepository) *ExpenseProcessorImpl { return &ExpenseProcessorImpl{ expenseRepo: expenseRepo, purchaseCategoryRepo: purchaseCategoryRepo, } } func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID uuid.UUID, req *models.CreateExpenseRequest) (*models.ExpenseResponse, error) { outletID, err := uuid.Parse(req.OutletID) if err != nil { return nil, fmt.Errorf("invalid outlet_id: %w", err) } transactionDate, err := time.Parse("2006-01-02", req.TransactionDate) if err != nil { return nil, fmt.Errorf("invalid transaction_date format, expected YYYY-MM-DD: %w", err) } status := string(constants.ExpenseStatusDraft) if req.Status != nil { 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, Receiver: req.Receiver, TransactionDate: transactionDate, CodeNumber: req.CodeNumber, Status: status, Description: req.Description, Tax: req.Tax, Total: req.Total, } err = p.expenseRepo.Create(ctx, expenseEntity) if err != nil { return nil, fmt.Errorf("failed to create expense: %w", err) } for i := range items { items[i].ExpenseID = expenseEntity.ID err = p.expenseRepo.CreateItem(ctx, &items[i]) if err != nil { return nil, fmt.Errorf("failed to create expense item: %w", err) } } created, err := p.expenseRepo.GetByID(ctx, expenseEntity.ID) if err != nil { return mappers.ExpenseEntityToResponse(expenseEntity), nil } return mappers.ExpenseEntityToResponse(created), nil } func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdateExpenseRequest) (*models.ExpenseResponse, error) { expenseEntity, err := p.expenseRepo.GetByIDAndOrganizationID(ctx, id, organizationID) if err != nil { return nil, fmt.Errorf("expense not found: %w", err) } if req.Receiver != nil { expenseEntity.Receiver = *req.Receiver } if req.TransactionDate != nil { parsedDate, err := time.Parse("2006-01-02", *req.TransactionDate) if err != nil { return nil, fmt.Errorf("invalid transaction_date format, expected YYYY-MM-DD: %w", err) } expenseEntity.TransactionDate = parsedDate } if req.CodeNumber != nil { expenseEntity.CodeNumber = *req.CodeNumber } if req.Status != nil { expenseEntity.Status = *req.Status } if req.OutletID != nil { outletID, err := uuid.Parse(*req.OutletID) if err != nil { return nil, fmt.Errorf("invalid outlet_id: %w", err) } expenseEntity.OutletID = outletID } if req.Description != nil { expenseEntity.Description = req.Description } if req.Tax != nil { expenseEntity.Tax = *req.Tax } if req.Total != nil { expenseEntity.Total = *req.Total } if req.Reserved1 != nil { expenseEntity.Reserved1 = req.Reserved1 } var items []entities.ExpenseItem if req.Items != nil { 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) if err != nil { return nil, fmt.Errorf("invalid chart_of_account_id for item: %w", err) } } 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 } item := "" if itemReq.Item != nil { item = *itemReq.Item } items[i] = entities.ExpenseItem{ ExpenseID: expenseEntity.ID, ChartOfAccountID: chartOfAccountID, PurchaseCategoryID: purchaseCategoryID, Item: item, Description: itemReq.Description, Amount: amount, } } 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) } } } err = p.expenseRepo.Update(ctx, expenseEntity) if err != nil { return nil, fmt.Errorf("failed to update expense: %w", err) } updated, err := p.expenseRepo.GetByID(ctx, id) if err != nil { return mappers.ExpenseEntityToResponse(expenseEntity), nil } return mappers.ExpenseEntityToResponse(updated), nil } func (p *ExpenseProcessorImpl) DeleteExpense(ctx context.Context, id, organizationID uuid.UUID) error { _, err := p.expenseRepo.GetByIDAndOrganizationID(ctx, id, organizationID) if err != nil { return fmt.Errorf("expense not found: %w", err) } err = p.expenseRepo.Delete(ctx, id) if err != nil { return fmt.Errorf("failed to delete expense: %w", err) } return nil } func (p *ExpenseProcessorImpl) GetExpenseByID(ctx context.Context, id, organizationID uuid.UUID) (*models.ExpenseResponse, error) { expenseEntity, err := p.expenseRepo.GetByIDAndOrganizationID(ctx, id, organizationID) if err != nil { return nil, fmt.Errorf("expense not found: %w", err) } return mappers.ExpenseEntityToResponse(expenseEntity), nil } func (p *ExpenseProcessorImpl) ListExpenses(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.ExpenseResponse, int, error) { offset := (page - 1) * limit expenseEntities, total, err := p.expenseRepo.List(ctx, organizationID, filters, limit, offset) if err != nil { return nil, 0, fmt.Errorf("failed to list expenses: %w", err) } expenseResponses := mappers.ExpenseEntitiesToResponses(expenseEntities) totalPages := int((total + int64(limit) - 1) / int64(limit)) 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{ 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, 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, 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 }