Compare commits

...

20 Commits

Author SHA1 Message Date
61d9c03837 add pluggable PayBridge and Dojo payment flow with CMS credentials 2026-07-03 16:25:11 +01:00
f5ff78e60e added voucher field to the guest receipt 2026-06-26 13:36:44 +01:00
ffd814e076 added Dormakaba lock server integration 2026-06-02 10:10:17 +01:00
b982698ccd restructure hardlink layout 2026-05-14 12:20:25 +01:00
af151fd389 retrieve CardType from the ChepDNA response 2026-05-13 16:48:25 +01:00
20fcbe5499 added the second attempt to send the card to the encoder after 6 seconds 2026-04-20 11:17:27 +01:00
c6c2b40f37 removed early return on error when checking dispenser status in the start and final loops 2026-03-20 20:46:21 +00:00
8f093159cd added check if keycard at the encoder position before trying to encode key 2026-03-20 11:38:42 +00:00
e1549dda2f added check if keycard at the encoder position before trying to encode key 2026-03-18 16:02:43 +00:00
163ac0e808 added debounceNight 10 minutes 2026-03-11 18:47:06 +00:00
19cfbf185b increased waiting time befor sending email on PDQ unavailability to 60 seconds 2026-03-10 17:45:58 +00:00
48e2b6f568 released version 1.2.0 2026-03-09 16:31:52 +00:00
eb80332c5a increased timeout for reading response from the Assa abloy lock server to 20 seconds 2026-03-02 17:11:29 +00:00
aeea755045 added logging for unknown dispenser status positions 2026-02-04 16:27:46 +00:00
ebe50b17a9 added contionuous polling of the dispenser status every 8 seconds to update the card well status 2026-02-02 17:15:25 +00:00
4a255c06ed release version 1.1.0 2026-01-29 17:23:00 +00:00
9f0a9c939f and reconnection functionality if the connection to the database is lost 2026-01-27 16:44:12 +00:00
7f6262b470 db reconnect 2026-01-23 17:15:45 +00:00
895849376e improved logging for preauth releaser 2026-01-09 16:26:34 +00:00
43f1e8787f added count down before exiting the preauth releaser 20 seconds 2026-01-08 17:12:51 +00:00
44 changed files with 4230 additions and 946 deletions

2
.gitignore vendored
View File

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

318
cmd/hardlink/main.go Normal file
View File

@ -0,0 +1,318 @@
package main
import (
"context"
"encoding/xml"
"fmt"
"net/http"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"time"
"github.com/tarm/serial"
log "github.com/sirupsen/logrus"
"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/paybridge"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/printer"
)
const (
buildVersion = "1.3.0"
serviceName = "hardlink"
pollingFrequency = 8 * time.Second
)
func main() {
// Load config
cfg := config.ReadHardlinkConfig()
printer.Layout = readTicketLayout()
printer.PrinterName = cfg.PrinterName
lockserver.Cert = cfg.Cert
lockserver.LockServerURL = cfg.LockserverURL
mail.SendErrorEmails = cfg.SendErrorEmails
// Root context for background goroutines
// rootCtx, rootCancel := context.WithCancel(context.Background())
// defer rootCancel()
var (
dispPort *serial.Port
disp *dispenser.Client
cardWellStatus string
)
// Setup logging and get file handle
logFile, err := logging.SetupLogging(cfg.LogDir, serviceName, buildVersion)
if err != nil {
log.Printf("Failed to set up logging: %v\n", err)
}
if logFile != nil {
defer logFile.Close()
}
// Initialize dispenser
if !cfg.TestMode {
dispenser.SerialPort = cfg.DispenserPort
dispenser.Address = []byte(cfg.DispenserAdrr)
dispPort, err = dispenser.InitializeDispenser()
if err != nil {
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Dispenser Initialization Error", fmt.Errorf("failed to initialize dispenser: %v", err))
}
defer dispPort.Close()
disp = dispenser.NewClient(dispPort, 32)
defer disp.Close()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cardWellStatus, err = disp.DispenserPrepare(ctx)
if err != nil {
err = fmt.Errorf("%s; wrong dispenser address: %s", err, cfg.DispenserAdrr)
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Dispenser Preparation Error", err)
}
fmt.Println(cardWellStatus)
}
// Test lock-server connection
switch strings.ToLower(cfg.LockType) {
case lockserver.TLJ:
// TLJ uses HTTP - skip TCP probe here
default:
lockConn, err := lockserver.InitializeServerConnection(cfg.LockserverURL)
if err != nil {
fmt.Println(err.Error())
log.Errorf(err.Error())
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Lock Server Connection Error", err.Error())
} else {
fmt.Printf("Connected to the lock server successfuly at %s\n", cfg.LockserverURL)
log.Infof("Connected to the lock server successfuly at %s", cfg.LockserverURL)
lockConn.Close()
}
}
database, err := bootstrap.OpenDB(&cfg)
if err != nil {
log.Warnf("DB init failed: %v", err)
}
if database != nil {
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")
var provider paymentsvc.Provider
reservationSystem, err := cms.ReadHotel(cfg.Hotel, cfg.CMSBaseURL)
if err != nil {
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Failed to read hotel from CMS", fmt.Errorf("failed to read hotel from CMS: %v", err))
}
paymentProvider := reservationSystem.PaymentSystem
if paymentProvider == 0 {
paymentProvider = cms.PaymentSystemIndex(cfg.PaymentProvider)
}
switch paymentProvider {
case cmstypes.PaySystemCreditCall:
// CreditCall keeps using the existing /takepayment and /takepreauth endpoints.
startChipDnaClient()
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")
}
// Update cardWellStatus when dispenser status changes
if !cfg.TestMode && disp != nil {
// Set initial cardWellStatus
app.SetCardWellStatus(cardWellStatus)
// Set up callback to update cardWellStatus when dispenser status changes
disp.OnStockUpdate(func(stock string) {
app.SetCardWellStatus(stock)
})
// Start polling for dispenser status every 10 seconds
disp.StartPolling(pollingFrequency)
}
mux := http.NewServeMux()
app.RegisterRoutes(mux)
addr := fmt.Sprintf(":%d", cfg.Port)
log.Infof("Starting HTTP server on http://localhost%s", addr)
fmt.Printf("Starting HTTP server on http://localhost%s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
errorhandlers.FatalError(err)
}
}
func readTicketLayout() printer.LayoutOptions {
const layoutName = "TicketLayout.xml"
var layout printer.LayoutOptions
// 1) Read the file
data, err := os.ReadFile(layoutName)
if err != nil {
errorhandlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err))
}
// 2) Unmarshal into your struct
if err := xml.Unmarshal(data, &layout); err != nil {
errorhandlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err))
}
return layout
}
func startChipDnaClient() {
startClient := func() (*exec.Cmd, error) {
cmd := exec.Command("./ChipDNAClient/ChipDnaClient.exe")
err := cmd.Start()
if err != nil {
return nil, fmt.Errorf("failed to start ChipDnaClient: %v", err)
}
log.Infof("ChipDnaClient started with PID %d", cmd.Process.Pid)
return cmd, nil
}
cmd, err := startClient()
if err != nil {
errorhandlers.FatalError(err)
}
// Restart loop
go func() {
for {
err := cmd.Wait()
if err != nil {
log.Errorf("ChipDnaClient exited unexpectedly: %v", err)
fmt.Printf("ChipDnaClient exited unexpectedly: %v", err)
time.Sleep(2 * time.Second)
cmd, err = startClient()
if err != nil {
log.Errorf("Restart failed: %v", err)
return
}
log.Info("ChipDnaClient restarted successfully")
fmt.Printf("ChipDnaClient restarted successfully")
}
}
}()
// Handle shutdown signals
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
log.Info("Shutting down...")
if cmd.Process != nil {
log.Info("Sending SIGTERM to ChipDnaClient...")
_ = cmd.Process.Signal(syscall.SIGTERM)
// wait up to 5s for graceful shutdown
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-time.After(5 * time.Second):
log.Warn("ChipDnaClient did not exit in time, killing...")
_ = cmd.Process.Kill()
case err := <-done:
log.Infof("ChipDnaClient exited cleanly: %v", err)
}
}
os.Exit(0)
}()
}

120
cms/cms.go Normal file
View File

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

233
cms/cms_test.go Normal file
View File

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

View File

@ -1,3 +1,4 @@
// Package config handles reading and parsing configuration from config.yml.
package config
import (
@ -5,35 +6,41 @@ import (
"os"
"strings"
"gitea.futuresens.co.uk/futuresens/hardlink/handlers"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
log "github.com/sirupsen/logrus"
yaml "gopkg.in/yaml.v3"
)
// configRec holds values from config.yml.
// ConfigRec holds values from config.yml.
type ConfigRec struct {
Port int `yaml:"port"`
LockserverUrl string `yaml:"lockservUrl"`
LockType string `yaml:"lockType"`
EncoderAddress string `yaml:"encoderAddr"`
Cert string `yaml:"cert"`
DispenserPort string `yaml:"dispensPort"`
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
IsPayment bool `yaml:"isPayment"`
TestMode bool `yaml:"testMode"`
Port int `yaml:"port"`
LockserverURL string `yaml:"lockservUrl"`
LockType string `yaml:"lockType"`
EncoderAddress string `yaml:"encoderAddr"`
Cert string `yaml:"cert"`
DispenserPort string `yaml:"dispensPort"`
DispenserAdrr string `yaml:"dispensAddr"`
PrinterName string `yaml:"printerName"`
LogDir string `yaml:"logdir"`
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"`
}
// ReadConfig reads config.yml and applies defaults.
// 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)
@ -48,8 +55,7 @@ func ReadHardlinkConfig() ConfigRec {
}
if cfg.LockType == "" {
err = fmt.Errorf("LockType is required in %s", configName)
handlers.FatalError(err)
errorhandlers.FatalError(fmt.Errorf("LockType is required in %s", configName))
}
cfg.LockType = strings.ToLower(cfg.LockType)
@ -60,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
@ -80,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)
handlers.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 == "" {

View File

@ -1,294 +0,0 @@
package dispenser
import (
// "encoding/hex"
"fmt"
// "log"
"time"
log "github.com/sirupsen/logrus"
"github.com/tarm/serial"
)
// Control characters.
const (
STX = 0x02 // Start of Text
ETX = 0x03 // End of Text
ACK = 0x06 // Positive response
NAK = 0x15 // Negative response
ENQ = 0x05 // Enquiry from host
space = 0x00 // Space character
baudRate = 9600 // Baud rate for serial communication
delay = 500 * time.Millisecond // Delay for processing commands
)
// type (
// configRec struct {
// SerialPort string `yaml:"port"`
// Address string `yaml:"addr"`
// }
// )
var (
SerialPort string
Address []byte
commandFC7 = []byte{ETX, 0x46, 0x43, 0x37} // "FC7" command dispense card at read card position
commandFC0 = []byte{ETX, 0x46, 0x43, 0x30} // "FC0" command dispense card out of card mouth command
statusPos0 = map[byte]string{
0x38: "Keep",
0x34: "Command cannot execute",
0x32: "Preparing card fails",
0x31: "Preparing card",
0x30: "Normal", // Default if none of the above
}
statusPos1 = map[byte]string{
0x38: "Dispensing card",
0x34: "Capturing card",
0x32: "Dispense card error",
0x31: "Capture card error",
0x30: "Normal",
}
statusPos2 = map[byte]string{
0x38: "No captured card",
0x34: "Card overlapped",
0x32: "Card jammed",
0x31: "Card pre-empty",
0x30: "Normal",
}
statusPos3 = map[byte]string{
0x38: "Card empty",
0x34: "Card ready position",
0x33: "Card at encoder position",
0x32: "Card at hold card position",
0x31: "Card out of card mouth position",
0x30: "Normal",
}
)
func checkStatus(statusResp []byte) (string, error) {
if len(statusResp) > 3 {
statusBytes := statusResp[7:11] // Extract the relevant bytes from the response
// For each position, get the ASCII character, hex value, and mapped meaning.
posStatus := []struct {
pos int
value byte
mapper map[byte]string
}{
{pos: 1, value: statusBytes[0], mapper: statusPos0},
{pos: 2, value: statusBytes[1], mapper: statusPos1},
{pos: 3, value: statusBytes[2], mapper: statusPos2},
{pos: 4, value: statusBytes[3], mapper: statusPos3},
}
result := ""
for _, p := range posStatus {
statusMsg, exists := p.mapper[p.value]
if !exists {
statusMsg = "Unknown status"
}
if p.value != 0x30 {
result += fmt.Sprintf("Status: %s; ", statusMsg)
}
if p.pos == 4 && p.value == 0x38 {
return result, fmt.Errorf("Card well empty")
}
}
return result, nil
} else {
if len(statusResp) == 3 && statusResp[0] == ACK && statusResp[1] == Address[0] && statusResp[2] == Address[1] {
return "active;", nil
} else if len(statusResp) > 0 && statusResp[0] == NAK {
return "", fmt.Errorf("negative response from dispenser")
} else {
return "", fmt.Errorf("unexpected response status: % X", statusResp)
}
}
}
// calculateBCC computes the Block Check Character (BCC) as the XOR of all bytes from STX to ETX.
func calculateBCC(data []byte) byte {
var bcc byte
for _, b := range data {
bcc ^= b
}
return bcc
}
func createPacket(address []byte, command []byte) []byte {
packet := []byte{STX}
packet = append(packet, address...) // Address bytes
packet = append(packet, space) // Space character
packet = append(packet, command...)
packet = append(packet, ETX)
bcc := calculateBCC(packet)
packet = append(packet, bcc)
return packet
}
func buildCheckRF(address []byte) []byte {
return createPacket(address, []byte{STX, 0x52, 0x46})
}
func buildCheckAP(address []byte) []byte {
return createPacket(address, []byte{STX, 0x41, 0x50})
}
func sendAndReceive(port *serial.Port, packet []byte, delay time.Duration) ([]byte, error) {
n, err := port.Write(packet)
if err != nil {
return nil, fmt.Errorf("error writing to port: %w", err)
}
// log.Printf("TX %d bytes: % X", n, packet[:n])
time.Sleep(delay) // Wait for the dispenser to process the command
buf := make([]byte, 128)
n, err = port.Read(buf)
if err != nil {
return nil, fmt.Errorf("error reading from port: %w", err)
}
resp := buf[:n]
// log.Printf("RX %d bytes: % X", n, buf[:n])
return resp, nil
}
func InitializeDispenser() (*serial.Port, error) {
const funcName = "initializeDispenser"
serialConfig := &serial.Config{
Name: SerialPort,
Baud: baudRate,
ReadTimeout: time.Second * 2,
}
port, err := serial.OpenPort(serialConfig)
if err != nil {
return nil, fmt.Errorf("error opening dispenser COM port: %w", err)
}
return port, nil
}
func DispenserSequence(port *serial.Port) (string, error) {
const funcName = "dispenserSequence"
var result string
// Check dispenser status
status, err := CheckDispenserStatus(port)
if err != nil {
return status, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
}
result += status
// Send card to encoder position
status, err = CardToEncoderPosition(port)
if err != nil {
return status, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err)
}
result += "; " + status
return result, nil
}
// if dispenser is not responding, I should repeat the command
func CheckDispenserStatus(port *serial.Port) (string, error) {
const funcName = "checkDispenserStatus"
var result string
checkCmd := buildCheckAP(Address)
enq := append([]byte{ENQ}, Address...)
// Send check command (AP)
statusResp, err := sendAndReceive(port, checkCmd, delay)
if err != nil {
return "", fmt.Errorf("error sending check command: %v", err)
}
if len(statusResp) == 0 {
return "", fmt.Errorf("no response from dispenser")
}
status, err := checkStatus(statusResp)
if err != nil {
return status, err
}
result += "; " + status
// Send ENQ+ADDR to prompt device to execute the command.
statusResp, err = sendAndReceive(port, enq, delay)
if err != nil {
log.Errorf("error sending ENQ: %v", err)
}
if len(statusResp) == 0 {
return "", fmt.Errorf("no response from dispenser")
}
status, err = checkStatus(statusResp)
if err != nil {
return status, err
}
result += status
return result, nil
}
func CardToEncoderPosition(port *serial.Port) (string, error) {
const funcName = "cartToEncoderPosition"
enq := append([]byte{ENQ}, Address...)
//Send Dispense card to encoder position (FC7) ---
dispenseCmd := createPacket(Address, commandFC7)
log.Println("Send card to encoder position")
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
if err != nil {
return "", fmt.Errorf("error sending card to encoder position: %v", err)
}
_, err = checkStatus(statusResp)
if err != nil {
return "", err
}
//Send ENQ to prompt device ---
_, err = port.Write(enq)
if err != nil {
return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
}
time.Sleep(delay)
//Check card position status
status, err := CheckDispenserStatus(port)
if err != nil {
return "", err
}
return status, nil
}
func CardOutOfMouth(port *serial.Port) (string, error) {
const funcName = "CardOutOfMouth"
enq := append([]byte{ENQ}, Address...)
// Send card out of card mouth (FC0) ---
dispenseCmd := createPacket(Address, commandFC0)
log.Println("Send card to out mouth position")
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
if err != nil {
return "", fmt.Errorf("error sending out of mouth command: %v", err)
}
_, err = checkStatus(statusResp)
if err != nil {
return "", err
}
//Send ENQ to prompt device ---
_, err = port.Write(enq)
if err != nil {
return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
}
time.Sleep(delay)
//Check card position status
status, err := CheckDispenserStatus(port)
if err != nil {
return "", err
}
return status, nil
}

