Compare commits

...

5 Commits

13 changed files with 936 additions and 345 deletions

18
bootstrap/bootstrap.go Normal file
View File

@ -0,0 +1,18 @@
// 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,
)
}

93
config/config.go Normal file
View File

@ -0,0 +1,93 @@
package config
import (
"fmt"
"os"
"strings"
"gitea.futuresens.co.uk/futuresens/hardlink/handlers"
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)
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
}
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)
handlers.FatalError(err)
}
if cfg.LogDir == "" {
cfg.LogDir = "./logs" + sep
} else if !strings.HasSuffix(cfg.LogDir, sep) {
cfg.LogDir += sep
}
return cfg
}

200
db/db.go Normal file
View File

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

2
go.mod
View File

@ -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.179 gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190
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
View File

@ -1,5 +1,5 @@
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.179 h1:3OLzX6jJ2dwfZ9Fcijk5z6/GUdTl5FUNw3eWuRkDhZw= gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190 h1:OxP911wT8HQqBJ20KIZcBxi898rsYHhhCkne2u45p1A=
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.179/go.mod h1:ABMUkdm+3VGrkuoCJsXMfPPud9GHDOwBb1NiifFqxes= gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190/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=

View File

@ -2,6 +2,7 @@ package handlers
import ( import (
"bytes" "bytes"
"database/sql"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"io" "io"
@ -12,46 +13,47 @@ import (
"github.com/tarm/serial" "github.com/tarm/serial"
"gitea.futuresens.co.uk/futuresens/cmstypes" "gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/db"
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser" "gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
"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 {
dispPort *serial.Port dispPort *serial.Port
lockserver lockserver.LockServer lockserver lockserver.LockServer
isPayment bool isPayment bool
db *sql.DB
} }
func NewApp(dispPort *serial.Port, lockType, encoderAddress string, isPayment bool) *App { func NewApp(dispPort *serial.Port, lockType, encoderAddress string, db *sql.DB, isPayment bool) *App {
return &App{ return &App{
isPayment: isPayment, isPayment: isPayment,
dispPort: dispPort, dispPort: dispPort,
lockserver: lockserver.NewLockServer(lockType, encoderAddress, FatalError), lockserver: lockserver.NewLockServer(lockType, encoderAddress, FatalError),
db: db,
} }
} }
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("/starttransaction", app.startTransaction) mux.HandleFunc("/takepreauth", app.takePreauthorization)
mux.HandleFunc("/takepayment", app.takePayment)
} }
func (app *App) startTransaction(w http.ResponseWriter, r *http.Request) { func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("startTransaction") const op = logging.Op("takePreauthorization")
var ( var (
theResponse cmstypes.ResponseRec theResponse cmstypes.ResponseRec
cardholderReceipt string
theRequest cmstypes.TransactionRec theRequest cmstypes.TransactionRec
trResult payment.TransactionResultXML trResult payment.TransactionResultXML
result payment.PaymentResult
save bool
) )
theResponse.Status.Code = http.StatusInternalServerError theResponse.Status.Code = http.StatusInternalServerError
@ -63,7 +65,7 @@ func (app *App) startTransaction(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(payment.ResultError, "Payment processing is disabled") theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse) writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return return
} }
@ -73,16 +75,16 @@ func (app *App) startTransaction(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Println("startTransaction called") log.Println("takePreauthorization called")
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Method not allowed; use POST") theResponse.Data = payment.BuildFailureURL(types.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(payment.ResultError, "Content-Type must be text/xml") theResponse.Data = payment.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse) writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return return
} }
@ -91,17 +93,17 @@ func (app *App) startTransaction(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(payment.ResultError, "Invalid XML payload") theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse) writeTransactionResult(w, http.StatusBadRequest, theResponse)
return return
} }
log.Printf("Start trnasaction 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(transactionUrl, "text/xml", bytes.NewBuffer(body)) response, err := client.Post(types.LinkTakePreauthorization, "text/xml", bytes.NewBuffer(body))
if err != nil { if err != nil {
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0) logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "No response from payment processor") theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse) writeTransactionResult(w, http.StatusBadGateway, theResponse)
return return
} }
@ -110,7 +112,7 @@ func (app *App) startTransaction(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(payment.ResultError, "Failed to read response body") theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read response body")
writeTransactionResult(w, http.StatusInternalServerError, theResponse) writeTransactionResult(w, http.StatusInternalServerError, theResponse)
return return
} }
@ -120,27 +122,103 @@ func (app *App) startTransaction(w http.ResponseWriter, r *http.Request) {
} }
// Compose JSON from responseEntries // Compose JSON from responseEntries
result := make(map[string]string) result.FillFromTransactionResult(trResult)
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(cardholderReceipt); err != nil { if err := printer.PrintCardholderReceipt(result.CardholderReceipt); err != nil {
log.Errorf("PrintCardholderReceipt error: %v", err) log.Errorf("PrintCardholderReceipt error: %v", err)
} }
theResponse.Data = payment.BuildRedirectURL(result) theResponse.Status = result.Status
theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields)
if save {
db.InsertPreauth(r.Context(), app.db, result.Fields, theRequest.CheckoutDate)
}
writeTransactionResult(w, http.StatusOK, theResponse)
}
func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("takePayment")
var (
theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec
trResult payment.TransactionResultXML
result payment.PaymentResult
)
theResponse.Status.Code = http.StatusInternalServerError
theResponse.Status.Message = "500 Internal server error"
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Content-Type", "application/json")
if !app.isPayment {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("takePayment called")
if r.Method != http.MethodPost {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Method not allowed; use POST")
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
return
}
defer r.Body.Close()
if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return
}
body, _ := io.ReadAll(r.Body)
err := xml.Unmarshal(body, &theRequest)
if err != nil {
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse)
return
}
log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType)
client := &http.Client{Timeout: 300 * time.Second}
response, err := client.Post(types.LinkTakePayment, "text/xml", bytes.NewBuffer(body))
if err != nil {
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return
}
defer response.Body.Close()
body, err = io.ReadAll(response.Body)
if err != nil {
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read response body")
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
return
}
if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(serviceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
}
// Compose JSON from responseEntries
result.FillFromTransactionResult(trResult)
if err := printer.PrintCardholderReceipt(result.CardholderReceipt); err != nil {
log.Errorf("PrintCardholderReceipt error: %v", err)
}
theResponse.Status = result.Status
theResponse.Data = payment.BuildPaymentRedirectURL(result.Fields)
writeTransactionResult(w, http.StatusOK, theResponse) writeTransactionResult(w, http.StatusOK, theResponse)
} }
@ -180,13 +258,13 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
} }
// parse times // parse times
checkIn, err := time.Parse(customLayout, doorReq.CheckinTime) checkIn, err := time.Parse(types.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)
writeError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error()) writeError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error())
return return
} }
checkOut, err := time.Parse(customLayout, doorReq.CheckoutTime) checkOut, err := time.Parse(types.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)
writeError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error()) writeError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error())

