feat(loyalty): customer PIN

Adds the 6-digit customer PIN that approves every action moving EnakPoint
or EnakCoin on the customer's request (docs/prd-point-coin.md K8, F11, Q16,
Q17, PC-301).

Migration 000093 adds the PIN columns to customers and the
customer_security_events table. PIN data is read and written only through
CustomerPinRepository, never the Customer entity, so the hash cannot reach
a customer response. Only a bcrypt hash is stored.

- /customer/pin: status, OTP (pin_setup, pin_reset), create, change,
  reset. The OTP must be for that purpose and sent to the customer's own
  number; the existing OTP validation checks neither. A new PIN is checked
  (6 digits, confirmed, not one digit, not a run up or down, not the birth
  date as DDMMYY or YYMMDD) before the OTP is spent.
- Five wrong attempts in a row lock the PIN for 30 minutes; the counter is
  incremented in one statement so attempts at the same time all count,
  and a lock that ran out starts a new series. A locked PIN is refused even
  when right. The customer is told by WhatsApp, as there is no push channel
  to customers yet; only the attempt that reached the limit alerts.
- A reset through OTP lifts the lock and holds outgoing transfers for 24
  hours; paying and exchanging still work, and a held transfer costs no
  attempt.
- VerifyPin(ctx, customer, pin, action) for the flows that follow, with
  PIN_NOT_SET, PIN_INVALID (attempts left), PIN_LOCKED and
  TRANSFER_BLOCKED (until when), which PinErrorResponse turns into
  distinct codes and statuses.
- DELETE /marketing/customers/:id/pin (loyalty managers, reason required)
  and GET /marketing/customers/:id/security-events, scoped to the
  organization.

Every PIN event is in the security log with IP and user agent. No message
or binding error contains a PIN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 11:20:29 +07:00
co-authored by Claude Opus 5.5
parent fc97c78300
commit 8370851ed2
16 changed files with 1460 additions and 7 deletions
+138
View File
@@ -0,0 +1,138 @@
package handler
import (
"strconv"
"github.com/gin-gonic/gin"
"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/service"
"apskel-pos-be/internal/util"
)
// CustomerPinHandler serves /customer/pin and the dashboard's PIN endpoints
// (docs/prd-point-coin.md F11). Request bodies hold PINs, so nothing here logs a body,
// and binding errors are reported without the values sent.
type CustomerPinHandler struct {
pinService service.CustomerPinService
}
func NewCustomerPinHandler(pinService service.CustomerPinService) *CustomerPinHandler {
return &CustomerPinHandler{pinService: pinService}
}
func (h *CustomerPinHandler) Status(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::Status")
if !ok {
return
}
util.HandleResponse(c.Writer, c.Request, h.pinService.Status(c.Request.Context(), customerID), "CustomerPinHandler::Status")
}
func (h *CustomerPinHandler) RequestOtp(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::RequestOtp")
if !ok {
return
}
var req contract.RequestPinOtpRequest
if !bindPinRequest(c, &req, "CustomerPinHandler::RequestOtp") {
return
}
util.HandleResponse(c.Writer, c.Request, h.pinService.RequestOtp(c.Request.Context(), customerID, &req), "CustomerPinHandler::RequestOtp")
}
func (h *CustomerPinHandler) CreatePin(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::CreatePin")
if !ok {
return
}
var req contract.CreateCustomerPinRequest
if !bindPinRequest(c, &req, "CustomerPinHandler::CreatePin") {
return
}
util.HandleResponse(c.Writer, c.Request, h.pinService.CreatePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::CreatePin")
}
func (h *CustomerPinHandler) ChangePin(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ChangePin")
if !ok {
return
}
var req contract.ChangeCustomerPinRequest
if !bindPinRequest(c, &req, "CustomerPinHandler::ChangePin") {
return
}
util.HandleResponse(c.Writer, c.Request, h.pinService.ChangePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ChangePin")
}
func (h *CustomerPinHandler) ResetPin(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ResetPin")
if !ok {
return
}
var req contract.ResetCustomerPinRequest
if !bindPinRequest(c, &req, "CustomerPinHandler::ResetPin") {
return
}
util.HandleResponse(c.Writer, c.Request, h.pinService.ResetPin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ResetPin")
}
// RemovePin is DELETE /marketing/customers/:id/pin.
func (h *CustomerPinHandler) RemovePin(c *gin.Context) {
customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::RemovePin")
if !ok {
return
}
var req contract.RemoveCustomerPinRequest
if !bindPinRequest(c, &req, "CustomerPinHandler::RemovePin") {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.pinService.RemovePin(ctx, appcontext.FromGinContext(ctx), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::RemovePin")
}
// ListSecurityEvents is GET /marketing/customers/:id/security-events.
func (h *CustomerPinHandler) ListSecurityEvents(c *gin.Context) {
customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::ListSecurityEvents")
if !ok {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.pinService.ListSecurityEvents(ctx, appcontext.FromGinContext(ctx), customerID, page, limit), "CustomerPinHandler::ListSecurityEvents")
}
// bindPinRequest binds a JSON body. The error it reports names what is wrong, never the
// values, since those can be PINs.
func bindPinRequest(c *gin.Context, req interface{}, method string) bool {
if err := c.ShouldBindJSON(req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "invalid request body: required fields are missing or have the wrong type"),
}), method)
return false
}
return true
}
// customerIDFromGin reads the customer set by CustomerAuthMiddleware.
func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) {
raw, _ := c.Get("customer_id")
s, _ := raw.(string)
id, err := uuid.Parse(s)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
}), method)
return uuid.Nil, false
}
return id, true
}
func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo {
return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()}
}