Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
This commit is contained in:
@@ -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 == "TOKENS" {
|
||||
if req.RewardType == "POINTS" || req.RewardType == "COINS" || 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 == "TOKENS" {
|
||||
if req.RewardType == "POINTS" || req.RewardType == "COINS" || 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", "TOKENS", "MIXED"}
|
||||
validTypes := []string{"REWARD", "POINTS", "COINS", "TOKENS", "MIXED"}
|
||||
for _, validType := range validTypes {
|
||||
if campaignType == validType {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid campaign type: %s. Valid types are: REWARD, POINTS, TOKENS, MIXED", campaignType)
|
||||
return fmt.Errorf("invalid campaign type: %s. Valid types are: REWARD, POINTS, COINS, 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 == "TOKENS" {
|
||||
if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || 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", "TOKENS", "REWARD"}
|
||||
validTypes := []string{"POINTS", "COINS", "TOKENS", "REWARD"}
|
||||
for _, validType := range validTypes {
|
||||
if rewardType == validType {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid reward type: %s. Valid types are: POINTS, TOKENS, REWARD", rewardType)
|
||||
return fmt.Errorf("invalid reward type: %s. Valid types are: POINTS, COINS, REWARD", rewardType)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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,6 +12,8 @@ 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)
|
||||
}
|
||||
@@ -82,3 +84,17 @@ 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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()
|
||||
}
|
||||
@@ -11,26 +11,6 @@ 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)
|
||||
@@ -72,18 +52,14 @@ type GamificationService interface {
|
||||
}
|
||||
|
||||
type GamificationServiceImpl struct {
|
||||
customerPointsProcessor *processor.CustomerPointsProcessor
|
||||
customerTokensProcessor *processor.CustomerTokensProcessor
|
||||
tierProcessor *processor.TierProcessor
|
||||
gameProcessor *processor.GameProcessor
|
||||
gamePrizeProcessor *processor.GamePrizeProcessor
|
||||
gamePlayProcessor *processor.GamePlayProcessor
|
||||
omsetTrackerProcessor *processor.OmsetTrackerProcessor
|
||||
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,
|
||||
@@ -91,144 +67,14 @@ func NewGamificationService(
|
||||
omsetTrackerProcessor *processor.OmsetTrackerProcessor,
|
||||
) *GamificationServiceImpl {
|
||||
return &GamificationServiceImpl{
|
||||
customerPointsProcessor: customerPointsProcessor,
|
||||
customerTokensProcessor: customerTokensProcessor,
|
||||
tierProcessor: tierProcessor,
|
||||
gameProcessor: gameProcessor,
|
||||
gamePrizeProcessor: gamePrizeProcessor,
|
||||
gamePlayProcessor: gamePlayProcessor,
|
||||
omsetTrackerProcessor: omsetTrackerProcessor,
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
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
|
||||
}
|
||||
@@ -557,7 +557,8 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen
|
||||
return fmt.Errorf("payment method ID is required")
|
||||
}
|
||||
|
||||
if req.Amount <= 0 {
|
||||
// A payment with EnakPoint gives points instead; its amount is computed from them.
|
||||
if req.Points == nil && req.Amount <= 0 {
|
||||
return fmt.Errorf("payment amount must be greater than zero")
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ 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 {
|
||||
|
||||
@@ -2,8 +2,10 @@ 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"
|
||||
@@ -39,7 +41,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("PAYMENT_METHOD_CREATE_ERROR", "payment_method", err.Error()),
|
||||
contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_CREATE_ERROR"), "payment_method", err.Error()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,7 +86,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("PAYMENT_METHOD_UPDATE_ERROR", "payment_method", err.Error()),
|
||||
contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_UPDATE_ERROR"), "payment_method", err.Error()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,7 +99,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("PAYMENT_METHOD_DELETE_ERROR", "payment_method", err.Error()),
|
||||
contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_DELETE_ERROR"), "payment_method", err.Error()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,3 +125,12 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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()),
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -17,13 +16,11 @@ type SpinGameService interface {
|
||||
|
||||
type spinGameService struct {
|
||||
gamePlayProcessor *processor.GamePlayProcessor
|
||||
txManager *repository.TxManager
|
||||
}
|
||||
|
||||
func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor, txManager *repository.TxManager) SpinGameService {
|
||||
func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor) SpinGameService {
|
||||
return &spinGameService{
|
||||
gamePlayProcessor: gamePlayProcessor,
|
||||
txManager: txManager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,28 +42,13 @@ func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, r
|
||||
return nil, fmt.Errorf("invalid customer ID format: %w", err)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to play spin game: %w", err)
|
||||
}
|
||||
|
||||
// Convert prize to customer-facing format
|
||||
@@ -88,9 +70,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,
|
||||
TokensRemaining: playResponse.TokensRemaining,
|
||||
GamePlay: playResponse.GamePlay,
|
||||
PrizeWon: customerPrize,
|
||||
CoinsRemaining: playResponse.CoinsRemaining,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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 := ¬ifierFake{}
|
||||
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{}, ¬ifierFake{})
|
||||
job.Start(0)
|
||||
job.Stop()
|
||||
job.Stop() // stopping twice is harmless
|
||||
}
|
||||
Reference in New Issue
Block a user