Compare commits

..

1 Commits

Author SHA1 Message Date
56d33b167a start of preauth 2025-12-02 17:03:30 +00:00
25 changed files with 735 additions and 2695 deletions

View File

@ -1,18 +0,0 @@
// internal/bootstrap/db.go
package bootstrap
import (
"database/sql"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/db"
)
func OpenDB(cfg *config.ConfigRec) (*sql.DB, error) {
return db.InitMSSQL(
cfg.Dbport,
cfg.Dbuser,
cfg.Dbpassword,
cfg.Dbname,
)
}

View File

@ -1,96 +0,0 @@
package config
import (
"fmt"
"os"
"strings"
"gitea.futuresens.co.uk/futuresens/hardlink/errorhandlers"
log "github.com/sirupsen/logrus"
yaml "gopkg.in/yaml.v3"
)
// 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"`
Hotel string `yaml:"hotel"`
Kiosk int `yaml:"kiosk"`
SendErrorEmails []string `yaml:"senderroremails"`
}
// ReadConfig reads config.yml and applies defaults.
func ReadHardlinkConfig() ConfigRec {
var cfg ConfigRec
const configName = "config.yml"
defaultPort := 9091
sep := string(os.PathSeparator)
data, err := os.ReadFile(configName)
if err != nil {
log.Warnf("ReadConfig %s: %v", configName, err)
} else if err := yaml.Unmarshal(data, &cfg); err != nil {
log.Warnf("Unmarshal config: %v", err)
}
if cfg.Port == 0 {
cfg.Port = defaultPort
}
if cfg.LockType == "" {
err = fmt.Errorf("LockType is required in %s", configName)
errorhandlers.FatalError(err)
}
cfg.LockType = strings.ToLower(cfg.LockType)
if cfg.LogDir == "" {
cfg.LogDir = "./logs" + sep
} else if !strings.HasSuffix(cfg.LogDir, sep) {
cfg.LogDir += sep
}
if cfg.Dbport <= 0 || cfg.Dbuser == "" || cfg.Dbname == "" || cfg.Dbpassword == "" {
err = fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName)
log.Warnf(err.Error())
}
return cfg
}
func ReadPreauthReleaserConfig() ConfigRec {
var cfg ConfigRec
const configName = "config.yml"
sep := string(os.PathSeparator)
data, err := os.ReadFile(configName)
if err != nil {
log.Warnf("ReadConfig %s: %v", configName, err)
} else if err := yaml.Unmarshal(data, &cfg); err != nil {
log.Warnf("Unmarshal config: %v", err)
}
if cfg.Dbport <= 0 || cfg.Dbuser == "" || cfg.Dbname == "" || cfg.Dbpassword == "" {
err = fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName)
errorhandlers.FatalError(err)
}
if cfg.LogDir == "" {
cfg.LogDir = "./logs" + sep
} else if !strings.HasSuffix(cfg.LogDir, sep) {
cfg.LogDir += sep
}
return cfg
}

201
db/db.go
View File

