diff --git a/go.mod b/go.mod index fa0aba6..f58d285 100644 --- a/go.mod +++ b/go.mod @@ -76,6 +76,7 @@ require ( github.com/subosito/gotenv v1.4.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // 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 go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect @@ -107,6 +108,7 @@ require ( require ( 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/boombuler/barcode v1.1.0 github.com/golang-jwt/jwt/v5 v5.2.3 diff --git a/go.sum b/go.sum index 3f6c466..b178a33 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 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.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 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/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= diff --git a/internal/app/app.go b/internal/app/app.go index a002680..b5b5e9d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -388,6 +388,7 @@ type processors struct { loyaltySettingsProcessor *processor.LoyaltySettingsProcessor earningProcessor *processor.EarningProcessor customerPinProcessor *processor.CustomerPinProcessor + paymentCodeProcessor *processor.PaymentCodeProcessor } 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) // Customer PIN (docs/prd-point-coin.md F11) customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, otpProcessor) + paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor) 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) @@ -453,6 +455,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor loyaltySettingsProcessor: loyaltySettingsProcessor, earningProcessor: earningProcessor, customerPinProcessor: customerPinProcessor, + paymentCodeProcessor: paymentCodeProcessor, 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), walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor), loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo), - customerPinService: service.NewCustomerPinService(processors.customerPinProcessor), + customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor), } } diff --git a/internal/contract/customer_pin_contract.go b/internal/contract/customer_pin_contract.go index 9b089eb..8140bd3 100644 --- a/internal/contract/customer_pin_contract.go +++ b/internal/contract/customer_pin_contract.go @@ -26,3 +26,8 @@ type ResetCustomerPinRequest = CreateCustomerPinRequest type RemoveCustomerPinRequest struct { Reason string `json:"reason" binding:"required"` } + +// IssuePaymentCodeRequest is POST /customer/wallet/payment-code. +type IssuePaymentCodeRequest struct { + Pin string `json:"pin" binding:"required"` +} diff --git a/internal/handler/customer_pin_handler.go b/internal/handler/customer_pin_handler.go index 65279f2..c0f9185 100644 --- a/internal/handler/customer_pin_handler.go +++ b/internal/handler/customer_pin_handler.go @@ -136,3 +136,16 @@ func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) { func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo { 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") +} diff --git a/internal/models/customer_pin.go b/internal/models/customer_pin.go index fe5477b..6b3616c 100644 --- a/internal/models/customer_pin.go +++ b/internal/models/customer_pin.go @@ -37,3 +37,11 @@ type CustomerPinRequestInfo struct { IPAddress 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"` +} diff --git a/internal/processor/payment_code_processor.go b/internal/processor/payment_code_processor.go new file mode 100644 index 0000000..7c7038c --- /dev/null +++ b/internal/processor/payment_code_processor.go @@ -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 +} diff --git a/internal/processor/payment_code_processor_test.go b/internal/processor/payment_code_processor_test.go new file mode 100644 index 0000000..bb134d7 --- /dev/null +++ b/internal/processor/payment_code_processor_test.go @@ -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") +} diff --git a/internal/repository/payment_code_repository.go b/internal/repository/payment_code_repository.go new file mode 100644 index 0000000..f76fc5b --- /dev/null +++ b/internal/repository/payment_code_repository.go @@ -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 + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 7070399..3c8e79b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -164,6 +164,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) 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) customer.GET("/pin/status", r.customerPinHandler.Status) customer.POST("/pin/otp", r.customerPinHandler.RequestOtp) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 0049f32..31dfbde 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -35,6 +35,7 @@ func TestAllRoutesRegister(t *testing.T) { "GET /api/v1/marketing/loyalty-settings", "PUT /api/v1/marketing/loyalty-settings", "GET /api/v1/marketing/loyalty-settings/history", + "POST /api/v1/customer/wallet/payment-code", "GET /api/v1/customer/pin/status", "POST /api/v1/customer/pin/otp", "POST /api/v1/customer/pin", diff --git a/internal/service/customer_pin_service.go b/internal/service/customer_pin_service.go index 02c9020..9fdc5b4 100644 --- a/internal/service/customer_pin_service.go +++ b/internal/service/customer_pin_service.go @@ -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 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 { - pins *processor.CustomerPinProcessor + pins *processor.CustomerPinProcessor + codes *processor.PaymentCodeProcessor } -func NewCustomerPinService(pins *processor.CustomerPinProcessor) *CustomerPinServiceImpl { - return &CustomerPinServiceImpl{pins: pins} +func NewCustomerPinService(pins *processor.CustomerPinProcessor, codes *processor.PaymentCodeProcessor) *CustomerPinServiceImpl { + return &CustomerPinServiceImpl{pins: pins, codes: codes} } 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()), }) } + +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) +}