package dojo import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc" "gitea.futuresens.co.uk/futuresens/hardlink/internal/types" ) 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 } 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) (*paymentsvc.Result, error) { if req.Currency == "" { req.Currency = "GBP" } intent, err := c.createPaymentIntent(ctx, req) if err != nil { return nil, err } session, err := c.createTerminalSession(ctx, intent.ID) if err != nil { return nil, err } session, err = c.waitForTerminalSession(ctx, session.ID) 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) } return c.mapCapturedResult(req, intent), 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) (*terminalSessionResponse, error) { ticker := time.NewTicker(time.Second) defer ticker.Stop() for { session, err := c.getTerminalSession(ctx, terminalSessionID) if err != nil { return nil, err } switch strings.ToLower(session.Status) { case types.ResultInitiateRequested, types.ResultInitiated, types.ResultAuthorized, types.ResultCancelRequested: // Still processing. For this integration payment intents use captureMode Auto, // so Authorized is expected to continue to Captured. case types.ResultCaptured, types.ResultCancelled, types.ResultCanceled, types.ResultDeclined, types.ResultExpired, types.ResultSignatureRequired, types.ResultSignatureAccepted, types.ResultSignatureRejected: return session, 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 (c *Client) getTerminalSession(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) { var response terminalSessionResponse 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 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.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) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return fmt.Errorf("Dojo returned HTTP %d: %s", resp.StatusCode, 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 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:]) }