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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user