feat(loyalty): outlet loyalty settings API
Adds GET and PUT /outlets/:id/loyalty-settings (docs/prd-point-coin.md F1, PC-201) on top of the typed settings processor. The response shows every setting with its default when unset, the organization's point value, and the effective EnakPoint cashback (earn_value × point_value / earn_per_amount), so an owner cannot misread the scale. PUT applies the body on top of the current settings: fields left out keep their value, null clears an optional limit, and unknown fields are refused so a typo cannot be ignored silently. The read-only fields of the GET response are accepted and ignored, so a client can send back what it received. It returns the keys that changed. Values outside the F1 bounds answer 400, and an outlet of another organization 404. RequireAdminOrManager also lets the purchasing role through, so loyalty settings and the manual wallet adjustment from PC-107 now use a stricter RequireLoyaltyManager (superadmin, admin, manager, owner). Adds a test that registers every route, since gin panics at startup when two routes name the same path parameter differently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
39e47ff0e6
commit
2bd53ee4a4
@@ -152,6 +152,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
validators.cashAdvanceValidator,
|
||||
services.walletAdminService,
|
||||
validators.walletValidator,
|
||||
services.loyaltySettingsService,
|
||||
a.redisClient,
|
||||
)
|
||||
|
||||
@@ -474,6 +475,7 @@ type services struct {
|
||||
expenseService *service.ExpenseServiceImpl
|
||||
cashAdvanceService *service.CashAdvanceServiceImpl
|
||||
walletAdminService *service.WalletAdminServiceImpl
|
||||
loyaltySettingsService *service.LoyaltySettingsServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@@ -556,6 +558,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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ const (
|
||||
ExpenseServiceEntity = "expense_service"
|
||||
CashAdvanceServiceEntity = "cash_advance_service"
|
||||
WalletServiceEntity = "wallet_service"
|
||||
LoyaltySettingsServiceEntity = "loyalty_settings_service"
|
||||
)
|
||||
|
||||
var HttpErrorMap = map[string]int{
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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 /outlets/:id/loyalty-settings over HTTP down to Postgres. Needs
|
||||
// TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestOutletLoyaltySettingsEndpoints_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, otherOrg, outlet, admin := uuid.New(), 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'), (?, 'B', 'basic')`, org, otherOrg)
|
||||
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
})
|
||||
|
||||
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))
|
||||
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings))
|
||||
auth := middleware.NewAuthMiddleware(nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
as := func(orgID uuid.UUID, role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, orgID.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
}
|
||||
for prefix, who := range map[string]struct {
|
||||
org uuid.UUID
|
||||
role string
|
||||
}{"/manager": {org, "manager"}, "/purchasing": {org, "purchasing"}, "/other": {otherOrg, "admin"}} {
|
||||
g := router.Group(prefix, as(who.org, who.role))
|
||||
g.GET("/outlets/:outlet_id/loyalty-settings", h.GetOutletSettings)
|
||||
g.PUT("/outlets/:outlet_id/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOutletSettings)
|
||||
}
|
||||
call := func(method, path string, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
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
|
||||
}
|
||||
path := "/outlets/" + outlet.String() + "/loyalty-settings"
|
||||
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
||||
|
||||
// An outlet without settings shows every default and 1% cashback.
|
||||
status, body := call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got := data(body)
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
|
||||
assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"])
|
||||
assert.EqualValues(t, 1, got["point_value"])
|
||||
assert.EqualValues(t, 1, got["point_cashback_percent"])
|
||||
|
||||
// A partial update keeps everything it does not mention.
|
||||
status, body = call(http.MethodPut, "/manager"+path, `{"point": {"enabled": true, "earn_per_amount": 1000}, "coin": {"max_per_order": 3}}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got = data(body)
|
||||
assert.Equal(t, true, got["point"].(map[string]any)["enabled"])
|
||||
assert.EqualValues(t, 1000, got["point"].(map[string]any)["earn_per_amount"])
|
||||
assert.EqualValues(t, 1, got["point"].(map[string]any)["earn_value"], "untouched")
|
||||
assert.EqualValues(t, 25000, got["coin"].(map[string]any)["earn_per_amount"], "untouched")
|
||||
assert.EqualValues(t, 3, got["coin"].(map[string]any)["max_per_order"])
|
||||
assert.EqualValues(t, 0.1, got["point_cashback_percent"], "1 point per Rp 1.000 at Rp 1 a point")
|
||||
assert.Len(t, got["changes"], 3)
|
||||
|
||||
// The cashback follows the organization's point value.
|
||||
orgSettings, err := settings.Organization(context.Background(), org)
|
||||
require.NoError(t, err)
|
||||
orgSettings.PointValue = 5
|
||||
_, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings)
|
||||
require.NoError(t, err)
|
||||
status, body = call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 0.5, data(body)["point_cashback_percent"])
|
||||
|
||||
// Sending back what GET returned changes nothing.
|
||||
echo, err := json.Marshal(data(body))
|
||||
require.NoError(t, err)
|
||||
status, body = call(http.MethodPut, "/manager"+path, string(echo))
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Empty(t, data(body)["changes"])
|
||||
|
||||
// null clears a limit.
|
||||
status, body = call(http.MethodPut, "/manager"+path, `{"coin": {"max_per_order": null}}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Nil(t, data(body)["coin"].(map[string]any)["max_per_order"])
|
||||
|
||||
// Values out of bounds, unknown fields and bad JSON are refused and change nothing.
|
||||
for name, bad := range map[string]string{
|
||||
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
|
||||
"negative earn_value": `{"coin": {"earn_value": -1}}`,
|
||||
"negative min_order": `{"point": {"min_order_amount": -5}}`,
|
||||
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
|
||||
"payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`,
|
||||
"unknown field": `{"point": {"earn_per_amout": 50}}`,
|
||||
"wrong type": `{"point": {"enabled": "yes"}}`,
|
||||
"not json": `enabled=true`,
|
||||
} {
|
||||
status, _ = call(http.MethodPut, "/manager"+path, bad)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name)
|
||||
}
|
||||
status, body = call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1000, data(body)["point"].(map[string]any)["earn_per_amount"])
|
||||
|
||||
// Purchasing staff can read but not change; another organization sees nothing.
|
||||
status, _ = call(http.MethodPut, "/purchasing"+path, `{"point": {"enabled": false}}`)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, _ = call(http.MethodGet, "/other"+path, "")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = call(http.MethodPut, "/other"+path, `{"point": {"enabled": false}}`)
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = call(http.MethodGet, "/manager/outlets/not-a-uuid/loyalty-settings", "")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// Every change is in the history with who made it.
|
||||
history, err := settings.ListChanges(context.Background(), org, &outlet, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 4, history.Pagination.Total, "three keys, then the limit cleared")
|
||||
for _, c := range history.Data {
|
||||
assert.Equal(t, admin, c.ChangedBy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// loyaltySettingsBodyLimit caps a settings body; a real one is well under 2 KB.
|
||||
const loyaltySettingsBodyLimit = 64 << 10
|
||||
|
||||
// LoyaltySettingsHandler serves the loyalty settings (docs/prd-point-coin.md F1, F2).
|
||||
type LoyaltySettingsHandler struct {
|
||||
loyaltySettingsService service.LoyaltySettingsService
|
||||
}
|
||||
|
||||
func NewLoyaltySettingsHandler(loyaltySettingsService service.LoyaltySettingsService) *LoyaltySettingsHandler {
|
||||
return &LoyaltySettingsHandler{loyaltySettingsService: loyaltySettingsService}
|
||||
}
|
||||
|
||||
func (h *LoyaltySettingsHandler) GetOutletSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::GetOutletSettings")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response := h.loyaltySettingsService.GetOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::GetOutletSettings -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::GetOutletSettings")
|
||||
}
|
||||
|
||||
func (h *LoyaltySettingsHandler) UpdateOutletSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::UpdateOutletSettings")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
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::UpdateOutletSettings")
|
||||
return
|
||||
}
|
||||
response := h.loyaltySettingsService.UpdateOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID, body)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOutletSettings -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOutletSettings")
|
||||
}
|
||||
|
||||
// parseUUIDParam reads a UUID path parameter, answering 400 itself when it is not one.
|
||||
func parseUUIDParam(c *gin.Context, name, method string) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param(name))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid "+name),
|
||||
}), method)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
@@ -85,6 +85,14 @@ func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||
}
|
||||
|
||||
// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty
|
||||
// settings and manual wallet adjustments (docs/prd-point-coin.md F1, F2, F7). Only
|
||||
// admins and managers may do these; unlike RequireAdminOrManager it does not let the
|
||||
// purchasing role through.
|
||||
func (m *AuthMiddleware) RequireLoyaltyManager() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner")
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -79,3 +80,26 @@ type LoyaltySettingChange struct {
|
||||
ChangedBy uuid.UUID `json:"changed_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// OutletLoyaltySettingsView is GET and PUT /outlets/:id/loyalty-settings.
|
||||
type OutletLoyaltySettingsView struct {
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
OutletLoyaltySettings
|
||||
// The organization's rupiah value of one EnakPoint, which the cashback depends on.
|
||||
PointValue int64 `json:"point_value"`
|
||||
// Effective EnakPoint cashback in percent: earn_value × point_value /
|
||||
// earn_per_amount × 100. Shown next to the setting so an owner cannot misread the
|
||||
// scale (F1).
|
||||
PointCashbackPercent float64 `json:"point_cashback_percent"`
|
||||
// Set on PUT: the keys that changed.
|
||||
Changes []LoyaltySettingChange `json:"changes,omitempty"`
|
||||
}
|
||||
|
||||
// LoyaltyCashbackPercent is earnValue × pointValue / earnPerAmount as a percentage,
|
||||
// rounded to two decimals.
|
||||
func LoyaltyCashbackPercent(earnValue, pointValue, earnPerAmount int64) float64 {
|
||||
if earnPerAmount <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Round(float64(earnValue)*float64(pointValue)*10000/float64(earnPerAmount)) / 100
|
||||
}
|
||||
|
||||
@@ -44,6 +44,19 @@ func (p *LoyaltySettingsProcessor) Outlet(ctx context.Context, outletID uuid.UUI
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// OutletForOrganization returns an outlet's settings for the dashboard, and
|
||||
// repository.ErrLoyaltyOutletNotFound unless the outlet belongs to the organization.
|
||||
func (p *LoyaltySettingsProcessor) OutletForOrganization(ctx context.Context, organizationID, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
|
||||
owner, err := p.repo.OutletOrganizationID(ctx, outletID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owner != organizationID {
|
||||
return nil, repository.ErrLoyaltyOutletNotFound
|
||||
}
|
||||
return p.Outlet(ctx, outletID)
|
||||
}
|
||||
|
||||
// Organization returns an organization's settings.
|
||||
func (p *LoyaltySettingsProcessor) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
|
||||
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
||||
|
||||
@@ -55,12 +55,13 @@ type Router struct {
|
||||
expenseHandler *handler.ExpenseHandler
|
||||
cashAdvanceHandler *handler.CashAdvanceHandler
|
||||
walletAdminHandler *handler.WalletAdminHandler
|
||||
loyaltySettingsHandler *handler.LoyaltySettingsHandler
|
||||
authMiddleware *middleware.AuthMiddleware
|
||||
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
||||
redisClient *redis.Client
|
||||
}
|
||||
|
||||
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, redisClient *redis.Client) *Router {
|
||||
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, redisClient *redis.Client) *Router {
|
||||
|
||||
return &Router{
|
||||
config: cfg,
|
||||
@@ -107,6 +108,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
|
||||
expenseHandler: handler.NewExpenseHandler(expenseService, expenseValidator),
|
||||
cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator),
|
||||
walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator),
|
||||
loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService),
|
||||
redisClient: redisClient,
|
||||
}
|
||||
}
|
||||
@@ -621,7 +623,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
marketingCustomers := gamification.Group("/customers")
|
||||
{
|
||||
marketingCustomers.GET("/:id/wallet", r.walletAdminHandler.GetCustomerWallet)
|
||||
marketingCustomers.POST("/:id/wallet/adjust", r.walletAdminHandler.AdjustCustomerWallet)
|
||||
marketingCustomers.POST("/:id/wallet/adjust", r.authMiddleware.RequireLoyaltyManager(), r.walletAdminHandler.AdjustCustomerWallet)
|
||||
}
|
||||
|
||||
campaignRules := gamification.Group("/campaign-rules")
|
||||
@@ -644,6 +646,9 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
outlets.PUT("/detail/:id", r.outletHandler.UpdateOutlet)
|
||||
outlets.GET("/printer-setting/:outlet_id", r.outletSettingHandler.GetPrinterSettings)
|
||||
outlets.PUT("/printer-setting/:outlet_id", r.outletSettingHandler.UpdatePrinterSettings)
|
||||
// EnakPoint & EnakCoin settings of the outlet (docs/prd-point-coin.md F1)
|
||||
outlets.GET("/:outlet_id/loyalty-settings", r.loyaltySettingsHandler.GetOutletSettings)
|
||||
outlets.PUT("/:outlet_id/loyalty-settings", r.authMiddleware.RequireLoyaltyManager(), r.loyaltySettingsHandler.UpdateOutletSettings)
|
||||
outlets.GET("/:outlet_id/tables/available", r.tableHandler.GetAvailableTables)
|
||||
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
|
||||
// Reports
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"apskel-pos-be/config"
|
||||
"apskel-pos-be/internal/middleware"
|
||||
)
|
||||
|
||||
// Gin panics at startup when two routes disagree on a path parameter's name at the
|
||||
// same position (say /outlets/:id/x next to /outlets/:outlet_id/y). Registering every
|
||||
// route here catches that in a test instead of on deploy. Handlers are nil: nothing is
|
||||
// served, only registered.
|
||||
func TestAllRoutesRegister(t *testing.T) {
|
||||
r := &Router{
|
||||
config: &config.Config{},
|
||||
authMiddleware: middleware.NewAuthMiddleware(nil),
|
||||
customerAuthMiddleware: middleware.NewCustomerAuthMiddleware("test"),
|
||||
}
|
||||
engine := r.Init()
|
||||
|
||||
registered := map[string]bool{}
|
||||
for _, route := range engine.Routes() {
|
||||
registered[route.Method+" "+route.Path] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"GET /api/v1/customer/wallet",
|
||||
"GET /api/v1/customer/wallet/transactions",
|
||||
"GET /api/v1/marketing/customers/:id/wallet",
|
||||
"POST /api/v1/marketing/customers/:id/wallet/adjust",
|
||||
"GET /api/v1/outlets/:outlet_id/loyalty-settings",
|
||||
"PUT /api/v1/outlets/:outlet_id/loyalty-settings",
|
||||
} {
|
||||
assert.True(t, registered[want], want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// LoyaltySettingsService is the dashboard's access to loyalty settings
|
||||
// (docs/prd-point-coin.md F1, F2). Every call is scoped to the caller's organization.
|
||||
type LoyaltySettingsService interface {
|
||||
GetOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID) *contract.Response
|
||||
// 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
|
||||
}
|
||||
|
||||
type LoyaltySettingsServiceImpl struct {
|
||||
settings *processor.LoyaltySettingsProcessor
|
||||
}
|
||||
|
||||
func NewLoyaltySettingsService(settings *processor.LoyaltySettingsProcessor) *LoyaltySettingsServiceImpl {
|
||||
return &LoyaltySettingsServiceImpl{settings: settings}
|
||||
}
|
||||
|
||||
func (s *LoyaltySettingsServiceImpl) GetOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID) *contract.Response {
|
||||
current, err := s.settings.OutletForOrganization(ctx, apctx.OrganizationID, outletID)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
view, err := s.outletView(ctx, apctx.OrganizationID, outletID, *current, nil)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
return contract.BuildSuccessResponse(view)
|
||||
}
|
||||
|
||||
// outletSettingsInput is what PUT accepts: the settings, plus the read-only fields of
|
||||
// the GET response so a client can send back what it received. Those are ignored.
|
||||
type outletSettingsInput struct {
|
||||
*models.OutletLoyaltySettings
|
||||
OutletID json.RawMessage `json:"outlet_id"`
|
||||
PointValue json.RawMessage `json:"point_value"`
|
||||
PointCashbackPercent json.RawMessage `json:"point_cashback_percent"`
|
||||
Changes json.RawMessage `json:"changes"`
|
||||
}
|
||||
|
||||
func (s *LoyaltySettingsServiceImpl) UpdateOutletSettings(ctx context.Context, apctx *appcontext.ContextInfo, outletID uuid.UUID, body []byte) *contract.Response {
|
||||
current, err := s.settings.OutletForOrganization(ctx, apctx.OrganizationID, outletID)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
|
||||
next := *current
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&outletSettingsInput{OutletLoyaltySettings: &next}); err != nil {
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.LoyaltySettingsServiceEntity, fmt.Sprintf("invalid request body: %v", err)),
|
||||
})
|
||||
}
|
||||
|
||||
changes, err := s.settings.UpdateOutlet(ctx, apctx.OrganizationID, outletID, apctx.UserID, next)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
saved, err := s.settings.Outlet(ctx, outletID)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
view, err := s.outletView(ctx, apctx.OrganizationID, outletID, *saved, changes)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
return contract.BuildSuccessResponse(view)
|
||||
}
|
||||
|
||||
func (s *LoyaltySettingsServiceImpl) outletView(ctx context.Context, organizationID, outletID uuid.UUID, settings models.OutletLoyaltySettings, changes []models.LoyaltySettingChange) (*models.OutletLoyaltySettingsView, error) {
|
||||
pointValue, err := s.settings.PointValue(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if changes == nil {
|
||||
changes = []models.LoyaltySettingChange{}
|
||||
}
|
||||
return &models.OutletLoyaltySettingsView{
|
||||
OutletID: outletID,
|
||||
OutletLoyaltySettings: settings,
|
||||
PointValue: pointValue,
|
||||
PointCashbackPercent: models.LoyaltyCashbackPercent(settings.Point.EarnValue, pointValue, settings.Point.EarnPerAmount),
|
||||
Changes: changes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loyaltyErrorResponse(err error) *contract.Response {
|
||||
code, message := constants.InternalServerErrorCode, err.Error()
|
||||
switch {
|
||||
case errors.Is(err, repository.ErrLoyaltyOutletNotFound):
|
||||
code, message = constants.NotFoundErrorCode, "outlet not found"
|
||||
case errors.Is(err, processor.ErrInvalidLoyaltySettings):
|
||||
code = constants.ValidationErrorCode
|
||||
}
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(code, constants.LoyaltySettingsServiceEntity, message),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user