Adds LoyaltySettingsProcessor (docs/prd-point-coin.md F1, F2, F12, PC-109). Reading returns typed settings for an outlet (earning per currency, paying with EnakPoint) and for an organization (point value, exchange rate, transfers, and the expiry settings awaiting note N4). A key that was never set takes the PRD default. A stored value that is unusable, such as an earn_per_amount of 0 that would divide by zero, also falls back to the default and is logged, so a bad row never reaches a calculation. Writing takes the whole settings struct, validates every rule in the PRD before touching the database, and stores and records in loyalty_setting_changes only the keys whose effective value changes: old value (NULL while it was on its default), new value, and who changed it. Clearing a limit deletes the stored value. Each save runs in one transaction under an advisory lock per outlet or organization, so two saves at once cannot both compute their change from the same old value. The outlet must belong to the caller's organization. Every key is described once (key, default, valid range, bound field), and reading, validating and diffing all use that description. GET /customer/wallet now reads the point value through this processor; the minimal organization settings repository from PC-106 is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
9.8 KiB
Go
219 lines
9.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"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"
|
|
applogger "apskel-pos-be/internal/logger"
|
|
"apskel-pos-be/internal/processor"
|
|
"apskel-pos-be/internal/repository"
|
|
"apskel-pos-be/internal/service"
|
|
)
|
|
|
|
// Drives the customer wallet endpoints over HTTP down to Postgres, to show the app
|
|
// gets the right numbers from the new endpoints and from the old /points, /tokens
|
|
// and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see
|
|
// internal/repository/wallet_repository_test.go.
|
|
func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL not set")
|
|
}
|
|
applogger.Setup("fatal", "json") // the handlers log their errors; the server sets this up at startup
|
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
require.NoError(t, err)
|
|
|
|
org, customer, other := uuid.New(), uuid.New(), uuid.New()
|
|
customers := []uuid.UUID{customer, other}
|
|
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 (?, 'wallet api test', 'basic')`, org)
|
|
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, customer, org, other, org)
|
|
exec(`INSERT INTO organization_settings (organization_id, key, value) VALUES (?, ?, '100')`, org, constants.LoyaltyPointValueKey)
|
|
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 organization_settings WHERE organization_id = ?`, org)
|
|
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
|
})
|
|
|
|
// Balance: 300 earned in two lots that expire on the same Jakarta day but on
|
|
// different UTC days, 8 coins, 50 paid, and 20 in a lot that has expired but
|
|
// that the expiry job has not processed.
|
|
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
|
txm := repository.NewTxManager(db)
|
|
next := time.Now().Year() + 1
|
|
lateNightUTC := time.Date(next, 7, 1, 17, 30, 0, 0, time.UTC) // 2 July 00:30 WIB
|
|
afternoonUTC := time.Date(next, 7, 2, 10, 0, 0, 0, time.UTC) // 2 July 17:00 WIB
|
|
expired := time.Now().Add(-time.Hour)
|
|
outlet, order, payment := uuid.New(), uuid.New(), uuid.New()
|
|
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
|
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{
|
|
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
|
|
Type: constants.WalletTxTypeEarn, Amount: 300, ReferenceType: constants.WalletRefTypeOrder,
|
|
ReferenceID: order, OutletID: &outlet, Description: "Belanja #ORD-1"},
|
|
Lots: []processor.WalletLotInput{{Amount: 200, ExpiresAt: &afternoonUTC}, {Amount: 100, ExpiresAt: &lateNightUTC}},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
|
CustomerID: customer, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration,
|
|
Amount: 8, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: customer,
|
|
Description: "Saldo awal dari sistem lama"}}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{
|
|
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
|
|
Amount: 50, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, OutletID: &outlet,
|
|
Description: "Bayar #ORD-2"}}); err != nil {
|
|
return err
|
|
}
|
|
_, err := wallet.Credit(ctx, processor.WalletCreditInput{
|
|
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
|
|
Type: constants.WalletTxTypeEarn, Amount: 20, ReferenceType: constants.WalletRefTypeOrder,
|
|
ReferenceID: uuid.New(), OutletID: &outlet, Description: "Belanja #ORD-0"},
|
|
Lots: []processor.WalletLotInput{{Amount: 20, ExpiresAt: &expired}},
|
|
})
|
|
return err
|
|
}))
|
|
|
|
h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
|
processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))),
|
|
nil,
|
|
)))
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
as := func(id uuid.UUID) gin.HandlerFunc {
|
|
return func(c *gin.Context) { c.Set("customer_id", id.String()) }
|
|
}
|
|
for _, who := range []struct {
|
|
prefix string
|
|
id uuid.UUID
|
|
}{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} {
|
|
g := router.Group(who.prefix, as(who.id))
|
|
g.GET("/points", h.GetCustomerPoints)
|
|
g.GET("/tokens", h.GetCustomerTokens)
|
|
g.GET("/wallet", h.GetCustomerWallet)
|
|
g.GET("/wallet/transactions", h.GetCustomerWalletTransactions)
|
|
}
|
|
get := func(path string) (int, map[string]any) {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
var body map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), rec.Body.String())
|
|
return rec.Code, body
|
|
}
|
|
// The balance endpoints wrap their data in {status, message, data}.
|
|
inner := func(body map[string]any) map[string]any {
|
|
return body["data"].(map[string]any)["data"].(map[string]any)
|
|
}
|
|
|
|
// Old app: /points and /tokens keep their shape and show the spendable balance.
|
|
status, body := get("/a/points")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable")
|
|
assert.Len(t, inner(body)["points_history"], 3)
|
|
|
|
status, body = get("/a/tokens")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.EqualValues(t, 8, inner(body)["total_tokens"])
|
|
|
|
// /wallet: new fields, with the old ones still there.
|
|
status, body = get("/a/wallet")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
w := inner(body)
|
|
assert.EqualValues(t, 250, w["point_balance"])
|
|
assert.EqualValues(t, 8, w["coin_balance"])
|
|
assert.EqualValues(t, 100, w["point_value"])
|
|
assert.EqualValues(t, 25000, w["point_discount_value"])
|
|
nearest := w["nearest_expiring"].(map[string]any)
|
|
// The payment took 50 from the lot expiring first, leaving 50 + 200.
|
|
assert.Equal(t, map[string]any{"amount": float64(250), "date": time.Date(next, 7, 2, 0, 0, 0, 0, time.UTC).Format("2006-01-02")},
|
|
nearest["point"], "both lots fall on 2 July in Jakarta although they are on different UTC days")
|
|
assert.Nil(t, nearest["coin"])
|
|
for _, legacy := range []string{"total_points", "total_tokens", "points_history", "tokens_history", "last_updated"} {
|
|
assert.Contains(t, w, legacy)
|
|
}
|
|
assert.EqualValues(t, 250, w["total_points"])
|
|
|
|
recent := w["recent_transactions"].([]any)
|
|
require.Len(t, recent, 4)
|
|
for _, raw := range recent {
|
|
tx := raw.(map[string]any)
|
|
assert.NotContains(t, tx, "counterparty_customer_id")
|
|
assert.NotContains(t, tx, "metadata")
|
|
if tx["amount"].(float64) > 0 {
|
|
assert.Contains(t, tx, "source", tx["type"])
|
|
assert.NotContains(t, tx, "destination")
|
|
} else {
|
|
assert.Equal(t, map[string]any{"type": "PAYMENT", "id": payment.String()}, tx["destination"])
|
|
assert.NotContains(t, tx, "source")
|
|
}
|
|
}
|
|
|
|
// History with filters.
|
|
status, body = get("/a/wallet/transactions?currency=POINT&type=EARN&limit=1&page=2")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
page := body["data"].(map[string]any)
|
|
assert.Equal(t, map[string]any{"page": float64(2), "limit": float64(1), "total_count": float64(2), "total_pages": float64(2)}, page["pagination"])
|
|
rows := page["data"].([]any)
|
|
require.Len(t, rows, 1)
|
|
first := rows[0].(map[string]any)
|
|
assert.Equal(t, "Belanja #ORD-1", first["description"], "newest first, so page 2 is the older earning")
|
|
assert.Equal(t, map[string]any{"type": "ORDER", "id": order.String()}, first["source"])
|
|
assert.Equal(t, lateNightUTC.Format(time.RFC3339), parseTime(t, first["expires_at"]).UTC().Format(time.RFC3339))
|
|
assert.Len(t, first["lots"], 2)
|
|
|
|
today := time.Now().In(time.FixedZone("WIB", 7*3600)).Format("2006-01-02")
|
|
status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=PAYMENT,MIGRATION")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.EqualValues(t, 2, body["data"].(map[string]any)["pagination"].(map[string]any)["total_count"])
|
|
|
|
status, _ = get("/a/wallet/transactions?currency=GOLD")
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
|
status, _ = get("/a/wallet/transactions?limit=abc")
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
|
|
|
// A customer who has never had a wallet sees zeros, not an error.
|
|
status, body = get("/b/wallet")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.EqualValues(t, 0, inner(body)["point_balance"])
|
|
assert.Equal(t, []any{}, inner(body)["recent_transactions"])
|
|
status, body = get("/b/wallet/transactions")
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.Equal(t, []any{}, body["data"].(map[string]any)["data"])
|
|
|
|
// A token for a customer that no longer exists.
|
|
status, _ = get("/gone/wallet")
|
|
assert.Equal(t, http.StatusNotFound, status)
|
|
}
|
|
|
|
func parseTime(t *testing.T, v any) time.Time {
|
|
t.Helper()
|
|
s, ok := v.(string)
|
|
require.True(t, ok, "expected a time string, got %v", v)
|
|
parsed, err := time.Parse(time.RFC3339Nano, s)
|
|
require.NoError(t, err)
|
|
return parsed
|
|
}
|