From 7941e2065e09cbd98095c9598e9098752becc82c Mon Sep 17 00:00:00 2001 From: yurii Date: Mon, 22 Dec 2025 13:58:17 +0000 Subject: [PATCH] added preauth releaser --- bootstrap/bootstrap.go | 18 ++ config/config.go | 93 ++++++++++ db/db.go | 200 +++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- handlers/handlers.go | 107 +++++------- hardlink-preauth-release/main.go | 42 +++++ logging/logging.go | 31 ++++ main.go | 87 ++------- payment/creditcall.go | 291 ++++++++----------------------- payment/preauthReleaser.go | 237 +++++++++++++++++++++++++ release notes.md | 3 + types/types.go | 71 ++++++++ 13 files changed, 822 insertions(+), 364 deletions(-) create mode 100644 bootstrap/bootstrap.go create mode 100644 config/config.go create mode 100644 db/db.go create mode 100644 hardlink-preauth-release/main.go create mode 100644 logging/logging.go create mode 100644 payment/preauthReleaser.go create mode 100644 types/types.go diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go new file mode 100644 index 0000000..124ce80 --- /dev/null +++ b/bootstrap/bootstrap.go @@ -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, + ) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..408ee6b --- /dev/null +++ b/config/config.go @@ -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 +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..3ff2176 --- /dev/null +++ b/db/db.go @@ -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 +} diff --git a/go.mod b/go.mod index 3c4a9c8..b6d8b5d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module gitea.futuresens.co.uk/futuresens/hardlink go 1.23.2 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 github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb github.com/denisenkom/go-mssqldb v0.12.3 diff --git a/go.sum b/go.sum index 06f59df..4bedda2 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -gitea.futuresens.co.uk/futuresens/cmstypes v1.0.179 h1:3OLzX6jJ2dwfZ9Fcijk5z6/GUdTl5FUNw3eWuRkDhZw= -gitea.futuresens.co.uk/futuresens/cmstypes v1.0.179/go.mod h1:ABMUkdm+3VGrkuoCJsXMfPPud9GHDOwBb1NiifFqxes= +gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190 h1:OxP911wT8HQqBJ20KIZcBxi898rsYHhhCkne2u45p1A= +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/go.mod h1:p95ouVfK4qyC20D3/k9QLsWSxD2pdweWiY6vcYi9hpM= gitea.futuresens.co.uk/futuresens/logging v1.0.9 h1:uvCQq/plecB0z/bUWOhFhwyYUWGPkTBZHsYNL+3RFvI= diff --git a/handlers/handlers.go b/handlers/handlers.go index e81c56f..1bd476b 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -2,6 +2,7 @@ package handlers import ( "bytes" + "database/sql" "encoding/json" "encoding/xml" "io" @@ -12,31 +13,29 @@ import ( "github.com/tarm/serial" "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/lockserver" "gitea.futuresens.co.uk/futuresens/hardlink/payment" "gitea.futuresens.co.uk/futuresens/hardlink/printer" + "gitea.futuresens.co.uk/futuresens/hardlink/types" "gitea.futuresens.co.uk/futuresens/logging" log "github.com/sirupsen/logrus" ) -const ( - customLayout = "2006-01-02 15:04:05 -0700" - takePreauthorizationUrl = "http://127.0.0.1:18181/start-transaction/" - takePaymentUrl = "http://127.0.0.1:18181/start-and-confirm-transaction/" -) - type App struct { dispPort *serial.Port lockserver lockserver.LockServer 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{ isPayment: isPayment, dispPort: dispPort, lockserver: lockserver.NewLockServer(lockType, encoderAddress, FatalError), + db: db, } } @@ -50,10 +49,11 @@ func (app *App) RegisterRoutes(mux *http.ServeMux) { func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { const op = logging.Op("takePreauthorization") var ( - theResponse cmstypes.ResponseRec - cardholderReceipt string - theRequest cmstypes.TransactionRec - trResult payment.TransactionResultXML + theResponse cmstypes.ResponseRec + theRequest cmstypes.TransactionRec + trResult payment.TransactionResultXML + result payment.PaymentResult + save bool ) theResponse.Status.Code = http.StatusInternalServerError @@ -65,7 +65,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") 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) return } @@ -77,14 +77,14 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { log.Println("takePreauthorization called") 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) return } defer r.Body.Close() 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) return } @@ -93,17 +93,17 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { err := xml.Unmarshal(body, &theRequest) if err != nil { 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) return } log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType) client := &http.Client{Timeout: 300 * time.Second} - response, err := client.Post(takePreauthorizationUrl, "text/xml", bytes.NewBuffer(body)) + response, err := client.Post(types.LinkTakePreauthorization, "text/xml", bytes.NewBuffer(body)) if err != nil { 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) return } @@ -112,7 +112,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { body, err = io.ReadAll(response.Body) if err != nil { logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0) - theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Failed to read response body") + theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read response body") writeTransactionResult(w, http.StatusInternalServerError, theResponse) return } @@ -122,37 +122,27 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) { } // Compose JSON from responseEntries - 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 - } - } + result.FillFromTransactionResult(trResult) - if err := printer.PrintCardholderReceipt(cardholderReceipt); err != nil { + if err := printer.PrintCardholderReceipt(result.CardholderReceipt); err != nil { log.Errorf("PrintCardholderReceipt error: %v", err) } - theResponse.Data = payment.BuildPreauthRedirectURL(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 - cardholderReceipt string - theRequest cmstypes.TransactionRec - trResult payment.TransactionResultXML + theResponse cmstypes.ResponseRec + theRequest cmstypes.TransactionRec + trResult payment.TransactionResultXML + result payment.PaymentResult ) theResponse.Status.Code = http.StatusInternalServerError @@ -164,7 +154,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") 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) return } @@ -176,14 +166,14 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) { log.Println("takePayment called") 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) return } defer r.Body.Close() 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) return } @@ -192,17 +182,17 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) { err := xml.Unmarshal(body, &theRequest) if err != nil { 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) return } log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType) client := &http.Client{Timeout: 300 * time.Second} - response, err := client.Post(takePaymentUrl, "text/xml", bytes.NewBuffer(body)) + 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(payment.ResultError, "No response from payment processor") + theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor") writeTransactionResult(w, http.StatusBadGateway, theResponse) return } @@ -211,7 +201,7 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) { body, err = io.ReadAll(response.Body) if err != nil { logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0) - theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Failed to read response body") + theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read response body") writeTransactionResult(w, http.StatusInternalServerError, theResponse) return } @@ -221,27 +211,14 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) { } // Compose JSON from responseEntries - 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 - } - } + result.FillFromTransactionResult(trResult) - if err := printer.PrintCardholderReceipt(cardholderReceipt); err != nil { + if err := printer.PrintCardholderReceipt(result.CardholderReceipt); err != nil { log.Errorf("PrintCardholderReceipt error: %v", err) } - theResponse.Data = payment.BuildPaymentRedirectURL(result) + theResponse.Status = result.Status + theResponse.Data = payment.BuildPaymentRedirectURL(result.Fields) writeTransactionResult(w, http.StatusOK, theResponse) } @@ -281,13 +258,13 @@ func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) { } // parse times - checkIn, err := time.Parse(customLayout, doorReq.CheckinTime) + checkIn, err := time.Parse(types.CustomLayout, doorReq.CheckinTime) if err != nil { logging.Error(serviceName, err.Error(), "Invalid checkinTime format", string(op), "", "", 0) writeError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error()) return } - checkOut, err := time.Parse(customLayout, doorReq.CheckoutTime) + checkOut, err := time.Parse(types.CustomLayout, doorReq.CheckoutTime) if err != nil { logging.Error(serviceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0) writeError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error()) diff --git a/hardlink-preauth-release/main.go b/hardlink-preauth-release/main.go new file mode 100644 index 0000000..e5e5749 --- /dev/null +++ b/hardlink-preauth-release/main.go @@ -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) +} diff --git a/logging/logging.go b/logging/logging.go new file mode 100644 index 0000000..3cc9e97 --- /dev/null +++ b/logging/logging.go @@ -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 +} diff --git a/main.go b/main.go index 04aef21..9bd235b 100644 --- a/main.go +++ b/main.go @@ -14,37 +14,24 @@ import ( "github.com/tarm/serial" 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/handlers" "gitea.futuresens.co.uk/futuresens/hardlink/lockserver" + "gitea.futuresens.co.uk/futuresens/hardlink/logging" "gitea.futuresens.co.uk/futuresens/hardlink/printer" ) const ( - buildVersion = "1.0.27" + buildVersion = "1.0.28" 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() { // Load config - config := readConfig() + config := config.ReadHardlinkConfig() printer.Layout = readTicketLayout() printer.PrinterName = config.PrinterName lockserver.Cert = config.Cert @@ -52,7 +39,7 @@ func main() { dispHandle := &serial.Port{} // Setup logging and get file handle - logFile, err := setupLogging(config.LogDir) + logFile, err := logging.SetupLogging(config.LogDir, serviceName, buildVersion) if err != nil { 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 { fmt.Println("Payment processing is enabled") log.Info("Payment processing is enabled") @@ -107,7 +100,7 @@ func main() { } // 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() 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 { const layoutName = "TicketLayout.xml" var layout printer.LayoutOptions diff --git a/payment/creditcall.go b/payment/creditcall.go index 704abd4..60da2e7 100644 --- a/payment/creditcall.go +++ b/payment/creditcall.go @@ -1,55 +1,19 @@ package payment import ( - "context" - "database/sql" "encoding/hex" "encoding/xml" "fmt" + "net/http" "net/url" - "strconv" "strings" - "time" + "gitea.futuresens.co.uk/futuresens/cmstypes" + "gitea.futuresens.co.uk/futuresens/hardlink/types" _ "github.com/denisenkom/go-mssqldb" 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" - PAN_MASKED = "PAN_MASKED" - EXPIRY_DATE = "EXPIRY_DATE" - TransactionResult = "TRANSACTION_RESULT" - TransactionType = "TRANSACTION_TYPE" - ConfirmResult = "CONFIRM_RESULT" - ConfirmErrors = "CONFIRM_ERRORS" - - // Log field keys - LogFieldError = "error" - LogFieldDescription = "description" - LogResult = "transactionResult" -) - // XML parsing structs type ( TransactionRec struct { @@ -75,6 +39,17 @@ type ( ErrorDescription string `xml:"ErrorDescription"` 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. @@ -85,232 +60,104 @@ func (tr *TransactionResultXML) ParseTransactionResult(data []byte) error { return nil } -// initMSSQL opens and pings the SQL Server instance localhost\SQLEXPRESS -// using user=Kiosk, password=Gr33nfarm, database=TransactionDatabase. -func InitMSSQL(port int, user, password, database string) (*sql.DB, error) { - const server = "localhost" - - // Use TCP; drop the \SQLEXPRESS instance name - 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 -} - -// insertTransactionRecord inserts one row into TransactionRecords. -// m is the map from keys to string values as returned by ChipDNA. -func InsertTransactionRecord(ctx context.Context, db *sql.DB, m map[string]string) error { - // Extract fields with defaults or NULL handling. - - // 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 - 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() - } - - // 3. TotalAmount <- parse TOTAL_AMOUNT minor units into float (divide by 100) - var totalAmount sql.NullFloat64 - if s, ok := m["TOTAL_AMOUNT"]; ok && s != "" { - if iv, err := strconv.ParseInt(s, 10, 64); err == nil { - // convert minor units to major (e.g. 150 -> 1.50) - totalAmount.Float64 = float64(iv) / 100.0 - totalAmount.Valid = true +func (ti *TransactionInfo) FillFromTransactionResult(trResult TransactionResultXML) { + for _, e := range trResult.Entries { + switch e.Key { + case types.TransactionResult: + ti.transactionRes = e.Value + case types.TransactionState: + ti.transactionState = 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 +func (r *PaymentResult) FillFromTransactionResult(trResult TransactionResultXML) { + if r.Fields == nil { + r.Fields = make(map[string]string) } - return nil -} -func nullableFloatArg(nf sql.NullFloat64) interface{} { - if nf.Valid { - return nf.Float64 + + for _, e := range trResult.Entries { + switch e.Key { + + case types.ReceiptData, types.ReceiptDataMerchant: + // intentionally ignored + + case types.ReceiptDataCardholder: + r.CardholderReceipt = e.Value + + 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 + } } - return nil } // BuildRedirectURL builds the redirect URL to send the guest to after payment. func BuildPaymentRedirectURL(result map[string]string) string { - res := result[TransactionResult] + res := result[types.TransactionResult] // Transaction approved? - if strings.EqualFold(res, ResultApproved) { + if strings.EqualFold(res, types.ResultApproved) { // Transaction confirmed? - if strings.EqualFold(result[ConfirmResult], ResultApproved) { - log.WithField(LogResult, result[ConfirmResult]). + if strings.EqualFold(result[types.ConfirmResult], types.ResultApproved) { + log.WithField(types.LogResult, result[types.ConfirmResult]). Info("Transaction approved and confirmed") return buildSuccessURL(result) } // 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") - return BuildFailureURL(result[ConfirmResult], result[ConfirmErrors]) + return BuildFailureURL(result[types.ConfirmResult], result[types.ConfirmErrors]) } // Not approved - return BuildFailureURL(res, result[Errors]) + return BuildFailureURL(res, result[types.Errors]) } -func BuildPreauthRedirectURL(result map[string]string) string { - res := result[TransactionResult] - tType := result[TransactionType] +func BuildPreauthRedirectURL(result map[string]string) (string, bool) { + res := result[types.TransactionResult] + tType := result[types.TransactionType] // 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]). + case strings.EqualFold(tType, types.AccountVerificationType): + log.WithField(types.LogResult, result[types.TransactionResult]). Info("Account verification approved") - return buildSuccessURL(result) + return buildSuccessURL(result), false // Transaction type Sale? - case strings.EqualFold(tType, SaleTransactionType): + case strings.EqualFold(tType, types.SaleTransactionType): // Transaction confirmed? - log.WithField(LogResult, result[ConfirmResult]). + log.WithField(types.LogResult, result[types.ConfirmResult]). Info("Amount preauthorized successfully") - return buildSuccessURL(result) + return buildSuccessURL(result), true } } // Not approved - return BuildFailureURL(res, result[Errors]) + return BuildFailureURL(res, result[types.Errors]), false } func buildSuccessURL(result map[string]string) string { q := url.Values{} - q.Set("CardNumber", hex.EncodeToString([]byte(result[PAN_MASKED]))) - q.Set("ExpiryDate", hex.EncodeToString([]byte(result[EXPIRY_DATE]))) - q.Set("TxnReference", result[Reference]) - q.Set("CardHash", hex.EncodeToString([]byte(result[CardHash]))) - q.Set("CardReference", hex.EncodeToString([]byte(result[CardReference]))) + q.Set("CardNumber", hex.EncodeToString([]byte(result[types.PAN_MASKED]))) + q.Set("ExpiryDate", hex.EncodeToString([]byte(result[types.EXPIRY_DATE]))) + 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{ - Path: CheckinSuccessfulEndpoint, + Path: types.CheckinSuccessfulEndpoint, RawQuery: q.Encode(), }).String() } @@ -321,16 +168,16 @@ func BuildFailureURL(msgType, description string) string { description = fmt.Sprintf("Transaction %s", strings.ToLower(msgType)) } if description != "" { - msgType = ResultError + msgType = types.ResultError } - log.WithFields(log.Fields{LogFieldError: msgType, LogFieldDescription: description}). + log.WithFields(log.Fields{types.LogFieldError: msgType, types.LogFieldDescription: description}). Error("Transaction failed") q.Set("MsgType", msgType) q.Set("Description", description) return (&url.URL{ - Path: CheckinUnsuccessfulEndpoint, + Path: types.CheckinUnsuccessfulEndpoint, RawQuery: q.Encode(), }).String() } diff --git a/payment/preauthReleaser.go b/payment/preauthReleaser.go new file mode 100644 index 0000000..b45d3e5 --- /dev/null +++ b/payment/preauthReleaser.go @@ -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) +} diff --git a/release notes.md b/release notes.md index 77448bb..2a6ecda 100644 --- a/release notes.md +++ b/release notes.md @@ -2,6 +2,9 @@ builtVersion is a const in main.go +#### 1.0.28 - 10 December 2025 +added preauth releaser + #### 1.0.27 - 10 December 2025 updated handling AmountTooSmall response from creditcall diff --git a/types/types.go b/types/types.go new file mode 100644 index 0000000..913a634 --- /dev/null +++ b/types/types.go @@ -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 + } +)