added support for Dojo terminal-unavailable responses

This commit is contained in:
yurii 2026-07-23 12:08:18 +01:00
parent 478d260ebe
commit fbf652720c
9 changed files with 409 additions and 9 deletions

View File

@ -34,7 +34,7 @@ import (
) )
const ( const (
buildVersion = "1.3.3" buildVersion = "v1.3.4"
serviceName = "hardlink" serviceName = "hardlink"
pollingFrequency = 8 * time.Second pollingFrequency = 8 * time.Second
) )

View File

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -35,6 +36,20 @@ type Client struct {
httpClient *http.Client httpClient *http.Client
} }
const (
dojoTerminalUnavailableStatus = "TERMINAL_UNAVAILABLE"
dojoTerminalUnavailableMessage = "Payment terminal is unavailable"
)
type httpResponseError struct {
StatusCode int
Body string
}
func (e *httpResponseError) Error() string {
return fmt.Sprintf("Dojo returned HTTP %d: %s", e.StatusCode, e.Body)
}
type terminalSessionWithUpdates struct { type terminalSessionWithUpdates struct {
terminalSessionResponse terminalSessionResponse
@ -103,6 +118,21 @@ func (c *Client) Sale(
session, err := c.createTerminalSession(ctx, intent.ID) session, err := c.createTerminalSession(ctx, intent.ID)
if err != nil { if err != nil {
var responseErr *httpResponseError
if errors.As(err, &responseErr) && responseErr.StatusCode == http.StatusConflict {
log.WithFields(log.Fields{
"provider": "Dojo",
"operation": "createTerminalSession",
"status": responseErr.StatusCode,
"terminal_unavailable": true,
}).Warn(dojoTerminalUnavailableMessage)
sendPaymentStatus(onStatus, paymentstatus.TerminalUnavailable)
result := c.baseResult(req, nil)
result.Status = dojoTerminalUnavailableStatus
result.ErrorMessage = dojoTerminalUnavailableMessage
return result, nil
}
return nil, err return nil, err
} }
@ -569,10 +599,13 @@ func (c *Client) doJSON(ctx context.Context, method, path string, payload any, t
if err != nil { if err != nil {
return fmt.Errorf("read response: %w", err) return fmt.Errorf("read response: %w", err)
} }
// log.Println("Dojo payment result raw:", string(responseBody))
if resp.StatusCode < http.StatusOK || if resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices { resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("Dojo returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody))) return &httpResponseError{
StatusCode: resp.StatusCode,
Body: strings.TrimSpace(string(responseBody)),
}
} }
if target == nil || len(responseBody) == 0 { if target == nil || len(responseBody) == 0 {

View File

@ -0,0 +1,199 @@
package dojo
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync/atomic"
"testing"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
)
const terminalUnavailableBody = `{"detail":"the terminal is either offline or currently in use","errors":{},"status":409,"title":"terminal unavailable","traceId":"trace-secret","type":"https://docs.dojo.tech/problems/terminal-unavailable"}`
func TestDoJSONReturnsStructuredHTTPResponseError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(" " + terminalUnavailableBody + " "))
}))
defer server.Close()
client := newDojoTestClient(t, server)
err := client.doJSON(context.Background(), http.MethodPost, "/payment-intents", nil, false, nil)
var responseErr *httpResponseError
if !errors.As(err, &responseErr) {
t.Fatalf("error = %T %v, want *httpResponseError", err, err)
}
if responseErr.StatusCode != http.StatusConflict {
t.Fatalf("StatusCode = %d, want %d", responseErr.StatusCode, http.StatusConflict)
}
if responseErr.Body != terminalUnavailableBody {
t.Fatalf("Body = %q, want retained trimmed response", responseErr.Body)
}
}
func TestSaleMapsCreateTerminalSessionConflictToTerminalUnavailable(t *testing.T) {
var paymentIntentRequests atomic.Int32
var terminalSessionRequests atomic.Int32
var unexpectedRequests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/payment-intents":
paymentIntentRequests.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintln(w, `{"id":"intent-123"}`)
case r.Method == http.MethodPost && r.URL.Path == "/terminal-sessions":
terminalSessionRequests.Add(1)
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusConflict)
_, _ = fmt.Fprintln(w, terminalUnavailableBody)
default:
unexpectedRequests.Add(1)
http.Error(w, "unexpected request", http.StatusInternalServerError)
}
}))
defer server.Close()
client := newDojoTestClient(t, server)
request := paymentsvc.SaleRequest{
RequestID: "REQ-123",
Reference: "BOOKING-123",
Amount: 10852,
Currency: "GBP",
}
var statuses []string
result, err := client.Sale(context.Background(), request, func(update paymentsvc.StatusUpdate) {
statuses = append(statuses, update.Code)
})
if err != nil {
t.Fatal(err)
}
if result == nil {
t.Fatal("Sale returned nil result")
}
if result.Success || result.Status == "APPROVED" {
t.Fatalf("result was accidentally successful: %+v", result)
}
if result.Status != dojoTerminalUnavailableStatus || result.ErrorMessage != dojoTerminalUnavailableMessage {
t.Fatalf("terminal-unavailable result = %+v", result)
}
if result.RequestID != request.RequestID ||
result.Operation != "SALE" ||
result.Amount != request.Amount ||
result.Currency != request.Currency ||
result.DeviceUsed != "terminal-1" ||
result.DeviceType != "Dojo Terminal" {
t.Fatalf("base result fields were not retained: %+v", result)
}
if result.TransactionID != "" || result.CustomerReceipt != "" || result.MerchantReceipt != "" {
t.Fatalf("terminal-unavailable result invented transaction data: %+v", result)
}
if !reflect.DeepEqual(statuses, []string{paymentstatus.Starting, paymentstatus.TerminalUnavailable}) {
t.Fatalf("statuses = %#v", statuses)
}
if paymentIntentRequests.Load() != 1 || terminalSessionRequests.Load() != 1 || unexpectedRequests.Load() != 0 {
t.Fatalf(
"requests: intent=%d terminal=%d unexpected=%d",
paymentIntentRequests.Load(),
terminalSessionRequests.Load(),
unexpectedRequests.Load(),
)
}
resultText := fmt.Sprintf("%+v", result)
for _, forbidden := range []string{"offline or currently in use", "trace-secret", "docs.dojo.tech", terminalUnavailableBody} {
if strings.Contains(resultText, forbidden) {
t.Fatalf("result exposed %q: %s", forbidden, resultText)
}
}
}
func TestSaleDoesNotClassifyPaymentIntentConflictAsTerminalUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/payment-intents" {
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusConflict)
_, _ = fmt.Fprintln(w, terminalUnavailableBody)
}))
defer server.Close()
client := newDojoTestClient(t, server)
var statuses []string
result, err := client.Sale(context.Background(), paymentsvc.SaleRequest{
RequestID: "REQ-123",
Amount: 10852,
Currency: "GBP",
}, func(update paymentsvc.StatusUpdate) {
statuses = append(statuses, update.Code)
})
if result != nil || err == nil {
t.Fatalf("Sale result/error = %+v/%v, want nil generic error", result, err)
}
var responseErr *httpResponseError
if !errors.As(err, &responseErr) || responseErr.StatusCode != http.StatusConflict {
t.Fatalf("error = %T %v, want wrapped HTTP 409", err, err)
}
if !reflect.DeepEqual(statuses, []string{paymentstatus.Starting}) {
t.Fatalf("statuses = %#v, want only PAYMENT_STARTING", statuses)
}
}
func TestSaleRetainsGenericCreateTerminalSessionErrors(t *testing.T) {
for _, statusCode := range []int{http.StatusBadRequest, http.StatusBadGateway} {
t.Run(http.StatusText(statusCode), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/payment-intents":
_, _ = fmt.Fprintln(w, `{"id":"intent-123"}`)
case "/terminal-sessions":
w.WriteHeader(statusCode)
_, _ = fmt.Fprintln(w, `{"detail":"provider failure"}`)
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
client := newDojoTestClient(t, server)
result, err := client.Sale(context.Background(), paymentsvc.SaleRequest{
RequestID: "REQ-123",
Amount: 10852,
Currency: "GBP",
}, nil)
if result != nil || err == nil {
t.Fatalf("Sale result/error = %+v/%v, want nil generic error", result, err)
}
var responseErr *httpResponseError
if !errors.As(err, &responseErr) || responseErr.StatusCode != statusCode {
t.Fatalf("error = %T %v, want wrapped HTTP %d", err, err, statusCode)
}
})
}
}
func newDojoTestClient(t *testing.T, server *httptest.Server) *Client {
t.Helper()
client, err := NewClient(Config{
BaseURL: server.URL,
APIKey: "test-api-key",
SoftwareHouseID: "software-house-1",
TerminalID: "terminal-1",
})
if err != nil {
t.Fatal(err)
}
client.httpClient = server.Client()
return client
}

