2026-09-30 15:31:44 +07:00
package processor
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"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/constants"
"apskel-pos-be/internal/repository"
)
// Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestEarningReversal_AgainstPostgres ( t * testing . T ) {
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 )
ctx := context . Background ()
org , user , outlet := uuid . New (), uuid . New (), uuid . New ()
voider , refunder , spender , loyal := uuid . New (), uuid . New (), uuid . New (), uuid . New ()
customers := [] uuid . UUID { voider , refunder , spender , loyal }
exec := func ( q string , args ... any ) {
t . Helper ()
require . NoError ( t , db . Exec ( q , args ... ). Error )
}
exec ( `INSERT INTO organizations (id, name, plan_type) VALUES (?, 'reversal test', 'basic')` , org )
exec ( `INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')` , user , org , user . String () + "@test" )
exec ( `INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')` , outlet , org )
for _ , c := range customers {
exec ( `INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')` , c , org )
}
t . Cleanup ( func () {
db . Exec ( `DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)` , customers )
db . Exec ( `DELETE FROM wallet_lots WHERE customer_id IN ?` , customers )
db . Exec ( `DELETE FROM wallet_transactions WHERE customer_id IN ?` , customers )
db . Exec ( `DELETE FROM customer_wallets WHERE customer_id IN ?` , customers )
db . Exec ( `DELETE FROM orders WHERE organization_id = ?` , org )
db . Exec ( `DELETE FROM loyalty_setting_changes WHERE organization_id = ?` , org )
db . Exec ( `DELETE FROM outlet_settings WHERE outlet_id = ?` , outlet )
db . Exec ( `DELETE FROM customers WHERE id IN ?` , customers )
db . Exec ( `DELETE FROM outlets WHERE id = ?` , outlet )
db . Exec ( `DELETE FROM users WHERE id = ?` , user )
db . Exec ( `DELETE FROM organizations WHERE id = ?` , org )
})
txm := repository . NewTxManager ( db )
settings := NewLoyaltySettingsProcessor ( repository . NewLoyaltySettingsRepository ( db ), txm )
s , err := settings . Outlet ( ctx , outlet )
require . NoError ( t , err )
s . Point . Enabled , s . Coin . Enabled = true , true
_ , err = settings . UpdateOutlet ( ctx , org , outlet , user , * s )
require . NoError ( t , err )
wallet := NewWalletProcessor ( repository . NewWalletRepository ( db ))
earning := NewEarningProcessor ( repository . NewEarningRepository ( db ), settings , wallet , txm )
// Each order is the PRD example: basis Rp 87.500, 875 EnakPoint and 3 EnakCoin.
paidOrder := func ( customer uuid . UUID ) uuid . UUID {
t . Helper ()
id := uuid . New ()
exec ( `INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
subtotal, discount_amount, tax_amount, total_amount, payment_status)
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', 97500, 10000, 8750, 96250, 'completed')` ,
id , org , outlet , user , customer , "ORD-" + id . String ()[: 8 ])
outcome , err := earning . EarnForOrder ( ctx , id )
require . NoError ( t , err )
require . Equal ( t , int64 ( 875 ), outcome . Points )
return id
}
balance := func ( customer uuid . UUID ) ( points , coins int64 ) {
t . Helper ()
var b struct { PointBalance , CoinBalance int64 }
require . NoError ( t , db . Raw ( `SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?` , customer ). Scan ( & b ). Error )
return b . PointBalance , b . CoinBalance
}
reversed := func ( order uuid . UUID , currency string ) int64 {
t . Helper ()
var total int64
require . NoError ( t , db . Raw ( `SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions
WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = ?` , order , currency ). Scan ( & total ). Error )
return total
}
// Void: everything comes back.
voided := paidOrder ( voider )
exec ( `UPDATE orders SET is_void = true WHERE id = ?` , voided )
earning . OnOrderRefunded ( ctx , voided )
assert . Equal ( t , int64 ( 875 ), reversed ( voided , "POINT" ))
assert . Equal ( t , int64 ( 3 ), reversed ( voided , "COIN" ))
p , c := balance ( voider )
assert . Equal ( t , [ 2 ] int64 { 0 , 0 }, [ 2 ] int64 { p , c })
var rev struct {
ReversesTransactionID string
Description string
}
require . NoError ( t , db . Raw ( `SELECT reverses_transaction_id::text AS reverses_transaction_id, description FROM wallet_transactions
WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'` , voided ). Scan ( & rev ). Error )
var earnID string
require . NoError ( t , db . Raw ( `SELECT id::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN' AND currency = 'POINT'` , voided ). Scan ( & earnID ). Error )
assert . Equal ( t , earnID , rev . ReversesTransactionID , "the reversal points at the EARN it reverses" )
assert . Contains ( t , rev . Description , "Batal #ORD-" )
// Repeating it takes nothing more.
earning . OnOrderRefunded ( ctx , voided )
assert . Equal ( t , int64 ( 875 ), reversed ( voided , "POINT" ))
// Partial refunds are proportional and add up to at most what was earned.
refunded := paidOrder ( refunder )
for _ , step := range [] struct {
refundAmount float64
wantTotal int64
}{
{ 20000 , 200 }, // floor(875 ร 20000 / 87500)
{ 20000 , 200 }, // same refund again: nothing more
{ 50000 , 500 }, // cumulative refund grew
{ 96250 , 875 }, // the whole total, tax included, cannot exceed the earning
{ 96250 , 875 },
} {
exec ( `UPDATE orders SET refund_amount = ? WHERE id = ?` , step . refundAmount , refunded )
outcome , err := earning . ReverseForOrder ( ctx , refunded )
require . NoError ( t , err )
assert . Zero ( t , outcome . PointShortfall )
assert . Equal ( t , step . wantTotal , reversed ( refunded , "POINT" ), "after a refund of %.0f" , step . refundAmount )
}
assert . Equal ( t , int64 ( 3 ), reversed ( refunded , "COIN" ))
// Balance already spent: what is left is taken, the rest recorded as shortfall,
// and the refund is not blocked.
spent := paidOrder ( spender )
committed ( t , txm , func ( ctx context . Context ) ( * WalletResult , error ) {
return wallet . Debit ( ctx , WalletDebitInput { WalletEntry : WalletEntry {
2026-10-07 13:48:29 +07:00
CustomerID : spender , Currency : constants . WalletCurrencyPoint , Type : constants . WalletTxTypeRewardRedeem ,
Amount : 800 , ReferenceType : constants . WalletRefTypeRewardRedemption , ReferenceID : uuid . New (), OutletID : & outlet ,
2026-09-30 15:31:44 +07:00
Description : "Bayar" }})
})
exec ( `UPDATE orders SET is_void = true WHERE id = ?` , spent )
outcome , err := earning . ReverseForOrder ( ctx , spent )
require . NoError ( t , err )
assert . Equal ( t , int64 ( 75 ), outcome . Points )
assert . Equal ( t , int64 ( 800 ), outcome . PointShortfall )
p , _ = balance ( spender )
assert . Equal ( t , int64 ( 0 ), p , "never negative" )
var metadata string
require . NoError ( t , db . Raw ( `SELECT metadata::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'` , spent ). Scan ( & metadata ). Error )
assert . Contains ( t , metadata , `"shortfall": 800` )
assert . Contains ( t , metadata , `"requested_amount": 875` )
// Asked back in full already, shortfall included: a repeat takes nothing.
outcome , err = earning . ReverseForOrder ( ctx , spent )
require . NoError ( t , err )
assert . Zero ( t , outcome . Points )
// The EARN's own lots are drawn first, even when an older lot would come first.
older := committed ( t , txm , func ( ctx context . Context ) ( * WalletResult , error ) {
return wallet . Credit ( ctx , WalletCreditInput { WalletEntry : WalletEntry {
CustomerID : loyal , Currency : constants . WalletCurrencyPoint , Type : constants . WalletTxTypeMigration ,
Amount : 500 , ReferenceType : constants . WalletRefTypeLegacyPoints , ReferenceID : uuid . New (), Description : "Saldo awal" }})
})
loyalOrder := paidOrder ( loyal )
exec ( `UPDATE orders SET refund_amount = 20000 WHERE id = ?` , loyalOrder )
_ , err = earning . ReverseForOrder ( ctx , loyalOrder )
require . NoError ( t , err )
var fromOlder int64
require . NoError ( t , db . Raw ( `SELECT COALESCE(SUM(a.amount), 0) FROM wallet_lot_allocations a
JOIN wallet_transactions t ON t.id = a.transaction_id
WHERE t.reference_id = ? AND t.type = 'EARN_REVERSAL' AND a.lot_id = ?` , loyalOrder , older . Lots [ 0 ]. ID ). Scan ( & fromOlder ). Error )
assert . Zero ( t , fromOlder , "the older lot is untouched" )
assert . Equal ( t , int64 ( 200 ), reversed ( loyalOrder , "POINT" ))
// An order that never earned has nothing to reverse.
outcome , err = earning . ReverseForOrder ( ctx , uuid . New ())
assert . ErrorIs ( t , err , repository . ErrEarningOrderNotFound )
assert . Nil ( t , outcome )
// Every reversal kept balances, ledger and lots in step (ยง7.5).
found , err := repository . NewWalletReconciliationRepository ( db ). FindDiscrepancies ( ctx , 1000 )
require . NoError ( t , err )
for _ , d := range found {
for _ , c := range customers {
assert . NotEqual ( t , c , d . CustomerID , "discrepancy %s for one of our customers" , d . Check )
}
}
}
// committed runs fn in a transaction that commits before it returns, for engine calls
// made directly from a test.
func committed [ T any ]( t * testing . T , txm * repository . TxManager , fn func ( ctx context . Context ) ( T , error )) T {
t . Helper ()
var out T
require . NoError ( t , txm . WithTransaction ( context . Background (), func ( ctx context . Context ) error {
var err error
out , err = fn ( ctx )
return err
}))
return out
}