Compare commits

..

No commits in common. "development" and "1.1.3" have entirely different histories.

17 changed files with 134 additions and 989 deletions

View File

@ -27,9 +27,6 @@ type ConfigRec struct {
Dbpassword string `yaml:"dbpassword"` // Password for the database connection Dbpassword string `yaml:"dbpassword"` // Password for the database connection
IsPayment bool `yaml:"isPayment"` IsPayment bool `yaml:"isPayment"`
TestMode bool `yaml:"testMode"` TestMode bool `yaml:"testMode"`
Hotel string `yaml:"hotel"`
Kiosk int `yaml:"kiosk"`
SendErrorEmails []string `yaml:"senderroremails"`
} }
// ReadConfig reads config.yml and applies defaults. // ReadConfig reads config.yml and applies defaults.

View File

@ -37,7 +37,6 @@ var (
0x32: "Preparing card fails", 0x32: "Preparing card fails",
0x31: "Preparing card", 0x31: "Preparing card",
0x30: "Normal", 0x30: "Normal",
0x36: "Command cannot execute; Preparing card fails",
} }
statusPos1 = map[byte]string{ statusPos1 = map[byte]string{
0x38: "Dispensing card", 0x38: "Dispensing card",
@ -106,9 +105,6 @@ func stockTake(statusBytes []byte) string {
return "" return ""
} }
status := "" status := ""
if statusBytes[0] == 0x32 || statusBytes[0] == 0x36 {
status = statusPos0[statusBytes[0]]
}
if statusBytes[2] != 0x30 { if statusBytes[2] != 0x30 {
status = statusPos2[statusBytes[2]] status = statusPos2[statusBytes[2]]
} }
@ -182,7 +178,7 @@ func InitializeDispenser() (*serial.Port, error) {
const ( const (
funcName = "InitializeDispenser" funcName = "InitializeDispenser"
maxRetries = 3 maxRetries = 3
retryDelay = 4 * time.Second retryDelay = 2 * time.Second
) )
if SerialPort == "" { if SerialPort == "" {

View File

@ -279,9 +279,7 @@ func (c *Client) DispenserStart(ctx context.Context) (string, error) {
return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err) return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err)
} }
defer func() {
logStatus(status) logStatus(status)
}()
stockStatus = stockTake(status) stockStatus = stockTake(status)
c.setStock(status) c.setStock(status)
@ -296,75 +294,48 @@ func (c *Client) DispenserStart(ctx context.Context) (string, error) {
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err) return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
} }
deadline := time.Now().Add(6 * time.Second) time.Sleep(delay)
status, err = c.CheckStatus(ctx)
for { if err != nil {
time.Sleep(delay * 2) return stockStatus, fmt.Errorf("[%s] re-check status: %w", funcName, err)
if time.Now().After(deadline) {
return stockStatus, nil
} }
logStatus(status)
status, _ = c.do(ctx, cmdStatus)
stockStatus = stockTake(status) stockStatus = stockTake(status)
c.setStock(status) c.setStock(status)
logStatus(status)
// error states first
if isCardWellEmpty(status) {
return stockStatus, fmt.Errorf(stockStatus)
}
if isAtEncoderPosition(status) {
return stockStatus, nil return stockStatus, nil
}
}
} }
func (c *Client) DispenserFinal(ctx context.Context) (string, error) { func (c *Client) DispenserFinal(ctx context.Context) (string, error) {
const funcName = "DispenserFinal" const funcName = "DispenserFinal"
stockStatus := "" stockStatus := ""
var status []byte
if err := c.OutOfMouth(ctx); err != nil { if err := c.OutOfMouth(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] out of mouth: %w", funcName, err) return stockStatus, fmt.Errorf("[%s] out of mouth: %w", funcName, err)
} }
time.Sleep(delay) time.Sleep(delay)
status, err := c.do(ctx, cmdStatus) status, err := c.CheckStatus(ctx)
if err == nil && len(status) >= 4 { if err != nil {
c.setStock(status) return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err)
} }
logStatus(status)
stockStatus = stockTake(status)
c.setStock(status)
time.Sleep(delay) time.Sleep(delay)
if err := c.ToEncoder(ctx); err != nil { if err := c.ToEncoder(ctx); err != nil {
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err) return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
} }
defer func() { time.Sleep(delay)
logStatus(status) status, err = c.CheckStatus(ctx)
}() if err != nil {
return stockStatus, fmt.Errorf("[%s] re-check status: %w", funcName, err)
deadline := time.Now().Add(6 * time.Second)
for {
time.Sleep(delay * 2)
if time.Now().After(deadline) {
return stockStatus, nil
} }
logStatus(status)
status, _ = c.do(ctx, cmdStatus)
stockStatus = stockTake(status) stockStatus = stockTake(status)
c.setStock(status) c.setStock(status)
logStatus(status)
if isCardWellEmpty(status) {
return stockStatus, nil return stockStatus, nil
}
if isAtEncoderPosition(status) {
return stockStatus, nil
}
}
} }

