feature/expense #13

Merged
aefril merged 10 commits from feature/expense into main 2026-06-01 17:20:29 +00:00
23 changed files with 640 additions and 84 deletions
Showing only changes of commit dc13bb5f93 - Show all commits

View File

@ -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
}

View File

@ -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"`

View File

@ -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"`

View File

@ -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"`

View File

@ -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"`

View File

@ -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"`

View File

@ -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,

View File

@ -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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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)
})
}
}

View File

@ -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)

View File

@ -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,

View File

@ -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)
}

View File

@ -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{

View File

@ -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`)
}

View File

@ -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")
}

View File

@ -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
}
}

View File

@ -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")
}

View File

@ -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;

View File

@ -0,0 +1,2 @@
ALTER TABLE purchase_orders
ALTER COLUMN due_date DROP NOT NULL;