Compare commits
1 Commits
developmen
...
preauth
| Author | SHA1 | Date | |
|---|---|---|---|
| 56d33b167a |
@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,93 +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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
201
db/db.go
@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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,25 +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
|
||||||
}
|
}
|
||||||
|
|
||||||
statusPos1 = map[byte]string{
|
statusPos1 = map[byte]string{
|
||||||
0x38: "Dispensing card",
|
0x38: "Dispensing card",
|
||||||
0x34: "Capturing card",
|
0x34: "Capturing card",
|
||||||
@ -45,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",
|
||||||
@ -52,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",
|
||||||
@ -62,77 +69,48 @@ 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) {
|
posStatus := []struct {
|
||||||
if len(statusBytes) < 4 {
|
pos int
|
||||||
log.Infof("Dispenser status: <invalid len=%d>", len(statusBytes))
|
value byte
|
||||||
return
|
mapper map[byte]string
|
||||||
}
|
}{
|
||||||
|
{pos: 1, value: statusBytes[0], mapper: statusPos0},
|
||||||
posStatus := []struct {
|
{pos: 2, value: statusBytes[1], mapper: statusPos1},
|
||||||
pos int
|
{pos: 3, value: statusBytes[2], mapper: statusPos2},
|
||||||
value byte
|
{pos: 4, value: statusBytes[3], mapper: statusPos3},
|
||||||
mapper map[byte]string
|
|
||||||
}{
|
|
||||||
{pos: 1, value: statusBytes[0], mapper: statusPos0},
|
|
||||||
{pos: 2, value: statusBytes[1], mapper: statusPos1},
|
|
||||||
{pos: 3, value: statusBytes[2], mapper: statusPos2},
|
|
||||||
{pos: 4, value: statusBytes[3], mapper: statusPos3},
|
|
||||||
}
|
|
||||||
|
|
||||||
var result strings.Builder
|
|
||||||
for _, p := range posStatus {
|
|
||||||
statusMsg, exists := p.mapper[p.value]
|
|
||||||
if !exists {
|
|
||||||
statusMsg = fmt.Sprintf("Unknown status 0x%X at position %d", p.value, p.pos)
|
|
||||||
}
|
}
|
||||||
if p.value != 0x30 {
|
|
||||||
result.WriteString(statusMsg + "; ")
|
result := ""
|
||||||
|
for _, p := range posStatus {
|
||||||
|
statusMsg, exists := p.mapper[p.value]
|
||||||
|
if !exists {
|
||||||
|
statusMsg = "Unknown status"
|
||||||
|
}
|
||||||
|
if p.value != 0x30 {
|
||||||
|
result += fmt.Sprintf("Status: %s; ", statusMsg)
|
||||||
|
}
|
||||||
|
if p.pos == 4 && p.value == 0x38 {
|
||||||
|
return result, fmt.Errorf("Card well empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
if len(statusResp) == 3 && statusResp[0] == ACK && statusResp[1] == Address[0] && statusResp[2] == Address[1] {
|
||||||
|
return "active;", nil
|
||||||
|
} else if len(statusResp) > 0 && statusResp[0] == NAK {
|
||||||
|
return "", fmt.Errorf("negative response from dispenser")
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("unexpected response status: % X", statusResp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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[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 {
|
||||||
@ -143,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)
|
||||||
@ -152,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 = 2 * 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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,341 +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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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) DispenserFinal(ctx context.Context) (string, error) {
|
|
||||||
const funcName = "DispenserFinal"
|
|
||||||
stockStatus := ""
|
|
||||||
|
|
||||||
if err := c.OutOfMouth(ctx); err != nil {
|
|
||||||
return stockStatus, fmt.Errorf("[%s] out of mouth: %w", funcName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(delay)
|
|
||||||
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)
|
|
||||||
|
|
||||||
time.Sleep(delay)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
package errorhandlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
|
||||||
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 FatalError(err error) {
|
|
||||||
fmt.Println(err.Error())
|
|
||||||
log.Errorf(err.Error())
|
|
||||||
fmt.Println(". Press Enter to exit...")
|
|
||||||
fmt.Scanln()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
2
go.mod
2
go.mod
@ -3,7 +3,7 @@ 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
|
||||||
|
|||||||
4
go.sum
4
go.sum
@ -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=
|
||||||
|
|||||||
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,67 +2,57 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"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/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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
theRequest cmstypes.TransactionRec
|
cardholderReceipt string
|
||||||
trResult payment.TransactionResultXML
|
theRequest cmstypes.TransactionRec
|
||||||
result payment.PaymentResult
|
trResult payment.TransactionResultXML
|
||||||
save bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
theResponse.Status.Code = http.StatusInternalServerError
|
theResponse.Status.Code = http.StatusInternalServerError
|
||||||
@ -74,7 +64,7 @@ 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 {
|
||||||
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled")
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Payment processing is disabled")
|
||||||
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
|
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -84,16 +74,16 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
|||||||
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()
|
defer r.Body.Close()
|
||||||
|
|
||||||
if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
|
if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
|
||||||
theResponse.Data = payment.BuildFailureURL(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
|
||||||
}
|
}
|
||||||
@ -102,17 +92,17 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
|||||||
err := xml.Unmarshal(body, &theRequest)
|
err := xml.Unmarshal(body, &theRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
||||||
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload")
|
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)
|
log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 300 * time.Second}
|
client := &http.Client{Timeout: 300 * time.Second}
|
||||||
response, err := client.Post(types.LinkTakePreauthorization, "text/xml", bytes.NewBuffer(body))
|
response, err := client.Post(transactionUrl, "text/xml", bytes.NewBuffer(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
|
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
|
||||||
theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor")
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "No response from payment processor")
|
||||||
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -121,7 +111,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
|||||||
body, err = io.ReadAll(response.Body)
|
body, err = io.ReadAll(response.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
|
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
|
||||||
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read response body")
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Failed to read response body")
|
||||||
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
|
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -131,28 +121,37 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compose JSON from responseEntries
|
// Compose JSON from responseEntries
|
||||||
result.FillFromTransactionResult(trResult)
|
result := make(map[string]string)
|
||||||
|
for _, e := range trResult.Entries {
|
||||||
|
switch e.Key {
|
||||||
|
case payment.ReceiptData, payment.ReceiptDataMerchant:
|
||||||
|
// ignore these
|
||||||
|
case payment.ReceiptDataCardholder:
|
||||||
|
cardholderReceipt = e.Value
|
||||||
|
case payment.TransactionResult:
|
||||||
|
theResponse.Status.Message = e.Value
|
||||||
|
theResponse.Status.Code = http.StatusOK
|
||||||
|
result[e.Key] = e.Value
|
||||||
|
default:
|
||||||
|
result[e.Key] = e.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := printer.PrintCardholderReceipt(result.CardholderReceipt); err != nil {
|
if err := printer.PrintCardholderReceipt(cardholderReceipt); err != nil {
|
||||||
log.Errorf("PrintCardholderReceipt error: %v", err)
|
log.Errorf("PrintCardholderReceipt error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
theResponse.Status = result.Status
|
theResponse.Data = payment.BuildPaymentRedirectURL(result)
|
||||||
theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields)
|
|
||||||
if save {
|
|
||||||
go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
theRequest cmstypes.TransactionRec
|
cardholderReceipt string
|
||||||
trResult payment.TransactionResultXML
|
theRequest cmstypes.TransactionRec
|
||||||
result payment.PaymentResult
|
trResult payment.TransactionResultXML
|
||||||
)
|
)
|
||||||
|
|
||||||
theResponse.Status.Code = http.StatusInternalServerError
|
theResponse.Status.Code = http.StatusInternalServerError
|
||||||
@ -164,7 +163,7 @@ 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 {
|
||||||
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled")
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Payment processing is disabled")
|
||||||
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
|
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -174,16 +173,16 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
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()
|
defer r.Body.Close()
|
||||||
|
|
||||||
if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
|
if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
|
||||||
theResponse.Data = payment.BuildFailureURL(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
|
||||||
}
|
}
|
||||||
@ -192,17 +191,17 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
err := xml.Unmarshal(body, &theRequest)
|
err := xml.Unmarshal(body, &theRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
||||||
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload")
|
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)
|
log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 300 * time.Second}
|
client := &http.Client{Timeout: 300 * time.Second}
|
||||||
response, err := client.Post(types.LinkTakePayment, "text/xml", bytes.NewBuffer(body))
|
response, err := client.Post(transactionUrl, "text/xml", bytes.NewBuffer(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
|
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
|
||||||
theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor")
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "No response from payment processor")
|
||||||
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -211,7 +210,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
body, err = io.ReadAll(response.Body)
|
body, err = io.ReadAll(response.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
|
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
|
||||||
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read response body")
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Failed to read response body")
|
||||||
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
|
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -221,14 +220,27 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compose JSON from responseEntries
|
// Compose JSON from responseEntries
|
||||||
result.FillFromTransactionResult(trResult)
|
result := make(map[string]string)
|
||||||
|
for _, e := range trResult.Entries {
|
||||||
|
switch e.Key {
|
||||||
|
case payment.ReceiptData, payment.ReceiptDataMerchant:
|
||||||
|
// ignore these
|
||||||
|
case payment.ReceiptDataCardholder:
|
||||||
|
cardholderReceipt = e.Value
|
||||||
|
case payment.TransactionResult:
|
||||||
|
theResponse.Status.Message = e.Value
|
||||||
|
theResponse.Status.Code = http.StatusOK
|
||||||
|
result[e.Key] = e.Value
|
||||||
|
default:
|
||||||
|
result[e.Key] = e.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := printer.PrintCardholderReceipt(result.CardholderReceipt); err != nil {
|
if err := printer.PrintCardholderReceipt(cardholderReceipt); err != nil {
|
||||||
log.Errorf("PrintCardholderReceipt error: %v", err)
|
log.Errorf("PrintCardholderReceipt error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
theResponse.Status = result.Status
|
theResponse.Data = payment.BuildRedirectURL(result)
|
||||||
theResponse.Data = payment.BuildPaymentRedirectURL(result.Fields)
|
|
||||||
writeTransactionResult(w, http.StatusOK, theResponse)
|
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,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(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(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(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(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)
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()+"; check card stock")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
} else {
|
||||||
|
log.Info(status)
|
||||||
// Always attempt to finalize after we have moved a card / started an issuance flow.
|
|
||||||
// This guarantees we eject and prepare the next card even on lock failures.
|
|
||||||
finalize := func() {
|
|
||||||
if app.disp == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
status, ferr := app.disp.DispenserFinal(ctx)
|
|
||||||
if ferr != nil {
|
|
||||||
logging.Error(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()
|
||||||
|
if err != nil {
|
||||||
logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0)
|
logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0)
|
||||||
finalize()
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
errorhandlers.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) {
|
||||||
@ -342,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(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(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(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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -379,24 +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()
|
|
||||||
app.cardWellStatus = s
|
|
||||||
app.cardWellMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) CardWellStatus() string {
|
|
||||||
app.cardWellMu.RLock()
|
|
||||||
defer app.cardWellMu.RUnlock()
|
|
||||||
return app.cardWellStatus
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,14 +2,28 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||||
"gitea.futuresens.co.uk/futuresens/logging"
|
"gitea.futuresens.co.uk/futuresens/logging"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
const serviceName = "hardlink"
|
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)
|
||||||
@ -18,3 +32,10 @@ func writeTransactionResult(w http.ResponseWriter, status int, theResponse cmsty
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FatalError(err error) {
|
||||||
|
fmt.Println(err.Error())
|
||||||
|
log.Errorf(err.Error())
|
||||||
|
fmt.Println(". Press Enter to exit...")
|
||||||
|
fmt.Scanln()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
177
main.go
177
main.go
@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -15,94 +14,90 @@ 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/printer"
|
"gitea.futuresens.co.uk/futuresens/hardlink/printer"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
buildVersion = "1.1.2"
|
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
|
||||||
|
dispHandle := &serial.Port{}
|
||||||
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)
|
||||||
}
|
}
|
||||||
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 {
|
||||||
errorhandlers.FatalError(err)
|
handlers.FatalError(err)
|
||||||
}
|
}
|
||||||
defer dispPort.Close()
|
defer dispHandle.Close()
|
||||||
|
|
||||||
// Start queued dispenser client (single goroutine owns the serial port)
|
status, err := dispenser.CheckDispenserStatus(dispHandle)
|
||||||
disp = dispenser.NewClient(dispPort, 32)
|
|
||||||
defer disp.Close()
|
|
||||||
|
|
||||||
// Prepare dispenser (ensures card at encoder position unless empty)
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cardWellStatus, err = disp.DispenserPrepare(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("%s; wrong dispenser address: %s", err, cfg.DispenserAdrr)
|
if len(status) == 0 {
|
||||||
errorhandlers.FatalError(err)
|
err = fmt.Errorf("%s; wrong dispenser address: %s", err, config.DispenserAdrr)
|
||||||
|
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 (as you did before)
|
|
||||||
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())
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
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()
|
||||||
@ -112,35 +107,73 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create App and wire routes
|
// Create App and wire routes
|
||||||
// NOTE: change handlers.NewApp signature to accept *dispenser.Client instead of *serial.Port
|
app := handlers.NewApp(dispHandle, config.LockType, config.EncoderAddress, config.IsPayment)
|
||||||
app := handlers.NewApp(disp, cfg.LockType, cfg.EncoderAddress, cardWellStatus, database, &cfg)
|
|
||||||
|
|
||||||
// Update cardWellStatus when dispenser status changes
|
|
||||||
if !cfg.TestMode {
|
|
||||||
// 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
|
||||||
@ -148,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
|
||||||
@ -172,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
|
||||||
|
|||||||
@ -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,17 +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
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseTransactionResult parses the XML into entries.
|
// ParseTransactionResult parses the XML into entries.
|
||||||
@ -60,104 +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.
|
||||||
if r.Fields == nil {
|
// m is the map from keys to string values as returned by ChipDNA.
|
||||||
r.Fields = make(map[string]string)
|
func InsertTransactionRecord(ctx context.Context, db *sql.DB, m map[string]string) error {
|
||||||
|
// Extract fields with defaults or NULL handling.
|
||||||
|
|
||||||
|
// 1. TxnReference <- REFERENCE
|
||||||
|
ref, ok := m["REFERENCE"]
|
||||||
|
if !ok || ref == "" {
|
||||||
|
return fmt.Errorf("missing REFERENCE in result map")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, e := range trResult.Entries {
|
// 2. TxnDateTime <- parse AUTH_DATE_TIME (layout "20060102150405"), else use now
|
||||||
switch e.Key {
|
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.ReceiptData, types.ReceiptDataMerchant:
|
// 3. TotalAmount <- parse TOTAL_AMOUNT minor units into float (divide by 100)
|
||||||
// intentionally ignored
|
var totalAmount sql.NullFloat64
|
||||||
|
if s, ok := m["TOTAL_AMOUNT"]; ok && s != "" {
|
||||||
case types.ReceiptDataCardholder:
|
if iv, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||||
r.CardholderReceipt = e.Value
|
// convert minor units to major (e.g. 150 -> 1.50)
|
||||||
|
totalAmount.Float64 = float64(iv) / 100.0
|
||||||
case types.TransactionResult:
|
totalAmount.Valid = true
|
||||||
r.Status.Message = e.Value
|
|
||||||
r.Status.Code = http.StatusOK
|
|
||||||
r.Fields[e.Key] = e.Value
|
|
||||||
|
|
||||||
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) {
|
||||||
// Transaction confirmed?
|
switch {
|
||||||
if strings.EqualFold(result[types.ConfirmResult], types.ResultApproved) {
|
// Transaction type AccountVerification?
|
||||||
log.WithField(types.LogResult, result[types.ConfirmResult]).
|
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?
|
||||||
|
if strings.EqualFold(result[ConfirmResult], ResultApproved) {
|
||||||
|
log.WithField(LogResult, result[ConfirmResult]).
|
||||||
|
Info("Transaction approved and confirmed")
|
||||||
|
|
||||||
|
return buildSuccessURL(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not confirmed
|
||||||
|
log.WithFields(log.Fields{LogFieldError: result[ConfirmResult], LogFieldDescription: result[ConfirmErrors]}).
|
||||||
|
Error("Transaction approved but not confirmed")
|
||||||
|
|
||||||
|
return BuildFailureURL(result[ConfirmResult], result[ConfirmErrors])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not approved
|
||||||
|
return BuildFailureURL(res, result[Errors])
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildPreauthRedirectURL(result map[string]string) string {
|
||||||
|
res := result[TransactionResult]
|
||||||
|
tType := result[TransactionType]
|
||||||
|
|
||||||
|
// Transaction approved?
|
||||||
|
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?
|
||||||
|
log.WithField(LogResult, result[ConfirmResult]).
|
||||||
Info("Transaction approved and confirmed")
|
Info("Transaction approved and confirmed")
|
||||||
|
|
||||||
return buildSuccessURL(result)
|
return buildSuccessURL(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not confirmed
|
|
||||||
log.WithFields(log.Fields{types.LogFieldError: result[types.ConfirmResult], types.LogFieldDescription: result[types.ConfirmErrors]}).
|
|
||||||
Error("Transaction approved but not confirmed")
|
|
||||||
|
|
||||||
return BuildFailureURL(result[types.ConfirmResult], result[types.ConfirmErrors])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not approved
|
// Not approved
|
||||||
return BuildFailureURL(res, result[types.Errors])
|
return BuildFailureURL(res, result[Errors])
|
||||||
}
|
|
||||||
|
|
||||||
func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
|
|
||||||
res := result[types.TransactionResult]
|
|
||||||
tType := result[types.TransactionType]
|
|
||||||
|
|
||||||
// Transaction approved?
|
|
||||||
if strings.EqualFold(res, types.ResultApproved) {
|
|
||||||
switch {
|
|
||||||
// Transaction type AccountVerification?
|
|
||||||
case strings.EqualFold(tType, types.AccountVerificationType):
|
|
||||||
log.WithField(types.LogResult, result[types.TransactionResult]).
|
|
||||||
Info("Account verification approved")
|
|
||||||
|
|
||||||
return buildSuccessURL(result), false
|
|
||||||
|
|
||||||
// Transaction type Sale?
|
|
||||||
case strings.EqualFold(tType, types.SaleTransactionType):
|
|
||||||
// Transaction confirmed?
|
|
||||||
log.WithField(types.LogResult, result[types.ConfirmResult]).
|
|
||||||
Info("Amount preauthorized successfully")
|
|
||||||
|
|
||||||
return buildSuccessURL(result), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not approved
|
|
||||||
return BuildFailureURL(res, result[types.Errors]), false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSuccessURL(result map[string]string) string {
|
func buildSuccessURL(result map[string]string) string {
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("CardNumber", hex.EncodeToString([]byte(result[types.PAN_MASKED])))
|
q.Set("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()
|
||||||
}
|
}
|
||||||
@ -168,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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -2,40 +2,6 @@
|
|||||||
|
|
||||||
builtVersion is a const in main.go
|
builtVersion is a const in main.go
|
||||||
|
|
||||||
#### 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
|
||||||
|
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/xml"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
DateOnly = "2006-01-02"
|
|
||||||
CustomLayout = "2006-01-02 15:04:05 -0700"
|
|
||||||
LinkTakePreauthorization = "http://127.0.0.1:18181/start-transaction/"
|
|
||||||
LinkTakePayment = "http://127.0.0.1:18181/start-and-confirm-transaction/"
|
|
||||||
LinkTransactionInformation = "http://127.0.0.1:18181/transaction-information/"
|
|
||||||
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"
|
|
||||||
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Loading…
x
Reference in New Issue
Block a user