added creditcall payment method

This commit is contained in:
yurii 2025-06-30 14:33:05 +01:00
parent b002c80b01
commit 5e99453eeb
8 changed files with 619 additions and 86 deletions

10
go.mod
View File

@ -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
)

34
go.sum
View File

@ -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=

View File

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

172
main.go
View File

@ -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

View File

@ -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(`
<StartTransaction>
<Amount>%d</Amount>
<AmountType>Actual</AmountType>
<Reference>%s</Reference>
<TransactionType>Sale</TransactionType>
</StartTransaction>`, 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()
}

View File

@ -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
}

View File

@ -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)

View File

@ -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