add pluggable PayBridge and Dojo payment flow with CMS credentials

This commit is contained in:
yurii 2026-07-03 16:19:13 +01:00
parent f5ff78e60e
commit 61d9c03837
26 changed files with 1526 additions and 75 deletions

2
.gitignore vendored
View File

@ -42,6 +42,6 @@ _cgo_export.*
_testmain.go _testmain.go
*.exe *.exe*
*.test *.test
*.prof *.prof

View File

@ -16,20 +16,25 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/bootstrap" "gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/cms"
"gitea.futuresens.co.uk/futuresens/hardlink/config" "gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/bootstrap"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/dispenser" "gitea.futuresens.co.uk/futuresens/hardlink/internal/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/dojo"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers" "gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/handlers" "gitea.futuresens.co.uk/futuresens/hardlink/internal/handlers"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver" "gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/logging" "gitea.futuresens.co.uk/futuresens/hardlink/internal/logging"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail" "gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/payment" "gitea.futuresens.co.uk/futuresens/hardlink/internal/paybridge"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/printer" "gitea.futuresens.co.uk/futuresens/hardlink/internal/printer"
) )
const ( const (
buildVersion = "1.2.10" buildVersion = "1.3.0"
serviceName = "hardlink" serviceName = "hardlink"
pollingFrequency = 8 * time.Second pollingFrequency = 8 * time.Second
) )
@ -69,8 +74,7 @@ func main() {
dispPort, err = dispenser.InitializeDispenser() dispPort, err = dispenser.InitializeDispenser()
if err != nil { if err != nil {
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Dispenser Initialization Error", fmt.Sprintf("Failed to initialize dispenser: %v", err)) errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Dispenser Initialization Error", fmt.Errorf("failed to initialize dispenser: %v", err))
errorhandlers.FatalError(err)
} }
defer dispPort.Close() defer dispPort.Close()
@ -83,8 +87,7 @@ func main() {
cardWellStatus, err = disp.DispenserPrepare(ctx) cardWellStatus, err = disp.DispenserPrepare(ctx)
if err != nil { if err != nil {
err = fmt.Errorf("%s; wrong dispenser address: %s", err, cfg.DispenserAdrr) err = fmt.Errorf("%s; wrong dispenser address: %s", err, cfg.DispenserAdrr)
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Dispenser Preparation Error", err.Error()) errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Dispenser Preparation Error", err)
errorhandlers.FatalError(err)
} }
fmt.Println(cardWellStatus) fmt.Println(cardWellStatus)
} }
@ -114,31 +117,103 @@ func main() {
defer database.Close() defer database.Close()
} }
// Create App and wire routes
app := handlers.NewApp(disp, cfg.LockType, cfg.EncoderAddress, cardWellStatus, database, &cfg)
if cfg.IsPayment { if cfg.IsPayment {
fmt.Println("Payment processing is enabled") fmt.Println("Payment processing is enabled")
log.Info("Payment processing is enabled") log.Info("Payment processing is enabled")
var provider paymentsvc.Provider
reservationSystem, err := cms.ReadHotel(cfg.Hotel, cfg.CMSBaseURL)
if err != nil {
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Failed to read hotel from CMS", fmt.Errorf("failed to read hotel from CMS: %v", err))
}
paymentProvider := reservationSystem.PaymentSystem
if paymentProvider == 0 {
paymentProvider = cms.PaymentSystemIndex(cfg.PaymentProvider)
}
switch paymentProvider {
case cmstypes.PaySystemCreditCall:
// CreditCall keeps using the existing /takepayment and /takepreauth endpoints.
startChipDnaClient() startChipDnaClient()
// check ChipDNA and PDQ status and log any errors, but continue running even if it fails
go func() { go func() {
time.Sleep(30 * time.Second) // give ChipDNA client a moment to start time.Sleep(30 * time.Second)
pdqstatus, err := payment.ReadPdqStatus(cfg.Hotel, cfg.Kiosk)
pdqStatus, err := creditcall.ReadPdqStatus(cfg.Hotel, cfg.Kiosk)
if err != nil { if err != nil {
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "PDQ Status Read Error", err.Error()) mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "PDQ Status Read Error", err.Error())
} else { return
fmt.Printf("\nPDQ availabile: %v\n", pdqstatus.IsAvailable)
log.Infof("PDQ availabile: %v", pdqstatus.IsAvailable)
} }
fmt.Printf("\nPDQ available: %v\n", pdqStatus.IsAvailable)
log.Infof("PDQ available: %v", pdqStatus.IsAvailable)
}() }()
log.Info("CreditCall payment provider enabled")
fmt.Println("CreditCall payment provider enabled")
case cmstypes.PaySystemPayBridge:
if reservationSystem.PaymentGatewayURL == "" {
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Payment Gateway URL not found", fmt.Errorf("payment provider paybridge requires paybridge.websocket_url"))
}
if reservationSystem.PaymentGatewayAPIKeyWeb == "" {
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Payment Gateway API Key not found", fmt.Errorf("payment provider paybridge requires paybridge.api_key"))
}
provider = paybridge.NewClient(
reservationSystem.PaymentGatewayURL,
reservationSystem.PaymentGatewayAPIKeyWeb,
cfg.TimeoutSeconds,
)
case cmstypes.PaySystemDojo:
var TerminalID string
for _, item := range reservationSystem.PDQs {
if item.Kiosk != cfg.Kiosk {
continue
}
TerminalID = item.Serial
break
}
dojoClient, err := dojo.NewClient(dojo.Config{
BaseURL: reservationSystem.PaymentGatewayURL,
APIKey: reservationSystem.PaymentGatewayAPIKeyWeb,
SoftwareHouseID: reservationSystem.PaymentGatewaySiteIDEPOS,
Version: reservationSystem.PaymentGatewayPasswordEPOS,
TerminalID: TerminalID,
})
if err != nil {
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Failed to create Dojo client", err)
}
provider = dojoClient
case cmstypes.PaySystemNone:
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "No payment provider selected", fmt.Errorf("payment processing is enabled, but no payment provider selected; expected creditcall, paybridge or dojo"))
default:
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Unsupported Payment Provider", fmt.Errorf(
"unsupported payment provider %q; expected creditcall, paybridge or dojo",
cfg.PaymentProvider,
))
}
// Only PayBridge and Dojo use POST /api/payment/sale.
if provider != nil {
app.SetPaymentService(paymentsvc.NewService(provider))
log.Infof("Payment provider enabled for POST /api/payment/sale: %s", cfg.PaymentProvider)
fmt.Printf("Payment provider enabled for POST /api/payment/sale: %s\n", cfg.PaymentProvider)
}
} else { } else {
fmt.Println("Payment processing is disabled") fmt.Println("Payment processing is disabled")
log.Info("Payment processing is disabled") log.Info("Payment processing is disabled")
} }
// Create App and wire routes
app := handlers.NewApp(disp, cfg.LockType, cfg.EncoderAddress, cardWellStatus, database, &cfg)
// Update cardWellStatus when dispenser status changes // Update cardWellStatus when dispenser status changes
if !cfg.TestMode && disp != nil { if !cfg.TestMode && disp != nil {
// Set initial cardWellStatus // Set initial cardWellStatus

120
cms/cms.go Normal file
View File

@ -0,0 +1,120 @@
// Package cms provides functions to read hotel records from the CMS and retrieve their reservation system configuration.
package cms
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes"
)
// ReadHotel gets the hotel record from CMS and returns its reservation system
// configuration if the record has been updated since the last read.
func ReadHotel(hotelCode, CMSBaseURL string) (cmstypes.ReservationSystemRec, error) {
var reservationSystem cmstypes.ReservationSystemRec
if hotelCode == "" {
return reservationSystem, errors.New("hotel code is empty")
}
if CMSBaseURL == "" {
return reservationSystem, errors.New("CMS base URL is empty")
}
var request cmstypes.RequestRec
request.Auth.ID = hotelCode
request.Auth.APIKey = cmstypes.APIKey
request.Auth.Hotel = hotelCode
request.Data = hotelCode
requestData, err := json.Marshal(request)
if err != nil {
return reservationSystem, fmt.Errorf(
"marshal CMS hotel request: %w",
err,
)
}
requestURL := CMSBaseURL + cmstypes.APIHotelDetails
req, err := http.NewRequest(http.MethodPost, requestURL, bytes.NewReader(requestData))
if err != nil {
return reservationSystem, fmt.Errorf(
"create CMS hotel request: %w",
err,
)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{
Timeout: 15 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return reservationSystem, fmt.Errorf(
"perform CMS hotel request: %w",
err,
)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return reservationSystem, fmt.Errorf(
"read CMS hotel response: %w",
err,
)
}
if resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices {
return reservationSystem, fmt.Errorf(
"CMS hotel request returned HTTP %s: %s",
resp.Status,
string(body),
)
}
var hotelResponse cmstypes.HotelResponseRec
if err := json.Unmarshal(body, &hotelResponse); err != nil {
return reservationSystem, fmt.Errorf(
"unmarshal CMS hotel response: %w",
err,
)
}
if hotelResponse.Status.Code != cmstypes.StatusSuccessCode {
return reservationSystem, fmt.Errorf(
"CMS hotel request failed: %s",
hotelResponse.Status.Message,
)
}
if hotelResponse.TheHotel.Updated <= -1 {
return reservationSystem, errors.New(
"hotel record not updated since last read",
)
}
return hotelResponse.TheHotel.ReservationSystem, nil
}
func PaymentSystemIndex(name string) int {
for i, paySystemName := range cmstypes.PaySystemNames {
if strings.EqualFold(paySystemName, name) {
return i
}
}
return -1
}

233
cms/cms_test.go Normal file
View File

@ -0,0 +1,233 @@
package cms
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"gitea.futuresens.co.uk/futuresens/cmstypes"
)
func TestReadHotelSuccess(t *testing.T) {
const hotelCode = "gb-test-hotel"
expected := cmstypes.ReservationSystemRec{}
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf(
"expected method %s, got %s",
http.MethodPost,
r.Method,
)
}
if r.URL.Path != cmstypes.APIHotelDetails {
t.Errorf(
"expected path %q, got %q",
cmstypes.APIHotelDetails,
r.URL.Path,
)
}
if contentType := r.Header.Get("Content-Type"); contentType != "application/json" {
t.Errorf(
"expected Content-Type application/json, got %q",
contentType,
)
}
if accept := r.Header.Get("Accept"); accept != "application/json" {
t.Errorf(
"expected Accept application/json, got %q",
accept,
)
}
var request cmstypes.RequestRec
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if request.Auth.ID != hotelCode {
t.Errorf(
"expected auth ID %q, got %q",
hotelCode,
request.Auth.ID,
)
}
if request.Auth.Hotel != hotelCode {
t.Errorf(
"expected auth hotel %q, got %q",
hotelCode,
request.Auth.Hotel,
)
}
if request.Auth.APIKey != cmstypes.APIKey {
t.Errorf(
"expected API key %q, got %q",
cmstypes.APIKey,
request.Auth.APIKey,
)
}
if request.Data != hotelCode {
t.Errorf(
"expected request data %q, got %q",
hotelCode,
request.Data,
)
}
response := cmstypes.HotelResponseRec{}
response.Status.Code = cmstypes.StatusSuccessCode
response.TheHotel.Updated = 1
response.TheHotel.ReservationSystem = expected
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
t.Errorf("encode response: %v", err)
}
},
))
defer server.Close()
got, err := ReadHotel(hotelCode, server.URL)
if err != nil {
t.Fatalf("ReadHotel returned an unexpected error: %v", err)
}
if !reflect.DeepEqual(got, expected) {
t.Errorf(
"unexpected reservation system:\ngot: %+v\nwant: %+v",
got,
expected,
)
}
}
func TestReadHotelValidation(t *testing.T) {
tests := []struct {
name string
hotelCode string
cmsBaseURL string
wantMessage string
}{
{
name: "empty hotel code",
hotelCode: "",
cmsBaseURL: "http://example.com",
wantMessage: "hotel code is empty",
},
{
name: "empty CMS base URL",
hotelCode: "gb-test-hotel",
cmsBaseURL: "",
wantMessage: "CMS base URL is empty",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := ReadHotel(test.hotelCode, test.cmsBaseURL)
if err == nil {
t.Fatal("expected an error, got nil")
}
if err.Error() != test.wantMessage {
t.Errorf(
"expected error %q, got %q",
test.wantMessage,
err.Error(),
)
}
})
}
}
func TestReadHotelNotUpdated(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
response := cmstypes.HotelResponseRec{}
response.Status.Code = cmstypes.StatusSuccessCode
response.TheHotel.Updated = -1
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
t.Errorf("encode response: %v", err)
}
},
))
defer server.Close()
_, err := ReadHotel("gb-test-hotel", server.URL)
if err == nil {
t.Fatal("expected an error, got nil")
}
const expected = "hotel record not updated since last read"
if err.Error() != expected {
t.Errorf("expected error %q, got %q", expected, err.Error())
}
}
func TestReadHotelHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
if _, err := io.WriteString(w, "CMS unavailable"); err != nil {
t.Errorf("write response: %v", err)
}
},
))
defer server.Close()
_, err := ReadHotel("gb-test-hotel", server.URL)
if err == nil {
t.Fatal("expected an error, got nil")
}
if !strings.Contains(err.Error(), "HTTP 500 Internal Server Error") {
t.Errorf("unexpected error: %v", err)
}
if !strings.Contains(err.Error(), "CMS unavailable") {
t.Errorf("expected response body in error, got: %v", err)
}
}
func TestReadHotelInvalidJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if _, err := io.WriteString(w, `{invalid JSON`); err != nil {
t.Errorf("write response: %v", err)
}
},
))
defer server.Close()
_, err := ReadHotel("gb-test-hotel", server.URL)
if err == nil {
t.Fatal("expected an error, got nil")
}
if !strings.Contains(err.Error(), "unmarshal CMS hotel response") {
t.Errorf("unexpected error: %v", err)
}
}

