package processor import ( "context" "errors" "os" "sync" "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/models" "apskel-pos-be/internal/repository" ) // failingSettings fails for the outlet settings until healed, to stand in for the // database being unreachable right after a payment. type failingSettings struct { real earningSettingsReader mu sync.Mutex fail bool } func (f *failingSettings) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) { f.mu.Lock() fail := f.fail f.mu.Unlock() if fail { return nil, errors.New("connection refused") } return f.real.Outlet(ctx, outletID) } // Needs TEST_DATABASE_URL pointing at a migrated database; see // internal/repository/wallet_repository_test.go. func TestEarningProcessor_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 := uuid.New(), uuid.New() earningOutlet, quietOutlet := uuid.New(), uuid.New() regular, inactive := uuid.New(), uuid.New() var walkIn uuid.UUID var customers []uuid.UUID 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 (?, 'earning 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'), (?, ?, 'Tanpa Poin')`, earningOutlet, org, quietOutlet, org) exec(`INSERT INTO customers (id, organization_id, name, is_default, is_active) VALUES (?, ?, 'Budi', false, true), (?, ?, 'Nonaktif', false, false)`, regular, org, inactive, org) // Creating the organization created its walk-in customer (trigger_create_default_customer). var walkInID string require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, org).Scan(&walkInID).Error) walkIn = uuid.MustParse(walkInID) customers = []uuid.UUID{regular, walkIn, inactive} 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 IN ?`, []uuid.UUID{earningOutlet, quietOutlet}) db.Exec(`DELETE FROM customers WHERE id IN ?`, customers) db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{earningOutlet, quietOutlet}) db.Exec(`DELETE FROM users WHERE id = ?`, user) db.Exec(`DELETE FROM organizations WHERE id = ?`, org) }) txm := repository.NewTxManager(db) settingsProcessor := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) outletSettings, err := settingsProcessor.Outlet(ctx, earningOutlet) require.NoError(t, err) outletSettings.Point.Enabled = true outletSettings.Coin.Enabled = true _, err = settingsProcessor.UpdateOutlet(ctx, org, earningOutlet, user, *outletSettings) require.NoError(t, err) settings := &failingSettings{real: settingsProcessor} earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, NewWalletProcessor(repository.NewWalletRepository(db)), txm) orderNo := 0 newOrder := func(outlet uuid.UUID, customer *uuid.UUID, paymentStatus string, isVoid bool, subtotal, discount float64) uuid.UUID { t.Helper() orderNo++ 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, is_void) VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, ?, 0, ?, ?, ?)`, id, org, outlet, user, customer, id.String()[:8]+"-"+string(rune('A'+orderNo)), subtotal, discount, subtotal-discount, paymentStatus, isVoid) return id } earnRows := func(orderID uuid.UUID) map[string]int64 { t.Helper() var rows []struct { Currency string Amount int64 } require.NoError(t, db.Raw(`SELECT currency, amount FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, orderID).Scan(&rows).Error) out := map[string]int64{} for _, r := range rows { out[r.Currency] += r.Amount } return out } // Paid in full: the PRD example, 875 EnakPoint and 3 EnakCoin. paid := newOrder(earningOutlet, ®ular, "completed", false, 97500, 10000) earning.OnOrderPaid(ctx, paid) assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid)) var row struct { Description string Metadata string OutletID string } require.NoError(t, db.Raw(`SELECT description, metadata::text AS metadata, outlet_id::text AS outlet_id FROM wallet_transactions WHERE reference_id = ? AND currency = 'POINT'`, paid).Scan(&row).Error) assert.Contains(t, row.Description, "di Kemang") assert.Contains(t, row.Metadata, `"earn_per_amount": 100`, "the settings used are frozen on the row") assert.Contains(t, row.Metadata, `"basis": 87500`) assert.Equal(t, earningOutlet.String(), row.OutletID) // Called again, and five times at once, it is still one earning. var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done(); earning.OnOrderPaid(ctx, paid) }() } wg.Wait() outcome, err := earning.EarnForOrder(ctx, paid) require.NoError(t, err) assert.Equal(t, int64(875), outcome.Points, "a repeat reports the first earning") assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid)) // A self-order goes through the same payment path and the same rules. selfOrder := newOrder(earningOutlet, ®ular, "completed", false, 25000, 0) earning.OnOrderPaid(ctx, selfOrder) assert.Equal(t, map[string]int64{"POINT": 250, "COIN": 1}, earnRows(selfOrder)) // A split bill earns once, on the payment that settles it: while partial, nothing. split := newOrder(earningOutlet, ®ular, "partial", false, 60000, 0) earning.OnOrderPaid(ctx, split) assert.Empty(t, earnRows(split)) exec(`UPDATE orders SET payment_status = 'completed' WHERE id = ?`, split) earning.OnOrderPaid(ctx, split) assert.Equal(t, map[string]int64{"POINT": 600, "COIN": 2}, earnRows(split)) // Orders that must not earn. for name, c := range map[string]struct { id uuid.UUID skip string }{ "walk-in customer": {newOrder(earningOutlet, &walkIn, "completed", false, 50000, 0), EarningSkipDefaultCustomer}, "inactive customer": {newOrder(earningOutlet, &inactive, "completed", false, 50000, 0), EarningSkipInactiveCustomer}, "no customer": {newOrder(earningOutlet, nil, "completed", false, 50000, 0), EarningSkipNoCustomer}, "void": {newOrder(earningOutlet, ®ular, "completed", true, 50000, 0), EarningSkipVoid}, "unpaid": {newOrder(earningOutlet, ®ular, "pending", false, 50000, 0), EarningSkipNotPaid}, "outlet not earning": {newOrder(quietOutlet, ®ular, "completed", false, 50000, 0), EarningSkipNothingToEarn}, } { outcome, err := earning.EarnForOrder(ctx, c.id) require.NoError(t, err, name) assert.Equal(t, c.skip, outcome.Skipped, name) assert.Empty(t, earnRows(c.id), name) } // An earning that fails does not surface to the payment, and the job picks it up. settings.mu.Lock() settings.fail = true settings.mu.Unlock() missed := newOrder(earningOutlet, ®ular, "completed", false, 40000, 0) assert.NotPanics(t, func() { earning.OnOrderPaid(ctx, missed) }) assert.Empty(t, earnRows(missed)) settings.mu.Lock() settings.fail = false settings.mu.Unlock() since := time.Now().Add(-time.Hour) checked, earned, err := earning.EarnMissing(ctx, since, 1000) require.NoError(t, err) assert.GreaterOrEqual(t, earned, 1) assert.GreaterOrEqual(t, checked, earned) assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed)) // The job only looks at orders that could earn and have not. candidates, err := repository.NewEarningRepository(db).ListPaidOrdersWithoutEarning(ctx, since, nil, 1000) require.NoError(t, err) var ours []uuid.UUID for _, c := range candidates { var n int64 db.Raw(`SELECT COUNT(*) FROM orders WHERE id = ? AND organization_id = ?`, c.ID, org).Scan(&n) if n > 0 { ours = append(ours, c.ID) } } assert.Empty(t, ours, "every eligible order of ours has earned; walk-in, inactive, void, unpaid and non-earning outlets are never candidates") // A second run finds nothing more to do for these orders. _, _, err = earning.EarnMissing(ctx, since, 1000) require.NoError(t, err) assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed)) // What the order screen and receipt show is what the ledger has. shown, err := earning.EarnedByOrders(ctx, []uuid.UUID{paid, split, missed, uuid.New()}) require.NoError(t, err) assert.Equal(t, OrderEarned{Points: 875, Coins: 3}, shown[paid]) assert.Equal(t, OrderEarned{Points: 600, Coins: 2}, shown[split]) assert.Equal(t, OrderEarned{Points: 400, Coins: 1}, shown[missed]) assert.Len(t, shown, 3, "an order without earning is absent, so it shows 0") balance := struct{ PointBalance, CoinBalance int64 }{} require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, regular).Scan(&balance).Error) assert.Equal(t, int64(875+250+600+400), balance.PointBalance) assert.Equal(t, int64(3+1+2+1), balance.CoinBalance) } func (f *failingSettings) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) { return f.real.Organization(ctx, organizationID) }