327 lines
9.0 KiB
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:])
|
|
}
|