feat(loyalty): pay orders with EnakPoint at the cashier
Adds paying with the EnakPoint method (docs/prd-point-coin.md F9, K7,
PC-305).
POST /payments with the EnakPoint method now takes points and the
customer's payment code and goes through PointPaymentProcessor instead of
the generic path, which would record a payment without taking any balance.
After checking the order, its customer (not walk-in, active), the outlet
(accepts EnakPoint, minimum) and the method, it redeems the code, then in
one transaction locks the order row and the wallet, recomputes the F9
limits from fresh data, inserts the payment with points_used and the frozen
point_value, writes the PAYMENT ledger row (key payment:{id}, the outlet,
the cashier) and updates the order. The limits are
min(balance, floor(min(remaining, total × max_payment_percent / 100 − paid
with EnakPoint) / point_value)) in cents, so EnakPoint never pays more than
what is left and gives no change.
Unlike the generic CreatePayment, which always marks the order paid, an
EnakPoint payment leaves it partial with the right remaining amount until
it is settled, so the rest can be paid in cash. Settling it triggers
earning, whose basis leaves out the EnakPoint part. Splitting with the
EnakPoint method is refused. Refusals answer 400. The payment response
carries points_used and point_value for the receipt.
GET /orders/:id/point-payment/preview returns eligibility, balance, point
value and the maximum for the use-maximum button.
The payment and order repositories write outside transactions, so this
path uses its own repository that joins it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0c4dd72583
commit
4b3beaed41
@@ -157,6 +157,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
validators.walletValidator,
|
||||
services.loyaltySettingsService,
|
||||
services.customerPinService,
|
||||
services.pointPaymentService,
|
||||
a.redisClient,
|
||||
)
|
||||
|
||||
@@ -389,6 +390,7 @@ type processors struct {
|
||||
earningProcessor *processor.EarningProcessor
|
||||
customerPinProcessor *processor.CustomerPinProcessor
|
||||
paymentCodeProcessor *processor.PaymentCodeProcessor
|
||||
pointPaymentProcessor *processor.PointPaymentProcessor
|
||||
}
|
||||
|
||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||
@@ -405,6 +407,9 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
// Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3)
|
||||
earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
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)
|
||||
|
||||
return &processors{
|
||||
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
|
||||
@@ -456,6 +461,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
earningProcessor: earningProcessor,
|
||||
customerPinProcessor: customerPinProcessor,
|
||||
paymentCodeProcessor: paymentCodeProcessor,
|
||||
pointPaymentProcessor: pointPaymentProcessor,
|
||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
|
||||
}
|
||||
}
|
||||
@@ -502,6 +508,7 @@ type services struct {
|
||||
walletAdminService *service.WalletAdminServiceImpl
|
||||
loyaltySettingsService *service.LoyaltySettingsServiceImpl
|
||||
customerPinService *service.CustomerPinServiceImpl
|
||||
pointPaymentService *service.PointPaymentServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@@ -586,6 +593,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor),
|
||||
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
|
||||
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor),
|
||||
pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -188,7 +188,11 @@ type SetOrderCustomerResponse struct {
|
||||
type CreatePaymentRequest struct {
|
||||
OrderID uuid.UUID `json:"order_id" validate:"required"`
|
||||
PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"`
|
||||
Amount float64 `json:"amount" validate:"required,min=0"`
|
||||
// For the EnakPoint method: how many to use and the code the customer shows. The
|
||||
// amount is then computed from them.
|
||||
Points *int64 `json:"points,omitempty"`
|
||||
PaymentCode *string `json:"payment_code,omitempty"`
|
||||
Amount float64 `json:"amount" validate:"min=0"`
|
||||
TransactionID *string `json:"transaction_id,omitempty" validate:"omitempty"`
|
||||
SplitNumber int `json:"split_number,omitempty" validate:"omitempty,min=1"`
|
||||
SplitTotal int `json:"split_total,omitempty" validate:"omitempty,min=1"`
|
||||
@@ -217,6 +221,10 @@ type PaymentResponse struct {
|
||||
SplitType *string `json:"split_type,omitempty"`
|
||||
SplitDescription *string `json:"split_description,omitempty"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
// Set for a payment with EnakPoint, for "EnakPoint: 50.000 (Rp 50.000)" on the
|
||||
// receipt (docs/prd-point-coin.md F9).
|
||||
PointsUsed *int64 `json:"points_used,omitempty"`
|
||||
PointValue *float64 `json:"point_value,omitempty"`
|
||||
RefundReason *string `json:"refund_reason,omitempty"`
|
||||
RefundedAt *time.Time `json:"refunded_at,omitempty"`
|
||||
RefundedBy *uuid.UUID `json:"refunded_by,omitempty"`
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/transformer"
|
||||
"apskel-pos-be/internal/util"
|
||||
@@ -225,6 +228,11 @@ func (h *OrderHandler) CreatePayment(c *gin.Context) {
|
||||
|
||||
response, err := h.orderService.CreatePayment(c.Request.Context(), modelReq)
|
||||
if err != nil {
|
||||
// A refused EnakPoint payment is the cashier's or customer's to fix, not a server fault.
|
||||
if errors.Is(err, processor.ErrPointPaymentRejected) {
|
||||
util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::CreatePayment")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::CreatePayment", err.Error())}), "OrderHandler::CreatePayment")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// PointPaymentHandler serves GET /orders/:id/point-payment/preview
|
||||
// (docs/prd-point-coin.md F9).
|
||||
type PointPaymentHandler struct {
|
||||
pointPaymentService service.PointPaymentService
|
||||
}
|
||||
|
||||
func NewPointPaymentHandler(pointPaymentService service.PointPaymentService) *PointPaymentHandler {
|
||||
return &PointPaymentHandler{pointPaymentService: pointPaymentService}
|
||||
}
|
||||
|
||||
func (h *PointPaymentHandler) Preview(c *gin.Context) {
|
||||
orderID, ok := parseUUIDParam(c, "id", "PointPaymentHandler::Preview")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.pointPaymentService.Preview(ctx, appcontext.FromGinContext(ctx), orderID), "PointPaymentHandler::Preview")
|
||||
}
|
||||
@@ -189,6 +189,8 @@ func PaymentEntityToResponse(payment *entities.Payment) *models.PaymentResponse
|
||||
SplitType: (*string)(payment.SplitType),
|
||||
SplitDescription: payment.SplitDescription,
|
||||
RefundAmount: payment.RefundAmount,
|
||||
PointsUsed: payment.PointsUsed,
|
||||
PointValue: payment.PointValue,
|
||||
RefundReason: payment.RefundReason,
|
||||
RefundedAt: payment.RefundedAt,
|
||||
RefundedBy: payment.RefundedBy,
|
||||
|
||||
@@ -30,6 +30,8 @@ type Payment struct {
|
||||
type CreatePaymentRequest struct {
|
||||
OrderID uuid.UUID `validate:"required"`
|
||||
PaymentMethodID uuid.UUID `validate:"required"`
|
||||
Points *int64
|
||||
PaymentCode *string
|
||||
Amount float64 `validate:"required,min=0"`
|
||||
TransactionID *string `validate:"omitempty"`
|
||||
SplitNumber int `validate:"omitempty,min=1"`
|
||||
@@ -60,6 +62,9 @@ type PaymentResponse struct {
|
||||
SplitType *string
|
||||
SplitDescription *string
|
||||
RefundAmount float64
|
||||
// Set for a payment with EnakPoint.
|
||||
PointsUsed *int64
|
||||
PointValue *float64
|
||||
RefundReason *string
|
||||
RefundedAt *time.Time
|
||||
RefundedBy *uuid.UUID
|
||||
|
||||
@@ -138,3 +138,22 @@ type AdminWalletAdjustmentResult struct {
|
||||
// True when the idempotency key had been used before and nothing changed.
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
// PointPaymentPreview is GET /orders/:id/point-payment/preview (docs/prd-point-coin.md
|
||||
// F9): whether the order can be paid with EnakPoint and at most how much, for the
|
||||
// cashier's "use maximum" button.
|
||||
type PointPaymentPreview struct {
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
CustomerID *uuid.UUID `json:"customer_id"`
|
||||
Eligible bool `json:"eligible"`
|
||||
// Why not, when not eligible.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
PointBalance int64 `json:"point_balance"`
|
||||
PointValue int64 `json:"point_value"`
|
||||
RemainingAmount float64 `json:"remaining_amount"`
|
||||
MinPaymentPoints int64 `json:"min_payment_points"`
|
||||
MaxPaymentPercent int64 `json:"max_payment_percent"`
|
||||
MaxPoints int64 `json:"max_points"`
|
||||
// Rupiah covered by MaxPoints.
|
||||
MaxAmount int64 `json:"max_amount"`
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
@@ -111,6 +112,8 @@ type OrderProcessorImpl struct {
|
||||
inventoryMovementService InventoryMovementService
|
||||
productOutletPriceRepo repository.ProductOutletPriceRepository
|
||||
loyalty OrderLoyalty
|
||||
pointPayments *PointPaymentProcessor
|
||||
paymentCodes paymentCodeRedeemer
|
||||
}
|
||||
|
||||
// OrderLoyalty is what the order flow tells and asks the loyalty program
|
||||
@@ -137,6 +140,56 @@ func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) {
|
||||
p.loyalty = loyalty
|
||||
}
|
||||
|
||||
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) {
|
||||
p.pointPayments = pointPayments
|
||||
p.paymentCodes = codes
|
||||
}
|
||||
|
||||
// createPointPayment is CreatePayment for the EnakPoint method. It never uses the
|
||||
// generic payment path, which would record the payment without taking any balance.
|
||||
func (p *OrderProcessorImpl) createPointPayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) {
|
||||
if p.pointPayments == nil || p.paymentCodes == nil {
|
||||
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
|
||||
}
|
||||
if req.Points == nil || req.PaymentCode == nil || *req.PaymentCode == "" {
|
||||
return nil, fmt.Errorf("%w: points and the customer's payment code are required", ErrPointPaymentRejected)
|
||||
}
|
||||
var cashier *uuid.UUID
|
||||
if id := appcontext.FromContext(ctx).UserID; id != uuid.Nil {
|
||||
cashier = &id
|
||||
}
|
||||
code := *req.PaymentCode
|
||||
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
|
||||
OrderID: req.OrderID,
|
||||
PaymentMethodID: req.PaymentMethodID,
|
||||
Points: *req.Points,
|
||||
CashierID: cashier,
|
||||
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
|
||||
if err := p.paymentCodes.Redeem(ctx, code, customerID); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrPointPaymentRejected, err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Completed {
|
||||
p.onOrderPaid(ctx, req.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
|
||||
}
|
||||
|
||||
// onOrderPaid is the single place every path that completes an order's payment goes
|
||||
// through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called
|
||||
// after the payment has committed. The hook runs detached from the caller's
|
||||
@@ -892,10 +945,13 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea
|
||||
return nil, fmt.Errorf("order is already fully paid")
|
||||
}
|
||||
|
||||
_, err = p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
|
||||
method, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payment method not found: %w", err)
|
||||
}
|
||||
if method.Type == entities.PaymentMethodTypePoint {
|
||||
return p.createPointPayment(ctx, req)
|
||||
}
|
||||
|
||||
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID)
|
||||
if err != nil {
|
||||
@@ -1280,6 +1336,11 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payment method not found: %w", err)
|
||||
}
|
||||
// Splitting with EnakPoint would record a payment without taking any balance; an
|
||||
// EnakPoint part goes through CreatePayment and the rest is split as usual (F9).
|
||||
if payment.Type == entities.PaymentMethodTypePoint {
|
||||
return nil, fmt.Errorf("%w: pay the EnakPoint part as its own payment, not as a split", ErrPointPaymentRejected)
|
||||
}
|
||||
|
||||
customer := &entities.Customer{}
|
||||
if req.CustomerID != uuid.Nil {
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// pointPaymentEnv is an order flow wired as in the app, against Postgres and a
|
||||
// miniredis for payment codes.
|
||||
type pointPaymentEnv struct {
|
||||
t *testing.T
|
||||
db *gorm.DB
|
||||
orders *OrderProcessorImpl
|
||||
payments *PointPaymentProcessor
|
||||
codes *PaymentCodeProcessor
|
||||
org uuid.UUID
|
||||
cashier uuid.UUID
|
||||
outlet uuid.UUID
|
||||
point uuid.UUID
|
||||
cash uuid.UUID
|
||||
walkIn uuid.UUID
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func newPointPaymentEnv(t *testing.T) *pointPaymentEnv {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
e := &pointPaymentEnv{t: t, db: db, org: uuid.New(), cashier: uuid.New(), outlet: uuid.New(), cash: uuid.New()}
|
||||
e.exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'point pay test', 'basic')`, e.org)
|
||||
e.exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, e.cashier, e.org, e.cashier.String()+"@t")
|
||||
e.exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, e.outlet, e.org)
|
||||
e.exec(`INSERT INTO payment_methods (id, organization_id, name, type) VALUES (?, ?, 'Tunai', 'cash')`, e.cash, e.org)
|
||||
var ids []string
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM payment_methods WHERE organization_id = ? AND type = 'point'`, e.org).Scan(&ids).Error)
|
||||
require.Len(t, ids, 1)
|
||||
e.point = uuid.MustParse(ids[0])
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, e.org).Scan(&ids).Error)
|
||||
e.walkIn = uuid.MustParse(ids[0])
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, e.org)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM payments WHERE order_id IN (SELECT id FROM orders WHERE organization_id = ?)`, e.org)
|
||||
db.Exec(`DELETE FROM orders WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, e.outlet)
|
||||
db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM customers WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM outlets WHERE id = ?`, e.outlet)
|
||||
db.Exec(`DELETE FROM users WHERE id = ?`, e.cashier)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, e.org)
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
e.payments = NewPointPaymentProcessor(repository.NewPointPaymentRepository(db), settings, repository.NewWalletQueryRepository(db), wallet, txm)
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { client.Close() })
|
||||
e.codes = NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"})
|
||||
|
||||
e.orders = &OrderProcessorImpl{
|
||||
orderRepo: repository.NewOrderRepositoryImpl(db),
|
||||
orderItemRepo: repository.NewOrderItemRepositoryImpl(db),
|
||||
paymentRepo: repository.NewPaymentRepositoryImpl(db),
|
||||
paymentMethodRepo: repository.NewPaymentMethodRepositoryImpl(db),
|
||||
splitBillProcessor: nil,
|
||||
txManager: txm,
|
||||
}
|
||||
e.orders.SetLoyalty(NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm))
|
||||
e.orders.SetPointPayments(e.payments, e.codes)
|
||||
|
||||
// The outlet earns 1 EnakPoint per Rp 100 and accepts EnakPoint.
|
||||
s, err := settings.Outlet(context.Background(), e.outlet)
|
||||
require.NoError(t, err)
|
||||
s.Point.Enabled = true
|
||||
s.PointPayment.AcceptPayment = true
|
||||
_, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s)
|
||||
require.NoError(t, err)
|
||||
|
||||
e.ctx = context.WithValue(context.Background(), appcontext.UserIDKey, e.cashier.String())
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) exec(q string, args ...any) {
|
||||
e.t.Helper()
|
||||
require.NoError(e.t, e.db.Exec(q, args...).Error)
|
||||
}
|
||||
|
||||
// customerWith creates a customer holding the given EnakPoint.
|
||||
func (e *pointPaymentEnv) customerWith(points int64) uuid.UUID {
|
||||
e.t.Helper()
|
||||
id := uuid.New()
|
||||
e.exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, id, e.org)
|
||||
if points > 0 {
|
||||
require.NoError(e.t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
|
||||
CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: points,
|
||||
ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}})
|
||||
return err
|
||||
}))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) order(customer uuid.UUID, subtotal float64) uuid.UUID {
|
||||
e.t.Helper()
|
||||
id := uuid.New()
|
||||
e.exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
|
||||
subtotal, tax_amount, total_amount, remaining_amount, payment_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, 0, ?, ?, 'pending')`,
|
||||
id, e.org, e.outlet, e.cashier, customer, "PP-"+id.String()[:8], subtotal, subtotal, subtotal)
|
||||
return id
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) code(customer uuid.UUID) string {
|
||||
e.t.Helper()
|
||||
c, err := e.codes.Issue(context.Background(), customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(e.t, err)
|
||||
return c.Code
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) payPoints(order uuid.UUID, points int64, code string) (*models.PaymentResponse, error) {
|
||||
return e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &points, PaymentCode: &code})
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) balance(customer uuid.UUID) int64 {
|
||||
e.t.Helper()
|
||||
var b int64
|
||||
require.NoError(e.t, e.db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error)
|
||||
return b
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) orderState(order uuid.UUID) (status string, remaining float64) {
|
||||
e.t.Helper()
|
||||
var row struct {
|
||||
PaymentStatus string
|
||||
RemainingAmount float64
|
||||
}
|
||||
require.NoError(e.t, e.db.Raw(`SELECT payment_status, remaining_amount FROM orders WHERE id = ?`, order).Scan(&row).Error)
|
||||
return row.PaymentStatus, row.RemainingAmount
|
||||
}
|
||||
|
||||
func TestPointPayment_FullPayment(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 50000)
|
||||
|
||||
payment, err := e.payPoints(order, 50000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 50000.0, payment.Amount)
|
||||
require.NotNil(t, payment.PointsUsed)
|
||||
assert.Equal(t, int64(50000), *payment.PointsUsed)
|
||||
assert.Equal(t, 1.0, *payment.PointValue, "the value is frozen on the payment")
|
||||
status, remaining := e.orderState(order)
|
||||
assert.Equal(t, "completed", status)
|
||||
assert.Zero(t, remaining)
|
||||
assert.Equal(t, int64(50000), e.balance(customer))
|
||||
|
||||
var ledger struct {
|
||||
Amount int64
|
||||
ReferenceType string
|
||||
ReferenceID string
|
||||
OutletID string
|
||||
CreatedByUser string
|
||||
}
|
||||
require.NoError(t, e.db.Raw(`SELECT amount, reference_type, reference_id::text AS reference_id, outlet_id::text AS outlet_id,
|
||||
created_by_user::text AS created_by_user FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT'`, customer).Scan(&ledger).Error)
|
||||
assert.Equal(t, int64(-50000), ledger.Amount)
|
||||
assert.Equal(t, "PAYMENT", ledger.ReferenceType)
|
||||
assert.Equal(t, payment.ID.String(), ledger.ReferenceID)
|
||||
assert.Equal(t, e.outlet.String(), ledger.OutletID)
|
||||
assert.Equal(t, e.cashier.String(), ledger.CreatedByUser, "the cashier who took it")
|
||||
|
||||
// Paid entirely with EnakPoint, so nothing earns (Q10).
|
||||
var earned int64
|
||||
require.NoError(t, e.db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error)
|
||||
assert.Zero(t, earned)
|
||||
}
|
||||
|
||||
func TestPointPayment_PartialThenCash(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 87500)
|
||||
|
||||
_, err := e.payPoints(order, 20000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
status, remaining := e.orderState(order)
|
||||
assert.Equal(t, "partial", status)
|
||||
assert.Equal(t, 67500.0, remaining)
|
||||
|
||||
// The rest in cash settles it; earning counts only the part not paid with
|
||||
// EnakPoint: floor(67.500 / 100) = 675.
|
||||
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
|
||||
require.NoError(t, err)
|
||||
status, _ = e.orderState(order)
|
||||
assert.Equal(t, "completed", status)
|
||||
var earned int64
|
||||
require.NoError(t, e.db.Raw(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error)
|
||||
assert.Equal(t, int64(675), earned)
|
||||
assert.Equal(t, int64(100000-20000+675), e.balance(customer))
|
||||
}
|
||||
|
||||
func TestPointPayment_PercentCap(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db))
|
||||
s, err := settings.Outlet(context.Background(), e.outlet)
|
||||
require.NoError(t, err)
|
||||
s.PointPayment.MaxPaymentPercent = 50
|
||||
_, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s)
|
||||
require.NoError(t, err)
|
||||
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 100000)
|
||||
|
||||
preview, err := e.payments.Preview(context.Background(), e.org, order)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, preview.Eligible)
|
||||
assert.Equal(t, int64(50000), preview.MaxPoints)
|
||||
assert.Equal(t, int64(100000), preview.PointBalance)
|
||||
|
||||
_, err = e.payPoints(order, 50001, e.code(customer))
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected)
|
||||
_, err = e.payPoints(order, 30000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
_, err = e.payPoints(order, 20001, e.code(customer))
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "earlier EnakPoint counts toward the cap")
|
||||
_, err = e.payPoints(order, 20000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(50000), e.balance(customer))
|
||||
}
|
||||
|
||||
func TestPointPayment_Refusals(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
other := e.customerWith(100000)
|
||||
|
||||
// A walk-in order cannot be paid with EnakPoint.
|
||||
walkInOrder := e.order(e.walkIn, 10000)
|
||||
preview, err := e.payments.Preview(context.Background(), e.org, walkInOrder)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, preview.Eligible)
|
||||
assert.Contains(t, preview.Reason, "walk-in")
|
||||
_, err = e.payPoints(walkInOrder, 1000, "123456")
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected)
|
||||
|
||||
order := e.order(customer, 10000)
|
||||
_, err = e.payPoints(order, 1000, "000000")
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "a wrong code")
|
||||
_, err = e.payPoints(order, 1000, e.code(other))
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "another customer's code")
|
||||
missing := int64(1000)
|
||||
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &missing})
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "no code at all")
|
||||
|
||||
code := e.code(customer)
|
||||
_, err = e.payPoints(order, 1000, code)
|
||||
require.NoError(t, err)
|
||||
_, err = e.payPoints(order, 1000, code)
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "a code is used once, so a double tap takes once")
|
||||
_, err = e.payPoints(order, 9001, e.code(customer))
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "no change is given: not more than what is left")
|
||||
|
||||
// Splitting with the EnakPoint method would skip the balance, so it is refused.
|
||||
e.orders.splitBillProcessor = splitFake{}
|
||||
_, err = e.orders.SplitBill(e.ctx, &models.SplitBillRequest{OrderID: order, PaymentMethodID: e.point, Type: "AMOUNT", Amount: 1000})
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected)
|
||||
|
||||
assert.Equal(t, int64(99000), e.balance(customer), "only the one payment took anything")
|
||||
assert.Equal(t, int64(100000), e.balance(other))
|
||||
}
|
||||
|
||||
// Two payments for the same customer at once, on two orders: the balance is taken
|
||||
// once, never twice. Authorization is taken as given so only the balance decides.
|
||||
func TestPointPayment_ConcurrentForOneCustomer(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(30000)
|
||||
orders := []uuid.UUID{e.order(customer, 20000), e.order(customer, 20000)}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, len(orders))
|
||||
for i, order := range orders {
|
||||
wg.Add(1)
|
||||
go func(i int, order uuid.UUID) {
|
||||
defer wg.Done()
|
||||
_, results[i] = e.payments.Pay(e.ctx, PointPaymentInput{
|
||||
OrderID: order, PaymentMethodID: e.point, Points: 20000,
|
||||
Authorize: func(context.Context, uuid.UUID) error { return nil },
|
||||
})
|
||||
}(i, order)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
succeeded := 0
|
||||
for _, err := range results {
|
||||
if err == nil {
|
||||
succeeded++
|
||||
} else {
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, succeeded, "30.000 EnakPoint pays one 20.000 order, not two")
|
||||
assert.Equal(t, int64(10000), e.balance(customer))
|
||||
|
||||
// And the wallet still reconciles.
|
||||
found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000)
|
||||
require.NoError(t, err)
|
||||
for _, d := range found {
|
||||
assert.NotEqual(t, customer, d.CustomerID, d.Check)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrPointPaymentRejected wraps every reason a payment with EnakPoint is refused: the
|
||||
// order, the customer, the outlet or the amount. The message says which.
|
||||
var ErrPointPaymentRejected = errors.New("EnakPoint payment refused")
|
||||
|
||||
type pointPaymentSettings interface {
|
||||
Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error)
|
||||
PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error)
|
||||
}
|
||||
|
||||
type spendableReader interface {
|
||||
SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error)
|
||||
}
|
||||
|
||||
// PointPaymentInput is one payment of an order with EnakPoint.
|
||||
type PointPaymentInput struct {
|
||||
OrderID uuid.UUID
|
||||
PaymentMethodID uuid.UUID
|
||||
Points int64
|
||||
// The cashier taking the payment at the POS; nil when the customer pays in the app.
|
||||
CashierID *uuid.UUID
|
||||
// Authorize proves the customer agreed, before anything is taken: at the POS it
|
||||
// redeems the payment code, in the app it checks the PIN (K8).
|
||||
Authorize func(ctx context.Context, customerID uuid.UUID) error
|
||||
}
|
||||
|
||||
// PointPaymentResult is the payment made and where it left the order.
|
||||
type PointPaymentResult struct {
|
||||
Payment *entities.Payment
|
||||
// True when this payment settled the order.
|
||||
Completed bool
|
||||
// Rupiah still to pay with another method.
|
||||
Remaining float64
|
||||
}
|
||||
|
||||
// pointPaymentLimits applies the formula of docs/prd-point-coin.md F9, in cents:
|
||||
//
|
||||
// cap = min(remaining, total × max_payment_percent / 100 − already paid with EnakPoint)
|
||||
// max_points = min(balance, floor(cap / point_value))
|
||||
type pointPaymentLimits struct {
|
||||
RemainingCents int64
|
||||
CapCents int64
|
||||
MaxPoints int64
|
||||
}
|
||||
|
||||
func computePointPaymentLimits(total, totalPaid, paidWithPoints float64, maxPercent, pointValue, balance int64) pointPaymentLimits {
|
||||
remaining := toCents(total) - toCents(totalPaid)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
byPercent := toCents(total)*maxPercent/100 - toCents(paidWithPoints)
|
||||
capCents := min(remaining, byPercent)
|
||||
if capCents < 0 {
|
||||
capCents = 0
|
||||
}
|
||||
maxPoints := int64(0)
|
||||
if pointValue > 0 {
|
||||
maxPoints = min(balance, capCents/(pointValue*100))
|
||||
}
|
||||
if maxPoints < 0 {
|
||||
maxPoints = 0
|
||||
}
|
||||
return pointPaymentLimits{RemainingCents: remaining, CapCents: capCents, MaxPoints: maxPoints}
|
||||
}
|
||||
|
||||
// PointPaymentProcessor pays orders with EnakPoint (docs/prd-point-coin.md F9).
|
||||
type PointPaymentProcessor struct {
|
||||
repo repository.PointPaymentRepository
|
||||
settings pointPaymentSettings
|
||||
spendable spendableReader
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewPointPaymentProcessor(repo repository.PointPaymentRepository, settings pointPaymentSettings, spendable spendableReader, wallet *WalletProcessor, tx TxRunner) *PointPaymentProcessor {
|
||||
return &PointPaymentProcessor{repo: repo, settings: settings, spendable: spendable, wallet: wallet, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// Preview is GET /orders/:id/point-payment/preview: whether the order can be paid with
|
||||
// EnakPoint, and at most how much.
|
||||
func (p *PointPaymentProcessor) Preview(ctx context.Context, organizationID, orderID uuid.UUID) (*models.PointPaymentPreview, error) {
|
||||
order, err := p.repo.GetOrder(ctx, orderID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.OrganizationID != organizationID {
|
||||
return nil, repository.ErrPointPaymentOrderNotFound
|
||||
}
|
||||
preview := &models.PointPaymentPreview{OrderID: orderID, CustomerID: order.CustomerID}
|
||||
settings, err := p.settings.Outlet(ctx, order.OutletID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := p.settings.PointValue(ctx, order.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preview.PointValue = value
|
||||
preview.MinPaymentPoints = settings.PointPayment.MinPaymentPoints
|
||||
preview.MaxPaymentPercent = settings.PointPayment.MaxPaymentPercent
|
||||
|
||||
if reason := pointPaymentOrderProblem(order, settings); reason != "" {
|
||||
preview.Reason = reason
|
||||
return preview, nil
|
||||
}
|
||||
limits, balance, err := p.limits(ctx, order, settings, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preview.PointBalance = balance
|
||||
preview.RemainingAmount = float64(limits.RemainingCents) / 100
|
||||
preview.MaxPoints = limits.MaxPoints
|
||||
preview.MaxAmount = limits.MaxPoints * value
|
||||
if limits.MaxPoints < settings.PointPayment.MinPaymentPoints {
|
||||
preview.Reason = "the customer cannot pay the minimum of EnakPoint on this order"
|
||||
return preview, nil
|
||||
}
|
||||
preview.Eligible = true
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
// Pay takes EnakPoint from the order's customer and records the payment. The payment
|
||||
// row, the ledger PAYMENT row, the balance and the order change in one transaction
|
||||
// with the order row and the wallet locked (F9 steps 1–6).
|
||||
func (p *PointPaymentProcessor) Pay(ctx context.Context, in PointPaymentInput) (*PointPaymentResult, error) {
|
||||
reject := func(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrPointPaymentRejected, fmt.Sprintf(format, args...))
|
||||
}
|
||||
if in.Points <= 0 {
|
||||
return nil, reject("the number of EnakPoint must be positive")
|
||||
}
|
||||
order, err := p.repo.GetOrder(ctx, in.OrderID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
methodOrg, methodType, err := p.repo.GetPaymentMethod(ctx, in.PaymentMethodID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if methodType != string(constants.PaymentMethodTypePoint) || methodOrg != order.OrganizationID {
|
||||
return nil, reject("the payment method is not this organization's EnakPoint method")
|
||||
}
|
||||
settings, err := p.settings.Outlet(ctx, order.OutletID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reason := pointPaymentOrderProblem(order, settings); reason != "" {
|
||||
return nil, reject("%s", reason)
|
||||
}
|
||||
if in.Points < settings.PointPayment.MinPaymentPoints {
|
||||
return nil, reject("at least %d EnakPoint must be used", settings.PointPayment.MinPaymentPoints)
|
||||
}
|
||||
customerID := *order.CustomerID
|
||||
|
||||
// The customer agrees before anything is taken. A code is used up here even if the
|
||||
// payment then fails, and the customer shows a new one.
|
||||
if in.Authorize == nil {
|
||||
return nil, reject("the customer has not approved the payment")
|
||||
}
|
||||
if err := in.Authorize(ctx, customerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &PointPaymentResult{}
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
// Lock the order, then the wallet, and read everything again: another payment
|
||||
// may have landed since the checks above.
|
||||
order, err := p.repo.GetOrder(ctx, in.OrderID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reason := pointPaymentOrderProblem(order, settings); reason != "" {
|
||||
return reject("%s", reason)
|
||||
}
|
||||
if err := p.wallet.LockWallet(ctx, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := p.settings.PointValue(ctx, order.OrganizationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, _, err := p.limits(ctx, order, settings, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Points > limits.MaxPoints {
|
||||
return reject("at most %d EnakPoint can pay this order now", limits.MaxPoints)
|
||||
}
|
||||
amountCents := in.Points * value * 100
|
||||
// No change is ever given for EnakPoint (K7); the limits already keep it
|
||||
// within what is left, this only guards that.
|
||||
if amountCents > limits.RemainingCents {
|
||||
return reject("EnakPoint cannot pay more than what is left on the order")
|
||||
}
|
||||
|
||||
pointsUsed := in.Points
|
||||
frozenValue := float64(value)
|
||||
payment := &entities.Payment{
|
||||
ID: uuid.New(),
|
||||
OrderID: order.ID,
|
||||
PaymentMethodID: in.PaymentMethodID,
|
||||
Amount: float64(amountCents) / 100,
|
||||
Status: entities.PaymentTransactionStatusCompleted,
|
||||
PointsUsed: &pointsUsed,
|
||||
PointValue: &frozenValue,
|
||||
Metadata: entities.Metadata{"points_used": pointsUsed, "point_value": value},
|
||||
}
|
||||
if err := p.repo.InsertPayment(ctx, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outletID := order.OutletID
|
||||
if _, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: customerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypePayment,
|
||||
Amount: in.Points,
|
||||
ReferenceType: constants.WalletRefTypePayment,
|
||||
ReferenceID: payment.ID,
|
||||
OutletID: &outletID,
|
||||
CreatedByUser: in.CashierID,
|
||||
Description: pointPaymentDescription(order, amountCents),
|
||||
Metadata: entities.Metadata{"point_value": value, "amount": payment.Amount},
|
||||
IdempotencyKey: "payment:" + payment.ID.String(),
|
||||
}}); err != nil {
|
||||
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
|
||||
return reject("the customer does not have enough EnakPoint")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
remainingCents := limits.RemainingCents - amountCents
|
||||
completed := remainingCents == 0
|
||||
if err := p.repo.UpdateOrderAfterPayment(ctx, order.ID, float64(remainingCents)/100, completed); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Payment = payment
|
||||
result.Completed = completed
|
||||
result.Remaining = float64(remainingCents) / 100
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *PointPaymentProcessor) limits(ctx context.Context, order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings, value int64) (pointPaymentLimits, int64, error) {
|
||||
totalPaid, err := p.repo.TotalPaid(ctx, order.ID)
|
||||
if err != nil {
|
||||
return pointPaymentLimits{}, 0, err
|
||||
}
|
||||
paidWithPoints, err := p.repo.PaidWithPoints(ctx, order.ID)
|
||||
if err != nil {
|
||||
return pointPaymentLimits{}, 0, err
|
||||
}
|
||||
balances, err := p.spendable.SpendableBalances(ctx, *order.CustomerID, p.now())
|
||||
if err != nil {
|
||||
return pointPaymentLimits{}, 0, err
|
||||
}
|
||||
balance := balances[constants.WalletCurrencyPoint]
|
||||
return computePointPaymentLimits(order.TotalAmount, totalPaid, paidWithPoints, settings.PointPayment.MaxPaymentPercent, value, balance), balance, nil
|
||||
}
|
||||
|
||||
// pointPaymentOrderProblem says why an order cannot be paid with EnakPoint at all, or
|
||||
// "" when it can.
|
||||
func pointPaymentOrderProblem(order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings) string {
|
||||
switch {
|
||||
case order.IsVoid:
|
||||
return "the order is void"
|
||||
case order.PaymentStatus == string(entities.PaymentStatusCompleted):
|
||||
return "the order is already paid"
|
||||
case !settings.PointPayment.AcceptPayment:
|
||||
return "this outlet does not accept EnakPoint"
|
||||
case order.CustomerID == nil || order.CustomerIsDefault == nil:
|
||||
return "the order has no customer"
|
||||
case *order.CustomerIsDefault:
|
||||
return "a walk-in order cannot be paid with EnakPoint"
|
||||
case order.CustomerIsActive == nil || !*order.CustomerIsActive:
|
||||
return "the customer is not active"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pointPaymentDescription(order *repository.PointPaymentOrder, amountCents int64) string {
|
||||
description := "Bayar #" + order.OrderNumber
|
||||
if order.OutletName != "" {
|
||||
description += " di " + order.OutletName
|
||||
}
|
||||
description += " (Rp " + formatRupiah(amountCents/100) + ")"
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
|
||||
// formatRupiah writes 50000 as 50.000.
|
||||
func formatRupiah(n int64) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
out := make([]byte, 0, len(s)+len(s)/3)
|
||||
for i, c := range []byte(s) {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, '.')
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComputePointPaymentLimits(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
total, paid, paidWithPoints float64
|
||||
percent, value, balance int64
|
||||
wantRemaining, wantMax int64
|
||||
}{
|
||||
// The F9 example: Rp 87.550 left, 50.000 EnakPoint, 100%, Rp 1 a point.
|
||||
"PRD example": {87550, 0, 0, 100, 1, 50000, 8755000, 50000},
|
||||
"balance covers it all": {87550, 0, 0, 100, 1, 100000, 8755000, 87550},
|
||||
"part already paid": {100000, 30000, 0, 100, 1, 100000, 7000000, 70000},
|
||||
"capped by percent": {100000, 0, 0, 50, 1, 100000, 10000000, 50000},
|
||||
"percent counts EnakPoint already used": {100000, 20000, 20000, 50, 1, 100000, 8000000, 30000},
|
||||
"percent cap already used": {100000, 50000, 50000, 50, 1, 100000, 5000000, 0},
|
||||
"point worth more than Rp 1": {87550, 0, 0, 100, 100, 1000, 8755000, 875},
|
||||
"nothing left": {50000, 50000, 0, 100, 1, 100000, 0, 0},
|
||||
"overpaid": {50000, 60000, 0, 100, 1, 100000, 0, 0},
|
||||
"no balance": {50000, 0, 0, 100, 1, 0, 5000000, 0},
|
||||
"cents left over": {10000.50, 0, 0, 100, 1, 100000, 1000050, 10000},
|
||||
} {
|
||||
got := computePointPaymentLimits(c.total, c.paid, c.paidWithPoints, c.percent, c.value, c.balance)
|
||||
assert.Equal(t, c.wantRemaining, got.RemainingCents, name)
|
||||
assert.Equal(t, c.wantMax, got.MaxPoints, name)
|
||||
assert.LessOrEqual(t, got.MaxPoints*c.value*100, got.RemainingCents, "%s: never more than what is left", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRupiah(t *testing.T) {
|
||||
for n, want := range map[int64]string{0: "0", 999: "999", 1000: "1.000", 50000: "50.000", 1234567: "1.234.567"} {
|
||||
assert.Equal(t, want, formatRupiah(n))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// ErrPointPaymentOrderNotFound means the order does not exist.
|
||||
var ErrPointPaymentOrderNotFound = errors.New("point payment: order not found")
|
||||
|
||||
// PointPaymentOrder is what paying with EnakPoint needs to know about an order.
|
||||
type PointPaymentOrder struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID uuid.UUID
|
||||
OrderNumber string
|
||||
OutletName string
|
||||
CustomerID *uuid.UUID
|
||||
TotalAmount float64
|
||||
PaymentStatus string
|
||||
IsVoid bool
|
||||
CustomerIsDefault *bool
|
||||
CustomerIsActive *bool
|
||||
}
|
||||
|
||||
// PointPaymentRepository reads and writes what paying an order with EnakPoint touches
|
||||
// (docs/prd-point-coin.md F9). Unlike the order and payment repositories, every method
|
||||
// joins the caller's transaction, since the payment row, the balance and the order
|
||||
// must change together.
|
||||
type PointPaymentRepository interface {
|
||||
// GetOrder reads the order; with lock it also locks the order row for the rest of
|
||||
// the transaction, so two payments of the same order queue up.
|
||||
GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error)
|
||||
// GetPaymentMethod returns a method's organization and type.
|
||||
GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (organizationID uuid.UUID, methodType string, err error)
|
||||
// TotalPaid sums the order's completed payments, as the rest of the order flow does.
|
||||
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)
|
||||
InsertPayment(ctx context.Context, payment *entities.Payment) error
|
||||
// UpdateOrderAfterPayment stores what is left to pay and marks the order paid when
|
||||
// nothing is.
|
||||
UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error
|
||||
}
|
||||
|
||||
type pointPaymentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPointPaymentRepository(db *gorm.DB) PointPaymentRepository {
|
||||
return &pointPaymentRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error) {
|
||||
lockClause := ""
|
||||
if lock {
|
||||
lockClause = "FOR UPDATE OF o"
|
||||
}
|
||||
var rows []struct {
|
||||
ID string
|
||||
OrganizationID string
|
||||
OutletID string
|
||||
OrderNumber string
|
||||
OutletName string
|
||||
CustomerID *string
|
||||
TotalAmount float64
|
||||
PaymentStatus string
|
||||
IsVoid bool
|
||||
CustomerIsDefault *bool
|
||||
CustomerIsActive *bool
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id,
|
||||
o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id,
|
||||
o.total_amount, o.payment_status, COALESCE(o.is_void, false) AS is_void,
|
||||
c.is_default AS customer_is_default, c.is_active AS customer_is_active
|
||||
FROM orders o
|
||||
LEFT JOIN outlets ou ON ou.id = o.outlet_id
|
||||
LEFT JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.id = ?
|
||||
`+lockClause, orderID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read order: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, ErrPointPaymentOrderNotFound
|
||||
}
|
||||
row := rows[0]
|
||||
order := &PointPaymentOrder{
|
||||
OrderNumber: row.OrderNumber,
|
||||
OutletName: row.OutletName,
|
||||
TotalAmount: row.TotalAmount,
|
||||
PaymentStatus: row.PaymentStatus,
|
||||
IsVoid: row.IsVoid,
|
||||
CustomerIsDefault: row.CustomerIsDefault,
|
||||
CustomerIsActive: row.CustomerIsActive,
|
||||
}
|
||||
order.ID, _ = uuid.Parse(row.ID)
|
||||
order.OrganizationID, _ = uuid.Parse(row.OrganizationID)
|
||||
order.OutletID, _ = uuid.Parse(row.OutletID)
|
||||
if row.CustomerID != nil {
|
||||
if id, err := uuid.Parse(*row.CustomerID); err == nil {
|
||||
order.CustomerID = &id
|
||||
}
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (uuid.UUID, string, error) {
|
||||
var rows []struct {
|
||||
OrganizationID string
|
||||
Type string
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT organization_id::text AS organization_id, type FROM payment_methods WHERE id = ?`, methodID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return uuid.Nil, "", fmt.Errorf("failed to read payment method: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return uuid.Nil, "", fmt.Errorf("payment method not found")
|
||||
}
|
||||
org, _ := uuid.Parse(rows[0].OrganizationID)
|
||||
return org, rows[0].Type, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
||||
var total float64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(amount), 0) FROM payments WHERE order_id = ? AND status = ?`,
|
||||
orderID, entities.PaymentTransactionStatusCompleted).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum payments: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
||||
var total float64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) FROM payments p
|
||||
JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||
WHERE p.order_id = ? AND p.status = ? AND pm.type = ?`,
|
||||
orderID, entities.PaymentTransactionStatusCompleted, constants.PaymentMethodTypePoint).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) InsertPayment(ctx context.Context, payment *entities.Payment) error {
|
||||
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(payment).Error; err != nil {
|
||||
return fmt.Errorf("failed to create payment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error {
|
||||
paymentStatus := entities.PaymentStatusPartial
|
||||
if completed {
|
||||
paymentStatus = entities.PaymentStatusCompleted
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
UPDATE orders SET remaining_amount = ?, payment_status = ?,
|
||||
status = CASE WHEN ? THEN ? ELSE status END, updated_at = NOW()
|
||||
WHERE id = ?`,
|
||||
remaining, paymentStatus, completed, entities.OrderStatusCompleted, orderID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update order after payment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -57,12 +57,13 @@ type Router struct {
|
||||
walletAdminHandler *handler.WalletAdminHandler
|
||||
loyaltySettingsHandler *handler.LoyaltySettingsHandler
|
||||
customerPinHandler *handler.CustomerPinHandler
|
||||
pointPaymentHandler *handler.PointPaymentHandler
|
||||
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, 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, redisClient *redis.Client) *Router {
|
||||
|
||||
return &Router{
|
||||
config: cfg,
|
||||
@@ -111,6 +112,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
|
||||
walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator),
|
||||
loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService),
|
||||
customerPinHandler: handler.NewCustomerPinHandler(customerPinService),
|
||||
pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService),
|
||||
redisClient: redisClient,
|
||||
}
|
||||
}
|
||||
@@ -294,6 +296,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
{
|
||||
orders.GET("", r.orderHandler.ListOrders)
|
||||
orders.GET("/:id", r.orderHandler.GetOrderByID)
|
||||
orders.GET("/:id/point-payment/preview", r.pointPaymentHandler.Preview)
|
||||
orders.POST("", r.orderHandler.CreateOrder)
|
||||
orders.POST("/:id/add-items", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.AddToOrder)
|
||||
orders.PUT("/:id", r.orderHandler.UpdateOrder)
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestAllRoutesRegister(t *testing.T) {
|
||||
"PUT /api/v1/marketing/loyalty-settings",
|
||||
"GET /api/v1/marketing/loyalty-settings/history",
|
||||
"POST /api/v1/customer/wallet/payment-code",
|
||||
"GET /api/v1/orders/:id/point-payment/preview",
|
||||
"GET /api/v1/customer/pin/status",
|
||||
"POST /api/v1/customer/pin/otp",
|
||||
"POST /api/v1/customer/pin",
|
||||
|
||||
@@ -557,7 +557,8 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen
|
||||
return fmt.Errorf("payment method ID is required")
|
||||
}
|
||||
|
||||
if req.Amount <= 0 {
|
||||
// A payment with EnakPoint gives points instead; its amount is computed from them.
|
||||
if req.Points == nil && req.Amount <= 0 {
|
||||
return fmt.Errorf("payment amount must be greater than zero")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// PointPaymentService serves what the cashier needs before taking EnakPoint
|
||||
// (docs/prd-point-coin.md F9).
|
||||
type PointPaymentService interface {
|
||||
Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response
|
||||
}
|
||||
|
||||
type PointPaymentServiceImpl struct {
|
||||
payments *processor.PointPaymentProcessor
|
||||
}
|
||||
|
||||
func NewPointPaymentService(payments *processor.PointPaymentProcessor) *PointPaymentServiceImpl {
|
||||
return &PointPaymentServiceImpl{payments: payments}
|
||||
}
|
||||
|
||||
func (s *PointPaymentServiceImpl) Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response {
|
||||
preview, err := s.payments.Preview(ctx, apctx.OrganizationID, orderID)
|
||||
if err != nil {
|
||||
return PointPaymentErrorResponse(err)
|
||||
}
|
||||
return contract.BuildSuccessResponse(preview)
|
||||
}
|
||||
|
||||
// PointPaymentErrorResponse answers a refused EnakPoint payment as a bad request, PIN
|
||||
// problems with their own codes, and anything else as a server error.
|
||||
func PointPaymentErrorResponse(err error) *contract.Response {
|
||||
var pinErr *processor.PinError
|
||||
if errors.As(err, &pinErr) {
|
||||
return PinErrorResponse(err)
|
||||
}
|
||||
code := constants.InternalServerErrorCode
|
||||
switch {
|
||||
case errors.Is(err, repository.ErrPointPaymentOrderNotFound):
|
||||
code = constants.NotFoundErrorCode
|
||||
case errors.Is(err, processor.ErrPointPaymentRejected):
|
||||
code = constants.ValidationErrorCode
|
||||
}
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(code, constants.WalletServiceEntity, err.Error()),
|
||||
})
|
||||
}
|
||||
@@ -324,6 +324,8 @@ func CreatePaymentContractToModel(req *contract.CreatePaymentRequest) *models.Cr
|
||||
return &models.CreatePaymentRequest{
|
||||
OrderID: req.OrderID,
|
||||
PaymentMethodID: req.PaymentMethodID,
|
||||
Points: req.Points,
|
||||
PaymentCode: req.PaymentCode,
|
||||
Amount: req.Amount,
|
||||
TransactionID: req.TransactionID,
|
||||
SplitNumber: req.SplitNumber,
|
||||
@@ -365,6 +367,8 @@ func PaymentModelToContract(resp *models.PaymentResponse) *contract.PaymentRespo
|
||||
SplitType: resp.SplitType,
|
||||
SplitDescription: resp.SplitDescription,
|
||||
RefundAmount: resp.RefundAmount,
|
||||
PointsUsed: resp.PointsUsed,
|
||||
PointValue: resp.PointValue,
|
||||
RefundReason: resp.RefundReason,
|
||||
RefundedAt: resp.RefundedAt,
|
||||
RefundedBy: resp.RefundedBy,
|
||||
|
||||
Reference in New Issue
Block a user