From 48e2b6f568b73864e64322ea4e0ca18f1d2c9128 Mon Sep 17 00:00:00 2001 From: yurii Date: Mon, 9 Mar 2026 16:31:52 +0000 Subject: [PATCH] released version 1.2.0 --- config/config.go | 33 ++-- dispenser/dispenser.go | 6 +- errorhandlers/errorhandlers.go | 2 - go.mod | 1 + go.sum | 2 + handlers/handlers.go | 230 +++++++++++++++++++-------- handlers/http_helpers.go | 57 ++++++- handlers/testhandlers.go | 232 +++++++++++++++++++++++++++ mail/mail.go | 68 ++++++++ mail/mail_test.go | 9 ++ main.go | 40 ++++- payment/chipdnastatus.go | 278 +++++++++++++++++++++++++++++++++ payment/creditcall.go | 18 ++- printer/printer.go | 14 +- release notes.md | 6 + types/types.go | 7 +- 16 files changed, 895 insertions(+), 108 deletions(-) create mode 100644 handlers/testhandlers.go create mode 100644 mail/mail.go create mode 100644 mail/mail_test.go create mode 100644 payment/chipdnastatus.go diff --git a/config/config.go b/config/config.go index 4c47b8a..fe934c7 100644 --- a/config/config.go +++ b/config/config.go @@ -12,21 +12,24 @@ import ( // 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"` // 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"` + Hotel string `yaml:"hotel"` + Kiosk int `yaml:"kiosk"` + SendErrorEmails []string `yaml:"senderroremails"` } // ReadConfig reads config.yml and applies defaults. diff --git a/dispenser/dispenser.go b/dispenser/dispenser.go index 0e193b3..c86c832 100644 --- a/dispenser/dispenser.go +++ b/dispenser/dispenser.go @@ -37,6 +37,7 @@ var ( 0x32: "Preparing card fails", 0x31: "Preparing card", 0x30: "Normal", + 0x36: "Command cannot execute; Preparing card fails", } statusPos1 = map[byte]string{ 0x38: "Dispensing card", @@ -105,6 +106,9 @@ func stockTake(statusBytes []byte) string { return "" } status := "" + if statusBytes[0] == 0x32 || statusBytes[0] == 0x36 { + status = statusPos0[statusBytes[0]] + } if statusBytes[2] != 0x30 { status = statusPos2[statusBytes[2]] } @@ -178,7 +182,7 @@ func InitializeDispenser() (*serial.Port, error) { const ( funcName = "InitializeDispenser" maxRetries = 3 - retryDelay = 2 * time.Second + retryDelay = 4 * time.Second ) if SerialPort == "" { diff --git a/errorhandlers/errorhandlers.go b/errorhandlers/errorhandlers.go index 1c5cd48..d20e627 100644 --- a/errorhandlers/errorhandlers.go +++ b/errorhandlers/errorhandlers.go @@ -10,8 +10,6 @@ import ( 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) { theResponse := cmstypes.StatusRec{ diff --git a/go.mod b/go.mod index b6d8b5d..29e9a36 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( 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/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 diff --git a/go.sum b/go.sum index 4bedda2..a8dbb5d 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ 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/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= diff --git a/handlers/handlers.go b/handlers/handlers.go index bf5ed23..7e246cf 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -1,7 +1,6 @@ package handlers import ( - "bytes" "context" "database/sql" "encoding/json" @@ -17,6 +16,7 @@ import ( "gitea.futuresens.co.uk/futuresens/hardlink/dispenser" "gitea.futuresens.co.uk/futuresens/hardlink/errorhandlers" "gitea.futuresens.co.uk/futuresens/hardlink/lockserver" + "gitea.futuresens.co.uk/futuresens/hardlink/mail" "gitea.futuresens.co.uk/futuresens/hardlink/payment" "gitea.futuresens.co.uk/futuresens/hardlink/printer" "gitea.futuresens.co.uk/futuresens/hardlink/types" @@ -25,23 +25,26 @@ import ( ) type App struct { - disp *dispenser.Client - lockserver lockserver.LockServer - isPayment bool - db *sql.DB - cfg *config.ConfigRec - dbMu sync.Mutex - cardWellMu sync.RWMutex - cardWellStatus string + disp *dispenser.Client + lockserver lockserver.LockServer + 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, + 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 @@ -53,10 +56,14 @@ func (app *App) RegisterRoutes(mux *http.ServeMux) { 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) } func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { const op = logging.Op("takePreauthorization") + var ( theResponse cmstypes.ResponseRec theRequest cmstypes.TransactionRec @@ -74,9 +81,12 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { 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 !app.cfg.TestMode { + mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted preauthorization while payment processing is disabled") + theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled") + writeTransactionResult(w, http.StatusServiceUnavailable, theResponse) + return + } } if r.Method == http.MethodOptions { @@ -90,55 +100,64 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse) return } - defer r.Body.Close() - if ct := r.Header.Get("Content-Type"); ct != "text/xml" { + if r.Header.Get("Content-Type") != "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) + defer r.Body.Close() + + body, err := io.ReadAll(r.Body) if err != nil { - logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0) + logging.Error(types.ServiceName, err.Error(), "Read body error", string(op), "", "", 0) + theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read request body") + writeTransactionResult(w, http.StatusBadRequest, theResponse) + return + } + + if err := xml.Unmarshal(body, &theRequest); err != nil { + logging.Error(types.ServiceName, err.Error(), "ReadXML", string(op), "", "", 0) theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload") writeTransactionResult(w, http.StatusBadRequest, theResponse) return } - log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType) + + log.Printf( + "Preauthorization 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)) + + // ---- START TRANSACTION ---- + + body, err = callChipDNA(client, types.LinkStartTransaction, body) if err != nil { - logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0) + logging.Error(types.ServiceName, err.Error(), "Preauth processing error", string(op), "", "", 0) + theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor") 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) + logging.Error(types.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) - } + // ---- PRINT RECEIPT ---- + + printer.PrintReceipt(result.CardholderReceipt) + + // ---- REDIRECT ---- theResponse.Status = result.Status theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields) + if save { go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate) } @@ -148,6 +167,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { func (app *App) takePayment(w http.ResponseWriter, r *http.Request) { const op = logging.Op("takePayment") + var ( theResponse cmstypes.ResponseRec theRequest cmstypes.TransactionRec @@ -164,9 +184,13 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) { 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 !app.cfg.TestMode { + mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted payment while payment processing is disabled") + theResponse.Status.Code = http.StatusServiceUnavailable + theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled") + writeTransactionResult(w, http.StatusServiceUnavailable, theResponse) + return + } } if r.Method == http.MethodOptions { @@ -180,55 +204,118 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) { writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse) return } - defer r.Body.Close() - if ct := r.Header.Get("Content-Type"); ct != "text/xml" { + if r.Header.Get("Content-Type") != "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) + defer r.Body.Close() + + body, err := io.ReadAll(r.Body) if err != nil { - logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0) + logging.Error(types.ServiceName, err.Error(), "Read body error", string(op), "", "", 0) + theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read request body") + writeTransactionResult(w, http.StatusBadRequest, theResponse) + return + } + + if err := xml.Unmarshal(body, &theRequest); err != nil { + logging.Error(types.ServiceName, err.Error(), "ReadXML", string(op), "", "", 0) theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload") writeTransactionResult(w, http.StatusBadRequest, theResponse) return } - log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType) + + 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)) + + // ---- START TRANSACTION ---- + + body, err = callChipDNA(client, types.LinkStartTransaction, body) if err != nil { - logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0) + logging.Error(types.ServiceName, err.Error(), "Start transaction error", string(op), "", "", 0) + theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor") writeTransactionResult(w, http.StatusBadGateway, theResponse) return } - defer response.Body.Close() - body, err = io.ReadAll(response.Body) + 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 = payment.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 := payment.ConfirmTransactionRequest{ + Amount: theRequest.AmountMinorUnits, + Reference: ref, + } + + body, err = confirmWithRetry(client, confirmReq, 2) 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) + logging.Error(types.ServiceName, err.Error(), "Confirm transaction error", string(op), "", "", 0) + mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Error: "+err.Error()) + theResponse.Data = payment.BuildFailureURL(types.ResultError, "ConfirmTransactionError") + writeTransactionResult(w, http.StatusBadGateway, theResponse) return } if err := trResult.ParseTransactionResult(body); err != nil { - logging.Error(serviceName, err.Error(), "Parse transaction result error", string(op), "", "", 0) + logging.Error(types.ServiceName, err.Error(), "Parse confirm 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) + 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 = payment.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 = payment.BuildPaymentRedirectURL(result.Fields) + theResponse.Data = payment.BuildSuccessURL(result.Fields) + writeTransactionResult(w, http.StatusOK, theResponse) } @@ -262,7 +349,7 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) { } if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil { - logging.Error(serviceName, err.Error(), "ReadJSON", string(op), "", "", 0) + logging.Error(types.ServiceName, err.Error(), "ReadJSON", string(op), "", "", 0) errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error()) return } @@ -270,13 +357,13 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) { // parse times checkIn, err := time.Parse(types.CustomLayout, doorReq.CheckinTime) if err != nil { - logging.Error(serviceName, err.Error(), "Invalid checkinTime format", string(op), "", "", 0) + 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(serviceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0) + logging.Error(types.ServiceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0) errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error()) return } @@ -286,7 +373,7 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) { status, err := app.disp.DispenserStart(r.Context()) app.SetCardWellStatus(status) if err != nil { - logging.Error(serviceName, err.Error(), "Dispense error", string(op), "", "", 0) + logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0) errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()) return } @@ -302,7 +389,7 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) { status, ferr := app.disp.DispenserFinal(ctx) if ferr != nil { - logging.Error(serviceName, ferr.Error(), "Dispenser final error", string(op), "", "", 0) + logging.Error(types.ServiceName, ferr.Error(), "Dispenser final error", string(op), "", "", 0) return } app.SetCardWellStatus(status) @@ -313,7 +400,7 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) { // lock server sequence if err := app.lockserver.LockSequence(); err != nil { - logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0) + logging.Error(types.ServiceName, err.Error(), "Key encoding", string(op), "", "", 0) finalize() errorhandlers.WriteError(w, http.StatusBadGateway, err.Error()) return @@ -352,21 +439,21 @@ func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() if err := xml.NewDecoder(r.Body).Decode(&roomDetails); err != nil { - logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0) + 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(serviceName, err.Error(), "BuildRoomTicket", string(op), "", "", 0) + 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(serviceName, err.Error(), "printRoomTicket", "printRoomTicket", "", "", 0) + logging.Error(types.ServiceName, err.Error(), "printRoomTicket", "printRoomTicket", "", "", 0) errorhandlers.WriteError(w, http.StatusInternalServerError, "Print failed: "+err.Error()) return } @@ -391,8 +478,13 @@ func (app *App) reportDispenserStatus(w http.ResponseWriter, r *http.Request) { 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 { diff --git a/handlers/http_helpers.go b/handlers/http_helpers.go index 0cfc279..7c8477b 100644 --- a/handlers/http_helpers.go +++ b/handlers/http_helpers.go @@ -1,20 +1,71 @@ package handlers import ( + "bytes" "encoding/json" + "encoding/xml" + "io" "net/http" + "time" "gitea.futuresens.co.uk/futuresens/cmstypes" + "gitea.futuresens.co.uk/futuresens/hardlink/payment" + "gitea.futuresens.co.uk/futuresens/hardlink/types" "gitea.futuresens.co.uk/futuresens/logging" + log "github.com/sirupsen/logrus" ) -const serviceName = "hardlink" - 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) + 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 payment.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 +} diff --git a/handlers/testhandlers.go b/handlers/testhandlers.go new file mode 100644 index 0000000..512b7dc --- /dev/null +++ b/handlers/testhandlers.go @@ -0,0 +1,232 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "gitea.futuresens.co.uk/futuresens/cmstypes" + "gitea.futuresens.co.uk/futuresens/hardlink/errorhandlers" + "gitea.futuresens.co.uk/futuresens/hardlink/lockserver" + "gitea.futuresens.co.uk/futuresens/hardlink/mail" + "gitea.futuresens.co.uk/futuresens/hardlink/payment" + "gitea.futuresens.co.uk/futuresens/hardlink/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 := payment.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 payment.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 payment.KeyErrors: + mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, title, e.Value) + + case payment.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) { + title := "ChipDNA Error" + key := fmt.Sprintf("%s-%d", app.cfg.Hotel, app.cfg.Kiosk) + + 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 + } + + log.Println("PDQ reported unavailable - starting 10s debounce timer") + + timer := time.AfterFunc(5*time.Second, func() { + mail.SendEmailOnError( + app.cfg.Hotel, + app.cfg.Kiosk, + title, + "ChipDNA PDQ unavailable for more than 5 seconds", + ) + + app.availabilityMu.Lock() + delete(app.availabilityTimers, key) + app.availabilityMu.Unlock() + }) + + app.availabilityTimers[key] = timer +} diff --git a/mail/mail.go b/mail/mail.go new file mode 100644 index 0000000..c8de682 --- /dev/null +++ b/mail/mail.go @@ -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) + } +} diff --git a/mail/mail_test.go b/mail/mail_test.go new file mode 100644 index 0000000..f75ef1a --- /dev/null +++ b/mail/mail_test.go @@ -0,0 +1,9 @@ +package mail + +import ( + "testing" +) + +func Test_SendMail(t *testing.T) { + SendMail("zotacrtx5@gmail.com", "Test Subjectp", "Test Message") +} diff --git a/main.go b/main.go index 9829ed7..6ff4158 100644 --- a/main.go +++ b/main.go @@ -23,11 +23,13 @@ import ( "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/mail" + "gitea.futuresens.co.uk/futuresens/hardlink/payment" "gitea.futuresens.co.uk/futuresens/hardlink/printer" ) const ( - buildVersion = "1.1.3" + buildVersion = "1.2.0" serviceName = "hardlink" pollingFrequency = 8 * time.Second ) @@ -39,6 +41,11 @@ func main() { 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 @@ -51,7 +58,9 @@ func main() { if err != nil { log.Printf("Failed to set up logging: %v\n", err) } - defer logFile.Close() + if logFile != nil { + defer logFile.Close() + } // Initialize dispenser if !cfg.TestMode { @@ -60,21 +69,21 @@ func main() { dispPort, err = dispenser.InitializeDispenser() if err != nil { + mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Dispenser Initialization Error", fmt.Sprintf("Failed to initialize dispenser: %v", err)) errorhandlers.FatalError(err) } defer dispPort.Close() - // Start queued dispenser client (single goroutine owns the serial port) disp = dispenser.NewClient(dispPort, 32) defer disp.Close() - // Prepare dispenser (ensures card at encoder position unless empty) 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) + mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Dispenser Preparation Error", err.Error()) errorhandlers.FatalError(err) } fmt.Println(cardWellStatus) @@ -83,12 +92,13 @@ func main() { // Test lock-server connection switch strings.ToLower(cfg.LockType) { case lockserver.TLJ: - // TLJ uses HTTP - skip TCP probe here (as you did before) + // 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) @@ -100,23 +110,37 @@ func main() { if err != nil { log.Warnf("DB init failed: %v", err) } - defer database.Close() + if database != nil { + defer database.Close() + } if cfg.IsPayment { fmt.Println("Payment processing is enabled") log.Info("Payment processing is enabled") + startChipDnaClient() + + // check ChipDNA and PDQ status and log any errors, but continue running even if it fails + go func() { + time.Sleep(30 * time.Second) // give ChipDNA client a moment to start + pdqstatus, err := payment.ReadPdqStatus(cfg.Hotel, cfg.Kiosk) + if err != nil { + mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "PDQ Status Read Error", err.Error()) + } else { + fmt.Printf("PDQ availabile: %v\n", pdqstatus.IsAvailable) + log.Infof("PDQ availabile: %v", pdqstatus.IsAvailable) + } + }() } else { fmt.Println("Payment processing is disabled") log.Info("Payment processing is disabled") } // Create App and wire routes - // NOTE: change handlers.NewApp signature to accept *dispenser.Client instead of *serial.Port app := handlers.NewApp(disp, cfg.LockType, cfg.EncoderAddress, cardWellStatus, database, &cfg) // Update cardWellStatus when dispenser status changes - if !cfg.TestMode { + if !cfg.TestMode && disp != nil { // Set initial cardWellStatus app.SetCardWellStatus(cardWellStatus) diff --git a/payment/chipdnastatus.go b/payment/chipdnastatus.go new file mode 100644 index 0000000..dfc4246 --- /dev/null +++ b/payment/chipdnastatus.go @@ -0,0 +1,278 @@ +package payment + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "html" + "io" + "net/http" + "strings" + "time" + + "gitea.futuresens.co.uk/futuresens/hardlink/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 + 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: + } +} diff --git a/payment/creditcall.go b/payment/creditcall.go index 60da2e7..620345d 100644 --- a/payment/creditcall.go +++ b/payment/creditcall.go @@ -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,9 +78,7 @@ 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 { @@ -107,7 +111,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 +137,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 +145,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,7 +153,7 @@ 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]))) diff --git a/printer/printer.go b/printer/printer.go index e41114b..676b04a 100644 --- a/printer/printer.go +++ b/printer/printer.go @@ -167,7 +167,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) diff --git a/release notes.md b/release notes.md index 54db49b..2f6fc32 100644 --- a/release notes.md +++ b/release notes.md @@ -2,6 +2,12 @@ builtVersion is a const in main.go +#### 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 diff --git a/types/types.go b/types/types.go index 913a634..219338c 100644 --- a/types/types.go +++ b/types/types.go @@ -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" @@ -32,6 +34,7 @@ const ( CardReference = "CARD_REFERENCE" CardHash = "CARD_HASH" Errors = "ERRORS" + ErrorDescription = "ERROR_DESCRIPTION" ReceiptData = "RECEIPT_DATA" ReceiptDataMerchant = "RECEIPT_DATA_MERCHANT" ReceiptDataCardholder = "RECEIPT_DATA_CARDHOLDER"