View File

@ -298,16 +298,16 @@ func setPaymentCORS(w http.ResponseWriter) {
} }
func buildPaymentSuccessURL(result *paymentsvc.Result) string { func buildPaymentSuccessURL(result *paymentsvc.Result) string {
txnReference := result.ReferenceNumber // txnReference := result.ReferenceNumber
if txnReference == "" { // if txnReference == "" {
txnReference = result.TransactionID // txnReference = result.TransactionID
} // }
q := url.Values{} q := url.Values{}
q.Set("CardNumber", hex.EncodeToString([]byte(result.CardNumber))) q.Set("CardNumber", hex.EncodeToString([]byte(result.CardNumber)))
q.Set("CardType", hex.EncodeToString([]byte(result.CardType))) q.Set("CardType", hex.EncodeToString([]byte(result.CardType)))
q.Set("ExpiryDate", hex.EncodeToString([]byte(result.ExpiryDate))) q.Set("ExpiryDate", hex.EncodeToString([]byte(result.ExpiryDate)))
q.Set("TxnReference", txnReference) q.Set("TxnReference", result.RequestID)
q.Set("CardHash", hex.EncodeToString([]byte(result.CardHash))) q.Set("CardHash", hex.EncodeToString([]byte(result.CardHash)))
q.Set("CardReference", hex.EncodeToString([]byte(result.CardReference))) q.Set("CardReference", hex.EncodeToString([]byte(result.CardReference)))

View File

@ -0,0 +1,162 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
)
type paymentProviderFunc func(
context.Context,
paymentsvc.SaleRequest,
paymentsvc.StatusHandler,
) (*paymentsvc.Result, error)
func (f paymentProviderFunc) Sale(
ctx context.Context,
request paymentsvc.SaleRequest,
onStatus paymentsvc.StatusHandler,
) (*paymentsvc.Result, error) {
return f(ctx, request, onStatus)
}
func TestSalePaymentStreamsTerminalUnavailableAsAuthoritativeUnsuccessfulResult(t *testing.T) {
provider := paymentProviderFunc(func(
_ context.Context,
request paymentsvc.SaleRequest,
onStatus paymentsvc.StatusHandler,
) (*paymentsvc.Result, error) {
onStatus(paymentsvc.StatusUpdate{Code: paymentstatus.Starting})
onStatus(paymentsvc.StatusUpdate{Code: paymentstatus.TerminalUnavailable})
return &paymentsvc.Result{
RequestID: request.RequestID,
Operation: "SALE",
Status: "TERMINAL_UNAVAILABLE",
ErrorMessage: "Payment terminal is unavailable",
Amount: request.Amount,
Currency: request.Currency,
DeviceUsed: "terminal-1",
DeviceType: "Dojo Terminal",
}, nil
})
recorder := performSalePaymentRequest(t, provider)
if recorder.Code != http.StatusOK {
t.Fatalf("outer status = %d, want %d", recorder.Code, http.StatusOK)
}
messages := decodePaymentStream(t, recorder.Body)
if len(messages) != 3 {
t.Fatalf("stream messages = %#v, want two statuses and one result", messages)
}
if messages[0].Type != "status" || messages[0].Code != paymentstatus.Starting ||
messages[1].Type != "status" || messages[1].Code != paymentstatus.TerminalUnavailable {
t.Fatalf("status frames = %#v", messages[:2])
}
final := messages[2]
if final.Type != "result" || final.Response == nil {
t.Fatalf("final frame = %#v", final)
}
if final.Response.Status.Code != http.StatusOK {
t.Fatalf("nested status = %d, want %d", final.Response.Status.Code, http.StatusOK)
}
resultURL, err := url.Parse(final.Response.Data)
if err != nil {
t.Fatal(err)
}
if resultURL.Path != types.CheckinUnsuccessfulEndpoint {
t.Fatalf("result path = %q, want %q", resultURL.Path, types.CheckinUnsuccessfulEndpoint)
}
if got := resultURL.Query().Get("Description"); got != "Payment terminal is unavailable" {
t.Fatalf("Description = %q", got)
}
body := recorder.Body.String()
for _, forbidden := range []string{
"Dojo returned HTTP 409",
"offline or currently in use",
"traceId",
"docs.dojo.tech",
} {
if strings.Contains(body, forbidden) {
t.Fatalf("stream exposed %q: %s", forbidden, body)
}
}
}
func TestSalePaymentRetainsNestedBadGatewayForProviderErrorsAfterStreamingStarts(t *testing.T) {
provider := paymentProviderFunc(func(
_ context.Context,
_ paymentsvc.SaleRequest,
onStatus paymentsvc.StatusHandler,
) (*paymentsvc.Result, error) {
onStatus(paymentsvc.StatusUpdate{Code: paymentstatus.Starting})
return nil, errors.New("provider failed")
})
recorder := performSalePaymentRequest(t, provider)
if recorder.Code != http.StatusOK {
t.Fatalf("outer status = %d, want streaming status %d", recorder.Code, http.StatusOK)
}
messages := decodePaymentStream(t, recorder.Body)
if len(messages) != 2 || messages[1].Type != "result" || messages[1].Response == nil {
t.Fatalf("stream messages = %#v", messages)
}
if messages[1].Response.Status.Code != http.StatusBadGateway {
t.Fatalf("nested status = %d, want %d", messages[1].Response.Status.Code, http.StatusBadGateway)
}
resultURL, err := url.Parse(messages[1].Response.Data)
if err != nil {
t.Fatal(err)
}
if resultURL.Path != types.CheckinUnsuccessfulEndpoint {
t.Fatalf("result path = %q, want %q", resultURL.Path, types.CheckinUnsuccessfulEndpoint)
}
}
func performSalePaymentRequest(t *testing.T, provider paymentsvc.Provider) *httptest.ResponseRecorder {
t.Helper()
app := &App{
paymentService: paymentsvc.NewService(provider),
cfg: &config.ConfigRec{
Hotel: "HOTEL",
Kiosk: 7,
TimeoutSeconds: 5,
},
}
request := httptest.NewRequest(
http.MethodPost,
"/api/payment/sale",
strings.NewReader(`{"reference":"BOOKING-123","amount":10852,"currency":"GBP"}`),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
app.salePayment(recorder, request)
return recorder
}
func decodePaymentStream(t *testing.T, body io.Reader) []paymentStreamMessage {
t.Helper()
decoder := json.NewDecoder(body)
var messages []paymentStreamMessage
for {
var message paymentStreamMessage
if err := decoder.Decode(&message); err != nil {
if errors.Is(err, io.EOF) {
return messages
}
t.Fatal(err)
}
messages = append(messages, message)
}
}

View File

@ -152,6 +152,7 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest, onStatus pay
case types.MesTypePaymentResult: case types.MesTypePaymentResult:
var result PaymentResultEnvelope var result PaymentResultEnvelope
// log.Println("PayBridge payment result raw:", string(raw))
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)
} }

