diff --git a/internal/app/app.go b/internal/app/app.go index 1ac98bb..91862c4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -408,7 +408,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor fonnteClient := client.NewFonnteClient(cfg.GetFonnte()) otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo) // Customer PIN (docs/prd-point-coin.md F11) - customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, otpProcessor) + customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, customerDeviceProcessor) paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor) inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo) diff --git a/internal/processor/customer_pin_processor.go b/internal/processor/customer_pin_processor.go index 447e9da..0de4f19 100644 --- a/internal/processor/customer_pin_processor.go +++ b/internal/processor/customer_pin_processor.go @@ -100,24 +100,22 @@ type pinOtpSender interface { 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 -} +// NotificationTypePinLocked is the data type of the push a customer gets when their +// PIN locks, so the app can offer the PIN reset. +const NotificationTypePinLocked = "PIN_LOCKED" // 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 + repo repository.CustomerPinRepository + otp pinOtpSender + notifier customerNotifier + 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 NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, notifier customerNotifier) *CustomerPinProcessor { + return &CustomerPinProcessor{repo: repo, otp: otp, notifier: notifier, now: time.Now, cost: bcrypt.DefaultCost} } func (p *CustomerPinProcessor) Status(ctx context.Context, customerID uuid.UUID) (*models.CustomerPinStatus, error) { @@ -295,7 +293,7 @@ func (p *CustomerPinProcessor) verify(ctx context.Context, state *repository.Cus // customer; attempts racing it just see the lock. if attempts == pinMaxAttempts { p.logEvent(ctx, state.CustomerID, PinEventLocked, nil, nil, info) - p.alertLocked(state, *lockedUntil) + p.alertLocked(ctx, state, *lockedUntil) } return &PinError{Code: PinErrLocked, Until: lockedUntil} } @@ -420,13 +418,19 @@ func (p *CustomerPinProcessor) logEvent(ctx context.Context, customerID uuid.UUI } } -func (p *CustomerPinProcessor) alertLocked(state *repository.CustomerPinState, until time.Time) { - if p.alerter == nil || state.PhoneNumber == nil { +// alertLocked pushes the lock to the customer's app through FCM (F11). It is best +// effort: the lock stands whether or not the push goes out. +func (p *CustomerPinProcessor) alertLocked(ctx context.Context, state *repository.CustomerPinState, until time.Time) { + if p.notifier == 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.", + body := 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 { + data := map[string]string{ + "type": NotificationTypePinLocked, + "locked_until": until.UTC().Format(time.RFC3339), + } + if err := p.notifier.Notify(ctx, state.CustomerID, "PIN terkunci", body, data); err != nil { logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s their PIN is locked", state.CustomerID), err) } } diff --git a/internal/processor/customer_pin_processor_db_test.go b/internal/processor/customer_pin_processor_db_test.go index 7167972..ca0e8d0 100644 --- a/internal/processor/customer_pin_processor_db_test.go +++ b/internal/processor/customer_pin_processor_db_test.go @@ -68,10 +68,10 @@ type alerterFake struct { messages []string } -func (f *alerterFake) SendWhatsAppMessage(_ string, message string) error { +func (f *alerterFake) Notify(_ context.Context, _ uuid.UUID, _, body string, _ map[string]string) error { f.mu.Lock() defer f.mu.Unlock() - f.messages = append(f.messages, message) + f.messages = append(f.messages, body) return nil } diff --git a/internal/processor/customer_pin_processor_test.go b/internal/processor/customer_pin_processor_test.go index b2d5ac7..f99a593 100644 --- a/internal/processor/customer_pin_processor_test.go +++ b/internal/processor/customer_pin_processor_test.go @@ -1,10 +1,17 @@ package processor import ( + "context" "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" ) func TestCheckNewPin(t *testing.T) { @@ -14,18 +21,18 @@ func TestCheckNewPin(t *testing.T) { 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"}, + "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) @@ -34,3 +41,67 @@ func TestCheckNewPin(t *testing.T) { // Without a birth date only the other rules apply. assert.NoError(t, checkNewPin("140390", "140390", nil)) } + +// pinRepoFake holds one customer's PIN state, with the lock rules of RecordFailure. +type pinRepoFake struct { + repository.CustomerPinRepository + state repository.CustomerPinState +} + +func (f *pinRepoFake) GetState(context.Context, uuid.UUID) (*repository.CustomerPinState, error) { + s := f.state + return &s, nil +} + +func (f *pinRepoFake) RecordFailure(_ context.Context, _ uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) { + if f.state.LockedUntil != nil && !f.state.LockedUntil.After(now) { + f.state.FailedAttempts, f.state.LockedUntil = 1, nil + } else { + f.state.FailedAttempts++ + if f.state.FailedAttempts >= maxAttempts { + f.state.LockedUntil = &lockUntil + } + } + return f.state.FailedAttempts, f.state.LockedUntil, nil +} + +func (f *pinRepoFake) ClearFailures(context.Context, uuid.UUID) error { + f.state.FailedAttempts, f.state.LockedUntil = 0, nil + return nil +} + +func (f *pinRepoFake) InsertEvent(context.Context, repository.CustomerSecurityEvent) error { + return nil +} + +func TestCustomerPin_LockIsPushedThroughFCM(t *testing.T) { + customer := uuid.New() + hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost) + require.NoError(t, err) + h := string(hash) + repo := &pinRepoFake{state: repository.CustomerPinState{CustomerID: customer, PinHash: &h}} + notifier := ¬ifierFake{} + p := NewCustomerPinProcessor(repo, nil, notifier) + now := time.Date(2026, 9, 30, 3, 0, 0, 0, time.UTC) + p.now = func() time.Time { return now } + ctx := context.Background() + + for i := 0; i < 4; i++ { + _ = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{}) + } + assert.Empty(t, notifier.pushes[customer], "no push before the PIN locks") + + err = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{}) + var pinErr *PinError + require.ErrorAs(t, err, &pinErr) + assert.Equal(t, PinErrLocked, pinErr.Code) + + // Attempts while locked do not push again. + _ = p.VerifyPin(ctx, customer, "482913", PinActionPay, models.CustomerPinRequestInfo{}) + + require.Len(t, notifier.pushes[customer], 1) + push := notifier.pushes[customer][0] + assert.Equal(t, "PIN terkunci", push.title) + assert.Equal(t, "PIN EnakPoint kamu terkunci sampai 30 Sep 2026 10:30 WIB karena salah dimasukkan 5 kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.", push.body) + assert.Equal(t, map[string]string{"type": NotificationTypePinLocked, "locked_until": "2026-09-30T03:30:00Z"}, push.data) +} diff --git a/internal/processor/otp_processor.go b/internal/processor/otp_processor.go index b53d297..28013aa 100644 --- a/internal/processor/otp_processor.go +++ b/internal/processor/otp_processor.go @@ -18,8 +18,6 @@ type OtpProcessor interface { 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 @@ -242,10 +240,3 @@ func (p *otpProcessor) formatPhoneNumber(phoneNumber string) string { 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 -}