5
go.mod
View File

@ -3,10 +3,13 @@ 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
golang.org/x/image v0.27.0

10
go.sum
View File

@ -1,5 +1,5 @@
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190 h1:OxP911wT8HQqBJ20KIZcBxi898rsYHhhCkne2u45p1A=
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.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,12 @@ 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=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View File

@ -1,367 +0,0 @@
package handlers
import (
"bytes"
"database/sql"
"encoding/json"
"encoding/xml"
"io"
"net/http"
"strings"
"time"
"github.com/tarm/serial"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/db"
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/payment"
"gitea.futuresens.co.uk/futuresens/hardlink/printer"
"gitea.futuresens.co.uk/futuresens/hardlink/types"
"gitea.futuresens.co.uk/futuresens/logging"
log "github.com/sirupsen/logrus"
)
type App struct {
dispPort *serial.Port
lockserver lockserver.LockServer
isPayment bool
db *sql.DB
}
func NewApp(dispPort *serial.Port, lockType, encoderAddress string, db *sql.DB, isPayment bool) *App {
return &App{
isPayment: isPayment,
dispPort: dispPort,
lockserver: lockserver.NewLockServer(lockType, encoderAddress, FatalError),
db: db,
}
}
func (app *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/issuedoorcard", app.issueDoorCard)
mux.HandleFunc("/printroomticket", app.printRoomTicket)
mux.HandleFunc("/takepreauth", app.takePreauthorization)
mux.HandleFunc("/takepayment", app.takePayment)
}
func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("takePreauthorization")
var (
theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec
trResult payment.TransactionResultXML
result payment.PaymentResult
save bool
)
theResponse.Status.Code = http.StatusInternalServerError
theResponse.Status.Message = "500 Internal server error"
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")
if !app.isPayment {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("takePreauthorization called")
if r.Method != http.MethodPost {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Method not allowed; use POST")
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
return
}
defer r.Body.Close()
if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return
}
body, _ := io.ReadAll(r.Body)
err := xml.Unmarshal(body, &theRequest)
if err != nil {
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse)
return
}
log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType)
client := &http.Client{Timeout: 300 * time.Second}
response, err := client.Post(types.LinkTakePreauthorization, "text/xml", bytes.NewBuffer(body))
if err != nil {
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return
}
defer response.Body.Close()
body, err = io.ReadAll(response.Body)
if err != nil {
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read response body")
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
return
}
if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(serviceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
}
// Compose JSON from responseEntries
result.FillFromTransactionResult(trResult)
if err := printer.PrintCardholderReceipt(result.CardholderReceipt); err != nil {
log.Errorf("PrintCardholderReceipt error: %v", err)
}
theResponse.Status = result.Status
theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields)
if save {
db.InsertPreauth(r.Context(), app.db, result.Fields, theRequest.CheckoutDate)
}
writeTransactionResult(w, http.StatusOK, theResponse)
}
func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("takePayment")
var (
theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec
trResult payment.TransactionResultXML
result payment.PaymentResult
)
theResponse.Status.Code = http.StatusInternalServerError
theResponse.Status.Message = "500 Internal server error"
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")
if !app.isPayment {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("takePayment called")
if r.Method != http.MethodPost {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Method not allowed; use POST")
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
return
}
defer r.Body.Close()
if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return
}
body, _ := io.ReadAll(r.Body)
err := xml.Unmarshal(body, &theRequest)
if err != nil {
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse)
return
}
log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType)
client := &http.Client{Timeout: 300 * time.Second}
response, err := client.Post(types.LinkTakePayment, "text/xml", bytes.NewBuffer(body))
if err != nil {
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return
}
defer response.Body.Close()
body, err = io.ReadAll(response.Body)
if err != nil {
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read response body")
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
return
}
if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(serviceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
}
// Compose JSON from responseEntries
result.FillFromTransactionResult(trResult)
if err := printer.PrintCardholderReceipt(result.CardholderReceipt); err != nil {
log.Errorf("PrintCardholderReceipt error: %v", err)
}
theResponse.Status = result.Status
theResponse.Data = payment.BuildPaymentRedirectURL(result.Fields)
writeTransactionResult(w, http.StatusOK, theResponse)
}
func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("issueDoorCard")
var (
doorReq lockserver.DoorCardRequest
theResponse cmstypes.StatusRec
)
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")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("issueDoorCard called")
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
return
}
defer r.Body.Close()
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
return
}
if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil {
logging.Error(serviceName, err.Error(), "ReadJSON", string(op), "", "", 0)
writeError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
return
}
// parse times
checkIn, err := time.Parse(types.CustomLayout, doorReq.CheckinTime)
if err != nil {
logging.Error(serviceName, err.Error(), "Invalid checkinTime format", string(op), "", "", 0)
writeError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error())
return
}
checkOut, err := time.Parse(types.CustomLayout, doorReq.CheckoutTime)
if err != nil {
logging.Error(serviceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0)
writeError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error())
return
}
// dispenser sequence
if status, err := dispenser.DispenserSequence(app.dispPort); err != nil {
if status != "" {
logging.Error(serviceName, status, "Dispense error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
} else {
logging.Error(serviceName, err.Error(), "Dispense error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()+"; check card stock")
}
return
} else {
log.Info(status)
}
// build lock server command
app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
// lock server sequence
err = app.lockserver.LockSequence()
if err != nil {
logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0)
writeError(w, http.StatusBadGateway, err.Error())
dispenser.CardOutOfMouth(app.dispPort)
return
}
// final dispenser steps
if status, err := dispenser.CardOutOfMouth(app.dispPort); err != nil {
logging.Error(serviceName, err.Error(), "Dispenser eject error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, "Dispenser eject error: "+err.Error())
return
} else {
log.Info(status)
}
theResponse.Code = http.StatusOK
theResponse.Message = "Card issued successfully"
// success! return 200 and any data you like
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(theResponse)
}
func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("printRoomTicket")
var roomDetails printer.RoomDetailsRec
// Allow CORS preflight if needed
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")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("printRoomTicket called")
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
return
}
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "xml") {
writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/xml")
return
}
defer r.Body.Close()
if err := xml.NewDecoder(r.Body).Decode(&roomDetails); err != nil {
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
writeError(w, http.StatusBadRequest, "Invalid XML payload: "+err.Error())
return
}
data, err := printer.BuildRoomTicket(roomDetails)
if err != nil {
logging.Error(serviceName, err.Error(), "BuildRoomTicket", string(op), "", "", 0)
writeError(w, http.StatusInternalServerError, "BuildRoomTicket failed: "+err.Error())
return
}
// Send to the Windows Epson TM-T82II via the printer package
if err := printer.SendToPrinter(data); err != nil {
logging.Error(serviceName, err.Error(), "printRoomTicket", "printRoomTicket", "", "", 0)
writeError(w, http.StatusInternalServerError, "Print failed: "+err.Error())
return
}
// Success
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(cmstypes.StatusRec{
Code: http.StatusOK,
Message: "Print job sent successfully",
})
}

View File

@ -4,16 +4,17 @@ package main
import (
"fmt"
"os"
"time"
"gitea.futuresens.co.uk/futuresens/hardlink/bootstrap"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/bootstrap"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/logging"
"gitea.futuresens.co.uk/futuresens/hardlink/payment"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/logging"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
log "github.com/sirupsen/logrus"
)
const (
buildVersion = "1.0.0"
buildVersion = "1.0.2"
serviceName = "preauth-release"
)
@ -31,12 +32,18 @@ func main() {
}
defer database.Close()
if err := payment.ReleasePreauthorizations(database); err != nil {
log.WithError(err).Fatal("Preauth release failed")
if err := creditcall.ReleasePreauthorizations(database); err != nil {
log.Error(err)
fmt.Println(err)
} else {
log.Info("Task completed successfully")
fmt.Println("Task completed successfully")
}
log.Info("Task completed successfully")
fmt.Println(". Press Enter to exit...")
fmt.Scanln()
for i := 20; i > 0; i-- {
fmt.Printf("\rExiting in %2d seconds... ", i)
time.Sleep(time.Second)
}
fmt.Println("\rExiting now. ")
os.Exit(0)
}

View File

