feat(loyalty): organization loyalty settings API

Adds GET and PUT /marketing/loyalty-settings and GET
/marketing/loyalty-settings/history (docs/prd-point-coin.md F2, PC-302).

The settings are the point value, the exchange rate, transfer limits and
the stored expiry settings. PUT merges the body like the outlet settings
and is limited to loyalty managers. Every response carries the impact of
the change on the balances in circulation: outstanding EnakPoint and
EnakCoin, their rupiah value, and the coins exchanged into points, before
and after. With ?dry_run=true nothing is saved and the response lists the
keys that would change, for the warning shown before saving.

Saving records each change in loyalty_setting_changes with who made it;
history can be filtered to one outlet. Changing the value leaves what was
already written alone. The diff behind saving and previewing is shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 11:26:16 +07:00
co-authored by Claude Opus 5.5
parent 8370851ed2
commit fe2f459b03
12 changed files with 485 additions and 24 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
})
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings))
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db)))
auth := middleware.NewAuthMiddleware(nil)
gin.SetMode(gin.TestMode)
@@ -2,6 +2,7 @@ package handler
import (
"io"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -70,3 +71,47 @@ func parseUUIDParam(c *gin.Context, name, method string) (uuid.UUID, bool) {
}
return id, true
}
func (h *LoyaltySettingsHandler) GetOrganizationSettings(c *gin.Context) {
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.GetOrganizationSettings(ctx, appcontext.FromGinContext(ctx)), "LoyaltySettingsHandler::GetOrganizationSettings")
}
// UpdateOrganizationSettings is PUT /marketing/loyalty-settings. With ?dry_run=true it
// saves nothing and returns what would change and what the balances in circulation
// would be worth, for the warning the dashboard shows before saving (F2).
func (h *LoyaltySettingsHandler) UpdateOrganizationSettings(c *gin.Context) {
ctx := c.Request.Context()
body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit))
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"),
}), "LoyaltySettingsHandler::UpdateOrganizationSettings")
return
}
response := h.loyaltySettingsService.UpdateOrganizationSettings(ctx, appcontext.FromGinContext(ctx), body, c.Query("dry_run") == "true")
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOrganizationSettings -> service call failed")
}
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOrganizationSettings")
}
// ListHistory is GET /marketing/loyalty-settings/history, optionally for one outlet
// (?outlet_id=).
func (h *LoyaltySettingsHandler) ListHistory(c *gin.Context) {
ctx := c.Request.Context()
var outletID *uuid.UUID
if raw := c.Query("outlet_id"); raw != "" {
id, err := uuid.Parse(raw)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet_id"),
}), "LoyaltySettingsHandler::ListHistory")
return
}
outletID = &id
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.ListHistory(ctx, appcontext.FromGinContext(ctx), outletID, page, limit), "LoyaltySettingsHandler::ListHistory")
}
@@ -0,0 +1,180 @@
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"
applogger "apskel-pos-be/internal/logger"
"apskel-pos-be/internal/middleware"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/service"
)
// Drives GET/PUT /marketing/loyalty-settings and its history over HTTP down to
// Postgres. Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestOrganizationLoyaltySettingsEndpoints_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)
org, admin, customer := uuid.New(), uuid.New(), 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')`, org)
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org)
t.Cleanup(func() {
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 loyalty_setting_changes WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
// Balances in circulation: 50.000 EnakPoint and 105 EnakCoin.
txm := repository.NewTxManager(db)
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
for _, c := range []struct {
currency, ref string
amount int64
}{{"POINT", "LEGACY_POINTS", 50000}, {"COIN", "LEGACY_TOKENS", 105}} {
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
CustomerID: customer, Currency: c.currency, Type: "MIGRATION", Amount: c.amount,
ReferenceType: c.ref, ReferenceID: uuid.New(), Description: "Saldo awal",
Metadata: map[string]interface{}{"frozen": true}}})
return err
}))
}
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db)))
auth := middleware.NewAuthMiddleware(nil)
gin.SetMode(gin.TestMode)
router := gin.New()
for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} {
role := role
g := router.Group(prefix, func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
c.Request = c.Request.WithContext(ctx)
})
g.GET("/loyalty-settings", h.GetOrganizationSettings)
g.PUT("/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOrganizationSettings)
g.GET("/loyalty-settings/history", h.ListHistory)
}
call := func(method, path, body string) (int, map[string]any) {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
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
}
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
status, body := call(http.MethodGet, "/manager/loyalty-settings", "")
require.Equal(t, http.StatusOK, status, body)
got := data(body)
assert.EqualValues(t, 1, got["point_value"])
assert.Equal(t, map[string]any{"coin_amount": float64(1), "point_amount": float64(1)}, got["exchange"])
assert.Equal(t, true, got["transfer"].(map[string]any)["enabled"])
assert.Equal(t, "MONTH", got["point_expiry"].(map[string]any)["unit"])
impact := got["impact"].(map[string]any)
assert.EqualValues(t, 50000, impact["outstanding_points"])
assert.EqualValues(t, impact["point_rupiah_before"], impact["point_rupiah_after"])
// A dry run shows what the balances in circulation would be worth, and saves nothing.
change := `{"point_value": 100, "exchange": {"coin_amount": 10, "point_amount": 1}}`
status, body = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", change)
require.Equal(t, http.StatusOK, status, body)
got = data(body)
assert.Equal(t, true, got["dry_run"])
impact = got["impact"].(map[string]any)
assert.EqualValues(t, 50000, impact["point_rupiah_before"])
assert.EqualValues(t, 5000000, impact["point_rupiah_after"])
assert.EqualValues(t, 105, impact["coins_as_points_before"])
assert.EqualValues(t, 10, impact["coins_as_points_after"])
assert.Len(t, got["changes"], 2)
status, body = call(http.MethodGet, "/manager/loyalty-settings", "")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 1, data(body)["point_value"], "a dry run saves nothing")
status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 0, data(body)["pagination"].(map[string]any)["total_count"])
// Saving records every change with who made it.
status, body = call(http.MethodPut, "/manager/loyalty-settings", change)
require.Equal(t, http.StatusOK, status, body)
assert.Equal(t, false, data(body)["dry_run"])
assert.EqualValues(t, 100, data(body)["point_value"])
status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "")
require.Equal(t, http.StatusOK, status, body)
history := data(body)["data"].([]any)
require.Len(t, history, 2)
var keys []string
for _, raw := range history {
row := raw.(map[string]any)
keys = append(keys, row["key"].(string))
assert.Equal(t, admin.String(), row["changed_by"])
assert.Nil(t, row["old_value"], "was on its default")
assert.Nil(t, row["outlet_id"])
}
assert.ElementsMatch(t, []string{"loyalty.point.value", "loyalty.exchange.coin_amount"}, keys)
// Nothing already written changes: balances and ledger rows keep their numbers.
var rows []struct {
Amount int64
Metadata string
}
require.NoError(t, db.Raw(`SELECT amount, metadata::text AS metadata FROM wallet_transactions WHERE customer_id = ? ORDER BY currency`, customer).Scan(&rows).Error)
require.Len(t, rows, 2)
assert.Equal(t, int64(105), rows[0].Amount)
assert.Equal(t, int64(50000), rows[1].Amount)
for _, r := range rows {
assert.Contains(t, r.Metadata, `"frozen": true`)
}
// Out of bounds, unknown fields and the wrong role are refused.
for name, bad := range map[string]string{
"point value 0": `{"point_value": 0}`,
"exchange 0": `{"exchange": {"coin_amount": 0}}`,
"expiry unit YEAR": `{"coin_expiry": {"unit": "YEAR"}}`,
"unknown field": `{"point_valeu": 5}`,
} {
status, _ = call(http.MethodPut, "/manager/loyalty-settings", bad)
assert.Equal(t, http.StatusBadRequest, status, name)
status, _ = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", bad)
assert.Equal(t, http.StatusBadRequest, status, name+" (dry run)")
}
status, _ = call(http.MethodPut, "/purchasing/loyalty-settings", `{"point_value": 5}`)
assert.Equal(t, http.StatusForbidden, status)
status, body = call(http.MethodGet, "/manager/loyalty-settings", "")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 100, data(body)["point_value"])
}