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
+7
View File
@@ -156,6 +156,7 @@ func (a *App) Initialize(cfg *config.Config) error {
services.walletAdminService, services.walletAdminService,
validators.walletValidator, validators.walletValidator,
services.loyaltySettingsService, services.loyaltySettingsService,
services.customerPinService,
a.redisClient, a.redisClient,
) )
@@ -386,12 +387,15 @@ type processors struct {
walletAdminProcessor *processor.WalletAdminProcessor walletAdminProcessor *processor.WalletAdminProcessor
loyaltySettingsProcessor *processor.LoyaltySettingsProcessor loyaltySettingsProcessor *processor.LoyaltySettingsProcessor
earningProcessor *processor.EarningProcessor earningProcessor *processor.EarningProcessor
customerPinProcessor *processor.CustomerPinProcessor
} }
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
fileClient := client.NewFileClient(cfg.S3Config) fileClient := client.NewFileClient(cfg.S3Config)
fonnteClient := client.NewFonnteClient(cfg.GetFonnte()) fonnteClient := client.NewFonnteClient(cfg.GetFonnte())
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo) otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
// Customer PIN (docs/prd-point-coin.md F11)
customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, otpProcessor)
inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo) inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo)
orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo) orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo)
@@ -448,6 +452,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
walletProcessor: processor.NewWalletProcessor(repos.walletRepo), walletProcessor: processor.NewWalletProcessor(repos.walletRepo),
loyaltySettingsProcessor: loyaltySettingsProcessor, loyaltySettingsProcessor: loyaltySettingsProcessor,
earningProcessor: earningProcessor, earningProcessor: earningProcessor,
customerPinProcessor: customerPinProcessor,
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
} }
} }
@@ -493,6 +498,7 @@ type services struct {
cashAdvanceService *service.CashAdvanceServiceImpl cashAdvanceService *service.CashAdvanceServiceImpl
walletAdminService *service.WalletAdminServiceImpl walletAdminService *service.WalletAdminServiceImpl
loyaltySettingsService *service.LoyaltySettingsServiceImpl loyaltySettingsService *service.LoyaltySettingsServiceImpl
customerPinService *service.CustomerPinServiceImpl
} }
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
@@ -576,6 +582,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor), cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor), walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor),
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor), loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor),
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor),
} }
} }
+12
View File
@@ -12,6 +12,12 @@ const (
ValidationErrorCode = "304" ValidationErrorCode = "304"
InvalidFieldErrorCode = "305" InvalidFieldErrorCode = "305"
NotFoundErrorCode = "404" NotFoundErrorCode = "404"
// PIN outcomes the customer app tells apart (docs/prd-point-coin.md §9).
PinNotSetErrorCode = "PIN_NOT_SET"
PinInvalidErrorCode = "PIN_INVALID"
PinLockedErrorCode = "PIN_LOCKED"
TransferBlockedErrorCode = "TRANSFER_BLOCKED"
TooManyRequestsErrorCode = "429"
) )
const ( const (
@@ -65,6 +71,7 @@ const (
CashAdvanceServiceEntity = "cash_advance_service" CashAdvanceServiceEntity = "cash_advance_service"
WalletServiceEntity = "wallet_service" WalletServiceEntity = "wallet_service"
LoyaltySettingsServiceEntity = "loyalty_settings_service" LoyaltySettingsServiceEntity = "loyalty_settings_service"
CustomerPinServiceEntity = "customer_pin_service"
) )
var HttpErrorMap = map[string]int{ var HttpErrorMap = map[string]int{
@@ -74,6 +81,11 @@ var HttpErrorMap = map[string]int{
ValidationErrorCode: http.StatusBadRequest, ValidationErrorCode: http.StatusBadRequest,
InvalidFieldErrorCode: http.StatusBadRequest, InvalidFieldErrorCode: http.StatusBadRequest,
NotFoundErrorCode: http.StatusNotFound, NotFoundErrorCode: http.StatusNotFound,
PinNotSetErrorCode: http.StatusForbidden,
PinInvalidErrorCode: http.StatusBadRequest,
PinLockedErrorCode: http.StatusLocked,
TransferBlockedErrorCode: http.StatusForbidden,
TooManyRequestsErrorCode: http.StatusTooManyRequests,
} }
// Error messages // Error messages
@@ -0,0 +1,28 @@
package contract
// Requests of /customer/pin and /marketing/customers/:id/pin (docs/prd-point-coin.md
// F11). PINs are strings so a leading zero is kept.
type RequestPinOtpRequest struct {
// pin_setup or pin_reset.
Purpose string `json:"purpose" binding:"required"`
}
type CreateCustomerPinRequest struct {
OtpToken string `json:"otp_token" binding:"required"`
OtpCode string `json:"otp_code" binding:"required"`
Pin string `json:"pin" binding:"required"`
ConfirmPin string `json:"confirm_pin" binding:"required"`
}
type ChangeCustomerPinRequest struct {
OldPin string `json:"old_pin" binding:"required"`
Pin string `json:"pin" binding:"required"`
ConfirmPin string `json:"confirm_pin" binding:"required"`
}
type ResetCustomerPinRequest = CreateCustomerPinRequest
type RemoveCustomerPinRequest struct {
Reason string `json:"reason" binding:"required"`
}
+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()}
}
+39
View File
@@ -0,0 +1,39 @@
package models
import (
"time"
"github.com/google/uuid"
)
// CustomerPinStatus is GET /customer/pin/status.
type CustomerPinStatus struct {
HasPin bool `json:"has_pin"`
LockedUntil *time.Time `json:"locked_until"`
TransferBlockedUntil *time.Time `json:"transfer_blocked_until"`
}
// CustomerPinOtp is what POST /customer/pin/otp returns: the token to send back with
// the code the customer received.
type CustomerPinOtp struct {
Purpose string `json:"purpose"`
OtpToken string `json:"otp_token"`
ExpiresAt time.Time `json:"expires_at"`
}
// CustomerSecurityEventView is one row of GET /marketing/customers/:id/security-events.
type CustomerSecurityEventView struct {
ID uuid.UUID `json:"id"`
Event string `json:"event"`
ActorUser *uuid.UUID `json:"actor_user,omitempty"`
Reason *string `json:"reason,omitempty"`
IPAddress *string `json:"ip_address,omitempty"`
UserAgent *string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CustomerPinRequestInfo is where a PIN request came from, for the security log.
type CustomerPinRequestInfo struct {
IPAddress string
UserAgent string
}
@@ -0,0 +1,467 @@
package processor
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// PIN rules (docs/prd-point-coin.md F11, Q16, Q17).
const (
pinLength = 6
pinMaxAttempts = 5
pinLockDuration = 30 * time.Minute
pinTransferHold = 24 * time.Hour
pinSecurityReasonN = 255
PinOtpPurposeSetup = "pin_setup"
PinOtpPurposeReset = "pin_reset"
)
// Security log events.
const (
PinEventSet = "PIN_SET"
PinEventChanged = "PIN_CHANGED"
PinEventReset = "PIN_RESET"
PinEventFailed = "PIN_FAILED"
PinEventLocked = "PIN_LOCKED"
PinEventRemovedByAdmin = "PIN_REMOVED_BY_ADMIN"
)
// What a PIN approves. Only a transfer is held after a reset.
type PinAction string
const (
PinActionPay PinAction = "PAY"
PinActionExchange PinAction = "EXCHANGE"
PinActionTransfer PinAction = "TRANSFER"
)
// Codes of PinError, which the apps tell apart (docs/prd-point-coin.md §9).
const (
PinErrNotSet = "PIN_NOT_SET"
PinErrInvalid = "PIN_INVALID"
PinErrLocked = "PIN_LOCKED"
PinErrTransferBlocked = "TRANSFER_BLOCKED"
)
// PinError is why a PIN did not approve an action.
type PinError struct {
Code string
// Set for PIN_INVALID: attempts left before the PIN locks.
RemainingAttempts int
// Set for PIN_LOCKED and TRANSFER_BLOCKED.
Until *time.Time
}
func (e *PinError) Error() string {
switch e.Code {
case PinErrNotSet:
return "PIN has not been set"
case PinErrInvalid:
return fmt.Sprintf("wrong PIN, %d attempts left", e.RemainingAttempts)
case PinErrLocked:
return fmt.Sprintf("PIN is locked until %s", e.Until.Format(time.RFC3339))
case PinErrTransferBlocked:
return fmt.Sprintf("transfers are on hold after a PIN reset until %s", e.Until.Format(time.RFC3339))
}
return e.Code
}
var (
// ErrInvalidPinInput wraps a PIN that is malformed, weak, or not confirmed. The
// message never contains the PIN.
ErrInvalidPinInput = errors.New("invalid PIN")
// ErrPinAlreadySet means a first PIN was requested for a customer who has one.
ErrPinAlreadySet = errors.New("PIN has already been set")
// ErrPinOtpInvalid means the OTP was wrong, expired, used, for another purpose, or
// sent to another number.
ErrPinOtpInvalid = errors.New("invalid or expired OTP")
// ErrPinOtpTooSoon means an OTP was requested again too quickly.
ErrPinOtpTooSoon = errors.New("an OTP was sent recently; wait before asking again")
// ErrPinNoPhone means the customer has no phone number to send an OTP to.
ErrPinNoPhone = errors.New("customer has no phone number")
)
type pinOtpSender interface {
CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error)
CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
}
// pinAlerter tells a customer their PIN was locked. There is no push channel to
// customers yet, so the app sends it by WhatsApp.
type pinAlerter interface {
SendWhatsAppMessage(phoneNumber, message string) error
}
// CustomerPinProcessor manages customer PINs (docs/prd-point-coin.md F11). Every flow
// that moves balance on the customer's request calls VerifyPin first (K8).
type CustomerPinProcessor struct {
repo repository.CustomerPinRepository
otp pinOtpSender
alerter pinAlerter
now func() time.Time
cost int
}
func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, alerter pinAlerter) *CustomerPinProcessor {
return &CustomerPinProcessor{repo: repo, otp: otp, alerter: alerter, now: time.Now, cost: bcrypt.DefaultCost}
}
func (p *CustomerPinProcessor) Status(ctx context.Context, customerID uuid.UUID) (*models.CustomerPinStatus, error) {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return nil, err
}
now := p.now()
status := &models.CustomerPinStatus{HasPin: state.PinHash != nil}
if state.LockedUntil != nil && state.LockedUntil.After(now) {
status.LockedUntil = state.LockedUntil
}
if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) {
status.TransferBlockedUntil = state.TransferBlockedUntil
}
return status, nil
}
// RequestOtp sends an OTP to the customer's own phone number, for creating a first PIN
// (pin_setup) or resetting a forgotten one (pin_reset).
func (p *CustomerPinProcessor) RequestOtp(ctx context.Context, customerID uuid.UUID, purpose string) (*models.CustomerPinOtp, error) {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return nil, err
}
switch purpose {
case PinOtpPurposeSetup:
if state.PinHash != nil {
return nil, ErrPinAlreadySet
}
case PinOtpPurposeReset:
if state.PinHash == nil {
return nil, &PinError{Code: PinErrNotSet}
}
default:
return nil, fmt.Errorf("%w: purpose must be %s or %s", ErrInvalidPinInput, PinOtpPurposeSetup, PinOtpPurposeReset)
}
if state.PhoneNumber == nil || *state.PhoneNumber == "" {
return nil, ErrPinNoPhone
}
canSend, _, err := p.otp.CanResendOtp(ctx, *state.PhoneNumber, purpose)
if err != nil {
return nil, err
}
if !canSend {
return nil, ErrPinOtpTooSoon
}
session, err := p.otp.CreateOtpSession(ctx, *state.PhoneNumber, purpose)
if err != nil {
return nil, err
}
if err := p.otp.SendOtpViaWhatsApp(*state.PhoneNumber, session.Code, purpose); err != nil {
return nil, err
}
return &models.CustomerPinOtp{Purpose: purpose, OtpToken: session.Token, ExpiresAt: session.ExpiresAt}, nil
}
// CreatePin sets a customer's first PIN, approved by an OTP to their phone so it is set
// by the owner of the number and not by whoever holds a logged-in phone.
func (p *CustomerPinProcessor) CreatePin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
if state.PinHash != nil {
return ErrPinAlreadySet
}
// Check the PIN before spending the OTP, so a weak PIN does not cost a new code.
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
return err
}
if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeSetup); err != nil {
return err
}
hash, err := p.hash(pin)
if err != nil {
return err
}
if err := p.repo.SetPin(ctx, customerID, hash, nil); err != nil {
return err
}
p.logEvent(ctx, customerID, PinEventSet, nil, nil, info)
return nil
}
// ChangePin replaces the PIN after checking the old one, which counts toward the lock
// like any other attempt. A transfer hold from an earlier reset stays.
func (p *CustomerPinProcessor) ChangePin(ctx context.Context, customerID uuid.UUID, oldPin, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
return err
}
if err := p.verify(ctx, state, oldPin, PinActionPay, info); err != nil {
return err
}
hash, err := p.hash(pin)
if err != nil {
return err
}
if err := p.repo.SetPin(ctx, customerID, hash, p.activeHold(state)); err != nil {
return err
}
p.logEvent(ctx, customerID, PinEventChanged, nil, nil, info)
return nil
}
// ResetPin sets a new PIN for a customer who forgot theirs, approved by an OTP. It also
// lifts a lock, and holds outgoing transfers for 24 hours in case the phone number was
// taken over (Q16).
func (p *CustomerPinProcessor) ResetPin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
if state.PinHash == nil {
return &PinError{Code: PinErrNotSet}
}
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
return err
}
if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeReset); err != nil {
return err
}
hash, err := p.hash(pin)
if err != nil {
return err
}
hold := p.now().Add(pinTransferHold)
if err := p.repo.SetPin(ctx, customerID, hash, &hold); err != nil {
return err
}
p.logEvent(ctx, customerID, PinEventReset, nil, nil, info)
return nil
}
// VerifyPin checks the PIN before an action that moves balance. It returns a *PinError
// with the code the apps act on: PIN_NOT_SET, PIN_INVALID (with the attempts left),
// PIN_LOCKED or TRANSFER_BLOCKED (with until when).
func (p *CustomerPinProcessor) VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
return p.verify(ctx, state, pin, action, info)
}
func (p *CustomerPinProcessor) verify(ctx context.Context, state *repository.CustomerPinState, pin string, action PinAction, info models.CustomerPinRequestInfo) error {
if state.PinHash == nil {
return &PinError{Code: PinErrNotSet}
}
now := p.now()
// A locked PIN is refused before it is compared, even when it is right.
if state.LockedUntil != nil && state.LockedUntil.After(now) {
until := *state.LockedUntil
return &PinError{Code: PinErrLocked, Until: &until}
}
// A held transfer is refused before the PIN is compared, so it costs no attempt.
if action == PinActionTransfer && state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) {
until := *state.TransferBlockedUntil
return &PinError{Code: PinErrTransferBlocked, Until: &until}
}
if bcrypt.CompareHashAndPassword([]byte(*state.PinHash), []byte(pin)) != nil {
attempts, lockedUntil, err := p.repo.RecordFailure(ctx, state.CustomerID, pinMaxAttempts, now, now.Add(pinLockDuration))
if err != nil {
return err
}
p.logEvent(ctx, state.CustomerID, PinEventFailed, nil, nil, info)
if lockedUntil != nil && lockedUntil.After(now) {
// Only the attempt that reached the limit logs the lock and tells the
// customer; attempts racing it just see the lock.
if attempts == pinMaxAttempts {
p.logEvent(ctx, state.CustomerID, PinEventLocked, nil, nil, info)
p.alertLocked(state, *lockedUntil)
}
return &PinError{Code: PinErrLocked, Until: lockedUntil}
}
return &PinError{Code: PinErrInvalid, RemainingAttempts: pinMaxAttempts - attempts}
}
if state.FailedAttempts > 0 || state.LockedUntil != nil {
if err := p.repo.ClearFailures(ctx, state.CustomerID); err != nil {
return err
}
}
return nil
}
// RemovePinByAdmin deletes a customer's PIN, for example when they lost access to it,
// so they have to create a new one through OTP. Admins can never set or read a PIN.
func (p *CustomerPinProcessor) RemovePinByAdmin(ctx context.Context, organizationID, customerID, adminID uuid.UUID, reason string, info models.CustomerPinRequestInfo) error {
reason = strings.TrimSpace(reason)
if reason == "" {
return fmt.Errorf("%w: a reason is required", ErrInvalidPinInput)
}
if adminID == uuid.Nil {
return fmt.Errorf("%w: the admin is unknown", ErrInvalidPinInput)
}
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
if state.OrganizationID != organizationID {
return repository.ErrPinCustomerNotFound
}
if state.PinHash == nil {
return &PinError{Code: PinErrNotSet}
}
if err := p.repo.RemovePin(ctx, customerID); err != nil {
return err
}
reason = truncateRunes(reason, pinSecurityReasonN)
p.logEvent(ctx, customerID, PinEventRemovedByAdmin, &adminID, &reason, info)
return nil
}
// ListEvents returns a page of a customer's PIN security log for the dashboard.
func (p *CustomerPinProcessor) ListEvents(ctx context.Context, organizationID, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerSecurityEventView], error) {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return nil, err
}
if state.OrganizationID != organizationID {
return nil, repository.ErrPinCustomerNotFound
}
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 20
}
rows, total, err := p.repo.ListEvents(ctx, customerID, (page-1)*limit, limit)
if err != nil {
return nil, err
}
events := make([]models.CustomerSecurityEventView, 0, len(rows))
for _, e := range rows {
events = append(events, models.CustomerSecurityEventView{
ID: e.ID, Event: e.Event, ActorUser: e.ActorUser, Reason: e.Reason,
IPAddress: e.IPAddress, UserAgent: e.UserAgent, CreatedAt: e.CreatedAt,
})
}
return &models.PaginatedResponse[models.CustomerSecurityEventView]{
Data: events,
Pagination: models.Pagination{
Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)),
},
}, nil
}
// checkOtp validates an OTP and that it was issued for this purpose to this customer's
// own phone number. Without those checks an OTP from the login flow, or one sent to
// another number, could approve a PIN change.
func (p *CustomerPinProcessor) checkOtp(ctx context.Context, state *repository.CustomerPinState, token, code, purpose string) error {
if token == "" || code == "" || state.PhoneNumber == nil {
return ErrPinOtpInvalid
}
session, err := p.otp.ValidateOtpSession(ctx, token, code)
if err != nil || session == nil {
return ErrPinOtpInvalid
}
if session.Purpose != purpose || session.PhoneNumber != *state.PhoneNumber {
return ErrPinOtpInvalid
}
return nil
}
func (p *CustomerPinProcessor) hash(pin string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(pin), p.cost)
if err != nil {
return "", fmt.Errorf("failed to hash PIN: %w", err)
}
return string(hash), nil
}
func (p *CustomerPinProcessor) activeHold(state *repository.CustomerPinState) *time.Time {
if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(p.now()) {
return state.TransferBlockedUntil
}
return nil
}
// logEvent records a security event. The log is best effort: failing to write it must
// not undo what the customer just did, so a failure is logged instead.
func (p *CustomerPinProcessor) logEvent(ctx context.Context, customerID uuid.UUID, event string, actor *uuid.UUID, reason *string, info models.CustomerPinRequestInfo) {
e := repository.CustomerSecurityEvent{CustomerID: customerID, Event: event, ActorUser: actor, Reason: reason}
if info.IPAddress != "" {
ip := truncateRunes(info.IPAddress, 45)
e.IPAddress = &ip
}
if info.UserAgent != "" {
ua := truncateRunes(info.UserAgent, 255)
e.UserAgent = &ua
}
if err := p.repo.InsertEvent(ctx, e); err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not record %s for customer %s", event, customerID), err)
}
}
func (p *CustomerPinProcessor) alertLocked(state *repository.CustomerPinState, until time.Time) {
if p.alerter == nil || state.PhoneNumber == nil {
return
}
message := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.",
until.In(walletDisplayLocation).Format("02 Jan 2006 15:04 WIB"), pinMaxAttempts)
if err := p.alerter.SendWhatsAppMessage(*state.PhoneNumber, message); err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s their PIN is locked", state.CustomerID), err)
}
}
// checkNewPin rejects a PIN that is not 6 digits, does not match its confirmation, or
// is easy to guess: one digit repeated, a run up or down, or the birth date as DDMMYY
// or YYMMDD.
func checkNewPin(pin, confirm string, birthDate *time.Time) error {
if len(pin) != pinLength {
return fmt.Errorf("%w: a PIN is %d digits", ErrInvalidPinInput, pinLength)
}
for _, r := range pin {
if r < '0' || r > '9' {
return fmt.Errorf("%w: a PIN is digits only", ErrInvalidPinInput)
}
}
if pin != confirm {
return fmt.Errorf("%w: the PIN and its confirmation differ", ErrInvalidPinInput)
}
same, up, down := true, true, true
for i := 1; i < len(pin); i++ {
d := int(pin[i]) - int(pin[i-1])
same = same && d == 0
up = up && d == 1
down = down && d == -1
}
if same || up || down {
return fmt.Errorf("%w: the PIN is too easy to guess", ErrInvalidPinInput)
}
if birthDate != nil {
for _, layout := range []string{"020106", "060102"} {
if pin == birthDate.Format(layout) {
return fmt.Errorf("%w: the PIN must not be your birth date", ErrInvalidPinInput)
}
}
}
return nil
}
@@ -0,0 +1,257 @@
package processor
import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// otpFake keeps OTP sessions in memory with the checks the real one makes.
type otpFake struct {
mu sync.Mutex
sessions map[string]*entities.OtpSession
sent []string
}
func (f *otpFake) CanResendOtp(context.Context, string, string) (bool, int, error) {
return true, 0, nil
}
func (f *otpFake) CreateOtpSession(_ context.Context, phone, purpose string) (*entities.OtpSession, error) {
f.mu.Lock()
defer f.mu.Unlock()
s := &entities.OtpSession{Token: uuid.NewString(), Code: "246810", PhoneNumber: phone, Purpose: purpose, ExpiresAt: time.Now().Add(5 * time.Minute)}
f.sessions[s.Token] = s
return s, nil
}
func (f *otpFake) SendOtpViaWhatsApp(phone, code, purpose string) error {
f.sent = append(f.sent, purpose)
return nil
}
func (f *otpFake) ValidateOtpSession(_ context.Context, token, code string) (*entities.OtpSession, error) {
f.mu.Lock()
defer f.mu.Unlock()
s := f.sessions[token]
if s == nil || s.IsUsed || s.Code != code {
return nil, errors.New("invalid OTP")
}
s.IsUsed = true
return s, nil
}
// issue creates a session as if it had been sent, for any purpose and number.
func (f *otpFake) issue(phone, purpose string) *entities.OtpSession {
s, _ := f.CreateOtpSession(context.Background(), phone, purpose)
return s
}
type alerterFake struct {
mu sync.Mutex
messages []string
}
func (f *alerterFake) SendWhatsAppMessage(_ string, message string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.messages = append(f.messages, message)
return nil
}
// Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestCustomerPin_AgainstPostgres(t *testing.T) {
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)
ctx := context.Background()
org, otherOrg, customer, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
phone := "0812" + customer.String()[:8]
exec := func(q string, args ...any) {
t.Helper()
require.NoError(t, db.Exec(q, args...).Error)
}
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pin test', 'basic'), (?, 'other', 'basic')`, org, otherOrg)
exec(`INSERT INTO customers (id, organization_id, name, phone_number, birth_date) VALUES (?, ?, 'Budi', ?, '1990-03-14')`, customer, org, phone)
t.Cleanup(func() {
db.Exec(`DELETE FROM customer_security_events WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
})
otp := &otpFake{sessions: map[string]*entities.OtpSession{}}
alerts := &alerterFake{}
p := NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), otp, alerts)
p.cost = bcrypt.MinCost
clock := time.Now()
var clockMu sync.Mutex
p.now = func() time.Time { clockMu.Lock(); defer clockMu.Unlock(); return clock }
advance := func(d time.Duration) { clockMu.Lock(); clock = clock.Add(d); clockMu.Unlock() }
info := models.CustomerPinRequestInfo{IPAddress: "10.0.0.7", UserAgent: "EnakApp/2.0"}
const pin, newPin, resetPin = "482913", "572039", "613408"
pinErr := func(err error) *PinError {
t.Helper()
var pe *PinError
require.True(t, errors.As(err, &pe), "want a PinError, got %v", err)
for _, secret := range []string{pin, newPin, resetPin} {
assert.NotContains(t, err.Error(), secret, "an error must never contain a PIN")
}
return pe
}
events := func() []string {
t.Helper()
var out []string
require.NoError(t, db.Raw(`SELECT event FROM customer_security_events WHERE customer_id = ? ORDER BY created_at, id`, customer).Scan(&out).Error)
return out
}
// No PIN yet: nothing can be approved.
status, err := p.Status(ctx, customer)
require.NoError(t, err)
assert.False(t, status.HasPin)
assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info)).Code)
// Creating the first PIN takes an OTP sent to the customer's own number, for this
// purpose.
sent, err := p.RequestOtp(ctx, customer, PinOtpPurposeSetup)
require.NoError(t, err)
assert.Equal(t, []string{PinOtpPurposeSetup}, otp.sent)
loginOtp := otp.issue(phone, "login")
assert.ErrorIs(t, p.CreatePin(ctx, customer, loginOtp.Token, loginOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP for another purpose")
strangerOtp := otp.issue("0899999999", PinOtpPurposeSetup)
assert.ErrorIs(t, p.CreatePin(ctx, customer, strangerOtp.Token, strangerOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP sent to another number")
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "000000", pin, pin, info), ErrPinOtpInvalid, "a wrong code")
// A weak PIN is refused before the OTP is used, so the same OTP still works after.
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "123456", "123456", info), ErrInvalidPinInput)
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "140390", "140390", info), ErrInvalidPinInput, "birth date")
require.NoError(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info))
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info), ErrPinAlreadySet)
var stored string
require.NoError(t, db.Raw(`SELECT pin_hash FROM customers WHERE id = ?`, customer).Scan(&stored).Error)
assert.NotContains(t, stored, pin, "only a hash is stored")
assert.True(t, strings.HasPrefix(stored, "$2"), "bcrypt")
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
// Four wrong attempts count down; the fifth locks for 30 minutes.
for left := 4; left >= 1; left-- {
pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
assert.Equal(t, PinErrInvalid, pe.Code)
assert.Equal(t, left, pe.RemainingAttempts)
}
pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
assert.Equal(t, PinErrLocked, pe.Code)
assert.WithinDuration(t, clock.Add(30*time.Minute), *pe.Until, time.Second)
assert.Len(t, alerts.messages, 1, "the customer is told the PIN locked")
// While locked even the right PIN is refused.
pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info))
assert.Equal(t, PinErrLocked, pe.Code)
status, err = p.Status(ctx, customer)
require.NoError(t, err)
assert.NotNil(t, status.LockedUntil)
// Once the lock runs out a wrong PIN starts a new series of five.
advance(31 * time.Minute)
pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
assert.Equal(t, PinErrInvalid, pe.Code)
assert.Equal(t, 4, pe.RemainingAttempts)
// The right PIN resets the count.
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
assert.Equal(t, 4, pe.RemainingAttempts)
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
// Wrong attempts made at once all count: none slips past the lock.
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() { defer wg.Done(); _ = p.VerifyPin(ctx, customer, "000001", PinActionPay, info) }()
}
wg.Wait()
pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info))
assert.Equal(t, PinErrLocked, pe.Code)
// Resetting through OTP lifts the lock and holds transfers for 24 hours.
_, err = p.RequestOtp(ctx, customer, PinOtpPurposeReset)
require.NoError(t, err)
setupOtp := otp.issue(phone, PinOtpPurposeSetup)
assert.ErrorIs(t, p.ResetPin(ctx, customer, setupOtp.Token, setupOtp.Code, resetPin, resetPin, info), ErrPinOtpInvalid, "a setup OTP cannot reset")
resetOtp := otp.issue(phone, PinOtpPurposeReset)
require.NoError(t, p.ResetPin(ctx, customer, resetOtp.Token, resetOtp.Code, resetPin, resetPin, info))
status, err = p.Status(ctx, customer)
require.NoError(t, err)
assert.Nil(t, status.LockedUntil, "the lock is lifted")
require.NotNil(t, status.TransferBlockedUntil)
assert.WithinDuration(t, clock.Add(24*time.Hour), *status.TransferBlockedUntil, time.Second)
require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionPay, info), "paying still works")
require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionExchange, info), "exchanging still works")
pe = pinErr(p.VerifyPin(ctx, customer, resetPin, PinActionTransfer, info))
assert.Equal(t, PinErrTransferBlocked, pe.Code)
var failed int
require.NoError(t, db.Raw(`SELECT pin_failed_attempts FROM customers WHERE id = ?`, customer).Scan(&failed).Error)
assert.Zero(t, failed, "a held transfer costs no attempt")
// Changing the PIN needs the old one and keeps the transfer hold.
assert.Equal(t, PinErrInvalid, pinErr(p.ChangePin(ctx, customer, "000001", newPin, newPin, info)).Code)
require.NoError(t, p.ChangePin(ctx, customer, resetPin, newPin, newPin, info))
require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionPay, info))
assert.Equal(t, PinErrTransferBlocked, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info)).Code)
advance(25 * time.Hour)
require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info), "the hold ends after 24 hours")
// An admin can remove the PIN, only in their own organization and with a reason.
assert.ErrorIs(t, p.RemovePinByAdmin(ctx, otherOrg, customer, admin, "hilang HP", info), repository.ErrPinCustomerNotFound)
assert.ErrorIs(t, p.RemovePinByAdmin(ctx, org, customer, admin, " ", info), ErrInvalidPinInput)
require.NoError(t, p.RemovePinByAdmin(ctx, org, customer, admin, "hilang HP", info))
status, err = p.Status(ctx, customer)
require.NoError(t, err)
assert.False(t, status.HasPin)
assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionPay, info)).Code)
// Every event is in the security log, with where it came from.
got := events()
for _, want := range []string{PinEventSet, PinEventFailed, PinEventLocked, PinEventReset, PinEventChanged, PinEventRemovedByAdmin} {
assert.Contains(t, got, want)
}
page, err := p.ListEvents(ctx, org, customer, 1, 100)
require.NoError(t, err)
assert.EqualValues(t, len(got), page.Pagination.Total)
removed := page.Data[0]
assert.Equal(t, PinEventRemovedByAdmin, removed.Event)
assert.Equal(t, &admin, removed.ActorUser)
assert.Equal(t, "hilang HP", *removed.Reason)
assert.Equal(t, "10.0.0.7", *removed.IPAddress)
_, err = p.ListEvents(ctx, otherOrg, customer, 1, 10)
assert.ErrorIs(t, err, repository.ErrPinCustomerNotFound)
var locked int
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM customer_security_events WHERE customer_id = ? AND event = ?`, customer, PinEventLocked).Scan(&locked).Error)
assert.Equal(t, locked, len(alerts.messages), "one alert per lock")
}
@@ -0,0 +1,36 @@
package processor
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestCheckNewPin(t *testing.T) {
birth := time.Date(1990, 3, 14, 0, 0, 0, 0, time.UTC)
for _, ok := range []string{"482913", "019283", "135790", "112233"} {
assert.NoError(t, checkNewPin(ok, ok, &birth), ok)
}
for name, c := range map[string][2]string{
"too short": {"12345", "12345"},
"too long": {"1234567", "1234567"},
"not digits": {"12a456", "12a456"},
"confirmation": {"482913", "482914"},
"one digit": {"111111", "111111"},
"zeros": {"000000", "000000"},
"run up": {"123456", "123456"},
"run up from 4": {"456789", "456789"},
"run down": {"654321", "654321"},
"run down from 9": {"987654", "987654"},
"birth date DDMMYY": {"140390", "140390"},
"birth date YYMMDD": {"900314", "900314"},
} {
err := checkNewPin(c[0], c[1], &birth)
assert.ErrorIs(t, err, ErrInvalidPinInput, name)
assert.NotContains(t, err.Error(), c[0], "%s: the message must not echo the PIN", name)
}
// Without a birth date only the other rules apply.
assert.NoError(t, checkNewPin("140390", "140390", nil))
}
+13
View File
@@ -18,6 +18,8 @@ type OtpProcessor interface {
CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
ResendOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) ResendOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error
// SendWhatsAppMessage sends any message to a customer number, formatted like OTPs.
SendWhatsAppMessage(phoneNumber string, message string) error
ValidateOtpCode(code string) bool ValidateOtpCode(code string) bool
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error) ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
InvalidateOtpSession(ctx context.Context, token string) error InvalidateOtpSession(ctx context.Context, token string) error
@@ -133,6 +135,10 @@ func (p *otpProcessor) SendOtpViaWhatsApp(phoneNumber string, otpCode string, pu
switch purpose { switch purpose {
case "login": case "login":
message = fmt.Sprintf("Kode OTP untuk login kamu adalah %s. Berlaku 5 menit.", otpCode) message = fmt.Sprintf("Kode OTP untuk login kamu adalah %s. Berlaku 5 menit.", otpCode)
case "pin_setup":
message = fmt.Sprintf("Kode OTP untuk membuat PIN EnakPoint kamu adalah %s. Berlaku 5 menit. Jangan berikan kode ini kepada siapa pun, termasuk kasir.", otpCode)
case "pin_reset":
message = fmt.Sprintf("Kode OTP untuk reset PIN EnakPoint kamu adalah %s. Berlaku 5 menit. Jangan berikan kode ini kepada siapa pun. Setelah reset, transfer ditahan 24 jam.", otpCode)
case "registration": case "registration":
message = fmt.Sprintf("Kode OTP untuk registrasi kamu adalah %s. Berlaku 5 menit.", otpCode) message = fmt.Sprintf("Kode OTP untuk registrasi kamu adalah %s. Berlaku 5 menit.", otpCode)
default: default:
@@ -236,3 +242,10 @@ func (p *otpProcessor) formatPhoneNumber(phoneNumber string) string {
return digits return digits
} }
func (p *otpProcessor) SendWhatsAppMessage(phoneNumber string, message string) error {
if err := p.fonnteClient.SendWhatsAppMessage(p.formatPhoneNumber(phoneNumber), message); err != nil {
return fmt.Errorf("failed to send WhatsApp message: %w", err)
}
return nil
}
@@ -0,0 +1,220 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ErrPinCustomerNotFound means the customer does not exist.
var ErrPinCustomerNotFound = errors.New("pin: customer not found")
// CustomerPinState is a customer's PIN and what guards it. It lives in the customers
// table but is read and written only here, never through the Customer entity, so the
// hash cannot end up in a customer response.
type CustomerPinState struct {
CustomerID uuid.UUID
OrganizationID uuid.UUID
PhoneNumber *string
BirthDate *time.Time
PinHash *string
PinSetAt *time.Time
FailedAttempts int
LockedUntil *time.Time
TransferBlockedUntil *time.Time
}
// CustomerSecurityEvent is one row of the PIN security log.
type CustomerSecurityEvent struct {
ID uuid.UUID
CustomerID uuid.UUID
Event string
ActorUser *uuid.UUID
Reason *string
IPAddress *string
UserAgent *string
CreatedAt time.Time
}
// CustomerPinRepository stores customer PINs and their security log
// (docs/prd-point-coin.md F11).
type CustomerPinRepository interface {
GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error)
// SetPin stores a new PIN hash, clears the failure counter and any lock, and sets
// or clears the transfer hold.
SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error
// RemovePin deletes the PIN, so the customer has to create a new one through OTP.
RemovePin(ctx context.Context, customerID uuid.UUID) error
// RecordFailure adds one wrong attempt in a single statement, so wrong attempts
// made at the same time all count. A lock that has already run out starts the
// count again. When the count reaches maxAttempts the PIN is locked until
// lockUntil. It returns the count and lock after the update.
RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error)
ClearFailures(ctx context.Context, customerID uuid.UUID) error
InsertEvent(ctx context.Context, event CustomerSecurityEvent) error
// ListEvents returns a page of the customer's log, newest first, and the total.
ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error)
}
type customerPinRepository struct {
db *gorm.DB
}
func NewCustomerPinRepository(db *gorm.DB) CustomerPinRepository {
return &customerPinRepository{db: db}
}
func (r *customerPinRepository) GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error) {
var rows []struct {
CustomerID string
OrganizationID string
PhoneNumber *string
BirthDate *time.Time
PinHash *string
PinSetAt *time.Time
PinFailedAttempts int
PinLockedUntil *time.Time
TransferBlockedUntil *time.Time
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT id::text AS customer_id, organization_id::text AS organization_id,
COALESCE(phone_number, phone) AS phone_number, birth_date,
pin_hash, pin_set_at, pin_failed_attempts, pin_locked_until, transfer_blocked_until
FROM customers WHERE id = ? LIMIT 1`, customerID).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to read customer PIN: %w", err)
}
if len(rows) == 0 {
return nil, ErrPinCustomerNotFound
}
row := rows[0]
state := &CustomerPinState{
PhoneNumber: row.PhoneNumber,
BirthDate: row.BirthDate,
PinHash: row.PinHash,
PinSetAt: row.PinSetAt,
FailedAttempts: row.PinFailedAttempts,
LockedUntil: row.PinLockedUntil,
TransferBlockedUntil: row.TransferBlockedUntil,
}
state.CustomerID, _ = uuid.Parse(row.CustomerID)
state.OrganizationID, _ = uuid.Parse(row.OrganizationID)
return state, nil
}
func (r *customerPinRepository) SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE customers SET pin_hash = ?, pin_set_at = NOW(), pin_failed_attempts = 0,
pin_locked_until = NULL, transfer_blocked_until = ?, updated_at = NOW()
WHERE id = ?`, hash, transferBlockedUntil, customerID)
if result.Error != nil {
return fmt.Errorf("failed to store customer PIN: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrPinCustomerNotFound
}
return nil
}
func (r *customerPinRepository) RemovePin(ctx context.Context, customerID uuid.UUID) error {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE customers SET pin_hash = NULL, pin_set_at = NULL, pin_failed_attempts = 0,
pin_locked_until = NULL, updated_at = NOW()
WHERE id = ?`, customerID)
if result.Error != nil {
return fmt.Errorf("failed to remove customer PIN: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrPinCustomerNotFound
}
return nil
}
func (r *customerPinRepository) RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) {
var rows []struct {
PinFailedAttempts int
PinLockedUntil *time.Time
}
// When an earlier lock has run out, this attempt is the first of a new series.
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
UPDATE customers SET
pin_failed_attempts = CASE
WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN 1
ELSE pin_failed_attempts + 1 END,
pin_locked_until = CASE
WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN NULL
WHEN pin_failed_attempts + 1 >= @max THEN @lock
ELSE pin_locked_until END
WHERE id = @id
RETURNING pin_failed_attempts, pin_locked_until`,
map[string]interface{}{"now": now, "max": maxAttempts, "lock": lockUntil, "id": customerID}).
Scan(&rows).Error
if err != nil {
return 0, nil, fmt.Errorf("failed to record a wrong PIN: %w", err)
}
if len(rows) == 0 {
return 0, nil, ErrPinCustomerNotFound
}
return rows[0].PinFailedAttempts, rows[0].PinLockedUntil, nil
}
func (r *customerPinRepository) ClearFailures(ctx context.Context, customerID uuid.UUID) error {
return DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE customers SET pin_failed_attempts = 0, pin_locked_until = NULL
WHERE id = ? AND (pin_failed_attempts <> 0 OR pin_locked_until IS NOT NULL)`, customerID).Error
}
func (r *customerPinRepository) InsertEvent(ctx context.Context, event CustomerSecurityEvent) error {
err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
INSERT INTO customer_security_events (customer_id, event, actor_user, reason, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?)`,
event.CustomerID, event.Event, event.ActorUser, event.Reason, event.IPAddress, event.UserAgent).Error
if err != nil {
return fmt.Errorf("failed to record security event: %w", err)
}
return nil
}
func (r *customerPinRepository) ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error) {
db := DBFromContext(ctx, r.db).WithContext(ctx)
var total int64
if err := db.Table("customer_security_events").Where("customer_id = ?", customerID).Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count security events: %w", err)
}
var rows []struct {
ID string
CustomerID string
Event string
ActorUser *string
Reason *string
IPAddress *string
UserAgent *string
CreatedAt time.Time
}
err := db.Raw(`
SELECT id::text AS id, customer_id::text AS customer_id, event, actor_user::text AS actor_user,
reason, ip_address, user_agent, created_at
FROM customer_security_events WHERE customer_id = ?
ORDER BY created_at DESC, id DESC OFFSET ? LIMIT ?`, customerID, offset, limit).Scan(&rows).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to list security events: %w", err)
}
events := make([]CustomerSecurityEvent, 0, len(rows))
for _, row := range rows {
e := CustomerSecurityEvent{Event: row.Event, Reason: row.Reason, IPAddress: row.IPAddress, UserAgent: row.UserAgent, CreatedAt: row.CreatedAt}
e.ID, _ = uuid.Parse(row.ID)
e.CustomerID, _ = uuid.Parse(row.CustomerID)
if row.ActorUser != nil {
if id, err := uuid.Parse(*row.ActorUser); err == nil {
e.ActorUser = &id
}
}
events = append(events, e)
}
return events, total, nil
}
+11 -1
View File
@@ -56,12 +56,13 @@ type Router struct {
cashAdvanceHandler *handler.CashAdvanceHandler cashAdvanceHandler *handler.CashAdvanceHandler
walletAdminHandler *handler.WalletAdminHandler walletAdminHandler *handler.WalletAdminHandler
loyaltySettingsHandler *handler.LoyaltySettingsHandler loyaltySettingsHandler *handler.LoyaltySettingsHandler
customerPinHandler *handler.CustomerPinHandler
authMiddleware *middleware.AuthMiddleware authMiddleware *middleware.AuthMiddleware
customerAuthMiddleware *middleware.CustomerAuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware
redisClient *redis.Client 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, 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, redisClient *redis.Client) *Router {
return &Router{ return &Router{
config: cfg, config: cfg,
@@ -109,6 +110,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator), cashAdvanceHandler: handler.NewCashAdvanceHandler(cashAdvanceService, cashAdvanceValidator),
walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator), walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator),
loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService), loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService),
customerPinHandler: handler.NewCustomerPinHandler(customerPinService),
redisClient: redisClient, redisClient: redisClient,
} }
} }
@@ -162,6 +164,12 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens) customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens)
customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet)
customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions)
// PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11)
customer.GET("/pin/status", r.customerPinHandler.Status)
customer.POST("/pin/otp", r.customerPinHandler.RequestOtp)
customer.POST("/pin", r.customerPinHandler.CreatePin)
customer.PUT("/pin", r.customerPinHandler.ChangePin)
customer.POST("/pin/reset", r.customerPinHandler.ResetPin)
customer.GET("/games", r.customerPointsHandler.GetCustomerGames) customer.GET("/games", r.customerPointsHandler.GetCustomerGames)
customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame) customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame)
customer.POST("/spin", r.spinGameHandler.PlaySpinGame) customer.POST("/spin", r.spinGameHandler.PlaySpinGame)
@@ -624,6 +632,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
{ {
marketingCustomers.GET("/:id/wallet", r.walletAdminHandler.GetCustomerWallet) marketingCustomers.GET("/:id/wallet", r.walletAdminHandler.GetCustomerWallet)
marketingCustomers.POST("/:id/wallet/adjust", r.authMiddleware.RequireLoyaltyManager(), r.walletAdminHandler.AdjustCustomerWallet) marketingCustomers.POST("/:id/wallet/adjust", r.authMiddleware.RequireLoyaltyManager(), r.walletAdminHandler.AdjustCustomerWallet)
marketingCustomers.DELETE("/:id/pin", r.authMiddleware.RequireLoyaltyManager(), r.customerPinHandler.RemovePin)
marketingCustomers.GET("/:id/security-events", r.customerPinHandler.ListSecurityEvents)
} }
campaignRules := gamification.Group("/campaign-rules") campaignRules := gamification.Group("/campaign-rules")
+7
View File
@@ -32,6 +32,13 @@ func TestAllRoutesRegister(t *testing.T) {
"POST /api/v1/marketing/customers/:id/wallet/adjust", "POST /api/v1/marketing/customers/:id/wallet/adjust",
"GET /api/v1/outlets/:outlet_id/loyalty-settings", "GET /api/v1/outlets/:outlet_id/loyalty-settings",
"PUT /api/v1/outlets/:outlet_id/loyalty-settings", "PUT /api/v1/outlets/:outlet_id/loyalty-settings",
"GET /api/v1/customer/pin/status",
"POST /api/v1/customer/pin/otp",
"POST /api/v1/customer/pin",
"PUT /api/v1/customer/pin",
"POST /api/v1/customer/pin/reset",
"DELETE /api/v1/marketing/customers/:id/pin",
"GET /api/v1/marketing/customers/:id/security-events",
} { } {
assert.True(t, registered[want], want) assert.True(t, registered[want], want)
} }
+128
View File
@@ -0,0 +1,128 @@
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
}
type CustomerPinServiceImpl struct {
pins *processor.CustomerPinProcessor
}
func NewCustomerPinService(pins *processor.CustomerPinProcessor) *CustomerPinServiceImpl {
return &CustomerPinServiceImpl{pins: pins}
}
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()),
})
}
@@ -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)
}
}
@@ -0,0 +1,9 @@
DROP TABLE IF EXISTS customer_security_events;
ALTER TABLE customers
DROP CONSTRAINT IF EXISTS chk_customers_pin_failed_attempts,
DROP COLUMN IF EXISTS transfer_blocked_until,
DROP COLUMN IF EXISTS pin_locked_until,
DROP COLUMN IF EXISTS pin_failed_attempts,
DROP COLUMN IF EXISTS pin_set_at,
DROP COLUMN IF EXISTS pin_hash;
+32
View File
@@ -0,0 +1,32 @@
-- Customer PIN (docs/prd-point-coin.md F11, K8). A 6-digit PIN, separate from the
-- login password, approves everything that moves EnakPoint or EnakCoin on the
-- customer's request. Only its bcrypt hash is stored.
ALTER TABLE customers
ADD COLUMN pin_hash VARCHAR(255),
ADD COLUMN pin_set_at TIMESTAMP WITH TIME ZONE,
-- Kept in the database, not a cache, so it cannot be dodged by waiting for a cache
-- to expire or by hitting another server (Q17).
ADD COLUMN pin_failed_attempts INT NOT NULL DEFAULT 0,
ADD COLUMN pin_locked_until TIMESTAMP WITH TIME ZONE,
-- Outgoing transfers are held for 24 hours after a PIN reset (Q16).
ADD COLUMN transfer_blocked_until TIMESTAMP WITH TIME ZONE,
ADD CONSTRAINT chk_customers_pin_failed_attempts CHECK (pin_failed_attempts >= 0);
-- Security log of PIN events. Not a balance movement, so not in wallet_transactions.
CREATE TABLE customer_security_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
-- PIN_SET, PIN_CHANGED, PIN_RESET, PIN_FAILED, PIN_LOCKED, PIN_REMOVED_BY_ADMIN
event VARCHAR(30) NOT NULL,
-- The admin, for PIN_REMOVED_BY_ADMIN.
actor_user UUID,
reason VARCHAR(255),
ip_address VARCHAR(45),
user_agent VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT chk_customer_security_events_admin CHECK (
event <> 'PIN_REMOVED_BY_ADMIN' OR (actor_user IS NOT NULL AND reason IS NOT NULL))
);
CREATE INDEX idx_customer_security_events_customer_id_created_at ON customer_security_events(customer_id, created_at DESC);