@ -5,7 +5,7 @@ import (
"database/sql"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/db"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/db"
)
func OpenDB(cfg *config.ConfigRec) (*sql.DB, error) {

View File

@ -0,0 +1,278 @@
package creditcall
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"html"
"io"
"net/http"
"strings"
"time"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/logging"
)
const (
KeyErrors = "ERRORS"
KeyVersionInformation = "VERSION_INFORMATION"
KeyChipDnaStatus = "CHIPDNA_STATUS"
KeyPaymentDeviceStatus = "PAYMENT_DEVICE_STATUS"
KeyRequestQueueStatus = "REQUEST_QUEUE_STATUS"
KeyTmsStatus = "TMS_STATUS"
KeyPaymentPlatform = "PAYMENT_PLATFORM_STATUS"
KeyPaymentDeviceModel = "PAYMENT_DEVICE_MODEL"
KeyPaymentDeviceIdentifier = "PAYMENT_DEVICE_IDENTIFIER"
KeyIsAvailable = "IS_AVAILABLE"
KeyAvailabilityError = "AVAILABILITY_ERROR"
KeyAvailabilityErrorInformation = "AVAILABILITY_ERROR_INFORMATION"
)
type (
ArrayOfParameter struct {
Parameters []Parameter `xml:"Parameter" json:"Parameters"`
}
Parameter struct {
Key string `xml:"Key" json:"Key"`
Value string `xml:"Value" json:"Value"`
}
ServerStatus struct {
IsProcessingTransaction bool `xml:"IsProcessingTransaction" json:"IsProcessingTransaction"`
ChipDnaServerIssue string `xml:"ChipDnaServerIssue" json:"ChipDnaServerIssue"`
}
ArrayOfPaymentDeviceStatus struct {
Items []PaymentDeviceStatus `xml:"PaymentDeviceStatus" json:"Items"`
}
PaymentDeviceStatus struct {
ConfiguredDeviceId string `xml:"ConfiguredDeviceId" json:"ConfiguredDeviceId"`
ConfiguredDeviceModel string `xml:"ConfiguredDeviceModel" json:"ConfiguredDeviceModel"`
ProcessingTransaction bool `xml:"ProcessingTransaction" json:"ProcessingTransaction"`
AvailabilityError string `xml:"AvailabilityError" json:"AvailabilityError"`
AvailabilityErrorInformation string `xml:"AvailabilityErrorInformation" json:"AvailabilityErrorInformation"`
ConfigurationState string `xml:"ConfigurationState" json:"ConfigurationState"`
IsAvailable bool `xml:"IsAvailable" json:"IsAvailable"`
BatteryPercentage int `xml:"BatteryPercentage" json:"BatteryPercentage"`
BatteryChargingStatus string `xml:"BatteryChargingStatus" json:"BatteryChargingStatus"`
BatteryStatusUpdateDateTime string `xml:"BatteryStatusUpdateDateTime" json:"BatteryStatusUpdateDateTime"`
BatteryStatusUpdateDateTimeFormat string `xml:"BatteryStatusUpdateDateTimeFormat" json:"BatteryStatusUpdateDateTimeFormat"`
}
RequestQueueStatus struct {
CreditRequestCount int `xml:"CreditRequestCount" json:"CreditRequestCount"`
CreditConfirmRequestCount int `xml:"CreditConfirmRequestCount" json:"CreditConfirmRequestCount"`
CreditVoidRequestCount int `xml:"CreditVoidRequestCount" json:"CreditVoidRequestCount"`
DebitRequestCount int `xml:"DebitRequestCount" json:"DebitRequestCount"`
DebitConfirmRequestCount int `xml:"DebitConfirmRequestCount" json:"DebitConfirmRequestCount"`
DebitVoidRequestCount int `xml:"DebitVoidRequestCount" json:"DebitVoidRequestCount"`
}
TmsStatus struct {
LastConfigUpdateDateTime string `xml:"LastConfigUpdateDateTime" json:"LastConfigUpdateDateTime"`
DaysUntilConfigUpdateIsRequired int `xml:"DaysUntilConfigUpdateIsRequired" json:"DaysUntilConfigUpdateIsRequired"`
RequiredConfigUpdateDateTime string `xml:"RequiredConfigUpdateDateTime" json:"RequiredConfigUpdateDateTime"`
}
PaymentPlatformStatus struct {
MachineLocalDateTime string `xml:"MachineLocalDateTime" json:"MachineLocalDateTime"`
PaymentPlatformLocalDateTime string `xml:"PaymentPlatformLocalDateTime" json:"PaymentPlatformLocalDateTime"`
PaymentPlatformLocalDateTimeFormat string `xml:"PaymentPlatformLocalDateTimeFormat" json:"PaymentPlatformLocalDateTimeFormat"`
State string `xml:"State" json:"State"`
}
ParsedStatus struct {
Errors []string `json:"Errors"`
VersionInfo map[string]string `json:"VersionInfo"`
ChipDnaStatus *ServerStatus `json:"ChipDnaStatus"`
PaymentDevices []PaymentDeviceStatus `json:"PaymentDevices"`
RequestQueue *RequestQueueStatus `json:"RequestQueue"`
TMS *TmsStatus `json:"TMS"`
PaymentPlatform *PaymentPlatformStatus `json:"PaymentPlatform"`
Unknown map[string]string `json:"Unknown"`
}
)
// ===========================
// Parser
// ===========================
func ParseStatusResult(data []byte) (*ParsedStatus, error) {
var tr TransactionResultXML
if err := tr.ParseTransactionResult(data); err != nil {
return nil, fmt.Errorf("unmarshal TransactionResult: %w", err)
}
out := &ParsedStatus{
VersionInfo: make(map[string]string),
Unknown: make(map[string]string),
}
for _, e := range tr.Entries {
switch e.Key {
// Some responses return plain text (not escaped XML) for ERRORS.
case KeyErrors:
msg := html.UnescapeString(e.Value) // safe even if not escaped
if msg != "" {
out.Errors = append(out.Errors, msg)
}
// Everything below is escaped XML inside <Value>
case KeyVersionInformation:
unescaped := html.UnescapeString(e.Value)
var a ArrayOfParameter
if err := xml.Unmarshal([]byte(unescaped), &a); err != nil {
return nil, fmt.Errorf("unmarshal %s: %w", e.Key, err)
}
for _, p := range a.Parameters {
out.VersionInfo[p.Key] = p.Value
}
case KeyChipDnaStatus:
unescaped := html.UnescapeString(e.Value)
var s ServerStatus
if err := xml.Unmarshal([]byte(unescaped), &s); err != nil {
return nil, fmt.Errorf("unmarshal %s: %w", e.Key, err)
}
out.ChipDnaStatus = &s
case KeyPaymentDeviceStatus:
unescaped := html.UnescapeString(e.Value)
var a ArrayOfPaymentDeviceStatus
if err := xml.Unmarshal([]byte(unescaped), &a); err != nil {
return nil, fmt.Errorf("unmarshal %s: %w", e.Key, err)
}
out.PaymentDevices = append(out.PaymentDevices, a.Items...)
case KeyRequestQueueStatus:
unescaped := html.UnescapeString(e.Value)
var s RequestQueueStatus
if err := xml.Unmarshal([]byte(unescaped), &s); err != nil {
return nil, fmt.Errorf("unmarshal %s: %w", e.Key, err)
}
out.RequestQueue = &s
case KeyTmsStatus:
unescaped := html.UnescapeString(e.Value)
var s TmsStatus
if err := xml.Unmarshal([]byte(unescaped), &s); err != nil {
return nil, fmt.Errorf("unmarshal %s: %w", e.Key, err)
}
out.TMS = &s
case KeyPaymentPlatform:
unescaped := html.UnescapeString(e.Value)
var s PaymentPlatformStatus
if err := xml.Unmarshal([]byte(unescaped), &s); err != nil {
return nil, fmt.Errorf("unmarshal %s: %w", e.Key, err)
}
out.PaymentPlatform = &s
default:
// Keep for logging / future additions. Unescape so it's readable XML if it was escaped.
out.Unknown[e.Key] = html.UnescapeString(e.Value)
}
}
return out, nil
}
func fetchChipDNAStatus() (*ParsedStatus, error) {
const op = logging.Op("fetchChipDNAStatus")
body := []byte{}
client := &http.Client{Timeout: 300 * time.Second}
response, err := client.Post(types.LinkChipDNAStatus, "text/xml", bytes.NewBuffer(body))
if err != nil {
logging.Error(types.ServiceName, err.Error(), "error fetching ChipDNA status", string(op), "", "", 0)
return nil, err
}
defer response.Body.Close()
body, err = io.ReadAll(response.Body)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Read response body error", string(op), "", "", 0)
return nil, err
}
result, err := ParseStatusResult(body)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Parse ChipDNA status error", string(op), "", "", 0)
return nil, err
}
return result, nil
}
func ReadPdqStatus(hotel string, kiosk int) (PaymentDeviceStatus, error) {
const op = logging.Op("readPdqStatus")
status, err := fetchChipDNAStatus()
if err != nil {
logging.Error(types.ServiceName, "pdq_unavailable", "Failed to fetch ChipDNA status: "+err.Error(), string(op), "", hotel, kiosk)
return PaymentDeviceStatus{}, fmt.Errorf("error fetch ChipDNA status: %w", err)
}
if len(status.Errors) > 0 {
msg := strings.Join(status.Errors, "; ")
logging.Error(types.ServiceName, "pdq_unavailable", "ChipDNA status errors: "+msg, string(op), "", hotel, kiosk)
return PaymentDeviceStatus{}, fmt.Errorf("ChipDNA status errors: %s", msg)
}
if len(status.PaymentDevices) == 0 {
logging.Error(types.ServiceName, "pdq_unavailable", "ChipDNA status has no PAYMENT_DEVICE_STATUS items", string(op), "", hotel, kiosk)
return PaymentDeviceStatus{}, fmt.Errorf("no payment devices returned")
}
dev := status.PaymentDevices[0]
if !dev.IsAvailable {
logging.Error(types.ServiceName, "pdq_unavailable", "Payment device unavailable", string(op), "", hotel, kiosk)
return dev, fmt.Errorf("device unavailable")
}
return dev, nil
}
func StartPdqHourlyCheck(ctx context.Context, hotel string, kiosk int) {
// waitUntilNextHour(ctx)
// First execution exactly at round hour
_, _ = ReadPdqStatus(hotel, kiosk)
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_, _ = ReadPdqStatus(hotel, kiosk)
}
}
}
func waitUntilNextHour(ctx context.Context) {
now := time.Now()
next := now.Truncate(time.Hour).Add(time.Hour)
d := time.Until(next)
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
}
}

View File

