From 5e99453eebb16cc15f7fca8c424cbd0df9f124b4 Mon Sep 17 00:00:00 2001 From: yurii Date: Mon, 30 Jun 2025 14:33:05 +0100 Subject: [PATCH] added creditcall payment method --- go.mod | 10 +- go.sum | 34 +++- lockserver/lockservercommon.go | 2 +- main.go | 172 ++++++++++++++++++- payment/creditcall.go | 296 +++++++++++++++++++++++++++++---- payment/paymentcommon.go | 32 ---- printer/printer.go | 155 +++++++++++++++-- release notes.md | 4 + 8 files changed, 619 insertions(+), 86 deletions(-) delete mode 100644 payment/paymentcommon.go diff --git a/go.mod b/go.mod index 663a327..3c4a9c8 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,19 @@ module gitea.futuresens.co.uk/futuresens/hardlink go 1.23.2 require ( - gitea.futuresens.co.uk/futuresens/cmstypes v1.0.171 + gitea.futuresens.co.uk/futuresens/cmstypes v1.0.179 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 github.com/sirupsen/logrus v1.9.3 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 golang.org/x/image v0.27.0 gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/sys v0.32.0 // indirect +require ( + github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + golang.org/x/crypto v0.3.0 // indirect + golang.org/x/sys v0.32.0 // indirect +) diff --git a/go.sum b/go.sum index 44d8fec..06f59df 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,26 @@ -gitea.futuresens.co.uk/futuresens/cmstypes v1.0.171 h1:/WM3mG5i4VYspeLaGFwjuvQJHM/Pks/dN3RjhrmYaN0= -gitea.futuresens.co.uk/futuresens/cmstypes v1.0.171/go.mod h1:ABMUkdm+3VGrkuoCJsXMfPPud9GHDOwBb1NiifFqxes= +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/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= gitea.futuresens.co.uk/futuresens/logging v1.0.9/go.mod h1:pepS4+sreKTXJUp1Dq2RunpvQ0oY3vU2AuYjMTZzVQo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= +github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb h1:OzF7h5OJLiB2QvpxfFdUFdSedYYsEKAXnE8BwsWQPmY= github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb/go.mod h1:aeB9oSJ1VNJXxBkCz6Krw3aW8lPx6rkWnW/hXcoujR4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw= +github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -19,16 +31,34 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lockserver/lockservercommon.go b/lockserver/lockservercommon.go index fe4c14e..994a4f6 100644 --- a/lockserver/lockservercommon.go +++ b/lockserver/lockservercommon.go @@ -29,7 +29,7 @@ type ( } OmniLockServer struct { - encoderAddr string // Encoder address for the lock server + encoderAddr string // Encoder unit address command []byte // Command to be sent to the lock server } ) diff --git a/main.go b/main.go index 5fa8c11..9c7011d 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,18 @@ package main import ( + "database/sql" "encoding/json" "encoding/xml" "fmt" + "io" "net" "net/http" "os" + "os/exec" + "os/signal" "strings" + "syscall" "time" "github.com/tarm/serial" @@ -17,6 +22,7 @@ import ( "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/cmstypes" @@ -24,9 +30,10 @@ import ( ) const ( - buildVersion = "0.9.2" - serviceName = "hardlink" - customLayout = "2006-01-02 15:04:05 -0700" + buildVersion = "1.0.0" + serviceName = "hardlink" + customLayout = "2006-01-02 15:04:05 -0700" + transactionUrl = "http://127.0.0.1:18181/start-transaction/" ) // configRec holds values from config.yml. @@ -39,6 +46,10 @@ type configRec struct { 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 } // DoorCardRequest is the JSON payload for /issue-door-card. @@ -54,13 +65,15 @@ type App struct { dispPort *serial.Port lockConn net.Conn lockserver lockserver.LockServer + db *sql.DB } -func newApp(dispPort *serial.Port, lockConn net.Conn, config configRec) *App { +func newApp(dispPort *serial.Port, lockConn net.Conn, config configRec, db *sql.DB) *App { return &App{ - dispPort: dispPort, - lockConn: lockConn, + dispPort: dispPort, + lockConn: lockConn, lockserver: lockserver.NewLockServer(config.LockType, config.EncoderAddress, fatalError), + db: db, } } @@ -107,8 +120,69 @@ func main() { defer lockConn.Close() log.Infof("Connected to lock server at %s", config.LockserverUrl) + db, err := payment.InitMSSQL(config.dbport, config.dbname, config.dbuser, config.dbpassword) + if err != nil { + fatalError(fmt.Errorf("DB init failed: %v", err)) + } + defer db.Close() + + startClient := func() (*exec.Cmd, error) { + cmd := exec.Command("./ChipDNAClient/ChipDnaClient.exe") + err := cmd.Start() + if err != nil { + return nil, fmt.Errorf("Failed to start ChipDnaClient: %v", err) + } + log.Infof("ChipDnaClient started with PID %d", cmd.Process.Pid) + return cmd, nil + } + + cmd, err := startClient() + if err != nil { + fatalError(err) + } + + // Restart loop + go func() { + for { + err := cmd.Wait() + if err != nil { + log.Errorf("ChipDnaClient exited unexpectedly: %v", err) + time.Sleep(2 * time.Second) + cmd, err = startClient() + if err != nil { + log.Errorf("Restart failed: %v", err) + return + } + log.Info("ChipDnaClient restarted successfully") + } + } + }() + + // Handle shutdown signals + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigs + log.Info("Shutting down...") + if cmd.Process != nil { + log.Info("Sending SIGTERM to ChipDnaClient...") + _ = cmd.Process.Signal(syscall.SIGTERM) + // wait up to 5s for graceful shutdown + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-time.After(5 * time.Second): + log.Warn("ChipDnaClient did not exit in time, killing...") + _ = cmd.Process.Kill() + case err := <-done: + log.Infof("ChipDnaClient exited cleanly: %v", err) + } + } + os.Exit(0) + }() + // Create App and wire routes - app := newApp(dispHandle, lockConn, config) + app := newApp(dispHandle, lockConn, config, db) mux := http.NewServeMux() setUpRoutes(app, mux) @@ -124,6 +198,7 @@ func main() { func setUpRoutes(app *App, mux *http.ServeMux) { mux.HandleFunc("/issuedoorcard", app.issueDoorCard) mux.HandleFunc("/printroomticket", app.printRoomTicket) + mux.HandleFunc("/starttransaction", app.startTransaction) } func fatalError(err error) { @@ -167,6 +242,87 @@ func writeError(w http.ResponseWriter, status int, msg string) { json.NewEncoder(w).Encode(theResponse) } +func (app *App) startTransaction(w http.ResponseWriter, r *http.Request) { + const op = logging.Op("startTransaction") + var ( + theResponse cmstypes.ResponseRec + cardholderReceipt string + ) + + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Content-Type", "application/json") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + log.Println("startTransaction called") + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST") + return + } + defer r.Body.Close() + + if ct := r.Header.Get("Content-Type"); ct != "text/xml" { + writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be text/xml") + return + } + + client := &http.Client{Timeout: 90 * time.Second} + response, err := client.Post(transactionUrl, "text/xml", r.Body) + if err != nil { + logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0) + writeError(w, http.StatusInternalServerError, "Payment processing failed: "+err.Error()) + 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) + writeError(w, http.StatusInternalServerError, "Failed to read response body: "+err.Error()) + return + } + + responseEntries, _ := payment.ParseTransactionResult(body) + + // Compose JSON from responseEntries + result := make(map[string]string) + for _, e := range responseEntries { + switch e.Key { + case "RECEIPT_DATA", "RECEIPT_DATA_MERCHANT": + case "RECEIPT_DATA_CARDHOLDER": + cardholderReceipt = e.Value + case "TRANSACTION_RESULT": + 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 { + log.Errorf("PrintCardholderReceipt error: %v", err) + } + + // Insert into DB + if err := payment.InsertTransactionRecord(r.Context(), app.db, result); err != nil { + log.Errorf("DB insert error: %v", err) + } + + theResponse.Data = payment.BuildRedirectURL(result) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(theResponse); err != nil { + logging.Error(serviceName, err.Error(), "JSON encode error", string(op), "", "", 0) + } +} + func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) { const op = logging.Op("issueDoorCard") var ( @@ -266,7 +422,7 @@ func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) { 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") - + if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return diff --git a/payment/creditcall.go b/payment/creditcall.go index 960fd0e..be842a2 100644 --- a/payment/creditcall.go +++ b/payment/creditcall.go @@ -1,42 +1,280 @@ package payment import ( + "context" + "database/sql" + "encoding/xml" "fmt" - "net" + "net/url" + "strconv" + "strings" "time" + + _ "github.com/denisenkom/go-mssqldb" + log "github.com/sirupsen/logrus" ) -// StartTransaction sends a start transaction XML to ChipDNA Server -func (pc *PaymentClient) StartTransaction(amountMinorUnits int, reference string) (string, error) { - conn, err := net.Dial("tcp", pc.Addr) - if err != nil { - return "", fmt.Errorf("failed to connect: %w", err) - } - defer conn.Close() +const ( + ResultApproved = "approved" + ResultDeclined = "declined" + ResultCancelled = "cancelled" + ResultPending = "pending" + ResultError = "error" + CheckinSuccessfulEndpoint = "/successful" // Endpoint to send guest to after successful payment + CheckinUnsuccessfulEndpoint = "/unsuccessful" +) - // Set timeout - conn.SetDeadline(time.Now().Add(15 * time.Second)) - - // Format XML request - request := fmt.Sprintf(` - - %d - Actual - %s - Sale -`, amountMinorUnits, reference) - - _, err = conn.Write([]byte(request)) - if err != nil { - return "", fmt.Errorf("failed to write to server: %w", err) +// XML parsing structs +type ( + TransactionRec struct { + XMLName xml.Name `xml:"TransactionPayload"` + AmountMinorUnits string `xml:"amount"` + TransactionType string `xml:"transactionType"` } - // Read response - buff := make([]byte, 4096) - n, err := conn.Read(buff) - if err != nil { - return "", fmt.Errorf("failed to read from server: %w", err) + TransactionResultXML struct { + XMLName xml.Name `xml:"TransactionResult"` + Entries []EntryXML `xml:"Entry"` } - return string(buff[:n]), nil + EntryXML struct { + Key string `xml:"Key"` + Value string `xml:"Value"` + } +) + +// ParseTransactionResult parses the XML into entries. +func ParseTransactionResult(data []byte) ([]EntryXML, error) { + var tr TransactionResultXML + if err := xml.Unmarshal(data, &tr); err != nil { + return nil, fmt.Errorf("XML unmarshal: %w", err) + } + return tr.Entries, 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 + } + } + + // 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 +} + +func BuildRedirectURL(result map[string]string) string { + // 1) normalize the result code + res := strings.ToLower(result["TRANSACTION_RESULT"]) + + // 2) pick base path and optional error params + var basePath string + var msgType, description string + + switch res { + case ResultApproved: + basePath = CheckinSuccessfulEndpoint + + case ResultDeclined: + basePath = CheckinUnsuccessfulEndpoint + msgType = "declined" + description = "payment declined" + + case ResultCancelled: + basePath = CheckinUnsuccessfulEndpoint + msgType = "cancelled" + description = "payment cancelled by customer" + + case ResultPending: + // you could choose to treat pending as unsuccessful or special-case it + basePath = CheckinUnsuccessfulEndpoint + msgType = "pending" + description = "payment pending" + case ResultError: + basePath = CheckinUnsuccessfulEndpoint + msgType = "error" + description = result["ERROR"] + + default: + basePath = CheckinUnsuccessfulEndpoint + msgType = "error" + description = "unknown transaction result" + } + + if msgType != "" { + log.Warnf("Transaction %s: %s - %s", res, msgType, description) + } + + // 3) build query params + q := url.Values{} + q.Set("TxnReference", result["REFERENCE"]) + q.Set("CardHash", result["CARD_HASH"]) + q.Set("CardReference", result["CARD_REFERENCE"]) + + // only append these when non-approved + if msgType != "" { + q.Set("MsgType", msgType) + q.Set("Description", description) + } + + // 4) assemble final URL + // note: url.URL automatically escapes values in RawQuery + u := url.URL{ + Path: basePath, + RawQuery: q.Encode(), + } + return u.String() } diff --git a/payment/paymentcommon.go b/payment/paymentcommon.go deleted file mode 100644 index ee3be6a..0000000 --- a/payment/paymentcommon.go +++ /dev/null @@ -1,32 +0,0 @@ -package payment - -import ( - "net" - "net/url" - "fmt" - "strings" -) - -// PaymentClient holds connection data -type PaymentClient struct { - Addr string // e.g., "127.0.0.1:1869" -} - -func InitializeConnection(paymentrUrl string) (net.Conn, error) { - const funcName = "InitializeServerConnection" - // Parse the URL to extract host and port - parsedUrl, err := url.Parse(paymentrUrl) - if err != nil { - return nil, fmt.Errorf("[%s] failed to parse LockserverUrl: %v", funcName, err) - } - - // Remove any leading/trailing slashes just in case - address := strings.Trim(parsedUrl.Host, "/") - - // Establish a TCP connection to the Visionline server - conn, err := net.Dial("tcp", address) - if err != nil { - return nil, fmt.Errorf("failed to connect to payment server: %v", err) - } - return conn, nil -} \ No newline at end of file diff --git a/printer/printer.go b/printer/printer.go index 3ad6763..e41114b 100644 --- a/printer/printer.go +++ b/printer/printer.go @@ -5,13 +5,13 @@ import ( "encoding/xml" "fmt" "image" - "image/color" "image/draw" _ "image/jpeg" _ "image/png" "os" "path/filepath" + "strings" "github.com/alexbrainman/printer" log "github.com/sirupsen/logrus" @@ -19,6 +19,21 @@ import ( ) type ( + CardholderReceipt struct { + XMLName xml.Name `xml:"ReceiptData"` + ReceiptEntries struct { + ReceiptEntry []ReceiptEntryXML `xml:"ReceiptEntry"` + } `xml:"ReceiptEntries"` + } + + ReceiptEntryXML struct { + ReceiptEntryId string `xml:"ReceiptEntryId"` + Value string `xml:"Value"` + Label string `xml:"Label"` + ReceiptItemType string `xml:"ReceiptItemType"` + Priority string `xml:"Priority"` + } + RoomDetailsRec struct { XMLName xml.Name `xml:"roomdetails"` Name string `xml:"customername"` @@ -54,11 +69,30 @@ type ( } ) +const ( + ESC = 0x1B + GS = 0x1D + CENTER = 0x01 + BOLD_ON = 0x01 + BOLD_OFF = 0x00 + LARGE_FONT = 0x11 + WIDER_FONT = 0x10 + NORMAL_FONT = 0x00 +) + var ( Layout LayoutOptions PrinterName string ) +func ParseCardholderReceipt(data []byte) ([]ReceiptEntryXML, error) { + var tr CardholderReceipt + if err := xml.Unmarshal(data, &tr); err != nil { + return nil, fmt.Errorf("XML unmarshal: %w", err) + } + return tr.ReceiptEntries.ReceiptEntry, nil +} + func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) { var buf bytes.Buffer @@ -66,17 +100,6 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) { write := func(b []byte) { buf.Write(b) } writeStr := func(s string) { buf.WriteString(s) } - const ( - ESC = 0x1B - GS = 0x1D - CENTER = 0x01 - BOLD_ON = 0x01 - BOLD_OFF = 0x00 - LARGE_FONT = 0x11 - WIDER_FONT = 0x10 - NORMAL_FONT = 0x00 - ) - // 0) Hotel logo at top logoBytes, err := printLogo(Layout.LogoPath) if err != nil { @@ -144,6 +167,114 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) { return buf.Bytes(), nil } +func PrintCardholderReceipt(cardholderReceipt string) error { + receiptEntries, err := ParseCardholderReceipt([]byte(cardholderReceipt)) + if err != nil { + return fmt.Errorf("ParseCardholderReceipt: %w", err) + } + + data, err := BuildCardholderReceipt(receiptEntries) + if err != nil { + return fmt.Errorf("BuildCardholderReceipt: %w", err) + } + + // Send to the Windows Epson TM-T82II via the printer package + if err := SendToPrinter(data); err != nil { + return fmt.Errorf("SendToPrinter: %w", err) + } + log.Info("Cardholder receipt printed successfully") + return nil +} + +func BuildCardholderReceipt(entries []ReceiptEntryXML) ([]byte, error) { + var buf bytes.Buffer + + write := func(b []byte) { buf.Write(b) } + // writeStr := func(s string) { buf.WriteString(s) } + writeln := func(s string) { + buf.WriteString(s) + buf.WriteByte('\n') + } + + // Build a lookup map by ReceiptEntryId + m := make(map[string]ReceiptEntryXML, len(entries)) + for _, e := range entries { + m[e.ReceiptEntryId] = e + } + + // Utility to center text + // center := func(s string) { + // write([]byte{ESC, 'a', CENTER}) + // writeln(s) + // write([]byte{ESC, 'a', 0}) + // } + + // Utility for bold lines + // boldOn := func() { write([]byte{GS, '!', WIDER_FONT}) } + // boldOff := func() { write([]byte{GS, '!', NORMAL_FONT}) } + + // boldOn() + + // 1) Header: CARDHOLDER COPY + writeln(m["Recipient"].Value) + // writeStr("\n\n") + // 2) Merchant name + writeln(m["MerchantName"].Value) + // writeStr("\n\n") + // 3) AID + writeln("AID: " + m["Aid"].Value) + + // 4) Card scheme & pan + writeln(fmt.Sprintf("%s Card: %s", m["CardScheme"].Value, m["PanMasked"].Value)) + + // 5) PAN Seq No + writeln("PAN Seq No: " + m["PanSequence"].Value) + + // 6) Transaction source + writeln(m["TransactionSource"].Value) + + // 7) Transaction type (Sale/Account Verification) + writeln(m["TransactionType"].Value) + + // 8) Total + // assuming value like "GBP1.50" — insert space after currency + total := m["TotalAmount"].Value + if !strings.HasPrefix(total, "GBP") && len(total) > 3 { + total = total[:3] + " " + total[3:] + } + writeln("TOTAL: " + total) + + // 9) Cardholder verification + writeln(m["CardholderVerification"].Value) + + // 10) Approved/Declined + writeln(m["TransactionResult"].Value) + + // 11) Auth code + writeln("Auth Code: " + m["AuthCode"].Value) + + // 12) Reference + writeln("Ref: " + m["AuthReference"].Value) + + // 13) Merchant & terminal IDs + writeln("MID: " + m["MerchantIdMasked"].Value) + writeln("TID: " + m["TerminalIdMasked"].Value) + + // 14) Date/time + writeln(m["AuthDateTime"].Value) + + // 15) Retention message + writeln(m["RetentionMessage"].Value) + + // boldOff() + + // finally feed & cut + write([]byte{ESC, 'd', 7}) // Feed 5 lines + write([]byte{GS, 'V', 1}) + + return buf.Bytes(), nil +} + func printLogo(path string) ([]byte, error) { const maxLogoWidth = 384 f, err := os.Open(path) diff --git a/release notes.md b/release notes.md index 0919f17..c2a5902 100644 --- a/release notes.md +++ b/release notes.md @@ -2,6 +2,10 @@ builtVersion is a const in main.go +#### 1.0.0 - 30 Jun 2024 +added creditcall payment method + `/starttransaction` - API payment endpoint to start a transaction + #### 0.9.1 - 22 May 2024 added lockserver interface and implemented workflow for Omnitec