642 lines
16 KiB
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,
|
|
¬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)
|
|
}
|
|
|
|
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)
|
|
}
|