View File

@ -0,0 +1,42 @@
// cmd/hardlink-preauth-release/main.go
package main
import (
"fmt"
"os"
"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.0"
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.WithError(err).Fatal("Preauth release failed")
}
log.Info("Task completed successfully")
fmt.Println(". Press Enter to exit...")
fmt.Scanln()
os.Exit(0)
}

31
logging/logging.go Normal file
View File

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

87
main.go
View File

@ -14,37 +14,24 @@ 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/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.0.22" buildVersion = "1.0.28"
serviceName = "hardlink" serviceName = "hardlink"
) )
// 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
config := readConfig() config := config.ReadHardlinkConfig()
printer.Layout = readTicketLayout() printer.Layout = readTicketLayout()
printer.PrinterName = config.PrinterName printer.PrinterName = config.PrinterName
lockserver.Cert = config.Cert lockserver.Cert = config.Cert
@ -52,7 +39,7 @@ func main() {
dispHandle := &serial.Port{} dispHandle := &serial.Port{}
// Setup logging and get file handle // Setup logging and get file handle
logFile, err := setupLogging(config.LogDir) logFile, err := logging.SetupLogging(config.LogDir, serviceName, buildVersion)
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)
} }
@ -97,6 +84,12 @@ func main() {
} }
} }
database, err := bootstrap.OpenDB(&config)
if err != nil {
log.Warnf("DB init failed: %v", err)
}
defer database.Close()
if config.IsPayment { if config.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")
@ -107,7 +100,7 @@ func main() {
} }
// Create App and wire routes // Create App and wire routes
app := handlers.NewApp(dispHandle, config.LockType, config.EncoderAddress, config.IsPayment) app := handlers.NewApp(dispHandle, config.LockType, config.EncoderAddress, database, config.IsPayment)
mux := http.NewServeMux() mux := http.NewServeMux()
app.RegisterRoutes(mux) app.RegisterRoutes(mux)
@ -120,60 +113,6 @@ func main() {
} }
} }
// 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

View File