View File

@ -22,22 +22,25 @@ type ConfigRec struct {
DispenserAdrr string `yaml:"dispensAddr"` DispenserAdrr string `yaml:"dispensAddr"`
PrinterName string `yaml:"printerName"` PrinterName string `yaml:"printerName"`
LogDir string `yaml:"logdir"` LogDir string `yaml:"logdir"`
Dbport int `yaml:"dbport"` // Port for the database connection Dbport int `yaml:"dbport"`
Dbname string `yaml:"dbname"` // Database name for the connection Dbname string `yaml:"dbname"`
Dbuser string `yaml:"dbuser"` // User for the database connection Dbuser string `yaml:"dbuser"`
Dbpassword string `yaml:"dbpassword"` // Password for the database connection Dbpassword string `yaml:"dbpassword"`
CMSBaseURL string `yaml:"cmsurl"`
IsPayment bool `yaml:"isPayment"` IsPayment bool `yaml:"isPayment"`
TestMode bool `yaml:"testMode"` TestMode bool `yaml:"testMode"`
Hotel string `yaml:"hotel"` Hotel string `yaml:"hotel"`
Kiosk int `yaml:"kiosk"` Kiosk int `yaml:"kiosk"`
SendErrorEmails []string `yaml:"senderroremails"` SendErrorEmails []string `yaml:"senderroremails"`
PaymentProvider string `yaml:"paymentProvider"`
TimeoutSeconds int `yaml:"timeoutSeconds"`
} }
// ReadHardlinkConfig reads config.yml and applies defaults. // ReadHardlinkConfig reads config.yml and applies defaults.
func ReadHardlinkConfig() ConfigRec { func ReadHardlinkConfig() ConfigRec {
var cfg ConfigRec var cfg ConfigRec
const configName = "config.yml" const configName = "config.yml"
defaultPort := 9091 const defaultPort = 9091
sep := string(os.PathSeparator) sep := string(os.PathSeparator)
data, err := os.ReadFile(configName) data, err := os.ReadFile(configName)
@ -52,8 +55,7 @@ func ReadHardlinkConfig() ConfigRec {
} }
if cfg.LockType == "" { if cfg.LockType == "" {
err = fmt.Errorf("LockType is required in %s", configName) errorhandlers.FatalError(fmt.Errorf("LockType is required in %s", configName))
errorhandlers.FatalError(err)
} }
cfg.LockType = strings.ToLower(cfg.LockType) cfg.LockType = strings.ToLower(cfg.LockType)
@ -64,8 +66,13 @@ func ReadHardlinkConfig() ConfigRec {
} }
if cfg.Dbport <= 0 || cfg.Dbuser == "" || cfg.Dbname == "" || cfg.Dbpassword == "" { if cfg.Dbport <= 0 || cfg.Dbuser == "" || cfg.Dbname == "" || cfg.Dbpassword == "" {
err = fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName) log.Warnf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName)
log.Warnf(err.Error()) }
cfg.PaymentProvider = strings.ToLower(strings.TrimSpace(cfg.PaymentProvider))
if cfg.TimeoutSeconds <= 0 {
cfg.TimeoutSeconds = 300
} }
return cfg return cfg
@ -84,8 +91,7 @@ func ReadPreauthReleaserConfig() ConfigRec {
} }
if cfg.Dbport <= 0 || cfg.Dbuser == "" || cfg.Dbname == "" || cfg.Dbpassword == "" { if cfg.Dbport <= 0 || cfg.Dbuser == "" || cfg.Dbname == "" || cfg.Dbpassword == "" {
err = fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName) errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "PreauthReleaser Database Configuration Error", fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName))
errorhandlers.FatalError(err)
} }
if cfg.LogDir == "" { if cfg.LogDir == "" {

4
go.mod
View File

@ -3,10 +3,12 @@ module gitea.futuresens.co.uk/futuresens/hardlink
go 1.23.2 go 1.23.2
require ( require (
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190 gitea.futuresens.co.uk/futuresens/cmstypes v1.0.200
gitea.futuresens.co.uk/futuresens/logging v1.0.9 gitea.futuresens.co.uk/futuresens/logging v1.0.9
github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb
github.com/denisenkom/go-mssqldb v0.12.3 github.com/denisenkom/go-mssqldb v0.12.3
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394 github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07

8
go.sum
View File

@ -1,5 +1,5 @@
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190 h1:OxP911wT8HQqBJ20KIZcBxi898rsYHhhCkne2u45p1A= gitea.futuresens.co.uk/futuresens/cmstypes v1.0.200 h1:CRGAuhwecpOwY1CAuC038NFyw6EFulVG554HbUqfezI=
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190/go.mod h1:ABMUkdm+3VGrkuoCJsXMfPPud9GHDOwBb1NiifFqxes= gitea.futuresens.co.uk/futuresens/cmstypes v1.0.200/go.mod h1:ABMUkdm+3VGrkuoCJsXMfPPud9GHDOwBb1NiifFqxes=
gitea.futuresens.co.uk/futuresens/fscrypto v0.0.0-20221125125050-9acaffd21362 h1:MnhYo7XtsECCU+5yVMo3tZZOOSOKGkl7NpOvTAieBTo= gitea.futuresens.co.uk/futuresens/fscrypto v0.0.0-20221125125050-9acaffd21362 h1:MnhYo7XtsECCU+5yVMo3tZZOOSOKGkl7NpOvTAieBTo=
gitea.futuresens.co.uk/futuresens/fscrypto v0.0.0-20221125125050-9acaffd21362/go.mod h1:p95ouVfK4qyC20D3/k9QLsWSxD2pdweWiY6vcYi9hpM= gitea.futuresens.co.uk/futuresens/fscrypto v0.0.0-20221125125050-9acaffd21362/go.mod h1:p95ouVfK4qyC20D3/k9QLsWSxD2pdweWiY6vcYi9hpM=
gitea.futuresens.co.uk/futuresens/logging v1.0.9 h1:uvCQq/plecB0z/bUWOhFhwyYUWGPkTBZHsYNL+3RFvI= gitea.futuresens.co.uk/futuresens/logging v1.0.9 h1:uvCQq/plecB0z/bUWOhFhwyYUWGPkTBZHsYNL+3RFvI=
@ -19,6 +19,10 @@ github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZ
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394 h1:+6kiV40vfmh17TDlZG15C2uGje1/XBGT32j6xKmUkqM= github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394 h1:+6kiV40vfmh17TDlZG15C2uGje1/XBGT32j6xKmUkqM=
github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394/go.mod h1:ogN8Sxy3n5VKLhQxbtSBM3ICG/VgjXS/akQJIoDSrgA= github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394/go.mod h1:ogN8Sxy3n5VKLhQxbtSBM3ICG/VgjXS/akQJIoDSrgA=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=

View File

@ -9,7 +9,7 @@ import (
"gitea.futuresens.co.uk/futuresens/hardlink/internal/bootstrap" "gitea.futuresens.co.uk/futuresens/hardlink/internal/bootstrap"
"gitea.futuresens.co.uk/futuresens/hardlink/config" "gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/logging" "gitea.futuresens.co.uk/futuresens/hardlink/internal/logging"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/payment" "gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -32,7 +32,7 @@ func main() {
} }
defer database.Close() defer database.Close()
if err := payment.ReleasePreauthorizations(database); err != nil { if err := creditcall.ReleasePreauthorizations(database); err != nil {
log.Error(err) log.Error(err)
fmt.Println(err) fmt.Println(err)
} else { } else {

View File

@ -1,4 +1,4 @@
package payment package creditcall
import ( import (
"bytes" "bytes"

View File

@ -1,4 +1,4 @@
package payment package creditcall
import ( import (
"encoding/hex" "encoding/hex"
@ -82,7 +82,6 @@ func (r *PaymentResult) FillFromTransactionResult(trResult TransactionResultXML)
for _, e := range trResult.Entries { for _, e := range trResult.Entries {
switch e.Key { switch e.Key {
case types.ReceiptData, types.ReceiptDataMerchant: case types.ReceiptData, types.ReceiptDataMerchant:
// intentionally ignored // intentionally ignored
@ -100,7 +99,7 @@ func (r *PaymentResult) FillFromTransactionResult(trResult TransactionResultXML)
} }
} }
// BuildRedirectURL builds the redirect URL to send the guest to after payment. // BuildPaymentRedirectURL builds the redirect URL to send the guest to after payment.
func BuildPaymentRedirectURL(result map[string]string) string { func BuildPaymentRedirectURL(result map[string]string) string {
res := result[types.TransactionResult] res := result[types.TransactionResult]

View File

@ -1,4 +1,4 @@
package payment package creditcall
import ( import (
"bytes" "bytes"

326
internal/dojo/client.go Normal file
View File

@ -0,0 +1,326 @@
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:])
}

59
internal/dojo/types.go Normal file
View File

@ -0,0 +1,59 @@
package dojo
type money struct {
Value int64 `json:"value"`
CurrencyCode string `json:"currencyCode"`
}
type createPaymentIntentRequest struct {
Amount money `json:"amount"`
Reference string `json:"reference"`
CaptureMode string `json:"captureMode"`
}
type createTerminalSessionRequest struct {
TerminalID string `json:"terminalId"`
Details terminalSessionDetails `json:"details"`
}
type terminalSessionDetails struct {
SessionType string `json:"sessionType"`
Sale terminalSessionSale `json:"sale"`
}
type terminalSessionSale struct {
PaymentIntentID string `json:"paymentIntentId"`
}
type terminalSessionResponse struct {
ID string `json:"id"`
Status string `json:"status"`
TerminalID string `json:"terminalId"`
PaymentDetails *paymentDetails `json:"paymentDetails"`
}
type paymentIntentResponse struct {
ID string `json:"id"`
Status string `json:"status"`
Reference string `json:"reference"`
Amount money `json:"amount"`
PaymentDetails *paymentDetails `json:"paymentDetails"`
}
type paymentDetails struct {
TransactionID string `json:"transactionId"`
TransactionDateTime string `json:"transactionDateTime"`
Message string `json:"message"`
AuthCode string `json:"authCode"`
Card dojoCard `json:"card"`
}
type dojoCard struct {
CardNumber string `json:"cardNumber"`
CardName string `json:"cardName"`
ExpiryDate string `json:"expiryDate"`
CardType string `json:"cardType"`
CardFundingType string `json:"cardFundingType"`
EntryMode string `json:"entryMode"`
VerificationMethod string `json:"verificationMethod"`
}

View File

@ -7,6 +7,7 @@ import (
"os" "os"
"gitea.futuresens.co.uk/futuresens/cmstypes" "gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -28,3 +29,8 @@ func FatalError(err error) {
fmt.Scanln() fmt.Scanln()
os.Exit(1) os.Exit(1)
} }
func FatalErrorWithMail(hotel string, kiosk int, title string, err error) {
mail.SendEmailOnError(hotel, kiosk, title, err.Error())
FatalError(err)
}

View File

@ -13,11 +13,12 @@ import (
"gitea.futuresens.co.uk/futuresens/cmstypes" "gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/config" "gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/dispenser" "gitea.futuresens.co.uk/futuresens/hardlink/internal/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers" "gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver" "gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail" "gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/payment" "gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/printer" "gitea.futuresens.co.uk/futuresens/hardlink/internal/printer"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types" "gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/logging" "gitea.futuresens.co.uk/futuresens/logging"
@ -27,6 +28,7 @@ import (
type App struct { type App struct {
disp *dispenser.Client disp *dispenser.Client
lockserver lockserver.LockServer lockserver lockserver.LockServer
paymentService *paymentsvc.Service
isPayment bool isPayment bool
db *sql.DB db *sql.DB
cfg *config.ConfigRec cfg *config.ConfigRec
@ -50,6 +52,10 @@ func NewApp(disp *dispenser.Client, lockType, encoderAddress, cardWellStatus str
return app return app
} }
func (app *App) SetPaymentService(service *paymentsvc.Service) {
app.paymentService = service
}
func (app *App) RegisterRoutes(mux *http.ServeMux) { func (app *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/issuedoorcard", app.issueDoorCard) mux.HandleFunc("/issuedoorcard", app.issueDoorCard)
mux.HandleFunc("/printroomticket", app.printRoomTicket) mux.HandleFunc("/printroomticket", app.printRoomTicket)
@ -59,6 +65,7 @@ func (app *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/testissuedoorcard", app.testIssueDoorCard) mux.HandleFunc("/testissuedoorcard", app.testIssueDoorCard)
mux.HandleFunc("/ping-pdq", app.fetchChipDNAStatus) mux.HandleFunc("/ping-pdq", app.fetchChipDNAStatus)
mux.HandleFunc("/logerror", app.onChipDNAError) mux.HandleFunc("/logerror", app.onChipDNAError)
mux.HandleFunc("/api/payment/sale", app.salePayment)
} }
func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
@ -67,8 +74,8 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
var ( var (
theResponse cmstypes.ResponseRec theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec theRequest cmstypes.TransactionRec
trResult payment.TransactionResultXML trResult creditcall.TransactionResultXML
result payment.PaymentResult result creditcall.PaymentResult
save bool save bool
) )
@ -83,7 +90,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
if !app.isPayment { if !app.isPayment {
if !app.cfg.TestMode { if !app.cfg.TestMode {
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted preauthorization while payment processing is disabled") mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted preauthorization while payment processing is disabled")
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse) writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return return
} }
@ -96,13 +103,13 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
log.Println("takePreauthorization called") log.Println("takePreauthorization called")
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Method not allowed; use POST") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Method not allowed; use POST")
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse) writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
return return
} }
if r.Header.Get("Content-Type") != "text/xml" { if r.Header.Get("Content-Type") != "text/xml" {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Content-Type must be text/xml") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse) writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return return
} }
@ -112,14 +119,14 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Read body error", string(op), "", "", 0) logging.Error(types.ServiceName, err.Error(), "Read body error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read request body") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Failed to read request body")
writeTransactionResult(w, http.StatusBadRequest, theResponse) writeTransactionResult(w, http.StatusBadRequest, theResponse)
return return
} }
if err := xml.Unmarshal(body, &theRequest); err != nil { if err := xml.Unmarshal(body, &theRequest); err != nil {
logging.Error(types.ServiceName, err.Error(), "ReadXML", string(op), "", "", 0) logging.Error(types.ServiceName, err.Error(), "ReadXML", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse) writeTransactionResult(w, http.StatusBadRequest, theResponse)
return return
} }
@ -138,7 +145,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Preauth processing error", string(op), "", "", 0) logging.Error(types.ServiceName, err.Error(), "Preauth processing error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse) writeTransactionResult(w, http.StatusBadGateway, theResponse)
return return
} }
@ -156,7 +163,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
// ---- REDIRECT ---- // ---- REDIRECT ----
theResponse.Status = result.Status theResponse.Status = result.Status
theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields) theResponse.Data, save = creditcall.BuildPreauthRedirectURL(result.Fields)
if save { if save {
go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate) go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate)
@ -171,8 +178,8 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
var ( var (
theResponse cmstypes.ResponseRec theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec theRequest cmstypes.TransactionRec
trResult payment.TransactionResultXML trResult creditcall.TransactionResultXML
result payment.PaymentResult result creditcall.PaymentResult
) )
theResponse.Status.Code = http.StatusInternalServerError theResponse.Status.Code = http.StatusInternalServerError
@ -187,7 +194,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
if !app.cfg.TestMode { if !app.cfg.TestMode {
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted payment while payment processing is disabled") mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted payment while payment processing is disabled")
theResponse.Status.Code = http.StatusServiceUnavailable theResponse.Status.Code = http.StatusServiceUnavailable
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse) writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return return
} }
@ -201,13 +208,13 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
log.Println("takePayment called") log.Println("takePayment called")
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Method not allowed; use POST") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Method not allowed; use POST")
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse) writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
return return
} }
if r.Header.Get("Content-Type") != "text/xml" { if r.Header.Get("Content-Type") != "text/xml" {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Content-Type must be text/xml") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse) writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return return
} }
@ -217,14 +224,14 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Read body error", string(op), "", "", 0) logging.Error(types.ServiceName, err.Error(), "Read body error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read request body") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Failed to read request body")
writeTransactionResult(w, http.StatusBadRequest, theResponse) writeTransactionResult(w, http.StatusBadRequest, theResponse)
return return
} }
if err := xml.Unmarshal(body, &theRequest); err != nil { if err := xml.Unmarshal(body, &theRequest); err != nil {
logging.Error(types.ServiceName, err.Error(), "ReadXML", string(op), "", "", 0) logging.Error(types.ServiceName, err.Error(), "ReadXML", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse) writeTransactionResult(w, http.StatusBadRequest, theResponse)
return return
} }
@ -242,7 +249,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Start transaction error", string(op), "", "", 0) logging.Error(types.ServiceName, err.Error(), "Start transaction error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse) writeTransactionResult(w, http.StatusBadGateway, theResponse)
return return
} }
@ -263,7 +270,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
} }
logging.Error(types.ServiceName, "Preauthorization failed", "Result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk) logging.Error(types.ServiceName, "Preauthorization failed", "Result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
theResponse.Status = result.Status theResponse.Status = result.Status
theResponse.Data = payment.BuildFailureURL(res, result.Fields[types.Errors]) theResponse.Data = creditcall.BuildFailureURL(res, result.Fields[types.Errors])
writeTransactionResult(w, http.StatusOK, theResponse) writeTransactionResult(w, http.StatusOK, theResponse)
return return
@ -273,7 +280,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
ref := result.Fields[types.Reference] ref := result.Fields[types.Reference]
log.Printf("Preauth approved, reference: %s. Sending confirm...", ref) log.Printf("Preauth approved, reference: %s. Sending confirm...", ref)
confirmReq := payment.ConfirmTransactionRequest{ confirmReq := creditcall.ConfirmTransactionRequest{
Amount: theRequest.AmountMinorUnits, Amount: theRequest.AmountMinorUnits,
Reference: ref, Reference: ref,
} }
@ -282,7 +289,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Confirm transaction error", string(op), "", "", 0) logging.Error(types.ServiceName, err.Error(), "Confirm transaction error", string(op), "", "", 0)
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Error: "+err.Error()) mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Error: "+err.Error())
theResponse.Data = payment.BuildFailureURL(types.ResultError, "ConfirmTransactionError") theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "ConfirmTransactionError")
writeTransactionResult(w, http.StatusBadGateway, theResponse) writeTransactionResult(w, http.StatusBadGateway, theResponse)
return return
} }
@ -304,7 +311,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
logging.Error(types.ServiceName, "Transaction not approved after confirm", "Confirm result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk) logging.Error(types.ServiceName, "Transaction not approved after confirm", "Confirm result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Confirm result: "+res+" Description: "+desc) mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Confirm result: "+res+" Description: "+desc)
theResponse.Status = result.Status theResponse.Status = result.Status
theResponse.Data = payment.BuildFailureURL(res, result.Fields[types.Errors]) theResponse.Data = creditcall.BuildFailureURL(res, result.Fields[types.Errors])
writeTransactionResult(w, http.StatusOK, theResponse) writeTransactionResult(w, http.StatusOK, theResponse)
return return
@ -315,7 +322,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
printer.PrintReceipt(result.CardholderReceipt) printer.PrintReceipt(result.CardholderReceipt)
log.Printf("Transaction approved and confirmed, reference: %s", ref) log.Printf("Transaction approved and confirmed, reference: %s", ref)
theResponse.Status = result.Status theResponse.Status = result.Status
theResponse.Data = payment.BuildSuccessURL(result.Fields) theResponse.Data = creditcall.BuildSuccessURL(result.Fields)
writeTransactionResult(w, http.StatusOK, theResponse) writeTransactionResult(w, http.StatusOK, theResponse)
} }
@ -396,6 +403,7 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
app.SetCardWellStatus(status) app.SetCardWellStatus(status)
} }
doorReq.RoomField = "104"
// build lock server command // build lock server command
app.lockserver.BuildCommand(doorReq, checkIn, checkOut) app.lockserver.BuildCommand(doorReq, checkIn, checkOut)

