Files
apskel-pos-backend/internal/service/customer_wallet_service.go
T
efrilmandClaude Opus 5.5 ab3425070b feat(loyalty): exchange EnakCoin into EnakPoint
Adds GET /customer/wallet/exchange/preview?coins= and
POST /customer/wallet/exchange (docs/prd-point-coin.md F4, K3, PC-401).

The customer exchanges a multiple of the organization's coin_amount and
gets (coins / coin_amount) x point_amount EnakPoint, approved by their PIN
(K8). A malformed amount is refused before the PIN is checked, so it costs
no attempt. In one transaction the wallet is locked, EXCHANGE_OUT takes the
EnakCoin in K9 order and EXCHANGE_IN adds the EnakPoint; the two rows share
a group, point at each other and both freeze the rate in their metadata.

The EnakPoint are split over the EnakCoin lots they came from, each part
keeping its lot's expiry and pointing back at it, so exchanging cannot
extend a balance's life. The split takes floor(coins so far x rate) per
lot, which adds up exactly because the total is a multiple of coin_amount.
EnakPoint have no validity of their own until the expiry model is decided
(N4), so the EnakCoin lot is for now the only bound.

The Idempotency-Key header (or X-Idempotency-Key) is required. A retry
with the same key is recognised under the wallet lock and replayed with
the ids and rate the first attempt froze, even if the rate has changed
since; the same key for another amount is refused.

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

67 lines
2.4 KiB
Go

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 (docs/prd-point-coin.md F4).
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
}
type CustomerWalletServiceImpl struct {
exchanges *processor.WalletExchangeProcessor
}
func NewCustomerWalletService(exchanges *processor.WalletExchangeProcessor) *CustomerWalletServiceImpl {
return &CustomerWalletServiceImpl{exchanges: exchanges}
}
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):
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()),
})
}