2025-09-18 01:32:01 +07:00
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
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 )
2026-09-30 15:31:11 +07:00
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 )
2025-09-18 01:32:01 +07:00
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
}