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>
377 lines
16 KiB
Go
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")
|
|
}
|