diff --git a/.gitignore b/.gitignore index ac0bf59..aaf540b 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,6 @@ _cgo_export.* _testmain.go -*.exe +*.exe* *.test *.prof diff --git a/cmd/hardlink/main.go b/cmd/hardlink/main.go index 6dc798a..da77452 100644 --- a/cmd/hardlink/main.go +++ b/cmd/hardlink/main.go @@ -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 diff --git a/cms/cms.go b/cms/cms.go new file mode 100644 index 0000000..8fe9074 --- /dev/null +++ b/cms/cms.go @@ -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 +} diff --git a/cms/cms_test.go b/cms/cms_test.go new file mode 100644 index 0000000..17dfd26 --- /dev/null +++ b/cms/cms_test.go @@ -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) + } +} \ No newline at end of file diff --git a/config/config.go b/config/config.go index cf97ea4..88d954e 100644 --- a/config/config.go +++ b/config/config.go @@ -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 == "" { diff --git a/go.mod b/go.mod index 29e9a36..443a062 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index a8dbb5d..37cd651 100644 --- a/go.sum +++ b/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= diff --git a/hardlink-preauth-release/main.go b/hardlink-preauth-release/main.go index 97f73b2..8ac31f8 100644 --- a/hardlink-preauth-release/main.go +++ b/hardlink-preauth-release/main.go @@ -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 { diff --git a/internal/payment/chipdnastatus.go b/internal/creditcall/chipdnastatus.go similarity index 99% rename from internal/payment/chipdnastatus.go rename to internal/creditcall/chipdnastatus.go index ae4d9ae..5ed7229 100644 --- a/internal/payment/chipdnastatus.go +++ b/internal/creditcall/chipdnastatus.go @@ -1,4 +1,4 @@ -package payment +package creditcall import ( "bytes" diff --git a/internal/payment/creditcall.go b/internal/creditcall/creditcall.go similarity index 98% rename from internal/payment/creditcall.go rename to internal/creditcall/creditcall.go index a77b455..e65eb84 100644 --- a/internal/payment/creditcall.go +++ b/internal/creditcall/creditcall.go @@ -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] diff --git a/internal/payment/preauthReleaser.go b/internal/creditcall/preauthReleaser.go similarity index 99% rename from internal/payment/preauthReleaser.go rename to internal/creditcall/preauthReleaser.go index 5825336..9288923 100644 --- a/internal/payment/preauthReleaser.go +++ b/internal/creditcall/preauthReleaser.go @@ -1,4 +1,4 @@ -package payment +package creditcall import ( "bytes" diff --git a/internal/dojo/client.go b/internal/dojo/client.go new file mode 100644 index 0000000..5a479c8 --- /dev/null +++ b/internal/dojo/client.go @@ -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:]) +} diff --git a/internal/dojo/types.go b/internal/dojo/types.go new file mode 100644 index 0000000..1836c6d --- /dev/null +++ b/internal/dojo/types.go @@ -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"` +} diff --git a/internal/errorhandlers/errorhandlers.go b/internal/errorhandlers/errorhandlers.go index d20e627..aa58653 100644 --- a/internal/errorhandlers/errorhandlers.go +++ b/internal/errorhandlers/errorhandlers.go @@ -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) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 7247a2a..eec77f5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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) diff --git a/internal/handlers/http_helpers.go b/internal/handlers/http_helpers.go index 0eeb10f..7ed5518 100644 --- a/internal/handlers/http_helpers.go +++ b/internal/handlers/http_helpers.go @@ -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) +} diff --git a/internal/handlers/payment_handlers.go b/internal/handlers/payment_handlers.go new file mode 100644 index 0000000..7587756 --- /dev/null +++ b/internal/handlers/payment_handlers.go @@ -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() +} diff --git a/internal/handlers/testhandlers.go b/internal/handlers/testhandlers.go index 92b221c..e7a7cac 100644 --- a/internal/handlers/testhandlers.go +++ b/internal/handlers/testhandlers.go @@ -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) } diff --git a/internal/paybridge/client.go b/internal/paybridge/client.go new file mode 100644 index 0000000..e320b17 --- /dev/null +++ b/internal/paybridge/client.go @@ -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 +} diff --git a/internal/paybridge/errors.go b/internal/paybridge/errors.go new file mode 100644 index 0000000..3958f0d --- /dev/null +++ b/internal/paybridge/errors.go @@ -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") +) diff --git a/internal/paybridge/mapper.go b/internal/paybridge/mapper.go new file mode 100644 index 0000000..064e75b --- /dev/null +++ b/internal/paybridge/mapper.go @@ -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, + } +} diff --git a/internal/paybridge/types.go b/internal/paybridge/types.go new file mode 100644 index 0000000..733b8ed --- /dev/null +++ b/internal/paybridge/types.go @@ -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"` +} diff --git a/internal/paymentsvc/service.go b/internal/paymentsvc/service.go new file mode 100644 index 0000000..6ccb5a6 --- /dev/null +++ b/internal/paymentsvc/service.go @@ -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) +} diff --git a/internal/paymentsvc/types.go b/internal/paymentsvc/types.go new file mode 100644 index 0000000..a496bcd --- /dev/null +++ b/internal/paymentsvc/types.go @@ -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"` +} diff --git a/internal/types/types.go b/internal/types/types.go index 45b5668..f1364cb 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -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" diff --git a/release notes.md b/release notes.md index ce0d5c1..b7f2d61 100644 --- a/release notes.md +++ b/release notes.md @@ -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