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 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, 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"` 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 if dryRun { changes, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next) } else { changes, 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, 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, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) { points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID) if err != nil { return nil, err } if changes == nil { changes = []models.LoyaltySettingChange{} } return &models.OrganizationLoyaltySettingsView{ OrganizationLoyaltySettings: after, Impact: models.NewLoyaltySettingsImpact(points, coins, before, after), Changes: changes, DryRun: dryRun, }, nil }