@ -1,201 +0,0 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
mssqldb "github.com/denisenkom/go-mssqldb" // for error inspection
log "github.com/sirupsen/logrus"
"gitea.futuresens.co.uk/futuresens/hardlink/types"
)
// InitMSSQL opens and pings the SQL Server instance (keeps your original behaviour)
func InitMSSQL(port int, user, password, database string) (*sql.DB, error) {
if port <= 0 || user == "" || database == "" {
return nil, errors.New("incomplete database configuration")
}
dsn := fmt.Sprintf(
"sqlserver://%s:%s@%s:%d?database=%s&encrypt=disable",
user, password, "localhost", port, database,
)
db, err := sql.Open("sqlserver", dsn)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, err
}
if err := db.QueryRowContext(ctx, "SELECT 1").Scan(new(int)); err != nil {
db.Close()
return nil, err
}
log.Info("Database connection established")
return db, nil
}
func parseDateOnly(s string) (time.Time, error) {
parsed, err := time.Parse(types.CustomLayout, s)
if err == nil {
// construct midnight in local timezone, then convert to UTC for storage consistency
localMidnight := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.Local)
return localMidnight.UTC(), nil
}
return time.Time{}, fmt.Errorf("parseDateOnly error: parsing %q: %w", s, err)
}
// InsertPreauth stores a preauthorization record. It is idempotent: duplicate TxnReference is ignored.
func InsertPreauth(ctx context.Context, db *sql.DB, m map[string]string, checkoutDate string) error {
const funcName = "InsertPreauth"
totalMinorUnits := m[types.TotalAmount]
txnReference := m[types.Reference]
if txnReference == "" {
return fmt.Errorf("%s: missing REFERENCE", funcName)
}
if totalMinorUnits == "" {
return fmt.Errorf("%s: missing TotalAmount", funcName)
}
// parse minor units, fallback to 0 on parse error but report it
amountInt, err := strconv.ParseInt(totalMinorUnits, 10, 64)
if err != nil {
log.WithFields(log.Fields{
"func": funcName,
"value": totalMinorUnits,
"error": err,
}).Warnf("parsing TotalAmount, defaulting to 0")
amountInt = 0
}
totalAmount := float64(amountInt) / 100.0
txnTime := time.Now().UTC()
// parse departure / checkout date and compute release date (48h after departure)
checkOutDate, err := parseDateOnly(checkoutDate)
if err != nil {
return fmt.Errorf("InsertPreauth: parsing checkoutDate %q: %w", checkoutDate, err)
}
releaseDate := checkOutDate.Add(48 * time.Hour)
const stmt = `
INSERT INTO dbo.Preauthorizations
(TxnReference, TotalMinorUnits, TotalAmount, TxnDateTime, DepartureDate, ReleaseDate)
VALUES
(@TxnReference, @TotalMinorUnits, @TotalAmount, @TxnDateTime, @DepartureDate, @ReleaseDate);
`
_, err = db.ExecContext(ctx, stmt,
sql.Named("TxnReference", txnReference),
sql.Named("TotalMinorUnits", totalMinorUnits),
sql.Named("TotalAmount", totalAmount),
sql.Named("TxnDateTime", txnTime),
sql.Named("DepartureDate", checkOutDate),
sql.Named("ReleaseDate", releaseDate),
)
if err != nil {
// handle duplicate-key (unique constraint) gracefully: SQL Server error numbers 2601/2627
var sqlErr mssqldb.Error
if errors.As(err, &sqlErr) {
if sqlErr.Number == 2627 || sqlErr.Number == 2601 {
log.Infof("InsertPreauth: preauth %s already exists (duplicate key) - ignoring", txnReference)
return nil
}
}
return fmt.Errorf("InsertPreauth exec: %w", err)
}
log.Infof("Inserted preauth %s amount=%s minorUnits release=%s", txnReference, totalMinorUnits, releaseDate.Format(time.RFC3339))
return nil
}
// GetDuePreauths returns preauths with ReleaseDate <= now where Released = 0.
// If limit > 0, the query uses TOP(limit) to bound results at DB level.
func GetDuePreauths(ctx context.Context, db *sql.DB, now time.Time, limit int) ([]types.PreauthRec, error) {
baseQuery := `
SELECT Id, TxnReference, TotalMinorUnits, TotalAmount, TxnDateTime, DepartureDate, ReleaseDate, Released, ReleasedAt
FROM dbo.Preauthorizations
WHERE Released = 0 AND ReleaseDate <= @Now
ORDER BY ReleaseDate ASC
`
query := baseQuery
if limit > 0 {
// embed TOP to keep DB from returning everything; limit is controlled by the caller.
query = strings.Replace(baseQuery, "SELECT", fmt.Sprintf("SELECT TOP (%d)", limit), 1)
}
rows, err := db.QueryContext(ctx, query, sql.Named("Now", now))
if err != nil {
return nil, fmt.Errorf("GetDuePreauths query: %w", err)
}
defer rows.Close()
var out []types.PreauthRec
for rows.Next() {
var r types.PreauthRec
var departure sql.NullTime
var releasedAt sql.NullTime
// Note: adjust scanning targets if types.PreauthRec fields differ
if err := rows.Scan(
&r.Id,
&r.TxnReference,
&r.TotalMinorUnits,
&r.TotalAmount,
&r.TxnDateTime,
&departure,
&r.ReleaseDate,
&r.Released,
&releasedAt,
); err != nil {
return nil, fmt.Errorf("GetDuePreauths scan: %w", err)
}
if departure.Valid {
r.DepartureDate = departure.Time
} else {
r.DepartureDate = time.Time{}
}
r.ReleasedAt = releasedAt
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("GetDuePreauths rows: %w", err)
}
return out, nil
}
// MarkPreauthReleased sets Released=1 and stores the timestamp. Returns error if no rows updated.
func MarkPreauthReleased(ctx context.Context, db *sql.DB, txnReference string, releasedAt time.Time) error {
const stmt = `
UPDATE dbo.Preauthorizations
SET Released = 1, ReleasedAt = @ReleasedAt
WHERE TxnReference = @TxnReference AND Released = 0;
`
res, err := db.ExecContext(ctx, stmt, sql.Named("ReleasedAt", releasedAt), sql.Named("TxnReference", txnReference))
if err != nil {
return fmt.Errorf("MarkPreauthReleased exec: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("MarkPreauthReleased RowsAffected: %w", err)
}
if n == 0 {
return fmt.Errorf("no rows updated for %s (maybe already released)", txnReference)
}
log.Infof("Marked preauth %s released at %s", txnReference, releasedAt.Format(time.RFC3339))
return nil
}

View File

@ -1,8 +1,9 @@
package dispenser package dispenser
import ( import (
// "encoding/hex"
"fmt" "fmt"
"strings" // "log"
"time" "time"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -19,26 +20,29 @@ const (
space = 0x00 // Space character space = 0x00 // Space character
baudRate = 9600 // Baud rate for serial communication baudRate = 9600 // Baud rate for serial communication
delay = 500 * time.Millisecond // Delay for processing commands delay = 500 * time.Millisecond // Delay for processing commands
// cache freshness for "continuous status" reads (tune as you wish)
defaultStatusTTL = 1500 * time.Millisecond
) )
// type (
// configRec struct {
// SerialPort string `yaml:"port"`
// Address string `yaml:"addr"`
// }
// )
var ( var (
SerialPort string SerialPort string
Address []byte Address []byte
commandFC7 = []byte{ETX, 0x46, 0x43, 0x37} // "FC7" command dispense card at read card position
commandFC7 = []byte{ETX, 0x46, 0x43, 0x37} // "FC7" commandFC0 = []byte{ETX, 0x46, 0x43, 0x30} // "FC0" command dispense card out of card mouth command
commandFC0 = []byte{ETX, 0x46, 0x43, 0x30} // "FC0"
statusPos0 = map[byte]string{ statusPos0 = map[byte]string{
0x38: "Keep", 0x38: "Keep",
0x34: "Command cannot execute", 0x34: "Command cannot execute",
0x32: "Preparing card fails", 0x32: "Preparing card fails",
0x31: "Preparing card", 0x31: "Preparing card",
0x30: "Normal", 0x30: "Normal", // Default if none of the above
0x36: "Command cannot execute; Preparing card fails",
} }
statusPos1 = map[byte]string{ statusPos1 = map[byte]string{
0x38: "Dispensing card", 0x38: "Dispensing card",
0x34: "Capturing card", 0x34: "Capturing card",
@ -46,6 +50,7 @@ var (
0x31: "Capture card error", 0x31: "Capture card error",
0x30: "Normal", 0x30: "Normal",
} }
statusPos2 = map[byte]string{ statusPos2 = map[byte]string{
0x38: "No captured card", 0x38: "No captured card",
0x34: "Card overlapped", 0x34: "Card overlapped",
@ -53,6 +58,7 @@ var (
0x31: "Card pre-empty", 0x31: "Card pre-empty",
0x30: "Normal", 0x30: "Normal",
} }
statusPos3 = map[byte]string{ statusPos3 = map[byte]string{
0x38: "Card empty", 0x38: "Card empty",
0x34: "Card ready position", 0x34: "Card ready position",
@ -63,16 +69,10 @@ var (
} }
) )
// -------------------- func checkStatus(statusResp []byte) (string, error) {
// Status helpers if len(statusResp) > 3 {
// -------------------- statusBytes := statusResp[7:11] // Extract the relevant bytes from the response
// For each position, get the ASCII character, hex value, and mapped meaning.
func logStatus(statusBytes []byte) {
if len(statusBytes) < 4 {
log.Infof("Dispenser status: <invalid len=%d>", len(statusBytes))
return
}
posStatus := []struct { posStatus := []struct {
pos int pos int
value byte value byte
@ -84,59 +84,33 @@ func logStatus(statusBytes []byte) {
{pos: 4, value: statusBytes[3], mapper: statusPos3}, {pos: 4, value: statusBytes[3], mapper: statusPos3},
} }
var result strings.Builder result := ""
for _, p := range posStatus { for _, p := range posStatus {
statusMsg, exists := p.mapper[p.value] statusMsg, exists := p.mapper[p.value]
if !exists { if !exists {
statusMsg = fmt.Sprintf("Unknown status 0x%X at position %d", p.value, p.pos) statusMsg = "Unknown status"
} }
if p.value != 0x30 { if p.value != 0x30 {
result.WriteString(statusMsg + "; ") result += fmt.Sprintf("Status: %s; ", statusMsg)
}
if p.pos == 4 && p.value == 0x38 {
return result, fmt.Errorf("Card well empty")
}
}
return result, nil
} else {
if len(statusResp) == 3 && statusResp[0] == ACK && statusResp[1] == Address[0] && statusResp[2] == Address[1] {
return "active;", nil
} else if len(statusResp) > 0 && statusResp[0] == NAK {
return "", fmt.Errorf("negative response from dispenser")
} else {
return "", fmt.Errorf("unexpected response status: % X", statusResp)
} }
} }
log.Infof("Dispenser status: %s", result.String())
} }
func isAtEncoderPosition(statusBytes []byte) bool { // calculateBCC computes the Block Check Character (BCC) as the XOR of all bytes from STX to ETX.
return len(statusBytes) >= 4 && statusBytes[3] == 0x33
}
func stockTake(statusBytes []byte) string {
if len(statusBytes) < 4 {
return ""
}
status := ""
if statusBytes[0] == 0x32 || statusBytes[0] == 0x36 {
status = statusPos0[statusBytes[0]]
}
if statusBytes[2] != 0x30 {
status = statusPos2[statusBytes[2]]
}
if statusBytes[3] == 0x38 {
status = statusPos3[statusBytes[3]]
}
return status
}
func isCardWellEmpty(statusBytes []byte) bool {
return len(statusBytes) >= 4 && statusBytes[3] == 0x38
}
func checkACK(statusResp []byte) error {
if len(statusResp) == 3 &&
statusResp[0] == ACK &&
len(Address) >= 2 &&
statusResp[1] == Address[0] &&
statusResp[2] == Address[1] {
return nil
}
if len(statusResp) > 0 && statusResp[0] == NAK {
return fmt.Errorf("negative response from dispenser")
}
return fmt.Errorf("unexpected response status: % X", statusResp)
}
// calculateBCC computes BCC as XOR of all bytes from STX to ETX.
func calculateBCC(data []byte) byte { func calculateBCC(data []byte) byte {
var bcc byte var bcc byte
for _, b := range data { for _, b := range data {
@ -147,8 +121,8 @@ func calculateBCC(data []byte) byte {
func createPacket(address []byte, command []byte) []byte { func createPacket(address []byte, command []byte) []byte {
packet := []byte{STX} packet := []byte{STX}
packet = append(packet, address...) packet = append(packet, address...) // Address bytes
packet = append(packet, space) packet = append(packet, space) // Space character
packet = append(packet, command...) packet = append(packet, command...)
packet = append(packet, ETX) packet = append(packet, ETX)
bcc := calculateBCC(packet) bcc := calculateBCC(packet)
@ -156,134 +130,165 @@ func createPacket(address []byte, command []byte) []byte {
return packet return packet
} }
func buildCheckAP(address []byte) []byte { return createPacket(address, []byte{STX, 0x41, 0x50}) } func buildCheckRF(address []byte) []byte {
return createPacket(address, []byte{STX, 0x52, 0x46})
}
func buildCheckAP(address []byte) []byte {
return createPacket(address, []byte{STX, 0x41, 0x50})
}
func sendAndReceive(port *serial.Port, packet []byte, delay time.Duration) ([]byte, error) { func sendAndReceive(port *serial.Port, packet []byte, delay time.Duration) ([]byte, error) {
_, err := port.Write(packet) n, err := port.Write(packet)
if err != nil { if err != nil {
return nil, fmt.Errorf("error writing to port: %w", err) return nil, fmt.Errorf("error writing to port: %w", err)
} }
// log.Printf("TX %d bytes: % X", n, packet[:n])
time.Sleep(delay) // Wait for the dispenser to process the command
buf := make([]byte, 128)
n, err = port.Read(buf)
if err != nil {
return nil, fmt.Errorf("error reading from port: %w", err)
}
resp := buf[:n]
// log.Printf("RX %d bytes: % X", n, buf[:n])
return resp, nil
}
func InitializeDispenser() (*serial.Port, error) {
const funcName = "initializeDispenser"
serialConfig := &serial.Config{
Name: SerialPort,
Baud: baudRate,
ReadTimeout: time.Second * 2,
}
port, err := serial.OpenPort(serialConfig)
if err != nil {
return nil, fmt.Errorf("error opening dispenser COM port: %w", err)
}
return port, nil
}
func DispenserSequence(port *serial.Port) (string, error) {
const funcName = "dispenserSequence"
var result string
// Check dispenser status
status, err := CheckDispenserStatus(port)
if err != nil {
return status, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
}
result += status
// Send card to encoder position
status, err = CardToEncoderPosition(port)
if err != nil {
return status, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err)
}
result += "; " + status
return result, nil
}
// if dispenser is not responding, I should repeat the command
func CheckDispenserStatus(port *serial.Port) (string, error) {
const funcName = "checkDispenserStatus"
var result string
checkCmd := buildCheckAP(Address)
enq := append([]byte{ENQ}, Address...)
// Send check command (AP)
statusResp, err := sendAndReceive(port, checkCmd, delay)
if err != nil {
return "", fmt.Errorf("error sending check command: %v", err)
}
if len(statusResp) == 0 {
return "", fmt.Errorf("no response from dispenser")
}
status, err := checkStatus(statusResp)
if err != nil {
return status, err
}
result += "; " + status
// Send ENQ+ADDR to prompt device to execute the command.
statusResp, err = sendAndReceive(port, enq, delay)
if err != nil {
log.Errorf("error sending ENQ: %v", err)
}
if len(statusResp) == 0 {
return "", fmt.Errorf("no response from dispenser")
}
status, err = checkStatus(statusResp)
if err != nil {
return status, err
}
result += status
return result, nil
}
func CardToEncoderPosition(port *serial.Port) (string, error) {
const funcName = "cartToEncoderPosition"
enq := append([]byte{ENQ}, Address...)
//Send Dispense card to encoder position (FC7) ---
dispenseCmd := createPacket(Address, commandFC7)
log.Println("Send card to encoder position")
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
if err != nil {
return "", fmt.Errorf("error sending card to encoder position: %v", err)
}
_, err = checkStatus(statusResp)
if err != nil {
return "", err
}
//Send ENQ to prompt device ---
_, err = port.Write(enq)
if err != nil {
return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
}
time.Sleep(delay) time.Sleep(delay)
buf := make([]byte, 128) //Check card position status
n, err := port.Read(buf) status, err := CheckDispenserStatus(port)
if err != nil { if err != nil {
return nil, fmt.Errorf("error reading from port: %w", err) return "", err
} }
return buf[:n], nil return status, nil
} }
// -------------------- func CardOutOfMouth(port *serial.Port) (string, error) {
// Serial init (3 attempts) const funcName = "CardOutOfMouth"
// --------------------
func InitializeDispenser() (*serial.Port, error) {
const (
funcName = "InitializeDispenser"
maxRetries = 3
retryDelay = 4 * time.Second
)
if SerialPort == "" {
return nil, fmt.Errorf("%s: SerialPort is empty", funcName)
}
if len(Address) < 2 {
return nil, fmt.Errorf("%s: Address must be at least 2 bytes", funcName)
}
serialConfig := &serial.Config{
Name: SerialPort,
Baud: baudRate,
ReadTimeout: 2 * time.Second,
}
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
port, err := serial.OpenPort(serialConfig)
if err == nil {
log.Infof("%s: dispenser opened on %s (attempt %d/%d)", funcName, SerialPort, attempt, maxRetries)
return port, nil
}
lastErr = err
log.Warnf("%s: failed to open dispenser on %s (attempt %d/%d): %v", funcName, SerialPort, attempt, maxRetries, err)
if attempt < maxRetries {
time.Sleep(retryDelay)
}
}
return nil, fmt.Errorf("%s: failed to open dispenser on %s after %d attempts: %w", funcName, SerialPort, maxRetries, lastErr)
}
// --------------------
// Internal (port-owner only) operations
// --------------------
// checkDispenserStatus talks to the device and returns the 4 status bytes [pos0..pos3].
func checkDispenserStatus(port *serial.Port) ([]byte, error) {
checkCmd := buildCheckAP(Address)
enq := append([]byte{ENQ}, Address...)
statusResp, err := sendAndReceive(port, checkCmd, delay)
if err != nil {
return nil, fmt.Errorf("error sending check command: %w", err)
}
if len(statusResp) == 0 {
return nil, fmt.Errorf("no response from dispenser")
}
if err := checkACK(statusResp); err != nil {
return nil, err
}
statusResp, err = sendAndReceive(port, enq, delay)
if err != nil {
return nil, fmt.Errorf("error sending ENQ: %w", err)
}
if len(statusResp) < 13 {
return nil, fmt.Errorf("incomplete status response from dispenser: % X", statusResp)
}
return statusResp[7:11], nil
}
func cardToEncoderPosition(port *serial.Port) error {
enq := append([]byte{ENQ}, Address...)
dispenseCmd := createPacket(Address, commandFC7)
log.Println("Send card to encoder position")
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
if err != nil {
return fmt.Errorf("error sending card to encoder position: %w", err)
}
if err := checkACK(statusResp); err != nil {
return err
}
_, err = port.Write(enq)
if err != nil {
return fmt.Errorf("error sending ENQ to prompt device: %w", err)
}
return nil
}
func cardOutOfMouth(port *serial.Port) error {
enq := append([]byte{ENQ}, Address...) enq := append([]byte{ENQ}, Address...)
// Send card out of card mouth (FC0) ---
dispenseCmd := createPacket(Address, commandFC0) dispenseCmd := createPacket(Address, commandFC0)
log.Println("Send card to out mouth position") log.Println("Send card to out mouth position")
statusResp, err := sendAndReceive(port, dispenseCmd, delay) statusResp, err := sendAndReceive(port, dispenseCmd, delay)
if err != nil { if err != nil {
return fmt.Errorf("error sending out of mouth command: %w", err) return "", fmt.Errorf("error sending out of mouth command: %v", err)
} }
if err := checkACK(statusResp); err != nil { _, err = checkStatus(statusResp)
return err if err != nil {
return "", err
} }
//Send ENQ to prompt device ---
_, err = port.Write(enq) _, err = port.Write(enq)
if err != nil { if err != nil {
return fmt.Errorf("error sending ENQ to prompt device: %w", err) return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
} }
return nil
time.Sleep(delay)
//Check card position status
status, err := CheckDispenserStatus(port)
if err != nil {
return "", err
}
return status, nil
} }

View File

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

View File

@ -1,30 +0,0 @@
package errorhandlers
import (
"encoding/json"
"fmt"
"net/http"
"os"
"gitea.futuresens.co.uk/futuresens/cmstypes"
log "github.com/sirupsen/logrus"
)
// 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{
Code: status,
Message: msg,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(theResponse)
}
func FatalError(err error) {
fmt.Println(err.Error())
log.Errorf(err.Error())
fmt.Println(". Press Enter to exit...")
fmt.Scanln()
os.Exit(1)
}

3
go.mod
View File

@ -3,11 +3,10 @@ module gitea.futuresens.co.uk/futuresens/hardlink
go 1.23.2 go 1.23.2
require ( require (
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190 gitea.futuresens.co.uk/futuresens/cmstypes v1.0.179
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

6
go.sum
View File

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

View File

@ -1,75 +1,58 @@
package handlers package handlers
import ( import (
"context" "bytes"
"database/sql"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"io" "io"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/tarm/serial"
"gitea.futuresens.co.uk/futuresens/cmstypes" "gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"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/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/logging" "gitea.futuresens.co.uk/futuresens/logging"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
const (
customLayout = "2006-01-02 15:04:05 -0700"
transactionUrl = "http://127.0.0.1:18181/start-transaction/"
)
type App struct { type App struct {
disp *dispenser.Client dispPort *serial.Port
lockserver lockserver.LockServer lockserver lockserver.LockServer
isPayment bool 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 { func NewApp(dispPort *serial.Port, lockType, encoderAddress string, isPayment bool) *App {
app := &App{ return &App{
isPayment: cfg.IsPayment, isPayment: isPayment,
disp: disp, dispPort: dispPort,
lockserver: lockserver.NewLockServer(lockType, encoderAddress, errorhandlers.FatalError), lockserver: lockserver.NewLockServer(lockType, encoderAddress, FatalError),
db: db,
cfg: cfg,
availabilityTimers: make(map[string]*time.Timer),
} }
app.SetCardWellStatus(cardWellStatus)
return app
} }
func (app *App) RegisterRoutes(mux *http.ServeMux) { func (app *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/issuedoorcard", app.issueDoorCard) mux.HandleFunc("/issuedoorcard", app.issueDoorCard)
mux.HandleFunc("/printroomticket", app.printRoomTicket) mux.HandleFunc("/printroomticket", app.printRoomTicket)
mux.HandleFunc("/takepreauth", app.takePreauthorization) mux.HandleFunc("/starttransaction", app.startTransaction)
mux.HandleFunc("/takepayment", app.takePayment) mux.HandleFunc("/startandconfirmtransaction", app.startAndConfirmTransaction)
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) startTransaction(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("takePreauthorization") const op = logging.Op("startTransaction")
var ( var (
theResponse cmstypes.ResponseRec theResponse cmstypes.ResponseRec
cardholderReceipt string
theRequest cmstypes.TransactionRec theRequest cmstypes.TransactionRec
trResult payment.TransactionResultXML trResult payment.TransactionResultXML
result payment.PaymentResult
save bool
) )
theResponse.Status.Code = http.StatusInternalServerError theResponse.Status.Code = http.StatusInternalServerError
@ -81,98 +64,94 @@ 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 { theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Payment processing is disabled")
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) 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
} }
log.Println("takePreauthorization called") log.Println("startTransaction called")
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Method not allowed; use POST") theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Method not allowed; use POST")
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(payment.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") theResponse.Data = payment.BuildFailureURL(payment.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)
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(
"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(transactionUrl, "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(payment.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()
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(payment.ResultError, "Failed to read response body")
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
return
}
if err := trResult.ParseTransactionResult(body); err != nil { if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
} }
result.FillFromTransactionResult(trResult) // Compose JSON from responseEntries
result := make(map[string]string)
// ---- PRINT RECEIPT ---- for _, e := range trResult.Entries {
switch e.Key {
printer.PrintReceipt(result.CardholderReceipt) case payment.ReceiptData, payment.ReceiptDataMerchant:
// ignore these
// ---- REDIRECT ---- case payment.ReceiptDataCardholder:
cardholderReceipt = e.Value
theResponse.Status = result.Status case payment.TransactionResult:
theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields) theResponse.Status.Message = e.Value
theResponse.Status.Code = http.StatusOK
if save { result[e.Key] = e.Value
go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate) default:
result[e.Key] = e.Value
}
} }
if err := printer.PrintCardholderReceipt(cardholderReceipt); err != nil {
log.Errorf("PrintCardholderReceipt error: %v", err)
}
theResponse.Data = payment.BuildPaymentRedirectURL(result)
writeTransactionResult(w, http.StatusOK, theResponse) writeTransactionResult(w, http.StatusOK, theResponse)
} }
func (app *App) takePayment(w http.ResponseWriter, r *http.Request) { func (app *App) startAndConfirmTransaction(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("takePayment") const op = logging.Op("startAndConfirmTransaction")
var ( var (
theResponse cmstypes.ResponseRec theResponse cmstypes.ResponseRec
cardholderReceipt string
theRequest cmstypes.TransactionRec theRequest cmstypes.TransactionRec
trResult payment.TransactionResultXML trResult payment.TransactionResultXML
result payment.PaymentResult
) )
theResponse.Status.Code = http.StatusInternalServerError theResponse.Status.Code = http.StatusInternalServerError
@ -184,139 +163,84 @@ 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 { theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Payment processing is disabled")
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) 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
} }
log.Println("takePayment called") log.Println("startAndConfirmTransaction called")
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Method not allowed; use POST") theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Method not allowed; use POST")
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(payment.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") theResponse.Data = payment.BuildFailureURL(payment.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)
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,
)
client := &http.Client{Timeout: 300 * time.Second} client := &http.Client{Timeout: 300 * time.Second}
response, err := client.Post(transactionUrl, "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(payment.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()
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(payment.ResultError, "Failed to read response body")
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
return
}
if err := trResult.ParseTransactionResult(body); err != nil { if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
} }
result.FillFromTransactionResult(trResult) // Compose JSON from responseEntries
result := make(map[string]string)
res := result.Fields[types.TransactionResult] for _, e := range trResult.Entries {
switch e.Key {
if !strings.EqualFold(res, types.ResultApproved) { case payment.ReceiptData, payment.ReceiptDataMerchant:
printer.PrintReceipt(result.CardholderReceipt) // ignore these
desc := result.Fields[types.ErrorDescription] case payment.ReceiptDataCardholder:
if desc == "" { cardholderReceipt = e.Value
desc = result.Fields[types.Errors] case payment.TransactionResult:
theResponse.Status.Message = e.Value
theResponse.Status.Code = http.StatusOK
result[e.Key] = e.Value
default:
result[e.Key] = e.Value
} }
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 ---- if err := printer.PrintCardholderReceipt(cardholderReceipt); err != nil {
log.Errorf("PrintCardholderReceipt error: %v", err)
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) theResponse.Data = payment.BuildRedirectURL(result)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Confirm transaction error", string(op), "", "", 0)
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Error: "+err.Error())
theResponse.Data = payment.BuildFailureURL(types.ResultError, "ConfirmTransactionError")
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return
}
if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(types.ServiceName, err.Error(), "Parse confirm result error", string(op), "", "", 0)
}
result.FillFromTransactionResult(trResult)
res = result.Fields[types.TransactionResult]
if !strings.EqualFold(res, types.ResultApproved) {
printer.PrintReceipt(result.CardholderReceipt)
desc := result.Fields[types.ErrorDescription]
if desc == "" {
desc = result.Fields[types.Errors]
}
logging.Error(types.ServiceName, "Transaction not approved after confirm", "Confirm result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Confirm result: "+res+" Description: "+desc)
theResponse.Status = result.Status
theResponse.Data = 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.BuildSuccessURL(result.Fields)
writeTransactionResult(w, http.StatusOK, theResponse) writeTransactionResult(w, http.StatusOK, theResponse)
} }
@ -339,81 +263,77 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
log.Println("issueDoorCard called") log.Println("issueDoorCard called")
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST") writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
return return
} }
defer r.Body.Close() defer r.Body.Close()
if ct := r.Header.Get("Content-Type"); ct != "application/json" { if ct := r.Header.Get("Content-Type"); ct != "application/json" {
errorhandlers.WriteError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json") writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
return return
} }
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()) writeError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
return return
} }
// parse times // parse times
checkIn, err := time.Parse(types.CustomLayout, doorReq.CheckinTime) checkIn, err := time.Parse(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()) writeError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error())
return return
} }
checkOut, err := time.Parse(types.CustomLayout, doorReq.CheckoutTime) checkOut, err := time.Parse(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()) writeError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error())
return return
} }
// Ensure dispenser ready (card at encoder) BEFORE we attempt encoding. // dispenser sequence
// With queued dispenser ops, this will not clash with polling. if status, err := dispenser.DispenserSequence(app.dispPort); err != nil {
status, err := app.disp.DispenserStart(r.Context()) if status != "" {
app.SetCardWellStatus(status) logging.Error(serviceName, status, "Dispense error", string(op), "", "", 0)
if err != nil { writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0) } else {
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()) logging.Error(serviceName, err.Error(), "Dispense error", string(op), "", "", 0)
return writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()+"; check card stock")
} }
// Always attempt to finalize after we have moved a card / started an issuance flow.
// This guarantees we eject and prepare the next card even on lock failures.
finalize := func() {
if app.disp == nil {
return return
} } else {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) log.Info(status)
defer cancel()
status, ferr := app.disp.DispenserFinal(ctx)
if ferr != nil {
logging.Error(types.ServiceName, ferr.Error(), "Dispenser final error", string(op), "", "", 0)
return
}
app.SetCardWellStatus(status)
} }
// build lock server command // build lock server command
app.lockserver.BuildCommand(doorReq, checkIn, checkOut) app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
// lock server sequence // lock server sequence
if err := app.lockserver.LockSequence(); err != nil { err = app.lockserver.LockSequence()
logging.Error(types.ServiceName, err.Error(), "Key encoding", string(op), "", "", 0) if err != nil {
finalize() logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0)
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error()) writeError(w, http.StatusBadGateway, err.Error())
dispenser.CardOutOfMouth(app.dispPort)
return return
} }
// final dispenser steps // final dispenser steps
finalize() if status, err := dispenser.CardOutOfMouth(app.dispPort); err != nil {
logging.Error(serviceName, err.Error(), "Dispenser eject error", string(op), "", "", 0)
writeError(w, http.StatusServiceUnavailable, "Dispenser eject error: "+err.Error())
return
} else {
log.Info(status)
}
theResponse.Code = http.StatusOK theResponse.Code = http.StatusOK
theResponse.Message = "Card issued successfully" theResponse.Message = "Card issued successfully"
// success! return 200 and any data you like
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(theResponse) json.NewEncoder(w).Encode(theResponse)
} }
func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) { func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
@ -430,32 +350,32 @@ func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
} }
log.Println("printRoomTicket called") log.Println("printRoomTicket called")
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST") writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
return return
} }
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "xml") { if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "xml") {
errorhandlers.WriteError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/xml") writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/xml")
return return
} }
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()) 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()) 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()) writeError(w, http.StatusInternalServerError, "Print failed: "+err.Error())
return return
} }
@ -467,29 +387,3 @@ func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
Message: "Print job sent successfully", Message: "Print job sent successfully",
}) })
} }
func (app *App) reportDispenserStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(cmstypes.StatusRec{
Code: http.StatusOK,
Message: app.CardWellStatus(),
})
}
func (app *App) SetCardWellStatus(s string) {
app.cardWellMu.Lock()
prev := app.cardWellStatus
app.cardWellStatus = s
app.cardWellMu.Unlock()
if s != "" && prev != s {
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Dispenser Error Status", "Status: "+s)
}
}
func (app *App) CardWellStatus() string {
app.cardWellMu.RLock()
defer app.cardWellMu.RUnlock()
return app.cardWellStatus
}

