diff --git a/internal/app/app.go b/internal/app/app.go index 9ea2e47..e477e73 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -166,6 +166,7 @@ func (a *App) Initialize(cfg *config.Config) error { services.customerWalletService, services.customerDeviceService, services.customerOutletService, + services.customerOrderService, a.redisClient, ) @@ -405,6 +406,7 @@ type processors struct { walletTraceProcessor *processor.WalletTraceProcessor customerDeviceProcessor *processor.CustomerDeviceProcessor customerOutletProcessor *processor.CustomerOutletProcessor + customerOrderProcessor *processor.CustomerOrderProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -487,6 +489,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)), customerDeviceProcessor: customerDeviceProcessor, customerOutletProcessor: processor.NewCustomerOutletProcessor(repository.NewCustomerOutletRepository(a.db), loyaltySettingsProcessor), + customerOrderProcessor: processor.NewCustomerOrderProcessor(repository.NewCustomerOrderRepository(a.db), earningProcessor), walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), loyaltySettingsProcessor, repos.txManager), } } @@ -538,6 +541,7 @@ type services struct { customerWalletService *service.CustomerWalletServiceImpl customerDeviceService *service.CustomerDeviceServiceImpl customerOutletService *service.CustomerOutletServiceImpl + customerOrderService *service.CustomerOrderServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -627,6 +631,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor), customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor), customerOutletService: service.NewCustomerOutletService(processors.customerOutletProcessor), + customerOrderService: service.NewCustomerOrderService(processors.customerOrderProcessor), } } diff --git a/internal/handler/customer_order_handler.go b/internal/handler/customer_order_handler.go new file mode 100644 index 0000000..3874676 --- /dev/null +++ b/internal/handler/customer_order_handler.go @@ -0,0 +1,49 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" +) + +// CustomerOrderHandler serves the customer app's order history. +type CustomerOrderHandler struct { + orders service.CustomerOrderService +} + +func NewCustomerOrderHandler(orders service.CustomerOrderService) *CustomerOrderHandler { + return &CustomerOrderHandler{orders: orders} +} + +// List is GET /customer/orders?page=&limit=. +func (h *CustomerOrderHandler) List(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerOrderHandler::List") + if !ok { + return + } + var query models.ListCustomerOrdersQuery + if err := c.ShouldBindQuery(&query); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "page and limit must be whole numbers"), + }), "CustomerOrderHandler::List") + return + } + util.HandleResponse(c.Writer, c.Request, h.orders.List(c.Request.Context(), customerID, query), "CustomerOrderHandler::List") +} + +// Detail is GET /customer/orders/:id. +func (h *CustomerOrderHandler) Detail(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerOrderHandler::Detail") + if !ok { + return + } + orderID, ok := parseUUIDParam(c, "id", "CustomerOrderHandler::Detail") + if !ok { + return + } + util.HandleResponse(c.Writer, c.Request, h.orders.Detail(c.Request.Context(), customerID, orderID), "CustomerOrderHandler::Detail") +} diff --git a/internal/models/customer_order.go b/internal/models/customer_order.go new file mode 100644 index 0000000..c5e5fad --- /dev/null +++ b/internal/models/customer_order.go @@ -0,0 +1,74 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// CustomerOrderSummary is one order in GET /customer/orders. +type CustomerOrderSummary struct { + ID uuid.UUID `json:"id"` + OrderNumber string `json:"order_number"` + OutletID uuid.UUID `json:"outlet_id"` + OutletName string `json:"outlet_name"` + OrderType string `json:"order_type"` + Status string `json:"status"` + PaymentStatus string `json:"payment_status"` + TotalAmount float64 `json:"total_amount"` + ItemCount int64 `json:"item_count"` + IsVoid bool `json:"is_void"` + IsRefund bool `json:"is_refund"` + // EnakPoint and EnakCoin the order earned; 0 when it earned nothing. + PointsEarned int64 `json:"points_earned"` + CoinsEarned int64 `json:"coins_earned"` + CreatedAt time.Time `json:"created_at"` +} + +// CustomerOrderDetail is GET /customer/orders/:id. +type CustomerOrderDetail struct { + CustomerOrderSummary + TableNumber *string `json:"table_number"` + Subtotal float64 `json:"subtotal"` + DiscountAmount float64 `json:"discount_amount"` + TaxAmount float64 `json:"tax_amount"` + RefundAmount float64 `json:"refund_amount"` + Items []CustomerOrderItem `json:"items"` + Payments []CustomerOrderPayment `json:"payments"` +} + +type CustomerOrderItem struct { + ID uuid.UUID `json:"id"` + ProductID uuid.UUID `json:"product_id"` + ProductName string `json:"product_name"` + VariantName *string `json:"variant_name"` + Quantity int `json:"quantity"` + // Set for products sold by weight, in UnitName. + Weight *float64 `json:"weight,omitempty"` + UnitName *string `json:"unit_name,omitempty"` + UnitPrice float64 `json:"unit_price"` + TotalPrice float64 `json:"total_price"` + RefundQuantity int `json:"refund_quantity"` + Modifiers []map[string]interface{} `json:"modifiers"` + Notes *string `json:"notes,omitempty"` + Status string `json:"status"` +} + +type CustomerOrderPayment struct { + ID uuid.UUID `json:"id"` + MethodName string `json:"method_name"` + MethodType string `json:"method_type"` + Amount float64 `json:"amount"` + Status string `json:"status"` + RefundAmount float64 `json:"refund_amount"` + // Set for a payment with EnakPoint. + PointsUsed *int64 `json:"points_used,omitempty"` + PointValue *float64 `json:"point_value,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ListCustomerOrdersQuery is GET /customer/orders. +type ListCustomerOrdersQuery struct { + Page int `form:"page"` + Limit int `form:"limit"` +} diff --git a/internal/processor/customer_order_processor.go b/internal/processor/customer_order_processor.go new file mode 100644 index 0000000..3a44014 --- /dev/null +++ b/internal/processor/customer_order_processor.go @@ -0,0 +1,157 @@ +package processor + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +const ( + customerOrdersPageLimit = 20 + customerOrdersMaxLimit = 100 +) + +type orderEarnedReader interface { + EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error) +} + +// CustomerOrderProcessor serves the customer app's order history: the customer's own +// orders only, without costs or staff details. +type CustomerOrderProcessor struct { + repo repository.CustomerOrderRepository + earned orderEarnedReader +} + +func NewCustomerOrderProcessor(repo repository.CustomerOrderRepository, earned orderEarnedReader) *CustomerOrderProcessor { + return &CustomerOrderProcessor{repo: repo, earned: earned} +} + +// List is GET /customer/orders: the customer's orders across all outlets, newest first. +func (p *CustomerOrderProcessor) List(ctx context.Context, customerID uuid.UUID, query models.ListCustomerOrdersQuery) (*models.PaginatedResponse[models.CustomerOrderSummary], error) { + page, limit := query.Page, query.Limit + if page == 0 { + page = 1 + } + if limit == 0 { + limit = customerOrdersPageLimit + } + if page < 1 || limit < 1 || limit > customerOrdersMaxLimit { + return nil, fmt.Errorf("%w: page must be at least 1 and limit between 1 and %d", ErrInvalidWalletQuery, customerOrdersMaxLimit) + } + + rows, total, err := p.repo.ListOrders(ctx, customerID, (page-1)*limit, limit) + if err != nil { + return nil, err + } + ids := make([]uuid.UUID, 0, len(rows)) + for _, r := range rows { + ids = append(ids, r.ID) + } + earned, err := p.earned.EarnedByOrders(ctx, ids) + if err != nil { + return nil, err + } + data := make([]models.CustomerOrderSummary, 0, len(rows)) + for _, r := range rows { + data = append(data, customerOrderSummary(r, earned[r.ID])) + } + return &models.PaginatedResponse[models.CustomerOrderSummary]{ + Data: data, + Pagination: models.Pagination{ + Page: page, + Limit: limit, + Total: total, + TotalPages: int((total + int64(limit) - 1) / int64(limit)), + }, + }, nil +} + +// Detail is GET /customer/orders/:id. Another customer's order is +// repository.ErrCustomerOrderNotFound, like one that does not exist. +func (p *CustomerOrderProcessor) Detail(ctx context.Context, customerID, orderID uuid.UUID) (*models.CustomerOrderDetail, error) { + row, err := p.repo.GetOrder(ctx, customerID, orderID) + if err != nil { + return nil, err + } + items, err := p.repo.ListItems(ctx, orderID) + if err != nil { + return nil, err + } + payments, err := p.repo.ListPayments(ctx, orderID) + if err != nil { + return nil, err + } + earned, err := p.earned.EarnedByOrders(ctx, []uuid.UUID{orderID}) + if err != nil { + return nil, err + } + + detail := &models.CustomerOrderDetail{ + CustomerOrderSummary: customerOrderSummary(*row, earned[orderID]), + TableNumber: row.TableNumber, + Subtotal: row.Subtotal, + DiscountAmount: row.DiscountAmount, + TaxAmount: row.TaxAmount, + RefundAmount: row.RefundAmount, + Items: make([]models.CustomerOrderItem, 0, len(items)), + Payments: make([]models.CustomerOrderPayment, 0, len(payments)), + } + for _, it := range items { + modifiers := []map[string]interface{}(it.Modifiers) + if modifiers == nil { + modifiers = []map[string]interface{}{} + } + detail.Items = append(detail.Items, models.CustomerOrderItem{ + ID: it.ID, + ProductID: it.ProductID, + ProductName: it.ProductName, + VariantName: it.VariantName, + Quantity: it.Quantity, + Weight: it.Weight, + UnitName: it.UnitName, + UnitPrice: it.UnitPrice, + TotalPrice: it.TotalPrice, + RefundQuantity: it.RefundQuantity, + Modifiers: modifiers, + Notes: it.Notes, + Status: it.Status, + }) + } + for _, pay := range payments { + detail.Payments = append(detail.Payments, models.CustomerOrderPayment{ + ID: pay.ID, + MethodName: pay.MethodName, + MethodType: pay.MethodType, + Amount: pay.Amount, + Status: pay.Status, + RefundAmount: pay.RefundAmount, + PointsUsed: pay.PointsUsed, + PointValue: pay.PointValue, + CreatedAt: pay.CreatedAt, + }) + } + return detail, nil +} + +func customerOrderSummary(r repository.CustomerOrderRow, earned OrderEarned) models.CustomerOrderSummary { + return models.CustomerOrderSummary{ + ID: r.ID, + OrderNumber: r.OrderNumber, + OutletID: r.OutletID, + OutletName: r.OutletName, + OrderType: r.OrderType, + Status: r.Status, + PaymentStatus: r.PaymentStatus, + TotalAmount: r.TotalAmount, + ItemCount: r.ItemCount, + IsVoid: r.IsVoid, + IsRefund: r.IsRefund, + PointsEarned: earned.Points, + CoinsEarned: earned.Coins, + CreatedAt: r.CreatedAt, + } +} diff --git a/internal/processor/customer_order_processor_test.go b/internal/processor/customer_order_processor_test.go new file mode 100644 index 0000000..380e9ad --- /dev/null +++ b/internal/processor/customer_order_processor_test.go @@ -0,0 +1,145 @@ +package processor + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +type customerOrderRepoFake struct { + owner map[uuid.UUID]uuid.UUID // order -> customer + orders []repository.CustomerOrderRow + items map[uuid.UUID][]repository.CustomerOrderItemRow + payments map[uuid.UUID][]repository.CustomerOrderPaymentRow + offset int + limit int +} + +func (f *customerOrderRepoFake) ListOrders(_ context.Context, customerID uuid.UUID, offset, limit int) ([]repository.CustomerOrderRow, int64, error) { + f.offset, f.limit = offset, limit + var mine []repository.CustomerOrderRow + for _, o := range f.orders { + if f.owner[o.ID] == customerID { + mine = append(mine, o) + } + } + return mine, int64(len(mine)), nil +} + +func (f *customerOrderRepoFake) GetOrder(_ context.Context, customerID, orderID uuid.UUID) (*repository.CustomerOrderRow, error) { + for _, o := range f.orders { + if o.ID == orderID && f.owner[o.ID] == customerID { + c := o + return &c, nil + } + } + return nil, repository.ErrCustomerOrderNotFound +} + +func (f *customerOrderRepoFake) ListItems(_ context.Context, orderID uuid.UUID) ([]repository.CustomerOrderItemRow, error) { + return f.items[orderID], nil +} + +func (f *customerOrderRepoFake) ListPayments(_ context.Context, orderID uuid.UUID) ([]repository.CustomerOrderPaymentRow, error) { + return f.payments[orderID], nil +} + +type earnedFake map[uuid.UUID]OrderEarned + +func (f earnedFake) EarnedByOrders(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]OrderEarned, error) { + out := map[uuid.UUID]OrderEarned{} + for _, id := range ids { + if e, ok := f[id]; ok { + out[id] = e + } + } + return out, nil +} + +func newCustomerOrderTest() (*customerOrderRepoFake, uuid.UUID, uuid.UUID, uuid.UUID) { + customer, other := uuid.New(), uuid.New() + mine, theirs := uuid.New(), uuid.New() + repo := &customerOrderRepoFake{ + owner: map[uuid.UUID]uuid.UUID{mine: customer, theirs: other}, + orders: []repository.CustomerOrderRow{ + {ID: mine, OrderNumber: "ORD-1", OutletName: "Gokuna 1", Status: "completed", PaymentStatus: "completed", Subtotal: 90000, TotalAmount: 99000, ItemCount: 2, CreatedAt: time.Now()}, + {ID: theirs, OrderNumber: "ORD-2"}, + }, + } + return repo, customer, mine, theirs +} + +func TestCustomerOrders_ListShowsOnlyTheCustomersOrdersWithEarning(t *testing.T) { + repo, customer, mine, _ := newCustomerOrderTest() + p := NewCustomerOrderProcessor(repo, earnedFake{mine: {Points: 900, Coins: 3}}) + + got, err := p.List(context.Background(), customer, models.ListCustomerOrdersQuery{Page: 2, Limit: 10}) + require.NoError(t, err) + require.Len(t, got.Data, 1) + assert.Equal(t, "ORD-1", got.Data[0].OrderNumber) + assert.Equal(t, int64(900), got.Data[0].PointsEarned) + assert.Equal(t, int64(3), got.Data[0].CoinsEarned) + assert.Equal(t, 10, repo.offset, "page 2 of 10 skips the first 10") + assert.Equal(t, 10, repo.limit) + assert.Equal(t, 2, got.Pagination.Page) +} + +func TestCustomerOrders_ListDefaultsAndLimits(t *testing.T) { + repo, customer, _, _ := newCustomerOrderTest() + p := NewCustomerOrderProcessor(repo, earnedFake{}) + + got, err := p.List(context.Background(), customer, models.ListCustomerOrdersQuery{}) + require.NoError(t, err) + assert.Equal(t, 1, got.Pagination.Page) + assert.Equal(t, 20, got.Pagination.Limit) + + for _, q := range []models.ListCustomerOrdersQuery{{Page: -1}, {Limit: 101}, {Limit: -5}} { + _, err := p.List(context.Background(), customer, q) + assert.ErrorIs(t, err, ErrInvalidWalletQuery, "%+v", q) + } +} + +func TestCustomerOrders_DetailHasItemsPaymentsAndEarning(t *testing.T) { + repo, customer, mine, _ := newCustomerOrderTest() + variant, unit := "Large", "ons" + weight := 4.2 + points, value := int64(12500), 1.0 + repo.items = map[uuid.UUID][]repository.CustomerOrderItemRow{ + mine: { + {ProductName: "Kopi Susu", VariantName: &variant, Quantity: 2, UnitPrice: 25000, TotalPrice: 50000, Status: "completed"}, + {ProductName: "Ikan Tude", Quantity: 1, Weight: &weight, UnitName: &unit, UnitPrice: 4500, TotalPrice: 18900, Status: "completed"}, + }, + } + repo.payments = map[uuid.UUID][]repository.CustomerOrderPaymentRow{ + mine: { + {MethodName: "EnakPoint", MethodType: "point", Amount: 12500, Status: "completed", PointsUsed: &points, PointValue: &value}, + {MethodName: "Cash", MethodType: "cash", Amount: 86500, Status: "completed"}, + }, + } + p := NewCustomerOrderProcessor(repo, earnedFake{mine: {Points: 865}}) + + got, err := p.Detail(context.Background(), customer, mine) + require.NoError(t, err) + assert.Equal(t, "Gokuna 1", got.OutletName) + assert.Equal(t, float64(90000), got.Subtotal) + assert.Equal(t, int64(865), got.PointsEarned) + require.Len(t, got.Items, 2) + assert.Equal(t, "Large", *got.Items[0].VariantName) + assert.Equal(t, []map[string]interface{}{}, got.Items[0].Modifiers, "no modifiers is an empty list, not null") + assert.Equal(t, 4.2, *got.Items[1].Weight) + require.Len(t, got.Payments, 2) + assert.Equal(t, int64(12500), *got.Payments[0].PointsUsed) +} + +func TestCustomerOrders_AnotherCustomersOrderIsNotFound(t *testing.T) { + repo, customer, _, theirs := newCustomerOrderTest() + _, err := NewCustomerOrderProcessor(repo, earnedFake{}).Detail(context.Background(), customer, theirs) + assert.ErrorIs(t, err, repository.ErrCustomerOrderNotFound) +} diff --git a/internal/repository/customer_order_repository.go b/internal/repository/customer_order_repository.go new file mode 100644 index 0000000..f6bf8d2 --- /dev/null +++ b/internal/repository/customer_order_repository.go @@ -0,0 +1,157 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "apskel-pos-be/internal/entities" +) + +// ErrCustomerOrderNotFound means the order does not exist or belongs to another +// customer; the two are not told apart. +var ErrCustomerOrderNotFound = errors.New("order not found") + +// CustomerOrderRow is one order as the customer app lists it. +type CustomerOrderRow struct { + ID uuid.UUID + OrderNumber string + OutletID uuid.UUID + OutletName string + OrderType string + TableNumber *string + Status string + PaymentStatus string + Subtotal float64 + DiscountAmount float64 + TaxAmount float64 + TotalAmount float64 + RefundAmount float64 + IsVoid bool + IsRefund bool + ItemCount int64 + CreatedAt time.Time +} + +// CustomerOrderItemRow is one line of an order, without costs. +type CustomerOrderItemRow struct { + ID uuid.UUID + ProductID uuid.UUID + ProductName string + VariantName *string + Quantity int + Weight *float64 + UnitName *string + UnitPrice float64 + TotalPrice float64 + RefundQuantity int + Modifiers entities.Modifiers `gorm:"type:jsonb"` + Notes *string + Status string +} + +// CustomerOrderPaymentRow is one payment of an order. +type CustomerOrderPaymentRow struct { + ID uuid.UUID + MethodName string + MethodType string + Amount float64 + Status string + RefundAmount float64 + PointsUsed *int64 + PointValue *float64 + CreatedAt time.Time +} + +// CustomerOrderRepository reads a customer's own orders for the customer app. Every +// read is scoped to the customer, so one customer can never see another's order. +type CustomerOrderRepository interface { + // ListOrders returns a page of the customer's orders, newest first, and the total. + ListOrders(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerOrderRow, int64, error) + // GetOrder returns ErrCustomerOrderNotFound unless the order is the customer's. + GetOrder(ctx context.Context, customerID, orderID uuid.UUID) (*CustomerOrderRow, error) + ListItems(ctx context.Context, orderID uuid.UUID) ([]CustomerOrderItemRow, error) + ListPayments(ctx context.Context, orderID uuid.UUID) ([]CustomerOrderPaymentRow, error) +} + +type customerOrderRepository struct { + db *gorm.DB +} + +func NewCustomerOrderRepository(db *gorm.DB) CustomerOrderRepository { + return &customerOrderRepository{db: db} +} + +const customerOrderColumns = `o.id, o.order_number, o.outlet_id, COALESCE(ol.name, '') AS outlet_name, + o.order_type, o.table_number, o.status, o.payment_status, o.subtotal, o.discount_amount, + o.tax_amount, o.total_amount, o.refund_amount, o.is_void, o.is_refund, + (SELECT COUNT(*) FROM order_items oi WHERE oi.order_id = o.id) AS item_count, o.created_at` + +func (r *customerOrderRepository) ListOrders(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerOrderRow, int64, error) { + db := DBFromContext(ctx, r.db).WithContext(ctx) + var total int64 + if err := db.Table("orders").Where("customer_id = ?", customerID).Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count customer orders: %w", err) + } + var rows []CustomerOrderRow + err := db.Raw(`SELECT `+customerOrderColumns+` + FROM orders o LEFT JOIN outlets ol ON ol.id = o.outlet_id + WHERE o.customer_id = ? + ORDER BY o.created_at DESC, o.id + LIMIT ? OFFSET ?`, customerID, limit, offset).Scan(&rows).Error + if err != nil { + return nil, 0, fmt.Errorf("failed to list customer orders: %w", err) + } + return rows, total, nil +} + +func (r *customerOrderRepository) GetOrder(ctx context.Context, customerID, orderID uuid.UUID) (*CustomerOrderRow, error) { + var rows []CustomerOrderRow + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`SELECT `+customerOrderColumns+` + FROM orders o LEFT JOIN outlets ol ON ol.id = o.outlet_id + WHERE o.id = ? AND o.customer_id = ?`, orderID, customerID).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to get customer order: %w", err) + } + if len(rows) == 0 { + return nil, ErrCustomerOrderNotFound + } + return &rows[0], nil +} + +func (r *customerOrderRepository) ListItems(ctx context.Context, orderID uuid.UUID) ([]CustomerOrderItemRow, error) { + var rows []CustomerOrderItemRow + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT oi.id, oi.product_id, COALESCE(p.name, '') AS product_name, pv.name AS variant_name, + oi.quantity, oi.weight, COALESCE(u.abbreviation, u.name) AS unit_name, + oi.unit_price, oi.total_price, oi.refund_quantity, oi.modifiers, oi.notes, oi.status + FROM order_items oi + LEFT JOIN products p ON p.id = oi.product_id + LEFT JOIN product_variants pv ON pv.id = oi.product_variant_id + LEFT JOIN units u ON u.id = oi.unit_id + WHERE oi.order_id = ? + ORDER BY oi.created_at, oi.id`, orderID).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to list order items: %w", err) + } + return rows, nil +} + +func (r *customerOrderRepository) ListPayments(ctx context.Context, orderID uuid.UUID) ([]CustomerOrderPaymentRow, error) { + var rows []CustomerOrderPaymentRow + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT pay.id, COALESCE(pm.name, '') AS method_name, COALESCE(pm.type, '') AS method_type, + pay.amount, pay.status, pay.refund_amount, pay.points_used, pay.point_value, pay.created_at + FROM payments pay + LEFT JOIN payment_methods pm ON pm.id = pay.payment_method_id + WHERE pay.order_id = ? + ORDER BY pay.created_at, pay.id`, orderID).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to list order payments: %w", err) + } + return rows, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index cf77c39..55bc014 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -62,12 +62,13 @@ type Router struct { customerWalletHandler *handler.CustomerWalletHandler customerDeviceHandler *handler.CustomerDeviceHandler customerOutletHandler *handler.CustomerOutletHandler + customerOrderHandler *handler.CustomerOrderHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, customerOutletService service.CustomerOutletService, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, customerOutletService service.CustomerOutletService, customerOrderService service.CustomerOrderService, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -121,6 +122,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer customerWalletHandler: handler.NewCustomerWalletHandler(customerWalletService), customerDeviceHandler: handler.NewCustomerDeviceHandler(customerDeviceService), customerOutletHandler: handler.NewCustomerOutletHandler(customerOutletService), + customerOrderHandler: handler.NewCustomerOrderHandler(customerOrderService), redisClient: redisClient, } } @@ -182,6 +184,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.PUT("/devices", r.customerDeviceHandler.Register) customer.DELETE("/devices/:device_id", r.customerDeviceHandler.Unregister) customer.GET("/outlets", r.customerOutletHandler.List) + customer.GET("/orders", r.customerOrderHandler.List) + customer.GET("/orders/:id", r.customerOrderHandler.Detail) customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints) // PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11) customer.GET("/pin/status", r.customerPinHandler.Status) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 75cafc7..18a39f5 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -45,6 +45,8 @@ func TestAllRoutesRegister(t *testing.T) { "PUT /api/v1/customer/devices", "DELETE /api/v1/customer/devices/:device_id", "GET /api/v1/customer/outlets", + "GET /api/v1/customer/orders", + "GET /api/v1/customer/orders/:id", "GET /api/v1/orders/:id/point-payment/preview", "POST /api/v1/customer/orders/:id/pay-with-points", "GET /api/v1/customer/pin/status", diff --git a/internal/service/customer_order_service.go b/internal/service/customer_order_service.go new file mode 100644 index 0000000..50019c5 --- /dev/null +++ b/internal/service/customer_order_service.go @@ -0,0 +1,57 @@ +package service + +import ( + "context" + "errors" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" +) + +// CustomerOrderService serves the customer app's order history. +type CustomerOrderService interface { + List(ctx context.Context, customerID uuid.UUID, query models.ListCustomerOrdersQuery) *contract.Response + Detail(ctx context.Context, customerID, orderID uuid.UUID) *contract.Response +} + +type CustomerOrderServiceImpl struct { + orders *processor.CustomerOrderProcessor +} + +func NewCustomerOrderService(orders *processor.CustomerOrderProcessor) *CustomerOrderServiceImpl { + return &CustomerOrderServiceImpl{orders: orders} +} + +func (s *CustomerOrderServiceImpl) List(ctx context.Context, customerID uuid.UUID, query models.ListCustomerOrdersQuery) *contract.Response { + orders, err := s.orders.List(ctx, customerID, query) + if err != nil { + return customerOrderErrorResponse(err) + } + return contract.BuildSuccessResponse(orders) +} + +func (s *CustomerOrderServiceImpl) Detail(ctx context.Context, customerID, orderID uuid.UUID) *contract.Response { + order, err := s.orders.Detail(ctx, customerID, orderID) + if err != nil { + return customerOrderErrorResponse(err) + } + return contract.BuildSuccessResponse(order) +} + +func customerOrderErrorResponse(err error) *contract.Response { + code := constants.InternalServerErrorCode + switch { + case errors.Is(err, repository.ErrCustomerOrderNotFound): + code = constants.NotFoundErrorCode + case errors.Is(err, processor.ErrInvalidWalletQuery): + code = constants.ValidationErrorCode + } + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(code, constants.RequestEntity, err.Error()), + }) +}