feat(loyalty): one-time EnakPoint payment code

Adds POST /customer/wallet/payment-code (docs/prd-point-coin.md F9, K8,
PC-304). The customer approves with their PIN on their own phone and gets a
6-digit code, as digits and as a QR payload (enakpoint:<code>) for the app
to render, valid for two minutes. The PIN is never typed at the cashier.

Codes are drawn from crypto/rand and stored in Redis with SET NX and a TTL,
bound to the customer; a new code retires the previous one. Redeeming is a
single Lua step that uses the code up only if it belongs to the order's
customer, so it stays one-time under a race, and a cashier scanning it
against the wrong order does not burn it for its owner, which a plain
GETDEL would. Expired, used, unknown and other customers' codes are all
refused alike.

Tests run against miniredis, added as a test dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 11:37:20 +07:00
co-authored by Claude Opus 5.5
parent cf5332c281
commit 0c4dd72583
12 changed files with 393 additions and 4 deletions
@@ -0,0 +1,101 @@
package processor
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
const (
paymentCodeDigits = 6
paymentCodeTTL = 2 * time.Minute
paymentCodeAttempts = 5
// PaymentCodeQRPrefix marks a scanned QR as an EnakPoint payment code.
PaymentCodeQRPrefix = "enakpoint:"
)
// ErrPaymentCodeInvalid means the code was never issued, has expired, has been used,
// or belongs to another customer.
var ErrPaymentCodeInvalid = errors.New("payment code is invalid or expired")
type pinVerifier interface {
VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error
}
// PaymentCodeProcessor issues and redeems the one-time codes that let a cashier take a
// customer's EnakPoint (docs/prd-point-coin.md F9, K8). The customer approves with
// their PIN on their own phone and shows the code; the PIN is never typed on the
// cashier's device.
type PaymentCodeProcessor struct {
codes repository.PaymentCodeRepository
pins pinVerifier
now func() time.Time
}
func NewPaymentCodeProcessor(codes repository.PaymentCodeRepository, pins pinVerifier) *PaymentCodeProcessor {
return &PaymentCodeProcessor{codes: codes, pins: pins, now: time.Now}
}
// Issue checks the customer's PIN and returns a fresh 6-digit code, valid for two
// minutes and bound to the customer. A new code retires the previous one.
func (p *PaymentCodeProcessor) Issue(ctx context.Context, customerID uuid.UUID, pin string, info models.CustomerPinRequestInfo) (*models.PaymentCode, error) {
if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info); err != nil {
return nil, err
}
for attempt := 0; attempt < paymentCodeAttempts; attempt++ {
code, err := randomDigits(paymentCodeDigits)
if err != nil {
return nil, err
}
err = p.codes.Save(ctx, code, customerID, paymentCodeTTL)
if errors.Is(err, repository.ErrPaymentCodeTaken) {
continue
}
if err != nil {
return nil, err
}
return &models.PaymentCode{
Code: code,
QRPayload: PaymentCodeQRPrefix + code,
ExpiresAt: p.now().Add(paymentCodeTTL),
}, nil
}
return nil, fmt.Errorf("could not draw a free payment code after %d attempts", paymentCodeAttempts)
}
// Redeem uses a code up for a payment by the given customer. It accepts the code as
// typed or as scanned from the QR. Every failure is ErrPaymentCodeInvalid.
func (p *PaymentCodeProcessor) Redeem(ctx context.Context, code string, customerID uuid.UUID) error {
code = strings.TrimPrefix(strings.TrimSpace(code), PaymentCodeQRPrefix)
if len(code) != paymentCodeDigits {
return ErrPaymentCodeInvalid
}
err := p.codes.Consume(ctx, code, customerID)
if errors.Is(err, repository.ErrPaymentCodeNotFound) || errors.Is(err, repository.ErrPaymentCodeWrongCustomer) {
return ErrPaymentCodeInvalid
}
return err
}
// randomDigits draws n decimal digits from a cryptographic source, so codes cannot be
// predicted.
func randomDigits(n int) (string, error) {
var b strings.Builder
for i := 0; i < n; i++ {
d, err := rand.Int(rand.Reader, big.NewInt(10))
if err != nil {
return "", fmt.Errorf("failed to draw a payment code: %w", err)
}
b.WriteByte(byte('0' + d.Int64()))
}
return b.String(), nil
}
@@ -0,0 +1,136 @@
package processor
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
type pinVerifierFake struct{ good string }
func (f pinVerifierFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error {
if action != PinActionPay {
return &PinError{Code: "UNEXPECTED_ACTION"}
}
if pin != f.good {
return &PinError{Code: PinErrInvalid, RemainingAttempts: 4}
}
return nil
}
func newPaymentCodeTest(t *testing.T) (*PaymentCodeProcessor, *miniredis.Miniredis) {
t.Helper()
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { client.Close() })
return NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}), mr
}
func TestPaymentCode_IssueNeedsThePin(t *testing.T) {
p, mr := newPaymentCodeTest(t)
_, err := p.Issue(context.Background(), uuid.New(), "000000", models.CustomerPinRequestInfo{})
var pe *PinError
require.ErrorAs(t, err, &pe)
assert.Equal(t, PinErrInvalid, pe.Code)
assert.Empty(t, mr.Keys(), "nothing is issued without the PIN")
}
func TestPaymentCode_Lifecycle(t *testing.T) {
p, mr := newPaymentCodeTest(t)
ctx := context.Background()
customer, other := uuid.New(), uuid.New()
code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Len(t, code.Code, 6)
assert.Equal(t, "enakpoint:"+code.Code, code.QRPayload)
assert.WithinDuration(t, time.Now().Add(2*time.Minute), code.ExpiresAt, 2*time.Second)
assert.InDelta(t, 120, mr.TTL("wallet:paycode:"+code.Code).Seconds(), 1, "Redis expires it by itself")
// A code of another customer is refused, and stays usable by its owner.
assert.ErrorIs(t, p.Redeem(ctx, code.Code, other), ErrPaymentCodeInvalid)
// Scanned from the QR it works; used once, it is gone.
require.NoError(t, p.Redeem(ctx, code.QRPayload, customer))
assert.ErrorIs(t, p.Redeem(ctx, code.Code, customer), ErrPaymentCodeInvalid)
// An expired code is refused.
late, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
mr.FastForward(2*time.Minute + time.Second)
assert.ErrorIs(t, p.Redeem(ctx, late.Code, customer), ErrPaymentCodeInvalid)
// A new code retires the previous one.
first, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
second, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
if first.Code != second.Code {
assert.ErrorIs(t, p.Redeem(ctx, first.Code, customer), ErrPaymentCodeInvalid)
}
require.NoError(t, p.Redeem(ctx, second.Code, customer))
// Garbage is refused without touching Redis.
for _, bad := range []string{"", "12345", "1234567", "enakpoint:"} {
assert.ErrorIs(t, p.Redeem(ctx, bad, customer), ErrPaymentCodeInvalid, bad)
}
}
// Two cashiers scanning the same code at once: exactly one gets it.
func TestPaymentCode_UsedOnceUnderRace(t *testing.T) {
p, _ := newPaymentCodeTest(t)
ctx := context.Background()
customer := uuid.New()
code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
var wins int32
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if p.Redeem(ctx, code.Code, customer) == nil {
atomic.AddInt32(&wins, 1)
}
}()
}
wg.Wait()
assert.Equal(t, int32(1), wins)
}
func TestPaymentCode_SaveRefusesALiveCode(t *testing.T) {
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
defer client.Close()
repo := repository.NewPaymentCodeRepository(client)
ctx := context.Background()
require.NoError(t, repo.Save(ctx, "123456", uuid.New(), time.Minute))
assert.ErrorIs(t, repo.Save(ctx, "123456", uuid.New(), time.Minute), repository.ErrPaymentCodeTaken,
"a live code is never handed to a second customer")
}
func TestRandomDigits(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 200; i++ {
d, err := randomDigits(6)
require.NoError(t, err)
require.Len(t, d, 6)
for _, r := range d {
require.True(t, r >= '0' && r <= '9')
}
seen[d] = true
}
assert.Greater(t, len(seen), 190, "codes do not repeat")
}