View File

@ -9,7 +9,7 @@ import (
"time" "time"
"gitea.futuresens.co.uk/futuresens/cmstypes" "gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/payment" "gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types" "gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/logging" "gitea.futuresens.co.uk/futuresens/logging"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -34,7 +34,7 @@ func callChipDNA(client *http.Client, url string, payload []byte) ([]byte, error
return io.ReadAll(resp.Body) return io.ReadAll(resp.Body)
} }
func confirmWithRetry(client *http.Client, req payment.ConfirmTransactionRequest, attempts int) ([]byte, error) { func confirmWithRetry(client *http.Client, req creditcall.ConfirmTransactionRequest, attempts int) ([]byte, error) {
payload, err := xml.Marshal(req) payload, err := xml.Marshal(req)
if err != nil { if err != nil {
@ -69,3 +69,14 @@ func confirmWithRetry(client *http.Client, req payment.ConfirmTransactionRequest
return nil, lastErr return nil, lastErr
} }
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if payload == nil {
return
}
_ = json.NewEncoder(w).Encode(payload)
}

View File

@ -0,0 +1,204 @@
package handlers
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/logging"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
)
type SalePaymentRequest struct {
Reference string `json:"reference,omitempty"`
ConfirmNo string `json:"confirmNo,omitempty"`
Amount int64 `json:"amount"`
Currency string `json:"currency,omitempty"`
}
func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("salePayment")
var response = cmstypes.ResponseRec{
Status: cmstypes.StatusRec{
Code: http.StatusInternalServerError,
Message: http.StatusText(http.StatusInternalServerError),
},
}
setPaymentCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
response.Data = buildPaymentFailureURL(types.ResultError, "Method not allowed; use POST")
writeTransactionResult(w, http.StatusMethodNotAllowed, response)
return
}
if app.paymentService == nil {
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Service Not Configured", "Payment service is not configured; cannot process payment requests")
response.Data = buildPaymentFailureURL(types.ResultError, "Payment service is not configured")
writeTransactionResult(w, http.StatusInternalServerError, response)
return
}
if ct := r.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") {
response.Data = buildPaymentFailureURL(types.ResultError, "Content-Type must be application/json")
writeTransactionResult(w, http.StatusUnsupportedMediaType, response)
return
}
defer r.Body.Close()
var req SalePaymentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logging.Error(types.ServiceName, err.Error(), "ReadJSON", string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
response.Data = buildPaymentFailureURL(types.ResultError, "invalid JSON payload: "+err.Error())
writeTransactionResult(w, http.StatusBadRequest, response)
return
}
if req.Amount <= 0 {
response.Data = buildPaymentFailureURL(types.ResultError, "Amount must be greater than zero")
writeTransactionResult(w, http.StatusBadRequest, response)
return
}
if req.Currency == "" {
req.Currency = "GBP"
}
if req.Reference == "" {
req.Reference = req.ConfirmNo
}
if req.Reference == "" {
req.Reference = uuid.NewString()
}
requestID := buildPaymentRequestID(req.Reference)
timeoutSeconds := app.cfg.TimeoutSeconds
if timeoutSeconds <= 0 {
timeoutSeconds = 300
}
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutSeconds)*time.Second)
defer cancel()
result, err := app.paymentService.Sale(ctx, paymentsvc.SaleRequest{
RequestID: requestID,
Reference: req.Reference,
Amount: req.Amount,
Currency: req.Currency,
})
if err != nil {
status := http.StatusBadGateway
if errors.Is(err, paymentsvc.ErrPaymentInProgress) {
status = http.StatusConflict
}
logging.Error(types.ServiceName, err.Error(), "Payment provider error", string(op), req.Reference, app.cfg.Hotel, app.cfg.Kiosk)
response.Status.Code = status
response.Status.Message = http.StatusText(status)
response.Data = buildPaymentFailureURL(types.ResultError, err.Error())
writeTransactionResult(w, status, response)
return
}
if result == nil {
response.Status.Code = http.StatusBadGateway
response.Status.Message = "Empty payment result"
response.Data = buildPaymentFailureURL(types.ResultError, "Payment provider returned an empty result")
writeTransactionResult(w, http.StatusBadGateway, response)
return
}
response.Status.Code = http.StatusOK
if result.Success && strings.EqualFold(result.Status, "APPROVED") {
response.Status.Message = result.Message
response.Data = buildPaymentSuccessURL(result)
writeTransactionResult(w, http.StatusOK, response)
return
}
description := result.ErrorMessage
if description == "" {
description = result.Message
}
if description == "" {
description = result.Status
}
response.Status.Message = "Payment unsuccessful"
response.Data = buildPaymentFailureURL(types.ResultError, description)
writeTransactionResult(w, http.StatusOK, response)
}
func buildPaymentRequestID(reference string) string {
const prefix = "REQ_"
const maxLength = 60
suffix := fmt.Sprintf("_%d", time.Now().UnixMilli())
maxReferenceLength := maxLength - len(prefix) - len(suffix)
runes := []rune(reference)
if len(runes) > maxReferenceLength {
runes = runes[:maxReferenceLength]
}
return prefix + string(runes) + suffix
}
func setPaymentCORS(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Content-Type", "application/json")
}
func buildPaymentSuccessURL(result *paymentsvc.Result) string {
txnReference := result.ReferenceNumber
if txnReference == "" {
txnReference = result.TransactionID
}
q := url.Values{}
q.Set("CardNumber", hex.EncodeToString([]byte(result.CardNumber)))
q.Set("CardType", hex.EncodeToString([]byte(result.CardType)))
q.Set("ExpiryDate", hex.EncodeToString([]byte(result.ExpiryDate)))
q.Set("TxnReference", txnReference)
q.Set("CardHash", hex.EncodeToString([]byte(result.CardHash)))
q.Set("CardReference", hex.EncodeToString([]byte(result.CardReference)))
return (&url.URL{
Path: types.CheckinSuccessfulEndpoint,
RawQuery: q.Encode(),
}).String()
}
func buildPaymentFailureURL(msgType, description string) string {
log.WithFields(log.Fields{
types.LogFieldError: msgType,
types.LogFieldDescription: description,
}).Error("Transaction failed")
q := url.Values{}
q.Set("MsgType", msgType)
q.Set("Description", description)
return (&url.URL{
Path: types.CheckinUnsuccessfulEndpoint,
RawQuery: q.Encode(),
}).String()
}

