From b8be29e1103de1bdf5284a9edb8865e85f700468 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 25 May 2026 16:19:36 +0700 Subject: [PATCH] Add item_expense --- internal/contract/expense_contract.go | 64 ++++++---- internal/entities/expense.go | 31 +++-- internal/entities/expense_item.go | 33 +++++ internal/mappers/expense_mapper.go | 116 +++++++++++------- internal/models/expense.go | 95 +++++++++----- internal/processor/expense_processor.go | 82 +++++++++---- internal/processor/expense_repository.go | 2 + internal/repository/expense_repository.go | 24 +++- internal/transformer/expense_transformer.go | 107 +++++++++++----- internal/validator/expense_validator.go | 50 +++++--- .../000070_create_expenses_table.down.sql | 1 + .../000070_create_expenses_table.up.sql | 15 ++- 12 files changed, 439 insertions(+), 181 deletions(-) create mode 100644 internal/entities/expense_item.go diff --git a/internal/contract/expense_contract.go b/internal/contract/expense_contract.go index ef8af97..ef1cedf 100644 --- a/internal/contract/expense_contract.go +++ b/internal/contract/expense_contract.go @@ -7,41 +7,63 @@ import ( ) type CreateExpenseRequest struct { - Receiver string `json:"receiver" validate:"required"` - TransactionDate string `json:"transaction_date" validate:"required"` - CodeNumber string `json:"code_number" validate:"required"` + Receiver string `json:"receiver" validate:"required"` + TransactionDate string `json:"transaction_date" validate:"required"` + CodeNumber string `json:"code_number" validate:"required"` + OutletID string `json:"outlet_id" validate:"required"` + Description *string `json:"description,omitempty"` + Tax float64 `json:"tax"` + Total float64 `json:"total" validate:"required"` + Items []CreateExpenseItemRequest `json:"items" validate:"required"` +} + +type CreateExpenseItemRequest struct { ChartOfAccountID string `json:"chart_of_account_id" validate:"required"` - OutletID string `json:"outlet_id" validate:"required"` Description *string `json:"description,omitempty"` - Tax float64 `json:"tax"` - Total float64 `json:"total" validate:"required"` + Amount float64 `json:"amount" validate:"required"` } type UpdateExpenseRequest struct { - Receiver *string `json:"receiver,omitempty"` - TransactionDate *string `json:"transaction_date,omitempty"` - CodeNumber *string `json:"code_number,omitempty"` + Receiver *string `json:"receiver,omitempty"` + TransactionDate *string `json:"transaction_date,omitempty"` + CodeNumber *string `json:"code_number,omitempty"` + OutletID *string `json:"outlet_id,omitempty"` + Description *string `json:"description,omitempty"` + Tax *float64 `json:"tax,omitempty"` + Total *float64 `json:"total,omitempty"` + Reserved1 *string `json:"reserved1,omitempty"` + Items []UpdateExpenseItemRequest `json:"items,omitempty"` +} + +type UpdateExpenseItemRequest struct { ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` - OutletID *string `json:"outlet_id,omitempty"` Description *string `json:"description,omitempty"` - Tax *float64 `json:"tax,omitempty"` - Total *float64 `json:"total,omitempty"` - Reserved1 *string `json:"reserved1,omitempty"` + Amount *float64 `json:"amount,omitempty"` } type ExpenseResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id"` + Receiver string `json:"receiver"` + TransactionDate time.Time `json:"transaction_date"` + CodeNumber string `json:"code_number"` + Description *string `json:"description"` + Tax float64 `json:"tax"` + Total float64 `json:"total"` + Reserved1 *string `json:"reserved1,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Items []ExpenseItemResponse `json:"items,omitempty"` +} + +type ExpenseItemResponse struct { ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID uuid.UUID `json:"outlet_id"` - Receiver string `json:"receiver"` - TransactionDate time.Time `json:"transaction_date"` - CodeNumber string `json:"code_number"` + ExpenseID uuid.UUID `json:"expense_id"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountName string `json:"chart_of_account_name,omitempty"` Description *string `json:"description"` - Tax float64 `json:"tax"` - Total float64 `json:"total"` - Reserved1 *string `json:"reserved1,omitempty"` + Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 4b42c02..a9ba53e 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -9,23 +9,22 @@ import ( ) type Expense struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` - OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"` - Receiver string `gorm:"not null;size:255" json:"receiver"` - TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date"` - CodeNumber string `gorm:"not null;size:50" json:"code_number"` - ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"` - Description *string `gorm:"type:text" json:"description"` - Tax float64 `gorm:"type:decimal(15,2);not null;default:0" json:"tax"` - Total float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total"` - Reserved1 *string `gorm:"type:text" json:"reserved1"` - 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"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` + OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"` + Receiver string `gorm:"not null;size:255" json:"receiver"` + TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date"` + CodeNumber string `gorm:"not null;size:50" json:"code_number"` + Description *string `gorm:"type:text" json:"description"` + Tax float64 `gorm:"type:decimal(15,2);not null;default:0" json:"tax"` + Total float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total"` + Reserved1 *string `gorm:"type:text" json:"reserved1"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` - Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` - ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"` + Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` + Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` + Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"` } func (e *Expense) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/entities/expense_item.go b/internal/entities/expense_item.go new file mode 100644 index 0000000..72b3c24 --- /dev/null +++ b/internal/entities/expense_item.go @@ -0,0 +1,33 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + + "gorm.io/gorm" +) + +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"` + 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"` +} + +func (e *ExpenseItem) BeforeCreate(tx *gorm.DB) error { + if e.ID == uuid.Nil { + e.ID = uuid.New() + } + return nil +} + +func (ExpenseItem) TableName() string { + return "expense_items" +} diff --git a/internal/mappers/expense_mapper.go b/internal/mappers/expense_mapper.go index 2ae595d..83078c2 100644 --- a/internal/mappers/expense_mapper.go +++ b/internal/mappers/expense_mapper.go @@ -11,19 +11,18 @@ func ExpenseEntityToModel(entity *entities.Expense) *models.Expense { } return &models.Expense{ - ID: entity.ID, - OrganizationID: entity.OrganizationID, - OutletID: entity.OutletID, - Receiver: entity.Receiver, - TransactionDate: entity.TransactionDate, - CodeNumber: entity.CodeNumber, - ChartOfAccountID: entity.ChartOfAccountID, - Description: entity.Description, - Tax: entity.Tax, - Total: entity.Total, - Reserved1: entity.Reserved1, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + Receiver: entity.Receiver, + TransactionDate: entity.TransactionDate, + CodeNumber: entity.CodeNumber, + Description: entity.Description, + Tax: entity.Tax, + Total: entity.Total, + Reserved1: entity.Reserved1, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, } } @@ -33,19 +32,18 @@ func ExpenseModelToEntity(model *models.Expense) *entities.Expense { } return &entities.Expense{ - ID: model.ID, - OrganizationID: model.OrganizationID, - OutletID: model.OutletID, - Receiver: model.Receiver, - TransactionDate: model.TransactionDate, - CodeNumber: model.CodeNumber, - ChartOfAccountID: model.ChartOfAccountID, - Description: model.Description, - Tax: model.Tax, - Total: model.Total, - Reserved1: model.Reserved1, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, + ID: model.ID, + OrganizationID: model.OrganizationID, + OutletID: model.OutletID, + Receiver: model.Receiver, + TransactionDate: model.TransactionDate, + CodeNumber: model.CodeNumber, + Description: model.Description, + Tax: model.Tax, + Total: model.Total, + Reserved1: model.Reserved1, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, } } @@ -55,23 +53,22 @@ func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse { } resp := &models.ExpenseResponse{ - ID: entity.ID, - OrganizationID: entity.OrganizationID, - OutletID: entity.OutletID, - Receiver: entity.Receiver, - TransactionDate: entity.TransactionDate, - CodeNumber: entity.CodeNumber, - ChartOfAccountID: entity.ChartOfAccountID, - Description: entity.Description, - Tax: entity.Tax, - Total: entity.Total, - Reserved1: entity.Reserved1, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, + ID: entity.ID, + OrganizationID: entity.OrganizationID, + OutletID: entity.OutletID, + Receiver: entity.Receiver, + TransactionDate: entity.TransactionDate, + CodeNumber: entity.CodeNumber, + Description: entity.Description, + Tax: entity.Tax, + Total: entity.Total, + Reserved1: entity.Reserved1, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, } - if entity.ChartOfAccount != nil { - resp.ChartOfAccountName = entity.ChartOfAccount.Name + if entity.Items != nil { + resp.Items = ExpenseItemEntitiesToResponses(entity.Items) } return resp @@ -88,3 +85,40 @@ func ExpenseEntitiesToResponses(entities []*entities.Expense) []*models.ExpenseR } return responses } + +func ExpenseItemEntityToResponse(entity *entities.ExpenseItem) *models.ExpenseItemResponse { + if entity == nil { + return nil + } + + response := &models.ExpenseItemResponse{ + ID: entity.ID, + ExpenseID: entity.ExpenseID, + ChartOfAccountID: entity.ChartOfAccountID, + Description: entity.Description, + Amount: entity.Amount, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } + + if entity.ChartOfAccount != nil { + response.ChartOfAccountName = entity.ChartOfAccount.Name + } + + return response +} + +func ExpenseItemEntitiesToResponses(entities []entities.ExpenseItem) []models.ExpenseItemResponse { + if entities == nil { + return nil + } + + responses := make([]models.ExpenseItemResponse, len(entities)) + for i, entity := range entities { + response := ExpenseItemEntityToResponse(&entity) + if response != nil { + responses[i] = *response + } + } + return responses +} diff --git a/internal/models/expense.go b/internal/models/expense.go index 40801ac..b1e6ae8 100644 --- a/internal/models/expense.go +++ b/internal/models/expense.go @@ -7,59 +7,90 @@ import ( ) type Expense struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id"` + Receiver string `json:"receiver"` + TransactionDate time.Time `json:"transaction_date"` + CodeNumber string `json:"code_number"` + Description *string `json:"description"` + Tax float64 `json:"tax"` + Total float64 `json:"total"` + Reserved1 *string `json:"reserved1"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ExpenseItem struct { ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID uuid.UUID `json:"outlet_id"` - Receiver string `json:"receiver"` - TransactionDate time.Time `json:"transaction_date"` - CodeNumber string `json:"code_number"` + ExpenseID uuid.UUID `json:"expense_id"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` Description *string `json:"description"` - Tax float64 `json:"tax"` - Total float64 `json:"total"` - Reserved1 *string `json:"reserved1"` + Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type ExpenseResponse struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID uuid.UUID `json:"outlet_id"` + Receiver string `json:"receiver"` + TransactionDate time.Time `json:"transaction_date"` + CodeNumber string `json:"code_number"` + Description *string `json:"description"` + Tax float64 `json:"tax"` + Total float64 `json:"total"` + Reserved1 *string `json:"reserved1"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Items []ExpenseItemResponse `json:"items,omitempty"` +} + +type ExpenseItemResponse struct { ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - OutletID uuid.UUID `json:"outlet_id"` - Receiver string `json:"receiver"` - TransactionDate time.Time `json:"transaction_date"` - CodeNumber string `json:"code_number"` + ExpenseID uuid.UUID `json:"expense_id"` ChartOfAccountID uuid.UUID `json:"chart_of_account_id"` ChartOfAccountName string `json:"chart_of_account_name,omitempty"` Description *string `json:"description"` - Tax float64 `json:"tax"` - Total float64 `json:"total"` - Reserved1 *string `json:"reserved1"` + Amount float64 `json:"amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type CreateExpenseRequest struct { - Receiver string `json:"receiver"` - TransactionDate string `json:"transaction_date"` - CodeNumber string `json:"code_number"` + Receiver string `json:"receiver"` + TransactionDate string `json:"transaction_date"` + CodeNumber string `json:"code_number"` + OutletID string `json:"outlet_id"` + Description *string `json:"description"` + Tax float64 `json:"tax"` + Total float64 `json:"total"` + Items []CreateExpenseItemRequest `json:"items"` +} + +type CreateExpenseItemRequest struct { ChartOfAccountID string `json:"chart_of_account_id"` - OutletID string `json:"outlet_id"` - Description *string `json:"description"` - Tax float64 `json:"tax"` - Total float64 `json:"total"` + Description *string `json:"description,omitempty"` + Amount float64 `json:"amount"` } type UpdateExpenseRequest struct { - Receiver *string `json:"receiver"` - TransactionDate *string `json:"transaction_date"` - CodeNumber *string `json:"code_number"` - ChartOfAccountID *string `json:"chart_of_account_id"` - OutletID *string `json:"outlet_id"` - Description *string `json:"description"` - Tax *float64 `json:"tax"` - Total *float64 `json:"total"` - Reserved1 *string `json:"reserved1"` + Receiver *string `json:"receiver,omitempty"` + TransactionDate *string `json:"transaction_date,omitempty"` + CodeNumber *string `json:"code_number,omitempty"` + OutletID *string `json:"outlet_id,omitempty"` + Description *string `json:"description,omitempty"` + Tax *float64 `json:"tax,omitempty"` + Total *float64 `json:"total,omitempty"` + Reserved1 *string `json:"reserved1,omitempty"` + Items []UpdateExpenseItemRequest `json:"items,omitempty"` +} + +type UpdateExpenseItemRequest struct { + ChartOfAccountID *string `json:"chart_of_account_id,omitempty"` + Description *string `json:"description,omitempty"` + Amount *float64 `json:"amount,omitempty"` } type ListExpenseRequest struct { diff --git a/internal/processor/expense_processor.go b/internal/processor/expense_processor.go index a223dda..94e73f9 100644 --- a/internal/processor/expense_processor.go +++ b/internal/processor/expense_processor.go @@ -31,11 +31,6 @@ func NewExpenseProcessorImpl(expenseRepo ExpenseRepository) *ExpenseProcessorImp } func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID uuid.UUID, req *models.CreateExpenseRequest) (*models.ExpenseResponse, error) { - chartOfAccountID, err := uuid.Parse(req.ChartOfAccountID) - if err != nil { - return nil, fmt.Errorf("invalid chart_of_account_id: %w", err) - } - outletID, err := uuid.Parse(req.OutletID) if err != nil { return nil, fmt.Errorf("invalid outlet_id: %w", err) @@ -47,15 +42,14 @@ func (p *ExpenseProcessorImpl) CreateExpense(ctx context.Context, organizationID } expenseEntity := &entities.Expense{ - OrganizationID: organizationID, - OutletID: outletID, - Receiver: req.Receiver, - TransactionDate: transactionDate, - CodeNumber: req.CodeNumber, - ChartOfAccountID: chartOfAccountID, - Description: req.Description, - Tax: req.Tax, - Total: req.Total, + OrganizationID: organizationID, + OutletID: outletID, + Receiver: req.Receiver, + TransactionDate: transactionDate, + CodeNumber: req.CodeNumber, + Description: req.Description, + Tax: req.Tax, + Total: req.Total, } err = p.expenseRepo.Create(ctx, expenseEntity) @@ -63,6 +57,25 @@ 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) + } + + itemEntity := &entities.ExpenseItem{ + ExpenseID: expenseEntity.ID, + ChartOfAccountID: chartOfAccountID, + Description: itemReq.Description, + Amount: itemReq.Amount, + } + + err = p.expenseRepo.CreateItem(ctx, itemEntity) + 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 @@ -90,13 +103,6 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati if req.CodeNumber != nil { expenseEntity.CodeNumber = *req.CodeNumber } - if req.ChartOfAccountID != nil { - chartOfAccountID, err := uuid.Parse(*req.ChartOfAccountID) - if err != nil { - return nil, fmt.Errorf("invalid chart_of_account_id: %w", err) - } - expenseEntity.ChartOfAccountID = chartOfAccountID - } if req.OutletID != nil { outletID, err := uuid.Parse(*req.OutletID) if err != nil { @@ -117,6 +123,40 @@ func (p *ExpenseProcessorImpl) UpdateExpense(ctx context.Context, id, organizati expenseEntity.Reserved1 = req.Reserved1 } + 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 { + 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) + } + } + + amount := 0.0 + if itemReq.Amount != nil { + amount = *itemReq.Amount + } + + itemEntity := &entities.ExpenseItem{ + ExpenseID: expenseEntity.ID, + ChartOfAccountID: chartOfAccountID, + Description: itemReq.Description, + Amount: amount, + } + + err = p.expenseRepo.CreateItem(ctx, itemEntity) + 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) diff --git a/internal/processor/expense_repository.go b/internal/processor/expense_repository.go index 46421e0..baefbc2 100644 --- a/internal/processor/expense_repository.go +++ b/internal/processor/expense_repository.go @@ -14,4 +14,6 @@ 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) + 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 36148a3..355163a 100644 --- a/internal/repository/expense_repository.go +++ b/internal/repository/expense_repository.go @@ -27,7 +27,9 @@ func (r *ExpenseRepositoryImpl) Create(ctx context.Context, expense *entities.Ex func (r *ExpenseRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Expense, error) { var expense entities.Expense - err := r.db.WithContext(ctx).Preload("ChartOfAccount").First(&expense, "id = ?", id).Error + err := r.db.WithContext(ctx). + Preload("Items.ChartOfAccount"). + First(&expense, "id = ?", id).Error if err != nil { return nil, err } @@ -36,7 +38,10 @@ func (r *ExpenseRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*ent func (r *ExpenseRepositoryImpl) GetByIDAndOrganizationID(ctx context.Context, id, organizationID uuid.UUID) (*entities.Expense, error) { var expense entities.Expense - err := r.db.WithContext(ctx).Preload("ChartOfAccount").Where("id = ? AND organization_id = ?", id, organizationID).First(&expense).Error + err := r.db.WithContext(ctx). + Preload("Items.ChartOfAccount"). + Where("id = ? AND organization_id = ?", id, organizationID). + First(&expense).Error if err != nil { return nil, err } @@ -78,6 +83,19 @@ func (r *ExpenseRepositoryImpl) List(ctx context.Context, organizationID uuid.UU return nil, 0, err } - err := query.Preload("ChartOfAccount").Order("created_at DESC").Limit(limit).Offset(offset).Find(&expenses).Error + err := query. + Preload("Items.ChartOfAccount"). + Order("created_at DESC"). + Limit(limit). + Offset(offset). + Find(&expenses).Error return expenses, total, err } + +func (r *ExpenseRepositoryImpl) CreateItem(ctx context.Context, item *entities.ExpenseItem) error { + return r.db.WithContext(ctx).Create(item).Error +} + +func (r *ExpenseRepositoryImpl) DeleteItemsByExpenseID(ctx context.Context, expenseID uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&entities.ExpenseItem{}, "expense_id = ?", expenseID).Error +} diff --git a/internal/transformer/expense_transformer.go b/internal/transformer/expense_transformer.go index 4de0859..695c0cc 100644 --- a/internal/transformer/expense_transformer.go +++ b/internal/transformer/expense_transformer.go @@ -6,29 +6,59 @@ import ( ) func CreateExpenseRequestToModel(req *contract.CreateExpenseRequest) *models.CreateExpenseRequest { + items := make([]models.CreateExpenseItemRequest, len(req.Items)) + for i, item := range req.Items { + items[i] = CreateExpenseItemRequestToModel(&item) + } + return &models.CreateExpenseRequest{ - Receiver: req.Receiver, - TransactionDate: req.TransactionDate, - CodeNumber: req.CodeNumber, + Receiver: req.Receiver, + TransactionDate: req.TransactionDate, + CodeNumber: req.CodeNumber, + OutletID: req.OutletID, + Description: req.Description, + Tax: req.Tax, + Total: req.Total, + Items: items, + } +} + +func CreateExpenseItemRequestToModel(req *contract.CreateExpenseItemRequest) models.CreateExpenseItemRequest { + return models.CreateExpenseItemRequest{ ChartOfAccountID: req.ChartOfAccountID, - OutletID: req.OutletID, Description: req.Description, - Tax: req.Tax, - Total: req.Total, + Amount: req.Amount, } } func UpdateExpenseRequestToModel(req *contract.UpdateExpenseRequest) *models.UpdateExpenseRequest { - return &models.UpdateExpenseRequest{ - Receiver: req.Receiver, - TransactionDate: req.TransactionDate, - CodeNumber: req.CodeNumber, + modelReq := &models.UpdateExpenseRequest{ + Receiver: req.Receiver, + TransactionDate: req.TransactionDate, + CodeNumber: req.CodeNumber, + OutletID: req.OutletID, + Description: req.Description, + Tax: req.Tax, + Total: req.Total, + Reserved1: req.Reserved1, + } + + if req.Items != nil { + items := make([]models.UpdateExpenseItemRequest, len(req.Items)) + for i, item := range req.Items { + items[i] = UpdateExpenseItemRequestToModel(&item) + } + modelReq.Items = items + } + + return modelReq +} + +func UpdateExpenseItemRequestToModel(req *contract.UpdateExpenseItemRequest) models.UpdateExpenseItemRequest { + return models.UpdateExpenseItemRequest{ ChartOfAccountID: req.ChartOfAccountID, - OutletID: req.OutletID, Description: req.Description, - Tax: req.Tax, - Total: req.Total, - Reserved1: req.Reserved1, + Amount: req.Amount, } } @@ -41,21 +71,42 @@ func ListExpenseRequestToModel(req *contract.ListExpenseRequest) *models.ListExp } func ExpenseModelResponseToResponse(expense *models.ExpenseResponse) *contract.ExpenseResponse { + if expense == nil { + return nil + } + + items := make([]contract.ExpenseItemResponse, len(expense.Items)) + for i, item := range expense.Items { + items[i] = ExpenseItemModelResponseToResponse(&item) + } + return &contract.ExpenseResponse{ - ID: expense.ID, - OrganizationID: expense.OrganizationID, - OutletID: expense.OutletID, - Receiver: expense.Receiver, - TransactionDate: expense.TransactionDate, - CodeNumber: expense.CodeNumber, - ChartOfAccountID: expense.ChartOfAccountID, - ChartOfAccountName: expense.ChartOfAccountName, - Description: expense.Description, - Tax: expense.Tax, - Total: expense.Total, - Reserved1: expense.Reserved1, - CreatedAt: expense.CreatedAt, - UpdatedAt: expense.UpdatedAt, + ID: expense.ID, + OrganizationID: expense.OrganizationID, + OutletID: expense.OutletID, + Receiver: expense.Receiver, + TransactionDate: expense.TransactionDate, + CodeNumber: expense.CodeNumber, + Description: expense.Description, + Tax: expense.Tax, + Total: expense.Total, + Reserved1: expense.Reserved1, + CreatedAt: expense.CreatedAt, + UpdatedAt: expense.UpdatedAt, + Items: items, + } +} + +func ExpenseItemModelResponseToResponse(item *models.ExpenseItemResponse) contract.ExpenseItemResponse { + return contract.ExpenseItemResponse{ + ID: item.ID, + ExpenseID: item.ExpenseID, + ChartOfAccountID: item.ChartOfAccountID, + ChartOfAccountName: item.ChartOfAccountName, + Description: item.Description, + Amount: item.Amount, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, } } diff --git a/internal/validator/expense_validator.go b/internal/validator/expense_validator.go index 5af4a35..b7c5aae 100644 --- a/internal/validator/expense_validator.go +++ b/internal/validator/expense_validator.go @@ -2,6 +2,7 @@ package validator import ( "errors" + "fmt" "strings" "apskel-pos-be/internal/constants" @@ -39,14 +40,6 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create return errors.New("code_number is required"), constants.MissingFieldErrorCode } - if strings.TrimSpace(req.ChartOfAccountID) == "" { - return errors.New("chart_of_account_id is required"), constants.MissingFieldErrorCode - } - - if _, err := uuid.Parse(req.ChartOfAccountID); err != nil { - return errors.New("chart_of_account_id must be a valid UUID"), constants.MalformedFieldErrorCode - } - if strings.TrimSpace(req.OutletID) == "" { return errors.New("outlet_id is required"), constants.MissingFieldErrorCode } @@ -63,6 +56,22 @@ func (v *ExpenseValidatorImpl) ValidateCreateExpenseRequest(req *contract.Create return errors.New("tax cannot be negative"), constants.MalformedFieldErrorCode } + if len(req.Items) == 0 { + return errors.New("at least one item is required"), constants.MissingFieldErrorCode + } + + for i, item := range req.Items { + if strings.TrimSpace(item.ChartOfAccountID) == "" { + return fmt.Errorf("item %d: chart_of_account_id 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 item.Amount <= 0 { + return fmt.Errorf("item %d: amount must be greater than 0", i), constants.MalformedFieldErrorCode + } + } + return nil, "" } @@ -79,15 +88,6 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update return errors.New("code_number cannot be empty"), constants.MalformedFieldErrorCode } - if req.ChartOfAccountID != nil { - if strings.TrimSpace(*req.ChartOfAccountID) == "" { - return errors.New("chart_of_account_id cannot be empty"), constants.MalformedFieldErrorCode - } - if _, err := uuid.Parse(*req.ChartOfAccountID); err != nil { - return errors.New("chart_of_account_id must be a valid UUID"), constants.MalformedFieldErrorCode - } - } - if req.OutletID != nil { if strings.TrimSpace(*req.OutletID) == "" { return errors.New("outlet_id cannot be empty"), constants.MalformedFieldErrorCode @@ -105,6 +105,22 @@ func (v *ExpenseValidatorImpl) ValidateUpdateExpenseRequest(req *contract.Update return errors.New("tax cannot be negative"), constants.MalformedFieldErrorCode } + if req.Items != nil { + for i, item := range req.Items { + if item.ChartOfAccountID != nil { + if strings.TrimSpace(*item.ChartOfAccountID) == "" { + return fmt.Errorf("item %d: chart_of_account_id cannot be empty", i), constants.MalformedFieldErrorCode + } + 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 item.Amount != nil && *item.Amount <= 0 { + return fmt.Errorf("item %d: amount must be greater than 0", i), constants.MalformedFieldErrorCode + } + } + } + return nil, "" } diff --git a/migrations/000070_create_expenses_table.down.sql b/migrations/000070_create_expenses_table.down.sql index 2205240..440c9dc 100644 --- a/migrations/000070_create_expenses_table.down.sql +++ b/migrations/000070_create_expenses_table.down.sql @@ -1 +1,2 @@ +DROP TABLE IF EXISTS expense_items; DROP TABLE IF EXISTS expenses; diff --git a/migrations/000070_create_expenses_table.up.sql b/migrations/000070_create_expenses_table.up.sql index 5652a99..292b548 100644 --- a/migrations/000070_create_expenses_table.up.sql +++ b/migrations/000070_create_expenses_table.up.sql @@ -5,7 +5,6 @@ CREATE TABLE expenses ( receiver VARCHAR(255) NOT NULL, transaction_date DATE NOT NULL, code_number VARCHAR(50) NOT NULL, - chart_of_account_id UUID NOT NULL REFERENCES chart_of_accounts(id) ON DELETE RESTRICT, description TEXT, tax DECIMAL(15,2) NOT NULL DEFAULT 0, total DECIMAL(15,2) NOT NULL DEFAULT 0, @@ -16,7 +15,19 @@ CREATE TABLE expenses ( CREATE INDEX idx_expenses_organization_id ON expenses(organization_id); CREATE INDEX idx_expenses_outlet_id ON expenses(outlet_id); -CREATE INDEX idx_expenses_chart_of_account_id ON expenses(chart_of_account_id); CREATE INDEX idx_expenses_transaction_date ON expenses(transaction_date); CREATE INDEX idx_expenses_code_number ON expenses(code_number); CREATE INDEX idx_expenses_created_at ON expenses(created_at); + +CREATE TABLE expense_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + expense_id UUID NOT NULL REFERENCES expenses(id) ON DELETE CASCADE, + chart_of_account_id UUID NOT NULL REFERENCES chart_of_accounts(id) ON DELETE RESTRICT, + description TEXT, + amount DECIMAL(15,2) NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_expense_items_expense_id ON expense_items(expense_id); +CREATE INDEX idx_expense_items_chart_of_account_id ON expense_items(chart_of_account_id);