Revert "feat(loyalty): EnakPoint & EnakCoin" (#32)

This reverts merge commit 645da30, returning main to f0ff59f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 15:16:15 +07:00
co-authored by Claude Opus 5.5
parent 645da3048e
commit 4e24f9bbb0
195 changed files with 2179 additions and 20060 deletions
+7 -7
View File
@@ -177,7 +177,7 @@ func (s *campaignService) CreateCampaignRule(ctx context.Context, req *contract.
}
// Validate reward value based on reward type
if req.RewardType == "POINTS" || req.RewardType == "COINS" || req.RewardType == "TOKENS" {
if req.RewardType == "POINTS" || req.RewardType == "TOKENS" {
if req.RewardValue == nil || *req.RewardValue <= 0 {
return nil, fmt.Errorf("reward value must be positive for %s type", req.RewardType)
}
@@ -247,7 +247,7 @@ func (s *campaignService) UpdateCampaignRule(ctx context.Context, req *contract.
}
// Validate reward value based on reward type
if req.RewardType == "POINTS" || req.RewardType == "COINS" || req.RewardType == "TOKENS" {
if req.RewardType == "POINTS" || req.RewardType == "TOKENS" {
if req.RewardValue == nil || *req.RewardValue <= 0 {
return nil, fmt.Errorf("reward value must be positive for %s type", req.RewardType)
}
@@ -295,13 +295,13 @@ func (s *campaignService) GetCampaignRulesByCampaignID(ctx context.Context, camp
}
func (s *campaignService) validateCampaignType(campaignType string) error {
validTypes := []string{"REWARD", "POINTS", "COINS", "TOKENS", "MIXED"}
validTypes := []string{"REWARD", "POINTS", "TOKENS", "MIXED"}
for _, validType := range validTypes {
if campaignType == validType {
return nil
}
}
return fmt.Errorf("invalid campaign type: %s. Valid types are: REWARD, POINTS, COINS, MIXED", campaignType)
return fmt.Errorf("invalid campaign type: %s. Valid types are: REWARD, POINTS, TOKENS, MIXED", campaignType)
}
func (s *campaignService) validateDateRange(startDate, endDate time.Time) error {
@@ -335,7 +335,7 @@ func (s *campaignService) validateCampaignRules(rules []contract.CampaignRuleStr
}
// Validate reward value based on reward type
if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || rule.RewardType == "TOKENS" {
if rule.RewardType == "POINTS" || rule.RewardType == "TOKENS" {
if rule.RewardValue == nil || *rule.RewardValue <= 0 {
return fmt.Errorf("reward value must be positive for %s type in rule %d", rule.RewardType, i+1)
}
@@ -365,11 +365,11 @@ func (s *campaignService) validateRuleType(ruleType string) error {
}
func (s *campaignService) validateRewardType(rewardType string) error {
validTypes := []string{"POINTS", "COINS", "TOKENS", "REWARD"}
validTypes := []string{"POINTS", "TOKENS", "REWARD"}
for _, validType := range validTypes {
if rewardType == validType {
return nil
}
}
return fmt.Errorf("invalid reward type: %s. Valid types are: POINTS, COINS, REWARD", rewardType)
return fmt.Errorf("invalid reward type: %s. Valid types are: POINTS, TOKENS, REWARD", rewardType)
}
@@ -1,58 +0,0 @@
package service
import (
"context"
"errors"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
)
// CustomerDeviceService registers the customer app's devices for push notifications.
type CustomerDeviceService interface {
Register(ctx context.Context, customerID uuid.UUID, req *contract.RegisterCustomerDeviceRequest) *contract.Response
Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) *contract.Response
}
type CustomerDeviceServiceImpl struct {
devices *processor.CustomerDeviceProcessor
}
func NewCustomerDeviceService(devices *processor.CustomerDeviceProcessor) *CustomerDeviceServiceImpl {
return &CustomerDeviceServiceImpl{devices: devices}
}
func (s *CustomerDeviceServiceImpl) Register(ctx context.Context, customerID uuid.UUID, req *contract.RegisterCustomerDeviceRequest) *contract.Response {
err := s.devices.Register(ctx, repository.CustomerDevice{
CustomerID: customerID,
DeviceID: req.DeviceID,
FCMToken: req.FCMToken,
Platform: req.Platform,
AppVersion: req.AppVersion,
})
if err != nil {
return customerDeviceErrorResponse(err)
}
return contract.BuildSuccessResponse(map[string]interface{}{"device_id": req.DeviceID})
}
func (s *CustomerDeviceServiceImpl) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) *contract.Response {
if err := s.devices.Unregister(ctx, customerID, deviceID); err != nil {
return customerDeviceErrorResponse(err)
}
return contract.BuildSuccessResponse(map[string]interface{}{"device_id": deviceID})
}
func customerDeviceErrorResponse(err error) *contract.Response {
code := constants.InternalServerErrorCode
if errors.Is(err, processor.ErrInvalidCustomerDevice) {
code = constants.ValidationErrorCode
}
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.RequestEntity, err.Error()),
})
}
@@ -1,34 +0,0 @@
package service
import (
"context"
"github.com/google/uuid"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/transformer"
)
// CustomerOrderPaymentService lets customers pay their own orders with EnakPoint in the
// app or a self-order (docs/prd-point-coin.md F9).
type CustomerOrderPaymentService interface {
PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response
}
type CustomerOrderPaymentServiceImpl struct {
orders processor.OrderProcessor
}
func NewCustomerOrderPaymentService(orders processor.OrderProcessor) *CustomerOrderPaymentServiceImpl {
return &CustomerOrderPaymentServiceImpl{orders: orders}
}
func (s *CustomerOrderPaymentServiceImpl) PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response {
payment, err := s.orders.PayWithPointsInApp(ctx, customerID, orderID, req.Points, req.Pin, info)
if err != nil {
return PointPaymentErrorResponse(err)
}
return contract.BuildSuccessResponse(transformer.PaymentModelToContract(payment))
}
-140
View File
@@ -1,140 +0,0 @@
package service
import (
"context"
"errors"
"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"
)
// CustomerPinService serves the customer's PIN (docs/prd-point-coin.md F11) and the
// dashboard's view of it.
type CustomerPinService interface {
Status(ctx context.Context, customerID uuid.UUID) *contract.Response
RequestOtp(ctx context.Context, customerID uuid.UUID, req *contract.RequestPinOtpRequest) *contract.Response
CreatePin(ctx context.Context, customerID uuid.UUID, req *contract.CreateCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response
ChangePin(ctx context.Context, customerID uuid.UUID, req *contract.ChangeCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response
ResetPin(ctx context.Context, customerID uuid.UUID, req *contract.ResetCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response
RemovePin(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.RemoveCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response
ListSecurityEvents(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, page, limit int) *contract.Response
// IssuePaymentCode checks the PIN and returns a one-time code for the cashier (F9).
IssuePaymentCode(ctx context.Context, customerID uuid.UUID, req *contract.IssuePaymentCodeRequest, info models.CustomerPinRequestInfo) *contract.Response
}
type CustomerPinServiceImpl struct {
pins *processor.CustomerPinProcessor
codes *processor.PaymentCodeProcessor
}
func NewCustomerPinService(pins *processor.CustomerPinProcessor, codes *processor.PaymentCodeProcessor) *CustomerPinServiceImpl {
return &CustomerPinServiceImpl{pins: pins, codes: codes}
}
func (s *CustomerPinServiceImpl) Status(ctx context.Context, customerID uuid.UUID) *contract.Response {
status, err := s.pins.Status(ctx, customerID)
if err != nil {
return PinErrorResponse(err)
}
return contract.BuildSuccessResponse(status)
}
func (s *CustomerPinServiceImpl) RequestOtp(ctx context.Context, customerID uuid.UUID, req *contract.RequestPinOtpRequest) *contract.Response {
otp, err := s.pins.RequestOtp(ctx, customerID, req.Purpose)
if err != nil {
return PinErrorResponse(err)
}
return contract.BuildSuccessResponse(otp)
}
func (s *CustomerPinServiceImpl) CreatePin(ctx context.Context, customerID uuid.UUID, req *contract.CreateCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response {
if err := s.pins.CreatePin(ctx, customerID, req.OtpToken, req.OtpCode, req.Pin, req.ConfirmPin, info); err != nil {
return PinErrorResponse(err)
}
return s.Status(ctx, customerID)
}
func (s *CustomerPinServiceImpl) ChangePin(ctx context.Context, customerID uuid.UUID, req *contract.ChangeCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response {
if err := s.pins.ChangePin(ctx, customerID, req.OldPin, req.Pin, req.ConfirmPin, info); err != nil {
return PinErrorResponse(err)
}
return s.Status(ctx, customerID)
}
func (s *CustomerPinServiceImpl) ResetPin(ctx context.Context, customerID uuid.UUID, req *contract.ResetCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response {
if err := s.pins.ResetPin(ctx, customerID, req.OtpToken, req.OtpCode, req.Pin, req.ConfirmPin, info); err != nil {
return PinErrorResponse(err)
}
return s.Status(ctx, customerID)
}
func (s *CustomerPinServiceImpl) RemovePin(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.RemoveCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response {
if err := s.pins.RemovePinByAdmin(ctx, apctx.OrganizationID, customerID, apctx.UserID, req.Reason, info); err != nil {
return PinErrorResponse(err)
}
return contract.BuildSuccessResponse(map[string]interface{}{"message": "PIN removed; the customer has to create a new one"})
}
func (s *CustomerPinServiceImpl) ListSecurityEvents(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, page, limit int) *contract.Response {
events, err := s.pins.ListEvents(ctx, apctx.OrganizationID, customerID, page, limit)
if err != nil {
return PinErrorResponse(err)
}
return contract.BuildSuccessResponse(events)
}
// PinErrorResponse turns an error from a PIN-guarded action into a response the apps
// can act on. A *processor.PinError keeps its code (PIN_NOT_SET, PIN_INVALID,
// PIN_LOCKED, TRANSFER_BLOCKED) and puts the attempts left or the time it lifts in the
// response data. Other errors map to a validation or server error.
func PinErrorResponse(err error) *contract.Response {
var pinErr *processor.PinError
if errors.As(err, &pinErr) {
data := map[string]interface{}{"code": pinErr.Code}
switch pinErr.Code {
case processor.PinErrInvalid:
data["remaining_attempts"] = pinErr.RemainingAttempts
case processor.PinErrLocked:
data["locked_until"] = pinErr.Until
case processor.PinErrTransferBlocked:
data["transfer_blocked_until"] = pinErr.Until
}
return &contract.Response{
Success: false,
Data: data,
Errors: []*contract.ResponseError{contract.NewResponseError(pinErr.Code, constants.CustomerPinServiceEntity, pinErr.Error())},
}
}
code := constants.InternalServerErrorCode
switch {
case errors.Is(err, repository.ErrPinCustomerNotFound):
code = constants.NotFoundErrorCode
case errors.Is(err, processor.ErrPinOtpTooSoon):
code = constants.TooManyRequestsErrorCode
case errors.Is(err, processor.ErrInvalidPinInput),
errors.Is(err, processor.ErrPinAlreadySet),
errors.Is(err, processor.ErrPinOtpInvalid),
errors.Is(err, processor.ErrPinNoPhone):
code = constants.ValidationErrorCode
}
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.CustomerPinServiceEntity, err.Error()),
})
}
func (s *CustomerPinServiceImpl) IssuePaymentCode(ctx context.Context, customerID uuid.UUID, req *contract.IssuePaymentCodeRequest, info models.CustomerPinRequestInfo) *contract.Response {
code, err := s.codes.Issue(ctx, customerID, req.Pin, info)
if err != nil {
return PinErrorResponse(err)
}
return contract.BuildSuccessResponse(code)
}
@@ -1,50 +0,0 @@
package service
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/util"
)
func TestPinErrorResponse(t *testing.T) {
until := time.Date(2026, 9, 30, 12, 30, 0, 0, time.UTC)
for name, c := range map[string]struct {
err error
code string
status int
data map[string]interface{}
}{
"not set": {&processor.PinError{Code: processor.PinErrNotSet}, "PIN_NOT_SET", http.StatusForbidden, map[string]interface{}{"code": "PIN_NOT_SET"}},
"invalid": {fmt.Errorf("pay: %w", &processor.PinError{Code: processor.PinErrInvalid, RemainingAttempts: 2}), "PIN_INVALID", http.StatusBadRequest, map[string]interface{}{"code": "PIN_INVALID", "remaining_attempts": 2}},
"locked": {&processor.PinError{Code: processor.PinErrLocked, Until: &until}, "PIN_LOCKED", http.StatusLocked, map[string]interface{}{"code": "PIN_LOCKED", "locked_until": &until}},
"transfer": {&processor.PinError{Code: processor.PinErrTransferBlocked, Until: &until}, "TRANSFER_BLOCKED", http.StatusForbidden, map[string]interface{}{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": &until}},
} {
resp := PinErrorResponse(c.err)
assert.False(t, resp.Success, name)
assert.Equal(t, c.code, resp.Errors[0].Code, name)
assert.Equal(t, c.status, util.MapErrorCodeToHttpStatus(resp.Errors[0].Code), name)
assert.Equal(t, c.data, resp.Data, name)
}
for name, c := range map[string]struct {
err error
status int
}{
"weak PIN": {fmt.Errorf("%w: too easy", processor.ErrInvalidPinInput), http.StatusBadRequest},
"bad OTP": {processor.ErrPinOtpInvalid, http.StatusBadRequest},
"already set": {processor.ErrPinAlreadySet, http.StatusBadRequest},
"too soon": {processor.ErrPinOtpTooSoon, http.StatusTooManyRequests},
"no customer": {repository.ErrPinCustomerNotFound, http.StatusNotFound},
"anything else": {fmt.Errorf("db down"), http.StatusInternalServerError},
} {
resp := PinErrorResponse(c.err)
assert.Equal(t, c.status, util.MapErrorCodeToHttpStatus(resp.Errors[0].Code), name)
}
}
@@ -12,8 +12,6 @@ type CustomerPointsService interface {
GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error)
GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error)
GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error)
GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error)
GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error)
GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error)
GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error)
}
@@ -84,17 +82,3 @@ func (s *customerPointsService) GetFerrisWheelGame(ctx context.Context) (*models
return response, nil
}
func (s *customerPointsService) GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) {
if customerID == "" {
return nil, fmt.Errorf("customer ID is required")
}
return s.customerPointsProcessor.GetCustomerWalletTransactionsAPI(ctx, customerID, query)
}
func (s *customerPointsService) GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) {
if customerID == "" {
return nil, fmt.Errorf("customer ID is required")
}
return s.customerPointsProcessor.GetCustomerWalletExpiringAPI(ctx, customerID)
}
@@ -1,91 +0,0 @@
package service
import (
"context"
"errors"
"github.com/google/uuid"
"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"
)
// CustomerWalletService moves balance on the customer's own request: exchanging
// EnakCoin into EnakPoint and sending either to another customer
// (docs/prd-point-coin.md F4, F5).
type CustomerWalletService interface {
PreviewExchange(ctx context.Context, customerID uuid.UUID, coins int64) *contract.Response
Exchange(ctx context.Context, customerID uuid.UUID, req *contract.ExchangeCoinsRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response
TransferRecipient(ctx context.Context, customerID uuid.UUID, phoneNumber string) *contract.Response
Transfer(ctx context.Context, customerID uuid.UUID, req *contract.TransferWalletRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response
}
type CustomerWalletServiceImpl struct {
exchanges *processor.WalletExchangeProcessor
transfers *processor.WalletTransferProcessor
}
func NewCustomerWalletService(exchanges *processor.WalletExchangeProcessor, transfers *processor.WalletTransferProcessor) *CustomerWalletServiceImpl {
return &CustomerWalletServiceImpl{exchanges: exchanges, transfers: transfers}
}
func (s *CustomerWalletServiceImpl) TransferRecipient(ctx context.Context, customerID uuid.UUID, phoneNumber string) *contract.Response {
recipient, err := s.transfers.Recipient(ctx, customerID, phoneNumber)
if err != nil {
return walletMoveErrorResponse(err)
}
return contract.BuildSuccessResponse(recipient)
}
func (s *CustomerWalletServiceImpl) Transfer(ctx context.Context, customerID uuid.UUID, req *contract.TransferWalletRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response {
result, err := s.transfers.Transfer(ctx, customerID, models.WalletTransfer{
Currency: req.Currency,
Amount: req.Amount,
RecipientPhone: req.RecipientPhone,
}, req.Pin, idempotencyKey, info)
if err != nil {
return walletMoveErrorResponse(err)
}
return contract.BuildSuccessResponse(result)
}
func (s *CustomerWalletServiceImpl) PreviewExchange(ctx context.Context, customerID uuid.UUID, coins int64) *contract.Response {
preview, err := s.exchanges.Preview(ctx, customerID, coins)
if err != nil {
return walletMoveErrorResponse(err)
}
return contract.BuildSuccessResponse(preview)
}
func (s *CustomerWalletServiceImpl) Exchange(ctx context.Context, customerID uuid.UUID, req *contract.ExchangeCoinsRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response {
result, err := s.exchanges.Exchange(ctx, customerID, req.Coins, req.Pin, idempotencyKey, info)
if err != nil {
return walletMoveErrorResponse(err)
}
return contract.BuildSuccessResponse(result)
}
// walletMoveErrorResponse keeps the PIN codes the apps act on, and tells a refused
// request apart from a server failure.
func walletMoveErrorResponse(err error) *contract.Response {
var pinErr *processor.PinError
if errors.As(err, &pinErr) {
return PinErrorResponse(err)
}
code := constants.InternalServerErrorCode
switch {
case errors.Is(err, repository.ErrWalletNotFound),
errors.Is(err, processor.ErrWalletRecipientNotFound):
code = constants.NotFoundErrorCode
case errors.Is(err, processor.ErrWalletMoveRejected),
errors.Is(err, processor.ErrWalletIdempotencyConflict),
errors.Is(err, processor.ErrWalletInvalidEntry):
code = constants.ValidationErrorCode
}
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.WalletServiceEntity, err.Error()),
})
}
-74
View File
@@ -1,74 +0,0 @@
package service
import (
"context"
"sync"
"time"
"apskel-pos-be/internal/logger"
)
const (
defaultEarningBackfillInterval = 30 * time.Minute
// How far back to look for paid orders that never earned.
earningBackfillWindow = 72 * time.Hour
// Orders looked at per run at most, so one run cannot run away.
earningBackfillMaxOrders = 5000
)
type missingEarner interface {
EarnMissing(ctx context.Context, since time.Time, maxOrders int) (checked, earned int, err error)
}
// EarningBackfillJob is the safety net behind earning at payment time
// (docs/prd-point-coin.md F3, PC-203). Every run it earns for orders paid in the last
// few days that should have earned and did not, for example because the database was
// briefly unreachable right after the payment committed.
type EarningBackfillJob struct {
earner missingEarner
now func() time.Time
stopCh chan struct{}
stopOnce sync.Once
}
func NewEarningBackfillJob(earner missingEarner) *EarningBackfillJob {
return &EarningBackfillJob{earner: earner, now: time.Now, stopCh: make(chan struct{})}
}
func (j *EarningBackfillJob) Start(interval time.Duration) {
if interval <= 0 {
interval = defaultEarningBackfillInterval
}
go func() {
j.RunOnce(context.Background())
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
j.RunOnce(context.Background())
case <-j.stopCh:
return
}
}
}()
logger.NonContext.Infof("Earning backfill job started (interval: %s)", interval)
}
func (j *EarningBackfillJob) Stop() {
j.stopOnce.Do(func() { close(j.stopCh) })
}
// RunOnce earns for every missed order in the window and reports how many it fixed.
// It is quiet when nothing was missed.
func (j *EarningBackfillJob) RunOnce(ctx context.Context) int {
checked, earned, err := j.earner.EarnMissing(ctx, j.now().Add(-earningBackfillWindow), earningBackfillMaxOrders)
if err != nil {
logger.NonContext.Error("Earning backfill failed to run", err)
}
if earned > 0 {
logger.NonContext.WarnWithFields("Earning backfill credited orders that had missed their earning",
map[string]interface{}{"checked": checked, "earned": earned}, nil)
}
return earned
}
@@ -1,46 +0,0 @@
package service
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"apskel-pos-be/internal/logger"
)
type missingEarnerFake struct {
since time.Time
max int
earned int
err error
calls int
}
func (f *missingEarnerFake) EarnMissing(_ context.Context, since time.Time, maxOrders int) (int, int, error) {
f.calls++
f.since, f.max = since, maxOrders
return f.earned * 2, f.earned, f.err
}
func TestEarningBackfillJob(t *testing.T) {
logger.Setup("fatal", "json")
now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
earner := &missingEarnerFake{earned: 3}
job := NewEarningBackfillJob(earner)
job.now = func() time.Time { return now }
assert.Equal(t, 3, job.RunOnce(context.Background()))
assert.Equal(t, now.Add(-72*time.Hour), earner.since, "looks back three days")
assert.Equal(t, earningBackfillMaxOrders, earner.max)
// A failing run is logged, not fatal.
earner.earned, earner.err = 0, errors.New("db down")
assert.Equal(t, 0, job.RunOnce(context.Background()))
job.Start(time.Hour)
job.Stop()
job.Stop()
}
+164 -10
View File
@@ -11,6 +11,26 @@ import (
)
type GamificationService interface {
// Customer Points
CreateCustomerPoints(ctx context.Context, req *contract.CreateCustomerPointsRequest) (*contract.CustomerPointsResponse, error)
GetCustomerPoints(ctx context.Context, id uuid.UUID) (*contract.CustomerPointsResponse, error)
GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*contract.CustomerPointsResponse, error)
ListCustomerPoints(ctx context.Context, query *contract.ListCustomerPointsRequest) (*contract.PaginatedCustomerPointsResponse, error)
UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerPointsRequest) (*contract.CustomerPointsResponse, error)
DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error
AddCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.AddCustomerPointsRequest) (*contract.CustomerPointsResponse, error)
DeductCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.DeductCustomerPointsRequest) (*contract.CustomerPointsResponse, error)
// Customer Tokens
CreateCustomerTokens(ctx context.Context, req *contract.CreateCustomerTokensRequest) (*contract.CustomerTokensResponse, error)
GetCustomerTokens(ctx context.Context, id uuid.UUID) (*contract.CustomerTokensResponse, error)
GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*contract.CustomerTokensResponse, error)
ListCustomerTokens(ctx context.Context, query *contract.ListCustomerTokensRequest) (*contract.PaginatedCustomerTokensResponse, error)
UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerTokensRequest) (*contract.CustomerTokensResponse, error)
DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error
AddCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.AddCustomerTokensRequest) (*contract.CustomerTokensResponse, error)
DeductCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.DeductCustomerTokensRequest) (*contract.CustomerTokensResponse, error)
// Tiers
CreateTier(ctx context.Context, req *contract.CreateTierRequest) (*contract.TierResponse, error)
GetTier(ctx context.Context, id uuid.UUID) (*contract.TierResponse, error)
@@ -52,14 +72,18 @@ type GamificationService interface {
}
type GamificationServiceImpl struct {
tierProcessor *processor.TierProcessor
gameProcessor *processor.GameProcessor
gamePrizeProcessor *processor.GamePrizeProcessor
gamePlayProcessor *processor.GamePlayProcessor
omsetTrackerProcessor *processor.OmsetTrackerProcessor
customerPointsProcessor *processor.CustomerPointsProcessor
customerTokensProcessor *processor.CustomerTokensProcessor
tierProcessor *processor.TierProcessor
gameProcessor *processor.GameProcessor
gamePrizeProcessor *processor.GamePrizeProcessor
gamePlayProcessor *processor.GamePlayProcessor
omsetTrackerProcessor *processor.OmsetTrackerProcessor
}
func NewGamificationService(
customerPointsProcessor *processor.CustomerPointsProcessor,
customerTokensProcessor *processor.CustomerTokensProcessor,
tierProcessor *processor.TierProcessor,
gameProcessor *processor.GameProcessor,
gamePrizeProcessor *processor.GamePrizeProcessor,
@@ -67,14 +91,144 @@ func NewGamificationService(
omsetTrackerProcessor *processor.OmsetTrackerProcessor,
) *GamificationServiceImpl {
return &GamificationServiceImpl{
tierProcessor: tierProcessor,
gameProcessor: gameProcessor,
gamePrizeProcessor: gamePrizeProcessor,
gamePlayProcessor: gamePlayProcessor,
omsetTrackerProcessor: omsetTrackerProcessor,
customerPointsProcessor: customerPointsProcessor,
customerTokensProcessor: customerTokensProcessor,
tierProcessor: tierProcessor,
gameProcessor: gameProcessor,
gamePrizeProcessor: gamePrizeProcessor,
gamePlayProcessor: gamePlayProcessor,
omsetTrackerProcessor: omsetTrackerProcessor,
}
}
// Customer Points Service Methods
func (s *GamificationServiceImpl) CreateCustomerPoints(ctx context.Context, req *contract.CreateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) {
modelReq := transformer.CreateCustomerPointsRequestToModel(req)
response, err := s.customerPointsProcessor.CreateCustomerPoints(ctx, modelReq)
if err != nil {
return nil, err
}
return transformer.CustomerPointsModelToResponse(response), nil
}
func (s *GamificationServiceImpl) GetCustomerPoints(ctx context.Context, id uuid.UUID) (*contract.CustomerPointsResponse, error) {
response, err := s.customerPointsProcessor.GetCustomerPoints(ctx, id)
if err != nil {
return nil, err
}
return transformer.CustomerPointsModelToResponse(response), nil
}
func (s *GamificationServiceImpl) GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*contract.CustomerPointsResponse, error) {
response, err := s.customerPointsProcessor.GetCustomerPointsByCustomerID(ctx, customerID)
if err != nil {
return nil, err
}
return transformer.CustomerPointsModelToResponse(response), nil
}
func (s *GamificationServiceImpl) ListCustomerPoints(ctx context.Context, query *contract.ListCustomerPointsRequest) (*contract.PaginatedCustomerPointsResponse, error) {
modelQuery := transformer.ListCustomerPointsRequestToModel(query)
response, err := s.customerPointsProcessor.ListCustomerPoints(ctx, modelQuery)
if err != nil {
return nil, err
}
return transformer.PaginatedCustomerPointsResponseToContract(response), nil
}
func (s *GamificationServiceImpl) UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) {
modelReq := transformer.UpdateCustomerPointsRequestToModel(req)
response, err := s.customerPointsProcessor.UpdateCustomerPoints(ctx, id, modelReq)
if err != nil {
return nil, err
}
return transformer.CustomerPointsModelToResponse(response), nil
}
func (s *GamificationServiceImpl) DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error {
return s.customerPointsProcessor.DeleteCustomerPoints(ctx, id)
}
func (s *GamificationServiceImpl) AddCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.AddCustomerPointsRequest) (*contract.CustomerPointsResponse, error) {
response, err := s.customerPointsProcessor.AddPoints(ctx, customerID, req.Points)
if err != nil {
return nil, err
}
return transformer.CustomerPointsModelToResponse(response), nil
}
func (s *GamificationServiceImpl) DeductCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.DeductCustomerPointsRequest) (*contract.CustomerPointsResponse, error) {
response, err := s.customerPointsProcessor.DeductPoints(ctx, customerID, req.Points)
if err != nil {
return nil, err
}
return transformer.CustomerPointsModelToResponse(response), nil
}
// Customer Tokens Service Methods
func (s *GamificationServiceImpl) CreateCustomerTokens(ctx context.Context, req *contract.CreateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) {
modelReq := transformer.CreateCustomerTokensRequestToModel(req)
response, err := s.customerTokensProcessor.CreateCustomerTokens(ctx, modelReq)
if err != nil {
return nil, err
}
return transformer.CustomerTokensModelToResponse(response), nil
}
func (s *GamificationServiceImpl) GetCustomerTokens(ctx context.Context, id uuid.UUID) (*contract.CustomerTokensResponse, error) {
response, err := s.customerTokensProcessor.GetCustomerTokens(ctx, id)
if err != nil {
return nil, err
}
return transformer.CustomerTokensModelToResponse(response), nil
}
func (s *GamificationServiceImpl) GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*contract.CustomerTokensResponse, error) {
response, err := s.customerTokensProcessor.GetCustomerTokensByCustomerIDAndType(ctx, customerID, tokenType)
if err != nil {
return nil, err
}
return transformer.CustomerTokensModelToResponse(response), nil
}
func (s *GamificationServiceImpl) ListCustomerTokens(ctx context.Context, query *contract.ListCustomerTokensRequest) (*contract.PaginatedCustomerTokensResponse, error) {
modelQuery := transformer.ListCustomerTokensRequestToModel(query)
response, err := s.customerTokensProcessor.ListCustomerTokens(ctx, modelQuery)
if err != nil {
return nil, err
}
return transformer.PaginatedCustomerTokensResponseToContract(response), nil
}
func (s *GamificationServiceImpl) UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) {
modelReq := transformer.UpdateCustomerTokensRequestToModel(req)
response, err := s.customerTokensProcessor.UpdateCustomerTokens(ctx, id, modelReq)
if err != nil {
return nil, err
}
return transformer.CustomerTokensModelToResponse(response), nil
}
func (s *GamificationServiceImpl) DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error {
return s.customerTokensProcessor.DeleteCustomerTokens(ctx, id)
}
func (s *GamificationServiceImpl) AddCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.AddCustomerTokensRequest) (*contract.CustomerTokensResponse, error) {
response, err := s.customerTokensProcessor.AddTokens(ctx, customerID, tokenType, req.Tokens)
if err != nil {
return nil, err
}
return transformer.CustomerTokensModelToResponse(response), nil
}
func (s *GamificationServiceImpl) DeductCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.DeductCustomerTokensRequest) (*contract.CustomerTokensResponse, error) {
response, err := s.customerTokensProcessor.DeductTokens(ctx, customerID, tokenType, req.Tokens)
if err != nil {
return nil, err
}
return transformer.CustomerTokensModelToResponse(response), nil
}
// Tier Service Methods
func (s *GamificationServiceImpl) CreateTier(ctx context.Context, req *contract.CreateTierRequest) (*contract.TierResponse, error) {
modelReq := transformer.CreateTierRequestToModel(req)
@@ -1,224 +0,0 @@
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
}
+1 -2
View File
@@ -557,8 +557,7 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen
return fmt.Errorf("payment method ID is required")
}
// A payment with EnakPoint gives points instead; its amount is computed from them.
if req.Points == nil && req.Amount <= 0 {
if req.Amount <= 0 {
return fmt.Errorf("payment amount must be greater than zero")
}
@@ -42,14 +42,6 @@ func (m *MockOrderProcessor) UpdateOrder(ctx context.Context, id uuid.UUID, req
return args.Get(0).(*models.OrderResponse), args.Error(1)
}
func (m *MockOrderProcessor) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) {
args := m.Called(ctx, customerID, orderID, points, pin, info)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.PaymentResponse), args.Error(1)
}
func (m *MockOrderProcessor) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
+3 -14
View File
@@ -2,10 +2,8 @@ package service
import (
"context"
"errors"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/processor"
@@ -41,7 +39,7 @@ func (s *PaymentMethodServiceImpl) CreatePaymentMethod(ctx context.Context, cont
response, err := s.paymentMethodProcessor.CreatePaymentMethod(ctx, modelReq)
if err != nil {
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_CREATE_ERROR"), "payment_method", err.Error()),
contract.NewResponseError("PAYMENT_METHOD_CREATE_ERROR", "payment_method", err.Error()),
})
}
@@ -86,7 +84,7 @@ func (s *PaymentMethodServiceImpl) UpdatePaymentMethod(ctx context.Context, id u
response, err := s.paymentMethodProcessor.UpdatePaymentMethod(ctx, id, modelReq)
if err != nil {
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_UPDATE_ERROR"), "payment_method", err.Error()),
contract.NewResponseError("PAYMENT_METHOD_UPDATE_ERROR", "payment_method", err.Error()),
})
}
@@ -99,7 +97,7 @@ func (s *PaymentMethodServiceImpl) DeletePaymentMethod(ctx context.Context, id u
err := s.paymentMethodProcessor.DeletePaymentMethod(ctx, id)
if err != nil {
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_DELETE_ERROR"), "payment_method", err.Error()),
contract.NewResponseError("PAYMENT_METHOD_DELETE_ERROR", "payment_method", err.Error()),
})
}
@@ -125,12 +123,3 @@ func (s *PaymentMethodServiceImpl) GetActivePaymentMethodsByOrganization(ctx con
return contract.BuildSuccessResponse(contractResponses)
}
// paymentMethodErrorCode answers a request to create, delete or retype the EnakPoint
// method as a bad request instead of a server error.
func paymentMethodErrorCode(err error, fallback string) string {
if errors.Is(err, processor.ErrSystemPaymentMethod) {
return constants.ValidationErrorCode
}
return fallback
}
-55
View File
@@ -1,55 +0,0 @@
package service
import (
"context"
"errors"
"github.com/google/uuid"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
)
// PointPaymentService serves what the cashier needs before taking EnakPoint
// (docs/prd-point-coin.md F9).
type PointPaymentService interface {
Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response
}
type PointPaymentServiceImpl struct {
payments *processor.PointPaymentProcessor
}
func NewPointPaymentService(payments *processor.PointPaymentProcessor) *PointPaymentServiceImpl {
return &PointPaymentServiceImpl{payments: payments}
}
func (s *PointPaymentServiceImpl) Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response {
preview, err := s.payments.Preview(ctx, apctx.OrganizationID, orderID)
if err != nil {
return PointPaymentErrorResponse(err)
}
return contract.BuildSuccessResponse(preview)
}
// PointPaymentErrorResponse answers a refused EnakPoint payment as a bad request, PIN
// problems with their own codes, and anything else as a server error.
func PointPaymentErrorResponse(err error) *contract.Response {
var pinErr *processor.PinError
if errors.As(err, &pinErr) {
return PinErrorResponse(err)
}
code := constants.InternalServerErrorCode
switch {
case errors.Is(err, repository.ErrPointPaymentOrderNotFound):
code = constants.NotFoundErrorCode
case errors.Is(err, processor.ErrPointPaymentRejected):
code = constants.ValidationErrorCode
}
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.WalletServiceEntity, err.Error()),
})
}
+27 -9
View File
@@ -6,6 +6,7 @@ import (
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
@@ -16,11 +17,13 @@ type SpinGameService interface {
type spinGameService struct {
gamePlayProcessor *processor.GamePlayProcessor
txManager *repository.TxManager
}
func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor) SpinGameService {
func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor, txManager *repository.TxManager) SpinGameService {
return &spinGameService{
gamePlayProcessor: gamePlayProcessor,
txManager: txManager,
}
}
@@ -42,13 +45,28 @@ func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, r
return nil, fmt.Errorf("invalid customer ID format: %w", err)
}
// PlayGame runs its own transaction and charges the game's coin_cost in EnakCoin.
playResponse, err := s.gamePlayProcessor.PlayGame(ctx, &models.PlayGameRequest{
GameID: spinID,
CustomerID: customerUUID,
// Use transaction to ensure consistency
var playResponse *models.PlayGameResponse
err = s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
// Create play game request
playGameReq := &models.PlayGameRequest{
GameID: spinID,
CustomerID: customerUUID,
TokenUsed: 1, // Default to 1 token per spin
}
// Play the game within transaction
var playErr error
playResponse, playErr = s.gamePlayProcessor.PlayGame(txCtx, playGameReq)
if playErr != nil {
return fmt.Errorf("failed to play spin game: %w", playErr)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to play spin game: %w", err)
return nil, err
}
// Convert prize to customer-facing format
@@ -70,9 +88,9 @@ func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, r
Status: "SUCCESS",
Message: "Spin game completed successfully.",
Data: &models.SpinGameResponseData{
GamePlay: playResponse.GamePlay,
PrizeWon: customerPrize,
CoinsRemaining: playResponse.CoinsRemaining,
GamePlay: playResponse.GamePlay,
PrizeWon: customerPrize,
TokensRemaining: playResponse.TokensRemaining,
},
}
-82
View File
@@ -1,82 +0,0 @@
package service
import (
"context"
"errors"
"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"
)
// WalletAdminService is the dashboard's access to a customer's wallet
// (docs/prd-point-coin.md F7). Every call is scoped to the caller's organization.
type WalletAdminService interface {
GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response
AdjustCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.AdjustCustomerWalletRequest) *contract.Response
// TraceTransaction follows a ledger row lot by lot back to where its balance came from.
TraceTransaction(ctx context.Context, apctx *appcontext.ContextInfo, transactionID uuid.UUID) *contract.Response
}
type WalletAdminServiceImpl struct {
walletAdminProcessor *processor.WalletAdminProcessor
walletTraceProcessor *processor.WalletTraceProcessor
}
func NewWalletAdminService(walletAdminProcessor *processor.WalletAdminProcessor, walletTraceProcessor *processor.WalletTraceProcessor) *WalletAdminServiceImpl {
return &WalletAdminServiceImpl{walletAdminProcessor: walletAdminProcessor, walletTraceProcessor: walletTraceProcessor}
}
func (s *WalletAdminServiceImpl) TraceTransaction(ctx context.Context, apctx *appcontext.ContextInfo, transactionID uuid.UUID) *contract.Response {
trace, err := s.walletTraceProcessor.Trace(ctx, apctx.OrganizationID, transactionID)
if err != nil {
return walletErrorResponse(err)
}
return contract.BuildSuccessResponse(trace)
}
func (s *WalletAdminServiceImpl) GetCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) *contract.Response {
wallet, err := s.walletAdminProcessor.GetCustomerWallet(ctx, apctx.OrganizationID, customerID, query)
if err != nil {
return walletErrorResponse(err)
}
return contract.BuildSuccessResponse(wallet)
}
func (s *WalletAdminServiceImpl) AdjustCustomerWallet(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.AdjustCustomerWalletRequest) *contract.Response {
result, err := s.walletAdminProcessor.Adjust(ctx, apctx.OrganizationID, apctx.UserID, customerID, models.WalletAdjustment{
Currency: req.Currency,
Amount: req.Amount,
Reason: req.Reason,
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return walletErrorResponse(err)
}
return contract.BuildSuccessResponse(result)
}
// walletErrorResponse tells the dashboard's mistakes apart from server failures.
func walletErrorResponse(err error) *contract.Response {
code, message := constants.InternalServerErrorCode, err.Error()
switch {
case errors.Is(err, repository.ErrWalletNotFound):
code, message = constants.NotFoundErrorCode, "customer not found"
case errors.Is(err, repository.ErrWalletTransactionNotFound):
code, message = constants.NotFoundErrorCode, "wallet transaction not found"
case errors.Is(err, repository.ErrWalletInsufficientBalance):
code, message = constants.ValidationErrorCode, "the adjustment would take more than the customer can spend"
case errors.Is(err, processor.ErrInvalidWalletQuery),
errors.Is(err, processor.ErrWalletInvalidEntry),
errors.Is(err, processor.ErrWalletIdempotencyConflict):
code = constants.ValidationErrorCode
}
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.WalletServiceEntity, message),
})
}
-77
View File
@@ -1,77 +0,0 @@
package service
import (
"context"
"sync"
"time"
"apskel-pos-be/internal/logger"
)
// Lots expire at the end of their day, so running every quarter of an hour keeps any
// lot from staying past its expiry for more than about that long (PC-503).
const defaultWalletExpiryInterval = 15 * time.Minute
type walletExpiryWork interface {
ExpireDue(ctx context.Context) (int, error)
SendReminders(ctx context.Context) (int, error)
}
// WalletExpiryJob expires the balances whose time is up and reminds customers of what
// is about to (docs/prd-point-coin.md F12, PC-503, PC-504).
// Unlike OmsetMilestoneScheduler it keeps no state in memory: several instances can
// run it at once, and a restart repeats nothing, because every lot is expired under
// its wallet's lock with an idempotency key.
type WalletExpiryJob struct {
expirer walletExpiryWork
stopCh chan struct{}
stopOnce sync.Once
}
func NewWalletExpiryJob(expirer walletExpiryWork) *WalletExpiryJob {
return &WalletExpiryJob{expirer: expirer, stopCh: make(chan struct{})}
}
func (j *WalletExpiryJob) Start(interval time.Duration) {
if interval <= 0 {
interval = defaultWalletExpiryInterval
}
go func() {
j.RunOnce(context.Background())
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
j.RunOnce(context.Background())
case <-j.stopCh:
return
}
}
}()
logger.NonContext.Infof("Wallet expiry job started (interval: %s)", interval)
}
func (j *WalletExpiryJob) Stop() {
j.stopOnce.Do(func() { close(j.stopCh) })
}
// RunOnce expires what is due, sends the reminders that are due, and returns how many
// lots it expired.
func (j *WalletExpiryJob) RunOnce(ctx context.Context) int {
expired, err := j.expirer.ExpireDue(ctx)
if err != nil {
logger.NonContext.Error("Wallet expiry failed to run", err)
}
if expired > 0 {
logger.NonContext.Infof("Wallet expiry expired %d lots", expired)
}
reminded, err := j.expirer.SendReminders(ctx)
if err != nil {
logger.NonContext.Error("Wallet expiry reminders failed to run", err)
}
if reminded > 0 {
logger.NonContext.Infof("Wallet expiry sent %d reminders", reminded)
}
return expired
}
@@ -1,212 +0,0 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"sync"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
const (
defaultWalletReconciliationInterval = 6 * time.Hour
// Per check, so one systematic bug cannot flood the log or the notification.
walletReconciliationLimit = 50
)
type walletDiscrepancyFinder interface {
FindDiscrepancies(ctx context.Context, limit int) ([]repository.WalletDiscrepancy, error)
}
type organizationUserLister interface {
GetByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error)
}
type notificationSender interface {
Send(ctx context.Context, req *models.SendNotificationRequest) (*models.NotificationResponse, error)
}
// WalletReconciliationJob periodically runs the §7.5 checks of
// docs/prd-point-coin.md over every wallet (PC-108). It is silent while the data is
// consistent. When it finds a discrepancy it logs each one and notifies the admins,
// owners and managers of the organization concerned.
//
// An organization is notified again only when its set of discrepancies changes, so an
// unfixed problem does not page the same people every run. That memory is in-process:
// a restart notifies once more, and each running instance keeps its own.
type WalletReconciliationJob struct {
finder walletDiscrepancyFinder
users organizationUserLister
notifier notificationSender
mu sync.Mutex
notified map[uuid.UUID]string // organization -> fingerprint last notified
stopCh chan struct{}
stopOnce sync.Once
}
func NewWalletReconciliationJob(finder walletDiscrepancyFinder, users organizationUserLister, notifier notificationSender) *WalletReconciliationJob {
return &WalletReconciliationJob{
finder: finder,
users: users,
notifier: notifier,
notified: make(map[uuid.UUID]string),
stopCh: make(chan struct{}),
}
}
// Start runs the checks once now and then every interval, in the background.
func (j *WalletReconciliationJob) Start(interval time.Duration) {
if interval <= 0 {
interval = defaultWalletReconciliationInterval
}
go func() {
j.runLogged()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
j.runLogged()
case <-j.stopCh:
return
}
}
}()
logger.NonContext.Infof("Wallet reconciliation job started (interval: %s)", interval)
}
func (j *WalletReconciliationJob) Stop() {
j.stopOnce.Do(func() { close(j.stopCh) })
}
func (j *WalletReconciliationJob) runLogged() {
if _, err := j.RunOnce(context.Background()); err != nil {
logger.NonContext.Error("Wallet reconciliation failed to run", err)
}
}
// RunOnce runs every check, reports what it finds, and returns it.
func (j *WalletReconciliationJob) RunOnce(ctx context.Context) ([]repository.WalletDiscrepancy, error) {
found, err := j.finder.FindDiscrepancies(ctx, walletReconciliationLimit)
if err != nil {
return nil, err
}
byOrg := make(map[uuid.UUID][]repository.WalletDiscrepancy)
for _, d := range found {
fields := map[string]interface{}{
"check": d.Check,
"organization_id": d.OrganizationID.String(),
"customer_id": d.CustomerID.String(),
"currency": d.Currency,
"expected": d.Expected,
"actual": d.Actual,
}
if d.ObjectID != nil {
fields["object_id"] = d.ObjectID.String()
}
logger.NonContext.WarnWithFields("Wallet reconciliation found a discrepancy", fields, nil)
byOrg[d.OrganizationID] = append(byOrg[d.OrganizationID], d)
}
j.mu.Lock()
defer j.mu.Unlock()
// Organizations that are clean again are forgotten, so a later problem notifies.
for org := range j.notified {
if _, still := byOrg[org]; !still {
delete(j.notified, org)
}
}
for org, discrepancies := range byOrg {
fingerprint := walletDiscrepancyFingerprint(discrepancies)
if j.notified[org] == fingerprint {
continue
}
if err := j.notify(ctx, org, discrepancies); err != nil {
logger.NonContext.Error(fmt.Sprintf("Wallet reconciliation could not notify organization %s", org), err)
continue
}
j.notified[org] = fingerprint
}
return found, nil
}
func (j *WalletReconciliationJob) notify(ctx context.Context, organizationID uuid.UUID, discrepancies []repository.WalletDiscrepancy) error {
if organizationID == uuid.Nil {
return fmt.Errorf("discrepancy without an organization")
}
users, err := j.users.GetByOrganizationID(ctx, organizationID)
if err != nil {
return err
}
var receivers []uuid.UUID
for _, u := range users {
switch u.Role {
case entities.RoleAdmin, entities.RoleOwner, entities.RoleManager:
receivers = append(receivers, u.ID)
}
}
if len(receivers) == 0 {
return nil
}
perCheck := map[string]int{}
customers := map[string]bool{}
for _, d := range discrepancies {
perCheck[d.Check]++
customers[d.CustomerID.String()] = true
}
customerIDs := make([]string, 0, len(customers))
for id := range customers {
customerIDs = append(customerIDs, id)
}
sort.Strings(customerIDs)
_, err = j.notifier.Send(ctx, &models.SendNotificationRequest{
Title: "Selisih saldo EnakPoint/EnakCoin terdeteksi",
Body: fmt.Sprintf("Pemeriksaan rutin menemukan %d selisih pada saldo %d customer. Saldo belum dikoreksi otomatis; tim teknis perlu memeriksanya.",
len(discrepancies), len(customerIDs)),
Type: "system",
Category: "wallet_reconciliation",
Priority: entities.NotificationPriorityHigh,
NotifiableType: "organization",
NotifiableID: &organizationID,
ReceiverIDs: receivers,
Data: map[string]interface{}{
"organization_id": organizationID.String(),
"discrepancies": len(discrepancies),
"per_check": perCheck,
"customer_ids": customerIDs,
},
})
return err
}
// walletDiscrepancyFingerprint identifies a set of discrepancies regardless of order.
func walletDiscrepancyFingerprint(discrepancies []repository.WalletDiscrepancy) string {
keys := make([]string, 0, len(discrepancies))
for _, d := range discrepancies {
object := ""
if d.ObjectID != nil {
object = d.ObjectID.String()
}
keys = append(keys, fmt.Sprintf("%s|%s|%s|%s|%d|%d", d.Check, d.CustomerID, d.Currency, object, d.Expected, d.Actual))
}
sort.Strings(keys)
h := sha256.New()
for _, k := range keys {
h.Write([]byte(k))
h.Write([]byte{'\n'})
}
return hex.EncodeToString(h.Sum(nil))
}
@@ -1,110 +0,0 @@
package service
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
type discrepancyFinderFake struct {
found []repository.WalletDiscrepancy
}
func (f *discrepancyFinderFake) FindDiscrepancies(context.Context, int) ([]repository.WalletDiscrepancy, error) {
return f.found, nil
}
type orgUsersFake map[uuid.UUID][]*entities.User
func (f orgUsersFake) GetByOrganizationID(_ context.Context, org uuid.UUID) ([]*entities.User, error) {
return f[org], nil
}
type notifierFake struct {
sent []*models.SendNotificationRequest
}
func (f *notifierFake) Send(_ context.Context, req *models.SendNotificationRequest) (*models.NotificationResponse, error) {
f.sent = append(f.sent, req)
return &models.NotificationResponse{}, nil
}
func TestWalletReconciliationJob(t *testing.T) {
logger.Setup("fatal", "json")
org := uuid.New()
admin, owner, manager, cashier := uuid.New(), uuid.New(), uuid.New(), uuid.New()
users := orgUsersFake{org: {
{ID: admin, Role: entities.RoleAdmin},
{ID: owner, Role: entities.RoleOwner},
{ID: manager, Role: entities.RoleManager},
{ID: cashier, Role: entities.RoleCashier},
}}
finder := &discrepancyFinderFake{}
notifier := &notifierFake{}
job := NewWalletReconciliationJob(finder, users, notifier)
ctx := context.Background()
// Consistent data: nothing reported.
found, err := job.RunOnce(ctx)
require.NoError(t, err)
assert.Empty(t, found)
assert.Empty(t, notifier.sent)
// A discrepancy notifies the organization's admins, owners and managers.
customer := uuid.New()
lot := uuid.New()
finder.found = []repository.WalletDiscrepancy{
{Check: repository.WalletCheckBalanceVsLots, OrganizationID: org, CustomerID: customer, Currency: "POINT", Expected: 100, Actual: 105},
{Check: repository.WalletCheckLotVsAllocations, OrganizationID: org, CustomerID: customer, Currency: "POINT", ObjectID: &lot, Expected: 50, Actual: 55},
}
found, err = job.RunOnce(ctx)
require.NoError(t, err)
assert.Len(t, found, 2)
require.Len(t, notifier.sent, 1)
sent := notifier.sent[0]
assert.ElementsMatch(t, []uuid.UUID{admin, owner, manager}, sent.ReceiverIDs, "cashiers are not told")
assert.Equal(t, &org, sent.NotifiableID)
assert.Equal(t, 2, sent.Data["discrepancies"])
assert.Equal(t, []string{customer.String()}, sent.Data["customer_ids"])
assert.Equal(t, entities.NotificationPriorityHigh, sent.Priority)
// The same problem, still unfixed and in a different order, does not notify again.
finder.found = []repository.WalletDiscrepancy{finder.found[1], finder.found[0]}
_, err = job.RunOnce(ctx)
require.NoError(t, err)
assert.Len(t, notifier.sent, 1)
// A changed problem does.
finder.found = finder.found[:1]
_, err = job.RunOnce(ctx)
require.NoError(t, err)
assert.Len(t, notifier.sent, 2)
// Once clean the organization is forgotten, so the same problem coming back
// notifies again.
previous := finder.found
finder.found = nil
_, err = job.RunOnce(ctx)
require.NoError(t, err)
assert.Len(t, notifier.sent, 2)
finder.found = previous
_, err = job.RunOnce(ctx)
require.NoError(t, err)
assert.Len(t, notifier.sent, 3)
}
func TestWalletReconciliationJobStartStop(t *testing.T) {
logger.Setup("fatal", "json")
job := NewWalletReconciliationJob(&discrepancyFinderFake{}, orgUsersFake{}, &notifierFake{})
job.Start(0)
job.Stop()
job.Stop() // stopping twice is harmless
}