package processor import ( "context" "os" "testing" "time" "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" ) // Runs the engine against Postgres, to show the rows it writes pass the database // constraints and reconcile the way §7.5 requires. Needs TEST_DATABASE_URL pointing // at a migrated database; see internal/repository/wallet_repository_test.go. func TestWalletProcessor_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) org, a, b := uuid.New(), uuid.New(), uuid.New() require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet test', 'basic')`, org).Error) require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, a, org, b, org).Error) customers := []uuid.UUID{a, b} 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 customers WHERE id IN ?`, customers) db.Exec(`DELETE FROM organizations WHERE id = ?`, org) }) p := NewWalletProcessor(repository.NewWalletRepository(db)) txm := repository.NewTxManager(db) now := time.Now() inTx := func(fn func(ctx context.Context) error) { t.Helper() require.NoError(t, txm.WithTransaction(context.Background(), fn)) } // Without a transaction nothing moves. _, err = p.Credit(context.Background(), earn(a, 10, nil)) assert.ErrorIs(t, err, repository.ErrWalletTxRequired) var earned *WalletResult inTx(func(ctx context.Context) error { soon := now.Add(time.Hour) earned, err = p.Credit(ctx, earn(a, 100, &soon)) require.NoError(t, err) _, err = p.Credit(ctx, earn(a, 50, nil)) return err }) // Transfer 120 from A to B, spanning both of A's lots. inTx(func(ctx context.Context) error { require.NoError(t, p.LockWallets(ctx, a, b)) group, outID, inID := uuid.New(), uuid.New(), uuid.New() out, err := p.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ TransactionID: outID, CustomerID: a, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeTransferOut, Amount: 120, ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID, GroupID: &group, CounterpartyCustomerID: &b, Description: "Transfer ke B", }}) require.NoError(t, err) _, err = p.Credit(ctx, WalletCreditInput{ WalletEntry: WalletEntry{ TransactionID: inID, CustomerID: b, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeTransferIn, Amount: 120, ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID, GroupID: &group, CounterpartyCustomerID: &a, Description: "Transfer dari A", }, Lots: out.CarryOver(), }) return err }) // Reversing the 100 earned leaves A 30 short. The retry reads the shortfall back // out of JSONB and takes nothing more. rev := reversal(a, 100, earned) rev.IdempotencyKey = "reverse:" + earned.Transaction.ID.String() var first, second *WalletResult inTx(func(ctx context.Context) error { first, err = p.DebitUpTo(ctx, rev) return err }) inTx(func(ctx context.Context) error { second, err = p.DebitUpTo(ctx, rev) return err }) assert.Equal(t, int64(-30), first.Transaction.Amount) assert.Equal(t, int64(70), first.Shortfall) assert.True(t, second.Replayed) assert.Equal(t, int64(70), second.Shortfall) assert.Equal(t, first.Transaction.ID, second.Transaction.ID) // Overdraw fails and rolls back cleanly. err = txm.WithTransaction(context.Background(), func(ctx context.Context) error { _, err := p.Debit(ctx, redeem(b, 121)) return err }) assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance) var balances []struct { CustomerID uuid.UUID PointBalance int64 } require.NoError(t, db.Raw(`SELECT customer_id, point_balance FROM customer_wallets WHERE customer_id IN ?`, customers).Scan(&balances).Error) got := map[uuid.UUID]int64{} for _, row := range balances { got[row.CustomerID] = row.PointBalance } assert.Equal(t, map[uuid.UUID]int64{a: 0, b: 120}, got) // §7.5, straight from the tables. var broken []string require.NoError(t, db.Raw(` SELECT 'wallet ' || w.customer_id FROM customer_wallets w WHERE w.customer_id IN ? AND ( w.point_balance <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions t WHERE t.customer_id = w.customer_id AND t.currency = 'POINT') OR w.point_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'POINT')) UNION ALL SELECT 'lot ' || l.id FROM wallet_lots l WHERE l.customer_id IN ? AND l.original_amount - l.remaining_amount <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.lot_id = l.id) UNION ALL SELECT 'debit ' || t.id FROM wallet_transactions t WHERE t.customer_id IN ? AND t.amount < 0 AND -t.amount <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.transaction_id = t.id)`, customers, customers, customers).Scan(&broken).Error) assert.Empty(t, broken) }