View File

@ -34,6 +34,7 @@ const (
VoidFailed = "PAYMENT_VOID_FAILED" VoidFailed = "PAYMENT_VOID_FAILED"
LimitValidationError = "PAYMENT_LIMIT_VALIDATION_ERROR" LimitValidationError = "PAYMENT_LIMIT_VALIDATION_ERROR"
Error = "PAYMENT_ERROR" Error = "PAYMENT_ERROR"
TerminalUnavailable = "PAYMENT_TERMINAL_UNAVAILABLE"
DojoNotificationPrefix = "PAYMENT_DOJO_NOTIFICATION_" DojoNotificationPrefix = "PAYMENT_DOJO_NOTIFICATION_"
DojoStatusPrefix = "PAYMENT_DOJO_STATUS_" DojoStatusPrefix = "PAYMENT_DOJO_STATUS_"

View File

@ -42,6 +42,7 @@ func TestFixedStatusValues(t *testing.T) {
{"VoidFailed", paymentstatus.VoidFailed, "PAYMENT_VOID_FAILED"}, {"VoidFailed", paymentstatus.VoidFailed, "PAYMENT_VOID_FAILED"},
{"LimitValidationError", paymentstatus.LimitValidationError, "PAYMENT_LIMIT_VALIDATION_ERROR"}, {"LimitValidationError", paymentstatus.LimitValidationError, "PAYMENT_LIMIT_VALIDATION_ERROR"},
{"Error", paymentstatus.Error, "PAYMENT_ERROR"}, {"Error", paymentstatus.Error, "PAYMENT_ERROR"},
{"TerminalUnavailable", paymentstatus.TerminalUnavailable, "PAYMENT_TERMINAL_UNAVAILABLE"},
} }
seen := make(map[string]string, len(statuses)) seen := make(map[string]string, len(statuses))

View File

@ -2,7 +2,10 @@
builtVersion is a const in main.go builtVersion is a const in main.go
#### 1.3.3 - 21 July 2026 #### v1.3.4 - 23 July 2026
added support for Dojo terminal-unavailable responses
#### v1.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 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