feat(loyalty): exchange EnakCoin into EnakPoint
Adds GET /customer/wallet/exchange/preview?coins= and POST /customer/wallet/exchange (docs/prd-point-coin.md F4, K3, PC-401). The customer exchanges a multiple of the organization's coin_amount and gets (coins / coin_amount) x point_amount EnakPoint, approved by their PIN (K8). A malformed amount is refused before the PIN is checked, so it costs no attempt. In one transaction the wallet is locked, EXCHANGE_OUT takes the EnakCoin in K9 order and EXCHANGE_IN adds the EnakPoint; the two rows share a group, point at each other and both freeze the rate in their metadata. The EnakPoint are split over the EnakCoin lots they came from, each part keeping its lot's expiry and pointing back at it, so exchanging cannot extend a balance's life. The split takes floor(coins so far x rate) per lot, which adds up exactly because the total is a multiple of coin_amount. EnakPoint have no validity of their own until the expiry model is decided (N4), so the EnakCoin lot is for now the only bound. The Idempotency-Key header (or X-Idempotency-Key) is required. A retry with the same key is recognised under the wallet lock and replayed with the ids and rate the first attempt froze, even if the rate has changed since; the same key for another amount is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0b52edf84e
commit
ab3425070b
@@ -0,0 +1,94 @@
|
||||
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/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// fixedOrganizationSettings serves the same organization settings to every caller.
|
||||
type fixedOrganizationSettings struct{ s models.OrganizationLoyaltySettings }
|
||||
|
||||
func (f fixedOrganizationSettings) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
|
||||
s := f.s
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// walletMoveDB opens TEST_DATABASE_URL and creates an organization with two customers,
|
||||
// removed again when the test ends. See internal/repository/wallet_repository_test.go.
|
||||
func walletMoveDB(t *testing.T) (db *gorm.DB, org, a, b uuid.UUID) {
|
||||
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)
|
||||
|
||||
org, a, b = uuid.New(), uuid.New(), uuid.New()
|
||||
phoneA, phoneB := "08"+a.String()[:10], "08"+b.String()[:10]
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet move test', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Anita', ?), (?, ?, 'Budi Santoso', ?)`,
|
||||
a, org, phoneA, b, org, phoneB).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 ? AND origin_lot_id IS NOT NULL`, 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)
|
||||
})
|
||||
return db, org, a, b
|
||||
}
|
||||
|
||||
func TestWalletExchange_AgainstPostgres(t *testing.T) {
|
||||
db, _, a, _ := walletMoveDB(t)
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{
|
||||
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3},
|
||||
}}
|
||||
p := NewWalletExchangeProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db),
|
||||
&movePinFake{good: "482913"}, wallet, txm)
|
||||
|
||||
expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
in := earn(a, 30, &expiry)
|
||||
in.Currency = constants.WalletCurrencyCoin
|
||||
_, err := wallet.Credit(ctx, in)
|
||||
return err
|
||||
}))
|
||||
|
||||
res, err := p.Exchange(context.Background(), a, 20, "482913", "db-key", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(6), res.Points)
|
||||
assert.Equal(t, int64(10), res.CoinBalance)
|
||||
assert.Equal(t, int64(6), res.PointBalance)
|
||||
require.Len(t, res.Lots, 1)
|
||||
require.NotNil(t, res.Lots[0].ExpiresAt)
|
||||
assert.True(t, res.Lots[0].ExpiresAt.Equal(expiry))
|
||||
|
||||
// The retry reads the frozen rate back out of JSONB and replays.
|
||||
again, err := p.Exchange(context.Background(), a, 20, "482913", "db-key", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, again.Replayed)
|
||||
assert.Equal(t, int64(6), again.Points)
|
||||
|
||||
var rows int64
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE group_id = ?`, res.GroupID).Scan(&rows).Error)
|
||||
assert.Equal(t, int64(2), rows)
|
||||
}
|
||||
Reference in New Issue
Block a user