View File

@ -12,7 +12,7 @@ import (
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers" "gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver" "gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail" "gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/payment" "gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types" "gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/logging" "gitea.futuresens.co.uk/futuresens/logging"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -91,7 +91,7 @@ func (app *App) fetchChipDNAStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
status, err := payment.ReadPdqStatus(app.cfg.Hotel, app.cfg.Kiosk) status, err := creditcall.ReadPdqStatus(app.cfg.Hotel, app.cfg.Kiosk)
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "fetchChipDNAStatus", string(op), "", app.cfg.Hotel, app.cfg.Kiosk) logging.Error(types.ServiceName, err.Error(), "fetchChipDNAStatus", string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
errorhandlers.WriteError(w, http.StatusServiceUnavailable, err.Error()) errorhandlers.WriteError(w, http.StatusServiceUnavailable, err.Error())
@ -111,7 +111,7 @@ func (app *App) fetchChipDNAStatus(w http.ResponseWriter, r *http.Request) {
func (app *App) onChipDNAError(w http.ResponseWriter, r *http.Request) { func (app *App) onChipDNAError(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("onChipDNAError") const op = logging.Op("onChipDNAError")
var tr payment.TransactionResultXML var tr creditcall.TransactionResultXML
title := "ChipDNA Error" title := "ChipDNA Error"
message := "" message := ""
@ -168,10 +168,10 @@ func (app *App) onChipDNAError(w http.ResponseWriter, r *http.Request) {
switch e.Key { switch e.Key {
case payment.KeyErrors: case creditcall.KeyErrors:
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, title, e.Value) mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, title, e.Value)
case payment.KeyIsAvailable: case creditcall.KeyIsAvailable:
isAvailable := strings.EqualFold(e.Value, "true") isAvailable := strings.EqualFold(e.Value, "true")
app.handleAvailabilityDebounced(isAvailable) app.handleAvailabilityDebounced(isAvailable)
} }

View File

@ -0,0 +1,182 @@
package paybridge
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"github.com/gorilla/websocket"
)
type Client struct {
URL string
APIKey string
TimeoutSeconds int
}
func NewClient(url, apiKey string, timeoutSeconds int) *Client {
if timeoutSeconds <= 0 {
timeoutSeconds = 300
}
return &Client{
URL: url,
APIKey: apiKey,
TimeoutSeconds: timeoutSeconds,
}
}
func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*paymentsvc.Result, error) {
if req.Currency == "" {
req.Currency = "GBP"
}
payBridgeReq := PaymentRequest{
RequestID: req.RequestID,
Amount: req.Amount,
Currency: req.Currency,
Operation: "SALE",
TimeoutSeconds: c.TimeoutSeconds,
}
return c.doPayment(ctx, payBridgeReq)
}
func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc.Result, error) {
connectURL, err := c.connectURL()
if err != nil {
return nil, err
}
ws, _, err := websocket.DefaultDialer.DialContext(ctx, connectURL, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err)
}
defer ws.Close()
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-ctx.Done():
_ = ws.Close()
case <-done:
}
}()
jwt, err := c.readAuthSuccess(ws)
if err != nil {
return nil, err
}
if err := ws.WriteJSON(Envelope{
Type: types.MesTypePaymentRequest,
JWT: jwt,
Data: req,
Timestamp: time.Now().UnixMilli(),
}); err != nil {
return nil, fmt.Errorf("send PayBridge payment_request: %w", err)
}
for {
_, raw, err := ws.ReadMessage()
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fmt.Errorf("read PayBridge message: %w", err)
}
var head struct {
Type string `json:"type"`
}
if err := json.Unmarshal(raw, &head); err != nil {
return nil, fmt.Errorf("decode PayBridge message header: %w", err)
}
switch strings.ToLower(head.Type) {
case types.MesTypePaymentResult:
var result PaymentResultEnvelope
if err := json.Unmarshal(raw, &result); err != nil {
return nil, fmt.Errorf("decode payment_result: %w", err)
}
return mapPaymentResult(result), nil
case types.MesTypePaymentError:
var paymentErr PaymentErrorEnvelope
if err := json.Unmarshal(raw, &paymentErr); err != nil {
return nil, fmt.Errorf("decode payment_error: %w", err)
}
return mapPaymentError(req, paymentErr), nil
case types.ResultError:
var genericErr struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &genericErr); err != nil {
return nil, fmt.Errorf("%w: %s", ErrUnexpectedMessage, string(raw))
}
if genericErr.Error.Message != "" {
return nil, fmt.Errorf("%w: %s: %s", ErrUnexpectedMessage, genericErr.Error.Code, genericErr.Error.Message)
}
return nil, fmt.Errorf("%w: %s", ErrUnexpectedMessage, string(raw))
case types.MesTypePaymentStatusUpdate, types.MesTypePaymentAccepted, types.MesTypeAuthSuccess:
// Intermediate message. Keep waiting for payment_result or payment_error.
default:
// PayBridge may introduce additional intermediate message types.
// Ignore them and continue waiting for the final result.
}
}
}
func (c *Client) connectURL() (string, error) {
u, err := url.Parse(c.URL)
if err != nil {
return "", fmt.Errorf("parse PayBridge WebSocket URL: %w", err)
}
q := u.Query()
q.Set("api_key", c.APIKey)
u.RawQuery = q.Encode()
return u.String(), nil
}
func (c *Client) readAuthSuccess(ws *websocket.Conn) (string, error) {
if err := ws.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
return "", fmt.Errorf("%w: set auth read deadline: %v", ErrAuthFailed, err)
}
defer ws.SetReadDeadline(time.Time{})
_, raw, err := ws.ReadMessage()
if err != nil {
return "", fmt.Errorf("%w: read auth_success: %v", ErrAuthFailed, err)
}
var auth struct {
Type string `json:"type"`
JWT string `json:"jwt"`
}
if err := json.Unmarshal(raw, &auth); err != nil {
return "", fmt.Errorf("%w: decode auth_success: %v", ErrAuthFailed, err)
}
if !strings.EqualFold(auth.Type, types.MesTypeAuthSuccess) {
return "", fmt.Errorf("%w: expected auth_success, got %s: %s", ErrAuthFailed, auth.Type, string(raw))
}
if auth.JWT == "" {
return "", fmt.Errorf("%w: auth_success did not contain jwt", ErrAuthFailed)
}
return auth.JWT, nil
}

