2026-09-30 11:46:31 +07:00
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 ))
2026-09-30 11:49:49 +07:00
e . orders . SetPointPayments ( e . payments , e . codes , pinVerifierFake { good : "482913" })
2026-09-30 11:46:31 +07:00
// 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 )
}
}
2026-09-30 11:49:49 +07:00
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" )
}