added support for Dojo terminal-unavailable responses
This commit is contained in:
parent
478d260ebe
commit
fbf652720c
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
199
internal/dojo/client_test.go
Normal file
199
internal/dojo/client_test.go
Normal 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
|
||||||
|
}
|
||||||
@ -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)))
|
||||||
|
|
||||||
|
|||||||
162
internal/handlers/payment_handlers_test.go
Normal file
162
internal/handlers/payment_handlers_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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_"
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user