hardlink/internal/dojo/client.go

642 lines
16 KiB
Go

package dojo
import (
"bytes"
"context"
"encoding/json"
"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
}
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 {
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,
&notificationCount,
&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)
}
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 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)
}