207 lines
9.1 KiB
Go
207 lines
9.1 KiB
Go
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
|
|||
|
|
}
|