Files
apskel-pos-backend/internal/processor/earning_reversal_db_test.go
T

207 lines
9.1 KiB
Go
Raw Normal View History

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{
CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
Amount: 800, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet,
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
}