hardlink/internal/dojo/client.go

327 lines
9.0 KiB
Go

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:])
}