Adds GET /customer/wallet/transfer/recipient?phone= and
POST /customer/wallet/transfer (docs/prd-point-coin.md F5, Q4, Q16,
PC-402).
The recipient is found by phone number and must be an active customer of
the same organization, not the walk-in customer and not the sender. A
number of another organization answers 404 like an unknown one, so the
check does not reveal who uses the app elsewhere. The recipient check
returns the name and number masked ("Bu*** Sa***", "08**-****-1234").
The organization's transfer settings apply: transfers turned off, the
minimum, the maximum per transaction and the daily limit per currency,
which starts over at midnight WIB. Everything the request alone can get
wrong is refused before the PIN, so it costs no attempt; the PIN then
refuses a transfer held for 24 hours after a PIN reset.
Both wallets are locked in customer_id order, so transfers in opposite
directions cannot deadlock, and the daily limit is summed under the lock.
TRANSFER_OUT takes from the sender's lots in K9 order and TRANSFER_IN
gives the recipient lots with exactly the same expiries, pointing back at
the sender's lots. The rows share a group, reference each other and name
the other customer; descriptions carry only the masked name.
The Idempotency-Key header is required. A retry is recognised under the
lock before the daily limit, so it replays instead of counting twice; the
same key towards another recipient is refused.
The recipient is told by WhatsApp after the commit, as PIN locks are:
NotificationService only reaches staff devices, there is no push channel
to customers yet. A failure to send is logged, never undoes the transfer.
Transfers must not be released before note N3 (legal) is closed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
6.2 KiB
Go
158 lines
6.2 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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/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)
|
|
}
|
|
|
|
// Transfers in both directions at once must not deadlock: both lock the two wallets
|
|
// in customer_id order. Every one of them lands, and the totals still reconcile.
|
|
func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) {
|
|
db, _, a, b := walletMoveDB(t)
|
|
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
|
txm := repository.NewTxManager(db)
|
|
moves := repository.NewWalletMoveRepository(db)
|
|
settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{
|
|
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
|
|
}}
|
|
p := NewWalletTransferProcessor(moves, settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil)
|
|
|
|
expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
|
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
|
if _, err := wallet.Credit(ctx, earn(a, 100, &expiry)); err != nil {
|
|
return err
|
|
}
|
|
_, err := wallet.Credit(ctx, earn(b, 100, nil))
|
|
return err
|
|
}))
|
|
phone := func(id uuid.UUID) string { return "08" + id.String()[:10] }
|
|
|
|
const rounds = 10
|
|
errs := make(chan error, 2*rounds)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < rounds; i++ {
|
|
for _, pair := range [][2]uuid.UUID{{a, b}, {b, a}} {
|
|
wg.Add(1)
|
|
go func(from, to uuid.UUID, i int) {
|
|
defer wg.Done()
|
|
_, err := p.Transfer(context.Background(), from, sendPoints(1, phone(to)), "482913", fmt.Sprintf("race-%d", i), models.CustomerPinRequestInfo{})
|
|
errs <- err
|
|
}(pair[0], pair[1], i)
|
|
}
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
var balances []int64
|
|
require.NoError(t, db.Raw(`SELECT point_balance FROM customer_wallets WHERE customer_id IN ? ORDER BY point_balance`, []uuid.UUID{a, b}).Scan(&balances).Error)
|
|
assert.Equal(t, []int64{100, 100}, balances)
|
|
|
|
// B's lots that came from A keep A's expiry to the second.
|
|
var mismatched int64
|
|
require.NoError(t, db.Raw(`
|
|
SELECT COUNT(*) FROM wallet_lots l JOIN wallet_lots o ON o.id = l.origin_lot_id
|
|
WHERE l.customer_id = ? AND o.customer_id = ? AND l.expires_at IS DISTINCT FROM o.expires_at`, b, a).Scan(&mismatched).Error)
|
|
assert.Zero(t, mismatched)
|
|
|
|
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
|
sent, err := moves.TransferredOutSince(ctx, a, constants.WalletCurrencyPoint, startOfWalletDay(time.Now()))
|
|
assert.Equal(t, int64(rounds), sent)
|
|
return err
|
|
}))
|
|
}
|