View File

@ -0,0 +1,9 @@
package paybridge
import "errors"
var (
ErrConnectionFailed = errors.New("paybridge connection failed")
ErrAuthFailed = errors.New("paybridge authentication failed")
ErrUnexpectedMessage = errors.New("paybridge unexpected message")
)

View File

@ -0,0 +1,47 @@
package paybridge
import "gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
func mapPaymentResult(res PaymentResultEnvelope) *paymentsvc.Result {
var merchantReceipt string
if res.Data.ReceiptData.Merchant != nil {
merchantReceipt = *res.Data.ReceiptData.Merchant
}
return &paymentsvc.Result{
Success: res.Data.Success,
TransactionID: res.Data.TransactionID,
RequestID: res.Data.RequestID,
Operation: res.Data.Operation,
Status: res.Data.Status,
Message: res.Data.Message,
ErrorMessage: res.Data.ErrorMessage,
Amount: res.Data.Amount,
Currency: res.Data.Currency,
AuthCode: res.Data.AuthCode,
DeviceUsed: res.Data.DeviceUsed,
DeviceType: res.Data.DeviceType,
ReferenceNumber: res.Data.ReferenceNumber,
LastFourDigits: res.Data.LastFourDigits,
CardType: res.Data.CardType,
CardNumber: res.Data.CardNumber,
ExpiryDate: res.Data.ExpiryDate,
CardHash: res.Data.CardHash,
CardReference: res.Data.CardReference,
CustomerReceipt: res.Data.ReceiptData.Customer,
MerchantReceipt: merchantReceipt,
}
}
func mapPaymentError(req PaymentRequest, res PaymentErrorEnvelope) *paymentsvc.Result {
return &paymentsvc.Result{
Success: false,
TransactionID: res.Data.TransactionID,
RequestID: req.RequestID,
Operation: req.Operation,
Status: res.Data.Status,
ErrorMessage: res.Data.Error,
Amount: req.Amount,
Currency: req.Currency,
}
}

