package paybridge import ( "context" "encoding/json" "fmt" "net/url" "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" "github.com/gorilla/websocket" log "github.com/sirupsen/logrus" ) type Client struct { URL string APIKey string TimeoutSeconds int } func NewClient(url, apiKey string, timeoutSeconds int) *Client { if timeoutSeconds <= 0 { timeoutSeconds = 300 } return &Client{ URL: url, APIKey: apiKey, TimeoutSeconds: timeoutSeconds, } } func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest, onStatus paymentsvc.StatusHandler) (*paymentsvc.Result, error) { if req.Currency == "" { req.Currency = "GBP" } payBridgeReq := PaymentRequest{ RequestID: req.RequestID, Amount: req.Amount, Currency: req.Currency, Operation: "SALE", TimeoutSeconds: c.TimeoutSeconds, } return c.doPayment(ctx, payBridgeReq, onStatus) } 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 } ws, _, err := websocket.DefaultDialer.DialContext(ctx, connectURL, nil) if err != nil { return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err) } defer ws.Close() done := make(chan struct{}) defer close(done) go func() { select { case <-ctx.Done(): _ = ws.Close() case <-done: } }() jwt, err := c.readAuthSuccess(ws) if err != nil { return nil, err } log.Info("PayBridge authentication successful") emitStatus(onStatus, paymentstatus.Started) if err := ws.WriteJSON(Envelope{ Type: types.MesTypePaymentRequest, JWT: jwt, Data: req, Timestamp: time.Now().UnixMilli(), }); err != nil { 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) } 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 // log.Println("PayBridge payment result raw:", string(raw)) if err := json.Unmarshal(raw, &result); err != nil { return nil, fmt.Errorf("decode payment_result: %w", err) } 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) } 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 { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"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: log.WithField( "messageType", head.Type, ).Warn("Unknown PayBridge intermediate message type") emitStatus(onStatus, paymentstatus.Processing) } } } func (c *Client) connectURL() (string, error) { u, err := url.Parse(c.URL) if err != nil { return "", fmt.Errorf( "parse PayBridge WebSocket URL: %w", err, ) } q := u.Query() q.Set("api_key", c.APIKey) u.RawQuery = q.Encode() return u.String(), nil } 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, ) } defer ws.SetReadDeadline(time.Time{}) _, raw, err := ws.ReadMessage() if err != nil { 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, ) } 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 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 } }