Compare commits
1 Commits
1.2.10
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d9c03837 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -42,6 +42,6 @@ _cgo_export.*
|
||||
|
||||
_testmain.go
|
||||
|
||||
*.exe
|
||||
*.exe*
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
@ -16,20 +16,25 @@ import (
|
||||
|
||||
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/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/dojo"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/handlers"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/logging"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
buildVersion = "1.2.10"
|
||||
buildVersion = "1.3.0"
|
||||
serviceName = "hardlink"
|
||||
pollingFrequency = 8 * time.Second
|
||||
)
|
||||
@ -69,8 +74,7 @@ func main() {
|
||||
|
||||
dispPort, err = dispenser.InitializeDispenser()
|
||||
if err != nil {
|
||||
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Dispenser Initialization Error", fmt.Sprintf("Failed to initialize dispenser: %v", err))
|
||||
errorhandlers.FatalError(err)
|
||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Dispenser Initialization Error", fmt.Errorf("failed to initialize dispenser: %v", err))
|
||||
}
|
||||
defer dispPort.Close()
|
||||
|
||||
@ -83,8 +87,7 @@ func main() {
|
||||
cardWellStatus, err = disp.DispenserPrepare(ctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("%s; wrong dispenser address: %s", err, cfg.DispenserAdrr)
|
||||
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Dispenser Preparation Error", err.Error())
|
||||
errorhandlers.FatalError(err)
|
||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Dispenser Preparation Error", err)
|
||||
}
|
||||
fmt.Println(cardWellStatus)
|
||||
}
|
||||
@ -114,31 +117,103 @@ func main() {
|
||||
defer database.Close()
|
||||
}
|
||||
|
||||
// Create App and wire routes
|
||||
app := handlers.NewApp(disp, cfg.LockType, cfg.EncoderAddress, cardWellStatus, database, &cfg)
|
||||
|
||||
if cfg.IsPayment {
|
||||
fmt.Println("Payment processing is enabled")
|
||||
log.Info("Payment processing is enabled")
|
||||
|
||||
startChipDnaClient()
|
||||
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))
|
||||
}
|
||||
|
||||
// check ChipDNA and PDQ status and log any errors, but continue running even if it fails
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second) // give ChipDNA client a moment to start
|
||||
pdqstatus, err := payment.ReadPdqStatus(cfg.Hotel, cfg.Kiosk)
|
||||
if err != nil {
|
||||
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "PDQ Status Read Error", err.Error())
|
||||
} else {
|
||||
fmt.Printf("\nPDQ availabile: %v\n", pdqstatus.IsAvailable)
|
||||
log.Infof("PDQ availabile: %v", pdqstatus.IsAvailable)
|
||||
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()
|
||||
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
|
||||
pdqStatus, err := creditcall.ReadPdqStatus(cfg.Hotel, cfg.Kiosk)
|
||||
if err != nil {
|
||||
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "PDQ Status Read Error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
fmt.Println("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
|
||||
if !cfg.TestMode && disp != nil {
|
||||
// Set initial cardWellStatus
|
||||
|
||||
120
cms/cms.go
Normal file
120
cms/cms.go
Normal 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
233
cms/cms_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -22,22 +22,25 @@ type ConfigRec struct {
|
||||
DispenserAdrr string `yaml:"dispensAddr"`
|
||||
PrinterName string `yaml:"printerName"`
|
||||
LogDir string `yaml:"logdir"`
|
||||
Dbport int `yaml:"dbport"` // Port for the database connection
|
||||
Dbname string `yaml:"dbname"` // Database name for the connection
|
||||
Dbuser string `yaml:"dbuser"` // User for the database connection
|
||||
Dbpassword string `yaml:"dbpassword"` // Password for the database connection
|
||||
Dbport int `yaml:"dbport"`
|
||||
Dbname string `yaml:"dbname"`
|
||||
Dbuser string `yaml:"dbuser"`
|
||||
Dbpassword string `yaml:"dbpassword"`
|
||||
CMSBaseURL string `yaml:"cmsurl"`
|
||||
IsPayment bool `yaml:"isPayment"`
|
||||
TestMode bool `yaml:"testMode"`
|
||||
Hotel string `yaml:"hotel"`
|
||||
Kiosk int `yaml:"kiosk"`
|
||||
SendErrorEmails []string `yaml:"senderroremails"`
|
||||
PaymentProvider string `yaml:"paymentProvider"`
|
||||
TimeoutSeconds int `yaml:"timeoutSeconds"`
|
||||
}
|
||||
|
||||
// ReadHardlinkConfig reads config.yml and applies defaults.
|
||||
func ReadHardlinkConfig() ConfigRec {
|
||||
var cfg ConfigRec
|
||||
const configName = "config.yml"
|
||||
defaultPort := 9091
|
||||
const defaultPort = 9091
|
||||
sep := string(os.PathSeparator)
|
||||
|
||||
data, err := os.ReadFile(configName)
|
||||
@ -52,8 +55,7 @@ func ReadHardlinkConfig() ConfigRec {
|
||||
}
|
||||
|
||||
if cfg.LockType == "" {
|
||||
err = fmt.Errorf("LockType is required in %s", configName)
|
||||
errorhandlers.FatalError(err)
|
||||
errorhandlers.FatalError(fmt.Errorf("LockType is required in %s", configName))
|
||||
}
|
||||
cfg.LockType = strings.ToLower(cfg.LockType)
|
||||
|
||||
@ -64,8 +66,13 @@ func ReadHardlinkConfig() ConfigRec {
|
||||
}
|
||||
|
||||
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(err.Error())
|
||||
log.Warnf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName)
|
||||
}
|
||||
|
||||
cfg.PaymentProvider = strings.ToLower(strings.TrimSpace(cfg.PaymentProvider))
|
||||
|
||||
if cfg.TimeoutSeconds <= 0 {
|
||||
cfg.TimeoutSeconds = 300
|
||||
}
|
||||
|
||||
return cfg
|
||||
@ -84,8 +91,7 @@ func ReadPreauthReleaserConfig() ConfigRec {
|
||||
}
|
||||
|
||||
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.FatalError(err)
|
||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "PreauthReleaser Database Configuration Error", fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName))
|
||||
}
|
||||
|
||||
if cfg.LogDir == "" {
|
||||
|
||||
4
go.mod
4
go.mod
@ -3,10 +3,12 @@ module gitea.futuresens.co.uk/futuresens/hardlink
|
||||
go 1.23.2
|
||||
|
||||
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
|
||||
github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb
|
||||
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/sirupsen/logrus v1.9.3
|
||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
||||
|
||||
8
go.sum
8
go.sum
@ -1,5 +1,5 @@
|
||||
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190 h1:OxP911wT8HQqBJ20KIZcBxi898rsYHhhCkne2u45p1A=
|
||||
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190/go.mod h1:ABMUkdm+3VGrkuoCJsXMfPPud9GHDOwBb1NiifFqxes=
|
||||
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.200 h1:CRGAuhwecpOwY1CAuC038NFyw6EFulVG554HbUqfezI=
|
||||
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/go.mod h1:p95ouVfK4qyC20D3/k9QLsWSxD2pdweWiY6vcYi9hpM=
|
||||
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/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
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/go.mod h1:ogN8Sxy3n5VKLhQxbtSBM3ICG/VgjXS/akQJIoDSrgA=
|
||||
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/bootstrap"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
||||
"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"
|
||||
)
|
||||
|
||||
@ -32,7 +32,7 @@ func main() {
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if err := payment.ReleasePreauthorizations(database); err != nil {
|
||||
if err := creditcall.ReleasePreauthorizations(database); err != nil {
|
||||
log.Error(err)
|
||||
fmt.Println(err)
|
||||
} else {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package payment
|
||||
package creditcall
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@ -1,4 +1,4 @@
|
||||
package payment
|
||||
package creditcall
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
@ -82,7 +82,6 @@ func (r *PaymentResult) FillFromTransactionResult(trResult TransactionResultXML)
|
||||
|
||||
for _, e := range trResult.Entries {
|
||||
switch e.Key {
|
||||
|
||||
case types.ReceiptData, types.ReceiptDataMerchant:
|
||||
// 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 {
|
||||
res := result[types.TransactionResult]
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package payment
|
||||
package creditcall
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
326
internal/dojo/client.go
Normal file
326
internal/dojo/client.go
Normal 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
59
internal/dojo/types.go
Normal 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"`
|
||||
}
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@ -28,3 +29,8 @@ func FatalError(err error) {
|
||||
fmt.Scanln()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func FatalErrorWithMail(hotel string, kiosk int, title string, err error) {
|
||||
mail.SendEmailOnError(hotel, kiosk, title, err.Error())
|
||||
FatalError(err)
|
||||
}
|
||||
|
||||
@ -13,11 +13,12 @@ import (
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"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/errorhandlers"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
|
||||
"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/types"
|
||||
"gitea.futuresens.co.uk/futuresens/logging"
|
||||
@ -27,6 +28,7 @@ import (
|
||||
type App struct {
|
||||
disp *dispenser.Client
|
||||
lockserver lockserver.LockServer
|
||||
paymentService *paymentsvc.Service
|
||||
isPayment bool
|
||||
db *sql.DB
|
||||
cfg *config.ConfigRec
|
||||
@ -50,6 +52,10 @@ func NewApp(disp *dispenser.Client, lockType, encoderAddress, cardWellStatus str
|
||||
return app
|
||||
}
|
||||
|
||||
func (app *App) SetPaymentService(service *paymentsvc.Service) {
|
||||
app.paymentService = service
|
||||
}
|
||||
|
||||
func (app *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/issuedoorcard", app.issueDoorCard)
|
||||
mux.HandleFunc("/printroomticket", app.printRoomTicket)
|
||||
@ -59,6 +65,7 @@ func (app *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/testissuedoorcard", app.testIssueDoorCard)
|
||||
mux.HandleFunc("/ping-pdq", app.fetchChipDNAStatus)
|
||||
mux.HandleFunc("/logerror", app.onChipDNAError)
|
||||
mux.HandleFunc("/api/payment/sale", app.salePayment)
|
||||
}
|
||||
|
||||
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 (
|
||||
theResponse cmstypes.ResponseRec
|
||||
theRequest cmstypes.TransactionRec
|
||||
trResult payment.TransactionResultXML
|
||||
result payment.PaymentResult
|
||||
trResult creditcall.TransactionResultXML
|
||||
result creditcall.PaymentResult
|
||||
save bool
|
||||
)
|
||||
|
||||
@ -83,7 +90,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
||||
if !app.isPayment {
|
||||
if !app.cfg.TestMode {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -96,13 +103,13 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Println("takePreauthorization called")
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -112,14 +119,14 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &theRequest); err != nil {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -138,7 +145,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -156,7 +163,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
||||
// ---- REDIRECT ----
|
||||
|
||||
theResponse.Status = result.Status
|
||||
theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields)
|
||||
theResponse.Data, save = creditcall.BuildPreauthRedirectURL(result.Fields)
|
||||
|
||||
if save {
|
||||
go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate)
|
||||
@ -171,8 +178,8 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
theResponse cmstypes.ResponseRec
|
||||
theRequest cmstypes.TransactionRec
|
||||
trResult payment.TransactionResultXML
|
||||
result payment.PaymentResult
|
||||
trResult creditcall.TransactionResultXML
|
||||
result creditcall.PaymentResult
|
||||
)
|
||||
|
||||
theResponse.Status.Code = http.StatusInternalServerError
|
||||
@ -187,11 +194,11 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
||||
if !app.cfg.TestMode {
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted payment while payment processing is disabled")
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
@ -201,13 +208,13 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Println("takePayment called")
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -217,14 +224,14 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &theRequest); err != nil {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -242,7 +249,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
return
|
||||
@ -273,7 +280,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ref := result.Fields[types.Reference]
|
||||
log.Printf("Preauth approved, reference: %s. Sending confirm...", ref)
|
||||
confirmReq := payment.ConfirmTransactionRequest{
|
||||
confirmReq := creditcall.ConfirmTransactionRequest{
|
||||
Amount: theRequest.AmountMinorUnits,
|
||||
Reference: ref,
|
||||
}
|
||||
@ -282,7 +289,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
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())
|
||||
theResponse.Data = payment.BuildFailureURL(types.ResultError, "ConfirmTransactionError")
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "ConfirmTransactionError")
|
||||
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
||||
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)
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Confirm result: "+res+" Description: "+desc)
|
||||
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)
|
||||
return
|
||||
@ -315,7 +322,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
||||
printer.PrintReceipt(result.CardholderReceipt)
|
||||
log.Printf("Transaction approved and confirmed, reference: %s", ref)
|
||||
theResponse.Status = result.Status
|
||||
theResponse.Data = payment.BuildSuccessURL(result.Fields)
|
||||
theResponse.Data = creditcall.BuildSuccessURL(result.Fields)
|
||||
|
||||
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||
}
|
||||
@ -396,6 +403,7 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||
app.SetCardWellStatus(status)
|
||||
}
|
||||
|
||||
doorReq.RoomField = "104"
|
||||
// build lock server command
|
||||
app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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/logging"
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
@ -69,3 +69,14 @@ func confirmWithRetry(client *http.Client, req payment.ConfirmTransactionRequest
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
204
internal/handlers/payment_handlers.go
Normal file
204
internal/handlers/payment_handlers.go
Normal 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()
|
||||
}
|
||||
@ -12,7 +12,7 @@ import (
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
|
||||
"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/logging"
|
||||
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-Headers", "Content-Type")
|
||||
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 {
|
||||
logging.Error(types.ServiceName, err.Error(), "fetchChipDNAStatus", string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
|
||||
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) {
|
||||
const op = logging.Op("onChipDNAError")
|
||||
var tr payment.TransactionResultXML
|
||||
var tr creditcall.TransactionResultXML
|
||||
title := "ChipDNA Error"
|
||||
message := ""
|
||||
|
||||
@ -168,10 +168,10 @@ func (app *App) onChipDNAError(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch e.Key {
|
||||
|
||||
case payment.KeyErrors:
|
||||
case creditcall.KeyErrors:
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, title, e.Value)
|
||||
|
||||
case payment.KeyIsAvailable:
|
||||
case creditcall.KeyIsAvailable:
|
||||
isAvailable := strings.EqualFold(e.Value, "true")
|
||||
app.handleAvailabilityDebounced(isAvailable)
|
||||
}
|
||||
|
||||
182
internal/paybridge/client.go
Normal file
182
internal/paybridge/client.go
Normal 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
|
||||
}
|
||||
9
internal/paybridge/errors.go
Normal file
9
internal/paybridge/errors.go
Normal 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")
|
||||
)
|
||||
47
internal/paybridge/mapper.go
Normal file
47
internal/paybridge/mapper.go
Normal 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,
|
||||
}
|
||||
}
|
||||
55
internal/paybridge/types.go
Normal file
55
internal/paybridge/types.go
Normal 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"`
|
||||
}
|
||||
42
internal/paymentsvc/service.go
Normal file
42
internal/paymentsvc/service.go
Normal 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)
|
||||
}
|
||||
37
internal/paymentsvc/types.go
Normal file
37
internal/paymentsvc/types.go
Normal 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"`
|
||||
}
|
||||
@ -23,6 +23,7 @@ const (
|
||||
ResultApproved = "approved"
|
||||
ResultDeclined = "declined"
|
||||
ResultCancelled = "cancelled"
|
||||
ResultCanceled = "canceled"
|
||||
ResultPending = "pending"
|
||||
ResultStateUncommitted = "uncommitted"
|
||||
ResultStateVoided = "voided"
|
||||
@ -49,6 +50,25 @@ const (
|
||||
ConfirmErrors = "CONFIRM_ERRORS"
|
||||
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
|
||||
LogFieldError = "error"
|
||||
LogFieldDescription = "description"
|
||||
|
||||
@ -2,7 +2,13 @@
|
||||
|
||||
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
|
||||
|
||||
#### 1.2.9 - 02 June 2026
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user