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>
204 lines
9.2 KiB
Go
204 lines
9.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
|
|
"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/appcontext"
|
|
"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"
|
|
"apskel-pos-be/internal/validator"
|
|
)
|
|
|
|
// Drives the dashboard wallet endpoints over HTTP down to Postgres. Needs
|
|
// TEST_DATABASE_URL pointing at a migrated database; see
|
|
// internal/repository/wallet_repository_test.go.
|
|
func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL not set")
|
|
}
|
|
applogger.Setup("fatal", "json")
|
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
require.NoError(t, err)
|
|
|
|
orgA, orgB := uuid.New(), uuid.New()
|
|
adminA, adminB := uuid.New(), uuid.New()
|
|
customer := uuid.New()
|
|
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 (?, 'A', 'basic'), (?, 'B', 'basic')`, orgA, orgB)
|
|
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES
|
|
(?, ?, 'Admin Satu', ?, 'x', 'admin'), (?, ?, 'Admin Lain', ?, 'x', 'admin')`,
|
|
adminA, orgA, adminA.String()+"@test", adminB, orgB, adminB.String()+"@test")
|
|
exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Budi Santoso', '081234567890')`, customer, orgA)
|
|
t.Cleanup(func() {
|
|
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id = ?)`, customer)
|
|
db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer)
|
|
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer)
|
|
db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer)
|
|
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
|
|
db.Exec(`DELETE FROM users WHERE id IN ?`, []uuid.UUID{adminA, adminB})
|
|
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{orgA, orgB})
|
|
})
|
|
|
|
walletRepo := repository.NewWalletRepository(db)
|
|
queryRepo := repository.NewWalletQueryRepository(db)
|
|
txm := repository.NewTxManager(db)
|
|
wallet := processor.NewWalletProcessor(walletRepo)
|
|
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
|
outlet := uuid.New()
|
|
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
|
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn,
|
|
Amount: 100, ReferenceType: constants.WalletRefTypeOrder, ReferenceID: uuid.New(), OutletID: &outlet,
|
|
Description: "Belanja #ORD-1"}})
|
|
return err
|
|
}))
|
|
|
|
h := NewWalletAdminHandler(
|
|
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm)),
|
|
validator.NewWalletValidator(),
|
|
)
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
as := func(org, user uuid.UUID) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
|
|
ctx = context.WithValue(ctx, appcontext.UserIDKey, user.String())
|
|
c.Request = c.Request.WithContext(ctx)
|
|
}
|
|
}
|
|
for prefix, who := range map[string][2]uuid.UUID{"/a": {orgA, adminA}, "/b": {orgB, adminB}} {
|
|
g := router.Group(prefix, as(who[0], who[1]))
|
|
g.GET("/customers/:id/wallet", h.GetCustomerWallet)
|
|
g.POST("/customers/:id/wallet/adjust", h.AdjustCustomerWallet)
|
|
}
|
|
call := func(method, path string, body any) (int, map[string]any) {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
if body != nil {
|
|
require.NoError(t, json.NewEncoder(&buf).Encode(body))
|
|
}
|
|
req := httptest.NewRequest(method, path, &buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
var out map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
|
return rec.Code, out
|
|
}
|
|
adjust := func(prefix string, body map[string]any) (int, map[string]any) {
|
|
return call(http.MethodPost, prefix+"/customers/"+customer.String()+"/wallet/adjust", body)
|
|
}
|
|
spendable := func() float64 {
|
|
t.Helper()
|
|
status, body := call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet", nil)
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
return body["data"].(map[string]any)["spendable_point_balance"].(float64)
|
|
}
|
|
|
|
// Add 50.
|
|
status, body := adjust("/a", map[string]any{"currency": "point", "amount": 50, "reason": "komplain #45"})
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
tx := body["data"].(map[string]any)["transaction"].(map[string]any)
|
|
assert.Equal(t, "ADJUSTMENT", tx["type"])
|
|
assert.EqualValues(t, 50, tx["amount"])
|
|
assert.Equal(t, "komplain #45", tx["reason"])
|
|
assert.Equal(t, "Koreksi oleh admin: komplain #45", tx["description"])
|
|
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, tx["created_by"])
|
|
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, tx["source"])
|
|
assert.EqualValues(t, 150, body["data"].(map[string]any)["spendable_point_balance"])
|
|
|
|
// Taking more than the customer can spend is refused and changes nothing.
|
|
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -151, "reason": "salah input"})
|
|
assert.Equal(t, http.StatusBadRequest, status, body)
|
|
assert.Equal(t, float64(150), spendable())
|
|
|
|
// Taking away, retried with the same key, happens once.
|
|
for i, wantReplayed := range []bool{false, true} {
|
|
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -30, "reason": "salah input", "idempotency_key": "req-1"})
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
assert.Equal(t, wantReplayed, body["data"].(map[string]any)["replayed"], "call %d", i+1)
|
|
}
|
|
assert.Equal(t, float64(120), spendable())
|
|
|
|
// Bad requests.
|
|
for name, req := range map[string]map[string]any{
|
|
"cash-out": {"currency": "POINT", "amount": -10, "reason": "pencairan saldo"},
|
|
"zero amount": {"currency": "POINT", "amount": 0, "reason": "x"},
|
|
"no reason": {"currency": "POINT", "amount": 10},
|
|
"bad currency": {"currency": "GOLD", "amount": 10, "reason": "x"},
|
|
} {
|
|
status, _ = adjust("/a", req)
|
|
assert.Equal(t, http.StatusBadRequest, status, name)
|
|
}
|
|
status, _ = call(http.MethodPost, "/a/customers/not-a-uuid/wallet/adjust", map[string]any{"currency": "POINT", "amount": 1, "reason": "x"})
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
|
assert.Equal(t, float64(120), spendable())
|
|
|
|
// Another organization's admin cannot see or touch this customer.
|
|
status, _ = call(http.MethodGet, "/b/customers/"+customer.String()+"/wallet", nil)
|
|
assert.Equal(t, http.StatusNotFound, status)
|
|
status, _ = adjust("/b", map[string]any{"currency": "POINT", "amount": 1000, "reason": "x"})
|
|
assert.Equal(t, http.StatusNotFound, status)
|
|
assert.Equal(t, float64(120), spendable())
|
|
|
|
// The dashboard view.
|
|
status, body = call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet?type=ADJUSTMENT", nil)
|
|
require.Equal(t, http.StatusOK, status, body)
|
|
w := body["data"].(map[string]any)
|
|
assert.Equal(t, map[string]any{"id": customer.String(), "name": "Budi Santoso", "phone": "081234567890"}, w["customer"])
|
|
assert.EqualValues(t, 120, w["point_balance"])
|
|
assert.EqualValues(t, 120, w["spendable_point_balance"])
|
|
lots := w["lots"].([]any)
|
|
var lotTotal float64
|
|
for _, l := range lots {
|
|
lotTotal += l.(map[string]any)["remaining_amount"].(float64)
|
|
}
|
|
assert.Equal(t, float64(120), lotTotal)
|
|
|
|
rows := w["transactions"].(map[string]any)["data"].([]any)
|
|
require.Len(t, rows, 2, "the two adjustments, newest first")
|
|
newest := rows[0].(map[string]any)
|
|
assert.EqualValues(t, -30, newest["amount"])
|
|
assert.Equal(t, "salah input", newest["reason"])
|
|
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, newest["created_by"])
|
|
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, newest["destination"])
|
|
|
|
// The customer's own history shows the adjustment too, with the reason in the
|
|
// description and without the admin's identity fields.
|
|
customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
|
processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil)))
|
|
crouter := gin.New()
|
|
crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions)
|
|
rec := httptest.NewRecorder()
|
|
crouter.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/wallet/transactions?type=ADJUSTMENT", nil))
|
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
|
var cbody map[string]any
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cbody))
|
|
crows := cbody["data"].(map[string]any)["data"].([]any)
|
|
require.Len(t, crows, 2)
|
|
first := crows[0].(map[string]any)
|
|
assert.Equal(t, "Koreksi oleh admin: salah input", first["description"])
|
|
assert.NotContains(t, first, "created_by")
|
|
assert.NotContains(t, first, "reason")
|
|
}
|