@ -1,4 +1,4 @@
package payment
package creditcall
import (
"encoding/hex"
@ -9,7 +9,7 @@ import (
"strings"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/types"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
_ "github.com/denisenkom/go-mssqldb"
log "github.com/sirupsen/logrus"
)
@ -50,6 +50,12 @@ type (
transactionRes string
transactionState string
}
ConfirmTransactionRequest struct {
XMLName xml.Name `xml:"ConfirmTransactionRequest"`
Amount string `xml:"Amount"`
Reference string `xml:"TransactionReference"`
}
)
// ParseTransactionResult parses the XML into entries.
@ -72,13 +78,10 @@ func (ti *TransactionInfo) FillFromTransactionResult(trResult TransactionResultX
}
func (r *PaymentResult) FillFromTransactionResult(trResult TransactionResultXML) {
if r.Fields == nil {
r.Fields = make(map[string]string)
}
r.Fields = make(map[string]string)
for _, e := range trResult.Entries {
switch e.Key {
case types.ReceiptData, types.ReceiptDataMerchant:
// intentionally ignored
@ -96,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]
@ -107,7 +110,7 @@ func BuildPaymentRedirectURL(result map[string]string) string {
log.WithField(types.LogResult, result[types.ConfirmResult]).
Info("Transaction approved and confirmed")
return buildSuccessURL(result)
return BuildSuccessURL(result)
}
// Not confirmed
@ -133,7 +136,7 @@ func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
log.WithField(types.LogResult, result[types.TransactionResult]).
Info("Account verification approved")
return buildSuccessURL(result), false
return BuildSuccessURL(result), false
// Transaction type Sale?
case strings.EqualFold(tType, types.SaleTransactionType):
@ -141,7 +144,7 @@ func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
log.WithField(types.LogResult, result[types.ConfirmResult]).
Info("Amount preauthorized successfully")
return buildSuccessURL(result), true
return BuildSuccessURL(result), true
}
}
@ -149,10 +152,11 @@ func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
return BuildFailureURL(res, result[types.Errors]), false
}
func buildSuccessURL(result map[string]string) string {
func BuildSuccessURL(result map[string]string) string {
q := url.Values{}
q.Set("CardNumber", hex.EncodeToString([]byte(result[types.PAN_MASKED])))
q.Set("ExpiryDate", hex.EncodeToString([]byte(result[types.EXPIRY_DATE])))
q.Set("CardNumber", hex.EncodeToString([]byte(result[types.PanMasked])))
q.Set("CardType", hex.EncodeToString([]byte(result[types.CardType])))
q.Set("ExpiryDate", hex.EncodeToString([]byte(result[types.ExpiryDate])))
q.Set("TxnReference", result[types.Reference])
q.Set("CardHash", hex.EncodeToString([]byte(result[types.CardHash])))
q.Set("CardReference", hex.EncodeToString([]byte(result[types.CardReference])))

View File

@ -1,4 +1,4 @@
package payment
package creditcall
import (
"bytes"
@ -11,8 +11,8 @@ import (
"strings"
"time"
"gitea.futuresens.co.uk/futuresens/hardlink/db"
"gitea.futuresens.co.uk/futuresens/hardlink/types"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/db"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
log "github.com/sirupsen/logrus"
)
@ -21,7 +21,7 @@ const (
)
/* ==============================
Public Entry Point (LEGACY)
Public Entry Point
============================== */
func ReleasePreauthorizations(database *sql.DB) error {
@ -78,6 +78,7 @@ func handlePreauthRelease(
if err != nil {
return err
}
log.Infof("res=%s state=%s", info.transactionRes, info.transactionState)
// If already voided or declined → mark released
if isAlreadyReleased(info) {
@ -86,8 +87,7 @@ func handlePreauthRelease(
// Only void approved + uncommitted
if !isVoidable(info) {
log.Infof("Preauth %s not eligible for void (res=%s state=%s)",
ref, info.transactionRes, info.transactionState)
log.Infof("Preauth %s not eligible for void (res=%s state=%s)", ref, info.transactionRes, info.transactionState)
return nil
}

View File

@ -12,7 +12,7 @@ import (
mssqldb "github.com/denisenkom/go-mssqldb" // for error inspection
log "github.com/sirupsen/logrus"
"gitea.futuresens.co.uk/futuresens/hardlink/types"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
)
// InitMSSQL opens and pings the SQL Server instance (keeps your original behaviour)
@ -198,3 +198,4 @@ WHERE TxnReference = @TxnReference AND Released = 0;
log.Infof("Marked preauth %s released at %s", txnReference, releasedAt.Format(time.RFC3339))
return nil
}

View File

@ -0,0 +1,289 @@
package dispenser
import (
"fmt"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/tarm/serial"
)
// Control characters.
const (
STX = 0x02 // Start of Text
ETX = 0x03 // End of Text
ACK = 0x06 // Positive response
NAK = 0x15 // Negative response
ENQ = 0x05 // Enquiry from host
space = 0x00 // Space character
baudRate = 9600 // Baud rate for serial communication
delay = 500 * time.Millisecond // Delay for processing commands
// cache freshness for "continuous status" reads (tune as you wish)
defaultStatusTTL = 1500 * time.Millisecond
)
var (
SerialPort string
Address []byte
commandFC7 = []byte{ETX, 0x46, 0x43, 0x37} // "FC7"
commandFC0 = []byte{ETX, 0x46, 0x43, 0x30} // "FC0"
statusPos0 = map[byte]string{
0x38: "Keep",
0x34: "Command cannot execute",
0x32: "Preparing card fails",
0x31: "Preparing card",
0x30: "Normal",
0x36: "Command cannot execute; Preparing card fails",
}
statusPos1 = map[byte]string{
0x38: "Dispensing card",
0x34: "Capturing card",
0x32: "Dispense card error",
0x31: "Capture card error",
0x30: "Normal",
}
statusPos2 = map[byte]string{
0x38: "No captured card",
0x34: "Card overlapped",
0x32: "Card jammed",
0x31: "Card pre-empty",
0x30: "Normal",
}
statusPos3 = map[byte]string{
0x38: "Card empty",
0x34: "Card ready position",
0x33: "Card at encoder position",
0x32: "Card at hold card position",
0x31: "Card out of card mouth position",
0x30: "Normal",
}
)
// --------------------
// Status helpers
// --------------------
func logStatus(statusBytes []byte) {
if len(statusBytes) < 4 {
log.Infof("Dispenser status: <invalid len=%d>", len(statusBytes))
return
}
posStatus := []struct {
pos int
value byte
mapper map[byte]string
}{
{pos: 1, value: statusBytes[0], mapper: statusPos0},
{pos: 2, value: statusBytes[1], mapper: statusPos1},
{pos: 3, value: statusBytes[2], mapper: statusPos2},
{pos: 4, value: statusBytes[3], mapper: statusPos3},
}
var result strings.Builder
for _, p := range posStatus {
statusMsg, exists := p.mapper[p.value]
if !exists {
statusMsg = fmt.Sprintf("Unknown status 0x%X at position %d", p.value, p.pos)
}
if p.value != 0x30 {
result.WriteString(statusMsg + "; ")
}
}
log.Infof("Dispenser status: %s", result.String())
}
func isAtEncoderPosition(statusBytes []byte) bool {
return len(statusBytes) >= 4 && statusBytes[3] == 0x33
}
func stockTake(statusBytes []byte) string {
if len(statusBytes) < 4 {
return ""
}
status := ""
if statusBytes[0] == 0x32 || statusBytes[0] == 0x36 {
status = statusPos0[statusBytes[0]]
}
if statusBytes[2] != 0x30 {
status = statusPos2[statusBytes[2]]
}
if statusBytes[3] == 0x38 {
status = statusPos3[statusBytes[3]]
}
return status
}
func isCardWellEmpty(statusBytes []byte) bool {
return len(statusBytes) >= 4 && statusBytes[3] == 0x38
}
func checkACK(statusResp []byte) error {
if len(statusResp) == 3 &&
statusResp[0] == ACK &&
len(Address) >= 2 &&
statusResp[1] == Address[0] &&
statusResp[2] == Address[1] {
return nil
}
if len(statusResp) > 0 && statusResp[0] == NAK {
return fmt.Errorf("negative response from dispenser")
}
return fmt.Errorf("unexpected response status: % X", statusResp)
}
// calculateBCC computes BCC as XOR of all bytes from STX to ETX.
func calculateBCC(data []byte) byte {
var bcc byte
for _, b := range data {
bcc ^= b
}
return bcc
}
func createPacket(address []byte, command []byte) []byte {
packet := []byte{STX}
packet = append(packet, address...)
packet = append(packet, space)
packet = append(packet, command...)
packet = append(packet, ETX)
bcc := calculateBCC(packet)
packet = append(packet, bcc)
return packet
}
func buildCheckAP(address []byte) []byte { return createPacket(address, []byte{STX, 0x41, 0x50}) }
func sendAndReceive(port *serial.Port, packet []byte, delay time.Duration) ([]byte, error) {
_, err := port.Write(packet)
if err != nil {
return nil, fmt.Errorf("error writing to port: %w", err)
}
time.Sleep(delay)
buf := make([]byte, 128)
n, err := port.Read(buf)
if err != nil {
return nil, fmt.Errorf("error reading from port: %w", err)
}
return buf[:n], nil
}
// --------------------
// Serial init (3 attempts)
// --------------------
func InitializeDispenser() (*serial.Port, error) {
const (
funcName = "InitializeDispenser"
maxRetries = 3
retryDelay = 4 * time.Second
)
if SerialPort == "" {
return nil, fmt.Errorf("%s: SerialPort is empty", funcName)
}
if len(Address) < 2 {
return nil, fmt.Errorf("%s: Address must be at least 2 bytes", funcName)
}
serialConfig := &serial.Config{
Name: SerialPort,
Baud: baudRate,
ReadTimeout: 2 * time.Second,
}
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
port, err := serial.OpenPort(serialConfig)
if err == nil {
log.Infof("%s: dispenser opened on %s (attempt %d/%d)", funcName, SerialPort, attempt, maxRetries)
return port, nil
}
lastErr = err
log.Warnf("%s: failed to open dispenser on %s (attempt %d/%d): %v", funcName, SerialPort, attempt, maxRetries, err)
if attempt < maxRetries {
time.Sleep(retryDelay)
}
}
return nil, fmt.Errorf("%s: failed to open dispenser on %s after %d attempts: %w", funcName, SerialPort, maxRetries, lastErr)
}
// --------------------
// Internal (port-owner only) operations
// --------------------
// checkDispenserStatus talks to the device and returns the 4 status bytes [pos0..pos3].
func checkDispenserStatus(port *serial.Port) ([]byte, error) {
checkCmd := buildCheckAP(Address)
enq := append([]byte{ENQ}, Address...)
statusResp, err := sendAndReceive(port, checkCmd, delay)
if err != nil {
return nil, fmt.Errorf("error sending check command: %w", err)
}
if len(statusResp) == 0 {
return nil, fmt.Errorf("no response from dispenser")
}
if err := checkACK(statusResp); err != nil {
return nil, err
}
statusResp, err = sendAndReceive(port, enq, delay)
if err != nil {
return nil, fmt.Errorf("error sending ENQ: %w", err)
}
if len(statusResp) < 13 {
return nil, fmt.Errorf("incomplete status response from dispenser: % X", statusResp)
}
return statusResp[7:11], nil
}
func cardToEncoderPosition(port *serial.Port) error {
enq := append([]byte{ENQ}, Address...)
dispenseCmd := createPacket(Address, commandFC7)
log.Println("Send card to encoder position")
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
if err != nil {
return fmt.Errorf("error sending card to encoder position: %w", err)
}
if err := checkACK(statusResp); err != nil {
return err
}
_, err = port.Write(enq)
if err != nil {
return fmt.Errorf("error sending ENQ to prompt device: %w", err)
}
return nil
}
func cardOutOfMouth(port *serial.Port) error {
enq := append([]byte{ENQ}, Address...)
dispenseCmd := createPacket(Address, commandFC0)
log.Println("Send card to out mouth position")
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
if err != nil {
return fmt.Errorf("error sending out of mouth command: %w", err)
}
if err := checkACK(statusResp); err != nil {
return err
}
_, err = port.Write(enq)
if err != nil {
return fmt.Errorf("error sending ENQ to prompt device: %w", err)
}
return nil
}

View File

@ -0,0 +1,380 @@
// Package dispenser provides a queue-based client (single owner of port).
package dispenser
import (
"context"
"fmt"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/tarm/serial"
)
type cmdType int
const (
cmdStatus cmdType = iota
cmdToEncoder
cmdOutOfMouth
)
type cmdReq struct {
typ cmdType
ctx context.Context
respCh chan cmdResp
}
type cmdResp struct {
status []byte
err error
}
type Client struct {
port *serial.Port
reqCh chan cmdReq
done chan struct{}
// status cache
mu sync.RWMutex
lastStatus []byte
lastStatusT time.Time
statusTTL time.Duration
// published "stock/cardwell" cache + callback
lastStockMu sync.RWMutex
lastStock string
onStock func(string)
}
// NewClient starts the worker that owns the serial port.
func NewClient(port *serial.Port, queueSize int) *Client {
if queueSize <= 0 {
queueSize = 16
}
c := &Client{
port: port,
reqCh: make(chan cmdReq, queueSize),
done: make(chan struct{}),
statusTTL: defaultStatusTTL,
}
go c.loop()
return c
}
func (c *Client) Close() {
select {
case <-c.done:
return
default:
close(c.done)
}
}
// SetStatusTTL sets the duration for which cached status is considered fresh.
func (c *Client) SetStatusTTL(d time.Duration) {
c.mu.Lock()
c.statusTTL = d
c.mu.Unlock()
}
// OnStockUpdate registers a callback called whenever polling (or status reads) produce a stock status string.
func (c *Client) OnStockUpdate(fn func(string)) {
c.lastStockMu.Lock()
c.onStock = fn
c.lastStockMu.Unlock()
}
// LastStock returns the most recently computed stock/card-well status string.
func (c *Client) LastStock() string {
c.lastStockMu.RLock()
defer c.lastStockMu.RUnlock()
return c.lastStock
}
func (c *Client) setStock(statusBytes []byte) {
stock := stockTake(statusBytes)
c.lastStockMu.Lock()
c.lastStock = stock
fn := c.onStock
c.lastStockMu.Unlock()
// call outside lock
if fn != nil {
fn(stock)
}
}
// StartPolling performs a periodic status refresh.
// It will NOT interrupt commands: it enqueues only when queue is idle.
func (c *Client) StartPolling(interval time.Duration) {
if interval <= 0 {
return
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-c.done:
return
case <-t.C:
// enqueue only if idle to avoid delaying real commands
if len(c.reqCh) != 0 {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_, err := c.CheckStatus(ctx)
if err != nil {
log.Debugf("dispenser polling: %v", err)
}
cancel()
}
}
}()
}
func (c *Client) loop() {
for {
select {
case <-c.done:
return
case req := <-c.reqCh:
c.handle(req)
}
}
}
func (c *Client) handle(req cmdReq) {
select {
case <-req.ctx.Done():
req.respCh <- cmdResp{err: req.ctx.Err()}
return
default:
}
switch req.typ {
case cmdStatus:
st, err := checkDispenserStatus(c.port)
if err == nil && len(st) == 4 {
c.mu.Lock()
c.lastStatus = append([]byte(nil), st...)
c.lastStatusT = time.Now()
c.mu.Unlock()
// publish stock/cardwell
c.setStock(st)
}
req.respCh <- cmdResp{status: st, err: err}
case cmdToEncoder:
err := cardToEncoderPosition(c.port)
req.respCh <- cmdResp{err: err}
case cmdOutOfMouth:
err := cardOutOfMouth(c.port)
req.respCh <- cmdResp{err: err}
default:
req.respCh <- cmdResp{err: fmt.Errorf("unknown command")}
}
}
func (c *Client) do(ctx context.Context, typ cmdType) ([]byte, error) {
rch := make(chan cmdResp, 1)
req := cmdReq{typ: typ, ctx: ctx, respCh: rch}
select {
case c.reqCh <- req:
case <-ctx.Done():
return nil, ctx.Err()
}
select {
case r := <-rch:
return r.status, r.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
// CheckStatus returns cached status if fresh, otherwise enqueues a device status read.
func (c *Client) CheckStatus(ctx context.Context) ([]byte, error) {
c.mu.RLock()
ttl := c.statusTTL
st := append([]byte(nil), c.lastStatus...)
ts := c.lastStatusT
c.mu.RUnlock()
if len(st) == 4 && time.Since(ts) <= ttl {
// even when returning cached, keep stock in sync
c.setStock(st)
return st, nil
}
return c.do(ctx, cmdStatus)
}
func (c *Client) ToEncoder(ctx context.Context) error {
_, err := c.do(ctx, cmdToEncoder)
return err
}
func (c *Client) OutOfMouth(ctx context.Context) error {
_, err := c.do(ctx, cmdOutOfMouth)
return err
}
// --------------------
// Public sequences updated to use Client (queue)
// --------------------
// DispenserPrepare checks status; if empty => ok; else ensure at encoder.
func (c *Client) DispenserPrepare(ctx context.Context) (string, error) {
const funcName = "DispenserPrepare"
stockStatus := ""
status, err := c.CheckStatus(ctx)
if err != nil {
return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err)
}
logStatus(status)
stockStatus = stockTake(status)
c.setStock(status)
if isCardWellEmpty(status) {
return stockStatus, nil
}
if isAtEncoderPosition(status) {
return stockStatus, nil
}
if err := c.ToEncoder(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
}
time.Sleep(delay)
status, err = c.CheckStatus(ctx)
if err != nil {
return stockStatus, fmt.Errorf("[%s] re-check status: %w", funcName, err)
}
logStatus(status)
stockStatus = stockTake(status)
c.setStock(status)
return stockStatus, nil
}
func (c *Client) DispenserStart(ctx context.Context) (string, error) {
const funcName = "DispenserStart"
stockStatus := ""
status, err := c.CheckStatus(ctx)
if err != nil {
return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err)
}
defer func() {
logStatus(status)
}()
stockStatus = stockTake(status)
c.setStock(status)
if isCardWellEmpty(status) {
return stockStatus, fmt.Errorf(stockStatus)
}
if isAtEncoderPosition(status) {
return stockStatus, nil
}
if err := c.ToEncoder(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
}
halfway := time.Now().Add(6 * time.Second)
deadline := time.Now().Add(12 * time.Second)
for {
time.Sleep(delay * 2)
switch {
case time.Now().After(halfway):
if err := c.ToEncoder(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
}
case time.Now().After(deadline):
return stockStatus, nil
}
status, _ = c.do(ctx, cmdStatus)
stockStatus = stockTake(status)
c.setStock(status)
logStatus(status)
// error states first
if isCardWellEmpty(status) {
return stockStatus, fmt.Errorf(stockStatus)
}
if isAtEncoderPosition(status) {
return stockStatus, nil
}
}
}
func (c *Client) DispenserFinal(ctx context.Context) (string, error) {
const funcName = "DispenserFinal"
stockStatus := ""
var status []byte
if err := c.OutOfMouth(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] out of mouth: %w", funcName, err)
}
time.Sleep(delay)
status, err := c.do(ctx, cmdStatus)
if err == nil && len(status) >= 4 {
c.setStock(status)
}
time.Sleep(delay)
if err := c.ToEncoder(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
}
defer func() {
logStatus(status)
}()
halfway := time.Now().Add(6 * time.Second)
deadline := time.Now().Add(12 * time.Second)
for {
time.Sleep(delay * 2)
switch {
case time.Now().After(halfway):
if err := c.ToEncoder(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
}
case time.Now().After(deadline):
return stockStatus, nil
}
status, _ = c.do(ctx, cmdStatus)
stockStatus = stockTake(status)
c.setStock(status)
logStatus(status)
if isCardWellEmpty(status) {
return stockStatus, nil
}
if isAtEncoderPosition(status) {
return stockStatus, nil
}
}
}

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

@ -0,0 +1,326 @@
package dojo
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
)
type Config struct {
BaseURL string
APIKey string
SoftwareHouseID string
Version string
TerminalID string
}
type Client struct {
baseURL string
apiKey string
version string
softwareHouseID string
terminalID string
httpClient *http.Client
}
func NewClient(cfg Config) (*Client, error) {
if cfg.BaseURL == "" {
return nil, fmt.Errorf("dojo base_url is required")
}
if cfg.APIKey == "" {
return nil, fmt.Errorf("dojo api_key is required")
}
if cfg.Version == "" {
cfg.Version = "2026-02-27"
}
if cfg.SoftwareHouseID == "" {
return nil, fmt.Errorf("dojo software_house_id is required")
}
if cfg.TerminalID == "" {
return nil, fmt.Errorf("dojo terminal_id is required")
}
return &Client{
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
apiKey: cfg.APIKey,
version: cfg.Version,
softwareHouseID: cfg.SoftwareHouseID,
terminalID: cfg.TerminalID,
httpClient: &http.Client{Timeout: 30 * time.Second},
}, nil
}
func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*paymentsvc.Result, error) {
if req.Currency == "" {
req.Currency = "GBP"
}
intent, err := c.createPaymentIntent(ctx, req)
if err != nil {
return nil, err
}
session, err := c.createTerminalSession(ctx, intent.ID)
if err != nil {
return nil, err
}
session, err = c.waitForTerminalSession(ctx, session.ID)
if err != nil {
return nil, err
}
switch strings.ToLower(session.Status) {
case types.ResultCaptured, types.ResultSignatureAccepted:
intent, err = c.getPaymentIntent(ctx, intent.ID)
if err != nil {
return nil, err
}
if strings.ToLower(intent.Status) != types.ResultCaptured {
return nil, fmt.Errorf("Dojo terminal session is %s but payment intent is %s", session.Status, intent.Status)
}
return c.mapCapturedResult(req, intent), nil
case types.ResultCancelled, types.ResultCanceled, types.ResultDeclined, types.ResultExpired, types.ResultSignatureRejected:
return c.mapTerminalFailure(req, session), nil
case types.ResultSignatureRequired:
result := c.baseResult(req, session.PaymentDetails)
result.Status = "SIGNATURE_VERIFICATION_REQUIRED"
result.ErrorMessage = "Dojo signature verification is required but is not supported"
return result, nil
default:
return nil, fmt.Errorf("unexpected final Dojo terminal session status %q", session.Status)
}
}
func (c *Client) createPaymentIntent(ctx context.Context, req paymentsvc.SaleRequest) (*paymentIntentResponse, error) {
payload := createPaymentIntentRequest{
Amount: money{
Value: req.Amount,
CurrencyCode: req.Currency,
},
Reference: dojoReference(req),
CaptureMode: "Auto",
}
var response paymentIntentResponse
if err := c.doJSON(ctx, http.MethodPost, "/payment-intents", payload, false, &response); err != nil {
return nil, fmt.Errorf("create Dojo payment intent: %w", err)
}
if response.ID == "" {
return nil, fmt.Errorf("create Dojo payment intent: response did not contain id")
}
return &response, nil
}
func (c *Client) createTerminalSession(ctx context.Context, paymentIntentID string) (*terminalSessionResponse, error) {
payload := createTerminalSessionRequest{
TerminalID: c.terminalID,
Details: terminalSessionDetails{
SessionType: "Sale",
Sale: terminalSessionSale{
PaymentIntentID: paymentIntentID,
},
},
}
var response terminalSessionResponse
if err := c.doJSON(ctx, http.MethodPost, "/terminal-sessions", payload, true, &response); err != nil {
return nil, fmt.Errorf("create Dojo terminal session: %w", err)
}
if response.ID == "" {
return nil, fmt.Errorf("create Dojo terminal session: response did not contain id")
}
return &response, nil
}
func (c *Client) waitForTerminalSession(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
session, err := c.getTerminalSession(ctx, terminalSessionID)
if err != nil {
return nil, err
}
switch strings.ToLower(session.Status) {
case types.ResultInitiateRequested, types.ResultInitiated, types.ResultAuthorized, types.ResultCancelRequested:
// Still processing. For this integration payment intents use captureMode Auto,
// so Authorized is expected to continue to Captured.
case types.ResultCaptured, types.ResultCancelled, types.ResultCanceled, types.ResultDeclined, types.ResultExpired,
types.ResultSignatureRequired, types.ResultSignatureAccepted,
types.ResultSignatureRejected:
return session, nil
default:
return nil, fmt.Errorf("unexpected Dojo terminal session status %q", session.Status)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
}
}
}
func (c *Client) getTerminalSession(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) {
var response terminalSessionResponse
path := "/terminal-sessions/" + url.PathEscape(terminalSessionID)
if err := c.doJSON(ctx, http.MethodGet, path, nil, true, &response); err != nil {
return nil, fmt.Errorf("get Dojo terminal session: %w", err)
}
return &response, nil
}
func (c *Client) getPaymentIntent(ctx context.Context, paymentIntentID string) (*paymentIntentResponse, error) {
var response paymentIntentResponse
path := "/payment-intents/" + url.PathEscape(paymentIntentID) + "?returnCanceled=true"
if err := c.doJSON(ctx, http.MethodGet, path, nil, false, &response); err != nil {
return nil, fmt.Errorf("get Dojo payment intent: %w", err)
}
return &response, nil
}
func (c *Client) mapCapturedResult(req paymentsvc.SaleRequest, intent *paymentIntentResponse) *paymentsvc.Result {
result := c.baseResult(req, intent.PaymentDetails)
result.Success = true
result.Status = "APPROVED"
if intent.Amount.Value != 0 {
result.Amount = intent.Amount.Value
}
if intent.Amount.CurrencyCode != "" {
result.Currency = intent.Amount.CurrencyCode
}
if result.Message == "" {
result.Message = "Payment approved"
}
return result
}
func (c *Client) mapTerminalFailure(req paymentsvc.SaleRequest, session *terminalSessionResponse) *paymentsvc.Result {
result := c.baseResult(req, session.PaymentDetails)
result.Status = strings.ToUpper(session.Status)
result.ErrorMessage = "Dojo terminal session ended with status " + session.Status
return result
}
func (c *Client) baseResult(req paymentsvc.SaleRequest, details *paymentDetails) *paymentsvc.Result {
result := &paymentsvc.Result{
RequestID: req.RequestID,
Operation: "SALE",
Amount: req.Amount,
Currency: req.Currency,
DeviceUsed: c.terminalID,
DeviceType: "Dojo Terminal",
}
if details == nil {
return result
}
result.TransactionID = details.TransactionID
result.ReferenceNumber = req.RequestID
result.AuthCode = details.AuthCode
result.Message = details.Message
result.CardNumber = details.Card.CardNumber
result.CardType = details.Card.CardType
result.ExpiryDate = details.Card.ExpiryDate
result.LastFourDigits = details.Card.CardNumber
return result
}
func (c *Client) doJSON(ctx context.Context, method, path string, payload any, terminalRequest bool, target any) error {
var body io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encode request: %w", err)
}
body = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Basic "+c.apiKey)
req.Header.Set("Version", c.version)
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if terminalRequest {
req.Header.Set("software-house-id", c.softwareHouseID)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("Dojo returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
}
if target == nil || len(responseBody) == 0 {
return nil
}
if err := json.Unmarshal(responseBody, target); err != nil {
return fmt.Errorf("decode response: %w", err)
}
return nil
}
func dojoReference(req paymentsvc.SaleRequest) string {
reference := req.RequestID
if reference == "" {
reference = req.Reference
}
runes := []rune(reference)
if len(runes) > 60 {
runes = runes[:60]
}
return string(runes)
}
func lastFour(cardNumber string) string {
digits := make([]byte, 0, len(cardNumber))
for i := 0; i < len(cardNumber); i++ {
if cardNumber[i] >= '0' && cardNumber[i] <= '9' {
digits = append(digits, cardNumber[i])
}
}
if len(digits) <= 4 {
return string(digits)
}
return string(digits[len(digits)-4:])
}

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

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