View File

@ -10,6 +10,8 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
const serviceName = "hardlink"
// writeError is a helper to send a JSON error and HTTP status in one go. // 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{ theResponse := cmstypes.StatusRec{

1
go.mod
View File

@ -7,7 +7,6 @@ require (
gitea.futuresens.co.uk/futuresens/logging v1.0.9 gitea.futuresens.co.uk/futuresens/logging v1.0.9
github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb
github.com/denisenkom/go-mssqldb v0.12.3 github.com/denisenkom/go-mssqldb v0.12.3
github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
golang.org/x/image v0.27.0 golang.org/x/image v0.27.0

2
go.sum
View File

@ -19,8 +19,6 @@ github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZ
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/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/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/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= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View File

@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
@ -16,7 +17,6 @@ import (
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser" "gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/errorhandlers" "gitea.futuresens.co.uk/futuresens/hardlink/errorhandlers"
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver" "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/payment"
"gitea.futuresens.co.uk/futuresens/hardlink/printer" "gitea.futuresens.co.uk/futuresens/hardlink/printer"
"gitea.futuresens.co.uk/futuresens/hardlink/types" "gitea.futuresens.co.uk/futuresens/hardlink/types"
@ -33,8 +33,6 @@ type App struct {
dbMu sync.Mutex dbMu sync.Mutex
cardWellMu sync.RWMutex cardWellMu sync.RWMutex
cardWellStatus string 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 { func NewApp(disp *dispenser.Client, lockType, encoderAddress, cardWellStatus string, db *sql.DB, cfg *config.ConfigRec) *App {
@ -44,7 +42,6 @@ func NewApp(disp *dispenser.Client, lockType, encoderAddress, cardWellStatus str
lockserver: lockserver.NewLockServer(lockType, encoderAddress, errorhandlers.FatalError), lockserver: lockserver.NewLockServer(lockType, encoderAddress, errorhandlers.FatalError),
db: db, db: db,
cfg: cfg, cfg: cfg,
availabilityTimers: make(map[string]*time.Timer),
} }
app.SetCardWellStatus(cardWellStatus) app.SetCardWellStatus(cardWellStatus)
return app return app
@ -56,14 +53,10 @@ func (app *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/takepreauth", app.takePreauthorization) mux.HandleFunc("/takepreauth", app.takePreauthorization)
mux.HandleFunc("/takepayment", app.takePayment) mux.HandleFunc("/takepayment", app.takePayment)
mux.HandleFunc("/dispenserstatus", app.reportDispenserStatus) 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) { func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("takePreauthorization") const op = logging.Op("takePreauthorization")
var ( var (
theResponse cmstypes.ResponseRec theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec theRequest cmstypes.TransactionRec
@ -81,13 +74,10 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if !app.isPayment { if !app.isPayment {
if !app.cfg.TestMode {
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted preauthorization while payment processing is disabled")
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled") theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse) writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return return
} }
}
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@ -100,64 +90,55 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse) writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
return return
} }
defer r.Body.Close()
if r.Header.Get("Content-Type") != "text/xml" { if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Content-Type must be text/xml") theResponse.Data = payment.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse) writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return return
} }
defer r.Body.Close() body, _ := io.ReadAll(r.Body)
err := xml.Unmarshal(body, &theRequest)
body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Read body error", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "ReadXML", 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") theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse) writeTransactionResult(w, http.StatusBadRequest, theResponse)
return 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} 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 { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Preauth processing error", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor") theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse) writeTransactionResult(w, http.StatusBadGateway, theResponse)
return return
} }
defer response.Body.Close()
if err := trResult.ParseTransactionResult(body); err != nil { body, err = io.ReadAll(response.Body)
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0) 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) result.FillFromTransactionResult(trResult)
// ---- PRINT RECEIPT ---- if err := printer.PrintCardholderReceipt(result.CardholderReceipt); err != nil {
log.Errorf("PrintCardholderReceipt error: %v", err)
printer.PrintReceipt(result.CardholderReceipt) }
// ---- REDIRECT ----
theResponse.Status = result.Status theResponse.Status = result.Status
theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields) theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields)
if save { if save {
go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate) go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate)
} }
@ -167,7 +148,6 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
func (app *App) takePayment(w http.ResponseWriter, r *http.Request) { func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("takePayment") const op = logging.Op("takePayment")
var ( var (
theResponse cmstypes.ResponseRec theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec theRequest cmstypes.TransactionRec
@ -184,16 +164,11 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if !app.isPayment { 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 = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled") theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse) writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return return
} }
}
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
@ -205,118 +180,55 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse) writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
return return
} }
defer r.Body.Close()
if r.Header.Get("Content-Type") != "text/xml" { if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Content-Type must be text/xml") theResponse.Data = payment.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse) writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return return
} }
defer r.Body.Close() body, _ := io.ReadAll(r.Body)
err := xml.Unmarshal(body, &theRequest)
body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Read body error", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "ReadXML", 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") theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse) writeTransactionResult(w, http.StatusBadRequest, theResponse)
return 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} 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 { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Start transaction error", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor") theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse) writeTransactionResult(w, http.StatusBadGateway, theResponse)
return return
} }
defer response.Body.Close()
if err := trResult.ParseTransactionResult(body); err != nil { body, err = io.ReadAll(response.Body)
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 { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Confirm transaction error", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Read response body 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, "Failed to read response body")
theResponse.Data = payment.BuildFailureURL(types.ResultError, "ConfirmTransactionError") writeTransactionResult(w, http.StatusInternalServerError, theResponse)
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return return
} }
if err := trResult.ParseTransactionResult(body); err != nil { if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(types.ServiceName, err.Error(), "Parse confirm result error", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
} }
// Compose JSON from responseEntries
result.FillFromTransactionResult(trResult) result.FillFromTransactionResult(trResult)
res = result.Fields[types.TransactionResult] if err := printer.PrintCardholderReceipt(result.CardholderReceipt); err != nil {
log.Errorf("PrintCardholderReceipt error: %v", err)
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.Status = result.Status
theResponse.Data = payment.BuildSuccessURL(result.Fields) theResponse.Data = payment.BuildPaymentRedirectURL(result.Fields)
writeTransactionResult(w, http.StatusOK, theResponse) writeTransactionResult(w, http.StatusOK, theResponse)
} }
@ -350,7 +262,7 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
} }
if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil { if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil {
logging.Error(types.ServiceName, err.Error(), "ReadJSON", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "ReadJSON", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error()) errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
return return
} }
@ -358,13 +270,13 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
// parse times // parse times
checkIn, err := time.Parse(types.CustomLayout, doorReq.CheckinTime) checkIn, err := time.Parse(types.CustomLayout, doorReq.CheckinTime)
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Invalid checkinTime format", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Invalid checkinTime format", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error()) errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error())
return return
} }
checkOut, err := time.Parse(types.CustomLayout, doorReq.CheckoutTime) checkOut, err := time.Parse(types.CustomLayout, doorReq.CheckoutTime)
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error()) errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error())
return return
} }
@ -374,7 +286,7 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
status, err := app.disp.DispenserStart(r.Context()) status, err := app.disp.DispenserStart(r.Context())
app.SetCardWellStatus(status) app.SetCardWellStatus(status)
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Dispense error", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()) errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
return return
} }
@ -390,7 +302,7 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
status, ferr := app.disp.DispenserFinal(ctx) status, ferr := app.disp.DispenserFinal(ctx)
if ferr != nil { if ferr != nil {
logging.Error(types.ServiceName, ferr.Error(), "Dispenser final error", string(op), "", "", 0) logging.Error(serviceName, ferr.Error(), "Dispenser final error", string(op), "", "", 0)
return return
} }
app.SetCardWellStatus(status) app.SetCardWellStatus(status)
@ -401,7 +313,7 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
// lock server sequence // lock server sequence
if err := app.lockserver.LockSequence(); err != nil { if err := app.lockserver.LockSequence(); err != nil {
logging.Error(types.ServiceName, err.Error(), "Key encoding", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0)
finalize() finalize()
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error()) errorhandlers.WriteError(w, http.StatusBadGateway, err.Error())
return return
@ -440,21 +352,21 @@ func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() defer r.Body.Close()
if err := xml.NewDecoder(r.Body).Decode(&roomDetails); err != nil { if err := xml.NewDecoder(r.Body).Decode(&roomDetails); err != nil {
logging.Error(types.ServiceName, err.Error(), "ReadXML", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid XML payload: "+err.Error()) errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid XML payload: "+err.Error())
return return
} }
data, err := printer.BuildRoomTicket(roomDetails) data, err := printer.BuildRoomTicket(roomDetails)
if err != nil { if err != nil {
logging.Error(types.ServiceName, err.Error(), "BuildRoomTicket", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "BuildRoomTicket", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusInternalServerError, "BuildRoomTicket failed: "+err.Error()) errorhandlers.WriteError(w, http.StatusInternalServerError, "BuildRoomTicket failed: "+err.Error())
return return
} }
// Send to the Windows Epson TM-T82II via the printer package // Send to the Windows Epson TM-T82II via the printer package
if err := printer.SendToPrinter(data); err != nil { if err := printer.SendToPrinter(data); err != nil {
logging.Error(types.ServiceName, err.Error(), "printRoomTicket", "printRoomTicket", "", "", 0) logging.Error(serviceName, err.Error(), "printRoomTicket", "printRoomTicket", "", "", 0)
errorhandlers.WriteError(w, http.StatusInternalServerError, "Print failed: "+err.Error()) errorhandlers.WriteError(w, http.StatusInternalServerError, "Print failed: "+err.Error())
return return
} }
@ -479,13 +391,8 @@ func (app *App) reportDispenserStatus(w http.ResponseWriter, r *http.Request) {
func (app *App) SetCardWellStatus(s string) { func (app *App) SetCardWellStatus(s string) {
app.cardWellMu.Lock() app.cardWellMu.Lock()
prev := app.cardWellStatus
app.cardWellStatus = s app.cardWellStatus = s
app.cardWellMu.Unlock() 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 { func (app *App) CardWellStatus() string {

View File

@ -1,71 +1,20 @@
package handlers package handlers
import ( import (
"bytes"
"encoding/json" "encoding/json"
"encoding/xml"
"io"
"net/http" "net/http"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes" "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" "gitea.futuresens.co.uk/futuresens/logging"
log "github.com/sirupsen/logrus"
) )
const serviceName = "hardlink"
func writeTransactionResult(w http.ResponseWriter, status int, theResponse cmstypes.ResponseRec) { func writeTransactionResult(w http.ResponseWriter, status int, theResponse cmstypes.ResponseRec) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(theResponse); err != nil { if err := json.NewEncoder(w).Encode(theResponse); err != nil {
logging.Error(types.ServiceName, err.Error(), "JSON encode error", "startTransaction", "", "", 0) logging.Error(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
}

View File

@ -1,252 +0,0 @@
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) {
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,68 +0,0 @@
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

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

38
main.go
View File

@ -23,13 +23,11 @@ import (
"gitea.futuresens.co.uk/futuresens/hardlink/handlers" "gitea.futuresens.co.uk/futuresens/hardlink/handlers"
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver" "gitea.futuresens.co.uk/futuresens/hardlink/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/logging" "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" "gitea.futuresens.co.uk/futuresens/hardlink/printer"
) )
const ( const (
buildVersion = "1.2.5" buildVersion = "1.1.3"
serviceName = "hardlink" serviceName = "hardlink"
pollingFrequency = 8 * time.Second pollingFrequency = 8 * time.Second
) )
@ -41,11 +39,6 @@ func main() {
printer.PrinterName = cfg.PrinterName printer.PrinterName = cfg.PrinterName
lockserver.Cert = cfg.Cert lockserver.Cert = cfg.Cert
lockserver.LockServerURL = cfg.LockserverUrl lockserver.LockServerURL = cfg.LockserverUrl
mail.SendErrorEmails = cfg.SendErrorEmails
// Root context for background goroutines
// rootCtx, rootCancel := context.WithCancel(context.Background())
// defer rootCancel()
var ( var (
dispPort *serial.Port dispPort *serial.Port
@ -58,9 +51,7 @@ func main() {
if err != nil { if err != nil {
log.Printf("Failed to set up logging: %v\n", err) log.Printf("Failed to set up logging: %v\n", err)
} }
if logFile != nil {
defer logFile.Close() defer logFile.Close()
}
// Initialize dispenser // Initialize dispenser
if !cfg.TestMode { if !cfg.TestMode {
@ -69,21 +60,21 @@ func main() {
dispPort, err = dispenser.InitializeDispenser() dispPort, err = dispenser.InitializeDispenser()
if err != nil { if err != nil {
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Dispenser Initialization Error", fmt.Sprintf("Failed to initialize dispenser: %v", err))
errorhandlers.FatalError(err) errorhandlers.FatalError(err)
} }
defer dispPort.Close() defer dispPort.Close()
// Start queued dispenser client (single goroutine owns the serial port)
disp = dispenser.NewClient(dispPort, 32) disp = dispenser.NewClient(dispPort, 32)
defer disp.Close() defer disp.Close()
// Prepare dispenser (ensures card at encoder position unless empty)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel() defer cancel()
cardWellStatus, err = disp.DispenserPrepare(ctx) cardWellStatus, err = disp.DispenserPrepare(ctx)
if err != nil { if err != nil {
err = fmt.Errorf("%s; wrong dispenser address: %s", err, cfg.DispenserAdrr) err = fmt.Errorf("%s; wrong dispenser address: %s", err, cfg.DispenserAdrr)
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Dispenser Preparation Error", err.Error())
errorhandlers.FatalError(err) errorhandlers.FatalError(err)
} }
fmt.Println(cardWellStatus) fmt.Println(cardWellStatus)
@ -92,13 +83,12 @@ func main() {
// Test lock-server connection // Test lock-server connection
switch strings.ToLower(cfg.LockType) { switch strings.ToLower(cfg.LockType) {
case lockserver.TLJ: case lockserver.TLJ:
// TLJ uses HTTP - skip TCP probe here // TLJ uses HTTP - skip TCP probe here (as you did before)
default: default:
lockConn, err := lockserver.InitializeServerConnection(cfg.LockserverUrl) lockConn, err := lockserver.InitializeServerConnection(cfg.LockserverUrl)
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
log.Errorf(err.Error()) log.Errorf(err.Error())
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Lock Server Connection Error", err.Error())
} else { } else {
fmt.Printf("Connected to the lock server successfuly at %s\n", cfg.LockserverUrl) 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) log.Infof("Connected to the lock server successfuly at %s", cfg.LockserverUrl)
@ -110,37 +100,23 @@ func main() {
if err != nil { if err != nil {
log.Warnf("DB init failed: %v", err) log.Warnf("DB init failed: %v", err)
} }
if database != nil {
defer database.Close() defer database.Close()
}
if cfg.IsPayment { if cfg.IsPayment {
fmt.Println("Payment processing is enabled") fmt.Println("Payment processing is enabled")
log.Info("Payment processing is enabled") log.Info("Payment processing is enabled")
startChipDnaClient() 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("\nPDQ availabile: %v\n", pdqstatus.IsAvailable)
log.Infof("PDQ availabile: %v", pdqstatus.IsAvailable)
}
}()
} else { } else {
fmt.Println("Payment processing is disabled") fmt.Println("Payment processing is disabled")
log.Info("Payment processing is disabled") log.Info("Payment processing is disabled")
} }
// Create App and wire routes // 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) app := handlers.NewApp(disp, cfg.LockType, cfg.EncoderAddress, cardWellStatus, database, &cfg)
// Update cardWellStatus when dispenser status changes // Update cardWellStatus when dispenser status changes
if !cfg.TestMode && disp != nil { if !cfg.TestMode {
// Set initial cardWellStatus // Set initial cardWellStatus
app.SetCardWellStatus(cardWellStatus) app.SetCardWellStatus(cardWellStatus)
@ -205,7 +181,6 @@ func startChipDnaClient() {
err := cmd.Wait() err := cmd.Wait()
if err != nil { if err != nil {
log.Errorf("ChipDnaClient exited unexpectedly: %v", err) log.Errorf("ChipDnaClient exited unexpectedly: %v", err)
fmt.Printf("ChipDnaClient exited unexpectedly: %v", err)
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
cmd, err = startClient() cmd, err = startClient()
if err != nil { if err != nil {
@ -213,7 +188,6 @@ func startChipDnaClient() {
return return
} }
log.Info("ChipDnaClient restarted successfully") log.Info("ChipDnaClient restarted successfully")
fmt.Printf("ChipDnaClient restarted successfully")
} }
} }
}() }()

View File

@ -1,278 +0,0 @@
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 <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

@ -50,12 +50,6 @@ type (
transactionRes string transactionRes string
transactionState string transactionState string
} }
ConfirmTransactionRequest struct {
XMLName xml.Name `xml:"ConfirmTransactionRequest"`
Amount string `xml:"Amount"`
Reference string `xml:"TransactionReference"`
}
) )
// ParseTransactionResult parses the XML into entries. // ParseTransactionResult parses the XML into entries.
@ -78,7 +72,9 @@ func (ti *TransactionInfo) FillFromTransactionResult(trResult TransactionResultX
} }
func (r *PaymentResult) FillFromTransactionResult(trResult TransactionResultXML) { 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 { for _, e := range trResult.Entries {
switch e.Key { switch e.Key {
@ -111,7 +107,7 @@ func BuildPaymentRedirectURL(result map[string]string) string {
log.WithField(types.LogResult, result[types.ConfirmResult]). log.WithField(types.LogResult, result[types.ConfirmResult]).
Info("Transaction approved and confirmed") Info("Transaction approved and confirmed")
return BuildSuccessURL(result) return buildSuccessURL(result)
} }
// Not confirmed // Not confirmed
@ -137,7 +133,7 @@ func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
log.WithField(types.LogResult, result[types.TransactionResult]). log.WithField(types.LogResult, result[types.TransactionResult]).
Info("Account verification approved") Info("Account verification approved")
return BuildSuccessURL(result), false return buildSuccessURL(result), false
// Transaction type Sale? // Transaction type Sale?
case strings.EqualFold(tType, types.SaleTransactionType): case strings.EqualFold(tType, types.SaleTransactionType):
@ -145,7 +141,7 @@ func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
log.WithField(types.LogResult, result[types.ConfirmResult]). log.WithField(types.LogResult, result[types.ConfirmResult]).
Info("Amount preauthorized successfully") Info("Amount preauthorized successfully")
return BuildSuccessURL(result), true return buildSuccessURL(result), true
} }
} }
@ -153,7 +149,7 @@ func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
return BuildFailureURL(res, result[types.Errors]), false 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 := url.Values{}
q.Set("CardNumber", hex.EncodeToString([]byte(result[types.PAN_MASKED]))) q.Set("CardNumber", hex.EncodeToString([]byte(result[types.PAN_MASKED])))
q.Set("ExpiryDate", hex.EncodeToString([]byte(result[types.EXPIRY_DATE]))) q.Set("ExpiryDate", hex.EncodeToString([]byte(result[types.EXPIRY_DATE])))

View File

@ -167,19 +167,7 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
func PrintReceipt(receipt string) { func PrintCardholderReceipt(cardholderReceipt string) error {
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)) receiptEntries, err := ParseCardholderReceipt([]byte(cardholderReceipt))
if err != nil { if err != nil {
return fmt.Errorf("ParseCardholderReceipt: %w", err) return fmt.Errorf("ParseCardholderReceipt: %w", err)

View File

@ -2,28 +2,6 @@
builtVersion is a const in main.go builtVersion is a const in main.go
#### 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 #### 1.1.3 - 02 February 2026
increased timeout for reading response from the Assa abloy lock server to 20 seconds increased timeout for reading response from the Assa abloy lock server to 20 seconds

View File

@ -7,13 +7,11 @@ import (
) )
const ( const (
ServiceName = "hardlink"
DateOnly = "2006-01-02" DateOnly = "2006-01-02"
CustomLayout = "2006-01-02 15:04:05 -0700" CustomLayout = "2006-01-02 15:04:05 -0700"
LinkStartTransaction = "http://127.0.0.1:18181/start-transaction/" LinkTakePreauthorization = "http://127.0.0.1:18181/start-transaction/"
LinkConfirmTransaction = "http://127.0.0.1:18181/confirm-transaction/" LinkTakePayment = "http://127.0.0.1:18181/start-and-confirm-transaction/"
LinkTransactionInformation = "http://127.0.0.1:18181/transaction-information/" 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/" LinkVoidTransaction = "http://127.0.0.1:18181/void-transaction/"
// Transaction types // Transaction types
SaleTransactionType = "sale" SaleTransactionType = "sale"
@ -34,7 +32,6 @@ const (
CardReference = "CARD_REFERENCE" CardReference = "CARD_REFERENCE"
CardHash = "CARD_HASH" CardHash = "CARD_HASH"
Errors = "ERRORS" Errors = "ERRORS"
ErrorDescription = "ERROR_DESCRIPTION"
ReceiptData = "RECEIPT_DATA" ReceiptData = "RECEIPT_DATA"
ReceiptDataMerchant = "RECEIPT_DATA_MERCHANT" ReceiptDataMerchant = "RECEIPT_DATA_MERCHANT"
ReceiptDataCardholder = "RECEIPT_DATA_CARDHOLDER" ReceiptDataCardholder = "RECEIPT_DATA_CARDHOLDER"