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
@@ -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