package processor import ( "context" "os" "sync" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/google/uuid" "github.com/redis/go-redis/v9" "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/appcontext" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) // pointPaymentEnv is an order flow wired as in the app, against Postgres and a // miniredis for payment codes. type pointPaymentEnv struct { t *testing.T db *gorm.DB orders *OrderProcessorImpl payments *PointPaymentProcessor codes *PaymentCodeProcessor org uuid.UUID cashier uuid.UUID outlet uuid.UUID point uuid.UUID cash uuid.UUID walkIn uuid.UUID ctx context.Context } func newPointPaymentEnv(t *testing.T) *pointPaymentEnv { t.Helper() 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) e := &pointPaymentEnv{t: t, db: db, org: uuid.New(), cashier: uuid.New(), outlet: uuid.New(), cash: uuid.New()} e.exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'point pay test', 'basic')`, e.org) e.exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, e.cashier, e.org, e.cashier.String()+"@t") e.exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, e.outlet, e.org) e.exec(`INSERT INTO payment_methods (id, organization_id, name, type) VALUES (?, ?, 'Tunai', 'cash')`, e.cash, e.org) var ids []string require.NoError(t, db.Raw(`SELECT id::text FROM payment_methods WHERE organization_id = ? AND type = 'point'`, e.org).Scan(&ids).Error) require.Len(t, ids, 1) e.point = uuid.MustParse(ids[0]) require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, e.org).Scan(&ids).Error) e.walkIn = uuid.MustParse(ids[0]) t.Cleanup(func() { db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, e.org) db.Exec(`DELETE FROM wallet_lots WHERE organization_id = ?`, e.org) db.Exec(`DELETE FROM wallet_transactions WHERE organization_id = ?`, e.org) db.Exec(`DELETE FROM customer_wallets WHERE organization_id = ?`, e.org) db.Exec(`DELETE FROM payments WHERE order_id IN (SELECT id FROM orders WHERE organization_id = ?)`, e.org) db.Exec(`DELETE FROM orders WHERE organization_id = ?`, e.org) db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.org) db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, e.outlet) db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, e.org) db.Exec(`DELETE FROM customers WHERE organization_id = ?`, e.org) db.Exec(`DELETE FROM outlets WHERE id = ?`, e.outlet) db.Exec(`DELETE FROM users WHERE id = ?`, e.cashier) db.Exec(`DELETE FROM organizations WHERE id = ?`, e.org) }) txm := repository.NewTxManager(db) settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm) wallet := NewWalletProcessor(repository.NewWalletRepository(db)) e.payments = NewPointPaymentProcessor(repository.NewPointPaymentRepository(db), settings, repository.NewWalletQueryRepository(db), wallet, txm) mr := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) t.Cleanup(func() { client.Close() }) e.codes = NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}) e.orders = &OrderProcessorImpl{ orderRepo: repository.NewOrderRepositoryImpl(db), orderItemRepo: repository.NewOrderItemRepositoryImpl(db), paymentRepo: repository.NewPaymentRepositoryImpl(db), paymentMethodRepo: repository.NewPaymentMethodRepositoryImpl(db), splitBillProcessor: nil, txManager: txm, } e.orders.SetLoyalty(NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm)) e.orders.SetPointPayments(e.payments, e.codes, pinVerifierFake{good: "482913"}) // The outlet earns 1 EnakPoint per Rp 100 and accepts EnakPoint. s, err := settings.Outlet(context.Background(), e.outlet) require.NoError(t, err) s.Point.Enabled = true s.PointPayment.AcceptPayment = true _, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s) require.NoError(t, err) e.ctx = context.WithValue(context.Background(), appcontext.UserIDKey, e.cashier.String()) return e } func (e *pointPaymentEnv) exec(q string, args ...any) { e.t.Helper() require.NoError(e.t, e.db.Exec(q, args...).Error) } // customerWith creates a customer holding the given EnakPoint. func (e *pointPaymentEnv) customerWith(points int64) uuid.UUID { e.t.Helper() id := uuid.New() e.exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, id, e.org) if points > 0 { require.NoError(e.t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error { _, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{ CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: points, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}}) return err })) } return id } func (e *pointPaymentEnv) order(customer uuid.UUID, subtotal float64) uuid.UUID { e.t.Helper() id := uuid.New() e.exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type, subtotal, tax_amount, total_amount, remaining_amount, payment_status) VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, 0, ?, ?, 'pending')`, id, e.org, e.outlet, e.cashier, customer, "PP-"+id.String()[:8], subtotal, subtotal, subtotal) return id } func (e *pointPaymentEnv) code(customer uuid.UUID) string { e.t.Helper() c, err := e.codes.Issue(context.Background(), customer, "482913", models.CustomerPinRequestInfo{}) require.NoError(e.t, err) return c.Code } func (e *pointPaymentEnv) payPoints(order uuid.UUID, points int64, code string) (*models.PaymentResponse, error) { return e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &points, PaymentCode: &code}) } func (e *pointPaymentEnv) balance(customer uuid.UUID) int64 { e.t.Helper() var b int64 require.NoError(e.t, e.db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error) return b } func (e *pointPaymentEnv) orderState(order uuid.UUID) (status string, remaining float64) { e.t.Helper() var row struct { PaymentStatus string RemainingAmount float64 } require.NoError(e.t, e.db.Raw(`SELECT payment_status, remaining_amount FROM orders WHERE id = ?`, order).Scan(&row).Error) return row.PaymentStatus, row.RemainingAmount } func TestPointPayment_FullPayment(t *testing.T) { e := newPointPaymentEnv(t) customer := e.customerWith(100000) order := e.order(customer, 50000) payment, err := e.payPoints(order, 50000, e.code(customer)) require.NoError(t, err) assert.Equal(t, 50000.0, payment.Amount) require.NotNil(t, payment.PointsUsed) assert.Equal(t, int64(50000), *payment.PointsUsed) assert.Equal(t, 1.0, *payment.PointValue, "the value is frozen on the payment") status, remaining := e.orderState(order) assert.Equal(t, "completed", status) assert.Zero(t, remaining) assert.Equal(t, int64(50000), e.balance(customer)) var ledger struct { Amount int64 ReferenceType string ReferenceID string OutletID string CreatedByUser string } require.NoError(t, e.db.Raw(`SELECT amount, reference_type, reference_id::text AS reference_id, outlet_id::text AS outlet_id, created_by_user::text AS created_by_user FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT'`, customer).Scan(&ledger).Error) assert.Equal(t, int64(-50000), ledger.Amount) assert.Equal(t, "PAYMENT", ledger.ReferenceType) assert.Equal(t, payment.ID.String(), ledger.ReferenceID) assert.Equal(t, e.outlet.String(), ledger.OutletID) assert.Equal(t, e.cashier.String(), ledger.CreatedByUser, "the cashier who took it") // Paid entirely with EnakPoint, so nothing earns (Q10). var earned int64 require.NoError(t, e.db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error) assert.Zero(t, earned) } func TestPointPayment_PartialThenCash(t *testing.T) { e := newPointPaymentEnv(t) customer := e.customerWith(100000) order := e.order(customer, 87500) _, err := e.payPoints(order, 20000, e.code(customer)) require.NoError(t, err) status, remaining := e.orderState(order) assert.Equal(t, "partial", status) assert.Equal(t, 67500.0, remaining) // The rest in cash settles it; earning counts only the part not paid with // EnakPoint: floor(67.500 / 100) = 675. _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500}) require.NoError(t, err) status, _ = e.orderState(order) assert.Equal(t, "completed", status) var earned int64 require.NoError(t, e.db.Raw(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error) assert.Equal(t, int64(675), earned) assert.Equal(t, int64(100000-20000+675), e.balance(customer)) } func TestPointPayment_PercentCap(t *testing.T) { e := newPointPaymentEnv(t) settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db)) s, err := settings.Outlet(context.Background(), e.outlet) require.NoError(t, err) s.PointPayment.MaxPaymentPercent = 50 _, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s) require.NoError(t, err) customer := e.customerWith(100000) order := e.order(customer, 100000) preview, err := e.payments.Preview(context.Background(), e.org, order) require.NoError(t, err) assert.True(t, preview.Eligible) assert.Equal(t, int64(50000), preview.MaxPoints) assert.Equal(t, int64(100000), preview.PointBalance) _, err = e.payPoints(order, 50001, e.code(customer)) assert.ErrorIs(t, err, ErrPointPaymentRejected) _, err = e.payPoints(order, 30000, e.code(customer)) require.NoError(t, err) _, err = e.payPoints(order, 20001, e.code(customer)) assert.ErrorIs(t, err, ErrPointPaymentRejected, "earlier EnakPoint counts toward the cap") _, err = e.payPoints(order, 20000, e.code(customer)) require.NoError(t, err) assert.Equal(t, int64(50000), e.balance(customer)) } func TestPointPayment_Refusals(t *testing.T) { e := newPointPaymentEnv(t) customer := e.customerWith(100000) other := e.customerWith(100000) // A walk-in order cannot be paid with EnakPoint. walkInOrder := e.order(e.walkIn, 10000) preview, err := e.payments.Preview(context.Background(), e.org, walkInOrder) require.NoError(t, err) assert.False(t, preview.Eligible) assert.Contains(t, preview.Reason, "walk-in") _, err = e.payPoints(walkInOrder, 1000, "123456") assert.ErrorIs(t, err, ErrPointPaymentRejected) order := e.order(customer, 10000) _, err = e.payPoints(order, 1000, "000000") assert.ErrorIs(t, err, ErrPointPaymentRejected, "a wrong code") _, err = e.payPoints(order, 1000, e.code(other)) assert.ErrorIs(t, err, ErrPointPaymentRejected, "another customer's code") missing := int64(1000) _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &missing}) assert.ErrorIs(t, err, ErrPointPaymentRejected, "no code at all") code := e.code(customer) _, err = e.payPoints(order, 1000, code) require.NoError(t, err) _, err = e.payPoints(order, 1000, code) assert.ErrorIs(t, err, ErrPointPaymentRejected, "a code is used once, so a double tap takes once") _, err = e.payPoints(order, 9001, e.code(customer)) assert.ErrorIs(t, err, ErrPointPaymentRejected, "no change is given: not more than what is left") // Splitting with the EnakPoint method would skip the balance, so it is refused. e.orders.splitBillProcessor = splitFake{} _, err = e.orders.SplitBill(e.ctx, &models.SplitBillRequest{OrderID: order, PaymentMethodID: e.point, Type: "AMOUNT", Amount: 1000}) assert.ErrorIs(t, err, ErrPointPaymentRejected) assert.Equal(t, int64(99000), e.balance(customer), "only the one payment took anything") assert.Equal(t, int64(100000), e.balance(other)) } // Two payments for the same customer at once, on two orders: the balance is taken // once, never twice. Authorization is taken as given so only the balance decides. func TestPointPayment_ConcurrentForOneCustomer(t *testing.T) { e := newPointPaymentEnv(t) customer := e.customerWith(30000) orders := []uuid.UUID{e.order(customer, 20000), e.order(customer, 20000)} var wg sync.WaitGroup results := make([]error, len(orders)) for i, order := range orders { wg.Add(1) go func(i int, order uuid.UUID) { defer wg.Done() _, results[i] = e.payments.Pay(e.ctx, PointPaymentInput{ OrderID: order, PaymentMethodID: e.point, Points: 20000, Authorize: func(context.Context, uuid.UUID) error { return nil }, }) }(i, order) } wg.Wait() succeeded := 0 for _, err := range results { if err == nil { succeeded++ } else { assert.ErrorIs(t, err, ErrPointPaymentRejected) } } assert.Equal(t, 1, succeeded, "30.000 EnakPoint pays one 20.000 order, not two") assert.Equal(t, int64(10000), e.balance(customer)) // And the wallet still reconciles. found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000) require.NoError(t, err) for _, d := range found { assert.NotEqual(t, customer, d.CustomerID, d.Check) } } func TestPointPayment_InApp(t *testing.T) { e := newPointPaymentEnv(t) owner := e.customerWith(100000) stranger := e.customerWith(100000) order := e.order(owner, 60000) info := models.CustomerPinRequestInfo{} // Another customer cannot pay it, and is not told it exists. _, err := e.orders.PayWithPointsInApp(e.ctx, stranger, order, 1000, "482913", info) assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound) _, err = e.orders.PayWithPointsInApp(e.ctx, owner, uuid.New(), 1000, "482913", info) assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound) // The session alone is not enough: a wrong PIN takes nothing. _, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 1000, "000000", info) var pe *PinError require.ErrorAs(t, err, &pe) assert.Equal(t, PinErrInvalid, pe.Code) assert.Equal(t, int64(100000), e.balance(owner)) // The owner pays part, then the rest, with the same rules as at the cashier. payment, err := e.orders.PayWithPointsInApp(e.ctx, owner, order, 10000, "482913", info) require.NoError(t, err) assert.Equal(t, int64(10000), *payment.PointsUsed) status, remaining := e.orderState(order) assert.Equal(t, "partial", status) assert.Equal(t, 50000.0, remaining) _, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50001, "482913", info) assert.ErrorIs(t, err, ErrPointPaymentRejected, "not more than what is left") _, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50000, "482913", info) require.NoError(t, err) status, _ = e.orderState(order) assert.Equal(t, "completed", status) assert.Equal(t, int64(40000), e.balance(owner)) assert.Equal(t, int64(100000), e.balance(stranger)) var createdBy *string require.NoError(t, e.db.Raw(`SELECT created_by_user::text FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT' LIMIT 1`, owner).Scan(&createdBy).Error) assert.Nil(t, createdBy, "no cashier took an in-app payment") } // The payment method report counts only money actually received as money in; the // EnakPoint part is listed apart (F9). func TestPointPayment_ReportKeepsEnakPointOutOfCashIn(t *testing.T) { e := newPointPaymentEnv(t) customer := e.customerWith(100000) order := e.order(customer, 87500) _, err := e.payPoints(order, 20000, e.code(customer)) require.NoError(t, err) _, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500}) require.NoError(t, err) report, err := NewAnalyticsProcessorImpl(repository.NewAnalyticsRepositoryImpl(e.db), nil).GetPaymentMethodAnalytics(context.Background(), &models.PaymentMethodAnalyticsRequest{OrganizationID: e.org, DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now().Add(time.Hour)}) require.NoError(t, err) assert.Equal(t, 67500.0, report.Summary.TotalAmount, "money in is the cash, not the order total") assert.Equal(t, 20000.0, report.Summary.PointAmount) assert.Equal(t, int64(20000), report.Summary.PointsUsed) assert.Equal(t, 87500.0, report.Summary.TotalWithPoints) require.Len(t, report.Data, 2) for _, d := range report.Data { assert.Equal(t, d.PaymentMethodType != "point", d.CountsAsCashIn, d.PaymentMethodName) } }