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:
co-authored by
Claude Opus 5.5
parent
cf5332c281
commit
0c4dd72583
@@ -76,6 +76,7 @@ require (
|
|||||||
github.com/subosito/gotenv v1.4.2 // indirect
|
github.com/subosito/gotenv v1.4.2 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||||
github.com/zeebo/errs v1.4.0 // indirect
|
github.com/zeebo/errs v1.4.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
|
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
|
||||||
@@ -107,6 +108,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
firebase.google.com/go/v4 v4.19.0
|
firebase.google.com/go/v4 v4.19.0
|
||||||
|
github.com/alicebob/miniredis/v2 v2.39.0
|
||||||
github.com/aws/aws-sdk-go v1.55.7
|
github.com/aws/aws-sdk-go v1.55.7
|
||||||
github.com/boombuler/barcode v1.1.0
|
github.com/boombuler/barcode v1.1.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.3
|
github.com/golang-jwt/jwt/v5 v5.2.3
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
|
|||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
|
||||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
|
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
|
||||||
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||||
@@ -349,6 +351,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
|||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
|||||||
+4
-1
@@ -388,6 +388,7 @@ type processors struct {
|
|||||||
loyaltySettingsProcessor *processor.LoyaltySettingsProcessor
|
loyaltySettingsProcessor *processor.LoyaltySettingsProcessor
|
||||||
earningProcessor *processor.EarningProcessor
|
earningProcessor *processor.EarningProcessor
|
||||||
customerPinProcessor *processor.CustomerPinProcessor
|
customerPinProcessor *processor.CustomerPinProcessor
|
||||||
|
paymentCodeProcessor *processor.PaymentCodeProcessor
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||||
@@ -396,6 +397,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
|
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
|
||||||
// Customer PIN (docs/prd-point-coin.md F11)
|
// 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, otpProcessor)
|
||||||
|
paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor)
|
||||||
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)
|
||||||
@@ -453,6 +455,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
loyaltySettingsProcessor: loyaltySettingsProcessor,
|
loyaltySettingsProcessor: loyaltySettingsProcessor,
|
||||||
earningProcessor: earningProcessor,
|
earningProcessor: earningProcessor,
|
||||||
customerPinProcessor: customerPinProcessor,
|
customerPinProcessor: customerPinProcessor,
|
||||||
|
paymentCodeProcessor: paymentCodeProcessor,
|
||||||
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -582,7 +585,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, repos.walletQueryRepo),
|
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
|
||||||
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor),
|
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,3 +26,8 @@ type ResetCustomerPinRequest = CreateCustomerPinRequest
|
|||||||
type RemoveCustomerPinRequest struct {
|
type RemoveCustomerPinRequest struct {
|
||||||
Reason string `json:"reason" binding:"required"`
|
Reason string `json:"reason" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IssuePaymentCodeRequest is POST /customer/wallet/payment-code.
|
||||||
|
type IssuePaymentCodeRequest struct {
|
||||||
|
Pin string `json:"pin" binding:"required"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,3 +136,16 @@ func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) {
|
|||||||
func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo {
|
func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo {
|
||||||
return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()}
|
return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IssuePaymentCode is POST /customer/wallet/payment-code.
|
||||||
|
func (h *CustomerPinHandler) IssuePaymentCode(c *gin.Context) {
|
||||||
|
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::IssuePaymentCode")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req contract.IssuePaymentCodeRequest
|
||||||
|
if !bindPinRequest(c, &req, "CustomerPinHandler::IssuePaymentCode") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
util.HandleResponse(c.Writer, c.Request, h.pinService.IssuePaymentCode(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::IssuePaymentCode")
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,3 +37,11 @@ type CustomerPinRequestInfo struct {
|
|||||||
IPAddress string
|
IPAddress string
|
||||||
UserAgent string
|
UserAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentCode is what POST /customer/wallet/payment-code returns: a one-time code the
|
||||||
|
// customer shows the cashier, as digits or as a QR of QRPayload.
|
||||||
|
type PaymentCode struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
QRPayload string `json:"qr_payload"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrPaymentCodeTaken means the code is already live for someone; draw another.
|
||||||
|
ErrPaymentCodeTaken = errors.New("payment code already in use")
|
||||||
|
// ErrPaymentCodeNotFound means the code does not exist: never issued, expired, or
|
||||||
|
// already used.
|
||||||
|
ErrPaymentCodeNotFound = errors.New("payment code not found")
|
||||||
|
// ErrPaymentCodeWrongCustomer means the code belongs to another customer.
|
||||||
|
ErrPaymentCodeWrongCustomer = errors.New("payment code belongs to another customer")
|
||||||
|
)
|
||||||
|
|
||||||
|
// PaymentCodeRepository keeps one-time EnakPoint payment codes in Redis
|
||||||
|
// (docs/prd-point-coin.md F9). A code expires by TTL and is removed when used.
|
||||||
|
type PaymentCodeRepository interface {
|
||||||
|
// Save stores a code for a customer for ttl, and retires the customer's previous
|
||||||
|
// code so only the newest one works. ErrPaymentCodeTaken if the code is live.
|
||||||
|
Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error
|
||||||
|
// Consume uses a code up if it belongs to the customer. A code of another customer
|
||||||
|
// is left in place, so a cashier scanning it against the wrong order does not burn
|
||||||
|
// it for its owner.
|
||||||
|
Consume(ctx context.Context, code string, customerID uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type paymentCodeRepository struct {
|
||||||
|
client *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPaymentCodeRepository(client *redis.Client) PaymentCodeRepository {
|
||||||
|
return &paymentCodeRepository{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paymentCodeKey(code string) string { return "wallet:paycode:" + code }
|
||||||
|
|
||||||
|
func paymentCodeCustomerKey(customerID uuid.UUID) string {
|
||||||
|
return "wallet:paycode:customer:" + customerID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *paymentCodeRepository) Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error {
|
||||||
|
ok, err := r.client.SetNX(ctx, paymentCodeKey(code), customerID.String(), ttl).Result()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to store payment code: %w", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return ErrPaymentCodeTaken
|
||||||
|
}
|
||||||
|
previous, err := r.client.GetSet(ctx, paymentCodeCustomerKey(customerID), code).Result()
|
||||||
|
if err != nil && !errors.Is(err, redis.Nil) {
|
||||||
|
return fmt.Errorf("failed to track payment code: %w", err)
|
||||||
|
}
|
||||||
|
r.client.Expire(ctx, paymentCodeCustomerKey(customerID), ttl)
|
||||||
|
if previous != "" && previous != code {
|
||||||
|
// Only if it is still that customer's: the number may have been reissued.
|
||||||
|
if err := r.compareAndDelete(ctx, previous, customerID); err != nil && !errors.Is(err, ErrPaymentCodeNotFound) && !errors.Is(err, ErrPaymentCodeWrongCustomer) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeScript deletes a code only if it belongs to the given customer, in one step.
|
||||||
|
// Returns 1 when used up, 0 when missing, -1 when it belongs to someone else.
|
||||||
|
var consumeScript = redis.NewScript(`
|
||||||
|
local owner = redis.call('GET', KEYS[1])
|
||||||
|
if not owner then return 0 end
|
||||||
|
if owner ~= ARGV[1] then return -1 end
|
||||||
|
redis.call('DEL', KEYS[1])
|
||||||
|
return 1
|
||||||
|
`)
|
||||||
|
|
||||||
|
func (r *paymentCodeRepository) Consume(ctx context.Context, code string, customerID uuid.UUID) error {
|
||||||
|
code = strings.TrimSpace(code)
|
||||||
|
if code == "" {
|
||||||
|
return ErrPaymentCodeNotFound
|
||||||
|
}
|
||||||
|
return r.compareAndDelete(ctx, code, customerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *paymentCodeRepository) compareAndDelete(ctx context.Context, code string, customerID uuid.UUID) error {
|
||||||
|
result, err := consumeScript.Run(ctx, r.client, []string{paymentCodeKey(code)}, customerID.String()).Int()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to use payment code: %w", err)
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case 1:
|
||||||
|
return nil
|
||||||
|
case -1:
|
||||||
|
return ErrPaymentCodeWrongCustomer
|
||||||
|
default:
|
||||||
|
return ErrPaymentCodeNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,7 @@ 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)
|
||||||
|
customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode)
|
||||||
// PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11)
|
// PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11)
|
||||||
customer.GET("/pin/status", r.customerPinHandler.Status)
|
customer.GET("/pin/status", r.customerPinHandler.Status)
|
||||||
customer.POST("/pin/otp", r.customerPinHandler.RequestOtp)
|
customer.POST("/pin/otp", r.customerPinHandler.RequestOtp)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func TestAllRoutesRegister(t *testing.T) {
|
|||||||
"GET /api/v1/marketing/loyalty-settings",
|
"GET /api/v1/marketing/loyalty-settings",
|
||||||
"PUT /api/v1/marketing/loyalty-settings",
|
"PUT /api/v1/marketing/loyalty-settings",
|
||||||
"GET /api/v1/marketing/loyalty-settings/history",
|
"GET /api/v1/marketing/loyalty-settings/history",
|
||||||
|
"POST /api/v1/customer/wallet/payment-code",
|
||||||
"GET /api/v1/customer/pin/status",
|
"GET /api/v1/customer/pin/status",
|
||||||
"POST /api/v1/customer/pin/otp",
|
"POST /api/v1/customer/pin/otp",
|
||||||
"POST /api/v1/customer/pin",
|
"POST /api/v1/customer/pin",
|
||||||
|
|||||||
@@ -25,14 +25,18 @@ type CustomerPinService interface {
|
|||||||
|
|
||||||
RemovePin(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.RemoveCustomerPinRequest, 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
|
ListSecurityEvents(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, page, limit int) *contract.Response
|
||||||
|
|
||||||
|
// IssuePaymentCode checks the PIN and returns a one-time code for the cashier (F9).
|
||||||
|
IssuePaymentCode(ctx context.Context, customerID uuid.UUID, req *contract.IssuePaymentCodeRequest, info models.CustomerPinRequestInfo) *contract.Response
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomerPinServiceImpl struct {
|
type CustomerPinServiceImpl struct {
|
||||||
pins *processor.CustomerPinProcessor
|
pins *processor.CustomerPinProcessor
|
||||||
|
codes *processor.PaymentCodeProcessor
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCustomerPinService(pins *processor.CustomerPinProcessor) *CustomerPinServiceImpl {
|
func NewCustomerPinService(pins *processor.CustomerPinProcessor, codes *processor.PaymentCodeProcessor) *CustomerPinServiceImpl {
|
||||||
return &CustomerPinServiceImpl{pins: pins}
|
return &CustomerPinServiceImpl{pins: pins, codes: codes}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CustomerPinServiceImpl) Status(ctx context.Context, customerID uuid.UUID) *contract.Response {
|
func (s *CustomerPinServiceImpl) Status(ctx context.Context, customerID uuid.UUID) *contract.Response {
|
||||||
@@ -126,3 +130,11 @@ func PinErrorResponse(err error) *contract.Response {
|
|||||||
contract.NewResponseError(code, constants.CustomerPinServiceEntity, err.Error()),
|
contract.NewResponseError(code, constants.CustomerPinServiceEntity, err.Error()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *CustomerPinServiceImpl) IssuePaymentCode(ctx context.Context, customerID uuid.UUID, req *contract.IssuePaymentCodeRequest, info models.CustomerPinRequestInfo) *contract.Response {
|
||||||
|
code, err := s.codes.Issue(ctx, customerID, req.Pin, info)
|
||||||
|
if err != nil {
|
||||||
|
return PinErrorResponse(err)
|
||||||
|
}
|
||||||
|
return contract.BuildSuccessResponse(code)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user