View File

@ -0,0 +1,55 @@
package paybridge
type Envelope struct {
Type string `json:"type"`
Data any `json:"data,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
JWT string `json:"jwt,omitempty"`
}
type PaymentRequest struct {
RequestID string `json:"requestId"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Operation string `json:"operation"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
}
type PaymentResultEnvelope struct {
Type string `json:"type"`
Data struct {
Success bool `json:"success"`
TransactionID string `json:"transactionId"`
RequestID string `json:"requestId"`
Operation string `json:"operation"`
Status string `json:"status"`
Message string `json:"message"`
ErrorMessage string `json:"errorMessage"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
AuthCode string `json:"authCode"`
DeviceUsed string `json:"deviceUsed"`
DeviceType string `json:"deviceType"`
ReferenceNumber string `json:"referenceNumber"`
LastFourDigits string `json:"lastFourDigits"`
CardType string `json:"cardType"`
CardNumber string `json:"cardNumber"`
ExpiryDate string `json:"expiryDate"`
CardHash string `json:"cardHash"`
CardReference string `json:"cardReference"`
ReceiptData struct {
Merchant *string `json:"merchant"`
Customer string `json:"customer"`
} `json:"receiptData"`
} `json:"data"`
}
type PaymentErrorEnvelope struct {
Type string `json:"type"`
Data struct {
TransactionID string `json:"transactionId"`
Error string `json:"error"`
Status string `json:"status"`
} `json:"data"`
}

