diff --git a/internal/app/app.go b/internal/app/app.go index 752520b..33a1c09 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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), } } diff --git a/internal/handler/loyalty_settings_db_test.go b/internal/handler/loyalty_settings_db_test.go index 72bf8d9..d776ba1 100644 --- a/internal/handler/loyalty_settings_db_test.go +++ b/internal/handler/loyalty_settings_db_test.go @@ -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) diff --git a/internal/handler/loyalty_settings_handler.go b/internal/handler/loyalty_settings_handler.go index dd91544..ea73cb9 100644 --- a/internal/handler/loyalty_settings_handler.go +++ b/internal/handler/loyalty_settings_handler.go @@ -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") +} diff --git a/internal/handler/loyalty_settings_org_db_test.go b/internal/handler/loyalty_settings_org_db_test.go new file mode 100644 index 0000000..6b455d5 --- /dev/null +++ b/internal/handler/loyalty_settings_org_db_test.go @@ -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"]) +} diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go index 09aaed6..1d168ba 100644 --- a/internal/models/loyalty.go +++ b/internal/models/loyalty.go @@ -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 +} diff --git a/internal/models/loyalty_test.go b/internal/models/loyalty_test.go new file mode 100644 index 0000000..2fc48e5 --- /dev/null +++ b/internal/models/loyalty_test.go @@ -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) +} diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go index 58c6aa7..4c67363 100644 --- a/internal/processor/loyalty_settings_processor.go +++ b/internal/processor/loyalty_settings_processor.go @@ -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 +} diff --git a/internal/processor/wallet_query_processor_test.go b/internal/processor/wallet_query_processor_test.go index 1a4a9aa..d990a2b 100644 --- a/internal/processor/wallet_query_processor_test.go +++ b/internal/processor/wallet_query_processor_test.go @@ -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 +} diff --git a/internal/repository/wallet_query_repository.go b/internal/repository/wallet_query_repository.go index f59b75c..0591693 100644 --- a/internal/repository/wallet_query_repository.go +++ b/internal/repository/wallet_query_repository.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go index 652a1f4..7070399 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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") { diff --git a/internal/router/router_test.go b/internal/router/router_test.go index a267dfd..0049f32 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -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", diff --git a/internal/service/loyalty_settings_service.go b/internal/service/loyalty_settings_service.go index 1d6c67a..aa87f5d 100644 --- a/internal/service/loyalty_settings_service.go +++ b/internal/service/loyalty_settings_service.go @@ -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 +}