Files
apskel-pos-backend/internal/service/loyalty_settings_service.go
T
efrilmandClaude Opus 5.5 fe2f459b03 feat(loyalty): organization loyalty settings API
Adds GET and PUT /marketing/loyalty-settings and GET
/marketing/loyalty-settings/history (docs/prd-point-coin.md F2, PC-302).

The settings are the point value, the exchange rate, transfer limits and
the stored expiry settings. PUT merges the body like the outlet settings
and is limited to loyalty managers. Every response carries the impact of
the change on the balances in circulation: outstanding EnakPoint and
EnakCoin, their rupiah value, and the coins exchanged into points, before
and after. With ?dry_run=true nothing is saved and the response lists the
keys that would change, for the warning shown before saving.

Saving records each change in loyalty_setting_changes with who made it;
history can be filtered to one outlet. Changing the value leaves what was
already written alone. The diff behind saving and previewing is shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:26:16 +07:00

213 lines
8.4 KiB
Go

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
}