Files
apskel-pos-backend/internal/processor/earning_reversal_db_test.go
T
efrilmandClaude Opus 5.5 2c9753fae7 feat(loyalty): remove paying with EnakPoint
EnakPoint can only be redeemed for vouchers now: it can no longer pay for
orders and is never cashed out (docs/enakgame-prd.md §3.2, EG-001,
EG-002). No order was ever paid with EnakPoint, so there is no data to
move.

Removed:
- POST /customer/wallet/payment-code, POST /customer/orders/:id/pay-with-points
  and GET /orders/:id/point-payment/preview, with their processors,
  repositories, services, handlers and tests.
- The point payment method type: paying, splitting and refunding with it,
  the outlet filter on the method list, and the system-method guard.
- points and payment_code on CreatePayment; points_used and point_value
  on payments; accepts_point_payment on the customer outlets.
- The outlet point_payment settings. A PUT that still sends them is
  rejected as an unknown field.
- The EnakPoint split in the payment method analytics.
- PAYMENT and PAYMENT_REFUND from the wallet type rules. Tests that used
  them as a generic EnakPoint debit use REWARD_REDEEM.
- The EnakPoint-paid part from the earning basis, which is
  subtotal − discount again.

Migration 000102 drops the trigger, the point methods and their index,
the payments columns, and the outlet settings, and restores the method
type CHECK without point. payments.payment_method_id is ON DELETE
RESTRICT, so it fails rather than lose a payment made with EnakPoint.

The integration docs list the removed endpoints and fields, and the
EnakPoint & EnakCoin PRD and tasks note what is superseded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:48:29 +07:00

207 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}