225 lines
8.9 KiB
Go
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
|
||
|
|
}
|