View File

@ -1,71 +1,41 @@
package handlers package handlers
import ( import (
"bytes"
"encoding/json" "encoding/json"
"encoding/xml" "fmt"
"io"
"net/http" "net/http"
"time" "os"
"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" 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{
Code: status,
Message: msg,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(theResponse)
}
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) { func FatalError(err error) {
fmt.Println(err.Error())
resp, err := client.Post(url, "text/xml", bytes.NewBuffer(payload)) log.Errorf(err.Error())
if err != nil { fmt.Println(". Press Enter to exit...")
return nil, err fmt.Scanln()
} os.Exit(1)
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,49 +0,0 @@
// cmd/hardlink-preauth-release/main.go
package main
import (
"fmt"
"os"
"time"
"gitea.futuresens.co.uk/futuresens/hardlink/bootstrap"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/logging"
"gitea.futuresens.co.uk/futuresens/hardlink/payment"
log "github.com/sirupsen/logrus"
)
const (
buildVersion = "1.0.2"
serviceName = "preauth-release"
)
func main() {
config := config.ReadPreauthReleaserConfig()
// Setup logging and get file handle
logFile, err := logging.SetupLogging(config.LogDir, serviceName, buildVersion)
if err != nil {
log.Printf("Failed to set up logging: %v\n", err)
}
defer logFile.Close()
database, err := bootstrap.OpenDB(&config)
if err != nil {
log.WithError(err).Fatal("DB init failed")
}
defer database.Close()
if err := payment.ReleasePreauthorizations(database); err != nil {
log.Error(err)
fmt.Println(err)
} else {
log.Info("Task completed successfully")
fmt.Println("Task completed successfully")
}
for i := 20; i > 0; i-- {
fmt.Printf("\rExiting in %2d seconds... ", i)
time.Sleep(time.Second)
}
fmt.Println("\rExiting now. ")
os.Exit(0)
}

View File

@ -102,7 +102,7 @@ func sendAndReceive(conn net.Conn, command []byte) (string, error) {
return "", fmt.Errorf("failed to send command: %v", err) return "", fmt.Errorf("failed to send command: %v", err)
} }
conn.SetReadDeadline(time.Now().Add(20 * time.Second)) conn.SetReadDeadline(time.Now().Add(10 * time.Second))
buf := make([]byte, 128) buf := make([]byte, 128)
reader := bufio.NewReader(conn) reader := bufio.NewReader(conn)

View File

@ -155,7 +155,7 @@ func (lock *SaltoLockServer) LockSequence() error {
reader := bufio.NewReader(conn) reader := bufio.NewReader(conn)
// 1. Send ENQ // 1. Send ENQ
log.Infof("LockSequence: sending 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)
} }
@ -166,7 +166,7 @@ func (lock *SaltoLockServer) LockSequence() error {
} }
// 3. Send command frame // 3. Send command frame
log.Infof("LockSequence: sending encoding command: %q", string(lock.command)) 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)
} }