View File

@ -0,0 +1,42 @@
package paymentsvc
import (
"context"
"errors"
"sync"
)
var ErrPaymentInProgress = errors.New("payment is already in progress")
type Provider interface {
Sale(ctx context.Context, req SaleRequest) (*Result, error)
}
type Service struct {
provider Provider
mu sync.Mutex
busy bool
}
func NewService(provider Provider) *Service {
return &Service{provider: provider}
}
func (s *Service) Sale(ctx context.Context, req SaleRequest) (*Result, error) {
s.mu.Lock()
if s.busy {
s.mu.Unlock()
return nil, ErrPaymentInProgress
}
s.busy = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.busy = false
s.mu.Unlock()
}()
return s.provider.Sale(ctx, req)
}

View File

@ -0,0 +1,37 @@
package paymentsvc
type SaleRequest struct {
RequestID string `json:"requestId"`
Reference string `json:"reference"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
type Result struct {
Success bool `json:"success"`
RequestID string `json:"requestId,omitempty"`
Operation string `json:"operation,omitempty"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
TransactionID string `json:"transactionId,omitempty"`
ReferenceNumber string `json:"referenceNumber,omitempty"`
AuthCode string `json:"authCode,omitempty"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
DeviceUsed string `json:"deviceUsed,omitempty"`
DeviceType string `json:"deviceType,omitempty"`
CardNumber string `json:"cardNumber,omitempty"`
LastFourDigits string `json:"lastFourDigits,omitempty"`
CardType string `json:"cardType,omitempty"`
ExpiryDate string `json:"expiryDate,omitempty"`
CardHash string `json:"cardHash,omitempty"`
CardReference string `json:"cardReference,omitempty"`
CustomerReceipt string `json:"customerReceipt,omitempty"`
MerchantReceipt string `json:"merchantReceipt,omitempty"`
}

View File

@ -23,6 +23,7 @@ const (
ResultApproved = "approved" ResultApproved = "approved"
ResultDeclined = "declined" ResultDeclined = "declined"
ResultCancelled = "cancelled" ResultCancelled = "cancelled"
ResultCanceled = "canceled"
ResultPending = "pending" ResultPending = "pending"
ResultStateUncommitted = "uncommitted" ResultStateUncommitted = "uncommitted"
ResultStateVoided = "voided" ResultStateVoided = "voided"
@ -49,6 +50,25 @@ const (
ConfirmErrors = "CONFIRM_ERRORS" ConfirmErrors = "CONFIRM_ERRORS"
TotalAmount = "TOTAL_AMOUNT" TotalAmount = "TOTAL_AMOUNT"
// Dojo terminal session statuses
ResultCaptured = "captured"
ResultSignatureAccepted = "signatureverificationaccepted"
ResultInitiateRequested = "initiaterequested"
ResultInitiated = "initiated"
ResultAuthorized = "authorized"
ResultCancelRequested = "cancelrequested"
ResultExpired = "expired"
ResultSignatureRejected = "signatureverificationrejected"
ResultSignatureRequired = "signatureverificationrequired"
//PayBridge message types
MesTypePaymentRequest = "payment_request"
MesTypePaymentResult = "payment_result"
MesTypePaymentError = "payment_error"
MesTypePaymentStatusUpdate = "payment_status_update"
MesTypePaymentAccepted = "payment_accepted"
MesTypeAuthSuccess = "auth_success"
// Log field keys // Log field keys
LogFieldError = "error" LogFieldError = "error"
LogFieldDescription = "description" LogFieldDescription = "description"

View File

@ -2,7 +2,13 @@
builtVersion is a const in main.go builtVersion is a const in main.go
#### 1.2.10 - 02 June 2026 #### 1.3.0 - 03 July 2026
added pluggable PayBridge and Dojo payment flow with CMS credentials
#### 1.2.11 - 26 June 2026
added PayBridge integration for payment processing functionality
#### 1.2.10 - 26 June 2026
added voucher field to the guest receipt added voucher field to the guest receipt
#### 1.2.9 - 02 June 2026 #### 1.2.9 - 02 June 2026