Files
apskel-pos-backend/internal/service/loyalty_settings_service.go
T
2026-09-30 15:31:44 +07:00

225 lines
8.9 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"time"
"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
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
outstanding outstandingBalanceReader
}
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 {
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),
})
}
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, 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"`
ExpiryPreview json.RawMessage `json:"expiry_preview"`
ExpiryActivations json.RawMessage `json:"expiry_activations"`
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
var activations []models.LoyaltyExpiryActivation
if dryRun {
changes, activations, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next)
} else {
changes, activations, 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, activations, 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, activations []models.LoyaltyExpiryActivation, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) {
now := time.Now()
points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID)
if err != nil {
return nil, err
}
if activations == nil {
activations = []models.LoyaltyExpiryActivation{}
}
if changes == nil {
changes = []models.LoyaltySettingChange{}
}
return &models.OrganizationLoyaltySettingsView{
OrganizationLoyaltySettings: after,
Impact: models.NewLoyaltySettingsImpact(points, coins, before, after),
ExpiryPreview: models.LoyaltyExpiryPreview{
Point: processor.ComputeExpiry(after.PointExpiry, now),
Coin: processor.ComputeExpiry(after.CoinExpiry, now),
},
Changes: changes,
DryRun: dryRun,
}, nil
}