Files
apskel-pos-backend/internal/handler/wallet_admin_db_test.go
efrilmandClaude Opus 5.5 4d63673a25 feat(loyalty): give new balances their expiry
Every lot now gets its expiry when it is created (docs/prd-point-coin.md
F12, PC-502), where it used to never expire until note N4 was settled:

- EARN and an ADJUSTMENT that adds: ComputeExpiry of the organization's
  settings for that currency, from the moment received.
- EXCHANGE_IN: the sooner of the EnakCoin lot's expiry and when EnakPoint
  received now expire (F4).
- PAYMENT_REFUND: the expiry of the lot the EnakPoint came from, but at
  least seven days from the refund (N4, decided). A lot that never expired
  stays so.
- TRANSFER_IN: unchanged, exactly the sender's expiry.

Turning expiry on for a currency for the first time dates every lot of the
organization that still holds something and has no expiry, MIGRATION lots
included, in the same transaction as the setting: a full period from now
when ROLLING, the second fixed date on or after today when FIXED_DATE, so
no customer loses a balance soon after the rule is announced (N4,
decided). Turning it off leaves dated lots as they are. PUT
/marketing/loyalty-settings reports these as expiry_activations (currency,
lots, amount, expires_at); a dry run counts them without dating anything.

The earning processor now also reads the organization settings, and the
wallet admin processor takes the settings reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:29:25 +07:00

204 lines
9.4 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, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm), txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))),
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")
}