WalletProcessor writes the balance, the ledger row and the lots or allocations together, which keeps SUM(ledger) = balance = SUM(lot remaining) (docs/prd-point-coin.md §7.5, PC-104). - Credit writes the ledger row and creates lots, each with its own expiry and origin lot. - Debit draws from the preferred lots first (a reversal's own lots, or the lot being expired), then from unexpired lots in K9 order, and returns the allocations with their expiry so CarryOver can give the receiving side of a transfer or exchange the same expiry. - DebitUpTo takes what the wallet has and reports the shortfall (F10, Q3). - An idempotency key returns the first result; reusing it for a different operation is an error. - §8.1 is checked in code from one rule table, ahead of the database constraints, so callers get a readable error. Each method locks the wallet itself, after validating the input and before checking the idempotency key, so correctness does not depend on the caller. Operations on two wallets still call LockWallets first to keep lock order. Unit tests run on an in-memory repository and check the §7.5 invariants after every scenario; one more test runs the engine against Postgres when TEST_DATABASE_URL is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
5.5 KiB
Go
143 lines
5.5 KiB
Go
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, pay(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)
|
|
}
|