View File

@ -1,31 +0,0 @@
package logging
import (
"fmt"
"os"
"time"
log "github.com/sirupsen/logrus"
)
// setupLogging ensures log directory, opens log file, and configures logrus.
// Returns the *os.File so caller can defer its Close().
func SetupLogging(logDir, serviceName, buildVersion string) (*os.File, error) {
fileName := logDir + serviceName + ".log"
f, err := os.OpenFile(fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
if err != nil {
return nil, fmt.Errorf("open log file: %w", err)
}
log.SetOutput(f)
log.SetFormatter(&log.JSONFormatter{
TimestampFormat: time.RFC3339,
})
log.SetLevel(log.InfoLevel)
log.WithFields(log.Fields{
"buildVersion": buildVersion,
}).Info("Logging initialized")
return f, nil
}

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")
}

201
main.go
View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"context"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"net/http" "net/http"
@ -15,156 +14,166 @@ import (
"github.com/tarm/serial" "github.com/tarm/serial"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
yaml "gopkg.in/yaml.v3"
"gitea.futuresens.co.uk/futuresens/hardlink/bootstrap"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser" "gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/errorhandlers"
"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/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.0.24"
serviceName = "hardlink" serviceName = "hardlink"
pollingFrequency = 8 * time.Second
) )
// 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"`
IsPayment bool `yaml:"isPayment"`
TestMode bool `yaml:"testMode"`
}
func main() { func main() {
// Load config // Load config
cfg := config.ReadHardlinkConfig() config := readConfig()
printer.Layout = readTicketLayout() printer.Layout = readTicketLayout()
printer.PrinterName = cfg.PrinterName printer.PrinterName = config.PrinterName
lockserver.Cert = cfg.Cert lockserver.Cert = config.Cert
lockserver.LockServerURL = cfg.LockserverUrl lockserver.LockServerURL = config.LockserverUrl
mail.SendErrorEmails = cfg.SendErrorEmails dispHandle := &serial.Port{}
// Root context for background goroutines
// rootCtx, rootCancel := context.WithCancel(context.Background())
// defer rootCancel()
var (
dispPort *serial.Port
disp *dispenser.Client
cardWellStatus string
)
// Setup logging and get file handle // Setup logging and get file handle
logFile, err := logging.SetupLogging(cfg.LogDir, serviceName, buildVersion) logFile, err := setupLogging(config.LogDir)
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 !config.TestMode {
dispenser.SerialPort = cfg.DispenserPort dispenser.SerialPort = config.DispenserPort
dispenser.Address = []byte(cfg.DispenserAdrr) dispenser.Address = []byte(config.DispenserAdrr)
dispHandle, 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)) handlers.FatalError(err)
errorhandlers.FatalError(err)
} }
defer dispPort.Close() defer dispHandle.Close()
disp = dispenser.NewClient(dispPort, 32) status, err := dispenser.CheckDispenserStatus(dispHandle)
defer disp.Close()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cardWellStatus, err = disp.DispenserPrepare(ctx)
if err != nil { if err != nil {
err = fmt.Errorf("%s; wrong dispenser address: %s", err, cfg.DispenserAdrr) if len(status) == 0 {
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Dispenser Preparation Error", err.Error()) err = fmt.Errorf("%s; wrong dispenser address: %s", err, config.DispenserAdrr)
errorhandlers.FatalError(err) handlers.FatalError(err)
} else {
fmt.Println(status)
fmt.Println(err.Error())
} }
fmt.Println(cardWellStatus) }
log.Infof("Dispenser initialized on port %s, %s", config.DispenserPort, status)
} }
// Test lock-server connection // Test lock-server connection
switch strings.ToLower(cfg.LockType) { switch strings.ToLower(config.LockType) {
case lockserver.TLJ: case lockserver.TLJ:
// TLJ uses HTTP - skip TCP probe here
default: default:
lockConn, err := lockserver.InitializeServerConnection(cfg.LockserverUrl) lockConn, err := lockserver.InitializeServerConnection(config.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", config.LockserverUrl)
log.Infof("Connected to the lock server successfuly at %s", cfg.LockserverUrl) log.Infof("Connected to the lock server successfuly at %s", config.LockserverUrl)
lockConn.Close() lockConn.Close()
} }
} }
database, err := bootstrap.OpenDB(&cfg) if config.IsPayment {
if err != nil {
log.Warnf("DB init failed: %v", err)
}
if database != nil {
defer database.Close()
}
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
app := handlers.NewApp(disp, cfg.LockType, cfg.EncoderAddress, cardWellStatus, database, &cfg) app := handlers.NewApp(dispHandle, config.LockType, config.EncoderAddress, config.IsPayment)
// Update cardWellStatus when dispenser status changes
if !cfg.TestMode && disp != nil {
// Set initial cardWellStatus
app.SetCardWellStatus(cardWellStatus)
// Set up callback to update cardWellStatus when dispenser status changes
disp.OnStockUpdate(func(stock string) {
app.SetCardWellStatus(stock)
})
// Start polling for dispenser status every 10 seconds
disp.StartPolling(pollingFrequency)
}
mux := http.NewServeMux() mux := http.NewServeMux()
app.RegisterRoutes(mux) app.RegisterRoutes(mux)
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", config.Port)
log.Infof("Starting HTTP server on http://localhost%s", addr) log.Infof("Starting HTTP server on http://localhost%s", addr)
fmt.Printf("Starting HTTP server on http://localhost%s", addr) fmt.Printf("Starting HTTP server on http://localhost%s", addr)
if err := http.ListenAndServe(addr, mux); err != nil { if err := http.ListenAndServe(addr, mux); err != nil {
errorhandlers.FatalError(err) handlers.FatalError(err)
} }
} }
// setupLogging ensures log directory, opens log file, and configures logrus.
// Returns the *os.File so caller can defer its Close().
func setupLogging(logDir string) (*os.File, error) {
fileName := logDir + serviceName + ".log"
f, err := os.OpenFile(fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
if err != nil {
return nil, fmt.Errorf("open log file: %w", err)
}
log.SetOutput(f)
log.SetFormatter(&log.JSONFormatter{
TimestampFormat: time.RFC3339,
})
log.SetLevel(log.InfoLevel)
log.WithFields(log.Fields{
"buildVersion": buildVersion,
}).Info("Logging initialized")
return f, nil
}
// readConfig reads config.yml and applies defaults.
func readConfig() configRec {
var cfg configRec
const configName = "config.yml"
defaultPort := 9091
sep := string(os.PathSeparator)
data, err := os.ReadFile(configName)
if err != nil {
log.Warnf("ReadConfig %s: %v", configName, err)
} else if err := yaml.Unmarshal(data, &cfg); err != nil {
log.Warnf("Unmarshal config: %v", err)
}
if cfg.Port == 0 {
cfg.Port = defaultPort
}
if cfg.LockType == "" {
err = fmt.Errorf("LockType is required in %s", configName)
handlers.FatalError(err)
}
cfg.LockType = strings.ToLower(cfg.LockType)
if cfg.LogDir == "" {
cfg.LogDir = "./logs" + sep
} else if !strings.HasSuffix(cfg.LogDir, sep) {
cfg.LogDir += sep
}
return cfg
}
func readTicketLayout() printer.LayoutOptions { func readTicketLayout() printer.LayoutOptions {
const layoutName = "TicketLayout.xml" const layoutName = "TicketLayout.xml"
var layout printer.LayoutOptions var layout printer.LayoutOptions
@ -172,12 +181,12 @@ func readTicketLayout() printer.LayoutOptions {
// 1) Read the file // 1) Read the file
data, err := os.ReadFile(layoutName) data, err := os.ReadFile(layoutName)
if err != nil { if err != nil {
errorhandlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err)) handlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err))
} }
// 2) Unmarshal into your struct // 2) Unmarshal into your struct
if err := xml.Unmarshal(data, &layout); err != nil { if err := xml.Unmarshal(data, &layout); err != nil {
errorhandlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err)) handlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err))
} }
return layout return layout
@ -196,7 +205,7 @@ func startChipDnaClient() {
cmd, err := startClient() cmd, err := startClient()
if err != nil { if err != nil {
errorhandlers.FatalError(err) handlers.FatalError(err)
} }
// Restart loop // Restart loop
@ -205,7 +214,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 +221,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

@ -1,19 +1,53 @@
package payment package payment
import ( import (
"context"
"database/sql"
"encoding/hex" "encoding/hex"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/types"
_ "github.com/denisenkom/go-mssqldb" _ "github.com/denisenkom/go-mssqldb"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
const (
// Transaction types
SaleTransactionType = "sale"
AccountVerificationType = "account verification"
// Transaction results
ResultApproved = "approved"
ResultDeclined = "declined"
ResultCancelled = "cancelled"
ResultPending = "pending"
ResultError = "error"
CheckinSuccessfulEndpoint = "/successful" // Endpoint to send guest to after successful payment
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
type ( type (
TransactionRec struct { TransactionRec struct {
@ -39,23 +73,6 @@ type (
ErrorDescription string `xml:"ErrorDescription"` ErrorDescription string `xml:"ErrorDescription"`
ReceiptDataCardholder string `xml:"ReceiptDataCardholder"` ReceiptDataCardholder string `xml:"ReceiptDataCardholder"`
} }
PaymentResult struct {
Fields map[string]string
CardholderReceipt string
Status cmstypes.StatusRec
}
TransactionInfo struct {
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. // ParseTransactionResult parses the XML into entries.
@ -66,102 +83,242 @@ func (tr *TransactionResultXML) ParseTransactionResult(data []byte) error {
return nil return nil
} }
func (ti *TransactionInfo) FillFromTransactionResult(trResult TransactionResultXML) { // initMSSQL opens and pings the SQL Server instance localhost\SQLEXPRESS
for _, e := range trResult.Entries { // using user=Kiosk, password=Gr33nfarm, database=TransactionDatabase.
switch e.Key { func InitMSSQL(port int, user, password, database string) (*sql.DB, error) {
case types.TransactionResult: const server = "localhost"
ti.transactionRes = e.Value
case types.TransactionState: // Use TCP; drop the \SQLEXPRESS instance name
ti.transactionState = e.Value connString := fmt.Sprintf(
"sqlserver://%s:%s@%s:%d?database=%s&encrypt=disable",
user, password, server, port, database,
)
db, err := sql.Open("sqlserver", connString)
if err != nil {
return nil, fmt.Errorf("opening DB: %w", err)
} }
// Verify connectivity
if err := db.PingContext(context.Background()); err != nil {
db.Close()
return nil, fmt.Errorf("pinging DB: %w", err)
} }
return db, nil
} }
func (r *PaymentResult) FillFromTransactionResult(trResult TransactionResultXML) { // insertTransactionRecord inserts one row into TransactionRecords.
r.Fields = make(map[string]string) // m is the map from keys to string values as returned by ChipDNA.
func InsertTransactionRecord(ctx context.Context, db *sql.DB, m map[string]string) error {
// Extract fields with defaults or NULL handling.
for _, e := range trResult.Entries { // 1. TxnReference <- REFERENCE
switch e.Key { ref, ok := m["REFERENCE"]
if !ok || ref == "" {
return fmt.Errorf("missing REFERENCE in result map")
}
case types.ReceiptData, types.ReceiptDataMerchant: // 2. TxnDateTime <- parse AUTH_DATE_TIME (layout "20060102150405"), else use now
// intentionally ignored var txnTime time.Time
if s, ok := m["AUTH_DATE_TIME"]; ok && s != "" {
t, err := time.ParseInLocation("20060102150405", s, time.UTC)
if err != nil {
// fallback: use now
txnTime = time.Now().UTC()
} else {
txnTime = t
}
} else {
txnTime = time.Now().UTC()
}
case types.ReceiptDataCardholder: // 3. TotalAmount <- parse TOTAL_AMOUNT minor units into float (divide by 100)
r.CardholderReceipt = e.Value var totalAmount sql.NullFloat64
if s, ok := m["TOTAL_AMOUNT"]; ok && s != "" {
case types.TransactionResult: if iv, err := strconv.ParseInt(s, 10, 64); err == nil {
r.Status.Message = e.Value // convert minor units to major (e.g. 150 -> 1.50)
r.Status.Code = http.StatusOK totalAmount.Float64 = float64(iv) / 100.0
r.Fields[e.Key] = e.Value totalAmount.Valid = true
default:
r.Fields[e.Key] = e.Value
} }
} }
// 4. MerchantId <- MERCHANT_ID_MASKED
merchantId := sql.NullString{String: m["MERCHANT_ID_MASKED"], Valid: m["MERCHANT_ID_MASKED"] != ""}
// 5. TerminalId <- TERMINAL_ID_MASKED
terminalId := sql.NullString{String: m["TERMINAL_ID_MASKED"], Valid: m["TERMINAL_ID_MASKED"] != ""}
// 6. CardSchemeName <- CARD_SCHEME
cardScheme := sql.NullString{String: m["CARD_SCHEME"], Valid: m["CARD_SCHEME"] != ""}
// 7. ExpiryDate <- EXPIRY_DATE
expiryDate := sql.NullString{String: m["EXPIRY_DATE"], Valid: m["EXPIRY_DATE"] != ""}
// 8. RecordReference <- CARD_REFERENCE
recordRef := sql.NullString{String: m["CARD_REFERENCE"], Valid: m["CARD_REFERENCE"] != ""}
// 9. Token1 <- CARD_HASH
token1 := sql.NullString{String: m["CARD_HASH"], Valid: m["CARD_HASH"] != ""}
// 10. Token2 <- CARDEASE_REFERENCE
token2 := sql.NullString{String: m["CARDEASE_REFERENCE"], Valid: m["CARDEASE_REFERENCE"] != ""}
// 11. PanMasked <- PAN_MASKED
panMasked := sql.NullString{String: m["PAN_MASKED"], Valid: m["PAN_MASKED"] != ""}
// 12. AuthCode <- AUTH_CODE
authCode := sql.NullString{String: m["AUTH_CODE"], Valid: m["AUTH_CODE"] != ""}
// 13. TransactionResult <- TRANSACTION_RESULT
txnResult := sql.NullString{String: m["TRANSACTION_RESULT"], Valid: m["TRANSACTION_RESULT"] != ""}
// Build INSERT statement with named parameters.
// Assuming your table is [TransactionDatabase].[dbo].[TransactionRecords].
const stmt = `
INSERT INTO [TransactionDatabase].[dbo].[TransactionRecords]
(
[TxnReference],
[TxnDateTime],
[TotalAmount],
[MerchantId],
[TerminalId],
[CardSchemeName],
[ExpiryDate],
[RecordReference],
[Token1],
[Token2],
[PanMasked],
[AuthCode],
[TransactionResult]
)
VALUES
(
@TxnReference,
@TxnDateTime,
@TotalAmount,
@MerchantId,
@TerminalId,
@CardSchemeName,
@ExpiryDate,
@RecordReference,
@Token1,
@Token2,
@PanMasked,
@AuthCode,
@TransactionResult
);
`
// Execute with sql.Named parameters:
_, err := db.ExecContext(ctx, stmt,
sql.Named("TxnReference", ref),
sql.Named("TxnDateTime", txnTime),
sql.Named("TotalAmount", nullableFloatArg(totalAmount)),
sql.Named("MerchantId", nullableStringArg(merchantId)),
sql.Named("TerminalId", nullableStringArg(terminalId)),
sql.Named("CardSchemeName", nullableStringArg(cardScheme)),
sql.Named("ExpiryDate", nullableStringArg(expiryDate)),
sql.Named("RecordReference", nullableStringArg(recordRef)),
sql.Named("Token1", nullableStringArg(token1)),
sql.Named("Token2", nullableStringArg(token2)),
sql.Named("PanMasked", nullableStringArg(panMasked)),
sql.Named("AuthCode", nullableStringArg(authCode)),
sql.Named("TransactionResult", nullableStringArg(txnResult)),
)
if err != nil {
return fmt.Errorf("insert TransactionRecords: %w", err)
}
// Successfully inserted
log.Infof("Inserted transaction record for reference %s", ref)
return nil
}
// Helpers to pass NULL when appropriate:
func nullableStringArg(ns sql.NullString) interface{} {
if ns.Valid {
return ns.String
}
return nil
}
func nullableFloatArg(nf sql.NullFloat64) interface{} {
if nf.Valid {
return nf.Float64
}
return nil
} }
// BuildRedirectURL builds the redirect URL to send the guest to after payment. // BuildRedirectURL builds the redirect URL to send the guest to after payment.
func BuildPaymentRedirectURL(result map[string]string) string { func BuildPaymentRedirectURL(result map[string]string) string {
res := result[types.TransactionResult] res := result[TransactionResult]
tType := result[TransactionType]
// Transaction approved? // Transaction approved?
if strings.EqualFold(res, types.ResultApproved) { if strings.EqualFold(res, ResultApproved) {
switch {
// Transaction type AccountVerification?
case strings.EqualFold(tType, AccountVerificationType):
log.WithField(LogResult, result[TransactionResult]).
Info("Account verification approved")
return buildSuccessURL(result)
// Transaction type Sale?
case strings.EqualFold(tType, SaleTransactionType):
// Transaction confirmed? // Transaction confirmed?
if strings.EqualFold(result[types.ConfirmResult], types.ResultApproved) { if strings.EqualFold(result[ConfirmResult], ResultApproved) {
log.WithField(types.LogResult, result[types.ConfirmResult]). log.WithField(LogResult, result[ConfirmResult]).
Info("Transaction approved and confirmed") Info("Transaction approved and confirmed")
return BuildSuccessURL(result) return buildSuccessURL(result)
} }
// Not confirmed // Not confirmed
log.WithFields(log.Fields{types.LogFieldError: result[types.ConfirmResult], types.LogFieldDescription: result[types.ConfirmErrors]}). log.WithFields(log.Fields{LogFieldError: result[ConfirmResult], LogFieldDescription: result[ConfirmErrors]}).
Error("Transaction approved but not confirmed") Error("Transaction approved but not confirmed")
return BuildFailureURL(result[types.ConfirmResult], result[types.ConfirmErrors]) return BuildFailureURL(result[ConfirmResult], result[ConfirmErrors])
}
} }
// Not approved // Not approved
return BuildFailureURL(res, result[types.Errors]) return BuildFailureURL(res, result[Errors])
} }
func BuildPreauthRedirectURL(result map[string]string) (string, bool) { func BuildPreauthRedirectURL(result map[string]string) string {
res := result[types.TransactionResult] res := result[TransactionResult]
tType := result[types.TransactionType] tType := result[TransactionType]
// Transaction approved? // Transaction approved?
if strings.EqualFold(res, types.ResultApproved) { if strings.EqualFold(res, ResultApproved) {
switch { switch {
// Transaction type AccountVerification? // Transaction type AccountVerification?
case strings.EqualFold(tType, types.AccountVerificationType): case strings.EqualFold(tType, AccountVerificationType):
log.WithField(types.LogResult, result[types.TransactionResult]). log.WithField(LogResult, result[TransactionResult]).
Info("Account verification approved") Info("Account verification approved")
return BuildSuccessURL(result), false return buildSuccessURL(result)
// Transaction type Sale? // Transaction type Sale?
case strings.EqualFold(tType, types.SaleTransactionType): case strings.EqualFold(tType, SaleTransactionType):
// Transaction confirmed? // Transaction confirmed?
log.WithField(types.LogResult, result[types.ConfirmResult]). log.WithField(LogResult, result[ConfirmResult]).
Info("Amount preauthorized successfully") Info("Transaction approved and confirmed")
return BuildSuccessURL(result), true return buildSuccessURL(result)
} }
} }
// Not approved // Not approved
return BuildFailureURL(res, result[types.Errors]), false return BuildFailureURL(res, result[Errors])
} }
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("TxnReference", result[Reference])
q.Set("ExpiryDate", hex.EncodeToString([]byte(result[types.EXPIRY_DATE]))) q.Set("CardHash", hex.EncodeToString([]byte(result[CardHash])))
q.Set("TxnReference", result[types.Reference]) q.Set("CardReference", hex.EncodeToString([]byte(result[CardReference])))
q.Set("CardHash", hex.EncodeToString([]byte(result[types.CardHash])))
q.Set("CardReference", hex.EncodeToString([]byte(result[types.CardReference])))
return (&url.URL{ return (&url.URL{
Path: types.CheckinSuccessfulEndpoint, Path: CheckinSuccessfulEndpoint,
RawQuery: q.Encode(), RawQuery: q.Encode(),
}).String() }).String()
} }
@ -172,16 +329,16 @@ func BuildFailureURL(msgType, description string) string {
description = fmt.Sprintf("Transaction %s", strings.ToLower(msgType)) description = fmt.Sprintf("Transaction %s", strings.ToLower(msgType))
} }
if description != "" { if description != "" {
msgType = types.ResultError msgType = ResultError
} }
log.WithFields(log.Fields{types.LogFieldError: msgType, types.LogFieldDescription: description}). log.WithFields(log.Fields{LogFieldError: msgType, LogFieldDescription: description}).
Error("Transaction failed") Error("Transaction failed")
q.Set("MsgType", msgType) q.Set("MsgType", msgType)
q.Set("Description", description) q.Set("Description", description)
return (&url.URL{ return (&url.URL{
Path: types.CheckinUnsuccessfulEndpoint, Path: CheckinUnsuccessfulEndpoint,
RawQuery: q.Encode(), RawQuery: q.Encode(),
}).String() }).String()
} }

View File

@ -1,237 +0,0 @@
package payment
import (
"bytes"
"context"
"database/sql"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"time"
"gitea.futuresens.co.uk/futuresens/hardlink/db"
"gitea.futuresens.co.uk/futuresens/hardlink/types"
log "github.com/sirupsen/logrus"
)
const (
httpTimeout = 120 * time.Second
)
/* ==============================
Public Entry Point
============================== */
func ReleasePreauthorizations(database *sql.DB) error {
ctx := context.Background()
now := time.Now().UTC()
client := &http.Client{Timeout: httpTimeout}
preauths, err := fetchDuePreauths(ctx, database, now)
if err != nil {
return err
}
if len(preauths) == 0 {
log.Info("No preauthorizations due for release")
return nil
} else {
log.Infof("%d preauthorizations due for release", len(preauths))
}
var failed []string
var completed int
for _, p := range preauths {
if err := handlePreauthRelease(ctx, database, client, p, now); err != nil {
log.Errorf("Preauth %s failed: %v", p.TxnReference, err)
failed = append(failed, p.TxnReference)
}
completed++
}
log.Infof("Preauth release completed: %d processed, %d failed", completed, len(failed))
if len(failed) > 0 {
return fmt.Errorf("preauth release incomplete, failed refs: %s", strings.Join(failed, ", "))
}
return nil
}
/* ==============================
Core Business Logic
============================== */
func handlePreauthRelease(
ctx context.Context,
dbConn *sql.DB,
client *http.Client,
preauth types.PreauthRec,
now time.Time,
) error {
ref := preauth.TxnReference
log.Infof("Evaluating preauth %s", ref)
info, err := fetchTransactionInfo(ctx, client, ref)
if err != nil {
return err
}
log.Infof("res=%s state=%s", info.transactionRes, info.transactionState)
// If already voided or declined → mark released
if isAlreadyReleased(info) {
return markReleased(ctx, dbConn, ref, now)
}
// Only void approved + uncommitted
if !isVoidable(info) {
log.Infof("Preauth %s not eligible for void (res=%s state=%s)", ref, info.transactionRes, info.transactionState)
return nil
}
// Void transaction
if err := voidPreauth(ctx, client, ref); err != nil {
return err
}
// Verify final state
finalInfo, err := fetchTransactionInfo(ctx, client, ref)
if err != nil {
return err
}
if !isSuccessfullyVoided(finalInfo) {
return fmt.Errorf("unexpected final state res=%s state=%s",
finalInfo.transactionRes, finalInfo.transactionState)
}
log.Infof("Preauth %s successfully voided", ref)
return markReleased(ctx, dbConn, ref, now)
}
/* ==============================
State Evaluation Helpers
============================== */
func isVoidable(info TransactionInfo) bool {
return strings.EqualFold(info.transactionRes, types.ResultApproved) &&
strings.EqualFold(info.transactionState, types.ResultStateUncommitted)
}
func isAlreadyReleased(info TransactionInfo) bool {
return strings.EqualFold(info.transactionState, types.ResultStateVoided) ||
strings.EqualFold(info.transactionRes, types.ResultDeclined)
}
func isSuccessfullyVoided(info TransactionInfo) bool {
return strings.EqualFold(info.transactionRes, types.ResultDeclined) &&
strings.EqualFold(info.transactionState, types.ResultStateVoided)
}
/* ==============================
External Operations
============================== */
func fetchDuePreauths(
ctx context.Context,
dbConn *sql.DB,
now time.Time,
) ([]types.PreauthRec, error) {
return db.GetDuePreauths(ctx, dbConn, now, 0)
}
func markReleased(
ctx context.Context,
dbConn *sql.DB,
ref string,
now time.Time,
) error {
return db.MarkPreauthReleased(ctx, dbConn, ref, now)
}
func fetchTransactionInfo(
ctx context.Context,
client *http.Client,
ref string,
) (TransactionInfo, error) {
var tr TransactionResultXML
var info TransactionInfo
payload, _ := xml.Marshal(types.TransactionReferenceRequest{
TransactionReference: ref,
})
body, err := postXML(ctx, client, types.LinkTransactionInformation, payload)
if err != nil {
return info, err
}
if err := tr.ParseTransactionResult(body); err != nil {
return info, err
}
info.FillFromTransactionResult(tr)
return info, nil
}
func voidPreauth(
ctx context.Context,
client *http.Client,
ref string,
) error {
var tr TransactionResultXML
var info TransactionInfo
payload, _ := xml.Marshal(types.TransactionReferenceRequest{
TransactionReference: ref,
})
body, err := postXML(ctx, client, types.LinkVoidTransaction, payload)
if err != nil {
return err
}
if err := tr.ParseTransactionResult(body); err != nil {
return err
}
info.FillFromTransactionResult(tr)
if !strings.EqualFold(info.transactionRes, types.ResultApproved) {
return fmt.Errorf("void rejected: %s", info.transactionRes)
}
return nil
}
/* ==============================
Low-level HTTP
============================== */
func postXML(
ctx context.Context,
client *http.Client,
url string,
payload []byte,
) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "text/xml")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}

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,65 +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
increased timeout for reading response from the Assa abloy lock server to 20 seconds
#### 1.1.2 - 02 February 2026
added logging for unknown dispenser status positions
#### 1.1.1 - 02 February 2026
added contionuous polling of the dispenser status every 8 seconds to update the card well status
#### 1.1.0 - 26 January 2026
divided `/starttransaction` endpoint into two separate endpoints:
`/takepreauth` to request preauthorization payment
`/takepayment` to request taking payment
added preauth releaser functionality to release preauthorization payments after a defined time period
added db connection check before adding a transaction to the database
and reconnection functionality if the connection to the database is lost
added `/dispenserstatus` endpoint
key card always stays at encoder position
#### 1.0.30 - 09 January 2026
improved logging for preauth releaser
#### 1.0.29 - 08 January 2026
added count down before exiting the preauth releaser 20 seconds
#### 1.0.28 - 10 December 2025
added preauth releaser
#### 1.0.27 - 10 December 2025
updated handling AmountTooSmall response from creditcall
#### 1.0.26 - 10 December 2025
added route for taking preauthorization payment
#### 1.0.25 - 08 December 2025
return masked card number and expiry date in the payment success URL
#### 1.0.24 - 13 November 2025 #### 1.0.24 - 13 November 2025
improved logging for creditcall payment processing improved logging for creditcall payment processing

