Main #33

Merged
aefril merged 57 commits from main into staging 2026-09-30 10:17:39 +02:00
10 changed files with 452 additions and 2 deletions
Showing only changes of commit 2bd53ee4a4 - Show all commits
+3
View File
@@ -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),
}
}
+1
View File
@@ -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
}
+8
View File
@@ -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")
}
+24
View File
@@ -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)
+7 -2
View File
@@ -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
+38
View File
@@ -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),
})
}