Added payment terminal status streaming
This commit is contained in:
parent
2a3a5bee7b
commit
478d260ebe
@ -34,7 +34,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
buildVersion = "1.3.2"
|
buildVersion = "1.3.3"
|
||||||
serviceName = "hardlink"
|
serviceName = "hardlink"
|
||||||
pollingFrequency = 8 * time.Second
|
pollingFrequency = 8 * time.Second
|
||||||
)
|
)
|
||||||
|
|||||||
@ -8,11 +8,13 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||||
|
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -33,6 +35,29 @@ type Client struct {
|
|||||||
httpClient *http.Client
|
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) {
|
func NewClient(cfg Config) (*Client, error) {
|
||||||
if cfg.BaseURL == "" {
|
if cfg.BaseURL == "" {
|
||||||
return nil, fmt.Errorf("dojo base_url is required")
|
return nil, fmt.Errorf("dojo base_url is required")
|
||||||
@ -60,11 +85,17 @@ func NewClient(cfg Config) (*Client, error) {
|
|||||||
}, nil
|
}, 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 == "" {
|
if req.Currency == "" {
|
||||||
req.Currency = "GBP"
|
req.Currency = "GBP"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendPaymentStatus(onStatus, paymentstatus.Starting)
|
||||||
|
|
||||||
intent, err := c.createPaymentIntent(ctx, req)
|
intent, err := c.createPaymentIntent(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -75,7 +106,7 @@ func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*payment
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err = c.waitForTerminalSession(ctx, session.ID)
|
session, err = c.waitForTerminalSession(ctx, session.ID, onStatus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -150,10 +181,17 @@ func (c *Client) createTerminalSession(ctx context.Context, paymentIntentID stri
|
|||||||
return &response, nil
|
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)
|
ticker := time.NewTicker(time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
notificationCount := 0
|
||||||
|
statusCount := 0
|
||||||
|
lastSessionStatus := ""
|
||||||
signatureRejected := false
|
signatureRejected := false
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@ -164,12 +202,40 @@ func (c *Client) waitForTerminalSession(ctx context.Context, terminalSessionID s
|
|||||||
|
|
||||||
log.Println("session status:", session.Status)
|
log.Println("session status:", session.Status)
|
||||||
|
|
||||||
switch strings.ToLower(session.Status) {
|
emitNewTerminalSessionEvents(
|
||||||
case types.ResultInitiateRequested, types.ResultInitiated, types.ResultAuthorized, types.ResultCancelRequested:
|
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:
|
case types.ResultSignatureRequired:
|
||||||
if !signatureRejected {
|
if !signatureRejected {
|
||||||
rejectedSession, err := c.rejectSignature(ctx, terminalSessionID)
|
sendPaymentStatus(
|
||||||
|
onStatus,
|
||||||
|
paymentstatus.SignatureRejecting,
|
||||||
|
)
|
||||||
|
|
||||||
|
rejectedSession, err := c.rejectSignature(
|
||||||
|
ctx,
|
||||||
|
terminalSessionID,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -177,30 +243,206 @@ func (c *Client) waitForTerminalSession(ctx context.Context, terminalSessionID s
|
|||||||
signatureRejected = true
|
signatureRejected = true
|
||||||
|
|
||||||
if rejectedSession != nil {
|
if rejectedSession != nil {
|
||||||
switch strings.ToLower(rejectedSession.Status) {
|
rejectedStatus := strings.ToLower(
|
||||||
case types.ResultCaptured, types.ResultCancelled, types.ResultCanceled, types.ResultDeclined,
|
rejectedSession.Status,
|
||||||
types.ResultExpired, types.ResultSignatureAccepted, types.ResultSignatureRejected:
|
)
|
||||||
|
|
||||||
|
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
|
return rejectedSession, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case types.ResultCaptured, types.ResultCancelled, types.ResultCanceled, types.ResultDeclined,
|
case types.ResultCaptured,
|
||||||
types.ResultExpired, types.ResultSignatureAccepted, types.ResultSignatureRejected:
|
types.ResultCancelled,
|
||||||
return session, nil
|
types.ResultCanceled,
|
||||||
|
types.ResultDeclined,
|
||||||
|
types.ResultExpired,
|
||||||
|
types.ResultSignatureAccepted,
|
||||||
|
types.ResultSignatureRejected:
|
||||||
|
return &session.terminalSessionResponse, nil
|
||||||
|
|
||||||
default:
|
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 {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
|
|
||||||
case <-ticker.C:
|
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) {
|
func (c *Client) rejectSignature(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) {
|
||||||
payload := signatureVerificationRequest{
|
payload := signatureVerificationRequest{
|
||||||
Accepted: false,
|
Accepted: false,
|
||||||
@ -216,8 +458,11 @@ func (c *Client) rejectSignature(ctx context.Context, terminalSessionID string)
|
|||||||
return &response, nil
|
return &response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) getTerminalSession(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) {
|
func (c *Client) getTerminalSession(
|
||||||
var response terminalSessionResponse
|
ctx context.Context,
|
||||||
|
terminalSessionID string,
|
||||||
|
) (*terminalSessionWithUpdates, error) {
|
||||||
|
var response terminalSessionWithUpdates
|
||||||
path := "/terminal-sessions/" + url.PathEscape(terminalSessionID)
|
path := "/terminal-sessions/" + url.PathEscape(terminalSessionID)
|
||||||
if err := c.doJSON(ctx, http.MethodGet, path, nil, true, &response); err != nil {
|
if err := c.doJSON(ctx, http.MethodGet, path, nil, true, &response); err != nil {
|
||||||
return nil, fmt.Errorf("get Dojo terminal session: %w", err)
|
return nil, fmt.Errorf("get Dojo terminal session: %w", err)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||||
@ -28,9 +29,16 @@ type SalePaymentRequest struct {
|
|||||||
Currency string `json:"currency,omitempty"`
|
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) {
|
func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
||||||
const op = logging.Op("salePayment")
|
const op = logging.Op("salePayment")
|
||||||
var response = cmstypes.ResponseRec{
|
|
||||||
|
response := cmstypes.ResponseRec{
|
||||||
Status: cmstypes.StatusRec{
|
Status: cmstypes.StatusRec{
|
||||||
Code: http.StatusInternalServerError,
|
Code: http.StatusInternalServerError,
|
||||||
Message: http.StatusText(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)
|
writeTransactionResult(w, http.StatusMethodNotAllowed, response)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.paymentService == nil {
|
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")
|
response.Data = buildPaymentFailureURL(types.ResultError, "Payment service is not configured")
|
||||||
writeTransactionResult(w, http.StatusInternalServerError, response)
|
writeTransactionResult(w, http.StatusInternalServerError, response)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ct := r.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") {
|
if ct := r.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") {
|
||||||
response.Data = buildPaymentFailureURL(types.ResultError, "Content-Type must be application/json")
|
response.Data = buildPaymentFailureURL(types.ResultError, "Content-Type must be application/json")
|
||||||
writeTransactionResult(w, http.StatusUnsupportedMediaType, response)
|
writeTransactionResult(w, http.StatusUnsupportedMediaType, response)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
var req SalePaymentRequest
|
var req SalePaymentRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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())
|
response.Data = buildPaymentFailureURL(types.ResultError, "invalid JSON payload: "+err.Error())
|
||||||
writeTransactionResult(w, http.StatusBadRequest, response)
|
writeTransactionResult(w, http.StatusBadRequest, response)
|
||||||
return
|
return
|
||||||
@ -75,31 +101,90 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeTransactionResult(w, http.StatusBadRequest, response)
|
writeTransactionResult(w, http.StatusBadRequest, response)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Currency == "" {
|
if req.Currency == "" {
|
||||||
req.Currency = "GBP"
|
req.Currency = "GBP"
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Reference == "" {
|
if req.Reference == "" {
|
||||||
req.Reference = req.ConfirmNo
|
req.Reference = req.ConfirmNo
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Reference == "" {
|
if req.Reference == "" {
|
||||||
req.Reference = uuid.NewString()
|
req.Reference = uuid.NewString()
|
||||||
}
|
}
|
||||||
|
|
||||||
requestID := buildPaymentRequestID(req.Reference)
|
requestID := buildPaymentRequestID(req.Reference)
|
||||||
|
|
||||||
timeoutSeconds := app.cfg.TimeoutSeconds
|
timeoutSeconds := app.cfg.TimeoutSeconds
|
||||||
if timeoutSeconds <= 0 {
|
if timeoutSeconds <= 0 {
|
||||||
timeoutSeconds = 300
|
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()
|
defer cancel()
|
||||||
|
|
||||||
result, err := app.paymentService.Sale(ctx, paymentsvc.SaleRequest{
|
flusher, ok := w.(http.Flusher)
|
||||||
RequestID: requestID,
|
if !ok {
|
||||||
Reference: req.Reference,
|
response.Data = buildPaymentFailureURL(
|
||||||
Amount: req.Amount,
|
types.ResultError,
|
||||||
Currency: req.Currency,
|
"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 {
|
if err != nil {
|
||||||
status := http.StatusBadGateway
|
status := http.StatusBadGateway
|
||||||
@ -107,20 +192,51 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
status = http.StatusConflict
|
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.Code = status
|
||||||
response.Status.Message = http.StatusText(status)
|
response.Status.Message = http.StatusText(status)
|
||||||
response.Data = buildPaymentFailureURL(types.ResultError, err.Error())
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
response.Status.Code = http.StatusBadGateway
|
response.Status.Code = http.StatusBadGateway
|
||||||
response.Status.Message = "Empty payment result"
|
response.Status.Message = "Empty payment result"
|
||||||
response.Data = buildPaymentFailureURL(types.ResultError, "Payment provider returned an empty result")
|
response.Data = buildPaymentFailureURL(
|
||||||
writeTransactionResult(w, http.StatusBadGateway, response)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,10 +244,14 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if result.Success && strings.EqualFold(result.Status, "APPROVED") {
|
if result.Success && strings.EqualFold(result.Status, "APPROVED") {
|
||||||
printer.PrintSaleReceipt(result.CustomerReceipt)
|
printer.PrintSaleReceipt(result.CustomerReceipt)
|
||||||
response.Status.Code = http.StatusOK
|
|
||||||
response.Status.Message = result.Message
|
response.Status.Message = result.Message
|
||||||
response.Data = buildPaymentSuccessURL(result)
|
response.Data = buildPaymentSuccessURL(result)
|
||||||
writeTransactionResult(w, http.StatusOK, response)
|
|
||||||
|
sendStreamMessage(paymentStreamMessage{
|
||||||
|
Type: "result",
|
||||||
|
Response: &response,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,9 +265,14 @@ func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
log.Printf("Transaction failed: %s", description)
|
log.Printf("Transaction failed: %s", description)
|
||||||
printer.PrintSaleReceipt(result.CustomerReceipt)
|
printer.PrintSaleReceipt(result.CustomerReceipt)
|
||||||
|
|
||||||
response.Status.Message = "Payment unsuccessful"
|
response.Status.Message = "Payment unsuccessful"
|
||||||
response.Data = buildPaymentFailureURL(types.ResultError, description)
|
response.Data = buildPaymentFailureURL(types.ResultError, description)
|
||||||
writeTransactionResult(w, http.StatusOK, response)
|
|
||||||
|
sendStreamMessage(paymentStreamMessage{
|
||||||
|
Type: "result",
|
||||||
|
Response: &response,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPaymentRequestID(reference string) string {
|
func buildPaymentRequestID(reference string) string {
|
||||||
|
|||||||
@ -10,7 +10,9 @@ import (
|
|||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||||
|
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
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 == "" {
|
if req.Currency == "" {
|
||||||
req.Currency = "GBP"
|
req.Currency = "GBP"
|
||||||
}
|
}
|
||||||
@ -44,10 +46,12 @@ func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*payment
|
|||||||
TimeoutSeconds: c.TimeoutSeconds,
|
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()
|
connectURL, err := c.connectURL()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -75,6 +79,9 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Info("PayBridge authentication successful")
|
||||||
|
emitStatus(onStatus, paymentstatus.Started)
|
||||||
|
|
||||||
if err := ws.WriteJSON(Envelope{
|
if err := ws.WriteJSON(Envelope{
|
||||||
Type: types.MesTypePaymentRequest,
|
Type: types.MesTypePaymentRequest,
|
||||||
JWT: jwt,
|
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)
|
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 {
|
for {
|
||||||
_, raw, err := ws.ReadMessage()
|
_, raw, err := ws.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("read PayBridge message: %w", err)
|
return nil, fmt.Errorf("read PayBridge message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var head struct {
|
var head struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(raw, &head); err != nil {
|
if err := json.Unmarshal(raw, &head); err != nil {
|
||||||
return nil, fmt.Errorf("decode PayBridge message header: %w", err)
|
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:
|
case types.MesTypePaymentResult:
|
||||||
var result PaymentResultEnvelope
|
var result PaymentResultEnvelope
|
||||||
if err := json.Unmarshal(raw, &result); err != nil {
|
if err := json.Unmarshal(raw, &result); err != nil {
|
||||||
return nil, fmt.Errorf("decode payment_result: %w", err)
|
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:
|
case types.MesTypePaymentError:
|
||||||
var paymentErr PaymentErrorEnvelope
|
var paymentErr PaymentErrorEnvelope
|
||||||
if err := json.Unmarshal(raw, &paymentErr); err != nil {
|
if err := json.Unmarshal(raw, &paymentErr); err != nil {
|
||||||
return nil, fmt.Errorf("decode payment_error: %w", err)
|
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:
|
case types.ResultError:
|
||||||
var genericErr struct {
|
var genericErr struct {
|
||||||
@ -122,20 +201,47 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
} `json:"error"`
|
} `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:
|
if err := json.Unmarshal(raw, &genericErr); err != nil {
|
||||||
// Intermediate message. Keep waiting for payment_result or payment_error.
|
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:
|
default:
|
||||||
// PayBridge may introduce additional intermediate message types.
|
log.WithField(
|
||||||
// Ignore them and continue waiting for the final result.
|
"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) {
|
func (c *Client) connectURL() (string, error) {
|
||||||
u, err := url.Parse(c.URL)
|
u, err := url.Parse(c.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("parse PayBridge WebSocket URL: %w", err)
|
return "", fmt.Errorf(
|
||||||
|
"parse PayBridge WebSocket URL: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
q := u.Query()
|
q := u.Query()
|
||||||
@ -154,29 +263,186 @@ func (c *Client) connectURL() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) readAuthSuccess(ws *websocket.Conn) (string, error) {
|
func (c *Client) readAuthSuccess(ws *websocket.Conn) (string, error) {
|
||||||
if err := ws.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
if err := ws.SetReadDeadline(
|
||||||
return "", fmt.Errorf("%w: set auth read deadline: %v", ErrAuthFailed, err)
|
time.Now().Add(10 * time.Second),
|
||||||
|
); err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"%w: set auth read deadline: %v",
|
||||||
|
ErrAuthFailed,
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
defer ws.SetReadDeadline(time.Time{})
|
defer ws.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
_, raw, err := ws.ReadMessage()
|
_, raw, err := ws.ReadMessage()
|
||||||
if err != nil {
|
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 {
|
var auth struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
JWT string `json:"jwt"`
|
JWT string `json:"jwt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(raw, &auth); err != nil {
|
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 == "" {
|
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
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
41
internal/paybridge/statuses.go
Normal file
41
internal/paybridge/statuses.go
Normal file
@ -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"
|
||||||
|
)
|
||||||
125
internal/paybridge/statuses_test.go
Normal file
125
internal/paybridge/statuses_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -53,3 +53,13 @@ type PaymentErrorEnvelope struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
} `json:"data"`
|
} `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"`
|
||||||
|
}
|
||||||
@ -6,10 +6,23 @@ import (
|
|||||||
"sync"
|
"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 {
|
type Provider interface {
|
||||||
Sale(ctx context.Context, req SaleRequest) (*Result, error)
|
Sale(
|
||||||
|
ctx context.Context,
|
||||||
|
req SaleRequest,
|
||||||
|
onStatus StatusHandler,
|
||||||
|
) (*Result, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@ -20,10 +33,20 @@ type Service struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewService(provider Provider) *Service {
|
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()
|
s.mu.Lock()
|
||||||
if s.busy {
|
if s.busy {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@ -38,5 +61,5 @@ func (s *Service) Sale(ctx context.Context, req SaleRequest) (*Result, error) {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return s.provider.Sale(ctx, req)
|
return s.provider.Sale(ctx, req, onStatus)
|
||||||
}
|
}
|
||||||
40
paymentstatus/status.go
Normal file
40
paymentstatus/status.go
Normal file
@ -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_"
|
||||||
|
)
|
||||||
83
paymentstatus/status_test.go
Normal file
83
paymentstatus/status_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
builtVersion is a const in main.go
|
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
|
#### 1.3.2 - 20 July 2026
|
||||||
make dojo decline payment in case of signature required
|
make dojo decline payment in case of signature required
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user