450 lines
10 KiB
Go
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
|
|
}
|
|
}
|