450 lines
10 KiB
Go

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
}
}