Compare commits

..

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

10 changed files with 188 additions and 309 deletions

1
.gitignore vendored
View File

@ -28,7 +28,6 @@ Checkin.code-workspace
_obj _obj
_test _test
.vscode/ .vscode/
ChipDNAClient/
# Architecture specific extensions/prefixes # Architecture specific extensions/prefixes
*.[568vq] *.[568vq]

View File

@ -251,8 +251,6 @@ func CardToEncoderPosition(port *serial.Port) (string, error) {
return "", fmt.Errorf("error sending ENQ to prompt device: %v", err) return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
} }
time.Sleep(delay)
//Check card position status //Check card position status
status, err := CheckDispenserStatus(port) status, err := CheckDispenserStatus(port)
if err != nil { if err != nil {
@ -283,8 +281,6 @@ func CardOutOfMouth(port *serial.Port) (string, error) {
return "", fmt.Errorf("error sending ENQ to prompt device: %v", err) return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
} }
time.Sleep(delay)
//Check card position status //Check card position status
status, err := CheckDispenserStatus(port) status, err := CheckDispenserStatus(port)
if err != nil { if err != nil {

View File

@ -19,15 +19,8 @@ func (lock *AssaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
} }
// Checks heart beat of the Assa Abloy lock server and perform key encoding // Checks heart beat of the Assa Abloy lock server and perform key encoding
func (lock *AssaLockServer) LockSequence() error { func (lock *AssaLockServer) LockSequence(conn net.Conn) error {
const funcName = "AssaLockServer.LockSequence" const funcName = "AssaLockServer.LockSequence"
conn, err := InitializeServerConnection(LockServerURL)
if err != nil {
return err
}
defer conn.Close()
resp, err := sendHeartbeatToServer(conn) resp, err := sendHeartbeatToServer(conn)
if err != nil { if err != nil {
return fmt.Errorf("[%s] heartbeat failed: %v", funcName, err) return fmt.Errorf("[%s] heartbeat failed: %v", funcName, err)

View File

@ -35,7 +35,7 @@ var (
type ( type (
LockServer interface { LockServer interface {
BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error
LockSequence() error LockSequence(conn net.Conn) error
} }
AssaLockServer struct { AssaLockServer struct {

View File

@ -53,15 +53,8 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
} }
// Starts link to the Omnitec lock server and perform key encoding // Starts link to the Omnitec lock server and perform key encoding
func (lock *OmniLockServer) LockSequence() error { func (lock *OmniLockServer) LockSequence(conn net.Conn) error {
const funcName = "OmniLockServer.LockSequence" const funcName = "OmniLockServer.LockSequence"
conn, err := InitializeServerConnection(LockServerURL)
if err != nil {
return err
}
defer conn.Close()
// Start the link with the lock server // Start the link with the lock server
regs, err := lock.linkStart(conn) regs, err := lock.linkStart(conn)
if err != nil { if err != nil {

View File

@ -75,135 +75,79 @@ func (lock *SaltoLockServer) BuildCommand(req DoorCardRequest, checkIn, checkOut
return nil return nil
} }
// readFrame consumes a full frame starting with firstByte (expected STX).
// It reads up to ETX and then attempts to read a trailing LRC (or CR). timeout controls read deadlines.
func (lock *SaltoLockServer) readFrame(conn net.Conn, reader *bufio.Reader, firstByte byte, timeout time.Duration) ([]byte, error) {
frame := []byte{firstByte}
for {
conn.SetReadDeadline(time.Now().Add(timeout))
b, e := reader.ReadByte()
if e != nil {
return frame, fmt.Errorf("error reading frame body: %w", e)
}
frame = append(frame, b)
if b == ETX {
break
}
}
// read trailing LRC (or CR) if present (non-blocking w/ timeout)
conn.SetReadDeadline(time.Now().Add(timeout))
if lrc, e := reader.ReadByte(); e == nil {
frame = append(frame, lrc)
} else {
// Not fatal: some devices might omit LRC/CR — just log
log.Warnf("readFrame: no trailing LRC/CR: %v", e)
}
return frame, nil
}
// waitForAck waits for ACK or NAK. If STX frames are encountered they are drained
// using readFrame. drainedCount (if non-nil) accumulates the number of drained frames.
func (lock *SaltoLockServer) waitForAck(conn net.Conn, reader *bufio.Reader, timeout time.Duration, drainedCount *int) error {
deadline := time.Now().Add(timeout)
for {
conn.SetReadDeadline(time.Now().Add(time.Until(deadline)))
b, e := reader.ReadByte()
if e != nil {
return fmt.Errorf("error waiting for ACK/NAK: %w", e)
}
switch b {
case ACK:
return nil
case NAK:
return fmt.Errorf("received NAK")
case STX:
// stale or queued full response: consume it and continue waiting
frame, fe := lock.readFrame(conn, reader, b, timeout)
if fe != nil {
// if we can't consume frame, consider it an error
return fmt.Errorf("failed to consume queued STX frame: %w", fe)
}
if drainedCount != nil {
*drainedCount++
log.Infof("Drained queued frame #%d (while waiting for ACK): %q", *drainedCount, string(frame))
} else {
log.Infof("Drained queued frame (while waiting for ACK): %q", string(frame))
}
// loop to keep waiting for ACK
default:
// Unexpected byte while waiting for ACK. Log and continue reading.
log.Warnf("waitForAck: unexpected byte 0x%X while waiting for ACK; ignoring", b)
// keep looping until timeout or ACK/NAK
}
}
}
// LockSequence performs the full ENQ/ACK handshake and command exchange // LockSequence performs the full ENQ/ACK handshake and command exchange
func (lock *SaltoLockServer) LockSequence() error { func (lock *SaltoLockServer) LockSequence(conn net.Conn) error {
log.Infof("Sending command: %q", string(lock.command))
const timeout = 10 * time.Second const timeout = 10 * time.Second
var ( var (
err error
resp []byte resp []byte
drained = 0 // count of stale frames consumed across waits
) )
conn, err := InitializeServerConnection(LockServerURL)
if err != nil {
return err
}
defer conn.Close()
reader := bufio.NewReader(conn) reader := bufio.NewReader(conn)
// 1. Send ENQ // 1. Send ENQ
log.Infof("Sending ENQ")
if _, e := conn.Write([]byte{ENQ}); e != nil { if _, e := conn.Write([]byte{ENQ}); e != nil {
return fmt.Errorf("failed to send ENQ: %w", e) return fmt.Errorf("failed to send ENQ: %w", e)
} }
// 2. Expect ACK (but drain any queued STX frames first) // 2. Expect ACK
if e := lock.waitForAck(conn, reader, timeout, &drained); e != nil { conn.SetReadDeadline(time.Now().Add(timeout))
if b, e := reader.ReadByte(); e != nil {
return fmt.Errorf("error awaiting ACK to ENQ: %w", e) return fmt.Errorf("error awaiting ACK to ENQ: %w", e)
} else if b != ACK {
return fmt.Errorf("expected ACK after ENQ, got 0x%X", b)
} }
// 3. Send command frame // 3. Send command frame
log.Infof("Sending encoding command: %q", string(lock.command))
if _, e := conn.Write(lock.command); e != nil { if _, e := conn.Write(lock.command); e != nil {
return fmt.Errorf("failed to send command frame: %w", e) return fmt.Errorf("failed to send command frame: %w", e)
} }
// 4. Expect ACK to command (again drain any queued frames that might precede it) // 4. Expect ACK to command
if e := lock.waitForAck(conn, reader, timeout, &drained); e != nil { conn.SetReadDeadline(time.Now().Add(timeout))
if b, e := reader.ReadByte(); e != nil {
return fmt.Errorf("error awaiting ACK to command: %w", e) return fmt.Errorf("error awaiting ACK to command: %w", e)
} else if b == NAK {
return fmt.Errorf("command rejected (NAK)")
} else if b != ACK {
return fmt.Errorf("expected ACK to command, got 0x%X", b)
} }
// 5. Now read the *next* STX frame which should be the response to our command. // 5. Expect STX
for { conn.SetReadDeadline(time.Now().Add(timeout))
conn.SetReadDeadline(time.Now().Add(20 * time.Second)) stx, e := reader.ReadByte()
b, e := reader.ReadByte()
if e != nil { if e != nil {
return fmt.Errorf("error reading response start: %w", e) return fmt.Errorf("error reading STX: %w", e)
} }
if b != STX { resp = append(resp, stx)
// If anything else arrives, it might be another control byte or noise; if stx != STX {
// log and keep consuming until we find STX. err = fmt.Errorf("expected STX, got 0x%X", stx)
log.Warnf("expected STX to start response but got 0x%X; ignoring", b) }
continue
} // 6. Read separator after STX
// consume full response frame conn.SetReadDeadline(time.Now().Add(timeout))
frame, fe := lock.readFrame(conn, reader, b, timeout) if sep, e := reader.ReadByte(); e == nil {
if fe != nil { resp = append(resp, sep)
return fmt.Errorf("error reading response frame: %w", fe) } else if err == nil {
} err = fmt.Errorf("error reading separator after STX: %w", e)
resp = append(resp, frame...) }
break
// 7. Read command code (e.g., CN, TD)
conn.SetReadDeadline(time.Now().Add(timeout))
b1, e1 := reader.ReadByte()
if e1 == nil {
resp = append(resp, b1)
} else if err == nil {
err = fmt.Errorf("error reading first response byte: %w", e1)
}
b2, e2 := reader.ReadByte()
if e2 == nil {
resp = append(resp, b2)
} else if err == nil {
err = fmt.Errorf("error reading second response byte: %w", e2)
} }
// parse the command code within response frame
if len(resp) >= 4 {
// The command bytes are usually at indices 2 and 3 if separator is at index 1
sepIndex := 1
b1 := resp[sepIndex+1]
b2 := resp[sepIndex+2]
switch { switch {
case b1 == 'C' && b2 == 'N': case b1 == 'C' && b2 == 'N':
log.Infof("LockSequence: command response is CN (normal)") log.Infof("LockSequence: command response is CN (normal)")
@ -211,16 +155,40 @@ func (lock *SaltoLockServer) LockSequence() error {
log.Infof("LockSequence: command response is CC (follow-up)") log.Infof("LockSequence: command response is CC (follow-up)")
case b1 == 'T' && b2 == 'D': case b1 == 'T' && b2 == 'D':
log.Warnf("LockSequence: command response is TD (room does not exist)") log.Warnf("LockSequence: command response is TD (room does not exist)")
return fmt.Errorf("lock response indicates room does not exist") if err == nil {
err = fmt.Errorf("lock response indicates room does not exist")
}
default: default:
log.Warnf("LockSequence: unexpected command response %q", string(resp)) log.Warnf("LockSequence: unexpected command response %q", string(resp))
return fmt.Errorf("error encoding keycard, unexpected response") if err == nil {
err = fmt.Errorf("error encoding keycard, unexpected response")
} }
}
// 8. Read rest of message until ETX
for {
conn.SetReadDeadline(time.Now().Add(timeout))
c, e := reader.ReadByte()
if e != nil {
if err == nil {
err = fmt.Errorf("error reading response body: %w", e)
}
break
}
resp = append(resp, c)
if c == ETX {
break
}
}
// 9. Optional: read trailing byte (LRC or CR)
conn.SetReadDeadline(time.Now().Add(timeout))
if lrc, e := reader.ReadByte(); e == nil {
resp = append(resp, lrc)
} else { } else {
log.Warnf("LockSequence: response too short: %q", string(resp)) log.Warnf("LockSequence: failed to read trailing LRC/CR: %v", e)
return fmt.Errorf("response too short")
} }
log.Infof("LockSequence: received response: %q", string(resp)) log.Infof("LockSequence: received response: %q", string(resp))
return nil return err
} }

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net"
"net/http" "net/http"
"net/url" "net/url"
"time" "time"
@ -35,9 +36,9 @@ func (lock *TLJLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkO
return nil return nil
} }
func (lock *TLJLockServer) LockSequence() error { func (lock *TLJLockServer) LockSequence(conn net.Conn) error {
log.Infof("Sending command: %q", lock.command) log.Infof("Sending command: %q", lock.command)
client := &http.Client{Timeout: 30 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(lock.command) resp, err := client.Get(lock.command)
if err != nil { if err != nil {
return fmt.Errorf("HTTP request failed: %v", err) return fmt.Errorf("HTTP request failed: %v", err)

96
main.go
View File

@ -1,11 +1,12 @@
package main package main
import ( import (
"bytes" // "database/sql"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
@ -29,7 +30,7 @@ import (
) )
const ( const (
buildVersion = "1.0.18" buildVersion = "1.0.10"
serviceName = "hardlink" serviceName = "hardlink"
customLayout = "2006-01-02 15:04:05 -0700" customLayout = "2006-01-02 15:04:05 -0700"
transactionUrl = "http://127.0.0.1:18181/start-transaction/" transactionUrl = "http://127.0.0.1:18181/start-transaction/"
@ -51,15 +52,15 @@ type configRec struct {
// App holds shared resources. // App holds shared resources.
type App struct { type App struct {
configRec configRec
dispPort *serial.Port dispPort *serial.Port
lockConn net.Conn
lockserver lockserver.LockServer lockserver lockserver.LockServer
} }
func newApp(dispPort *serial.Port, config configRec) *App { func newApp(dispPort *serial.Port, lockConn net.Conn, config configRec) *App {
return &App{ return &App{
configRec: config,
dispPort: dispPort, dispPort: dispPort,
lockConn: lockConn,
lockserver: lockserver.NewLockServer(config.LockType, config.EncoderAddress, fatalError), lockserver: lockserver.NewLockServer(config.LockType, config.EncoderAddress, fatalError),
} }
} }
@ -69,6 +70,8 @@ func main() {
config := readConfig() config := readConfig()
printer.Layout = readTicketLayout() printer.Layout = readTicketLayout()
printer.PrinterName = config.PrinterName printer.PrinterName = config.PrinterName
var lockConn net.Conn
lockserver.Cert = config.Cert lockserver.Cert = config.Cert
lockserver.LockServerURL = config.LockserverUrl lockserver.LockServerURL = config.LockserverUrl
@ -100,19 +103,25 @@ func main() {
} }
log.Infof("Dispenser initialized on port %s, %s", config.DispenserPort, status) log.Infof("Dispenser initialized on port %s, %s", config.DispenserPort, status)
// Test lock-server connection // Initialize lock-server connection once
switch strings.ToLower(config.LockType) { switch strings.ToLower(config.LockType) {
case lockserver.TLJ: case lockserver.TLJ:
default: default:
lockConn, err := lockserver.InitializeServerConnection(config.LockserverUrl) lockConn, err = lockserver.InitializeServerConnection(config.LockserverUrl)
if err != nil { if err != nil {
fatalError(err) fatalError(err)
} }
log.Infof("Connectting to lock server at %s", config.LockserverUrl) defer lockConn.Close()
lockConn.Close() log.Infof("Connected to lock server at %s", config.LockserverUrl)
} }
// db, err := payment.InitMSSQL(config.dbport, config.dbname, config.dbuser, config.dbpassword)
// if err != nil {
// fatalError(fmt.Errorf("DB init failed: %v", err))
// }
// defer db.Close()
if config.IsPayment { if config.IsPayment {
startClient := func() (*exec.Cmd, error) { startClient := func() (*exec.Cmd, error) {
cmd := exec.Command("./ChipDNAClient/ChipDnaClient.exe") cmd := exec.Command("./ChipDNAClient/ChipDnaClient.exe")
@ -172,7 +181,7 @@ func main() {
// Create App and wire routes // Create App and wire routes
// dispHandle := &serial.Port{} // Placeholder, replace with actual dispenser handle // dispHandle := &serial.Port{} // Placeholder, replace with actual dispenser handle
app := newApp(dispHandle, config) app := newApp(dispHandle, lockConn, config)
mux := http.NewServeMux() mux := http.NewServeMux()
setUpRoutes(app, mux) setUpRoutes(app, mux)
@ -232,36 +241,18 @@ func writeError(w http.ResponseWriter, status int, msg string) {
json.NewEncoder(w).Encode(theResponse) json.NewEncoder(w).Encode(theResponse)
} }
func writeTransactionResult(w http.ResponseWriter, status int, theResponse cmstypes.ResponseRec) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(theResponse); err != nil {
logging.Error(serviceName, err.Error(), "JSON encode error", "startTransaction", "", "", 0)
}
}
func (app *App) startTransaction(w http.ResponseWriter, r *http.Request) { func (app *App) startTransaction(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("startTransaction") const op = logging.Op("startTransaction")
var ( var (
theResponse cmstypes.ResponseRec theResponse cmstypes.ResponseRec
cardholderReceipt string cardholderReceipt string
theRequest cmstypes.TransactionRec
) )
theResponse.Status.Code = http.StatusInternalServerError
theResponse.Status.Message = "500 Internal server error"
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if !app.configRec.IsPayment {
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return
}
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
@ -269,43 +260,29 @@ func (app *App) startTransaction(w http.ResponseWriter, r *http.Request) {
log.Println("startTransaction called") log.Println("startTransaction called")
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Method not allowed; use POST") writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
return return
} }
defer r.Body.Close() defer r.Body.Close()
if ct := r.Header.Get("Content-Type"); ct != "text/xml" { if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Content-Type must be text/xml") writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return return
} }
body, _ := io.ReadAll(r.Body) client := &http.Client{Timeout: 90 * time.Second}
err := xml.Unmarshal(body, &theRequest) response, err := client.Post(transactionUrl, "text/xml", r.Body)
if err != nil {
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse)
return
}
log.Printf("Start trnasaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType)
client := &http.Client{Timeout: 300 * time.Second}
response, err := client.Post(transactionUrl, "text/xml", bytes.NewBuffer(body))
if err != nil { if err != nil {
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "No response from payment processor") writeError(w, http.StatusInternalServerError, "Payment processing failed: "+err.Error())
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return return
} }
defer response.Body.Close() defer response.Body.Close()
body, err = io.ReadAll(response.Body) body, err := io.ReadAll(response.Body)
if err != nil { if err != nil {
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Failed to read response body") writeError(w, http.StatusInternalServerError, "Failed to read response body: "+err.Error())
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
return return
} }
@ -315,11 +292,10 @@ func (app *App) startTransaction(w http.ResponseWriter, r *http.Request) {
result := make(map[string]string) result := make(map[string]string)
for _, e := range responseEntries { for _, e := range responseEntries {
switch e.Key { switch e.Key {
case payment.ReceiptData, payment.ReceiptDataMerchant: case "RECEIPT_DATA", "RECEIPT_DATA_MERCHANT":
// ignore these case "RECEIPT_DATA_CARDHOLDER":
case payment.ReceiptDataCardholder:
cardholderReceipt = e.Value cardholderReceipt = e.Value
case payment.TransactionResult: case "TRANSACTION_RESULT":
theResponse.Status.Message = e.Value theResponse.Status.Message = e.Value
theResponse.Status.Code = http.StatusOK theResponse.Status.Code = http.StatusOK
result[e.Key] = e.Value result[e.Key] = e.Value
@ -332,8 +308,18 @@ func (app *App) startTransaction(w http.ResponseWriter, r *http.Request) {
log.Errorf("PrintCardholderReceipt error: %v", err) log.Errorf("PrintCardholderReceipt error: %v", err)
} }
// Insert into DB
// if err := payment.InsertTransactionRecord(r.Context(), app.db, result); err != nil {
// log.Errorf("DB insert error: %v", err)
// }
theResponse.Data = payment.BuildRedirectURL(result) theResponse.Data = payment.BuildRedirectURL(result)
writeTransactionResult(w, http.StatusOK, theResponse)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(theResponse); err != nil {
logging.Error(serviceName, err.Error(), "JSON encode error", string(op), "", "", 0)
}
} }
func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) { func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
@ -403,7 +389,7 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
app.lockserver.BuildCommand(doorReq, checkIn, checkOut) app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
// lock server sequence // lock server sequence
err = app.lockserver.LockSequence() err = app.lockserver.LockSequence(app.lockConn)
if err != nil { if err != nil {
logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0)
writeError(w, http.StatusBadGateway, err.Error()) writeError(w, http.StatusBadGateway, err.Error())

View File

@ -16,11 +16,6 @@ import (
) )
const ( const (
// Transaction types
SaleTransactionType = "sale"
AccountVerificationType = "account verification"
// Transaction results
ResultApproved = "approved" ResultApproved = "approved"
ResultDeclined = "declined" ResultDeclined = "declined"
ResultCancelled = "cancelled" ResultCancelled = "cancelled"
@ -28,24 +23,6 @@ const (
ResultError = "error" ResultError = "error"
CheckinSuccessfulEndpoint = "/successful" // Endpoint to send guest to after successful payment CheckinSuccessfulEndpoint = "/successful" // Endpoint to send guest to after successful payment
CheckinUnsuccessfulEndpoint = "/unsuccessful" CheckinUnsuccessfulEndpoint = "/unsuccessful"
// Response map keys
CardReference = "CARD_REFERENCE"
CardHash = "CARD_HASH"
Errors = "ERRORS"
ReceiptData = "RECEIPT_DATA"
ReceiptDataMerchant = "RECEIPT_DATA_MERCHANT"
ReceiptDataCardholder = "RECEIPT_DATA_CARDHOLDER"
Reference = "REFERENCE"
TransactionResult = "TRANSACTION_RESULT"
TransactionType = "TRANSACTION_TYPE"
ConfirmResult = "CONFIRM_RESULT"
ConfirmErrors = "CONFIRM_ERRORS"
// Log field keys
LogFieldError = "error"
LogFieldDescription = "description"
LogResult = "transactionResult"
) )
// XML parsing structs // XML parsing structs
@ -65,14 +42,6 @@ type (
Key string `xml:"Key"` Key string `xml:"Key"`
Value string `xml:"Value"` Value string `xml:"Value"`
} }
TransactionConfirmation struct {
XMLName xml.Name `xml:"TransactionConfirmation"`
Result string `xml:"Result"`
Errors string `xml:"Errors"`
ErrorDescription string `xml:"ErrorDescription"`
ReceiptDataCardholder string `xml:"ReceiptDataCardholder"`
}
) )
// ParseTransactionResult parses the XML into entries. // ParseTransactionResult parses the XML into entries.
@ -248,70 +217,65 @@ func nullableFloatArg(nf sql.NullFloat64) interface{} {
return nil return nil
} }
// BuildRedirectURL builds the redirect URL to send the guest to after payment.
func BuildRedirectURL(result map[string]string) string { func BuildRedirectURL(result map[string]string) string {
res := result[TransactionResult] // 1) normalize the result code
tType := result[TransactionType] res := strings.ToLower(result["TRANSACTION_RESULT"])
// Transaction approved? // 2) pick base path and optional error params
if strings.EqualFold(res, ResultApproved) { var basePath string
switch { var msgType, description string
// Transaction type AccountVerification?
case strings.EqualFold(tType, AccountVerificationType):
log.WithField(LogResult, result[TransactionResult]).
Info("Account verification approved")
return buildSuccessURL(result) switch res {
case ResultApproved:
basePath = CheckinSuccessfulEndpoint
// Transaction type Sale? case ResultDeclined:
case strings.EqualFold(tType, SaleTransactionType): basePath = CheckinUnsuccessfulEndpoint
// Transaction confirmed? msgType = "declined"
if strings.EqualFold(result[ConfirmResult], ResultApproved) { description = "payment declined"
log.WithField(LogResult, result[ConfirmResult]).
Info("Transaction approved and confirmed")
return buildSuccessURL(result) case ResultCancelled:
basePath = CheckinUnsuccessfulEndpoint
msgType = "cancelled"
description = "payment cancelled by customer"
case ResultPending:
// you could choose to treat pending as unsuccessful or special-case it
basePath = CheckinUnsuccessfulEndpoint
msgType = "pending"
description = "payment pending"
case ResultError:
basePath = CheckinUnsuccessfulEndpoint
msgType = "error"
description = result["ERROR"]
default:
basePath = CheckinUnsuccessfulEndpoint
msgType = "error"
description = "unknown transaction result"
} }
// Not confirmed if msgType != "" {
log.WithFields(log.Fields{LogFieldError: result[ConfirmResult], LogFieldDescription: result[ConfirmErrors]}). log.Warnf("Transaction %s: %s - %s", res, msgType, description)
Error("Transaction approved but not confirmed")
return BuildFailureURL(result[ConfirmResult], result[ConfirmErrors])
}
} }
// Not approved // 3) build query params
return BuildFailureURL(res, result[Errors])
}
func buildSuccessURL(result map[string]string) string {
q := url.Values{} q := url.Values{}
q.Set("TxnReference", result[Reference]) q.Set("TxnReference", result["REFERENCE"])
q.Set("CardHash", hex.EncodeToString([]byte(result[CardHash]))) q.Set("CardHash", hex.EncodeToString([]byte(result["CARD_HASH"])))
q.Set("CardReference", hex.EncodeToString([]byte(result[CardReference]))) q.Set("CardReference", hex.EncodeToString([]byte(result["CARD_REFERENCE"])))
return (&url.URL{
Path: CheckinSuccessfulEndpoint,
RawQuery: q.Encode(),
}).String()
}
func BuildFailureURL(msgType, description string) string {
q := url.Values{}
if msgType == "" {
msgType = ResultError
}
if description == "" {
description = "Transaction failed"
}
log.WithFields(log.Fields{LogFieldError: msgType, LogFieldDescription: description}).
Error("Transaction failed")
// only append these when non-approved
if msgType != "" {
q.Set("MsgType", msgType) q.Set("MsgType", msgType)
q.Set("Description", description) q.Set("Description", description)
return (&url.URL{ }
Path: CheckinUnsuccessfulEndpoint,
// 4) assemble final URL
// note: url.URL automatically escapes values in RawQuery
u := url.URL{
Path: basePath,
RawQuery: q.Encode(), RawQuery: q.Encode(),
}).String() }
return u.String()
} }

View File

@ -2,56 +2,35 @@
builtVersion is a const in main.go builtVersion is a const in main.go
#### 1.0.18 - 04 September 2025 #### 1.0.10 - 08 August 2024
increased timeout for TLJ lock server connection to 30 seconds
#### 1.0.17 - 30 August 2025
added functionality to commit transactions
#### 1.0.15 - 27 August 2025
fixed TCP/IP connection to the lock server
#### 1.0.14 - 21 August 2025
fixed issue in creditcall payment processing where error description was not properly set
#### 1.0.13 - 21 August 2025
TCP/IP connection to the lock server is now established before encoding the keycard and closedafter the encoding is done.
#### 1.0.12 - 11 August 2025
added delay before checking dispenser status
#### 1.0.11 - 11 August 2025
updated Salto key encoding workflow
#### 1.0.10 - 08 August 2025
updated logging for TLJ locks updated logging for TLJ locks
#### 1.0.9 - 08 August 2025 #### 1.0.9 - 08 August 2024
added TLJ lock server and implemented workflow for TLJ locks added TLJ lock server and implemented workflow for TLJ locks
#### 1.0.8 - 01 August 2025 #### 1.0.8 - 01 August 2024
improved error handling and logging in Salto improved error handling and logging in Salto
#### 1.0.7 - 25 July 2025 #### 1.0.7 - 25 July 2024
added check if the room exists added check if the room exists
#### 1.0.6 - 25 July 2025 #### 1.0.6 - 25 July 2024
updated workflow for Salto locks updated workflow for Salto locks
#### 1.0.5 - 24 July 2025 #### 1.0.5 - 24 July 2024
added encoding keycard copy for Salto locks added encoding keycard copy for Salto locks
#### 1.0.4 - 22 July 2025 #### 1.0.4 - 22 July 2024
added salto lock server and implemented workflow for Salto added salto lock server and implemented workflow for Salto
#### 1.0.0 - 30 June 2025 #### 1.0.0 - 30 June 2024
added creditcall payment method added creditcall payment method
`/starttransaction` - API payment endpoint to start a transaction `/starttransaction` - API payment endpoint to start a transaction
#### 0.9.1 - 22 May 2025 #### 0.9.1 - 22 May 2024
added lockserver interface and implemented workflow for Omnitec added lockserver interface and implemented workflow for Omnitec
#### 0.9.0 - 22 May 2025 #### 0.9.0 - 22 May 2024
The new API has two new endpoints: The new API has two new endpoints:
- `/issuedoorcard` - encoding the door card for the room. - `/issuedoorcard` - encoding the door card for the room.
- `/printroomticket` - printing the room ticket. - `/printroomticket` - printing the room ticket.