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:
co-authored by
Claude Opus 5.5
parent
8370851ed2
commit
fe2f459b03
+1
-1
@@ -581,7 +581,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
expenseService: service.NewExpenseService(processors.expenseProcessor),
|
||||
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
|
||||
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor),
|
||||
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor),
|
||||
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
|
||||
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -103,3 +103,57 @@ func LoyaltyCashbackPercent(earnValue, pointValue, earnPerAmount int64) float64
|
||||
}
|
||||
return math.Round(float64(earnValue)*float64(pointValue)*10000/float64(earnPerAmount)) / 100
|
||||
}
|
||||
|
||||
// OrganizationLoyaltySettingsView is GET and PUT /marketing/loyalty-settings.
|
||||
type OrganizationLoyaltySettingsView struct {
|
||||
OrganizationLoyaltySettings
|
||||
// What the balances in circulation are worth, before and after the change.
|
||||
Impact LoyaltySettingsImpact `json:"impact"`
|
||||
// On PUT, the keys that changed; on a dry run, the keys that would.
|
||||
Changes []LoyaltySettingChange `json:"changes"`
|
||||
// True when nothing was saved.
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
// LoyaltySettingsImpact shows how a change of point value or exchange rate changes what
|
||||
// the balances in circulation are worth (F2). Before and after are equal when neither
|
||||
// changes.
|
||||
type LoyaltySettingsImpact struct {
|
||||
OutstandingPoints int64 `json:"outstanding_points"`
|
||||
OutstandingCoins int64 `json:"outstanding_coins"`
|
||||
|
||||
PointValueBefore int64 `json:"point_value_before"`
|
||||
PointValueAfter int64 `json:"point_value_after"`
|
||||
PointRupiahBefore int64 `json:"point_rupiah_before"`
|
||||
PointRupiahAfter int64 `json:"point_rupiah_after"`
|
||||
|
||||
// The coins in circulation exchanged at the rate, in EnakPoint and in rupiah.
|
||||
CoinsAsPointsBefore int64 `json:"coins_as_points_before"`
|
||||
CoinsAsPointsAfter int64 `json:"coins_as_points_after"`
|
||||
CoinRupiahBefore int64 `json:"coin_rupiah_before"`
|
||||
CoinRupiahAfter int64 `json:"coin_rupiah_after"`
|
||||
}
|
||||
|
||||
// NewLoyaltySettingsImpact computes the impact of moving from one organization setting
|
||||
// to another on the balances in circulation.
|
||||
func NewLoyaltySettingsImpact(points, coins int64, before, after OrganizationLoyaltySettings) LoyaltySettingsImpact {
|
||||
asPoints := func(s OrganizationLoyaltySettings) int64 {
|
||||
if s.Exchange.CoinAmount <= 0 {
|
||||
return 0
|
||||
}
|
||||
return coins * s.Exchange.PointAmount / s.Exchange.CoinAmount
|
||||
}
|
||||
impact := LoyaltySettingsImpact{
|
||||
OutstandingPoints: points,
|
||||
OutstandingCoins: coins,
|
||||
PointValueBefore: before.PointValue,
|
||||
PointValueAfter: after.PointValue,
|
||||
PointRupiahBefore: points * before.PointValue,
|
||||
PointRupiahAfter: points * after.PointValue,
|
||||
CoinsAsPointsBefore: asPoints(before),
|
||||
CoinsAsPointsAfter: asPoints(after),
|
||||
}
|
||||
impact.CoinRupiahBefore = impact.CoinsAsPointsBefore * before.PointValue
|
||||
impact.CoinRupiahAfter = impact.CoinsAsPointsAfter * after.PointValue
|
||||
return impact
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLoyaltyCashbackPercent(t *testing.T) {
|
||||
assert.Equal(t, 1.0, LoyaltyCashbackPercent(1, 1, 100))
|
||||
assert.Equal(t, 0.1, LoyaltyCashbackPercent(1, 1, 1000))
|
||||
assert.Equal(t, 0.33, LoyaltyCashbackPercent(1, 1, 300), "rounded to two decimals")
|
||||
assert.Equal(t, 0.67, LoyaltyCashbackPercent(2, 1, 300), "rounded, not cut")
|
||||
assert.Equal(t, 0.0, LoyaltyCashbackPercent(1, 1, 0))
|
||||
}
|
||||
|
||||
func TestNewLoyaltySettingsImpact(t *testing.T) {
|
||||
before := OrganizationLoyaltySettings{PointValue: 1, Exchange: LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}}
|
||||
after := OrganizationLoyaltySettings{PointValue: 100, Exchange: LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}}
|
||||
|
||||
got := NewLoyaltySettingsImpact(50000, 105, before, after)
|
||||
assert.Equal(t, LoyaltySettingsImpact{
|
||||
OutstandingPoints: 50000, OutstandingCoins: 105,
|
||||
PointValueBefore: 1, PointValueAfter: 100,
|
||||
PointRupiahBefore: 50000, PointRupiahAfter: 5000000,
|
||||
CoinsAsPointsBefore: 105, CoinsAsPointsAfter: 10, // floor(105 / 10)
|
||||
CoinRupiahBefore: 105, CoinRupiahAfter: 1000,
|
||||
}, got)
|
||||
|
||||
same := NewLoyaltySettingsImpact(10, 10, before, before)
|
||||
assert.Equal(t, same.PointRupiahBefore, same.PointRupiahAfter)
|
||||
}
|
||||
@@ -141,27 +141,11 @@ func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organ
|
||||
// on its default and not changed is not written, so the history only shows real
|
||||
// changes. current and next list the same keys in the same order.
|
||||
func (p *LoyaltySettingsProcessor) apply(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField, store func(key string, value *string) error) ([]models.LoyaltySettingChange, error) {
|
||||
var rows []entities.LoyaltySettingChange
|
||||
for i, field := range next {
|
||||
newValue := field.format()
|
||||
if equalOptionalStrings(current[i].format(), newValue) {
|
||||
continue
|
||||
}
|
||||
var oldValue *string
|
||||
if v, ok := raw[field.key]; ok {
|
||||
oldValue = &v
|
||||
}
|
||||
if err := store(field.key, newValue); err != nil {
|
||||
rows := diffLoyaltyFields(organizationID, outletID, changedBy, raw, current, next)
|
||||
for _, row := range rows {
|
||||
if err := store(row.Key, row.NewValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, entities.LoyaltySettingChange{
|
||||
OrganizationID: organizationID,
|
||||
OutletID: outletID,
|
||||
Key: field.key,
|
||||
OldValue: oldValue,
|
||||
NewValue: newValue,
|
||||
ChangedBy: changedBy,
|
||||
})
|
||||
}
|
||||
if err := p.repo.InsertChanges(ctx, rows); err != nil {
|
||||
return nil, err
|
||||
@@ -410,3 +394,49 @@ func equalOptionalStrings(a, b *string) bool {
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// diffLoyaltyFields lists the keys whose effective value differs between current and
|
||||
// next, with the stored old value (nil when the key was on its default). current and
|
||||
// next list the same keys in the same order.
|
||||
func diffLoyaltyFields(organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField) []entities.LoyaltySettingChange {
|
||||
var rows []entities.LoyaltySettingChange
|
||||
for i, field := range next {
|
||||
newValue := field.format()
|
||||
if equalOptionalStrings(current[i].format(), newValue) {
|
||||
continue
|
||||
}
|
||||
var oldValue *string
|
||||
if v, ok := raw[field.key]; ok {
|
||||
oldValue = &v
|
||||
}
|
||||
rows = append(rows, entities.LoyaltySettingChange{
|
||||
OrganizationID: organizationID,
|
||||
OutletID: outletID,
|
||||
Key: field.key,
|
||||
OldValue: oldValue,
|
||||
NewValue: newValue,
|
||||
ChangedBy: changedBy,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// PreviewOrganization validates new organization settings and lists what saving them
|
||||
// would change, without writing anything.
|
||||
func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) {
|
||||
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var current models.OrganizationLoyaltySettings
|
||||
loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String())
|
||||
rows := diffLoyaltyFields(organizationID, nil, uuid.Nil, raw, organizationLoyaltyFields(¤t), organizationLoyaltyFields(&settings))
|
||||
changes := make([]models.LoyaltySettingChange, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
changes = append(changes, loyaltySettingChangeModel(row))
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
@@ -239,3 +239,7 @@ func TestWalletQueryProcessor_RejectsBadQueries(t *testing.T) {
|
||||
models.ListCustomerWalletTransactionsQuery{From: "2026-06-01", To: "2026-06-01"})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func (f *walletQueryRepoFake) OrganizationOutstanding(context.Context, uuid.UUID) (int64, int64, error) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ type WalletQueryRepository interface {
|
||||
NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error)
|
||||
// ListTransactions returns a page of the ledger, newest first, and the total count.
|
||||
ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error)
|
||||
// OrganizationOutstanding sums every wallet balance of an organization.
|
||||
OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (points, coins int64, err error)
|
||||
// ListLotsBySourceTransactions returns the lots created by the given credits.
|
||||
ListLotsBySourceTransactions(ctx context.Context, transactionIDs []uuid.UUID) ([]entities.WalletLot, error)
|
||||
}
|
||||
@@ -168,3 +170,14 @@ func (r *walletQueryRepository) ListLotsBySourceTransactions(ctx context.Context
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletQueryRepository) OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (int64, int64, error) {
|
||||
var totals struct{ Points, Coins int64 }
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(point_balance), 0) AS points, COALESCE(SUM(coin_balance), 0) AS coins
|
||||
FROM customer_wallets WHERE organization_id = ?`, organizationID).Scan(&totals).Error
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to sum outstanding balances: %w", err)
|
||||
}
|
||||
return totals.Points, totals.Coins, nil
|
||||
}
|
||||
|
||||
@@ -627,6 +627,11 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
}
|
||||
|
||||
// Campaign Rules
|
||||
// EnakPoint & EnakCoin settings of the organization (docs/prd-point-coin.md F2)
|
||||
gamification.GET("/loyalty-settings", r.loyaltySettingsHandler.GetOrganizationSettings)
|
||||
gamification.PUT("/loyalty-settings", r.authMiddleware.RequireLoyaltyManager(), r.loyaltySettingsHandler.UpdateOrganizationSettings)
|
||||
gamification.GET("/loyalty-settings/history", r.loyaltySettingsHandler.ListHistory)
|
||||
|
||||
// EnakPoint & EnakCoin wallet of one customer (docs/prd-point-coin.md F7)
|
||||
marketingCustomers := gamification.Group("/customers")
|
||||
{
|
||||
|
||||
@@ -32,6 +32,9 @@ func TestAllRoutesRegister(t *testing.T) {
|
||||
"POST /api/v1/marketing/customers/:id/wallet/adjust",
|
||||
"GET /api/v1/outlets/:outlet_id/loyalty-settings",
|
||||
"PUT /api/v1/outlets/:outlet_id/loyalty-settings",
|
||||
"GET /api/v1/marketing/loyalty-settings",
|
||||
"PUT /api/v1/marketing/loyalty-settings",
|
||||
"GET /api/v1/marketing/loyalty-settings/history",
|
||||
"GET /api/v1/customer/pin/status",
|
||||
"POST /api/v1/customer/pin/otp",
|
||||
"POST /api/v1/customer/pin",
|
||||
|
||||
@@ -24,14 +24,26 @@ type LoyaltySettingsService interface {
|
||||
// UpdateOutletSettings applies a JSON body on top of the current settings: fields
|
||||
// left out keep their value, and null clears an optional limit.
|
||||
UpdateOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID, body []byte) *contract.Response
|
||||
|
||||
GetOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response
|
||||
// UpdateOrganizationSettings applies a JSON body like UpdateOutletSettings. With
|
||||
// dryRun it only validates and reports what would change and its impact.
|
||||
UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response
|
||||
// ListHistory returns the settings history of the organization, or of one outlet.
|
||||
ListHistory(ctx context.Context, apctx *appcontext.ContextInfo, outletID *uuid.UUID, page, limit int) *contract.Response
|
||||
}
|
||||
|
||||
type outstandingBalanceReader interface {
|
||||
OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (points, coins int64, err error)
|
||||
}
|
||||
|
||||
type LoyaltySettingsServiceImpl struct {
|
||||
settings *processor.LoyaltySettingsProcessor
|
||||
settings *processor.LoyaltySettingsProcessor
|
||||
outstanding outstandingBalanceReader
|
||||
}
|
||||
|
||||
func NewLoyaltySettingsService(settings *processor.LoyaltySettingsProcessor) *LoyaltySettingsServiceImpl {
|
||||
return &LoyaltySettingsServiceImpl{settings: settings}
|
||||
func NewLoyaltySettingsService(settings *processor.LoyaltySettingsProcessor, outstanding outstandingBalanceReader) *LoyaltySettingsServiceImpl {
|
||||
return &LoyaltySettingsServiceImpl{settings: settings, outstanding: outstanding}
|
||||
}
|
||||
|
||||
func (s *LoyaltySettingsServiceImpl) GetOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID) *contract.Response {
|
||||
@@ -115,3 +127,86 @@ func loyaltyErrorResponse(err error) *contract.Response {
|
||||
contract.NewResponseError(code, constants.LoyaltySettingsServiceEntity, message),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response {
|
||||
current, err := s.settings.Organization(ctx, apctx.OrganizationID)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
view, err := s.organizationView(ctx, apctx.OrganizationID, *current, *current, nil, false)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
return contract.BuildSuccessResponse(view)
|
||||
}
|
||||
|
||||
// organizationSettingsInput is what PUT accepts: the settings plus the read-only fields
|
||||
// of the GET response, which are ignored.
|
||||
type organizationSettingsInput struct {
|
||||
*models.OrganizationLoyaltySettings
|
||||
Impact json.RawMessage `json:"impact"`
|
||||
Changes json.RawMessage `json:"changes"`
|
||||
DryRun json.RawMessage `json:"dry_run"`
|
||||
}
|
||||
|
||||
func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response {
|
||||
current, err := s.settings.Organization(ctx, apctx.OrganizationID)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
next := *current
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&organizationSettingsInput{OrganizationLoyaltySettings: &next}); err != nil {
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.LoyaltySettingsServiceEntity, fmt.Sprintf("invalid request body: %v", err)),
|
||||
})
|
||||
}
|
||||
|
||||
var changes []models.LoyaltySettingChange
|
||||
if dryRun {
|
||||
changes, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next)
|
||||
} else {
|
||||
changes, err = s.settings.UpdateOrganization(ctx, apctx.OrganizationID, apctx.UserID, next)
|
||||
}
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
after := next
|
||||
if !dryRun {
|
||||
saved, err := s.settings.Organization(ctx, apctx.OrganizationID)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
after = *saved
|
||||
}
|
||||
view, err := s.organizationView(ctx, apctx.OrganizationID, *current, after, changes, dryRun)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
return contract.BuildSuccessResponse(view)
|
||||
}
|
||||
|
||||
func (s *LoyaltySettingsServiceImpl) ListHistory(ctx context.Context, apctx *appcontext.ContextInfo, outletID *uuid.UUID, page, limit int) *contract.Response {
|
||||
history, err := s.settings.ListChanges(ctx, apctx.OrganizationID, outletID, page, limit)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
return contract.BuildSuccessResponse(history)
|
||||
}
|
||||
|
||||
func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) {
|
||||
points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if changes == nil {
|
||||
changes = []models.LoyaltySettingChange{}
|
||||
}
|
||||
return &models.OrganizationLoyaltySettingsView{
|
||||
OrganizationLoyaltySettings: after,
|
||||
Impact: models.NewLoyaltySettingsImpact(points, coins, before, after),
|
||||
Changes: changes,
|
||||
DryRun: dryRun,
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user