From ab3425070bff2e1241f76851c6f20fc4a2289969 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 12:09:53 +0700 Subject: [PATCH] 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 --- internal/app/app.go | 7 + internal/contract/wallet_contract.go | 7 + internal/handler/customer_wallet_handler.go | 70 ++++ internal/models/wallet_move.go | 44 +++ .../processor/wallet_exchange_processor.go | 290 +++++++++++++++++ .../wallet_exchange_processor_test.go | 305 ++++++++++++++++++ internal/processor/wallet_move_db_test.go | 94 ++++++ internal/processor/wallet_processor.go | 7 + internal/repository/wallet_move_repository.go | 56 ++++ internal/router/router.go | 8 +- internal/router/router_test.go | 2 + internal/service/customer_wallet_service.go | 66 ++++ 12 files changed, 954 insertions(+), 2 deletions(-) create mode 100644 internal/handler/customer_wallet_handler.go create mode 100644 internal/models/wallet_move.go create mode 100644 internal/processor/wallet_exchange_processor.go create mode 100644 internal/processor/wallet_exchange_processor_test.go create mode 100644 internal/processor/wallet_move_db_test.go create mode 100644 internal/repository/wallet_move_repository.go create mode 100644 internal/service/customer_wallet_service.go diff --git a/internal/app/app.go b/internal/app/app.go index c207899..90e216d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -159,6 +159,7 @@ func (a *App) Initialize(cfg *config.Config) error { services.customerPinService, services.pointPaymentService, services.customerOrderPaymentService, + services.customerWalletService, a.redisClient, ) @@ -392,6 +393,7 @@ type processors struct { customerPinProcessor *processor.CustomerPinProcessor paymentCodeProcessor *processor.PaymentCodeProcessor pointPaymentProcessor *processor.PointPaymentProcessor + walletExchangeProcessor *processor.WalletExchangeProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -411,6 +413,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor // Pay orders with EnakPoint, approved by the customer's one-time code (docs/prd-point-coin.md F9) pointPaymentProcessor := processor.NewPointPaymentProcessor(repository.NewPointPaymentRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor) + // Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4) + walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), @@ -463,6 +467,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor customerPinProcessor: customerPinProcessor, paymentCodeProcessor: paymentCodeProcessor, pointPaymentProcessor: pointPaymentProcessor, + walletExchangeProcessor: walletExchangeProcessor, walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } @@ -511,6 +516,7 @@ type services struct { customerPinService *service.CustomerPinServiceImpl pointPaymentService *service.PointPaymentServiceImpl customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl + customerWalletService *service.CustomerWalletServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -597,6 +603,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor), pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor), customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor), + customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor), } } diff --git a/internal/contract/wallet_contract.go b/internal/contract/wallet_contract.go index 3e0e58a..9d7d133 100644 --- a/internal/contract/wallet_contract.go +++ b/internal/contract/wallet_contract.go @@ -12,3 +12,10 @@ type AdjustCustomerWalletRequest struct { // adjusting twice, so a retried request is safe. IdempotencyKey string `json:"idempotency_key"` } + +// ExchangeCoinsRequest is POST /customer/wallet/exchange (docs/prd-point-coin.md F4). +// The Idempotency-Key header is required. +type ExchangeCoinsRequest struct { + Coins int64 `json:"coins" binding:"required,min=1"` + Pin string `json:"pin" binding:"required"` +} diff --git a/internal/handler/customer_wallet_handler.go b/internal/handler/customer_wallet_handler.go new file mode 100644 index 0000000..2faffbb --- /dev/null +++ b/internal/handler/customer_wallet_handler.go @@ -0,0 +1,70 @@ +package handler + +import ( + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" +) + +// The header exchanges and transfers require (docs/prd-point-coin.md F4, F5), and the +// X- form the rest of the API uses, which is accepted too. +const ( + idempotencyKeyHeader = "Idempotency-Key" + legacyIdempotencyKeyHeader = "X-Idempotency-Key" +) + +// CustomerWalletHandler serves what the customer app does with its own wallet. Bodies +// hold the PIN, so they are never logged. +type CustomerWalletHandler struct { + wallets service.CustomerWalletService +} + +func NewCustomerWalletHandler(wallets service.CustomerWalletService) *CustomerWalletHandler { + return &CustomerWalletHandler{wallets: wallets} +} + +// PreviewExchange is GET /customer/wallet/exchange/preview?coins=. +func (h *CustomerWalletHandler) PreviewExchange(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::PreviewExchange") + if !ok { + return + } + var coins int64 + if raw := c.Query("coins"); raw != "" { + var err error + coins, err = strconv.ParseInt(raw, 10, 64) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "coins must be a whole number"), + }), "CustomerWalletHandler::PreviewExchange") + return + } + } + util.HandleResponse(c.Writer, c.Request, h.wallets.PreviewExchange(c.Request.Context(), customerID, coins), "CustomerWalletHandler::PreviewExchange") +} + +// Exchange is POST /customer/wallet/exchange. +func (h *CustomerWalletHandler) Exchange(c *gin.Context) { + customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Exchange") + if !ok { + return + } + var req contract.ExchangeCoinsRequest + if !bindPinRequest(c, &req, "CustomerWalletHandler::Exchange") { + return + } + util.HandleResponse(c.Writer, c.Request, h.wallets.Exchange(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Exchange") +} + +func idempotencyKey(c *gin.Context) string { + if key := strings.TrimSpace(c.GetHeader(idempotencyKeyHeader)); key != "" { + return key + } + return strings.TrimSpace(c.GetHeader(legacyIdempotencyKeyHeader)) +} diff --git a/internal/models/wallet_move.go b/internal/models/wallet_move.go new file mode 100644 index 0000000..5eb0c64 --- /dev/null +++ b/internal/models/wallet_move.go @@ -0,0 +1,44 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// WalletExchangePreview is GET /customer/wallet/exchange/preview +// (docs/prd-point-coin.md F4): the rate, and what exchanging Coins would give. +type WalletExchangePreview struct { + // The rate: CoinAmount EnakCoin exchange into PointAmount EnakPoint. + CoinAmount int64 `json:"coin_amount"` + PointAmount int64 `json:"point_amount"` + CoinBalance int64 `json:"coin_balance"` + Coins int64 `json:"coins"` + Points int64 `json:"points"` + // Whether Coins can be exchanged now, and why not when it cannot. + Valid bool `json:"valid"` + Reason string `json:"reason,omitempty"` +} + +// WalletMovedLot is part of what an exchange or a transfer delivered, with the +// expiry it carried over from the lot it came from (K9). +type WalletMovedLot struct { + Amount int64 `json:"amount"` + // Nil when it never expires. + ExpiresAt *time.Time `json:"expires_at"` +} + +// WalletExchangeResult is POST /customer/wallet/exchange. +type WalletExchangeResult struct { + GroupID uuid.UUID `json:"group_id"` + Coins int64 `json:"coins"` + Points int64 `json:"points"` + CoinAmount int64 `json:"coin_amount"` + PointAmount int64 `json:"point_amount"` + // The EnakPoint received, split by expiry. + Lots []WalletMovedLot `json:"lots"` + CoinBalance int64 `json:"coin_balance"` + PointBalance int64 `json:"point_balance"` + // True when this was a retry of an exchange already made; nothing moved again. + Replayed bool `json:"replayed"` +} diff --git a/internal/processor/wallet_exchange_processor.go b/internal/processor/wallet_exchange_processor.go new file mode 100644 index 0000000..79beac3 --- /dev/null +++ b/internal/processor/wallet_exchange_processor.go @@ -0,0 +1,290 @@ +package processor + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// ErrWalletMoveRejected wraps every reason an exchange or a transfer is refused on +// the customer's side: the amount, the limits, the recipient or the balance. The +// message says which. +var ErrWalletMoveRejected = errors.New("wallet move refused") + +// walletMoveKeyLimit keeps a client's Idempotency-Key short enough to fit, with the +// prefix that scopes it to the customer, in wallet_transactions.idempotency_key. +const walletMoveKeyLimit = 50 + +type organizationSettingsReader interface { + Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) +} + +// WalletExchangeProcessor exchanges EnakCoin into EnakPoint (docs/prd-point-coin.md +// F4, K3). It is one way only; nothing turns EnakPoint back into EnakCoin. +type WalletExchangeProcessor struct { + customers repository.WalletMoveRepository + settings organizationSettingsReader + spendable spendableReader + pins pinVerifier + wallet *WalletProcessor + tx TxRunner + now func() time.Time +} + +func NewWalletExchangeProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner) *WalletExchangeProcessor { + return &WalletExchangeProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, now: time.Now} +} + +// Preview is GET /customer/wallet/exchange/preview: the organization's rate and what +// exchanging coins would give, so the app can show it before asking for the PIN. +func (p *WalletExchangeProcessor) Preview(ctx context.Context, customerID uuid.UUID, coins int64) (*models.WalletExchangePreview, error) { + customer, err := p.customers.GetCustomer(ctx, customerID) + if err != nil { + return nil, err + } + settings, err := p.settings.Organization(ctx, customer.OrganizationID) + if err != nil { + return nil, err + } + balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now()) + if err != nil { + return nil, err + } + rate := settings.Exchange + preview := &models.WalletExchangePreview{ + CoinAmount: rate.CoinAmount, + PointAmount: rate.PointAmount, + CoinBalance: balances[constants.WalletCurrencyCoin], + Coins: coins, + } + if reason := exchangeProblem(customer, rate, coins); reason != "" { + preview.Reason = reason + return preview, nil + } + preview.Points = exchangePoints(coins, rate) + if coins > preview.CoinBalance { + preview.Reason = "not enough EnakCoin" + return preview, nil + } + preview.Valid = true + return preview, nil +} + +// Exchange takes coins EnakCoin and gives the EnakPoint they are worth at the +// organization's rate, in one transaction. The two ledger rows share a group and +// point at each other, and both freeze the rate. Each EnakPoint lot keeps the expiry +// of the EnakCoin lot it came from, so exchanging cannot extend a balance's life +// (K9). The PIN approves it (K8). +// +// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns +// the first exchange, at the rate it was made, without moving anything again. +func (p *WalletExchangeProcessor) Exchange(ctx context.Context, customerID uuid.UUID, coins int64, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.WalletExchangeResult, error) { + key, err := walletMoveKey(idempotencyKey) + if err != nil { + return nil, err + } + customer, err := p.customers.GetCustomer(ctx, customerID) + if err != nil { + return nil, err + } + settings, err := p.settings.Organization(ctx, customer.OrganizationID) + if err != nil { + return nil, err + } + // Refuse a malformed request before the PIN is checked, so a typo in the amount + // costs the customer no PIN attempt. + if reason := exchangeProblem(customer, settings.Exchange, coins); reason != "" { + return nil, fmt.Errorf("%w: %s", ErrWalletMoveRejected, reason) + } + if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionExchange, info); err != nil { + return nil, err + } + + outKey := fmt.Sprintf("exchange:%s:%s:out", customerID, key) + inKey := fmt.Sprintf("exchange:%s:%s:in", customerID, key) + result := &models.WalletExchangeResult{Coins: coins} + err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { + if err := p.wallet.LockWallet(ctx, customerID); err != nil { + return err + } + rate := settings.Exchange + groupID, outID, inID := uuid.New(), uuid.New(), uuid.New() + previous, err := p.wallet.FindTransaction(ctx, outKey) + if err != nil { + return err + } + if previous != nil && previous.GroupID != nil { + // A retry: repeat it with the ids and the rate the first attempt froze, so + // both rows replay even if the rate has changed since. + outID, inID, groupID = previous.ID, previous.ReferenceID, *previous.GroupID + rate = frozenExchangeRate(previous.Metadata, rate) + } + points := exchangePoints(coins, rate) + metadata := entities.Metadata{ + "coins": coins, + "points": points, + "coin_amount": rate.CoinAmount, + "point_amount": rate.PointAmount, + } + + out, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ + TransactionID: outID, + CustomerID: customerID, + Currency: constants.WalletCurrencyCoin, + Type: constants.WalletTxTypeExchangeOut, + Amount: coins, + ReferenceType: constants.WalletRefTypeWalletTx, + ReferenceID: inID, + GroupID: &groupID, + Description: fmt.Sprintf("Tukar %d EnakCoin ke EnakPoint", coins), + Metadata: metadata, + IdempotencyKey: outKey, + }}) + if errors.Is(err, repository.ErrWalletInsufficientBalance) { + return fmt.Errorf("%w: not enough EnakCoin", ErrWalletMoveRejected) + } + if err != nil { + return err + } + in, err := p.wallet.Credit(ctx, WalletCreditInput{ + WalletEntry: WalletEntry{ + TransactionID: inID, + CustomerID: customerID, + Currency: constants.WalletCurrencyPoint, + Type: constants.WalletTxTypeExchangeIn, + Amount: points, + ReferenceType: constants.WalletRefTypeWalletTx, + ReferenceID: outID, + GroupID: &groupID, + Description: fmt.Sprintf("Dari tukar %d EnakCoin", coins), + Metadata: metadata, + IdempotencyKey: inKey, + }, + Lots: exchangeLots(out.Allocations, rate), + }) + if err != nil { + return err + } + + result.GroupID = groupID + result.Points = points + result.CoinAmount = rate.CoinAmount + result.PointAmount = rate.PointAmount + result.Lots = movedLots(in.Lots) + result.Replayed = out.Replayed + return nil + }) + if err != nil { + return nil, err + } + + balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now()) + if err != nil { + return nil, err + } + result.CoinBalance = balances[constants.WalletCurrencyCoin] + result.PointBalance = balances[constants.WalletCurrencyPoint] + return result, nil +} + +// exchangeProblem says why coins cannot be exchanged, or "" when they can as far as +// the request goes. The balance is checked under the wallet lock. +func exchangeProblem(customer *repository.WalletMoveCustomer, rate models.LoyaltyExchangeSettings, coins int64) string { + switch { + case !customer.IsActive: + return "the customer is not active" + case coins <= 0: + return "the number of EnakCoin must be positive" + case rate.CoinAmount <= 0 || rate.PointAmount <= 0: + return "exchange is not available" + case coins%rate.CoinAmount != 0: + // Otherwise part of the EnakCoin would be lost to rounding (F4). + return fmt.Sprintf("EnakCoin are exchanged in multiples of %d", rate.CoinAmount) + } + return "" +} + +// exchangePoints is (coins / coin_amount) × point_amount, for coins that are a +// multiple of coin_amount. +func exchangePoints(coins int64, rate models.LoyaltyExchangeSettings) int64 { + return coins / rate.CoinAmount * rate.PointAmount +} + +// exchangeLots splits the EnakPoint of an exchange over the EnakCoin lots it took, +// so each part keeps the expiry of its lot and points back at it (K9). The share of +// a lot is the difference of floor(coins so far × point_amount / coin_amount) before +// and after it, which adds up exactly because the total is a multiple of +// coin_amount. A lot too small to earn a whole EnakPoint on its own gives none. +// +// F4 caps the expiry at now + the EnakPoint validity as well. EnakPoint have no +// validity until the expiry model is decided (F12, note N4), so for now the EnakCoin +// lot's expiry is the only bound. +func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings) []WalletLotInput { + var lots []WalletLotInput + var coinsSoFar int64 + for _, a := range allocations { + before := coinsSoFar * rate.PointAmount / rate.CoinAmount + coinsSoFar += a.Amount + points := coinsSoFar*rate.PointAmount/rate.CoinAmount - before + if points == 0 { + continue + } + lotID := a.LotID + lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID}) + } + return lots +} + +// frozenExchangeRate reads the rate an exchange was made at from its ledger row. +func frozenExchangeRate(metadata entities.Metadata, fallback models.LoyaltyExchangeSettings) models.LoyaltyExchangeSettings { + coinAmount, ok1 := metadataInt(metadata, "coin_amount") + pointAmount, ok2 := metadataInt(metadata, "point_amount") + if !ok1 || !ok2 || coinAmount <= 0 || pointAmount <= 0 { + return fallback + } + return models.LoyaltyExchangeSettings{CoinAmount: coinAmount, PointAmount: pointAmount} +} + +// metadataInt reads a whole number from metadata that may have been through JSONB, +// which gives numbers back as float64. +func metadataInt(metadata entities.Metadata, key string) (int64, bool) { + switch v := metadata[key].(type) { + case float64: + return int64(v), true + case int64: + return v, true + case int: + return int64(v), true + } + return 0, false +} + +func movedLots(lots []entities.WalletLot) []models.WalletMovedLot { + out := make([]models.WalletMovedLot, 0, len(lots)) + for _, lot := range lots { + out = append(out, models.WalletMovedLot{Amount: lot.OriginalAmount, ExpiresAt: lot.ExpiresAt}) + } + return out +} + +// walletMoveKey checks the client's Idempotency-Key, which exchanges and transfers +// require (F4, F5). +func walletMoveKey(key string) (string, error) { + key = strings.TrimSpace(key) + if key == "" { + return "", fmt.Errorf("%w: the Idempotency-Key header is required", ErrWalletMoveRejected) + } + if len(key) > walletMoveKeyLimit { + return "", fmt.Errorf("%w: the Idempotency-Key header must be at most %d characters", ErrWalletMoveRejected, walletMoveKeyLimit) + } + return key, nil +} diff --git a/internal/processor/wallet_exchange_processor_test.go b/internal/processor/wallet_exchange_processor_test.go new file mode 100644 index 0000000..8306751 --- /dev/null +++ b/internal/processor/wallet_exchange_processor_test.go @@ -0,0 +1,305 @@ +package processor + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// walletMoveEnv runs exchanges and transfers on the in-memory wallet, so every test +// also checks the §7.5 invariants when it ends. +type walletMoveEnv struct { + *walletTestEnv + customers *walletMoveRepoFake + settings *models.OrganizationLoyaltySettings + pins *movePinFake +} + +func newWalletMoveEnv(t *testing.T) *walletMoveEnv { + e := &walletMoveEnv{ + walletTestEnv: newWalletTestEnv(t), + customers: &walletMoveRepoFake{byID: map[uuid.UUID]*repository.WalletMoveCustomer{}}, + settings: &models.OrganizationLoyaltySettings{ + PointValue: 1, + Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}, + Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1}, + }, + pins: &movePinFake{good: "482913"}, + } + return e +} + +// member adds an active customer of the organization. +func (e *walletMoveEnv) member(name, phone string) uuid.UUID { + id := e.customer() + e.customers.byID[id] = &repository.WalletMoveCustomer{ + ID: id, OrganizationID: e.org, Name: name, PhoneNumber: &phone, IsActive: true, + } + return id +} + +func (e *walletMoveEnv) exchanges() *WalletExchangeProcessor { + p := NewWalletExchangeProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}) + p.now = func() time.Time { return e.now } + return p +} + +func (e *walletMoveEnv) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) { + s := *e.settings + return &s, nil +} + +// SpendableBalances sums the unexpired lots, as the real query does. +func (e *walletMoveEnv) SpendableBalances(_ context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) { + out := map[string]int64{} + for _, lot := range e.repo.lots { + if lot.CustomerID == customerID && (lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf)) { + out[lot.Currency] += lot.RemainingAmount + } + } + return out, nil +} + +// earnCoins gives a customer an EnakCoin lot. +func (e *walletMoveEnv) earnCoins(t *testing.T, customerID uuid.UUID, amount int64, expiresAt *time.Time) *WalletResult { + t.Helper() + in := earn(customerID, amount, expiresAt) + in.Currency = constants.WalletCurrencyCoin + return e.credit(t, in) +} + +func (e *walletMoveEnv) coinBalance(t *testing.T, customerID uuid.UUID) int64 { + t.Helper() + w, err := e.repo.GetWallet(e.ctx, customerID) + require.NoError(t, err) + return w.CoinBalance +} + +type walletMoveRepoFake struct { + byID map[uuid.UUID]*repository.WalletMoveCustomer +} + +func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repository.WalletMoveCustomer, error) { + c, ok := f.byID[id] + if !ok { + return nil, repository.ErrWalletNotFound + } + copied := *c + return &copied, nil +} + +// movePinFake accepts one PIN and records the actions it was asked to approve. +type movePinFake struct { + good string + err error + actions []PinAction +} + +func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error { + f.actions = append(f.actions, action) + if f.err != nil { + return f.err + } + if pin != f.good { + return &PinError{Code: PinErrInvalid, RemainingAttempts: 4} + } + return nil +} + +func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) { + e := newWalletMoveEnv(t) + c := e.member("Budi Santoso", "081234561234") + e.earnCoins(t, c, 50, nil) + + res, err := e.exchanges().Exchange(e.ctx, c, 50, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + + assert.Equal(t, int64(50), res.Points) + assert.Equal(t, int64(0), res.CoinBalance) + assert.Equal(t, int64(50), res.PointBalance) + assert.Equal(t, []PinAction{PinActionExchange}, e.pins.actions) + + out, in := e.repo.transactions[1], e.repo.transactions[2] + assert.Equal(t, constants.WalletTxTypeExchangeOut, out.Type) + assert.Equal(t, constants.WalletCurrencyCoin, out.Currency) + assert.Equal(t, int64(-50), out.Amount) + assert.Equal(t, constants.WalletTxTypeExchangeIn, in.Type) + assert.Equal(t, constants.WalletCurrencyPoint, in.Currency) + assert.Equal(t, int64(50), in.Amount) + // The pair shares a group and each row points at the other (§8.1). + assert.Equal(t, *out.GroupID, *in.GroupID) + assert.Equal(t, res.GroupID, *out.GroupID) + assert.Equal(t, in.ID, out.ReferenceID) + assert.Equal(t, out.ID, in.ReferenceID) + assert.Equal(t, "Tukar 50 EnakCoin ke EnakPoint", out.Description) + assert.Equal(t, "Dari tukar 50 EnakCoin", in.Description) + // Both rows freeze the rate. + for _, row := range []*entities.WalletTransaction{out, in} { + assert.Equal(t, int64(1), row.Metadata["coin_amount"]) + assert.Equal(t, int64(1), row.Metadata["point_amount"]) + } +} + +func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) { + e := newWalletMoveEnv(t) + e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 35, nil) + + preview, err := e.exchanges().Preview(e.ctx, c, 30) + require.NoError(t, err) + assert.True(t, preview.Valid) + assert.Equal(t, int64(9), preview.Points) + assert.Equal(t, int64(35), preview.CoinBalance) + + res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + assert.Equal(t, int64(9), res.Points) + assert.Equal(t, int64(5), res.CoinBalance) + assert.Equal(t, int64(9), res.PointBalance) +} + +func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) { + e := newWalletMoveEnv(t) + e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 50, nil) + + preview, err := e.exchanges().Preview(e.ctx, c, 25) + require.NoError(t, err) + assert.False(t, preview.Valid) + assert.Contains(t, preview.Reason, "multiples of 10") + + _, err = e.exchanges().Exchange(e.ctx, c, 25, "482913", "key-1", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, ErrWalletMoveRejected) + // Refused before the PIN, so a typo costs no attempt. + assert.Empty(t, e.pins.actions) + assert.Equal(t, int64(50), e.coinBalance(t, c)) + + _, err = e.exchanges().Exchange(e.ctx, c, 0, "482913", "key-2", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, ErrWalletMoveRejected) +} + +func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) { + e := newWalletMoveEnv(t) + e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} + c := e.member("Budi", "081234561234") + soon, later := e.at(24*time.Hour), e.at(48*time.Hour) + first := e.earnCoins(t, c, 15, soon) + second := e.earnCoins(t, c, 15, later) + third := e.earnCoins(t, c, 10, nil) + + // 40 EnakCoin take 15 from the lot expiring soonest, 15 from the next and 10 from + // the one that never expires, giving 12 EnakPoint split 4 + 5 + 3. + res, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + require.Len(t, res.Lots, 3) + assert.Equal(t, models.WalletMovedLot{Amount: 4, ExpiresAt: soon}, res.Lots[0]) + assert.Equal(t, models.WalletMovedLot{Amount: 5, ExpiresAt: later}, res.Lots[1]) + assert.Equal(t, models.WalletMovedLot{Amount: 3}, res.Lots[2]) + + origins := map[uuid.UUID]*time.Time{} + for _, lot := range e.repo.lots { + if lot.Currency == constants.WalletCurrencyPoint { + require.NotNil(t, lot.OriginLotID) + origins[*lot.OriginLotID] = lot.ExpiresAt + } + } + assert.Equal(t, map[uuid.UUID]*time.Time{ + first.Lots[0].ID: soon, second.Lots[0].ID: later, third.Lots[0].ID: nil, + }, origins) + for _, lot := range e.repo.lots { + if lot.Currency != constants.WalletCurrencyPoint || lot.OriginLotID == nil { + continue + } + for _, coinLot := range e.repo.lots { + if coinLot.ID == *lot.OriginLotID && coinLot.ExpiresAt != nil { + require.NotNil(t, lot.ExpiresAt, "a lot that expires cannot become one that does not") + assert.False(t, lot.ExpiresAt.After(*coinLot.ExpiresAt)) + } + } + } +} + +func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) { + e := newWalletMoveEnv(t) + e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1} + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 5, e.at(time.Hour)) + e.earnCoins(t, c, 5, nil) + + // Neither lot is worth a whole EnakPoint alone; the one that completes the 10 + // carries it. + res, err := e.exchanges().Exchange(e.ctx, c, 10, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + assert.Equal(t, []models.WalletMovedLot{{Amount: 1}}, res.Lots) +} + +func TestWalletExchange_NotEnoughCoins(t *testing.T) { + e := newWalletMoveEnv(t) + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 5, nil) + + preview, err := e.exchanges().Preview(e.ctx, c, 6) + require.NoError(t, err) + assert.False(t, preview.Valid) + + _, err = e.exchanges().Exchange(e.ctx, c, 6, "482913", "key-1", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, ErrWalletMoveRejected) + assert.Equal(t, int64(5), e.coinBalance(t, c)) +} + +func TestWalletExchange_WrongPinMovesNothing(t *testing.T) { + e := newWalletMoveEnv(t) + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 5, nil) + + _, err := e.exchanges().Exchange(e.ctx, c, 5, "000000", "key-1", models.CustomerPinRequestInfo{}) + var pinErr *PinError + require.True(t, errors.As(err, &pinErr)) + assert.Equal(t, PinErrInvalid, pinErr.Code) + assert.Equal(t, int64(5), e.coinBalance(t, c)) +} + +func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) { + e := newWalletMoveEnv(t) + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 100, nil) + + first, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + // The rate changes before the retry arrives; the retry still gets the first result. + e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 2} + again, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + + assert.True(t, again.Replayed) + assert.Equal(t, first.GroupID, again.GroupID) + assert.Equal(t, int64(40), again.Points) + assert.Equal(t, int64(60), e.coinBalance(t, c)) + assert.Len(t, e.repo.transactions, 3) + + // The same key for a different amount is not a retry. + _, err = e.exchanges().Exchange(e.ctx, c, 20, "482913", "key-1", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, ErrWalletIdempotencyConflict) +} + +func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) { + e := newWalletMoveEnv(t) + c := e.member("Budi", "081234561234") + e.earnCoins(t, c, 5, nil) + + _, err := e.exchanges().Exchange(e.ctx, c, 5, "482913", " ", models.CustomerPinRequestInfo{}) + assert.ErrorIs(t, err, ErrWalletMoveRejected) + assert.Empty(t, e.pins.actions) +} diff --git a/internal/processor/wallet_move_db_test.go b/internal/processor/wallet_move_db_test.go new file mode 100644 index 0000000..e65c062 --- /dev/null +++ b/internal/processor/wallet_move_db_test.go @@ -0,0 +1,94 @@ +package processor + +import ( + "context" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// fixedOrganizationSettings serves the same organization settings to every caller. +type fixedOrganizationSettings struct{ s models.OrganizationLoyaltySettings } + +func (f fixedOrganizationSettings) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) { + s := f.s + return &s, nil +} + +// walletMoveDB opens TEST_DATABASE_URL and creates an organization with two customers, +// removed again when the test ends. See internal/repository/wallet_repository_test.go. +func walletMoveDB(t *testing.T) (db *gorm.DB, org, a, b uuid.UUID) { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + + org, a, b = uuid.New(), uuid.New(), uuid.New() + phoneA, phoneB := "08"+a.String()[:10], "08"+b.String()[:10] + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet move test', 'basic')`, org).Error) + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Anita', ?), (?, ?, 'Budi Santoso', ?)`, + a, org, phoneA, b, org, phoneB).Error) + customers := []uuid.UUID{a, b} + t.Cleanup(func() { + db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers) + db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ? AND origin_lot_id IS NOT NULL`, customers) + db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers) + db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers) + db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers) + db.Exec(`DELETE FROM customers WHERE id IN ?`, customers) + db.Exec(`DELETE FROM organizations WHERE id = ?`, org) + }) + return db, org, a, b +} + +func TestWalletExchange_AgainstPostgres(t *testing.T) { + db, _, a, _ := walletMoveDB(t) + wallet := NewWalletProcessor(repository.NewWalletRepository(db)) + txm := repository.NewTxManager(db) + settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{ + Exchange: models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}, + }} + p := NewWalletExchangeProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db), + &movePinFake{good: "482913"}, wallet, txm) + + expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second) + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + in := earn(a, 30, &expiry) + in.Currency = constants.WalletCurrencyCoin + _, err := wallet.Credit(ctx, in) + return err + })) + + res, err := p.Exchange(context.Background(), a, 20, "482913", "db-key", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + assert.Equal(t, int64(6), res.Points) + assert.Equal(t, int64(10), res.CoinBalance) + assert.Equal(t, int64(6), res.PointBalance) + require.Len(t, res.Lots, 1) + require.NotNil(t, res.Lots[0].ExpiresAt) + assert.True(t, res.Lots[0].ExpiresAt.Equal(expiry)) + + // The retry reads the frozen rate back out of JSONB and replays. + again, err := p.Exchange(context.Background(), a, 20, "482913", "db-key", models.CustomerPinRequestInfo{}) + require.NoError(t, err) + assert.True(t, again.Replayed) + assert.Equal(t, int64(6), again.Points) + + var rows int64 + require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE group_id = ?`, res.GroupID).Scan(&rows).Error) + assert.Equal(t, int64(2), rows) +} diff --git a/internal/processor/wallet_processor.go b/internal/processor/wallet_processor.go index 3190fa3..28a22a2 100644 --- a/internal/processor/wallet_processor.go +++ b/internal/processor/wallet_processor.go @@ -142,6 +142,13 @@ func (p *WalletProcessor) LockWallets(ctx context.Context, a, b uuid.UUID) error return err } +// FindTransaction returns the row written under an idempotency key, or nil when the +// key is new. Read it under the wallet lock: a flow that writes several rows uses it +// to recognise a retry before checking limits the first attempt already counts in. +func (p *WalletProcessor) FindTransaction(ctx context.Context, idempotencyKey string) (*entities.WalletTransaction, error) { + return p.repo.GetTransactionByIdempotencyKey(ctx, idempotencyKey) +} + // Credit adds Amount to the wallet and creates its lots. func (p *WalletProcessor) Credit(ctx context.Context, in WalletCreditInput) (*WalletResult, error) { if err := validateWalletEntry(&in.WalletEntry, true); err != nil { diff --git a/internal/repository/wallet_move_repository.go b/internal/repository/wallet_move_repository.go new file mode 100644 index 0000000..08f983d --- /dev/null +++ b/internal/repository/wallet_move_repository.go @@ -0,0 +1,56 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// WalletMoveCustomer is a customer on either side of an exchange or a transfer. +type WalletMoveCustomer struct { + ID uuid.UUID + OrganizationID uuid.UUID + Name string + PhoneNumber *string + IsDefault bool + IsActive bool +} + +// WalletMoveRepository reads what exchanges and transfers check before moving a +// balance (docs/prd-point-coin.md F4, F5). The balance itself only moves through +// WalletProcessor. +type WalletMoveRepository interface { + // GetCustomer returns ErrWalletNotFound when the customer does not exist. + GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error) +} + +type walletMoveRepository struct { + db *gorm.DB +} + +func NewWalletMoveRepository(db *gorm.DB) WalletMoveRepository { + return &walletMoveRepository{db: db} +} + +func (r *walletMoveRepository) GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error) { + return r.customer(ctx, "id = ?", customerID) +} + +func (r *walletMoveRepository) customer(ctx context.Context, where string, arg interface{}) (*WalletMoveCustomer, error) { + var c WalletMoveCustomer + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("customers"). + Select("id, organization_id, name, phone_number, is_default, is_active"). + Where(where, arg). + Take(&c).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrWalletNotFound + } + return nil, fmt.Errorf("failed to get customer: %w", err) + } + return &c, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index ffd18f5..7052040 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -59,12 +59,13 @@ type Router struct { customerPinHandler *handler.CustomerPinHandler pointPaymentHandler *handler.PointPaymentHandler customerOrderPaymentHandler *handler.CustomerOrderPaymentHandler - authMiddleware *middleware.AuthMiddleware + customerWalletHandler *handler.CustomerWalletHandler + authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -115,6 +116,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer customerPinHandler: handler.NewCustomerPinHandler(customerPinService), pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService), customerOrderPaymentHandler: handler.NewCustomerOrderPaymentHandler(customerOrderPaymentService), + customerWalletHandler: handler.NewCustomerWalletHandler(customerWalletService), redisClient: redisClient, } } @@ -169,6 +171,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode) + customer.GET("/wallet/exchange/preview", r.customerWalletHandler.PreviewExchange) + customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange) customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints) // PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11) customer.GET("/pin/status", r.customerPinHandler.Status) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 1baa348..a0b5daa 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -36,6 +36,8 @@ func TestAllRoutesRegister(t *testing.T) { "PUT /api/v1/marketing/loyalty-settings", "GET /api/v1/marketing/loyalty-settings/history", "POST /api/v1/customer/wallet/payment-code", + "GET /api/v1/customer/wallet/exchange/preview", + "POST /api/v1/customer/wallet/exchange", "GET /api/v1/orders/:id/point-payment/preview", "POST /api/v1/customer/orders/:id/pay-with-points", "GET /api/v1/customer/pin/status", diff --git a/internal/service/customer_wallet_service.go b/internal/service/customer_wallet_service.go new file mode 100644 index 0000000..fed1450 --- /dev/null +++ b/internal/service/customer_wallet_service.go @@ -0,0 +1,66 @@ +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()), + }) +}