@ -1,53 +1,19 @@
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 {
@ -73,6 +39,17 @@ 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.
@ -83,234 +60,124 @@ func (tr *TransactionResultXML) ParseTransactionResult(data []byte) error {
return nil return nil
} }
// initMSSQL opens and pings the SQL Server instance localhost\SQLEXPRESS func (ti *TransactionInfo) FillFromTransactionResult(trResult TransactionResultXML) {
// using user=Kiosk, password=Gr33nfarm, database=TransactionDatabase. for _, e := range trResult.Entries {
func InitMSSQL(port int, user, password, database string) (*sql.DB, error) { switch e.Key {
const server = "localhost" case types.TransactionResult:
ti.transactionRes = e.Value
// Use TCP; drop the \SQLEXPRESS instance name case types.TransactionState:
connString := fmt.Sprintf( ti.transactionState = e.Value
"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
} }
// insertTransactionRecord inserts one row into TransactionRecords. func (r *PaymentResult) FillFromTransactionResult(trResult TransactionResultXML) {
// m is the map from keys to string values as returned by ChipDNA. if r.Fields == nil {
func InsertTransactionRecord(ctx context.Context, db *sql.DB, m map[string]string) error { r.Fields = make(map[string]string)
// 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")
} }
// 2. TxnDateTime <- parse AUTH_DATE_TIME (layout "20060102150405"), else use now for _, e := range trResult.Entries {
var txnTime time.Time switch e.Key {
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()
}
// 3. TotalAmount <- parse TOTAL_AMOUNT minor units into float (divide by 100) case types.ReceiptData, types.ReceiptDataMerchant:
var totalAmount sql.NullFloat64 // intentionally ignored
if s, ok := m["TOTAL_AMOUNT"]; ok && s != "" {
if iv, err := strconv.ParseInt(s, 10, 64); err == nil { case types.ReceiptDataCardholder:
// convert minor units to major (e.g. 150 -> 1.50) r.CardholderReceipt = e.Value
totalAmount.Float64 = float64(iv) / 100.0
totalAmount.Valid = true case types.TransactionResult:
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 BuildRedirectURL(result map[string]string) string { func BuildPaymentRedirectURL(result map[string]string) string {
res := result[TransactionResult] res := result[types.TransactionResult]
tType := result[TransactionType]
// Transaction approved? // Transaction approved?
if strings.EqualFold(res, ResultApproved) { if strings.EqualFold(res, types.ResultApproved) {
switch {
// Transaction type AccountVerification?
case strings.EqualFold(tType, AccountVerificationType):
log.WithField(LogResult, result[TransactionResult]).
Info("Account verification approved")
return buildSuccessURL(result)
// Transaction type Sale?
case strings.EqualFold(tType, SaleTransactionType):
// Transaction confirmed? // Transaction confirmed?
if strings.EqualFold(result[ConfirmResult], ResultApproved) { if strings.EqualFold(result[types.ConfirmResult], types.ResultApproved) {
log.WithField(LogResult, result[ConfirmResult]). log.WithField(types.LogResult, result[types.ConfirmResult]).
Info("Transaction approved and confirmed") Info("Transaction approved and confirmed")
return buildSuccessURL(result) return buildSuccessURL(result)
} }
// Not confirmed // Not confirmed
log.WithFields(log.Fields{LogFieldError: result[ConfirmResult], LogFieldDescription: result[ConfirmErrors]}). log.WithFields(log.Fields{types.LogFieldError: result[types.ConfirmResult], types.LogFieldDescription: result[types.ConfirmErrors]}).
Error("Transaction approved but not confirmed") Error("Transaction approved but not confirmed")
return BuildFailureURL(result[ConfirmResult], result[ConfirmErrors]) return BuildFailureURL(result[types.ConfirmResult], result[types.ConfirmErrors])
}
// Not approved
return BuildFailureURL(res, result[types.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 // Not approved
return BuildFailureURL(res, result[Errors]) 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("TxnReference", result[Reference]) q.Set("CardNumber", hex.EncodeToString([]byte(result[types.PAN_MASKED])))
q.Set("CardHash", hex.EncodeToString([]byte(result[CardHash]))) q.Set("ExpiryDate", hex.EncodeToString([]byte(result[types.EXPIRY_DATE])))
q.Set("CardReference", hex.EncodeToString([]byte(result[CardReference]))) q.Set("TxnReference", result[types.Reference])
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: CheckinSuccessfulEndpoint, Path: types.CheckinSuccessfulEndpoint,
RawQuery: q.Encode(), RawQuery: q.Encode(),
}).String() }).String()
} }
func BuildFailureURL(msgType, description string) string { func BuildFailureURL(msgType, description string) string {
q := url.Values{} q := url.Values{}
if msgType == "" { if msgType != "" {
msgType = ResultError description = fmt.Sprintf("Transaction %s", strings.ToLower(msgType))
} }
if description == "" { if description != "" {
description = "Transaction failed" msgType = types.ResultError
} }
log.WithFields(log.Fields{LogFieldError: msgType, LogFieldDescription: description}). log.WithFields(log.Fields{types.LogFieldError: msgType, types.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: CheckinUnsuccessfulEndpoint, Path: types.CheckinUnsuccessfulEndpoint,
RawQuery: q.Encode(), RawQuery: q.Encode(),
}).String() }).String()
} }

