feat(customer): order history and detail for the customer app

Adds GET /customer/orders and GET /customer/orders/:id for the customer
app: the orders linked to the logged-in customer across their
organization's outlets, newest first, paginated (limit 1-100, default 20).

The list shows the order number, outlet, type, status, total, item count,
void/refund flags and the EnakPoint and EnakCoin it earned. The detail adds
the amounts, the items with product and variant names, weight and unit
for weighed lines, modifiers and notes, and the payments with the method
and, for EnakPoint, the points used. Costs, cashier and other internal
fields are left out. Another customer's order answers 404 like one that
does not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 19:36:35 +07:00
co-authored by Claude Opus 5.5
parent c1ec6a3dfd
commit a24d5f0561
9 changed files with 651 additions and 1 deletions
+5
View File
@@ -166,6 +166,7 @@ func (a *App) Initialize(cfg *config.Config) error {
services.customerWalletService, services.customerWalletService,
services.customerDeviceService, services.customerDeviceService,
services.customerOutletService, services.customerOutletService,
services.customerOrderService,
a.redisClient, a.redisClient,
) )
@@ -405,6 +406,7 @@ type processors struct {
walletTraceProcessor *processor.WalletTraceProcessor walletTraceProcessor *processor.WalletTraceProcessor
customerDeviceProcessor *processor.CustomerDeviceProcessor customerDeviceProcessor *processor.CustomerDeviceProcessor
customerOutletProcessor *processor.CustomerOutletProcessor customerOutletProcessor *processor.CustomerOutletProcessor
customerOrderProcessor *processor.CustomerOrderProcessor
} }
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { 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)), walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
customerDeviceProcessor: customerDeviceProcessor, customerDeviceProcessor: customerDeviceProcessor,
customerOutletProcessor: processor.NewCustomerOutletProcessor(repository.NewCustomerOutletRepository(a.db), loyaltySettingsProcessor), 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), 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 customerWalletService *service.CustomerWalletServiceImpl
customerDeviceService *service.CustomerDeviceServiceImpl customerDeviceService *service.CustomerDeviceServiceImpl
customerOutletService *service.CustomerOutletServiceImpl customerOutletService *service.CustomerOutletServiceImpl
customerOrderService *service.CustomerOrderServiceImpl
} }
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { 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), customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor),
customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor), customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor),
customerOutletService: service.NewCustomerOutletService(processors.customerOutletProcessor), customerOutletService: service.NewCustomerOutletService(processors.customerOutletProcessor),
customerOrderService: service.NewCustomerOrderService(processors.customerOrderProcessor),
} }
} }
@@ -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")
}
+74
View File
@@ -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"`
}
@@ -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,
}
}
@@ -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)
}
@@ -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
}
+5 -1
View File
@@ -62,12 +62,13 @@ type Router struct {
customerWalletHandler *handler.CustomerWalletHandler customerWalletHandler *handler.CustomerWalletHandler
customerDeviceHandler *handler.CustomerDeviceHandler customerDeviceHandler *handler.CustomerDeviceHandler
customerOutletHandler *handler.CustomerOutletHandler customerOutletHandler *handler.CustomerOutletHandler
customerOrderHandler *handler.CustomerOrderHandler
authMiddleware *middleware.AuthMiddleware authMiddleware *middleware.AuthMiddleware
customerAuthMiddleware *middleware.CustomerAuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware
redisClient *redis.Client 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{ return &Router{
config: cfg, config: cfg,
@@ -121,6 +122,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
customerWalletHandler: handler.NewCustomerWalletHandler(customerWalletService), customerWalletHandler: handler.NewCustomerWalletHandler(customerWalletService),
customerDeviceHandler: handler.NewCustomerDeviceHandler(customerDeviceService), customerDeviceHandler: handler.NewCustomerDeviceHandler(customerDeviceService),
customerOutletHandler: handler.NewCustomerOutletHandler(customerOutletService), customerOutletHandler: handler.NewCustomerOutletHandler(customerOutletService),
customerOrderHandler: handler.NewCustomerOrderHandler(customerOrderService),
redisClient: redisClient, redisClient: redisClient,
} }
} }
@@ -182,6 +184,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.PUT("/devices", r.customerDeviceHandler.Register) customer.PUT("/devices", r.customerDeviceHandler.Register)
customer.DELETE("/devices/:device_id", r.customerDeviceHandler.Unregister) customer.DELETE("/devices/:device_id", r.customerDeviceHandler.Unregister)
customer.GET("/outlets", r.customerOutletHandler.List) 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) customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints)
// PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11) // PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11)
customer.GET("/pin/status", r.customerPinHandler.Status) customer.GET("/pin/status", r.customerPinHandler.Status)
+2
View File
@@ -45,6 +45,8 @@ func TestAllRoutesRegister(t *testing.T) {
"PUT /api/v1/customer/devices", "PUT /api/v1/customer/devices",
"DELETE /api/v1/customer/devices/:device_id", "DELETE /api/v1/customer/devices/:device_id",
"GET /api/v1/customer/outlets", "GET /api/v1/customer/outlets",
"GET /api/v1/customer/orders",
"GET /api/v1/customer/orders/:id",
"GET /api/v1/orders/:id/point-payment/preview", "GET /api/v1/orders/:id/point-payment/preview",
"POST /api/v1/customer/orders/:id/pay-with-points", "POST /api/v1/customer/orders/:id/pay-with-points",
"GET /api/v1/customer/pin/status", "GET /api/v1/customer/pin/status",
@@ -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()),
})
}