From dc13bb5f932fb01d3aafc919ba677f65f7e73658 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 29 May 2026 18:24:14 +0700 Subject: [PATCH] update due date and range date --- internal/constants/expense.go | 28 ++++ internal/contract/analytics_contract.go | 6 +- internal/contract/purchase_order_contract.go | 8 +- internal/entities/purchase_order.go | 24 +-- internal/models/analytics.go | 6 +- internal/models/purchase_order.go | 28 ++-- internal/processor/analytics_processor.go | 17 +- .../processor/analytics_processor_test.go | 2 +- internal/processor/expense_processor_test.go | 136 +++++++++++++++ .../processor/purchase_order_processor.go | 2 +- internal/repository/analytics_repository.go | 8 +- internal/service/analytics_service.go | 12 +- internal/service/analytics_service_test.go | 47 ++++++ internal/service/report_service.go | 2 +- internal/transformer/analytics_transformer.go | 18 +- .../transformer/analytics_transformer_test.go | 37 ++++ .../transformer/purchase_order_transformer.go | 12 +- .../purchase_order_transformer_test.go | 43 +++++ internal/validator/expense_validator_test.go | 158 ++++++++++++++++++ .../validator/purchase_order_validator.go | 60 ++++--- .../purchase_order_validator_test.go | 62 +++++++ ..._purchase_order_due_date_nullable.down.sql | 6 + ...ke_purchase_order_due_date_nullable.up.sql | 2 + 23 files changed, 640 insertions(+), 84 deletions(-) create mode 100644 internal/constants/expense.go create mode 100644 internal/processor/expense_processor_test.go create mode 100644 internal/transformer/purchase_order_transformer_test.go create mode 100644 internal/validator/expense_validator_test.go create mode 100644 internal/validator/purchase_order_validator_test.go create mode 100644 migrations/000074_make_purchase_order_due_date_nullable.down.sql create mode 100644 migrations/000074_make_purchase_order_due_date_nullable.up.sql diff --git a/internal/constants/expense.go b/internal/constants/expense.go new file mode 100644 index 0000000..38576df --- /dev/null +++ b/internal/constants/expense.go @@ -0,0 +1,28 @@ +package constants + +type ExpenseStatus string + +const ( + ExpenseStatusDraft ExpenseStatus = "draft" + ExpenseStatusSent ExpenseStatus = "sent" + ExpenseStatusApproved ExpenseStatus = "approved" + ExpenseStatusCancel ExpenseStatus = "cancel" +) + +func GetAllExpenseStatuses() []ExpenseStatus { + return []ExpenseStatus{ + ExpenseStatusDraft, + ExpenseStatusSent, + ExpenseStatusApproved, + ExpenseStatusCancel, + } +} + +func IsValidExpenseStatus(status ExpenseStatus) bool { + for _, validStatus := range GetAllExpenseStatuses() { + if status == validStatus { + return true + } + } + return false +} diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index d9d6556..09211d1 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -239,13 +239,15 @@ type DashboardOverview struct { type ProfitLossAnalyticsRequest struct { OrganizationID uuid.UUID OutletID *string `form:"outlet_id,omitempty"` - Date string `form:"date" validate:"required"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` } type ProfitLossAnalyticsResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` - Date time.Time `json:"date"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` MainSummary []ProfitLossSummaryRow `json:"main_summary"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpensesTotal float64 `json:"operational_expenses_total"` diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 4e57cb4..e6f1c92 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -9,8 +9,8 @@ import ( type CreatePurchaseOrderRequest struct { VendorID uuid.UUID `json:"vendor_id" validate:"required"` PONumber string `json:"po_number" validate:"required,min=1,max=50"` - TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD - DueDate string `json:"due_date" validate:"required"` // Format: YYYY-MM-DD + TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD + DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"` Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` Message *string `json:"message,omitempty" validate:"omitempty"` @@ -30,7 +30,7 @@ type UpdatePurchaseOrderRequest struct { VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"` PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"` TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD - DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD + DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"` Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"` Message *string `json:"message,omitempty" validate:"omitempty"` @@ -53,7 +53,7 @@ type PurchaseOrderResponse struct { VendorID uuid.UUID `json:"vendor_id"` PONumber string `json:"po_number"` TransactionDate time.Time `json:"transaction_date"` - DueDate time.Time `json:"due_date"` + DueDate *time.Time `json:"due_date"` Reference *string `json:"reference"` Status string `json:"status"` Message *string `json:"message"` diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index f445ef3..8fe74c6 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -9,18 +9,18 @@ import ( ) type PurchaseOrder struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"` - VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"` - PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"` - TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"` - DueDate time.Time `gorm:"type:date;not null" json:"due_date" validate:"required"` - Reference *string `gorm:"size:100" json:"reference" validate:"omitempty,max=100"` - Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"` - Message *string `gorm:"type:text" json:"message" validate:"omitempty"` - TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_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"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"` + VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"` + PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"` + TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"` + DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"` + Reference *string `gorm:"size:100" json:"reference" validate:"omitempty,max=100"` + Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"` + Message *string `gorm:"type:text" json:"message" validate:"omitempty"` + TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"` diff --git a/internal/models/analytics.go b/internal/models/analytics.go index 2a76965..dab9d22 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -249,13 +249,15 @@ type DashboardOverview struct { type ProfitLossAnalyticsRequest struct { OrganizationID uuid.UUID `validate:"required"` OutletID *uuid.UUID `validate:"omitempty"` - Date time.Time `validate:"required"` + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` } type ProfitLossAnalyticsResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` - Date time.Time `json:"date"` + DateFrom time.Time `json:"date_from"` + DateTo time.Time `json:"date_to"` MainSummary []ProfitLossSummaryRow `json:"main_summary"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpensesTotal float64 `json:"operational_expenses_total"` diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index eebf009..95b598b 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -7,18 +7,18 @@ import ( ) type PurchaseOrder struct { - ID uuid.UUID `json:"id"` - OrganizationID uuid.UUID `json:"organization_id"` - VendorID uuid.UUID `json:"vendor_id"` - PONumber string `json:"po_number"` - TransactionDate time.Time `json:"transaction_date"` - DueDate time.Time `json:"due_date"` - Reference *string `json:"reference"` - Status string `json:"status"` - Message *string `json:"message"` - TotalAmount float64 `json:"total_amount"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + VendorID uuid.UUID `json:"vendor_id"` + PONumber string `json:"po_number"` + TransactionDate time.Time `json:"transaction_date"` + DueDate *time.Time `json:"due_date"` + Reference *string `json:"reference"` + Status string `json:"status"` + Message *string `json:"message"` + TotalAmount float64 `json:"total_amount"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PurchaseOrderItem struct { @@ -46,7 +46,7 @@ type PurchaseOrderResponse struct { VendorID uuid.UUID `json:"vendor_id"` PONumber string `json:"po_number"` TransactionDate time.Time `json:"transaction_date"` - DueDate time.Time `json:"due_date"` + DueDate *time.Time `json:"due_date"` Reference *string `json:"reference"` Status string `json:"status"` Message *string `json:"message"` @@ -84,7 +84,7 @@ type CreatePurchaseOrderRequest struct { VendorID uuid.UUID `json:"vendor_id"` PONumber string `json:"po_number"` TransactionDate time.Time `json:"transaction_date"` - DueDate time.Time `json:"due_date"` + DueDate *time.Time `json:"due_date,omitempty"` Reference *string `json:"reference,omitempty"` Status *string `json:"status,omitempty"` Message *string `json:"message,omitempty"` diff --git a/internal/processor/analytics_processor.go b/internal/processor/analytics_processor.go index 41a5b33..22ad669 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -398,11 +398,19 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req } func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) { - if req.Date.IsZero() { - return nil, fmt.Errorf("date is required") + if req.DateFrom.IsZero() { + return nil, fmt.Errorf("date_from is required") } - result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.Date) + if req.DateTo.IsZero() { + return nil, fmt.Errorf("date_to is required") + } + + if req.DateFrom.After(req.DateTo) { + return nil, fmt.Errorf("date_from cannot be after date_to") + } + + result, err := p.analyticsRepo.GetProfitLossAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo) if err != nil { return nil, fmt.Errorf("failed to get profit/loss analytics: %w", err) } @@ -507,7 +515,8 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req return &models.ProfitLossAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, - Date: req.Date, + DateFrom: req.DateFrom, + DateTo: req.DateTo, MainSummary: mainSummary, OperationalExpenses: opsItems, OperationalExpensesTotal: opsTotal, diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 6fb57c5..7121861 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -40,7 +40,7 @@ func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, return nil, nil } -func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time) (*entities.ProfitLossAnalytics, error) { +func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.ProfitLossAnalytics, error) { return nil, nil } diff --git a/internal/processor/expense_processor_test.go b/internal/processor/expense_processor_test.go new file mode 100644 index 0000000..7afb0e7 --- /dev/null +++ b/internal/processor/expense_processor_test.go @@ -0,0 +1,136 @@ +package processor + +import ( + "context" + "testing" + + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +type expenseRepositoryCaptureStub struct { + createdExpense *entities.Expense + createdItems []*entities.ExpenseItem +} + +func (s *expenseRepositoryCaptureStub) Create(_ context.Context, expense *entities.Expense) error { + if expense.ID == uuid.Nil { + expense.ID = uuid.New() + } + s.createdExpense = expense + return nil +} + +func (s *expenseRepositoryCaptureStub) GetByID(context.Context, uuid.UUID) (*entities.Expense, error) { + if s.createdExpense == nil { + return nil, nil + } + items := make([]entities.ExpenseItem, len(s.createdItems)) + for i, item := range s.createdItems { + items[i] = *item + } + s.createdExpense.Items = items + return s.createdExpense, nil +} + +func (*expenseRepositoryCaptureStub) GetByIDAndOrganizationID(context.Context, uuid.UUID, uuid.UUID) (*entities.Expense, error) { + return nil, nil +} +func (*expenseRepositoryCaptureStub) Update(context.Context, *entities.Expense) error { return nil } +func (*expenseRepositoryCaptureStub) Delete(context.Context, uuid.UUID) error { return nil } +func (*expenseRepositoryCaptureStub) List(context.Context, uuid.UUID, map[string]interface{}, int, int) ([]*entities.Expense, int64, error) { + return nil, 0, nil +} +func (s *expenseRepositoryCaptureStub) CreateItem(_ context.Context, item *entities.ExpenseItem) error { + if item.ID == uuid.Nil { + item.ID = uuid.New() + } + s.createdItems = append(s.createdItems, item) + return nil +} +func (*expenseRepositoryCaptureStub) DeleteItemsByExpenseID(context.Context, uuid.UUID) error { + return nil +} + +func TestExpenseProcessorCreatePersistsItemName(t *testing.T) { + repo := &expenseRepositoryCaptureStub{} + p := NewExpenseProcessorImpl(repo) + chartOfAccountID := uuid.New() + + 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: chartOfAccountID.String(), + Item: "Cleaning supplies", + Amount: 10000, + }, + }, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, repo.createdItems, 1) + require.Equal(t, "Cleaning supplies", repo.createdItems[0].Item) + 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) + + 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(), + Item: "Cleaning supplies", + Amount: 10000, + }, + }, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, "draft", repo.createdExpense.Status) + require.Equal(t, "draft", resp.Status) +} + +func TestExpenseProcessorCreatePersistsProvidedStatus(t *testing.T) { + repo := &expenseRepositoryCaptureStub{} + p := NewExpenseProcessorImpl(repo) + status := "approved" + + resp, err := p.CreateExpense(context.Background(), uuid.New(), &models.CreateExpenseRequest{ + Receiver: "Cashier", + TransactionDate: "2026-05-29", + CodeNumber: "EXP-001", + OutletID: uuid.NewString(), + Status: &status, + Total: 10000, + Items: []models.CreateExpenseItemRequest{ + { + ChartOfAccountID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, + }, + }, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, "approved", repo.createdExpense.Status) + require.Equal(t, "approved", resp.Status) +} diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index 2804de1..fd5863e 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -175,7 +175,7 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id poEntity.TransactionDate = *req.TransactionDate } if req.DueDate != nil { - poEntity.DueDate = *req.DueDate + poEntity.DueDate = req.DueDate } if req.Reference != nil { poEntity.Reference = req.Reference diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go index 821b060..1519a1a 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -17,7 +17,7 @@ type AnalyticsRepository interface { GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error) GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error) - GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, date time.Time) (*entities.ProfitLossAnalytics, error) + GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ProfitLossAnalytics, error) } type AnalyticsRepositoryImpl struct { @@ -432,9 +432,9 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga return &result, nil } -func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, date time.Time) (*entities.ProfitLossAnalytics, error) { - mtdStart := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location()) - todayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) +func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ProfitLossAnalytics, error) { + mtdStart := time.Date(dateTo.Year(), dateTo.Month(), 1, 0, 0, 0, 0, dateTo.Location()) + todayStart := time.Date(dateTo.Year(), dateTo.Month(), dateTo.Day(), 0, 0, 0, 0, dateTo.Location()) todayEnd := todayStart.Add(24 * time.Hour).Add(-time.Nanosecond) type revenueCostResult struct { diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go index c0483a7..8511a17 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -306,8 +306,16 @@ func (s *AnalyticsServiceImpl) validateProfitLossAnalyticsRequest(req *models.Pr return fmt.Errorf("organization_id is required") } - if req.Date.IsZero() { - return fmt.Errorf("date is required") + if req.DateFrom.IsZero() { + return fmt.Errorf("date_from is required") + } + + if req.DateTo.IsZero() { + return fmt.Errorf("date_to is required") + } + + if req.DateFrom.After(req.DateTo) { + return fmt.Errorf("date_from cannot be after date_to") } return nil diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index 665578d..dbd536e 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -119,3 +119,50 @@ func TestAnalyticsServiceGetPurchasingAnalyticsAllowsEmptyGroupBy(t *testing.T) require.NoError(t, err) require.NotNil(t, resp) } + +func TestAnalyticsServiceGetProfitLossAnalyticsValidation(t *testing.T) { + service := NewAnalyticsServiceImpl(analyticsProcessorStub{}) + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + req *models.ProfitLossAnalyticsRequest + wantErr string + }{ + { + name: "missing date_from", + req: &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateTo: now, + }, + wantErr: "date_from is required", + }, + { + name: "missing date_to", + req: &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + }, + wantErr: "date_to is required", + }, + { + name: "reversed dates", + req: &models.ProfitLossAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now.AddDate(0, 0, 1), + DateTo: now, + }, + wantErr: "date_from cannot be after date_to", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := service.GetProfitLossAnalytics(context.Background(), tt.req) + + require.Nil(t, resp) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} diff --git a/internal/service/report_service.go b/internal/service/report_service.go index 915eb28..f24e98d 100644 --- a/internal/service/report_service.go +++ b/internal/service/report_service.go @@ -113,7 +113,7 @@ func (s *ReportServiceImpl) GenerateDailyTransactionPDF(ctx context.Context, org end := day.Add(24*time.Hour - time.Nanosecond) salesReq := &models.SalesAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, GroupBy: "day"} - plReq := &models.ProfitLossAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, Date: day} + plReq := &models.ProfitLossAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end} productReq := &models.ProductAnalyticsRequest{OrganizationID: orgID, OutletID: &outID, DateFrom: start, DateTo: end, Limit: 1000} sales, err := s.analyticsService.GetSalesAnalytics(ctx, salesReq) diff --git a/internal/transformer/analytics_transformer.go b/internal/transformer/analytics_transformer.go index c5436cc..2172cec 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -432,19 +432,24 @@ func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest return nil, fmt.Errorf("request cannot be nil") } - dateTime, err := util.ParseDateToJakartaTime(req.Date) + dateFrom, dateTo, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo) if err != nil { - return nil, fmt.Errorf("invalid date format: %w", err) + return nil, fmt.Errorf("invalid date range: %w", err) } - if dateTime == nil { - return nil, fmt.Errorf("date is required") + if dateFrom == nil { + return nil, fmt.Errorf("date_from is required") + } + + if dateTo == nil { + return nil, fmt.Errorf("date_to is required") } return &models.ProfitLossAnalyticsRequest{ OrganizationID: req.OrganizationID, OutletID: parseOutletID(req.OutletID), - Date: *dateTime, + DateFrom: *dateFrom, + DateTo: *dateTo, }, nil } @@ -469,7 +474,8 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse return &contract.ProfitLossAnalyticsResponse{ OrganizationID: resp.OrganizationID, OutletID: resp.OutletID, - Date: resp.Date, + DateFrom: resp.DateFrom, + DateTo: resp.DateTo, MainSummary: mainSummary, OperationalExpenses: opsItems, OperationalExpensesTotal: resp.OperationalExpensesTotal, diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 0c94fbb..efbb87e 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -74,3 +74,40 @@ func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { require.NoError(t, err) require.NotContains(t, string(payload), "outlet_name") } + +func TestProfitLossAnalyticsContractToModelParsesDateRange(t *testing.T) { + orgID := uuid.New() + outletID := uuid.New().String() + + result, err := ProfitLossAnalyticsContractToModel(&contract.ProfitLossAnalyticsRequest{ + OrganizationID: orgID, + OutletID: &outletID, + DateFrom: "01-05-2026", + DateTo: "29-05-2026", + }) + + require.NoError(t, err) + require.Equal(t, orgID, result.OrganizationID) + require.NotNil(t, result.OutletID) + require.Equal(t, outletID, result.OutletID.String()) + + location, err := time.LoadLocation("Asia/Jakarta") + require.NoError(t, err) + require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.DateFrom) + require.Equal(t, time.Date(2026, 5, 29, 23, 59, 59, int(time.Second-time.Nanosecond), location), result.DateTo) +} + +func TestProfitLossAnalyticsModelToContractCopiesDateRange(t *testing.T) { + dateFrom := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + dateTo := time.Date(2026, 5, 29, 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC) + + result := ProfitLossAnalyticsModelToContract(&models.ProfitLossAnalyticsResponse{ + OrganizationID: uuid.New(), + DateFrom: dateFrom, + DateTo: dateTo, + }) + + require.NotNil(t, result) + require.Equal(t, dateFrom, result.DateFrom) + require.Equal(t, dateTo, result.DateTo) +} diff --git a/internal/transformer/purchase_order_transformer.go b/internal/transformer/purchase_order_transformer.go index a867df0..9860f8f 100644 --- a/internal/transformer/purchase_order_transformer.go +++ b/internal/transformer/purchase_order_transformer.go @@ -25,10 +25,14 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest) return nil, err } - // Parse due date - dueDate, err := time.Parse("2006-01-02", req.DueDate) - if err != nil { - return nil, err + // Parse due date if provided + var dueDate *time.Time + if req.DueDate != nil && *req.DueDate != "" { + parsedDate, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return nil, err + } + dueDate = &parsedDate } return &models.CreatePurchaseOrderRequest{ diff --git a/internal/transformer/purchase_order_transformer_test.go b/internal/transformer/purchase_order_transformer_test.go new file mode 100644 index 0000000..24aea4c --- /dev/null +++ b/internal/transformer/purchase_order_transformer_test.go @@ -0,0 +1,43 @@ +package transformer + +import ( + "encoding/json" + "testing" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestCreatePurchaseOrderRequestToModelAllowsMissingDueDate(t *testing.T) { + result, err := CreatePurchaseOrderRequestToModel(&contract.CreatePurchaseOrderRequest{ + VendorID: uuid.New(), + PONumber: "PO-001", + TransactionDate: "2026-05-29", + Items: []contract.CreatePurchaseOrderItemRequest{ + { + IngredientID: uuid.New(), + Quantity: 1, + UnitID: uuid.New(), + Amount: 1000, + }, + }, + }) + + require.NoError(t, err) + require.Nil(t, result.DueDate) +} + +func TestPurchaseOrderModelResponseToResponseIncludesNullDueDate(t *testing.T) { + result := PurchaseOrderModelResponseToResponse(&models.PurchaseOrderResponse{ + ID: uuid.New(), + VendorID: uuid.New(), + PONumber: "PO-001", + }) + + payload, err := json.Marshal(result) + require.NoError(t, err) + require.Contains(t, string(payload), `"due_date":null`) +} diff --git a/internal/validator/expense_validator_test.go b/internal/validator/expense_validator_test.go new file mode 100644 index 0000000..d9ae15d --- /dev/null +++ b/internal/validator/expense_validator_test.go @@ -0,0 +1,158 @@ +package validator + +import ( + "testing" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestExpenseValidatorCreateRequiresItemName(t *testing.T) { + v := NewExpenseValidator() + + req := &contract.CreateExpenseRequest{ + Receiver: "Cashier", + TransactionDate: "2026-05-29", + CodeNumber: "EXP-001", + OutletID: uuid.NewString(), + Total: 10000, + Items: []contract.CreateExpenseItemRequest{ + { + ChartOfAccountID: uuid.NewString(), + Amount: 10000, + }, + }, + } + + err, code := v.ValidateCreateExpenseRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MissingFieldErrorCode, code) + require.Contains(t, err.Error(), "item 0: item is required") +} + +func TestExpenseValidatorCreateDoesNotRequireHeaderExpenseName(t *testing.T) { + v := NewExpenseValidator() + + req := &contract.CreateExpenseRequest{ + Receiver: "Cashier", + TransactionDate: "2026-05-29", + CodeNumber: "EXP-001", + OutletID: uuid.NewString(), + Total: 10000, + Items: []contract.CreateExpenseItemRequest{ + { + ChartOfAccountID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, + }, + }, + } + + err, code := v.ValidateCreateExpenseRequest(req) + + require.NoError(t, err) + require.Empty(t, code) +} + +func TestExpenseValidatorCreateAllowsValidOptionalStatus(t *testing.T) { + v := NewExpenseValidator() + status := "approved" + + req := &contract.CreateExpenseRequest{ + Receiver: "Cashier", + TransactionDate: "2026-05-29", + CodeNumber: "EXP-001", + OutletID: uuid.NewString(), + Status: &status, + Total: 10000, + Items: []contract.CreateExpenseItemRequest{ + { + ChartOfAccountID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, + }, + }, + } + + err, code := v.ValidateCreateExpenseRequest(req) + + require.NoError(t, err) + require.Empty(t, code) +} + +func TestExpenseValidatorCreateRejectsInvalidStatus(t *testing.T) { + v := NewExpenseValidator() + status := "cancelled" + + req := &contract.CreateExpenseRequest{ + Receiver: "Cashier", + TransactionDate: "2026-05-29", + CodeNumber: "EXP-001", + OutletID: uuid.NewString(), + Status: &status, + Total: 10000, + Items: []contract.CreateExpenseItemRequest{ + { + ChartOfAccountID: uuid.NewString(), + Item: "Cleaning supplies", + Amount: 10000, + }, + }, + } + + err, code := v.ValidateCreateExpenseRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "status must be one of: draft, sent, approved, cancel") +} + +func TestExpenseValidatorUpdateRejectsEmptyItemNameWhenProvided(t *testing.T) { + v := NewExpenseValidator() + empty := " " + + req := &contract.UpdateExpenseRequest{ + Items: []contract.UpdateExpenseItemRequest{ + {Item: &empty}, + }, + } + + err, code := v.ValidateUpdateExpenseRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "item 0: item cannot be empty") +} + +func TestExpenseValidatorUpdateRejectsInvalidStatus(t *testing.T) { + v := NewExpenseValidator() + status := "cancelled" + + req := &contract.UpdateExpenseRequest{Status: &status} + + err, code := v.ValidateUpdateExpenseRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "status must be one of: draft, sent, approved, cancel") +} + +func TestExpenseValidatorListRejectsInvalidStatus(t *testing.T) { + v := NewExpenseValidator() + + req := &contract.ListExpenseRequest{ + Page: 1, + Limit: 10, + Status: "cancelled", + } + + err, code := v.ValidateListExpenseRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "status must be one of: draft, sent, approved, cancel") +} diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index f6706fa..824ea1b 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -47,18 +47,19 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con return errors.New("transaction_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode } - // Validate due date - if strings.TrimSpace(req.DueDate) == "" { - return errors.New("due_date is required"), constants.MissingFieldErrorCode - } - dueDate, err := time.Parse("2006-01-02", req.DueDate) - if err != nil { - return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode - } + if req.DueDate != nil { + if strings.TrimSpace(*req.DueDate) == "" { + return errors.New("due_date cannot be empty"), constants.MalformedFieldErrorCode + } - // Check if due date is after transaction date - if dueDate.Before(transactionDate) { - return errors.New("due_date must be after transaction_date"), constants.MalformedFieldErrorCode + dueDate, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + + if dueDate.Before(transactionDate) { + return errors.New("due_date must be after transaction_date"), constants.MalformedFieldErrorCode + } } if req.Reference != nil && len(*req.Reference) > 100 { @@ -100,22 +101,27 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con } } - // Validate dates if both are provided - if req.TransactionDate != nil && req.DueDate != nil { - if *req.TransactionDate != "" && *req.DueDate != "" { - transactionDate, err := time.Parse("2006-01-02", *req.TransactionDate) - if err != nil { - return errors.New("transaction_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode - } - - dueDate, err := time.Parse("2006-01-02", *req.DueDate) - if err != nil { - return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode - } - - if dueDate.Before(transactionDate) { - return errors.New("due_date must be after transaction_date"), constants.MalformedFieldErrorCode - } + var transactionDate *time.Time + if req.TransactionDate != nil && *req.TransactionDate != "" { + parsedDate, err := time.Parse("2006-01-02", *req.TransactionDate) + if err != nil { + return errors.New("transaction_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + transactionDate = &parsedDate + } + + if req.DueDate != nil { + if strings.TrimSpace(*req.DueDate) == "" { + return errors.New("due_date cannot be empty"), constants.MalformedFieldErrorCode + } + + dueDate, err := time.Parse("2006-01-02", *req.DueDate) + if err != nil { + return errors.New("due_date must be in YYYY-MM-DD format"), constants.MalformedFieldErrorCode + } + + if transactionDate != nil && dueDate.Before(*transactionDate) { + return errors.New("due_date must be after transaction_date"), constants.MalformedFieldErrorCode } } diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go new file mode 100644 index 0000000..3cd821c --- /dev/null +++ b/internal/validator/purchase_order_validator_test.go @@ -0,0 +1,62 @@ +package validator + +import ( + "testing" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func validCreatePurchaseOrderRequest() *contract.CreatePurchaseOrderRequest { + return &contract.CreatePurchaseOrderRequest{ + VendorID: uuid.New(), + PONumber: "PO-001", + TransactionDate: "2026-05-29", + Items: []contract.CreatePurchaseOrderItemRequest{ + { + IngredientID: uuid.New(), + Quantity: 1, + UnitID: uuid.New(), + Amount: 1000, + }, + }, + } +} + +func TestPurchaseOrderValidatorCreateAllowsMissingDueDate(t *testing.T) { + validator := NewPurchaseOrderValidator() + + err, code := validator.ValidateCreatePurchaseOrderRequest(validCreatePurchaseOrderRequest()) + + require.NoError(t, err) + require.Empty(t, code) +} + +func TestPurchaseOrderValidatorCreateRejectsInvalidDueDate(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + dueDate := "29-05-2026" + req.DueDate = &dueDate + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "due_date must be in YYYY-MM-DD format") +} + +func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *testing.T) { + validator := NewPurchaseOrderValidator() + req := validCreatePurchaseOrderRequest() + dueDate := "2026-05-28" + req.DueDate = &dueDate + + err, code := validator.ValidateCreatePurchaseOrderRequest(req) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) + require.Contains(t, err.Error(), "due_date must be after transaction_date") +} diff --git a/migrations/000074_make_purchase_order_due_date_nullable.down.sql b/migrations/000074_make_purchase_order_due_date_nullable.down.sql new file mode 100644 index 0000000..dcdc95c --- /dev/null +++ b/migrations/000074_make_purchase_order_due_date_nullable.down.sql @@ -0,0 +1,6 @@ +UPDATE purchase_orders +SET due_date = transaction_date +WHERE due_date IS NULL; + +ALTER TABLE purchase_orders + ALTER COLUMN due_date SET NOT NULL; diff --git a/migrations/000074_make_purchase_order_due_date_nullable.up.sql b/migrations/000074_make_purchase_order_due_date_nullable.up.sql new file mode 100644 index 0000000..8483bfb --- /dev/null +++ b/migrations/000074_make_purchase_order_due_date_nullable.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE purchase_orders + ALTER COLUMN due_date DROP NOT NULL;