package dojo import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "sort" "strings" "time" "gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc" "gitea.futuresens.co.uk/futuresens/hardlink/internal/types" "gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus" log "github.com/sirupsen/logrus" ) type Config struct { BaseURL string APIKey string SoftwareHouseID string Version string TerminalID string } type Client struct { baseURL string apiKey string version string softwareHouseID string terminalID string 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 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) { if cfg.BaseURL == "" { return nil, fmt.Errorf("dojo base_url is required") } if cfg.APIKey == "" { return nil, fmt.Errorf("dojo api_key is required") } if cfg.Version == "" { cfg.Version = "2026-02-27" } if cfg.SoftwareHouseID == "" { return nil, fmt.Errorf("dojo software_house_id is required") } if cfg.TerminalID == "" { return nil, fmt.Errorf("dojo terminal_id is required") } return &Client{ baseURL: strings.TrimRight(cfg.BaseURL, "/"), apiKey: cfg.APIKey, version: cfg.Version, softwareHouseID: cfg.SoftwareHouseID, terminalID: cfg.TerminalID, httpClient: &http.Client{Timeout: 30 * time.Second}, }, nil } func (c *Client) Sale( ctx context.Context, req paymentsvc.SaleRequest, onStatus paymentsvc.StatusHandler, ) (*paymentsvc.Result, error) { if req.Currency == "" { req.Currency = "GBP" } sendPaymentStatus(onStatus, paymentstatus.Starting) intent, err := c.createPaymentIntent(ctx, req) if err != nil { return nil, err } 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 } session, err = c.waitForTerminalSession(ctx, session.ID, onStatus) if err != nil { return nil, err } switch strings.ToLower(session.Status) { case types.ResultCaptured, types.ResultSignatureAccepted: intent, err = c.getPaymentIntent(ctx, intent.ID) if err != nil { return nil, err } if strings.ToLower(intent.Status) != types.ResultCaptured { return nil, fmt.Errorf("Dojo terminal session is %s but payment intent is %s", session.Status, intent.Status) } result := c.mapCapturedResult(req, intent) result.CustomerReceipt = receiptToText(session.Receipt) return result, nil case types.ResultCancelled, types.ResultCanceled, types.ResultDeclined, types.ResultExpired, types.ResultSignatureRejected: return c.mapTerminalFailure(req, session), nil case types.ResultSignatureRequired: result := c.baseResult(req, session.PaymentDetails) result.Status = "SIGNATURE_VERIFICATION_REQUIRED" result.ErrorMessage = "Dojo signature verification is required but is not supported" return result, nil default: return nil, fmt.Errorf("unexpected final Dojo terminal session status %q", session.Status) } } func (c *Client) createPaymentIntent(ctx context.Context, req paymentsvc.SaleRequest) (*paymentIntentResponse, error) { payload := createPaymentIntentRequest{ Amount: money{ Value: req.Amount, CurrencyCode: req.Currency, }, Reference: dojoReference(req), CaptureMode: "Auto", } var response paymentIntentResponse if err := c.doJSON(ctx, http.MethodPost, "/payment-intents", payload, false, &response); err != nil { return nil, fmt.Errorf("create Dojo payment intent: %w", err) } if response.ID == "" { return nil, fmt.Errorf("create Dojo payment intent: response did not contain id") } return &response, nil } func (c *Client) createTerminalSession(ctx context.Context, paymentIntentID string) (*terminalSessionResponse, error) { payload := createTerminalSessionRequest{ TerminalID: c.terminalID, Details: terminalSessionDetails{ SessionType: "Sale", Sale: terminalSessionSale{ PaymentIntentID: paymentIntentID, }, }, } var response terminalSessionResponse if err := c.doJSON(ctx, http.MethodPost, "/terminal-sessions", payload, true, &response); err != nil { return nil, fmt.Errorf("create Dojo terminal session: %w", err) } if response.ID == "" { return nil, fmt.Errorf("create Dojo terminal session: response did not contain id") } return &response, nil } func (c *Client) waitForTerminalSession( ctx context.Context, terminalSessionID string, onStatus paymentsvc.StatusHandler, ) (*terminalSessionResponse, error) { ticker := time.NewTicker(time.Second) defer ticker.Stop() notificationCount := 0 statusCount := 0 lastSessionStatus := "" signatureRejected := false for { session, err := c.getTerminalSession(ctx, terminalSessionID) if err != nil { return nil, err } log.Println("session status:", session.Status) emitNewTerminalSessionEvents( 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: if !signatureRejected { sendPaymentStatus( onStatus, paymentstatus.SignatureRejecting, ) rejectedSession, err := c.rejectSignature( ctx, terminalSessionID, ) if err != nil { return nil, err } signatureRejected = true if rejectedSession != nil { rejectedStatus := strings.ToLower( rejectedSession.Status, ) 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 } } } case types.ResultCaptured, types.ResultCancelled, types.ResultCanceled, types.ResultDeclined, types.ResultExpired, types.ResultSignatureAccepted, types.ResultSignatureRejected: return &session.terminalSessionResponse, nil default: return nil, fmt.Errorf( "unexpected Dojo terminal session status %q", session.Status, ) } select { case <-ctx.Done(): return nil, ctx.Err() 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) { payload := signatureVerificationRequest{ Accepted: false, } var response terminalSessionResponse path := "/terminal-sessions/" + url.PathEscape(terminalSessionID) + "/signature" if err := c.doJSON(ctx, http.MethodPut, path, payload, true, &response); err != nil { return nil, fmt.Errorf("reject Dojo signature verification: %w", err) } return &response, nil } func (c *Client) getTerminalSession( ctx context.Context, terminalSessionID string, ) (*terminalSessionWithUpdates, error) { var response terminalSessionWithUpdates path := "/terminal-sessions/" + url.PathEscape(terminalSessionID) if err := c.doJSON(ctx, http.MethodGet, path, nil, true, &response); err != nil { return nil, fmt.Errorf("get Dojo terminal session: %w", err) } return &response, nil } func (c *Client) getPaymentIntent(ctx context.Context, paymentIntentID string) (*paymentIntentResponse, error) { var response paymentIntentResponse path := "/payment-intents/" + url.PathEscape(paymentIntentID) + "?returnCanceled=true" if err := c.doJSON(ctx, http.MethodGet, path, nil, false, &response); err != nil { return nil, fmt.Errorf("get Dojo payment intent: %w", err) } return &response, nil } func (c *Client) mapCapturedResult(req paymentsvc.SaleRequest, intent *paymentIntentResponse) *paymentsvc.Result { result := c.baseResult(req, intent.PaymentDetails) result.Success = true result.Status = "APPROVED" if intent.Amount.Value != 0 { result.Amount = intent.Amount.Value } if intent.Amount.CurrencyCode != "" { result.Currency = intent.Amount.CurrencyCode } if result.Message == "" { result.Message = "Payment approved" } return result } func (c *Client) mapTerminalFailure(req paymentsvc.SaleRequest, session *terminalSessionResponse) *paymentsvc.Result { result := c.baseResult(req, session.PaymentDetails) result.Status = strings.ToUpper(session.Status) result.ErrorMessage = "Dojo terminal session ended with status " + session.Status result.CustomerReceipt = receiptToText(session.Receipt) return result } func (c *Client) baseResult(req paymentsvc.SaleRequest, details *paymentDetails) *paymentsvc.Result { result := &paymentsvc.Result{ RequestID: req.RequestID, Operation: "SALE", Amount: req.Amount, Currency: req.Currency, DeviceUsed: c.terminalID, DeviceType: "Dojo Terminal", } if details == nil { return result } result.TransactionID = details.TransactionID result.ReferenceNumber = req.RequestID result.AuthCode = details.AuthCode result.Message = details.Message result.CardNumber = details.Card.CardNumber result.CardType = details.Card.CardType result.ExpiryDate = details.Card.ExpiryDate result.LastFourDigits = details.Card.Last4PAN if result.LastFourDigits == "" { result.LastFourDigits = lastFour(details.Card.CardNumber) } return result } func (c *Client) doJSON(ctx context.Context, method, path string, payload any, terminalRequest bool, target any) error { var body io.Reader if payload != nil { encoded, err := json.Marshal(payload) if err != nil { return fmt.Errorf("encode request: %w", err) } body = bytes.NewReader(encoded) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Basic "+c.apiKey) req.Header.Set("Version", c.version) req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } if terminalRequest { req.Header.Set("software-house-id", c.softwareHouseID) } resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("send request: %w", err) } defer resp.Body.Close() responseBody, err := io.ReadAll(resp.Body) 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 &httpResponseError{ StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(responseBody)), } } if target == nil || len(responseBody) == 0 { return nil } if err := json.Unmarshal(responseBody, target); err != nil { return fmt.Errorf("decode response: %w", err) } return nil } func dojoReference(req paymentsvc.SaleRequest) string { reference := req.RequestID if reference == "" { reference = req.Reference } runes := []rune(reference) if len(runes) > 60 { runes = runes[:60] } return string(runes) } func receiptToText(receipt *receipt) string { if receipt == nil { return "" } var builder strings.Builder for _, line := range receipt.Lines { if line.LineType != "Text" || line.Text == nil { continue } builder.WriteString(normalizeReceiptText(line.Text.Value)) builder.WriteByte('\n') } return builder.String() } func lastFour(cardNumber string) string { digits := make([]byte, 0, len(cardNumber)) for i := 0; i < len(cardNumber); i++ { if cardNumber[i] >= '0' && cardNumber[i] <= '9' { digits = append(digits, cardNumber[i]) } } if len(digits) <= 4 { return string(digits) } return string(digits[len(digits)-4:]) } func normalizeReceiptText(value string) string { replacer := strings.NewReplacer( "£", "GBP ", "€", "EUR ", "$", "USD ", ) return replacer.Replace(value) }