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.WalletTxTypeRewardRedeem, Amount: 800, ReferenceType: constants.WalletRefTypeRewardRedemption, 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 }