View File

@ -1,4 +1,4 @@
package handlers
package errorhandlers
import (
"encoding/json"
@ -7,14 +7,12 @@ import (
"os"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/logging"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
log "github.com/sirupsen/logrus"
)
const serviceName = "hardlink"
// writeError is a helper to send a JSON error and HTTP status in one go.
func writeError(w http.ResponseWriter, status int, msg string) {
func WriteError(w http.ResponseWriter, status int, msg string) {
theResponse := cmstypes.StatusRec{
Code: status,
Message: msg,
@ -24,14 +22,6 @@ func writeError(w http.ResponseWriter, status int, msg string) {
json.NewEncoder(w).Encode(theResponse)
}
func writeTransactionResult(w http.ResponseWriter, status int, theResponse cmstypes.ResponseRec) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(theResponse); err != nil {
logging.Error(serviceName, err.Error(), "JSON encode error", "startTransaction", "", "", 0)
}
}
func FatalError(err error) {
fmt.Println(err.Error())
log.Errorf(err.Error())
@ -39,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)
}

View File

@ -0,0 +1,206 @@
package handlers
import (
"bufio"
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/db"
log "github.com/sirupsen/logrus"
)
type preauthSpoolRecord struct {
CreatedAt time.Time `json:"createdAt"`
CheckoutDate string `json:"checkoutDate"` // keep as received
Fields map[string]string `json:"fields"` // ChipDNA result.Fields
}
func (app *App) getDB(ctx context.Context) (*sql.DB, error) {
app.dbMu.Lock()
defer app.dbMu.Unlock()
// Fast path: db exists and is alive
if app.db != nil {
pingCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
if err := app.db.PingContext(pingCtx); err == nil {
return app.db, nil
}
// stale handle
_ = app.db.Close()
app.db = nil
}
// Reconnect once, bounded
dialCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
dbConn, err := db.InitMSSQL(
app.cfg.Dbport,
app.cfg.Dbuser,
app.cfg.Dbpassword,
app.cfg.Dbname,
)
if err != nil {
return nil, err
}
pingCtx, cancel2 := context.WithTimeout(dialCtx, 1*time.Second)
defer cancel2()
if err := dbConn.PingContext(pingCtx); err != nil {
_ = dbConn.Close()
return nil, err
}
app.db = dbConn
return app.db, nil
}
func (app *App) spoolPath() string {
// keep it near logs; adjust if you prefer a dedicated dir
// ensure LogDir ends with separator in your config loader
return filepath.Join(app.cfg.LogDir, "preauth_spool.ndjson")
}
// persistPreauth tries DB first; if DB is down or insert fails, it spools to file.
// It never returns an error to the caller (so your HTTP flow stays simple),
// but it logs failures.
func (app *App) persistPreauth(ctx context.Context, fields map[string]string, checkoutDate string) {
// First, try DB (with your reconnect logic inside getDB)
dbConn, err := app.getDB(ctx)
if err == nil && dbConn != nil {
if err := db.InsertPreauth(ctx, dbConn, fields, checkoutDate); err == nil {
// opportunistic drain once DB is alive
go app.drainPreauthSpool(context.Background())
return
} else {
log.WithError(err).Warn("DB insert failed; will spool preauth")
}
} else {
log.WithError(err).Warn("DB unavailable; will spool preauth")
}
// Fallback: spool to file
rec := preauthSpoolRecord{
CreatedAt: time.Now().UTC(),
CheckoutDate: checkoutDate,
Fields: fields,
}
if spErr := app.spoolPreauth(rec); spErr != nil {
log.WithError(spErr).Error("failed to spool preauth")
}
}
// append one line JSON (NDJSON)
func (app *App) spoolPreauth(rec preauthSpoolRecord) error {
p := app.spoolPath()
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return fmt.Errorf("open spool file: %w", err)
}
defer f.Close()
b, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("marshal spool record: %w", err)
}
if _, err := f.Write(append(b, '\n')); err != nil {
return fmt.Errorf("write spool record: %w", err)
}
return f.Sync() // ensure it's on disk
}
// Drain spool into DB.
// Strategy: read all lines, insert each; keep failures in a temp file; then replace original.
func (app *App) drainPreauthSpool(ctx context.Context) {
dbConn, err := app.getDB(ctx)
if err != nil {
return // still down, nothing to do
}
spool := app.spoolPath()
in, err := os.Open(spool)
if err != nil {
// no spool is fine
return
}
defer in.Close()
tmp := spool + ".tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0666)
if err != nil {
log.WithError(err).Warn("drain spool: open tmp failed")
return
}
defer out.Close()
sc := bufio.NewScanner(in)
// allow long lines if receipts ever sneak in (shouldn't, but safe)
buf := make([]byte, 0, 64*1024)
sc.Buffer(buf, 2*1024*1024)
var (
okCount int
failCount int
)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var rec preauthSpoolRecord
if err := json.Unmarshal(line, &rec); err != nil {
// malformed line: keep it so we don't lose evidence
_, _ = out.Write(append(line, '\n'))
failCount++
continue
}
// attempt insert
if err := db.InsertPreauth(ctx, dbConn, rec.Fields, rec.CheckoutDate); err != nil {
// DB still flaky or data issue: keep it for later retry
_, _ = out.Write(append(line, '\n'))
failCount++
continue
}
okCount++
}
if err := sc.Err(); err != nil {
log.WithError(err).Warn("drain spool: scanner error")
// best effort; do not replace spool
return
}
_ = out.Sync()
// Replace original spool with temp (atomic on Windows is best-effort; still OK here)
_ = in.Close()
_ = out.Close()
if err := os.Rename(tmp, spool); err != nil {
log.WithError(err).Warn("drain spool: rename failed")
return
}
if okCount > 0 || failCount > 0 {
log.WithFields(log.Fields{
"inserted": okCount,
"remaining": failCount,
}).Info("preauth spool drained")
}
}