237
payment/preauthReleaser.go Normal file
View File

@ -0,0 +1,237 @@
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 (LEGACY)
============================== */
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
}
// If already voided or declined → mark released
if isAlreadyReleased(info) {
return markReleased(ctx, dbConn, ref, now)
}
// Only void approved + uncommitted
if !isVoidable(info) {
log.Infof("Preauth %s not eligible for void (res=%s state=%s)",
ref, info.transactionRes, info.transactionState)
return nil
}
// Void transaction
if err := voidPreauth(ctx, client, ref); err != nil {
return err
}
// Verify final state
finalInfo, err := fetchTransactionInfo(ctx, client, ref)
if err != nil {
return err
}
if !isSuccessfullyVoided(finalInfo) {
return fmt.Errorf("unexpected final state res=%s state=%s",
finalInfo.transactionRes, finalInfo.transactionState)
}
log.Infof("Preauth %s successfully voided", ref)
return markReleased(ctx, dbConn, ref, now)
}
/* ==============================
State Evaluation Helpers
============================== */
func isVoidable(info TransactionInfo) bool {
return strings.EqualFold(info.transactionRes, types.ResultApproved) &&
strings.EqualFold(info.transactionState, types.ResultStateUncommitted)
}
func isAlreadyReleased(info TransactionInfo) bool {
return strings.EqualFold(info.transactionState, types.ResultStateVoided) ||
strings.EqualFold(info.transactionRes, types.ResultDeclined)
}
func isSuccessfullyVoided(info TransactionInfo) bool {
return strings.EqualFold(info.transactionRes, types.ResultDeclined) &&
strings.EqualFold(info.transactionState, types.ResultStateVoided)
}
/* ==============================
External Operations
============================== */
func fetchDuePreauths(
ctx context.Context,
dbConn *sql.DB,
now time.Time,
) ([]types.PreauthRec, error) {
return db.GetDuePreauths(ctx, dbConn, now, 0)
}
func markReleased(
ctx context.Context,
dbConn *sql.DB,
ref string,
now time.Time,
) error {
return db.MarkPreauthReleased(ctx, dbConn, ref, now)
}
func fetchTransactionInfo(
ctx context.Context,
client *http.Client,
ref string,
) (TransactionInfo, error) {
var tr TransactionResultXML
var info TransactionInfo
payload, _ := xml.Marshal(types.TransactionReferenceRequest{
TransactionReference: ref,
})
body, err := postXML(ctx, client, types.LinkTransactionInformation, payload)
if err != nil {
return info, err
}
if err := tr.ParseTransactionResult(body); err != nil {
return info, err
}
info.FillFromTransactionResult(tr)
return info, nil
}
func voidPreauth(
ctx context.Context,
client *http.Client,
ref string,
) error {
var tr TransactionResultXML
var info TransactionInfo
payload, _ := xml.Marshal(types.TransactionReferenceRequest{
TransactionReference: ref,
})
body, err := postXML(ctx, client, types.LinkVoidTransaction, payload)
if err != nil {
return err
}
if err := tr.ParseTransactionResult(body); err != nil {
return err
}
info.FillFromTransactionResult(tr)
if !strings.EqualFold(info.transactionRes, types.ResultApproved) {
return fmt.Errorf("void rejected: %s", info.transactionRes)
}
return nil
}
/* ==============================
Low-level HTTP
============================== */
func postXML(
ctx context.Context,
client *http.Client,
url string,
payload []byte,
) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "text/xml")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}

View File

@ -2,10 +2,25 @@
builtVersion is a const in main.go builtVersion is a const in main.go
#### 1.0.23 - 20 October 2025 #### 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
improved logging for creditcall payment processing
#### 1.0.23 - 13 November 2025
moved handlers from main.go to handlers/handlers.go moved handlers from main.go to handlers/handlers.go
#### 1.0.22 - 20 October 2025 #### 1.0.22 - 13 November 2025
added test mode into config file to allow testing without connecting to the dispenser added test mode into config file to allow testing without connecting to the dispenser
#### 1.0.21 - 20 October 2025 #### 1.0.21 - 20 October 2025

71
types/types.go Normal file
View File

@ -0,0 +1,71 @@
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
}
)