Files
apskel-pos-backend/internal/processor/point_payment_db_test.go
T
efrilmandClaude Opus 5.5 43eac0ced4 feat(loyalty): pay own orders with EnakPoint from the app
Adds POST /customer/orders/:id/pay-with-points (docs/prd-point-coin.md F9,
PC-306) for the customer app and self-order. It uses the same payment path
as the cashier, approved by the customer's PIN instead of a code: the
session alone is not enough (K8), and a wrong PIN takes nothing and counts
toward the lock.

A customer can pay only their own order; any other order, and one that
does not exist, answer 404 alike, so the endpoint does not reveal other
customers' orders. The method is the organization's EnakPoint method, no
cashier is recorded, and settling the order triggers earning through the
same onOrderPaid hook as every other payment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:49:49 +07:00

377 lines
16 KiB
Go

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, pinVerifierFake{good: "482913"})
// 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)
}
}
func TestPointPayment_InApp(t *testing.T) {
e := newPointPaymentEnv(t)
owner := e.customerWith(100000)
stranger := e.customerWith(100000)
order := e.order(owner, 60000)
info := models.CustomerPinRequestInfo{}
// Another customer cannot pay it, and is not told it exists.
_, err := e.orders.PayWithPointsInApp(e.ctx, stranger, order, 1000, "482913", info)
assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound)
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, uuid.New(), 1000, "482913", info)
assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound)
// The session alone is not enough: a wrong PIN takes nothing.
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 1000, "000000", info)
var pe *PinError
require.ErrorAs(t, err, &pe)
assert.Equal(t, PinErrInvalid, pe.Code)
assert.Equal(t, int64(100000), e.balance(owner))
// The owner pays part, then the rest, with the same rules as at the cashier.
payment, err := e.orders.PayWithPointsInApp(e.ctx, owner, order, 10000, "482913", info)
require.NoError(t, err)
assert.Equal(t, int64(10000), *payment.PointsUsed)
status, remaining := e.orderState(order)
assert.Equal(t, "partial", status)
assert.Equal(t, 50000.0, remaining)
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50001, "482913", info)
assert.ErrorIs(t, err, ErrPointPaymentRejected, "not more than what is left")
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50000, "482913", info)
require.NoError(t, err)
status, _ = e.orderState(order)
assert.Equal(t, "completed", status)
assert.Equal(t, int64(40000), e.balance(owner))
assert.Equal(t, int64(100000), e.balance(stranger))
var createdBy *string
require.NoError(t, e.db.Raw(`SELECT created_by_user::text FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT' LIMIT 1`, owner).Scan(&createdBy).Error)
assert.Nil(t, createdBy, "no cashier took an in-app payment")
}