View File

@ -0,0 +1,503 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"encoding/xml"
"io"
"net/http"
"strings"
"sync"
"time"
"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/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/printer"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/logging"
log "github.com/sirupsen/logrus"
)
type App struct {
disp *dispenser.Client
lockserver lockserver.LockServer
paymentService *paymentsvc.Service
isPayment bool
db *sql.DB
cfg *config.ConfigRec
dbMu sync.Mutex
cardWellMu sync.RWMutex
cardWellStatus string
availabilityMu sync.Mutex
availabilityTimers map[string]*time.Timer
}
func NewApp(disp *dispenser.Client, lockType, encoderAddress, cardWellStatus string, db *sql.DB, cfg *config.ConfigRec) *App {
app := &App{
isPayment: cfg.IsPayment,
disp: disp,
lockserver: lockserver.NewLockServer(lockType, encoderAddress, errorhandlers.FatalError),
db: db,
cfg: cfg,
availabilityTimers: make(map[string]*time.Timer),
}
app.SetCardWellStatus(cardWellStatus)
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)
mux.HandleFunc("/takepreauth", app.takePreauthorization)
mux.HandleFunc("/takepayment", app.takePayment)
mux.HandleFunc("/dispenserstatus", app.reportDispenserStatus)
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) {
const op = logging.Op("takePreauthorization")
var (
theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec
trResult creditcall.TransactionResultXML
result creditcall.PaymentResult
save bool
)
theResponse.Status.Code = http.StatusInternalServerError
theResponse.Status.Message = "500 Internal server error"
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")
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 = creditcall.BuildFailureURL(types.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return
}
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("takePreauthorization called")
if r.Method != http.MethodPost {
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 = creditcall.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Read body error", string(op), "", "", 0)
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 = creditcall.BuildFailureURL(types.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse)
return
}
log.Printf(
"Preauthorization payload: Amount=%s, Type=%s",
theRequest.AmountMinorUnits,
theRequest.TransactionType,
)
client := &http.Client{Timeout: 300 * time.Second}
// ---- START TRANSACTION ----
body, err = callChipDNA(client, types.LinkStartTransaction, body)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Preauth processing error", string(op), "", "", 0)
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return
}
if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
}
result.FillFromTransactionResult(trResult)
// ---- PRINT RECEIPT ----
printer.PrintReceipt(result.CardholderReceipt)
// ---- REDIRECT ----
theResponse.Status = result.Status
theResponse.Data, save = creditcall.BuildPreauthRedirectURL(result.Fields)
if save {
go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate)
}
writeTransactionResult(w, http.StatusOK, theResponse)
}
func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("takePayment")
var (
theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec
trResult creditcall.TransactionResultXML
result creditcall.PaymentResult
)
theResponse.Status.Code = http.StatusInternalServerError
theResponse.Status.Message = "500 Internal server error"
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")
if !app.isPayment {
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 = creditcall.BuildFailureURL(types.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return
}
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("takePayment called")
if r.Method != http.MethodPost {
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 = creditcall.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Read body error", string(op), "", "", 0)
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 = creditcall.BuildFailureURL(types.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse)
return
}
log.Printf("Transaction payload: Amount=%s, Type=%s",
theRequest.AmountMinorUnits,
theRequest.TransactionType,
)
client := &http.Client{Timeout: 300 * time.Second}
// ---- START TRANSACTION ----
body, err = callChipDNA(client, types.LinkStartTransaction, body)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Start transaction error", string(op), "", "", 0)
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return
}
if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
}
result.FillFromTransactionResult(trResult)
res := result.Fields[types.TransactionResult]
if !strings.EqualFold(res, types.ResultApproved) {
printer.PrintReceipt(result.CardholderReceipt)
desc := result.Fields[types.ErrorDescription]
if desc == "" {
desc = result.Fields[types.Errors]
}
logging.Error(types.ServiceName, "Preauthorization failed", "Result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
theResponse.Status = result.Status
theResponse.Data = creditcall.BuildFailureURL(res, result.Fields[types.Errors])
writeTransactionResult(w, http.StatusOK, theResponse)
return
}
// ---- CONFIRM TRANSACTION ----
ref := result.Fields[types.Reference]
log.Printf("Preauth approved, reference: %s. Sending confirm...", ref)
confirmReq := creditcall.ConfirmTransactionRequest{
Amount: theRequest.AmountMinorUnits,
Reference: ref,
}
body, err = confirmWithRetry(client, confirmReq, 2)
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 = creditcall.BuildFailureURL(types.ResultError, "ConfirmTransactionError")
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return
}
if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(types.ServiceName, err.Error(), "Parse confirm result error", string(op), "", "", 0)
}
result.FillFromTransactionResult(trResult)
res = result.Fields[types.TransactionResult]
if !strings.EqualFold(res, types.ResultApproved) {
printer.PrintReceipt(result.CardholderReceipt)
desc := result.Fields[types.ErrorDescription]
if desc == "" {
desc = result.Fields[types.Errors]
}
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 = creditcall.BuildFailureURL(res, result.Fields[types.Errors])
writeTransactionResult(w, http.StatusOK, theResponse)
return
}
// ---- SUCCESS ----
printer.PrintReceipt(result.CardholderReceipt)
log.Printf("Transaction approved and confirmed, reference: %s", ref)
theResponse.Status = result.Status
theResponse.Data = creditcall.BuildSuccessURL(result.Fields)
writeTransactionResult(w, http.StatusOK, theResponse)
}
func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("issueDoorCard")
var (
doorReq lockserver.DoorCardRequest
theResponse cmstypes.StatusRec
)
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")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("issueDoorCard called")
if r.Method != http.MethodPost {
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
return
}
defer r.Body.Close()
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
errorhandlers.WriteError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
return
}
if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil {
logging.Error(types.ServiceName, err.Error(), "ReadJSON", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
return
}
// parse times
checkIn, err := time.Parse(types.CustomLayout, doorReq.CheckinTime)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Invalid checkinTime format", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error())
return
}
checkOut, err := time.Parse(types.CustomLayout, doorReq.CheckoutTime)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error())
return
}
// Ensure dispenser ready (card at encoder) BEFORE we attempt encoding.
// With queued dispenser ops, this will not clash with polling.
status, err := app.disp.DispenserStart(r.Context())
app.SetCardWellStatus(status)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
return
}
// Always attempt to finalize after we have moved a card / started an issuance flow.
// This guarantees we eject and prepare the next card even on lock failures.
finalize := func() {
if app.disp == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
status, ferr := app.disp.DispenserFinal(ctx)
if ferr != nil {
logging.Error(types.ServiceName, ferr.Error(), "Dispenser final error", string(op), "", "", 0)
return
}
app.SetCardWellStatus(status)
}
doorReq.RoomField = "104"
// build lock server command
app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
// lock server sequence
if err := app.lockserver.LockSequence(); err != nil {
logging.Error(types.ServiceName, err.Error(), "Key encoding", string(op), "", "", 0)
finalize()
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error())
return
}
// final dispenser steps
finalize()
theResponse.Code = http.StatusOK
theResponse.Message = "Card issued successfully"
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(theResponse)
}
func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("printRoomTicket")
var roomDetails printer.RoomDetailsRec
// Allow CORS preflight if needed
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")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("printRoomTicket called")
if r.Method != http.MethodPost {
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
return
}
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "xml") {
errorhandlers.WriteError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/xml")
return
}
defer r.Body.Close()
if err := xml.NewDecoder(r.Body).Decode(&roomDetails); err != nil {
logging.Error(types.ServiceName, err.Error(), "ReadXML", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid XML payload: "+err.Error())
return
}
data, err := printer.BuildRoomTicket(roomDetails)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "BuildRoomTicket", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusInternalServerError, "BuildRoomTicket failed: "+err.Error())
return
}
// Send to the Windows Epson TM-T82II via the printer package
if err := printer.SendToPrinter(data); err != nil {
logging.Error(types.ServiceName, err.Error(), "printRoomTicket", "printRoomTicket", "", "", 0)
errorhandlers.WriteError(w, http.StatusInternalServerError, "Print failed: "+err.Error())
return
}
// Success
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(cmstypes.StatusRec{
Code: http.StatusOK,
Message: "Print job sent successfully",
})
}
func (app *App) reportDispenserStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(cmstypes.StatusRec{
Code: http.StatusOK,
Message: app.CardWellStatus(),
})
}
func (app *App) SetCardWellStatus(s string) {
app.cardWellMu.Lock()
prev := app.cardWellStatus
app.cardWellStatus = s
app.cardWellMu.Unlock()
if s != "" && prev != s {
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Dispenser Error Status", "Status: "+s)
}
}
func (app *App) CardWellStatus() string {
app.cardWellMu.RLock()
defer app.cardWellMu.RUnlock()
return app.cardWellStatus
}

View File

@ -0,0 +1,82 @@
package handlers
import (
"bytes"
"encoding/json"
"encoding/xml"
"io"
"net/http"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"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"
)
func writeTransactionResult(w http.ResponseWriter, status int, theResponse cmstypes.ResponseRec) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(theResponse); err != nil {
logging.Error(types.ServiceName, err.Error(), "JSON encode error", "startTransaction", "", "", 0)
}
}
func callChipDNA(client *http.Client, url string, payload []byte) ([]byte, error) {
resp, err := client.Post(url, "text/xml", bytes.NewBuffer(payload))
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func confirmWithRetry(client *http.Client, req creditcall.ConfirmTransactionRequest, attempts int) ([]byte, error) {
payload, err := xml.Marshal(req)
if err != nil {
return nil, err
}
var lastErr error
for i := 1; i <= attempts; i++ {
resp, err := client.Post(types.LinkConfirmTransaction, "text/xml", bytes.NewBuffer(payload))
if err != nil {
lastErr = err
} else {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
lastErr = readErr
} else {
return body, nil
}
}
log.Warnf("ConfirmTransaction attempt %d/%d failed: %v", i, attempts, lastErr)
if i < attempts {
time.Sleep(2 * time.Second)
}
}
return nil, lastErr
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if payload == nil {
return
}
_ = json.NewEncoder(w).Encode(payload)
}

View File

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

View File

