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:
efrilm
2026-09-30 11:46:31 +07:00
co-authored by Claude Opus 5.5
parent 0c4dd72583
commit 4b3beaed41
17 changed files with 1096 additions and 21 deletions
+62 -1
View File
@@ -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 {
+334
View File
@@ -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))
}
}