Files
apskel-pos-backend/internal/processor/otp_processor.go
T
efrilmandClaude Opus 5.5 8370851ed2 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>
2026-09-30 11:20:29 +07:00

252 lines
8.1 KiB
Go

package processor
import (
"context"
"fmt"
"math/rand"
"time"
"apskel-pos-be/internal/client"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
type OtpProcessor interface {
GenerateOtpCode() string
CreateOtpSession(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
// SendWhatsAppMessage sends any message to a customer number, formatted like OTPs.
SendWhatsAppMessage(phoneNumber string, message string) error
ValidateOtpCode(code string) bool
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
InvalidateOtpSession(ctx context.Context, token string) error
CleanupExpiredOtps(ctx context.Context) error
CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error) // Returns (canResend, secondsUntilNext, error)
}
type otpProcessor struct {
fonnteClient client.FonnteClient
otpRepo repository.OtpRepository
}
func NewOtpProcessor(fonnteClient client.FonnteClient, otpRepo repository.OtpRepository) OtpProcessor {
return &otpProcessor{
fonnteClient: fonnteClient,
otpRepo: otpRepo,
}
}
func (p *otpProcessor) GenerateOtpCode() string {
// Generate a 6-digit OTP code
rand.Seed(time.Now().UnixNano())
code := rand.Intn(900000) + 100000 // Generates number between 100000-999999
return fmt.Sprintf("%06d", code)
}
func (p *otpProcessor) CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) {
// Generate OTP code and token
otpCode := p.GenerateOtpCode()
token := uuid.New().String()
// Invalidate any existing OTP sessions for this phone number and purpose
if err := p.otpRepo.InvalidateOtpSessionsByPhone(ctx, phoneNumber, purpose); err != nil {
return nil, fmt.Errorf("failed to invalidate existing OTP sessions: %w", err)
}
// Create new OTP session
otpSession := &entities.OtpSession{
Token: token,
Code: otpCode,
PhoneNumber: phoneNumber,
Purpose: purpose,
ExpiresAt: time.Now().Add(5 * time.Minute),
IsUsed: false,
AttemptsCount: 0,
MaxAttempts: 3,
}
if err := p.otpRepo.CreateOtpSession(ctx, otpSession); err != nil {
return nil, fmt.Errorf("failed to create OTP session: %w", err)
}
return otpSession, nil
}
func (p *otpProcessor) ResendOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) {
// Check if resend is allowed
canResend, secondsUntilNext, err := p.CanResendOtp(ctx, phoneNumber, purpose)
if err != nil {
return nil, fmt.Errorf("failed to check resend eligibility: %w", err)
}
if !canResend {
return nil, fmt.Errorf("resend not allowed yet, try again in %d seconds", secondsUntilNext)
}
// Create new OTP session (this will invalidate existing ones)
otpSession, err := p.CreateOtpSession(ctx, phoneNumber, purpose)
if err != nil {
return nil, fmt.Errorf("failed to create resend OTP session: %w", err)
}
// Send OTP via WhatsApp
if err := p.SendOtpViaWhatsApp(phoneNumber, otpSession.Code, purpose); err != nil {
return nil, fmt.Errorf("failed to send resend OTP: %w", err)
}
return otpSession, nil
}
func (p *otpProcessor) CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error) {
// Get the last OTP session for this phone number and purpose
lastOtpSession, err := p.otpRepo.GetLastOtpSessionByPhoneAndPurpose(ctx, phoneNumber, purpose)
if err != nil {
return false, 0, fmt.Errorf("failed to get last OTP session: %w", err)
}
// If no previous OTP session exists, resend is allowed immediately
if lastOtpSession == nil {
return true, 0, nil
}
// Calculate time since last OTP was created
timeSinceLastOtp := time.Since(lastOtpSession.CreatedAt)
// Minimum time between OTP sends (60 seconds)
minResendInterval := 60 * time.Second
if timeSinceLastOtp < minResendInterval {
secondsUntilNext := int((minResendInterval - timeSinceLastOtp).Seconds())
return false, secondsUntilNext, nil
}
return true, 0, nil
}
func (p *otpProcessor) SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error {
// Format phone number (remove any non-digit characters and ensure it starts with country code)
formattedPhone := p.formatPhoneNumber(phoneNumber)
// Create message based on purpose
var message string
switch purpose {
case "login":
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":
message = fmt.Sprintf("Kode OTP untuk registrasi kamu adalah %s. Berlaku 5 menit.", otpCode)
default:
message = fmt.Sprintf("Kode OTP kamu adalah %s. Berlaku 5 menit.", otpCode)
}
// Send message via Fonnte
if err := p.fonnteClient.SendWhatsAppMessage(formattedPhone, message); err != nil {
fmt.Printf("Failed to send OTP via WhatsApp to %s: %v\n", formattedPhone, err)
return fmt.Errorf("failed to send OTP via WhatsApp: %w", err)
}
fmt.Printf("OTP sent successfully to %s for purpose: %s\n", formattedPhone, purpose)
return nil
}
func (p *otpProcessor) ValidateOtpCode(code string) bool {
// Basic validation: should be 6 digits
if len(code) != 6 {
return false
}
// Check if all characters are digits
for _, char := range code {
if char < '0' || char > '9' {
return false
}
}
return true
}
func (p *otpProcessor) ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error) {
// Get OTP session by token
otpSession, err := p.otpRepo.GetOtpSessionByToken(ctx, token)
if err != nil {
return nil, fmt.Errorf("failed to get OTP session: %w", err)
}
if otpSession == nil {
return nil, fmt.Errorf("invalid OTP token")
}
// Check if OTP can be used
if !otpSession.CanBeUsed() {
// Update attempts count if max attempts not reached
if !otpSession.IsMaxAttemptsReached() {
otpSession.IncrementAttempts()
p.otpRepo.UpdateOtpSession(ctx, otpSession)
}
return nil, fmt.Errorf("OTP session expired, used, or max attempts reached")
}
// Validate OTP code
if otpSession.Code != code {
otpSession.IncrementAttempts()
if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil {
return nil, fmt.Errorf("failed to update OTP session attempts: %w", err)
}
return nil, fmt.Errorf("invalid OTP code")
}
// Mark as used
otpSession.MarkAsUsed()
if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil {
return nil, fmt.Errorf("failed to mark OTP as used: %w", err)
}
return otpSession, nil
}
func (p *otpProcessor) InvalidateOtpSession(ctx context.Context, token string) error {
return p.otpRepo.DeleteOtpSession(ctx, token)
}
func (p *otpProcessor) CleanupExpiredOtps(ctx context.Context) error {
return p.otpRepo.DeleteExpiredOtpSessions(ctx)
}
func (p *otpProcessor) formatPhoneNumber(phoneNumber string) string {
// Remove all non-digit characters
digits := ""
for _, char := range phoneNumber {
if char >= '0' && char <= '9' {
digits += string(char)
}
}
// If it doesn't start with country code, assume it's Indonesian (+62)
if len(digits) == 0 {
return phoneNumber // Return original if empty
}
// If starts with 0, replace with 62
if len(digits) > 0 && digits[0] == '0' {
digits = "62" + digits[1:]
} else if len(digits) > 0 && digits[:2] != "62" {
// If doesn't start with 62, add it
digits = "62" + 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
}