@ -0,0 +1,252 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"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/creditcall"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/logging"
log "github.com/sirupsen/logrus"
)
func (app *App) testIssueDoorCard(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("issueDoorCard")
var (
doorReq lockserver.DoorCardRequest
theResponse cmstypes.StatusRec
)
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")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("issueDoorCard called")
if r.Method != http.MethodPost {
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
return
}
defer r.Body.Close()
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
errorhandlers.WriteError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
return
}
if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil {
logging.Error(types.ServiceName, err.Error(), "ReadJSON", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
return
}
now := time.Now()
checkIn := time.Date(now.Year(), now.Month(), now.Day(), 23, 0, 0, 0, now.Location())
checkOut := checkIn.Add(2 * time.Hour)
// Ensure dispenser ready (card at encoder) BEFORE we attempt encoding.
// With queued dispenser ops, this will not clash with polling.
status, err := app.disp.DispenserStart(r.Context())
app.SetCardWellStatus(status)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
return
}
// build lock server command
app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
// lock server sequence
if err := app.lockserver.LockSequence(); err != nil {
logging.Error(types.ServiceName, err.Error(), "Key encoding", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error())
return
}
theResponse.Code = http.StatusOK
theResponse.Message = "Card issued successfully"
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(theResponse)
}
func (app *App) fetchChipDNAStatus(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("fetchChipDNAStatus")
var theResponse cmstypes.StatusRec
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")
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())
return
}
b, err := json.MarshalIndent(status, "", " ")
if err != nil {
logging.Error(types.ServiceName, err.Error(), "MarshalIndent", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusInternalServerError, "Failed to marshal status data")
return
}
theResponse.Code = http.StatusOK
theResponse.Message = string(b)
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(theResponse)
}
func (app *App) onChipDNAError(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("onChipDNAError")
var tr creditcall.TransactionResultXML
title := "ChipDNA Error"
message := ""
log.Println("onChipDNAError called")
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")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
message = "Failed to read request body: " + err.Error()
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, title, message)
errorhandlers.WriteError(w, http.StatusBadRequest, "Unable to read request body")
return
}
if len(body) == 0 {
message = "Received empty request body"
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, title, message)
errorhandlers.WriteError(w, http.StatusBadRequest, "Empty body")
return
}
if err := tr.ParseTransactionResult(body); err != nil {
logging.Error(
types.ServiceName,
err.Error(),
"Parse transaction result error",
string(op),
"",
app.cfg.Hotel,
app.cfg.Kiosk,
)
message = "Failed to parse transaction result: " + err.Error()
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, title, message)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid XML")
return
}
for _, e := range tr.Entries {
switch e.Key {
case creditcall.KeyErrors:
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, title, e.Value)
case creditcall.KeyIsAvailable:
isAvailable := strings.EqualFold(e.Value, "true")
app.handleAvailabilityDebounced(isAvailable)
}
logging.Error(
types.ServiceName,
e.Value,
e.Key,
string(op),
"",
app.cfg.Hotel,
app.cfg.Kiosk,
)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"received"}`))
}
func (app *App) handleAvailabilityDebounced(isAvailable bool) {
const (
debounceDay = 30
debounceNight = 600
title = "ChipDNA Error"
)
key := app.availabilityKey()
app.availabilityMu.Lock()
defer app.availabilityMu.Unlock()
// If device becomes available -> cancel pending timer
if isAvailable {
if t, exists := app.availabilityTimers[key]; exists {
t.Stop()
delete(app.availabilityTimers, key)
log.Println("PDQ availability restored - debounce timer cancelled")
}
return
}
// Device became unavailable -> start 10s debounce if not already started
if _, exists := app.availabilityTimers[key]; exists {
return
}
debounce := debounceDay
hour := time.Now().Hour()
if hour < 6 {
debounce = debounceNight
}
log.Printf("PDQ reported unavailable - starting %ds debounce timer", debounce)
timer := time.AfterFunc(time.Duration(debounce)*time.Second, func() {
mail.SendEmailOnError(
app.cfg.Hotel,
app.cfg.Kiosk,
title,
fmt.Sprintf("ChipDNA PDQ unavailable for more than %d seconds", debounce),
)
app.availabilityMu.Lock()
delete(app.availabilityTimers, key)
app.availabilityMu.Unlock()
})
app.availabilityTimers[key] = timer
}
func (app *App) availabilityKey() string {
return fmt.Sprintf("hotel=%s|kiosk=%d|app=%p",
strings.TrimSpace(app.cfg.Hotel),
app.cfg.Kiosk,
app,
)
}

View File

@ -1,15 +1,16 @@
// Package lockserver provides functionality for interacting with Assa Abloy lock servers.
package lockserver
import (
"fmt"
"net"
"time"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
// Build key encoding request command for the Assa Abloy lock server.
// BuildCommand builds a key encoding request command for the Assa Abloy lock server.
func (lock *AssaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
ci := checkIn.Format("200601021504")
co := checkOut.Format("200601021504")
@ -18,7 +19,7 @@ func (lock *AssaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
return nil
}
// Checks heart beat of the Assa Abloy lock server and perform key encoding
// LockSequence checks heartbeat of the Assa Abloy lock server and performs key encoding
func (lock *AssaLockServer) LockSequence() error {
const funcName = "AssaLockServer.LockSequence"
@ -71,4 +72,3 @@ func parseAssaResponse(raw string) (string, error) {
}
return "Success: " + clean, nil
}

View File

@ -0,0 +1,291 @@
package lockserver
import (
"bufio"
"fmt"
"net"
"net/url"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
const (
kabaSTX = 0x02
kabaETX = 0x03
kabaACK = 0x06
kabaNAK = 0x15
)
// BuildCommand builds a key encoding request command for the dormakaba/Kaba lock server.
// KR|KTD|WS192.168.135.20|KC2|RN41|KO000000|GA241213|TI16:56|GD241214|DT11:00|G#75|
func (lock *KabaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
const funcName = "DormakabaLockServer.BuildCommand"
room := strings.TrimSpace(doorReq.RoomField)
if room == "" {
return fmt.Errorf("[%s] roomField is required", funcName)
}
if checkIn.IsZero() {
return fmt.Errorf("[%s] checkin time is required", funcName)
}
if checkOut.IsZero() {
return fmt.Errorf("[%s] checkout time is required", funcName)
}
ws := dormakabaWorkstationID()
ga := checkIn.Format("060102") // yyMMdd, example: 241213
gd := checkOut.Format("060102") // yyMMdd, example: 241214
ti := checkIn.Format("15:04") // HH:mm, example: 16:56
dt := checkOut.Format("15:04") // HH:mm, example: 11:00
payload := fmt.Sprintf(
"KR|KTD|WS%s|KC%s|RN%s|KO000000|GA%s|TI%s|GD%s|DT%s|G#75|",
ws,
lock.encoderAddr,
room,
ga,
ti,
gd,
dt,
)
lock.command = wrapKabaFrame(payload)
return nil
}
// LockSequence starts the link and performs key encoding.
func (lock *KabaLockServer) LockSequence() error {
const funcName = "KabaLockServer.LockSequence"
conn, err := InitializeServerConnection(LockServerURL)
if err != nil {
return err
}
defer conn.Close()
reader := bufio.NewReader(conn)
regs, err := lock.linkStart(conn, reader)
if err != nil {
return fmt.Errorf("[%s] linkStart failed: %v", funcName, err)
}
for _, reg := range regs {
log.Printf("Received: %q", reg)
}
raw, err := lock.requestEncoding(conn, reader)
if err != nil {
return fmt.Errorf("[%s] request encoding failed: %v", funcName, err)
}
log.Infof("Encoding response: %s", raw)
return nil
}
// linkStart sends the dormakaba/Kaba LS command.
// LS|DA241213|TI165607|WS192.168.135.20|PW1234|
func (lock *KabaLockServer) linkStart(conn net.Conn, reader *bufio.Reader) ([]string, error) {
ws := dormakabaWorkstationID()
pw := dormakabaPassword()
payload := fmt.Sprintf(
"LS|DA%s|TI%s|WS%s|PW%s|",
time.Now().Format("060102"), // yyMMdd
time.Now().Format("150405"), // HHmmss
ws,
pw,
)
command := wrapKabaFrame(payload)
log.Printf("Sending Link Start command: %q", command)
if _, err := conn.Write(command); err != nil {
return nil, fmt.Errorf("failed to send Link Start command: %v", err)
}
var registers []string
timeout := 10 * time.Second
for {
conn.SetReadDeadline(time.Now().Add(timeout))
b, err := reader.ReadByte()
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
if len(registers) > 0 {
return registers, nil
}
}
return nil, fmt.Errorf("error reading Link Start response: %v", err)
}
switch b {
case kabaACK:
registers = append(registers, "ACK")
continue
case kabaNAK:
return registers, fmt.Errorf("received NAK after Link Start")
case kabaSTX:
frame, err := readKabaFrame(conn, reader, b, timeout)
if err != nil {
return registers, fmt.Errorf("failed to read Link Start frame: %v", err)
}
frameText := string(frame)
registers = append(registers, frameText)
clean := cleanKabaFrame(frameText)
if strings.HasPrefix(clean, "LA|") {
return registers, nil
}
default:
log.Warnf("Ignoring unexpected byte during Link Start: 0x%X", b)
}
}
}
func (lock *KabaLockServer) requestEncoding(conn net.Conn, reader *bufio.Reader) (string, error) {
log.Printf("Sending Encoding command: %q", lock.command)
if _, err := conn.Write(lock.command); err != nil {
return "", fmt.Errorf("failed to send Encoding command: %v", err)
}
deadline := time.Now().Add(60 * time.Second)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return "", fmt.Errorf("timeout waiting for dormakaba encoding response")
}
conn.SetReadDeadline(time.Now().Add(remaining))
b, err := reader.ReadByte()
if err != nil {
return "", fmt.Errorf("error reading encoding response: %v", err)
}
switch b {
case kabaACK:
log.Debug("Received ACK after Encoding command")
continue
case kabaNAK:
return "", fmt.Errorf("received NAK after Encoding command")
case kabaSTX:
frame, err := readKabaFrame(conn, reader, b, 60*time.Second)
if err != nil {
return "", fmt.Errorf("failed to read encoding response frame: %v", err)
}
raw := string(frame)
clean := cleanKabaFrame(raw)
log.Printf("Received Encoding frame: %q", clean)
if strings.HasPrefix(clean, "KA|") {
return parseDormakabaEncodingResponse(clean)
}
log.Warnf("Ignoring non-KA frame while waiting for encoding result: %q", clean)
default:
log.Warnf("Ignoring unexpected byte while waiting for encoding response: 0x%X", b)
}
}
}
func parseDormakabaEncodingResponse(clean string) (string, error) {
if strings.Contains(clean, "|ASOK|") {
return "Success: " + clean, nil
}
if strings.Contains(clean, "|AS") {
return "", fmt.Errorf("negative dormakaba response: %s", clean)
}
return "", fmt.Errorf("unexpected dormakaba response: %s", clean)
}
func readKabaFrame(conn net.Conn, reader *bufio.Reader, firstByte byte, timeout time.Duration) ([]byte, error) {
frame := []byte{firstByte}
for {
conn.SetReadDeadline(time.Now().Add(timeout))
b, err := reader.ReadByte()
if err != nil {
return frame, fmt.Errorf("error reading frame body: %w", err)
}
frame = append(frame, b)
if b == kabaETX {
return frame, nil
}
}
}
func wrapKabaFrame(payload string) []byte {
command := make([]byte, 0, len(payload)+2)
command = append(command, kabaSTX)
command = append(command, []byte(payload)...)
command = append(command, kabaETX)
return command
}
func cleanKabaFrame(raw string) string {
return strings.Trim(raw, string([]byte{kabaSTX, kabaETX}))
}
func dormakabaPassword() string {
if strings.TrimSpace(Cert) != "" {
return strings.TrimSpace(Cert)
}
return "1234"
}
func dormakabaWorkstationID() string {
parsed, err := url.Parse(LockServerURL)
if err == nil && parsed.Host != "" {
host := parsed.Host
if h, _, splitErr := net.SplitHostPort(host); splitErr == nil {
return h
}
return strings.Trim(host, "/")
}
raw := strings.TrimSpace(LockServerURL)
raw = strings.TrimPrefix(raw, "http://")
raw = strings.TrimPrefix(raw, "https://")
raw = strings.Trim(raw, "/")
if h, _, splitErr := net.SplitHostPort(raw); splitErr == nil {
return h
}
if idx := strings.Index(raw, ":"); idx >= 0 {
return raw[:idx]
}
return raw
}

View File

@ -25,10 +25,11 @@ const (
Omnitec = "omnitec"
Salto = "salto"
TLJ = "tlj"
Dormakaba = "kaba"
)
var (
Cert string
Cert string
LockServerURL string
)
@ -38,6 +39,11 @@ type (
LockSequence() error
}
KabaLockServer struct {
encoderAddr string
command []byte
}
AssaLockServer struct {
encoderAddr string
command string
@ -69,22 +75,24 @@ func NewLockServer(lockType, encoderAddr string, fatalError func(error)) LockSer
return &SaltoLockServer{encoderAddr: encoderAddr}
case TLJ:
return &TLJLockServer{encoderAddr: encoderAddr}
case Dormakaba:
return &KabaLockServer{encoderAddr: encoderAddr}
default:
fatalError(fmt.Errorf("unsupported LockType: %s; must be 'assaabloy' or 'omnitec'", lockType))
return nil // This line will never be reached, but is needed to satisfy the compiler
}
}
func InitializeServerConnection(LockserverUrl string) (net.Conn, error) {
func InitializeServerConnection(LockserverURL string) (net.Conn, error) {
const funcName = "InitializeServerConnection"
// Parse the URL to extract host and port
parsedUrl, err := url.Parse(LockserverUrl)
parsedURL, err := url.Parse(LockserverURL)
if err != nil {
return nil, fmt.Errorf("[%s] failed to parse LockserverUrl: %v", funcName, err)
return nil, fmt.Errorf("[%s] failed to parse LockserverURL: %v", funcName, err)
}
// Remove any leading/trailing slashes just in case
address := strings.Trim(parsedUrl.Host, "/")
address := strings.Trim(parsedURL.Host, "/")
// Establish a TCP connection to the Visionline server
conn, err := net.Dial("tcp", address)
@ -102,7 +110,7 @@ func sendAndReceive(conn net.Conn, command []byte) (string, error) {
return "", fmt.Errorf("failed to send command: %v", err)
}
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
conn.SetReadDeadline(time.Now().Add(20 * time.Second))
buf := make([]byte, 128)
reader := bufio.NewReader(conn)

View File

@ -7,12 +7,12 @@ import (
"os"
"strconv"
"strings"
"time"
"time"
log "github.com/sirupsen/logrus"
)
// Build key encoding request command for the Omnitec lock server.
// BuildCommand builds key encoding request command for the Omnitec lock server.
func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
const funcName = "OmniLockServer.BuildCommand"
hostname, err := os.Hostname()
@ -25,7 +25,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
if err != nil {
return fmt.Errorf("[%s] failed to convert lockId to integer: %v", funcName, err)
}
formattedLockId := fmt.Sprintf("%04d", idInt)
formattedLockID := fmt.Sprintf("%04d", idInt)
// Format date/time parts
dt := checkOut.Format("15:04") // DT = HH:mm
@ -37,7 +37,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
payload := fmt.Sprintf(
"KR|KC%s|KTD|RN%s|%s|DT%s|G#75|GA%s|GD%s|KO0000|DA%s|TI%s|",
lock.encoderAddr,
formattedLockId,
formattedLockID,
hostname,
dt,
ga,
@ -52,7 +52,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
return nil
}
// Starts link to the Omnitec lock server and perform key encoding
// LockSequence starts link to the Omnitec lock server and perform key encoding
func (lock *OmniLockServer) LockSequence() error {
const funcName = "OmniLockServer.LockSequence"
@ -136,4 +136,4 @@ func parseOmniResponse(raw string) (string, error) {
return "", fmt.Errorf("negative response code: %s", clean)
}
return "Success: " + clean, nil
}
}

View File

@ -155,7 +155,7 @@ func (lock *SaltoLockServer) LockSequence() error {
reader := bufio.NewReader(conn)
// 1. Send ENQ
log.Infof("Sending ENQ")
log.Infof("LockSequence: sending ENQ")
if _, e := conn.Write([]byte{ENQ}); e != nil {
return fmt.Errorf("failed to send ENQ: %w", e)
}
@ -166,7 +166,7 @@ func (lock *SaltoLockServer) LockSequence() error {
}
// 3. Send command frame
log.Infof("Sending encoding command: %q", string(lock.command))
log.Infof("LockSequence: sending encoding command: %q", string(lock.command))
if _, e := conn.Write(lock.command); e != nil {
return fmt.Errorf("failed to send command frame: %w", e)
}

68
internal/mail/mail.go Normal file
View File

@ -0,0 +1,68 @@
package mail
import (
"fmt"
"gitea.futuresens.co.uk/futuresens/logging"
mailjet "github.com/mailjet/mailjet-apiv3-go"
log "github.com/sirupsen/logrus"
)
const (
apiKey = "60f358a27e98562641c08f51e5450c9e"
secretKey = "068b65c3b337a0e3c14389544ecd771f"
)
const (
moduleName = "mail"
)
var (
// sendErrorEmail is the e-mail address to which to send an e-mail if there is an error during checkin or payment
SendErrorEmails []string
)
// SendMail will send reception an e-mail
func SendMail(recipient, title, message string) {
const funcName = "SendMail"
mailjetClient := mailjet.NewMailjetClient(apiKey, secretKey)
messagesInfo := []mailjet.InfoMessagesV31{
mailjet.InfoMessagesV31{
From: &mailjet.RecipientV31{
Email: "kiosk@cms.futuresens.co.uk",
Name: "Futuresens Kiosk",
},
To: &mailjet.RecipientsV31{
mailjet.RecipientV31{
Email: recipient,
Name: "",
},
},
Subject: title,
TextPart: message,
},
}
messages := mailjet.MessagesV31{Info: messagesInfo}
_, err := mailjetClient.SendMailV31(&messages)
if err != nil {
theFields := log.Fields{}
theFields["mailerror"] = true
theFields["recipient"] = recipient
theFields[logging.LogFunction] = funcName
theFields[logging.LogModule] = moduleName
theFields[logging.LogError] = err.Error()
theFields["error"] = err.Error()
log.WithFields(theFields).Error("sendmail error")
}
}
func SendEmailOnError(hotel string, kiosk int, title, errMsg string) {
log.Println("sendEmailOnError called")
message := fmt.Sprintf("Hotel: %s, kiosk: %d.\n%s", hotel, kiosk, errMsg)
for _, recipient := range SendErrorEmails {
SendMail(recipient, title, message)
}
}

View File

@ -0,0 +1,9 @@
package mail
import (
"testing"
)
func Test_SendMail(t *testing.T) {
SendMail("zotacrtx5@gmail.com", "Test Subjectp", "Test Message")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,6 +39,7 @@ type (
Name string `xml:"customername"`
Checkout string `xml:"checkoutdatetime"`
RoomID string `xml:"roomno"`
Voucher string `xml:"voucher"`
Map string `xml:"roommap"`
Directions string `xml:"roomdirections"`
}
@ -141,6 +142,11 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
write([]byte{ESC, 'a', CENTER})
writeStr(Layout.HotelSpecificDetails + "\n\n")
if details.Voucher != "" {
s := strings.Repeat("*", 44)
writeStr(fmt.Sprintf("%s\n\n%s\n\n%s\n\n", s, details.Voucher, s))
}
// 8) Room map image
mapPath := filepath.Join(Layout.RoomMapFolderPath, details.Map)
mapBytes, err := printMap(mapPath)
@ -167,7 +173,19 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
return buf.Bytes(), nil
}
func PrintCardholderReceipt(cardholderReceipt string) error {
func PrintReceipt(receipt string) {
if len(receipt) == 0 {
log.Warn("Empty cardholder receipt, skipping print")
return
}
if err := printCardholderReceipt(receipt); err != nil {
log.Errorf("PrintCardholderReceipt error: %v", err)
}
}
func printCardholderReceipt(cardholderReceipt string) error {
receiptEntries, err := ParseCardholderReceipt([]byte(cardholderReceipt))
if err != nil {
return fmt.Errorf("ParseCardholderReceipt: %w", err)

View File

@ -7,11 +7,13 @@ import (
)
const (
ServiceName = "hardlink"
DateOnly = "2006-01-02"
CustomLayout = "2006-01-02 15:04:05 -0700"
LinkTakePreauthorization = "http://127.0.0.1:18181/start-transaction/"
LinkTakePayment = "http://127.0.0.1:18181/start-and-confirm-transaction/"
LinkStartTransaction = "http://127.0.0.1:18181/start-transaction/"
LinkConfirmTransaction = "http://127.0.0.1:18181/confirm-transaction/"
LinkTransactionInformation = "http://127.0.0.1:18181/transaction-information/"
LinkChipDNAStatus = "http://127.0.0.1:18181/chipdna-status/"
LinkVoidTransaction = "http://127.0.0.1:18181/void-transaction/"
// Transaction types
SaleTransactionType = "sale"
@ -21,6 +23,7 @@ const (
ResultApproved = "approved"
ResultDeclined = "declined"
ResultCancelled = "cancelled"
ResultCanceled = "canceled"
ResultPending = "pending"
ResultStateUncommitted = "uncommitted"
ResultStateVoided = "voided"
@ -32,12 +35,14 @@ const (
CardReference = "CARD_REFERENCE"
CardHash = "CARD_HASH"
Errors = "ERRORS"
ErrorDescription = "ERROR_DESCRIPTION"
ReceiptData = "RECEIPT_DATA"
ReceiptDataMerchant = "RECEIPT_DATA_MERCHANT"
ReceiptDataCardholder = "RECEIPT_DATA_CARDHOLDER"
Reference = "REFERENCE"
PAN_MASKED = "PAN_MASKED"
EXPIRY_DATE = "EXPIRY_DATE"
CardType = "CARD_SCHEME_ID"
PanMasked = "PAN_MASKED"
ExpiryDate = "EXPIRY_DATE"
TransactionResult = "TRANSACTION_RESULT"
TransactionType = "TRANSACTION_TYPE"
TransactionState = "TRANSACTION_STATE"
@ -45,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"

189
main.go
View File

@ -1,189 +0,0 @@
package main
import (
"encoding/xml"
"fmt"
"net/http"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"time"
"github.com/tarm/serial"
log "github.com/sirupsen/logrus"
"gitea.futuresens.co.uk/futuresens/hardlink/bootstrap"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/handlers"
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/logging"
"gitea.futuresens.co.uk/futuresens/hardlink/printer"
)
const (
buildVersion = "1.0.28"
serviceName = "hardlink"
)
func main() {
// Load config
config := config.ReadHardlinkConfig()
printer.Layout = readTicketLayout()
printer.PrinterName = config.PrinterName
lockserver.Cert = config.Cert
lockserver.LockServerURL = config.LockserverUrl
dispHandle := &serial.Port{}
// Setup logging and get file handle
logFile, err := logging.SetupLogging(config.LogDir, serviceName, buildVersion)
if err != nil {
log.Printf("Failed to set up logging: %v\n", err)
}
defer logFile.Close()
// Initialize dispenser
if !config.TestMode {
dispenser.SerialPort = config.DispenserPort
dispenser.Address = []byte(config.DispenserAdrr)
dispHandle, err = dispenser.InitializeDispenser()
if err != nil {
handlers.FatalError(err)
}
defer dispHandle.Close()
status, err := dispenser.CheckDispenserStatus(dispHandle)
if err != nil {
if len(status) == 0 {
err = fmt.Errorf("%s; wrong dispenser address: %s", err, config.DispenserAdrr)
handlers.FatalError(err)
} else {
fmt.Println(status)
fmt.Println(err.Error())
}
}
log.Infof("Dispenser initialized on port %s, %s", config.DispenserPort, status)
}
// Test lock-server connection
switch strings.ToLower(config.LockType) {
case lockserver.TLJ:
default:
lockConn, err := lockserver.InitializeServerConnection(config.LockserverUrl)
if err != nil {
fmt.Println(err.Error())
log.Errorf(err.Error())
} else {
fmt.Printf("Connected to the lock server successfuly at %s\n", config.LockserverUrl)
log.Infof("Connected to the lock server successfuly at %s", config.LockserverUrl)
lockConn.Close()
}
}
database, err := bootstrap.OpenDB(&config)
if err != nil {
log.Warnf("DB init failed: %v", err)
}
defer database.Close()
if config.IsPayment {
fmt.Println("Payment processing is enabled")
log.Info("Payment processing is enabled")
startChipDnaClient()
} else {
fmt.Println("Payment processing is disabled")
log.Info("Payment processing is disabled")
}
// Create App and wire routes
app := handlers.NewApp(dispHandle, config.LockType, config.EncoderAddress, database, config.IsPayment)
mux := http.NewServeMux()
app.RegisterRoutes(mux)
addr := fmt.Sprintf(":%d", config.Port)
log.Infof("Starting HTTP server on http://localhost%s", addr)
fmt.Printf("Starting HTTP server on http://localhost%s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
handlers.FatalError(err)
}
}
func readTicketLayout() printer.LayoutOptions {
const layoutName = "TicketLayout.xml"
var layout printer.LayoutOptions
// 1) Read the file
data, err := os.ReadFile(layoutName)
if err != nil {
handlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err))
}
// 2) Unmarshal into your struct
if err := xml.Unmarshal(data, &layout); err != nil {
handlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err))
}
return layout
}
func startChipDnaClient() {
startClient := func() (*exec.Cmd, error) {
cmd := exec.Command("./ChipDNAClient/ChipDnaClient.exe")
err := cmd.Start()
if err != nil {
return nil, fmt.Errorf("failed to start ChipDnaClient: %v", err)
}
log.Infof("ChipDnaClient started with PID %d", cmd.Process.Pid)
return cmd, nil
}
cmd, err := startClient()
if err != nil {
handlers.FatalError(err)
}
// Restart loop
go func() {
for {
err := cmd.Wait()
if err != nil {
log.Errorf("ChipDnaClient exited unexpectedly: %v", err)
time.Sleep(2 * time.Second)
cmd, err = startClient()
if err != nil {
log.Errorf("Restart failed: %v", err)
return
}
log.Info("ChipDnaClient restarted successfully")
}
}
}()
// Handle shutdown signals
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
log.Info("Shutting down...")
if cmd.Process != nil {
log.Info("Sending SIGTERM to ChipDnaClient...")
_ = cmd.Process.Signal(syscall.SIGTERM)
// wait up to 5s for graceful shutdown
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-time.After(5 * time.Second):
log.Warn("ChipDnaClient did not exit in time, killing...")
_ = cmd.Process.Kill()
case err := <-done:
log.Infof("ChipDnaClient exited cleanly: %v", err)
}
}
os.Exit(0)
}()
}

View File

@ -2,6 +2,74 @@
builtVersion is a const in main.go
#### 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
added Dormakaba lock server integration
#### 1.2.8 - 14 May 2026
Updated hardlink source layout to use cmd/hardlink for the main application entry point and internal/ for application packages. Runtime files and preauth-release layout remain unchanged. No functional changes.
#### 1.2.7 - 13 May 2026
retrieve CardType from the ChepDNA response
#### 1.2.6 - 20 April 2026
added the second attempt to send the card to the encoder after 6 seconds
#### 1.2.5 - 20 March 2026
removed early return on error when checking dispenser status in the start and final loops.
#### 1.2.4 - 18 March 2026
added check if keycard at the encoder position before trying to encode key
#### 1.2.3 - 17 March 2026
added check if keycard at the encoder position before trying to encode key
#### 1.2.2 - 11 February 2026
increased waiting time befor sending email on PDQ unavailability to 30 seconds day time and 10 minutes night time
to give it a chance to become available again
#### 1.2.1 - 09 February 2026
increased waiting time befor sending email on PDQ unavailability to 60 seconds
#### 1.2.0 - 09 February 2026
added testissuedoorcard endpoint for testing the full workflow of encoding a door card without moving the card out
added ping-pdq endpoint to check the status of the pdq terminal
added sending the email on the pdq disconnect event to notify support about the issue
added sending the email on the dispenser error status to notify support about the issue
#### 1.1.3 - 02 February 2026
increased timeout for reading response from the Assa abloy lock server to 20 seconds
#### 1.1.2 - 02 February 2026
added logging for unknown dispenser status positions
#### 1.1.1 - 02 February 2026
added contionuous polling of the dispenser status every 8 seconds to update the card well status
#### 1.1.0 - 26 January 2026
divided `/starttransaction` endpoint into two separate endpoints:
`/takepreauth` to request preauthorization payment
`/takepayment` to request taking payment
added preauth releaser functionality to release preauthorization payments after a defined time period
added db connection check before adding a transaction to the database
and reconnection functionality if the connection to the database is lost
added `/dispenserstatus` endpoint
key card always stays at encoder position
#### 1.0.30 - 09 January 2026
improved logging for preauth releaser
#### 1.0.29 - 08 January 2026
added count down before exiting the preauth releaser 20 seconds
#### 1.0.28 - 10 December 2025
added preauth releaser