From 478d260ebe36e34659a19ecb7baab65592ec5142 Mon Sep 17 00:00:00 2001 From: yurii Date: Tue, 21 Jul 2026 14:46:54 +0100 Subject: [PATCH] Added payment terminal status streaming --- cmd/hardlink/main.go | 2 +- internal/dojo/client.go | 275 ++++++++++++++++++++-- internal/handlers/payment_handlers.go | 159 +++++++++++-- internal/paybridge/client.go | 316 ++++++++++++++++++++++++-- internal/paybridge/statuses.go | 41 ++++ internal/paybridge/statuses_test.go | 125 ++++++++++ internal/paybridge/types.go | 10 + internal/paymentsvc/service.go | 35 ++- paymentstatus/status.go | 40 ++++ paymentstatus/status_test.go | 83 +++++++ release notes.md | 3 + 11 files changed, 1025 insertions(+), 64 deletions(-) create mode 100644 internal/paybridge/statuses.go create mode 100644 internal/paybridge/statuses_test.go create mode 100644 paymentstatus/status.go create mode 100644 paymentstatus/status_test.go diff --git a/cmd/hardlink/main.go b/cmd/hardlink/main.go index 3c93307..4407fc9 100644 --- a/cmd/hardlink/main.go +++ b/cmd/hardlink/main.go @@ -34,7 +34,7 @@ import ( ) const ( - buildVersion = "1.3.2" + buildVersion = "1.3.3" serviceName = "hardlink" pollingFrequency = 8 * time.Second ) diff --git a/internal/dojo/client.go b/internal/dojo/client.go index 18b03a2..9a7f161 100644 --- a/internal/dojo/client.go +++ b/internal/dojo/client.go @@ -8,11 +8,13 @@ import ( "io" "net/http" "net/url" + "sort" "strings" "time" "gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc" "gitea.futuresens.co.uk/futuresens/hardlink/internal/types" + "gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus" log "github.com/sirupsen/logrus" ) @@ -33,6 +35,29 @@ type Client struct { httpClient *http.Client } +type terminalSessionWithUpdates struct { + terminalSessionResponse + + NotificationEvents []terminalNotificationEvent `json:"notificationEvents"` + StatusEvents []terminalStatusEvent `json:"statusEvents"` +} + +type terminalNotificationEvent struct { + CreatedAt time.Time `json:"createdAt"` + NotificationType string `json:"notificationType"` +} + +type terminalStatusEvent struct { + CreatedAt time.Time `json:"createdAt"` + Status string `json:"status"` +} + +type terminalDisplayEvent struct { + createdAt time.Time + code string + sessionStatus string +} + func NewClient(cfg Config) (*Client, error) { if cfg.BaseURL == "" { return nil, fmt.Errorf("dojo base_url is required") @@ -60,11 +85,17 @@ func NewClient(cfg Config) (*Client, error) { }, nil } -func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*paymentsvc.Result, error) { +func (c *Client) Sale( + ctx context.Context, + req paymentsvc.SaleRequest, + onStatus paymentsvc.StatusHandler, +) (*paymentsvc.Result, error) { if req.Currency == "" { req.Currency = "GBP" } + sendPaymentStatus(onStatus, paymentstatus.Starting) + intent, err := c.createPaymentIntent(ctx, req) if err != nil { return nil, err @@ -75,7 +106,7 @@ func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*payment return nil, err } - session, err = c.waitForTerminalSession(ctx, session.ID) + session, err = c.waitForTerminalSession(ctx, session.ID, onStatus) if err != nil { return nil, err } @@ -150,10 +181,17 @@ func (c *Client) createTerminalSession(ctx context.Context, paymentIntentID stri return &response, nil } -func (c *Client) waitForTerminalSession(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) { +func (c *Client) waitForTerminalSession( + ctx context.Context, + terminalSessionID string, + onStatus paymentsvc.StatusHandler, +) (*terminalSessionResponse, error) { ticker := time.NewTicker(time.Second) defer ticker.Stop() + notificationCount := 0 + statusCount := 0 + lastSessionStatus := "" signatureRejected := false for { @@ -164,12 +202,40 @@ func (c *Client) waitForTerminalSession(ctx context.Context, terminalSessionID s log.Println("session status:", session.Status) - switch strings.ToLower(session.Status) { - case types.ResultInitiateRequested, types.ResultInitiated, types.ResultAuthorized, types.ResultCancelRequested: + emitNewTerminalSessionEvents( + session, + ¬ificationCount, + &statusCount, + &lastSessionStatus, + onStatus, + ) + + currentStatus := strings.ToLower(session.Status) + if currentStatus != lastSessionStatus { + lastSessionStatus = currentStatus + sendPaymentStatus( + onStatus, + mapDojoSessionStatus(session.Status), + ) + } + + switch currentStatus { + case types.ResultInitiateRequested, + types.ResultInitiated, + types.ResultAuthorized, + types.ResultCancelRequested: case types.ResultSignatureRequired: if !signatureRejected { - rejectedSession, err := c.rejectSignature(ctx, terminalSessionID) + sendPaymentStatus( + onStatus, + paymentstatus.SignatureRejecting, + ) + + rejectedSession, err := c.rejectSignature( + ctx, + terminalSessionID, + ) if err != nil { return nil, err } @@ -177,30 +243,206 @@ func (c *Client) waitForTerminalSession(ctx context.Context, terminalSessionID s signatureRejected = true if rejectedSession != nil { - switch strings.ToLower(rejectedSession.Status) { - case types.ResultCaptured, types.ResultCancelled, types.ResultCanceled, types.ResultDeclined, - types.ResultExpired, types.ResultSignatureAccepted, types.ResultSignatureRejected: + rejectedStatus := strings.ToLower( + rejectedSession.Status, + ) + + if rejectedStatus != lastSessionStatus { + lastSessionStatus = rejectedStatus + sendPaymentStatus( + onStatus, + mapDojoSessionStatus( + rejectedSession.Status, + ), + ) + } + + switch rejectedStatus { + case types.ResultCaptured, + types.ResultCancelled, + types.ResultCanceled, + types.ResultDeclined, + types.ResultExpired, + types.ResultSignatureAccepted, + types.ResultSignatureRejected: return rejectedSession, nil } } } - case types.ResultCaptured, types.ResultCancelled, types.ResultCanceled, types.ResultDeclined, - types.ResultExpired, types.ResultSignatureAccepted, types.ResultSignatureRejected: - return session, nil + case types.ResultCaptured, + types.ResultCancelled, + types.ResultCanceled, + types.ResultDeclined, + types.ResultExpired, + types.ResultSignatureAccepted, + types.ResultSignatureRejected: + return &session.terminalSessionResponse, nil default: - return nil, fmt.Errorf("unexpected Dojo terminal session status %q", session.Status) + return nil, fmt.Errorf( + "unexpected Dojo terminal session status %q", + session.Status, + ) } select { case <-ctx.Done(): return nil, ctx.Err() + case <-ticker.C: } } } +func emitNewTerminalSessionEvents( + session *terminalSessionWithUpdates, + notificationCount *int, + statusCount *int, + lastSessionStatus *string, + onStatus paymentsvc.StatusHandler, +) { + if *notificationCount > len(session.NotificationEvents) { + *notificationCount = len(session.NotificationEvents) + } + if *statusCount > len(session.StatusEvents) { + *statusCount = len(session.StatusEvents) + } + + newEventCount := len(session.NotificationEvents) - *notificationCount + + len(session.StatusEvents) - *statusCount + events := make([]terminalDisplayEvent, 0, newEventCount) + + // Add status events first so a notification wins when timestamps are equal. + for *statusCount < len(session.StatusEvents) { + event := session.StatusEvents[*statusCount] + *statusCount = *statusCount + 1 + + events = append(events, terminalDisplayEvent{ + createdAt: event.CreatedAt, + code: mapDojoSessionStatus(event.Status), + sessionStatus: event.Status, + }) + } + + for *notificationCount < len(session.NotificationEvents) { + event := session.NotificationEvents[*notificationCount] + *notificationCount = *notificationCount + 1 + + events = append(events, terminalDisplayEvent{ + createdAt: event.CreatedAt, + code: mapDojoNotification(event.NotificationType), + }) + } + + sort.SliceStable(events, func(i, j int) bool { + return events[i].createdAt.Before(events[j].createdAt) + }) + + for _, event := range events { + if event.sessionStatus != "" { + *lastSessionStatus = strings.ToLower(event.sessionStatus) + } + + sendPaymentStatus(onStatus, event.code) + } +} + +func sendPaymentStatus( + handler paymentsvc.StatusHandler, + code string, +) { + if handler == nil || code == "" { + return + } + + handler(paymentsvc.StatusUpdate{ + Code: code, + }) +} + +func mapDojoNotification(notification string) string { + switch notification { + case "PresentCard": + return paymentstatus.PresentCard + + case "InsertCard": + return paymentstatus.InsertCard + + case "SwipeCard": + return paymentstatus.SwipeCard + + case "EnterPin": + return paymentstatus.EnterPIN + + case "RemoveCard": + return paymentstatus.RemoveCard + + case "PleaseWait": + return paymentstatus.PleaseWait + + default: + return dojoFallbackStatusCode( + paymentstatus.DojoNotificationPrefix, + notification, + ) + } +} + +func mapDojoSessionStatus(status string) string { + switch strings.ToLower(status) { + case types.ResultInitiateRequested: + return paymentstatus.Starting + + case types.ResultInitiated: + return paymentstatus.Started + + case types.ResultAuthorized: + return paymentstatus.Authorized + + case types.ResultCancelRequested: + return paymentstatus.Cancelling + + case types.ResultSignatureRequired: + return paymentstatus.SignatureRequired + + case types.ResultCaptured, types.ResultSignatureAccepted: + return paymentstatus.Approved + + case types.ResultCancelled, types.ResultCanceled: + return paymentstatus.Cancelled + + case types.ResultDeclined: + return paymentstatus.Declined + + case types.ResultExpired: + return paymentstatus.Expired + + case types.ResultSignatureRejected: + return paymentstatus.SignatureRejected + + default: + return dojoFallbackStatusCode( + paymentstatus.DojoStatusPrefix, + status, + ) + } +} + +func dojoFallbackStatusCode(prefix, value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + replacer := strings.NewReplacer( + " ", "_", + "-", "_", + ) + + return prefix + strings.ToUpper(replacer.Replace(value)) +} + func (c *Client) rejectSignature(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) { payload := signatureVerificationRequest{ Accepted: false, @@ -216,8 +458,11 @@ func (c *Client) rejectSignature(ctx context.Context, terminalSessionID string) return &response, nil } -func (c *Client) getTerminalSession(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) { - var response terminalSessionResponse +func (c *Client) getTerminalSession( + ctx context.Context, + terminalSessionID string, +) (*terminalSessionWithUpdates, error) { + var response terminalSessionWithUpdates path := "/terminal-sessions/" + url.PathEscape(terminalSessionID) if err := c.doJSON(ctx, http.MethodGet, path, nil, true, &response); err != nil { return nil, fmt.Errorf("get Dojo terminal session: %w", err) diff --git a/internal/handlers/payment_handlers.go b/internal/handlers/payment_handlers.go index caa92c2..583bda2 100644 --- a/internal/handlers/payment_handlers.go +++ b/internal/handlers/payment_handlers.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "strings" + "sync" "time" "gitea.futuresens.co.uk/futuresens/cmstypes" @@ -28,9 +29,16 @@ type SalePaymentRequest struct { Currency string `json:"currency,omitempty"` } +type paymentStreamMessage struct { + Type string `json:"type"` + Code string `json:"code,omitempty"` + Response *cmstypes.ResponseRec `json:"response,omitempty"` +} + func (app *App) salePayment(w http.ResponseWriter, r *http.Request) { const op = logging.Op("salePayment") - var response = cmstypes.ResponseRec{ + + response := cmstypes.ResponseRec{ Status: cmstypes.StatusRec{ Code: http.StatusInternalServerError, Message: http.StatusText(http.StatusInternalServerError), @@ -49,22 +57,40 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) { writeTransactionResult(w, http.StatusMethodNotAllowed, response) return } + if app.paymentService == nil { - mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Service Not Configured", "Payment service is not configured; cannot process payment requests") + mail.SendEmailOnError( + app.cfg.Hotel, + app.cfg.Kiosk, + "Payment Service Not Configured", + "Payment service is not configured; cannot process payment requests", + ) + response.Data = buildPaymentFailureURL(types.ResultError, "Payment service is not configured") writeTransactionResult(w, http.StatusInternalServerError, response) return } + if ct := r.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") { response.Data = buildPaymentFailureURL(types.ResultError, "Content-Type must be application/json") writeTransactionResult(w, http.StatusUnsupportedMediaType, response) return } + defer r.Body.Close() var req SalePaymentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - logging.Error(types.ServiceName, err.Error(), "ReadJSON", string(op), "", app.cfg.Hotel, app.cfg.Kiosk) + logging.Error( + types.ServiceName, + err.Error(), + "ReadJSON", + string(op), + "", + app.cfg.Hotel, + app.cfg.Kiosk, + ) + response.Data = buildPaymentFailureURL(types.ResultError, "invalid JSON payload: "+err.Error()) writeTransactionResult(w, http.StatusBadRequest, response) return @@ -75,31 +101,90 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) { writeTransactionResult(w, http.StatusBadRequest, response) return } + if req.Currency == "" { req.Currency = "GBP" } + if req.Reference == "" { req.Reference = req.ConfirmNo } + if req.Reference == "" { req.Reference = uuid.NewString() } requestID := buildPaymentRequestID(req.Reference) + timeoutSeconds := app.cfg.TimeoutSeconds if timeoutSeconds <= 0 { timeoutSeconds = 300 } - ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutSeconds)*time.Second) + ctx, cancel := context.WithTimeout( + r.Context(), + time.Duration(timeoutSeconds)*time.Second, + ) defer cancel() - result, err := app.paymentService.Sale(ctx, paymentsvc.SaleRequest{ - RequestID: requestID, - Reference: req.Reference, - Amount: req.Amount, - Currency: req.Currency, - }) + flusher, ok := w.(http.Flusher) + if !ok { + response.Data = buildPaymentFailureURL( + types.ResultError, + "Streaming payment updates are not supported", + ) + writeTransactionResult(w, http.StatusInternalServerError, response) + return + } + + w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Content-Type-Options", "nosniff") + + encoder := json.NewEncoder(w) + + var streamMu sync.Mutex + streamStarted := false + streamFailed := false + + sendStreamMessage := func(message paymentStreamMessage) { + streamMu.Lock() + defer streamMu.Unlock() + + if streamFailed { + return + } + + if err := encoder.Encode(message); err != nil { + streamFailed = true + return + } + + streamStarted = true + flusher.Flush() + } + + onStatus := func(update paymentsvc.StatusUpdate) { + if update.Code == "" { + return + } + + sendStreamMessage(paymentStreamMessage{ + Type: "status", + Code: update.Code, + }) + } + + result, err := app.paymentService.Sale( + ctx, + paymentsvc.SaleRequest{ + RequestID: requestID, + Reference: req.Reference, + Amount: req.Amount, + Currency: req.Currency, + }, + onStatus, + ) if err != nil { status := http.StatusBadGateway @@ -107,20 +192,51 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) { status = http.StatusConflict } - logging.Error(types.ServiceName, err.Error(), "Payment provider error", string(op), req.Reference, app.cfg.Hotel, app.cfg.Kiosk) + logging.Error( + types.ServiceName, + err.Error(), + "Payment provider error", + string(op), + req.Reference, + app.cfg.Hotel, + app.cfg.Kiosk, + ) response.Status.Code = status response.Status.Message = http.StatusText(status) response.Data = buildPaymentFailureURL(types.ResultError, err.Error()) - writeTransactionResult(w, status, response) + + if !streamStarted { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + writeTransactionResult(w, status, response) + return + } + + sendStreamMessage(paymentStreamMessage{ + Type: "result", + Response: &response, + }) return } if result == nil { response.Status.Code = http.StatusBadGateway response.Status.Message = "Empty payment result" - response.Data = buildPaymentFailureURL(types.ResultError, "Payment provider returned an empty result") - writeTransactionResult(w, http.StatusBadGateway, response) + response.Data = buildPaymentFailureURL( + types.ResultError, + "Payment provider returned an empty result", + ) + + if !streamStarted { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + writeTransactionResult(w, http.StatusBadGateway, response) + return + } + + sendStreamMessage(paymentStreamMessage{ + Type: "result", + Response: &response, + }) return } @@ -128,10 +244,14 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) { if result.Success && strings.EqualFold(result.Status, "APPROVED") { printer.PrintSaleReceipt(result.CustomerReceipt) - response.Status.Code = http.StatusOK + response.Status.Message = result.Message response.Data = buildPaymentSuccessURL(result) - writeTransactionResult(w, http.StatusOK, response) + + sendStreamMessage(paymentStreamMessage{ + Type: "result", + Response: &response, + }) return } @@ -145,9 +265,14 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) { log.Printf("Transaction failed: %s", description) printer.PrintSaleReceipt(result.CustomerReceipt) + response.Status.Message = "Payment unsuccessful" response.Data = buildPaymentFailureURL(types.ResultError, description) - writeTransactionResult(w, http.StatusOK, response) + + sendStreamMessage(paymentStreamMessage{ + Type: "result", + Response: &response, + }) } func buildPaymentRequestID(reference string) string { diff --git a/internal/paybridge/client.go b/internal/paybridge/client.go index e320b17..14caca2 100644 --- a/internal/paybridge/client.go +++ b/internal/paybridge/client.go @@ -10,7 +10,9 @@ import ( "gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc" "gitea.futuresens.co.uk/futuresens/hardlink/internal/types" + "gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus" "github.com/gorilla/websocket" + log "github.com/sirupsen/logrus" ) type Client struct { @@ -31,7 +33,7 @@ func NewClient(url, apiKey string, timeoutSeconds int) *Client { } } -func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*paymentsvc.Result, error) { +func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest, onStatus paymentsvc.StatusHandler) (*paymentsvc.Result, error) { if req.Currency == "" { req.Currency = "GBP" } @@ -44,10 +46,12 @@ func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*payment TimeoutSeconds: c.TimeoutSeconds, } - return c.doPayment(ctx, payBridgeReq) + return c.doPayment(ctx, payBridgeReq, onStatus) } -func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc.Result, error) { +func (c *Client) doPayment(ctx context.Context, req PaymentRequest, onStatus paymentsvc.StatusHandler) (*paymentsvc.Result, error) { + emitStatus(onStatus, paymentstatus.Starting) + connectURL, err := c.connectURL() if err != nil { return nil, err @@ -75,6 +79,9 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc return nil, err } + log.Info("PayBridge authentication successful") + emitStatus(onStatus, paymentstatus.Started) + if err := ws.WriteJSON(Envelope{ Type: types.MesTypePaymentRequest, JWT: jwt, @@ -84,36 +91,108 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc return nil, fmt.Errorf("send PayBridge payment_request: %w", err) } + log.WithFields(log.Fields{ + "requestId": req.RequestID, + "operation": req.Operation, + "amount": req.Amount, + "currency": req.Currency, + }).Info("PayBridge payment request sent") + + emitStatus(onStatus, paymentstatus.RequestSent) + for { _, raw, err := ws.ReadMessage() if err != nil { if ctx.Err() != nil { return nil, ctx.Err() } + return nil, fmt.Errorf("read PayBridge message: %w", err) } var head struct { Type string `json:"type"` } + if err := json.Unmarshal(raw, &head); err != nil { return nil, fmt.Errorf("decode PayBridge message header: %w", err) } - switch strings.ToLower(head.Type) { + messageType := strings.ToLower(head.Type) + + switch messageType { + case types.MesTypePaymentStatusUpdate: + var update StatusUpdateEnvelope + if err := json.Unmarshal(raw, &update); err != nil { + return nil, fmt.Errorf("decode payment_status_update: %w", err) + } + + log.WithFields(log.Fields{ + "status": update.Data.Status, + "code": update.Data.Code, + }).Info("PayBridge status update") + + statusCode, known := mapPayBridgeStatus( + update.Data.Status, + update.Data.Code, + ) + + if !known { + log.WithFields(log.Fields{ + "status": update.Data.Status, + "code": update.Data.Code, + }).Warn("Unknown PayBridge status update") + } + + emitStatus(onStatus, statusCode) + + case types.MesTypePaymentAccepted: + log.Info("PayBridge payment request accepted") + emitStatus(onStatus, paymentstatus.Accepted) + case types.MesTypePaymentResult: var result PaymentResultEnvelope if err := json.Unmarshal(raw, &result); err != nil { return nil, fmt.Errorf("decode payment_result: %w", err) } - return mapPaymentResult(result), nil + + mapped := mapPaymentResult(result) + + log.WithFields(log.Fields{ + "requestId": mapped.RequestID, + "transactionId": mapped.TransactionID, + "status": mapped.Status, + "success": mapped.Success, + }).Info("PayBridge payment result received") + + emitStatus( + onStatus, + mapPayBridgeFinalStatus(mapped.Status, mapped.Success), + ) + + return mapped, nil case types.MesTypePaymentError: var paymentErr PaymentErrorEnvelope if err := json.Unmarshal(raw, &paymentErr); err != nil { return nil, fmt.Errorf("decode payment_error: %w", err) } - return mapPaymentError(req, paymentErr), nil + + mapped := mapPaymentError(req, paymentErr) + + log.WithFields(log.Fields{ + "requestId": mapped.RequestID, + "transactionId": mapped.TransactionID, + "status": mapped.Status, + "error": mapped.ErrorMessage, + }).Warn("PayBridge payment error received") + + emitStatus( + onStatus, + mapPayBridgeFinalStatus(mapped.Status, false), + ) + + return mapped, nil case types.ResultError: var genericErr struct { @@ -122,20 +201,47 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc Message string `json:"message"` } `json:"error"` } - if err := json.Unmarshal(raw, &genericErr); err != nil { - return nil, fmt.Errorf("%w: %s", ErrUnexpectedMessage, string(raw)) - } - if genericErr.Error.Message != "" { - return nil, fmt.Errorf("%w: %s: %s", ErrUnexpectedMessage, genericErr.Error.Code, genericErr.Error.Message) - } - return nil, fmt.Errorf("%w: %s", ErrUnexpectedMessage, string(raw)) - case types.MesTypePaymentStatusUpdate, types.MesTypePaymentAccepted, types.MesTypeAuthSuccess: - // Intermediate message. Keep waiting for payment_result or payment_error. + if err := json.Unmarshal(raw, &genericErr); err != nil { + return nil, fmt.Errorf( + "%w: %s", + ErrUnexpectedMessage, + string(raw), + ) + } + + log.WithFields(log.Fields{ + "code": genericErr.Error.Code, + "message": genericErr.Error.Message, + }).Error("PayBridge error message received") + + emitStatus(onStatus, paymentstatus.Error) + + if genericErr.Error.Message != "" { + return nil, fmt.Errorf( + "%w: %s: %s", + ErrUnexpectedMessage, + genericErr.Error.Code, + genericErr.Error.Message, + ) + } + + return nil, fmt.Errorf( + "%w: %s", + ErrUnexpectedMessage, + string(raw), + ) + + case types.MesTypeAuthSuccess: + log.Info("Additional PayBridge auth_success message received") default: - // PayBridge may introduce additional intermediate message types. - // Ignore them and continue waiting for the final result. + log.WithField( + "messageType", + head.Type, + ).Warn("Unknown PayBridge intermediate message type") + + emitStatus(onStatus, paymentstatus.Processing) } } } @@ -143,7 +249,10 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc func (c *Client) connectURL() (string, error) { u, err := url.Parse(c.URL) if err != nil { - return "", fmt.Errorf("parse PayBridge WebSocket URL: %w", err) + return "", fmt.Errorf( + "parse PayBridge WebSocket URL: %w", + err, + ) } q := u.Query() @@ -154,29 +263,186 @@ func (c *Client) connectURL() (string, error) { } func (c *Client) readAuthSuccess(ws *websocket.Conn) (string, error) { - if err := ws.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil { - return "", fmt.Errorf("%w: set auth read deadline: %v", ErrAuthFailed, err) + if err := ws.SetReadDeadline( + time.Now().Add(10 * time.Second), + ); err != nil { + return "", fmt.Errorf( + "%w: set auth read deadline: %v", + ErrAuthFailed, + err, + ) } defer ws.SetReadDeadline(time.Time{}) _, raw, err := ws.ReadMessage() if err != nil { - return "", fmt.Errorf("%w: read auth_success: %v", ErrAuthFailed, err) + return "", fmt.Errorf( + "%w: read auth_success: %v", + ErrAuthFailed, + err, + ) } var auth struct { Type string `json:"type"` JWT string `json:"jwt"` } + if err := json.Unmarshal(raw, &auth); err != nil { - return "", fmt.Errorf("%w: decode auth_success: %v", ErrAuthFailed, err) + return "", fmt.Errorf( + "%w: decode auth_success: %v", + ErrAuthFailed, + err, + ) } - if !strings.EqualFold(auth.Type, types.MesTypeAuthSuccess) { - return "", fmt.Errorf("%w: expected auth_success, got %s: %s", ErrAuthFailed, auth.Type, string(raw)) + + if !strings.EqualFold( + auth.Type, + types.MesTypeAuthSuccess, + ) { + return "", fmt.Errorf( + "%w: expected auth_success, got %s: %s", + ErrAuthFailed, + auth.Type, + string(raw), + ) } + if auth.JWT == "" { - return "", fmt.Errorf("%w: auth_success did not contain jwt", ErrAuthFailed) + return "", fmt.Errorf( + "%w: auth_success did not contain jwt", + ErrAuthFailed, + ) } return auth.JWT, nil } + +func emitStatus( + onStatus paymentsvc.StatusHandler, + code string, +) { + if onStatus == nil || code == "" { + return + } + + onStatus(paymentsvc.StatusUpdate{ + Code: code, + }) +} + +func mapPayBridgeStatus( + status string, + code string, +) (string, bool) { + normalizedStatus := strings.ToLower( + strings.TrimSpace(status), + ) + + switch normalizedStatus { + case payBridgeMessageInsertOrSwipeCard, + payBridgeMessageInsertSwipeOrPresentCard: + return paymentstatus.PresentCard, true + + case payBridgeMessageInsertCard: + return paymentstatus.InsertCard, true + + case payBridgeMessagePleaseWait: + return paymentstatus.PleaseWait, true + + case payBridgeMessageDoNotRemoveCard: + return paymentstatus.DoNotRemoveCard, true + + case payBridgeMessageTryAnotherInterface: + return paymentstatus.TryAnotherInterface, true + + case payBridgeMessagePIN: + return paymentstatus.EnterPIN, true + + case payBridgeMessagePINAgain: + return paymentstatus.EnterPINAgain, true + + case payBridgeMessageTransactionCancelled: + return paymentstatus.Cancelled, true + + case payBridgeMessageProcessing: + return paymentstatus.Processing, true + + case payBridgeMessageApproved: + // Это ещё промежуточный статус. + // Финальный успех определяется только по payment_result. + return paymentstatus.Authorized, true + + case payBridgeMessageDeclined: + return paymentstatus.Declined, true + } + + // Fallback по фактически обнаруженным кодам терминала. + switch strings.TrimSpace(code) { + case payBridgeCodeInsertCard: + return paymentstatus.InsertCard, true + + case payBridgeCodeEnterPIN: + return paymentstatus.EnterPIN, true + + case payBridgeCodeCancelled: + return paymentstatus.Cancelled, true + + case payBridgeCodePleaseWait: + return paymentstatus.PleaseWait, true + + case payBridgeCodePresentCard, payBridgeCodePresentCardAlternate: + return paymentstatus.PresentCard, true + + case payBridgeCodeEnterPINAgain: + return paymentstatus.EnterPINAgain, true + + case payBridgeCodeTryAnotherInterface: + return paymentstatus.TryAnotherInterface, true + + case payBridgeCodeDoNotRemoveCard: + return paymentstatus.DoNotRemoveCard, true + } + + // Пока назначение кодов 200, 201, 205 и 210 неизвестно. + // Они останутся в логах, но на экране будет общий статус. + return paymentstatus.Processing, false +} + +func mapPayBridgeFinalStatus( + status string, + success bool, +) string { + if success && strings.EqualFold(status, payBridgeFinalStatusApproved) { + return paymentstatus.Approved + } + + switch strings.ToUpper(status) { + case payBridgeFinalStatusDeclined: + return paymentstatus.Declined + + case payBridgeFinalStatusCancelled: + return paymentstatus.Cancelled + + case payBridgeFinalStatusTimeout: + return paymentstatus.Timeout + + case payBridgeFinalStatusVoided: + return paymentstatus.Voided + + case payBridgeFinalStatusVoidedDailyLimitExceeded: + return paymentstatus.DailyLimitExceeded + + case payBridgeFinalStatusDailyLimitVoidFailed: + return paymentstatus.VoidFailed + + case payBridgeFinalStatusDailyLimitValidationError: + return paymentstatus.LimitValidationError + + case payBridgeFinalStatusError, payBridgeFinalStatusFailed: + return paymentstatus.Error + + default: + return paymentstatus.Error + } +} diff --git a/internal/paybridge/statuses.go b/internal/paybridge/statuses.go new file mode 100644 index 0000000..fa7c587 --- /dev/null +++ b/internal/paybridge/statuses.go @@ -0,0 +1,41 @@ +package paybridge + +const ( + payBridgeFinalStatusApproved = "APPROVED" + payBridgeFinalStatusDeclined = "DECLINED" + payBridgeFinalStatusCancelled = "CANCELLED" + payBridgeFinalStatusTimeout = "TIMEOUT" + payBridgeFinalStatusVoided = "VOIDED" + payBridgeFinalStatusVoidedDailyLimitExceeded = "VOIDED_DAILY_LIMIT_EXCEEDED" + payBridgeFinalStatusDailyLimitVoidFailed = "DAILY_LIMIT_EXCEEDED_VOID_FAILED" + payBridgeFinalStatusDailyLimitValidationError = "DAILY_LIMIT_VALIDATION_ERROR" + payBridgeFinalStatusError = "ERROR" + payBridgeFinalStatusFailed = "FAILED" +) + +const ( + payBridgeMessageInsertOrSwipeCard = "insert or swipe card" + payBridgeMessageInsertSwipeOrPresentCard = "insert, swipe or present card" + payBridgeMessageInsertCard = "insert card" + payBridgeMessagePleaseWait = "please wait" + payBridgeMessageDoNotRemoveCard = "please wait. do not remove card" + payBridgeMessageTryAnotherInterface = "please try another interface" + payBridgeMessagePIN = "pin" + payBridgeMessagePINAgain = "pin again" + payBridgeMessageTransactionCancelled = "transaction cancelled" + payBridgeMessageProcessing = "processing" + payBridgeMessageApproved = "approved" + payBridgeMessageDeclined = "declined" +) + +const ( + payBridgeCodeInsertCard = "101" + payBridgeCodeEnterPIN = "106" + payBridgeCodeCancelled = "124" + payBridgeCodePleaseWait = "1017" + payBridgeCodePresentCard = "1109" + payBridgeCodePresentCardAlternate = "1113" + payBridgeCodeEnterPINAgain = "1129" + payBridgeCodeTryAnotherInterface = "1274" + payBridgeCodeDoNotRemoveCard = "1312" +) diff --git a/internal/paybridge/statuses_test.go b/internal/paybridge/statuses_test.go new file mode 100644 index 0000000..b1f4ffa --- /dev/null +++ b/internal/paybridge/statuses_test.go @@ -0,0 +1,125 @@ +package paybridge + +import ( + "testing" + + "gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus" +) + +func TestMapPayBridgeFinalStatus(t *testing.T) { + tests := []struct { + name string + status string + success bool + want string + }{ + {"approved success", "APPROVED", true, paymentstatus.Approved}, + {"approved without success", "APPROVED", false, paymentstatus.Error}, + {"declined", "DECLINED", false, paymentstatus.Declined}, + {"cancelled", "CANCELLED", false, paymentstatus.Cancelled}, + {"timeout", "TIMEOUT", false, paymentstatus.Timeout}, + {"voided", "VOIDED", false, paymentstatus.Voided}, + {"voided daily limit exceeded", "VOIDED_DAILY_LIMIT_EXCEEDED", false, paymentstatus.DailyLimitExceeded}, + {"daily limit void failed", "DAILY_LIMIT_EXCEEDED_VOID_FAILED", false, paymentstatus.VoidFailed}, + {"daily limit validation error", "DAILY_LIMIT_VALIDATION_ERROR", false, paymentstatus.LimitValidationError}, + {"error", "ERROR", false, paymentstatus.Error}, + {"failed", "FAILED", false, paymentstatus.Error}, + {"unknown", "UNKNOWN", false, paymentstatus.Error}, + {"approved case insensitive", "approved", true, paymentstatus.Approved}, + {"declined case insensitive", "declined", false, paymentstatus.Declined}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := mapPayBridgeFinalStatus(test.status, test.success) + if got != test.want { + t.Fatalf("got %q, want %q", got, test.want) + } + }) + } +} + +func TestMapPayBridgeStatusMessages(t *testing.T) { + tests := []struct { + name string + status string + want string + }{ + {"insert or swipe card", "insert or swipe card", paymentstatus.PresentCard}, + {"insert swipe or present card", "insert, swipe or present card", paymentstatus.PresentCard}, + {"insert card", "insert card", paymentstatus.InsertCard}, + {"please wait", "please wait", paymentstatus.PleaseWait}, + {"do not remove card", "please wait. do not remove card", paymentstatus.DoNotRemoveCard}, + {"try another interface", "please try another interface", paymentstatus.TryAnotherInterface}, + {"pin", "pin", paymentstatus.EnterPIN}, + {"pin again", "pin again", paymentstatus.EnterPINAgain}, + {"transaction cancelled", "transaction cancelled", paymentstatus.Cancelled}, + {"processing", "processing", paymentstatus.Processing}, + {"approved", "approved", paymentstatus.Authorized}, + {"declined", "declined", paymentstatus.Declined}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, matched := mapPayBridgeStatus(test.status, "") + if !matched { + t.Fatal("expected status to match") + } + if got != test.want { + t.Fatalf("got %q, want %q", got, test.want) + } + }) + } +} + +func TestMapPayBridgeStatusCodes(t *testing.T) { + tests := []struct { + name string + code string + want string + }{ + {"insert card", "101", paymentstatus.InsertCard}, + {"enter pin", "106", paymentstatus.EnterPIN}, + {"cancelled", "124", paymentstatus.Cancelled}, + {"please wait", "1017", paymentstatus.PleaseWait}, + {"present card", "1109", paymentstatus.PresentCard}, + {"present card alternate", "1113", paymentstatus.PresentCard}, + {"enter pin again", "1129", paymentstatus.EnterPINAgain}, + {"try another interface", "1274", paymentstatus.TryAnotherInterface}, + {"do not remove card", "1312", paymentstatus.DoNotRemoveCard}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, matched := mapPayBridgeStatus("", test.code) + if !matched { + t.Fatal("expected code to match") + } + if got != test.want { + t.Fatalf("got %q, want %q", got, test.want) + } + }) + } +} + +func TestMapPayBridgeStatusNormalization(t *testing.T) { + got, matched := mapPayBridgeStatus(" PiN AgAiN ", "") + if !matched || got != paymentstatus.EnterPINAgain { + t.Fatalf("normalized message got (%q, %t), want (%q, true)", got, matched, paymentstatus.EnterPINAgain) + } + + got, matched = mapPayBridgeStatus("", " 101 ") + if !matched || got != paymentstatus.InsertCard { + t.Fatalf("trimmed code got (%q, %t), want (%q, true)", got, matched, paymentstatus.InsertCard) + } +} + +func TestMapPayBridgeStatusUnknown(t *testing.T) { + got, matched := mapPayBridgeStatus("unknown message", "999") + if matched { + t.Fatal("unknown status and code must not match") + } + if got != paymentstatus.Processing { + t.Fatalf("got %q, want %q", got, paymentstatus.Processing) + } +} diff --git a/internal/paybridge/types.go b/internal/paybridge/types.go index 733b8ed..23a6e36 100644 --- a/internal/paybridge/types.go +++ b/internal/paybridge/types.go @@ -53,3 +53,13 @@ type PaymentErrorEnvelope struct { Status string `json:"status"` } `json:"data"` } + +type StatusUpdateEnvelope struct { + Type string `json:"type"` + + Data struct { + Status string `json:"status"` + Code string `json:"code"` + Timestamp int64 `json:"timestamp"` + } `json:"data"` +} \ No newline at end of file diff --git a/internal/paymentsvc/service.go b/internal/paymentsvc/service.go index 6ccb5a6..c0fa9e5 100644 --- a/internal/paymentsvc/service.go +++ b/internal/paymentsvc/service.go @@ -6,10 +6,23 @@ import ( "sync" ) -var ErrPaymentInProgress = errors.New("payment is already in progress") +var ( + ErrPaymentInProgress = errors.New("payment is already in progress") + ErrProviderNotConfigured = errors.New("payment provider is not configured") +) + +type StatusUpdate struct { + Code string `json:"code"` +} + +type StatusHandler func(StatusUpdate) type Provider interface { - Sale(ctx context.Context, req SaleRequest) (*Result, error) + Sale( + ctx context.Context, + req SaleRequest, + onStatus StatusHandler, + ) (*Result, error) } type Service struct { @@ -20,10 +33,20 @@ type Service struct { } func NewService(provider Provider) *Service { - return &Service{provider: provider} + return &Service{ + provider: provider, + } } -func (s *Service) Sale(ctx context.Context, req SaleRequest) (*Result, error) { +func (s *Service) Sale( + ctx context.Context, + req SaleRequest, + onStatus StatusHandler, +) (*Result, error) { + if s == nil || s.provider == nil { + return nil, ErrProviderNotConfigured + } + s.mu.Lock() if s.busy { s.mu.Unlock() @@ -38,5 +61,5 @@ func (s *Service) Sale(ctx context.Context, req SaleRequest) (*Result, error) { s.mu.Unlock() }() - return s.provider.Sale(ctx, req) -} + return s.provider.Sale(ctx, req, onStatus) +} \ No newline at end of file diff --git a/paymentstatus/status.go b/paymentstatus/status.go new file mode 100644 index 0000000..440b528 --- /dev/null +++ b/paymentstatus/status.go @@ -0,0 +1,40 @@ +// Package paymentstatus defines progress codes emitted by Hardlink's +// POST /api/payment/sale stream. These codes are not authoritative final +// transaction decisions: clients may continue check-in only after receiving a +// final result frame whose response.data begins with /successful. +package paymentstatus + +const ( + Starting = "PAYMENT_STARTING" + Started = "PAYMENT_STARTED" + RequestSent = "PAYMENT_REQUEST_SENT" + Accepted = "PAYMENT_ACCEPTED" + PresentCard = "PAYMENT_PRESENT_CARD" + InsertCard = "PAYMENT_INSERT_CARD" + SwipeCard = "PAYMENT_SWIPE_CARD" + EnterPIN = "PAYMENT_ENTER_PIN" + EnterPINAgain = "PAYMENT_ENTER_PIN_AGAIN" + PleaseWait = "PAYMENT_PLEASE_WAIT" + DoNotRemoveCard = "PAYMENT_DO_NOT_REMOVE_CARD" + RemoveCard = "PAYMENT_REMOVE_CARD" + TryAnotherInterface = "PAYMENT_TRY_ANOTHER_INTERFACE" + Processing = "PAYMENT_PROCESSING" + Authorized = "PAYMENT_AUTHORIZED" + Approved = "PAYMENT_APPROVED" + Cancelling = "PAYMENT_CANCELLING" + Cancelled = "PAYMENT_CANCELLED" + Declined = "PAYMENT_DECLINED" + Expired = "PAYMENT_EXPIRED" + Timeout = "PAYMENT_TIMEOUT" + SignatureRequired = "PAYMENT_SIGNATURE_REQUIRED" + SignatureRejecting = "PAYMENT_SIGNATURE_REJECTING" + SignatureRejected = "PAYMENT_SIGNATURE_REJECTED" + Voided = "PAYMENT_VOIDED" + DailyLimitExceeded = "PAYMENT_DAILY_LIMIT_EXCEEDED" + VoidFailed = "PAYMENT_VOID_FAILED" + LimitValidationError = "PAYMENT_LIMIT_VALIDATION_ERROR" + Error = "PAYMENT_ERROR" + + DojoNotificationPrefix = "PAYMENT_DOJO_NOTIFICATION_" + DojoStatusPrefix = "PAYMENT_DOJO_STATUS_" +) diff --git a/paymentstatus/status_test.go b/paymentstatus/status_test.go new file mode 100644 index 0000000..3489cb8 --- /dev/null +++ b/paymentstatus/status_test.go @@ -0,0 +1,83 @@ +package paymentstatus_test + +import ( + "strings" + "testing" + + "gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus" +) + +func TestFixedStatusValues(t *testing.T) { + statuses := []struct { + name string + value string + want string + }{ + {"Starting", paymentstatus.Starting, "PAYMENT_STARTING"}, + {"Started", paymentstatus.Started, "PAYMENT_STARTED"}, + {"RequestSent", paymentstatus.RequestSent, "PAYMENT_REQUEST_SENT"}, + {"Accepted", paymentstatus.Accepted, "PAYMENT_ACCEPTED"}, + {"PresentCard", paymentstatus.PresentCard, "PAYMENT_PRESENT_CARD"}, + {"InsertCard", paymentstatus.InsertCard, "PAYMENT_INSERT_CARD"}, + {"SwipeCard", paymentstatus.SwipeCard, "PAYMENT_SWIPE_CARD"}, + {"EnterPIN", paymentstatus.EnterPIN, "PAYMENT_ENTER_PIN"}, + {"EnterPINAgain", paymentstatus.EnterPINAgain, "PAYMENT_ENTER_PIN_AGAIN"}, + {"PleaseWait", paymentstatus.PleaseWait, "PAYMENT_PLEASE_WAIT"}, + {"DoNotRemoveCard", paymentstatus.DoNotRemoveCard, "PAYMENT_DO_NOT_REMOVE_CARD"}, + {"RemoveCard", paymentstatus.RemoveCard, "PAYMENT_REMOVE_CARD"}, + {"TryAnotherInterface", paymentstatus.TryAnotherInterface, "PAYMENT_TRY_ANOTHER_INTERFACE"}, + {"Processing", paymentstatus.Processing, "PAYMENT_PROCESSING"}, + {"Authorized", paymentstatus.Authorized, "PAYMENT_AUTHORIZED"}, + {"Approved", paymentstatus.Approved, "PAYMENT_APPROVED"}, + {"Cancelling", paymentstatus.Cancelling, "PAYMENT_CANCELLING"}, + {"Cancelled", paymentstatus.Cancelled, "PAYMENT_CANCELLED"}, + {"Declined", paymentstatus.Declined, "PAYMENT_DECLINED"}, + {"Expired", paymentstatus.Expired, "PAYMENT_EXPIRED"}, + {"Timeout", paymentstatus.Timeout, "PAYMENT_TIMEOUT"}, + {"SignatureRequired", paymentstatus.SignatureRequired, "PAYMENT_SIGNATURE_REQUIRED"}, + {"SignatureRejecting", paymentstatus.SignatureRejecting, "PAYMENT_SIGNATURE_REJECTING"}, + {"SignatureRejected", paymentstatus.SignatureRejected, "PAYMENT_SIGNATURE_REJECTED"}, + {"Voided", paymentstatus.Voided, "PAYMENT_VOIDED"}, + {"DailyLimitExceeded", paymentstatus.DailyLimitExceeded, "PAYMENT_DAILY_LIMIT_EXCEEDED"}, + {"VoidFailed", paymentstatus.VoidFailed, "PAYMENT_VOID_FAILED"}, + {"LimitValidationError", paymentstatus.LimitValidationError, "PAYMENT_LIMIT_VALIDATION_ERROR"}, + {"Error", paymentstatus.Error, "PAYMENT_ERROR"}, + } + + seen := make(map[string]string, len(statuses)) + for _, status := range statuses { + t.Run(status.name, func(t *testing.T) { + if status.value != status.want { + t.Fatalf("got %q, want %q", status.value, status.want) + } + if !strings.HasPrefix(status.value, "PAYMENT_") { + t.Fatalf("status %q does not have PAYMENT_ prefix", status.value) + } + }) + + if previous, exists := seen[status.value]; exists { + t.Errorf("%s and %s have duplicate value %q", previous, status.name, status.value) + } + seen[status.value] = status.name + } +} + +func TestDojoPrefixes(t *testing.T) { + if paymentstatus.DojoNotificationPrefix != "PAYMENT_DOJO_NOTIFICATION_" { + t.Fatalf( + "DojoNotificationPrefix = %q, want %q", + paymentstatus.DojoNotificationPrefix, + "PAYMENT_DOJO_NOTIFICATION_", + ) + } + if paymentstatus.DojoStatusPrefix != "PAYMENT_DOJO_STATUS_" { + t.Fatalf( + "DojoStatusPrefix = %q, want %q", + paymentstatus.DojoStatusPrefix, + "PAYMENT_DOJO_STATUS_", + ) + } + if paymentstatus.DojoNotificationPrefix == paymentstatus.DojoStatusPrefix { + t.Fatal("Dojo prefixes must differ") + } +} diff --git a/release notes.md b/release notes.md index ee48cd1..40831b2 100644 --- a/release notes.md +++ b/release notes.md @@ -2,6 +2,9 @@ builtVersion is a const in main.go +#### 1.3.3 - 21 July 2026 +added PDQ status streaming to the payment flow to allow the front end to display the status of the PDQ terminal + #### 1.3.2 - 20 July 2026 make dojo decline payment in case of signature required