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