feat(loyalty): EnakPoint & EnakCoin #32

Merged
aefril merged 36 commits from feature/point-coint into main 2026-09-30 10:03:08 +02:00
11 changed files with 217 additions and 6 deletions
Showing only changes of commit 43eac0ced4 - Show all commits
+4 -1
View File
@@ -158,6 +158,7 @@ func (a *App) Initialize(cfg *config.Config) error {
services.loyaltySettingsService,
services.customerPinService,
services.pointPaymentService,
services.customerOrderPaymentService,
a.redisClient,
)
@@ -409,7 +410,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
orderProcessor.SetLoyalty(earningProcessor)
// Pay orders with EnakPoint, approved by the customer's one-time code (docs/prd-point-coin.md F9)
pointPaymentProcessor := processor.NewPointPaymentProcessor(repository.NewPointPaymentRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor)
orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor)
return &processors{
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
@@ -509,6 +510,7 @@ type services struct {
loyaltySettingsService *service.LoyaltySettingsServiceImpl
customerPinService *service.CustomerPinServiceImpl
pointPaymentService *service.PointPaymentServiceImpl
customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl
}
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
@@ -594,6 +596,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor),
pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor),
customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor),
}
}
@@ -31,3 +31,9 @@ type RemoveCustomerPinRequest struct {
type IssuePaymentCodeRequest struct {
Pin string `json:"pin" binding:"required"`
}
// PayWithPointsRequest is POST /customer/orders/:id/pay-with-points.
type PayWithPointsRequest struct {
Points int64 `json:"points" binding:"required,min=1"`
Pin string `json:"pin" binding:"required"`
}
@@ -0,0 +1,35 @@
package handler
import (
"github.com/gin-gonic/gin"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
)
// CustomerOrderPaymentHandler serves POST /customer/orders/:id/pay-with-points
// (docs/prd-point-coin.md F9). The body holds the PIN, so it is never logged.
type CustomerOrderPaymentHandler struct {
payments service.CustomerOrderPaymentService
}
func NewCustomerOrderPaymentHandler(payments service.CustomerOrderPaymentService) *CustomerOrderPaymentHandler {
return &CustomerOrderPaymentHandler{payments: payments}
}
func (h *CustomerOrderPaymentHandler) PayWithPoints(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerOrderPaymentHandler::PayWithPoints")
if !ok {
return
}
orderID, ok := parseUUIDParam(c, "id", "CustomerOrderPaymentHandler::PayWithPoints")
if !ok {
return
}
var req contract.PayWithPointsRequest
if !bindPinRequest(c, &req, "CustomerOrderPaymentHandler::PayWithPoints") {
return
}
util.HandleResponse(c.Writer, c.Request, h.payments.PayWithPoints(c.Request.Context(), customerID, orderID, &req, pinRequestInfo(c)), "CustomerOrderPaymentHandler::PayWithPoints")
}
+49 -3
View File
@@ -21,6 +21,9 @@ type OrderProcessor interface {
CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error)
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error)
UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error)
// PayWithPointsInApp pays the customer's own order with EnakPoint from the app or a
// self-order, approved by their PIN (docs/prd-point-coin.md F9).
PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error)
GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error)
ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error)
VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error
@@ -114,6 +117,7 @@ type OrderProcessorImpl struct {
loyalty OrderLoyalty
pointPayments *PointPaymentProcessor
paymentCodes paymentCodeRedeemer
pins pinVerifier
}
// OrderLoyalty is what the order flow tells and asks the loyalty program
@@ -144,11 +148,13 @@ type paymentCodeRedeemer interface {
Redeem(ctx context.Context, code string, customerID uuid.UUID) error
}
// SetPointPayments enables paying with the EnakPoint method: CreatePayment hands such
// payments to pointPayments, approved by the code the customer shows (F9).
func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer) {
// SetPointPayments enables paying with the EnakPoint method. CreatePayment hands such
// payments to pointPayments approved by the code the customer shows at the cashier,
// and PayWithPointsInApp approved by the customer's PIN (F9).
func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer, pins pinVerifier) {
p.pointPayments = pointPayments
p.paymentCodes = codes
p.pins = pins
}
// createPointPayment is CreatePayment for the EnakPoint method. It never uses the
@@ -1787,3 +1793,43 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Con
func stringPtr(s string) *string {
return &s
}
// PayWithPointsInApp pays an order with EnakPoint on the customer's own request, in the
// app or a self-order. The session alone is not enough: the customer's PIN approves
// it (K8). An order that is not the customer's own is reported as not found, so the
// endpoint does not reveal other customers' orders.
func (p *OrderProcessorImpl) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) {
if p.pointPayments == nil || p.pins == nil {
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
}
organizationID, owner, err := p.pointPayments.OrderOwner(ctx, orderID)
if err != nil {
return nil, err
}
if owner == nil || *owner != customerID {
return nil, repository.ErrPointPaymentOrderNotFound
}
methodID, err := p.pointPayments.PointMethodID(ctx, organizationID)
if err != nil {
return nil, err
}
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
OrderID: orderID,
PaymentMethodID: methodID,
Points: points,
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
return p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info)
},
})
if err != nil {
return nil, err
}
if result.Completed {
p.onOrderPaid(ctx, orderID)
}
payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
}
return mappers.PaymentEntityToResponse(payment), nil
}
+43 -1
View File
@@ -91,7 +91,7 @@ func newPointPaymentEnv(t *testing.T) *pointPaymentEnv {
txManager: txm,
}
e.orders.SetLoyalty(NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm))
e.orders.SetPointPayments(e.payments, e.codes)
e.orders.SetPointPayments(e.payments, e.codes, pinVerifierFake{good: "482913"})
// The outlet earns 1 EnakPoint per Rp 100 and accepts EnakPoint.
s, err := settings.Outlet(context.Background(), e.outlet)
@@ -332,3 +332,45 @@ func TestPointPayment_ConcurrentForOneCustomer(t *testing.T) {
assert.NotEqual(t, customer, d.CustomerID, d.Check)
}
}
func TestPointPayment_InApp(t *testing.T) {
e := newPointPaymentEnv(t)
owner := e.customerWith(100000)
stranger := e.customerWith(100000)
order := e.order(owner, 60000)
info := models.CustomerPinRequestInfo{}
// Another customer cannot pay it, and is not told it exists.
_, err := e.orders.PayWithPointsInApp(e.ctx, stranger, order, 1000, "482913", info)
assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound)
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, uuid.New(), 1000, "482913", info)
assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound)
// The session alone is not enough: a wrong PIN takes nothing.
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 1000, "000000", info)
var pe *PinError
require.ErrorAs(t, err, &pe)
assert.Equal(t, PinErrInvalid, pe.Code)
assert.Equal(t, int64(100000), e.balance(owner))
// The owner pays part, then the rest, with the same rules as at the cashier.
payment, err := e.orders.PayWithPointsInApp(e.ctx, owner, order, 10000, "482913", info)
require.NoError(t, err)
assert.Equal(t, int64(10000), *payment.PointsUsed)
status, remaining := e.orderState(order)
assert.Equal(t, "partial", status)
assert.Equal(t, 50000.0, remaining)
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50001, "482913", info)
assert.ErrorIs(t, err, ErrPointPaymentRejected, "not more than what is left")
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50000, "482913", info)
require.NoError(t, err)
status, _ = e.orderState(order)
assert.Equal(t, "completed", status)
assert.Equal(t, int64(40000), e.balance(owner))
assert.Equal(t, int64(100000), e.balance(stranger))
var createdBy *string
require.NoError(t, e.db.Raw(`SELECT created_by_user::text FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT' LIMIT 1`, owner).Scan(&createdBy).Error)
assert.Nil(t, createdBy, "no cashier took an in-app payment")
}
@@ -319,3 +319,18 @@ func formatRupiah(n int64) string {
}
return string(out)
}
// PointMethodID returns the organization's EnakPoint payment method.
func (p *PointPaymentProcessor) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
return p.repo.PointMethodID(ctx, organizationID)
}
// OrderOwner returns the organization and customer of an order, for checking that a
// customer pays only their own order.
func (p *PointPaymentProcessor) OrderOwner(ctx context.Context, orderID uuid.UUID) (organizationID uuid.UUID, customerID *uuid.UUID, err error) {
order, err := p.repo.GetOrder(ctx, orderID, false)
if err != nil {
return uuid.Nil, nil, err
}
return order.OrganizationID, order.CustomerID, nil
}
@@ -44,6 +44,8 @@ type PointPaymentRepository interface {
TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error)
// PaidWithPoints sums the rupiah of the order's completed EnakPoint payments.
PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error)
// PointMethodID returns the organization's EnakPoint payment method.
PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error)
InsertPayment(ctx context.Context, payment *entities.Payment) error
// UpdateOrderAfterPayment stores what is left to pay and marks the order paid when
// nothing is.
@@ -176,3 +178,19 @@ func (r *pointPaymentRepository) UpdateOrderAfterPayment(ctx context.Context, or
}
return nil
}
func (r *pointPaymentRepository) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
var ids []string
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table("payment_methods").
Where("organization_id = ? AND type = ?", organizationID, constants.PaymentMethodTypePoint).
Limit(1).
Pluck("id::text", &ids).Error
if err != nil {
return uuid.Nil, fmt.Errorf("failed to find the EnakPoint payment method: %w", err)
}
if len(ids) == 0 {
return uuid.Nil, fmt.Errorf("the organization has no EnakPoint payment method")
}
return uuid.Parse(ids[0])
}
+4 -1
View File
@@ -58,12 +58,13 @@ type Router struct {
loyaltySettingsHandler *handler.LoyaltySettingsHandler
customerPinHandler *handler.CustomerPinHandler
pointPaymentHandler *handler.PointPaymentHandler
customerOrderPaymentHandler *handler.CustomerOrderPaymentHandler
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, 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, redisClient *redis.Client) *Router {
return &Router{
config: cfg,
@@ -113,6 +114,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService),
customerPinHandler: handler.NewCustomerPinHandler(customerPinService),
pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService),
customerOrderPaymentHandler: handler.NewCustomerOrderPaymentHandler(customerOrderPaymentService),
redisClient: redisClient,
}
}
@@ -167,6 +169,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet)
customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions)
customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode)
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)
customer.POST("/pin/otp", r.customerPinHandler.RequestOtp)
+1
View File
@@ -37,6 +37,7 @@ func TestAllRoutesRegister(t *testing.T) {
"GET /api/v1/marketing/loyalty-settings/history",
"POST /api/v1/customer/wallet/payment-code",
"GET /api/v1/orders/:id/point-payment/preview",
"POST /api/v1/customer/orders/:id/pay-with-points",
"GET /api/v1/customer/pin/status",
"POST /api/v1/customer/pin/otp",
"POST /api/v1/customer/pin",
@@ -0,0 +1,34 @@
package service
import (
"context"
"github.com/google/uuid"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/transformer"
)
// CustomerOrderPaymentService lets customers pay their own orders with EnakPoint in the
// app or a self-order (docs/prd-point-coin.md F9).
type CustomerOrderPaymentService interface {
PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response
}
type CustomerOrderPaymentServiceImpl struct {
orders processor.OrderProcessor
}
func NewCustomerOrderPaymentService(orders processor.OrderProcessor) *CustomerOrderPaymentServiceImpl {
return &CustomerOrderPaymentServiceImpl{orders: orders}
}
func (s *CustomerOrderPaymentServiceImpl) PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response {
payment, err := s.orders.PayWithPointsInApp(ctx, customerID, orderID, req.Points, req.Pin, info)
if err != nil {
return PointPaymentErrorResponse(err)
}
return contract.BuildSuccessResponse(transformer.PaymentModelToContract(payment))
}
@@ -42,6 +42,14 @@ func (m *MockOrderProcessor) UpdateOrder(ctx context.Context, id uuid.UUID, req
return args.Get(0).(*models.OrderResponse), args.Error(1)
}
func (m *MockOrderProcessor) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) {
args := m.Called(ctx, customerID, orderID, points, pin, info)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.PaymentResponse), args.Error(1)
}
func (m *MockOrderProcessor) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {