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>
71 lines
2.3 KiB
Go
71 lines
2.3 KiB
Go
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))
|
|
}
|