diff --git a/cmd/hardlink/main.go b/cmd/hardlink/main.go index 4407fc9..b760081 100644 --- a/cmd/hardlink/main.go +++ b/cmd/hardlink/main.go @@ -34,7 +34,7 @@ import ( ) const ( - buildVersion = "1.3.3" + buildVersion = "v1.3.4" serviceName = "hardlink" pollingFrequency = 8 * time.Second ) diff --git a/internal/dojo/client.go b/internal/dojo/client.go index 9a7f161..81785a3 100644 --- a/internal/dojo/client.go +++ b/internal/dojo/client.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -35,6 +36,20 @@ type Client struct { 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 { terminalSessionResponse @@ -103,6 +118,21 @@ func (c *Client) Sale( session, err := c.createTerminalSession(ctx, intent.ID) 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 } @@ -569,10 +599,13 @@ func (c *Client) doJSON(ctx context.Context, method, path string, payload any, t if err != nil { return fmt.Errorf("read response: %w", err) } - + // log.Println("Dojo payment result raw:", string(responseBody)) if resp.StatusCode < http.StatusOK || 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 { diff --git a/internal/dojo/client_test.go b/internal/dojo/client_test.go new file mode 100644 index 0000000..b21ca2f --- /dev/null +++ b/internal/dojo/client_test.go @@ -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 +} diff --git a/internal/handlers/payment_handlers.go b/internal/handlers/payment_handlers.go index 583bda2..0580051 100644 --- a/internal/handlers/payment_handlers.go +++ b/internal/handlers/payment_handlers.go @@ -298,16 +298,16 @@ func setPaymentCORS(w http.ResponseWriter) { } func buildPaymentSuccessURL(result *paymentsvc.Result) string { - txnReference := result.ReferenceNumber - if txnReference == "" { - txnReference = result.TransactionID - } + // txnReference := result.ReferenceNumber + // if txnReference == "" { + // txnReference = result.TransactionID + // } q := url.Values{} q.Set("CardNumber", hex.EncodeToString([]byte(result.CardNumber))) q.Set("CardType", hex.EncodeToString([]byte(result.CardType))) 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("CardReference", hex.EncodeToString([]byte(result.CardReference))) diff --git a/internal/handlers/payment_handlers_test.go b/internal/handlers/payment_handlers_test.go new file mode 100644 index 0000000..3b9186e --- /dev/null +++ b/internal/handlers/payment_handlers_test.go @@ -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) + } +} diff --git a/internal/paybridge/client.go b/internal/paybridge/client.go index 14caca2..aacc523 100644 --- a/internal/paybridge/client.go +++ b/internal/paybridge/client.go @@ -152,6 +152,7 @@ func (c *Client) doPayment(ctx context.Context, req PaymentRequest, onStatus pay 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) } diff --git a/paymentstatus/status.go b/paymentstatus/status.go index 440b528..ba48c3e 100644 --- a/paymentstatus/status.go +++ b/paymentstatus/status.go @@ -34,6 +34,7 @@ const ( VoidFailed = "PAYMENT_VOID_FAILED" LimitValidationError = "PAYMENT_LIMIT_VALIDATION_ERROR" Error = "PAYMENT_ERROR" + TerminalUnavailable = "PAYMENT_TERMINAL_UNAVAILABLE" DojoNotificationPrefix = "PAYMENT_DOJO_NOTIFICATION_" DojoStatusPrefix = "PAYMENT_DOJO_STATUS_" diff --git a/paymentstatus/status_test.go b/paymentstatus/status_test.go index 3489cb8..19b0112 100644 --- a/paymentstatus/status_test.go +++ b/paymentstatus/status_test.go @@ -42,6 +42,7 @@ func TestFixedStatusValues(t *testing.T) { {"VoidFailed", paymentstatus.VoidFailed, "PAYMENT_VOID_FAILED"}, {"LimitValidationError", paymentstatus.LimitValidationError, "PAYMENT_LIMIT_VALIDATION_ERROR"}, {"Error", paymentstatus.Error, "PAYMENT_ERROR"}, + {"TerminalUnavailable", paymentstatus.TerminalUnavailable, "PAYMENT_TERMINAL_UNAVAILABLE"}, } seen := make(map[string]string, len(statuses)) diff --git a/release notes.md b/release notes.md index 40831b2..ced29af 100644 --- a/release notes.md +++ b/release notes.md @@ -2,7 +2,10 @@ 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 #### 1.3.2 - 20 July 2026