Settles note N4 of docs/prd-point-coin.md: both expiry models are
supported, chosen per currency by the owner, defaulting to one fixed date a
year (PC-501, F12).
New organization keys, per currency (loyalty.point.* / loyalty.coin.*):
- expiry_mode: FIXED_DATE (default) or ROLLING.
- expiry_fixed_dates: the days of the year balances expire on, as sorted
MM-DD values ("12-31" by default, "06-30,12-31" for twice a year). 29 Feb
is refused.
- expiry_grace_months: 0 to 24, default 3. A balance lasts at least this
long before a fixed date takes it.
The existing period, unit and end_of_month keys now belong to ROLLING, and
reminder_days to both.
ComputeExpiry gives the expiry of a balance received at a time: the first
fixed date on or after the day received plus the grace months, or the day
received plus the period (to the end of that month when asked). Days are
the customer's (WIB), a shorter month keeps to its last day, and a lot
lasts to 23:59:59 of its day so the apps group it under that day. Nil when
expiry is off. ActivationExpiry, RefundExpiry and EarlierExpiry hold the
other decided rules and are used by PC-502.
GET and PUT /marketing/loyalty-settings return expiry_preview: when a
balance received now would expire, for the dashboard's "received today
expires on ..." hint, also on a dry run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
8.6 KiB
Go
220 lines
8.6 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, 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"`
|
|
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) {
|
|
now := time.Now()
|
|
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),
|
|
ExpiryPreview: models.LoyaltyExpiryPreview{
|
|
Point: processor.ComputeExpiry(after.PointExpiry, now),
|
|
Coin: processor.ComputeExpiry(after.CoinExpiry, now),
|
|
},
|
|
Changes: changes,
|
|
DryRun: dryRun,
|
|
}, nil
|
|
}
|