View File

@ -1,74 +0,0 @@
package types
import (
"database/sql"
"encoding/xml"
"time"
)
const (
ServiceName = "hardlink"
DateOnly = "2006-01-02"
CustomLayout = "2006-01-02 15:04:05 -0700"
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"
AccountVerificationType = "account verification"
// Transaction results
ResultApproved = "approved"
ResultDeclined = "declined"
ResultCancelled = "cancelled"
ResultPending = "pending"
ResultStateUncommitted = "uncommitted"
ResultStateVoided = "voided"
ResultError = "error"
CheckinSuccessfulEndpoint = "/successful" // Endpoint to send guest to after successful payment
CheckinUnsuccessfulEndpoint = "/unsuccessful"
// Response map keys
CardReference = "CARD_REFERENCE"
CardHash = "CARD_HASH"
Errors = "ERRORS"
ErrorDescription = "ERROR_DESCRIPTION"
ReceiptData = "RECEIPT_DATA"
ReceiptDataMerchant = "RECEIPT_DATA_MERCHANT"
ReceiptDataCardholder = "RECEIPT_DATA_CARDHOLDER"
Reference = "REFERENCE"
PAN_MASKED = "PAN_MASKED"
EXPIRY_DATE = "EXPIRY_DATE"
TransactionResult = "TRANSACTION_RESULT"
TransactionType = "TRANSACTION_TYPE"
TransactionState = "TRANSACTION_STATE"
ConfirmResult = "CONFIRM_RESULT"
ConfirmErrors = "CONFIRM_ERRORS"
TotalAmount = "TOTAL_AMOUNT"
// Log field keys
LogFieldError = "error"
LogFieldDescription = "description"
LogResult = "transactionResult"
)
type (
TransactionReferenceRequest struct {
XMLName xml.Name `xml:"TransactionReferenceRequest"`
TransactionReference string `xml:"TransactionReference"`
}
PreauthRec struct {
Id int64
TxnReference string
TotalMinorUnits int64
TotalAmount float64
TxnDateTime time.Time
DepartureDate time.Time // date-only (00:00)
ReleaseDate time.Time
Released bool
ReleasedAt sql.NullTime
}
)