Compare commits
24 Commits
preauth
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d9c03837 | |||
| f5ff78e60e | |||
| ffd814e076 | |||
| b982698ccd | |||
| af151fd389 | |||
| 20fcbe5499 | |||
| c6c2b40f37 | |||
| 8f093159cd | |||
| e1549dda2f | |||
| 163ac0e808 | |||
| 19cfbf185b | |||
| 48e2b6f568 | |||
| eb80332c5a | |||
| aeea755045 | |||
| ebe50b17a9 | |||
| 4a255c06ed | |||
| 9f0a9c939f | |||
| 7f6262b470 | |||
| 895849376e | |||
| 43f1e8787f | |||
| 7941e2065e | |||
| b59d77a373 | |||
| a4885be458 | |||
| 89dfa28e6f |
2
.gitignore
vendored
2
.gitignore
vendored
@ -42,6 +42,6 @@ _cgo_export.*
|
||||
|
||||
_testmain.go
|
||||
|
||||
*.exe
|
||||
*.exe*
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
318
cmd/hardlink/main.go
Normal file
318
cmd/hardlink/main.go
Normal file
@ -0,0 +1,318 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/tarm/serial"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/cms"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/bootstrap"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/dispenser"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/dojo"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/handlers"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/logging"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paybridge"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/printer"
|
||||
)
|
||||
|
||||
const (
|
||||
buildVersion = "1.3.0"
|
||||
serviceName = "hardlink"
|
||||
pollingFrequency = 8 * time.Second
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load config
|
||||
cfg := config.ReadHardlinkConfig()
|
||||
printer.Layout = readTicketLayout()
|
||||
printer.PrinterName = cfg.PrinterName
|
||||
lockserver.Cert = cfg.Cert
|
||||
lockserver.LockServerURL = cfg.LockserverURL
|
||||
mail.SendErrorEmails = cfg.SendErrorEmails
|
||||
|
||||
// Root context for background goroutines
|
||||
// rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
// defer rootCancel()
|
||||
|
||||
var (
|
||||
dispPort *serial.Port
|
||||
disp *dispenser.Client
|
||||
cardWellStatus string
|
||||
)
|
||||
|
||||
// Setup logging and get file handle
|
||||
logFile, err := logging.SetupLogging(cfg.LogDir, serviceName, buildVersion)
|
||||
if err != nil {
|
||||
log.Printf("Failed to set up logging: %v\n", err)
|
||||
}
|
||||
if logFile != nil {
|
||||
defer logFile.Close()
|
||||
}
|
||||
|
||||
// Initialize dispenser
|
||||
if !cfg.TestMode {
|
||||
dispenser.SerialPort = cfg.DispenserPort
|
||||
dispenser.Address = []byte(cfg.DispenserAdrr)
|
||||
|
||||
dispPort, err = dispenser.InitializeDispenser()
|
||||
if err != nil {
|
||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Dispenser Initialization Error", fmt.Errorf("failed to initialize dispenser: %v", err))
|
||||
}
|
||||
defer dispPort.Close()
|
||||
|
||||
disp = dispenser.NewClient(dispPort, 32)
|
||||
defer disp.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cardWellStatus, err = disp.DispenserPrepare(ctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("%s; wrong dispenser address: %s", err, cfg.DispenserAdrr)
|
||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Dispenser Preparation Error", err)
|
||||
}
|
||||
fmt.Println(cardWellStatus)
|
||||
}
|
||||
|
||||
// Test lock-server connection
|
||||
switch strings.ToLower(cfg.LockType) {
|
||||
case lockserver.TLJ:
|
||||
// TLJ uses HTTP - skip TCP probe here
|
||||
default:
|
||||
lockConn, err := lockserver.InitializeServerConnection(cfg.LockserverURL)
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
log.Errorf(err.Error())
|
||||
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "Lock Server Connection Error", err.Error())
|
||||
} else {
|
||||
fmt.Printf("Connected to the lock server successfuly at %s\n", cfg.LockserverURL)
|
||||
log.Infof("Connected to the lock server successfuly at %s", cfg.LockserverURL)
|
||||
lockConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
database, err := bootstrap.OpenDB(&cfg)
|
||||
if err != nil {
|
||||
log.Warnf("DB init failed: %v", err)
|
||||
}
|
||||
if database != nil {
|
||||
defer database.Close()
|
||||
}
|
||||
|
||||
// Create App and wire routes
|
||||
app := handlers.NewApp(disp, cfg.LockType, cfg.EncoderAddress, cardWellStatus, database, &cfg)
|
||||
|
||||
if cfg.IsPayment {
|
||||
fmt.Println("Payment processing is enabled")
|
||||
log.Info("Payment processing is enabled")
|
||||
|
||||
var provider paymentsvc.Provider
|
||||
reservationSystem, err := cms.ReadHotel(cfg.Hotel, cfg.CMSBaseURL)
|
||||
if err != nil {
|
||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Failed to read hotel from CMS", fmt.Errorf("failed to read hotel from CMS: %v", err))
|
||||
}
|
||||
|
||||
paymentProvider := reservationSystem.PaymentSystem
|
||||
if paymentProvider == 0 {
|
||||
paymentProvider = cms.PaymentSystemIndex(cfg.PaymentProvider)
|
||||
}
|
||||
|
||||
switch paymentProvider {
|
||||
case cmstypes.PaySystemCreditCall:
|
||||
// CreditCall keeps using the existing /takepayment and /takepreauth endpoints.
|
||||
startChipDnaClient()
|
||||
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
|
||||
pdqStatus, err := creditcall.ReadPdqStatus(cfg.Hotel, cfg.Kiosk)
|
||||
if err != nil {
|
||||
mail.SendEmailOnError(cfg.Hotel, cfg.Kiosk, "PDQ Status Read Error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\nPDQ available: %v\n", pdqStatus.IsAvailable)
|
||||
log.Infof("PDQ available: %v", pdqStatus.IsAvailable)
|
||||
}()
|
||||
|
||||
log.Info("CreditCall payment provider enabled")
|
||||
fmt.Println("CreditCall payment provider enabled")
|
||||
|
||||
case cmstypes.PaySystemPayBridge:
|
||||
if reservationSystem.PaymentGatewayURL == "" {
|
||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Payment Gateway URL not found", fmt.Errorf("payment provider paybridge requires paybridge.websocket_url"))
|
||||
}
|
||||
if reservationSystem.PaymentGatewayAPIKeyWeb == "" {
|
||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Payment Gateway API Key not found", fmt.Errorf("payment provider paybridge requires paybridge.api_key"))
|
||||
}
|
||||
|
||||
provider = paybridge.NewClient(
|
||||
reservationSystem.PaymentGatewayURL,
|
||||
reservationSystem.PaymentGatewayAPIKeyWeb,
|
||||
cfg.TimeoutSeconds,
|
||||
)
|
||||
|
||||
case cmstypes.PaySystemDojo:
|
||||
var TerminalID string
|
||||
for _, item := range reservationSystem.PDQs {
|
||||
if item.Kiosk != cfg.Kiosk {
|
||||
continue
|
||||
}
|
||||
TerminalID = item.Serial
|
||||
break
|
||||
}
|
||||
|
||||
dojoClient, err := dojo.NewClient(dojo.Config{
|
||||
BaseURL: reservationSystem.PaymentGatewayURL,
|
||||
APIKey: reservationSystem.PaymentGatewayAPIKeyWeb,
|
||||
SoftwareHouseID: reservationSystem.PaymentGatewaySiteIDEPOS,
|
||||
Version: reservationSystem.PaymentGatewayPasswordEPOS,
|
||||
TerminalID: TerminalID,
|
||||
})
|
||||
if err != nil {
|
||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Failed to create Dojo client", err)
|
||||
}
|
||||
|
||||
provider = dojoClient
|
||||
|
||||
case cmstypes.PaySystemNone:
|
||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "No payment provider selected", fmt.Errorf("payment processing is enabled, but no payment provider selected; expected creditcall, paybridge or dojo"))
|
||||
|
||||
default:
|
||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Unsupported Payment Provider", fmt.Errorf(
|
||||
"unsupported payment provider %q; expected creditcall, paybridge or dojo",
|
||||
cfg.PaymentProvider,
|
||||
))
|
||||
}
|
||||
|
||||
// Only PayBridge and Dojo use POST /api/payment/sale.
|
||||
if provider != nil {
|
||||
app.SetPaymentService(paymentsvc.NewService(provider))
|
||||
log.Infof("Payment provider enabled for POST /api/payment/sale: %s", cfg.PaymentProvider)
|
||||
fmt.Printf("Payment provider enabled for POST /api/payment/sale: %s\n", cfg.PaymentProvider)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Payment processing is disabled")
|
||||
log.Info("Payment processing is disabled")
|
||||
}
|
||||
|
||||
// Update cardWellStatus when dispenser status changes
|
||||
if !cfg.TestMode && disp != nil {
|
||||
// Set initial cardWellStatus
|
||||
app.SetCardWellStatus(cardWellStatus)
|
||||
|
||||
// Set up callback to update cardWellStatus when dispenser status changes
|
||||
disp.OnStockUpdate(func(stock string) {
|
||||
app.SetCardWellStatus(stock)
|
||||
})
|
||||
|
||||
// Start polling for dispenser status every 10 seconds
|
||||
disp.StartPolling(pollingFrequency)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
app.RegisterRoutes(mux)
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
log.Infof("Starting HTTP server on http://localhost%s", addr)
|
||||
fmt.Printf("Starting HTTP server on http://localhost%s", addr)
|
||||
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
errorhandlers.FatalError(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readTicketLayout() printer.LayoutOptions {
|
||||
const layoutName = "TicketLayout.xml"
|
||||
var layout printer.LayoutOptions
|
||||
|
||||
// 1) Read the file
|
||||
data, err := os.ReadFile(layoutName)
|
||||
if err != nil {
|
||||
errorhandlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err))
|
||||
}
|
||||
|
||||
// 2) Unmarshal into your struct
|
||||
if err := xml.Unmarshal(data, &layout); err != nil {
|
||||
errorhandlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err))
|
||||
}
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
func startChipDnaClient() {
|
||||
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 {
|
||||
errorhandlers.FatalError(err)
|
||||
}
|
||||
|
||||
// Restart loop
|
||||
go func() {
|
||||
for {
|
||||
err := cmd.Wait()
|
||||
if err != nil {
|
||||
log.Errorf("ChipDnaClient exited unexpectedly: %v", err)
|
||||
fmt.Printf("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")
|
||||
fmt.Printf("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)
|
||||
}()
|
||||
}
|
||||
120
cms/cms.go
Normal file
120
cms/cms.go
Normal file
@ -0,0 +1,120 @@
|
||||
// Package cms provides functions to read hotel records from the CMS and retrieve their reservation system configuration.
|
||||
package cms
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
)
|
||||
|
||||
// ReadHotel gets the hotel record from CMS and returns its reservation system
|
||||
// configuration if the record has been updated since the last read.
|
||||
func ReadHotel(hotelCode, CMSBaseURL string) (cmstypes.ReservationSystemRec, error) {
|
||||
var reservationSystem cmstypes.ReservationSystemRec
|
||||
|
||||
if hotelCode == "" {
|
||||
return reservationSystem, errors.New("hotel code is empty")
|
||||
}
|
||||
|
||||
if CMSBaseURL == "" {
|
||||
return reservationSystem, errors.New("CMS base URL is empty")
|
||||
}
|
||||
|
||||
var request cmstypes.RequestRec
|
||||
|
||||
request.Auth.ID = hotelCode
|
||||
request.Auth.APIKey = cmstypes.APIKey
|
||||
request.Auth.Hotel = hotelCode
|
||||
request.Data = hotelCode
|
||||
|
||||
requestData, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return reservationSystem, fmt.Errorf(
|
||||
"marshal CMS hotel request: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
requestURL := CMSBaseURL + cmstypes.APIHotelDetails
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, requestURL, bytes.NewReader(requestData))
|
||||
if err != nil {
|
||||
return reservationSystem, fmt.Errorf(
|
||||
"create CMS hotel request: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return reservationSystem, fmt.Errorf(
|
||||
"perform CMS hotel request: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return reservationSystem, fmt.Errorf(
|
||||
"read CMS hotel response: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if resp.StatusCode < http.StatusOK ||
|
||||
resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return reservationSystem, fmt.Errorf(
|
||||
"CMS hotel request returned HTTP %s: %s",
|
||||
resp.Status,
|
||||
string(body),
|
||||
)
|
||||
}
|
||||
|
||||
var hotelResponse cmstypes.HotelResponseRec
|
||||
|
||||
if err := json.Unmarshal(body, &hotelResponse); err != nil {
|
||||
return reservationSystem, fmt.Errorf(
|
||||
"unmarshal CMS hotel response: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if hotelResponse.Status.Code != cmstypes.StatusSuccessCode {
|
||||
return reservationSystem, fmt.Errorf(
|
||||
"CMS hotel request failed: %s",
|
||||
hotelResponse.Status.Message,
|
||||
)
|
||||
}
|
||||
|
||||
if hotelResponse.TheHotel.Updated <= -1 {
|
||||
return reservationSystem, errors.New(
|
||||
"hotel record not updated since last read",
|
||||
)
|
||||
}
|
||||
|
||||
return hotelResponse.TheHotel.ReservationSystem, nil
|
||||
}
|
||||
|
||||
func PaymentSystemIndex(name string) int {
|
||||
for i, paySystemName := range cmstypes.PaySystemNames {
|
||||
if strings.EqualFold(paySystemName, name) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
233
cms/cms_test.go
Normal file
233
cms/cms_test.go
Normal file
@ -0,0 +1,233 @@
|
||||
package cms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
)
|
||||
|
||||
func TestReadHotelSuccess(t *testing.T) {
|
||||
const hotelCode = "gb-test-hotel"
|
||||
|
||||
expected := cmstypes.ReservationSystemRec{}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf(
|
||||
"expected method %s, got %s",
|
||||
http.MethodPost,
|
||||
r.Method,
|
||||
)
|
||||
}
|
||||
|
||||
if r.URL.Path != cmstypes.APIHotelDetails {
|
||||
t.Errorf(
|
||||
"expected path %q, got %q",
|
||||
cmstypes.APIHotelDetails,
|
||||
r.URL.Path,
|
||||
)
|
||||
}
|
||||
|
||||
if contentType := r.Header.Get("Content-Type"); contentType != "application/json" {
|
||||
t.Errorf(
|
||||
"expected Content-Type application/json, got %q",
|
||||
contentType,
|
||||
)
|
||||
}
|
||||
|
||||
if accept := r.Header.Get("Accept"); accept != "application/json" {
|
||||
t.Errorf(
|
||||
"expected Accept application/json, got %q",
|
||||
accept,
|
||||
)
|
||||
}
|
||||
|
||||
var request cmstypes.RequestRec
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if request.Auth.ID != hotelCode {
|
||||
t.Errorf(
|
||||
"expected auth ID %q, got %q",
|
||||
hotelCode,
|
||||
request.Auth.ID,
|
||||
)
|
||||
}
|
||||
|
||||
if request.Auth.Hotel != hotelCode {
|
||||
t.Errorf(
|
||||
"expected auth hotel %q, got %q",
|
||||
hotelCode,
|
||||
request.Auth.Hotel,
|
||||
)
|
||||
}
|
||||
|
||||
if request.Auth.APIKey != cmstypes.APIKey {
|
||||
t.Errorf(
|
||||
"expected API key %q, got %q",
|
||||
cmstypes.APIKey,
|
||||
request.Auth.APIKey,
|
||||
)
|
||||
}
|
||||
|
||||
if request.Data != hotelCode {
|
||||
t.Errorf(
|
||||
"expected request data %q, got %q",
|
||||
hotelCode,
|
||||
request.Data,
|
||||
)
|
||||
}
|
||||
|
||||
response := cmstypes.HotelResponseRec{}
|
||||
response.Status.Code = cmstypes.StatusSuccessCode
|
||||
response.TheHotel.Updated = 1
|
||||
response.TheHotel.ReservationSystem = expected
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
t.Errorf("encode response: %v", err)
|
||||
}
|
||||
},
|
||||
))
|
||||
defer server.Close()
|
||||
|
||||
got, err := ReadHotel(hotelCode, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadHotel returned an unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, expected) {
|
||||
t.Errorf(
|
||||
"unexpected reservation system:\ngot: %+v\nwant: %+v",
|
||||
got,
|
||||
expected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHotelValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hotelCode string
|
||||
cmsBaseURL string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "empty hotel code",
|
||||
hotelCode: "",
|
||||
cmsBaseURL: "http://example.com",
|
||||
wantMessage: "hotel code is empty",
|
||||
},
|
||||
{
|
||||
name: "empty CMS base URL",
|
||||
hotelCode: "gb-test-hotel",
|
||||
cmsBaseURL: "",
|
||||
wantMessage: "CMS base URL is empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := ReadHotel(test.hotelCode, test.cmsBaseURL)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if err.Error() != test.wantMessage {
|
||||
t.Errorf(
|
||||
"expected error %q, got %q",
|
||||
test.wantMessage,
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHotelNotUpdated(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
response := cmstypes.HotelResponseRec{}
|
||||
response.Status.Code = cmstypes.StatusSuccessCode
|
||||
response.TheHotel.Updated = -1
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
t.Errorf("encode response: %v", err)
|
||||
}
|
||||
},
|
||||
))
|
||||
defer server.Close()
|
||||
|
||||
_, err := ReadHotel("gb-test-hotel", server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
const expected = "hotel record not updated since last read"
|
||||
|
||||
if err.Error() != expected {
|
||||
t.Errorf("expected error %q, got %q", expected, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHotelHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
if _, err := io.WriteString(w, "CMS unavailable"); err != nil {
|
||||
t.Errorf("write response: %v", err)
|
||||
}
|
||||
},
|
||||
))
|
||||
defer server.Close()
|
||||
|
||||
_, err := ReadHotel("gb-test-hotel", server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "HTTP 500 Internal Server Error") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "CMS unavailable") {
|
||||
t.Errorf("expected response body in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHotelInvalidJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if _, err := io.WriteString(w, `{invalid JSON`); err != nil {
|
||||
t.Errorf("write response: %v", err)
|
||||
}
|
||||
},
|
||||
))
|
||||
defer server.Close()
|
||||
|
||||
_, err := ReadHotel("gb-test-hotel", server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "unmarshal CMS hotel response") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
103
config/config.go
Normal file
103
config/config.go
Normal file
@ -0,0 +1,103 @@
|
||||
// Package config handles reading and parsing configuration from config.yml.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
|
||||
log "github.com/sirupsen/logrus"
|
||||
yaml "gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ConfigRec holds values from config.yml.
|
||||
type ConfigRec struct {
|
||||
Port int `yaml:"port"`
|
||||
LockserverURL string `yaml:"lockservUrl"`
|
||||
LockType string `yaml:"lockType"`
|
||||
EncoderAddress string `yaml:"encoderAddr"`
|
||||
Cert string `yaml:"cert"`
|
||||
DispenserPort string `yaml:"dispensPort"`
|
||||
DispenserAdrr string `yaml:"dispensAddr"`
|
||||
PrinterName string `yaml:"printerName"`
|
||||
LogDir string `yaml:"logdir"`
|
||||
Dbport int `yaml:"dbport"`
|
||||
Dbname string `yaml:"dbname"`
|
||||
Dbuser string `yaml:"dbuser"`
|
||||
Dbpassword string `yaml:"dbpassword"`
|
||||
CMSBaseURL string `yaml:"cmsurl"`
|
||||
IsPayment bool `yaml:"isPayment"`
|
||||
TestMode bool `yaml:"testMode"`
|
||||
Hotel string `yaml:"hotel"`
|
||||
Kiosk int `yaml:"kiosk"`
|
||||
SendErrorEmails []string `yaml:"senderroremails"`
|
||||
PaymentProvider string `yaml:"paymentProvider"`
|
||||
TimeoutSeconds int `yaml:"timeoutSeconds"`
|
||||
}
|
||||
|
||||
// ReadHardlinkConfig reads config.yml and applies defaults.
|
||||
func ReadHardlinkConfig() ConfigRec {
|
||||
var cfg ConfigRec
|
||||
const configName = "config.yml"
|
||||
const 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 == "" {
|
||||
errorhandlers.FatalError(fmt.Errorf("LockType is required in %s", configName))
|
||||
}
|
||||
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 == "" {
|
||||
log.Warnf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName)
|
||||
}
|
||||
|
||||
cfg.PaymentProvider = strings.ToLower(strings.TrimSpace(cfg.PaymentProvider))
|
||||
|
||||
if cfg.TimeoutSeconds <= 0 {
|
||||
cfg.TimeoutSeconds = 300
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "PreauthReleaser Database Configuration Error", fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName))
|
||||
}
|
||||
|
||||
if cfg.LogDir == "" {
|
||||
cfg.LogDir = "./logs" + sep
|
||||
} else if !strings.HasSuffix(cfg.LogDir, sep) {
|
||||
cfg.LogDir += sep
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@ -1,294 +0,0 @@
|
||||
package dispenser
|
||||
|
||||
import (
|
||||
// "encoding/hex"
|
||||
"fmt"
|
||||
// "log"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tarm/serial"
|
||||
)
|
||||
|
||||
// Control characters.
|
||||
const (
|
||||
STX = 0x02 // Start of Text
|
||||
ETX = 0x03 // End of Text
|
||||
ACK = 0x06 // Positive response
|
||||
NAK = 0x15 // Negative response
|
||||
ENQ = 0x05 // Enquiry from host
|
||||
space = 0x00 // Space character
|
||||
baudRate = 9600 // Baud rate for serial communication
|
||||
delay = 500 * time.Millisecond // Delay for processing commands
|
||||
)
|
||||
|
||||
// type (
|
||||
// configRec struct {
|
||||
// SerialPort string `yaml:"port"`
|
||||
// Address string `yaml:"addr"`
|
||||
// }
|
||||
// )
|
||||
|
||||
var (
|
||||
SerialPort string
|
||||
Address []byte
|
||||
commandFC7 = []byte{ETX, 0x46, 0x43, 0x37} // "FC7" command dispense card at read card position
|
||||
commandFC0 = []byte{ETX, 0x46, 0x43, 0x30} // "FC0" command dispense card out of card mouth command
|
||||
|
||||
statusPos0 = map[byte]string{
|
||||
0x38: "Keep",
|
||||
0x34: "Command cannot execute",
|
||||
0x32: "Preparing card fails",
|
||||
0x31: "Preparing card",
|
||||
0x30: "Normal", // Default if none of the above
|
||||
}
|
||||
|
||||
statusPos1 = map[byte]string{
|
||||
0x38: "Dispensing card",
|
||||
0x34: "Capturing card",
|
||||
0x32: "Dispense card error",
|
||||
0x31: "Capture card error",
|
||||
0x30: "Normal",
|
||||
}
|
||||
|
||||
statusPos2 = map[byte]string{
|
||||
0x38: "No captured card",
|
||||
0x34: "Card overlapped",
|
||||
0x32: "Card jammed",
|
||||
0x31: "Card pre-empty",
|
||||
0x30: "Normal",
|
||||
}
|
||||
|
||||
statusPos3 = map[byte]string{
|
||||
0x38: "Card empty",
|
||||
0x34: "Card ready position",
|
||||
0x33: "Card at encoder position",
|
||||
0x32: "Card at hold card position",
|
||||
0x31: "Card out of card mouth position",
|
||||
0x30: "Normal",
|
||||
}
|
||||
)
|
||||
|
||||
func checkStatus(statusResp []byte) (string, error) {
|
||||
if len(statusResp) > 3 {
|
||||
statusBytes := statusResp[7:11] // Extract the relevant bytes from the response
|
||||
// For each position, get the ASCII character, hex value, and mapped meaning.
|
||||
posStatus := []struct {
|
||||
pos int
|
||||
value byte
|
||||
mapper map[byte]string
|
||||
}{
|
||||
{pos: 1, value: statusBytes[0], mapper: statusPos0},
|
||||
{pos: 2, value: statusBytes[1], mapper: statusPos1},
|
||||
{pos: 3, value: statusBytes[2], mapper: statusPos2},
|
||||
{pos: 4, value: statusBytes[3], mapper: statusPos3},
|
||||
}
|
||||
|
||||
result := ""
|
||||
for _, p := range posStatus {
|
||||
statusMsg, exists := p.mapper[p.value]
|
||||
if !exists {
|
||||
statusMsg = "Unknown status"
|
||||
}
|
||||
if p.value != 0x30 {
|
||||
result += fmt.Sprintf("Status: %s; ", statusMsg)
|
||||
}
|
||||
if p.pos == 4 && p.value == 0x38 {
|
||||
return result, fmt.Errorf("Card well empty")
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
|
||||
} else {
|
||||
if len(statusResp) == 3 && statusResp[0] == ACK && statusResp[1] == Address[0] && statusResp[2] == Address[1] {
|
||||
return "active;", nil
|
||||
} else if len(statusResp) > 0 && statusResp[0] == NAK {
|
||||
return "", fmt.Errorf("negative response from dispenser")
|
||||
} else {
|
||||
return "", fmt.Errorf("unexpected response status: % X", statusResp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calculateBCC computes the Block Check Character (BCC) as the XOR of all bytes from STX to ETX.
|
||||
func calculateBCC(data []byte) byte {
|
||||
var bcc byte
|
||||
for _, b := range data {
|
||||
bcc ^= b
|
||||
}
|
||||
return bcc
|
||||
}
|
||||
|
||||
func createPacket(address []byte, command []byte) []byte {
|
||||
packet := []byte{STX}
|
||||
packet = append(packet, address...) // Address bytes
|
||||
packet = append(packet, space) // Space character
|
||||
packet = append(packet, command...)
|
||||
packet = append(packet, ETX)
|
||||
bcc := calculateBCC(packet)
|
||||
packet = append(packet, bcc)
|
||||
return packet
|
||||
}
|
||||
|
||||
func buildCheckRF(address []byte) []byte {
|
||||
return createPacket(address, []byte{STX, 0x52, 0x46})
|
||||
}
|
||||
|
||||
func buildCheckAP(address []byte) []byte {
|
||||
return createPacket(address, []byte{STX, 0x41, 0x50})
|
||||
}
|
||||
|
||||
func sendAndReceive(port *serial.Port, packet []byte, delay time.Duration) ([]byte, error) {
|
||||
n, err := port.Write(packet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error writing to port: %w", err)
|
||||
}
|
||||
// log.Printf("TX %d bytes: % X", n, packet[:n])
|
||||
|
||||
time.Sleep(delay) // Wait for the dispenser to process the command
|
||||
|
||||
buf := make([]byte, 128)
|
||||
n, err = port.Read(buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading from port: %w", err)
|
||||
}
|
||||
resp := buf[:n]
|
||||
// log.Printf("RX %d bytes: % X", n, buf[:n])
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func InitializeDispenser() (*serial.Port, error) {
|
||||
const funcName = "initializeDispenser"
|
||||
serialConfig := &serial.Config{
|
||||
Name: SerialPort,
|
||||
Baud: baudRate,
|
||||
ReadTimeout: time.Second * 2,
|
||||
}
|
||||
port, err := serial.OpenPort(serialConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening dispenser COM port: %w", err)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func DispenserSequence(port *serial.Port) (string, error) {
|
||||
const funcName = "dispenserSequence"
|
||||
var result string
|
||||
|
||||
// Check dispenser status
|
||||
status, err := CheckDispenserStatus(port)
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
|
||||
}
|
||||
result += status
|
||||
|
||||
// Send card to encoder position
|
||||
status, err = CardToEncoderPosition(port)
|
||||
if err != nil {
|
||||
return status, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err)
|
||||
}
|
||||
result += "; " + status
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// if dispenser is not responding, I should repeat the command
|
||||
func CheckDispenserStatus(port *serial.Port) (string, error) {
|
||||
const funcName = "checkDispenserStatus"
|
||||
var result string
|
||||
checkCmd := buildCheckAP(Address)
|
||||
enq := append([]byte{ENQ}, Address...)
|
||||
|
||||
// Send check command (AP)
|
||||
statusResp, err := sendAndReceive(port, checkCmd, delay)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error sending check command: %v", err)
|
||||
}
|
||||
if len(statusResp) == 0 {
|
||||
return "", fmt.Errorf("no response from dispenser")
|
||||
}
|
||||
status, err := checkStatus(statusResp)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
result += "; " + status
|
||||
|
||||
// Send ENQ+ADDR to prompt device to execute the command.
|
||||
statusResp, err = sendAndReceive(port, enq, delay)
|
||||
if err != nil {
|
||||
log.Errorf("error sending ENQ: %v", err)
|
||||
}
|
||||
if len(statusResp) == 0 {
|
||||
return "", fmt.Errorf("no response from dispenser")
|
||||
}
|
||||
status, err = checkStatus(statusResp)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
result += status
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func CardToEncoderPosition(port *serial.Port) (string, error) {
|
||||
const funcName = "cartToEncoderPosition"
|
||||
enq := append([]byte{ENQ}, Address...)
|
||||
|
||||
//Send Dispense card to encoder position (FC7) ---
|
||||
dispenseCmd := createPacket(Address, commandFC7)
|
||||
log.Println("Send card to encoder position")
|
||||
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error sending card to encoder position: %v", err)
|
||||
}
|
||||
_, err = checkStatus(statusResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
//Send ENQ to prompt device ---
|
||||
_, err = port.Write(enq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(delay)
|
||||
|
||||
//Check card position status
|
||||
status, err := CheckDispenserStatus(port)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func CardOutOfMouth(port *serial.Port) (string, error) {
|
||||
const funcName = "CardOutOfMouth"
|
||||
enq := append([]byte{ENQ}, Address...)
|
||||
|
||||
// Send card out of card mouth (FC0) ---
|
||||
dispenseCmd := createPacket(Address, commandFC0)
|
||||
log.Println("Send card to out mouth position")
|
||||
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error sending out of mouth command: %v", err)
|
||||
}
|
||||
_, err = checkStatus(statusResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
//Send ENQ to prompt device ---
|
||||
_, err = port.Write(enq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(delay)
|
||||
|
||||
//Check card position status
|
||||
status, err := CheckDispenserStatus(port)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
5
go.mod
5
go.mod
@ -3,10 +3,13 @@ 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.200
|
||||
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/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
||||
golang.org/x/image v0.27.0
|
||||
|
||||
10
go.sum
10
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.200 h1:CRGAuhwecpOwY1CAuC038NFyw6EFulVG554HbUqfezI=
|
||||
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.200/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=
|
||||
@ -19,6 +19,12 @@ github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZ
|
||||
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394 h1:+6kiV40vfmh17TDlZG15C2uGje1/XBGT32j6xKmUkqM=
|
||||
github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394/go.mod h1:ogN8Sxy3n5VKLhQxbtSBM3ICG/VgjXS/akQJIoDSrgA=
|
||||
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=
|
||||
|
||||
@ -1,289 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tarm/serial"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"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/logging"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
customLayout = "2006-01-02 15:04:05 -0700"
|
||||
transactionUrl = "http://127.0.0.1:18181/start-transaction/"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
dispPort *serial.Port
|
||||
lockserver lockserver.LockServer
|
||||
isPayment bool
|
||||
}
|
||||
|
||||
func NewApp(dispPort *serial.Port, lockType, encoderAddress string, isPayment bool) *App {
|
||||
return &App{
|
||||
isPayment: isPayment,
|
||||
dispPort: dispPort,
|
||||
lockserver: lockserver.NewLockServer(lockType, encoderAddress, FatalError),
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/issuedoorcard", app.issueDoorCard)
|
||||
mux.HandleFunc("/printroomticket", app.printRoomTicket)
|
||||
mux.HandleFunc("/starttransaction", app.startTransaction)
|
||||
}
|
||||
|
||||
func (app *App) startTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("startTransaction")
|
||||
var (
|
||||
theResponse cmstypes.ResponseRec
|
||||
cardholderReceipt string
|
||||
theRequest cmstypes.TransactionRec
|
||||
trResult payment.TransactionResultXML
|
||||
)
|
||||
|
||||
theResponse.Status.Code = http.StatusInternalServerError
|
||||
theResponse.Status.Message = "500 Internal server error"
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if !app.isPayment {
|
||||
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Payment processing is disabled")
|
||||
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("startTransaction called")
|
||||
if r.Method != http.MethodPost {
|
||||
theResponse.Data = payment.BuildFailureURL(payment.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")
|
||||
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
err := xml.Unmarshal(body, &theRequest)
|
||||
if err != nil {
|
||||
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
||||
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Invalid XML payload")
|
||||
writeTransactionResult(w, http.StatusBadRequest, theResponse)
|
||||
return
|
||||
}
|
||||
log.Printf("Start trnasaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType)
|
||||
|
||||
client := &http.Client{Timeout: 300 * time.Second}
|
||||
response, err := client.Post(transactionUrl, "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")
|
||||
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err = io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
|
||||
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Failed to read response body")
|
||||
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
if err := trResult.ParseTransactionResult(body); err != nil {
|
||||
logging.Error(serviceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
|
||||
}
|
||||
|
||||
// Compose JSON from responseEntries
|
||||
result := make(map[string]string)
|
||||
for _, e := range trResult.Entries {
|
||||
switch e.Key {
|
||||
case payment.ReceiptData, payment.ReceiptDataMerchant:
|
||||
// ignore these
|
||||
case payment.ReceiptDataCardholder:
|
||||
cardholderReceipt = e.Value
|
||||
case payment.TransactionResult:
|
||||
theResponse.Status.Message = e.Value
|
||||
theResponse.Status.Code = http.StatusOK
|
||||
result[e.Key] = e.Value
|
||||
default:
|
||||
result[e.Key] = e.Value
|
||||
}
|
||||
}
|
||||
|
||||
if err := printer.PrintCardholderReceipt(cardholderReceipt); err != nil {
|
||||
log.Errorf("PrintCardholderReceipt error: %v", err)
|
||||
}
|
||||
|
||||
theResponse.Data = payment.BuildRedirectURL(result)
|
||||
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||
}
|
||||
|
||||
func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("issueDoorCard")
|
||||
var (
|
||||
doorReq lockserver.DoorCardRequest
|
||||
theResponse cmstypes.StatusRec
|
||||
)
|
||||
|
||||
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("issueDoorCard 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 != "application/json" {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil {
|
||||
logging.Error(serviceName, err.Error(), "ReadJSON", string(op), "", "", 0)
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// parse times
|
||||
checkIn, err := time.Parse(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)
|
||||
if err != nil {
|
||||
logging.Error(serviceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0)
|
||||
writeError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// dispenser sequence
|
||||
if status, err := dispenser.DispenserSequence(app.dispPort); err != nil {
|
||||
if status != "" {
|
||||
logging.Error(serviceName, status, "Dispense error", string(op), "", "", 0)
|
||||
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
|
||||
} else {
|
||||
logging.Error(serviceName, err.Error(), "Dispense error", string(op), "", "", 0)
|
||||
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()+"; check card stock")
|
||||
}
|
||||
return
|
||||
} else {
|
||||
log.Info(status)
|
||||
}
|
||||
|
||||
// build lock server command
|
||||
app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
|
||||
|
||||
// lock server sequence
|
||||
err = app.lockserver.LockSequence()
|
||||
if err != nil {
|
||||
logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0)
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
dispenser.CardOutOfMouth(app.dispPort)
|
||||
return
|
||||
}
|
||||
|
||||
// final dispenser steps
|
||||
if status, err := dispenser.CardOutOfMouth(app.dispPort); err != nil {
|
||||
logging.Error(serviceName, err.Error(), "Dispenser eject error", string(op), "", "", 0)
|
||||
writeError(w, http.StatusServiceUnavailable, "Dispenser eject error: "+err.Error())
|
||||
return
|
||||
} else {
|
||||
log.Info(status)
|
||||
}
|
||||
|
||||
theResponse.Code = http.StatusOK
|
||||
theResponse.Message = "Card issued successfully"
|
||||
// success! return 200 and any data you like
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(theResponse)
|
||||
}
|
||||
|
||||
func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("printRoomTicket")
|
||||
var roomDetails printer.RoomDetailsRec
|
||||
// Allow CORS preflight if needed
|
||||
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
|
||||
}
|
||||
log.Println("printRoomTicket called")
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
|
||||
return
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "xml") {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/xml")
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
if err := xml.NewDecoder(r.Body).Decode(&roomDetails); err != nil {
|
||||
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
||||
writeError(w, http.StatusBadRequest, "Invalid XML payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
data, err := printer.BuildRoomTicket(roomDetails)
|
||||
if err != nil {
|
||||
logging.Error(serviceName, err.Error(), "BuildRoomTicket", string(op), "", "", 0)
|
||||
writeError(w, http.StatusInternalServerError, "BuildRoomTicket failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Send to the Windows Epson TM-T82II via the printer package
|
||||
if err := printer.SendToPrinter(data); err != nil {
|
||||
logging.Error(serviceName, err.Error(), "printRoomTicket", "printRoomTicket", "", "", 0)
|
||||
writeError(w, http.StatusInternalServerError, "Print failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(cmstypes.StatusRec{
|
||||
Code: http.StatusOK,
|
||||
Message: "Print job sent successfully",
|
||||
})
|
||||
}
|
||||
49
hardlink-preauth-release/main.go
Normal file
49
hardlink-preauth-release/main.go
Normal file
@ -0,0 +1,49 @@
|
||||
// cmd/hardlink-preauth-release/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/bootstrap"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/logging"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
buildVersion = "1.0.2"
|
||||
serviceName = "preauth-release"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config := config.ReadPreauthReleaserConfig()
|
||||
// Setup logging and get file handle
|
||||
logFile, err := logging.SetupLogging(config.LogDir, serviceName, buildVersion)
|
||||
if err != nil {
|
||||
log.Printf("Failed to set up logging: %v\n", err)
|
||||
}
|
||||
defer logFile.Close()
|
||||
database, err := bootstrap.OpenDB(&config)
|
||||
if err != nil {
|
||||
log.WithError(err).Fatal("DB init failed")
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if err := creditcall.ReleasePreauthorizations(database); err != nil {
|
||||
log.Error(err)
|
||||
fmt.Println(err)
|
||||
} else {
|
||||
log.Info("Task completed successfully")
|
||||
fmt.Println("Task completed successfully")
|
||||
}
|
||||
|
||||
for i := 20; i > 0; i-- {
|
||||
fmt.Printf("\rExiting in %2d seconds... ", i)
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
fmt.Println("\rExiting now. ")
|
||||
os.Exit(0)
|
||||
}
|
||||
18
internal/bootstrap/bootstrap.go
Normal file
18
internal/bootstrap/bootstrap.go
Normal file
@ -0,0 +1,18 @@
|
||||
// internal/bootstrap/db.go
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/db"
|
||||
)
|
||||
|
||||
func OpenDB(cfg *config.ConfigRec) (*sql.DB, error) {
|
||||
return db.InitMSSQL(
|
||||
cfg.Dbport,
|
||||
cfg.Dbuser,
|
||||
cfg.Dbpassword,
|
||||
cfg.Dbname,
|
||||
)
|
||||
}
|
||||
278
internal/creditcall/chipdnastatus.go
Normal file
278
internal/creditcall/chipdnastatus.go
Normal file
@ -0,0 +1,278 @@
|
||||
package creditcall
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
"gitea.futuresens.co.uk/futuresens/logging"
|
||||
)
|
||||
|
||||
const (
|
||||
KeyErrors = "ERRORS"
|
||||
KeyVersionInformation = "VERSION_INFORMATION"
|
||||
KeyChipDnaStatus = "CHIPDNA_STATUS"
|
||||
KeyPaymentDeviceStatus = "PAYMENT_DEVICE_STATUS"
|
||||
KeyRequestQueueStatus = "REQUEST_QUEUE_STATUS"
|
||||
KeyTmsStatus = "TMS_STATUS"
|
||||
KeyPaymentPlatform = "PAYMENT_PLATFORM_STATUS"
|
||||
KeyPaymentDeviceModel = "PAYMENT_DEVICE_MODEL"
|
||||
KeyPaymentDeviceIdentifier = "PAYMENT_DEVICE_IDENTIFIER"
|
||||
KeyIsAvailable = "IS_AVAILABLE"
|
||||
KeyAvailabilityError = "AVAILABILITY_ERROR"
|
||||
KeyAvailabilityErrorInformation = "AVAILABILITY_ERROR_INFORMATION"
|
||||
)
|
||||
|
||||
type (
|
||||
ArrayOfParameter struct {
|
||||
Parameters []Parameter `xml:"Parameter" json:"Parameters"`
|
||||
}
|
||||
|
||||
Parameter struct {
|
||||
Key string `xml:"Key" json:"Key"`
|
||||
Value string `xml:"Value" json:"Value"`
|
||||
}
|
||||
|
||||
ServerStatus struct {
|
||||
IsProcessingTransaction bool `xml:"IsProcessingTransaction" json:"IsProcessingTransaction"`
|
||||
ChipDnaServerIssue string `xml:"ChipDnaServerIssue" json:"ChipDnaServerIssue"`
|
||||
}
|
||||
|
||||
ArrayOfPaymentDeviceStatus struct {
|
||||
Items []PaymentDeviceStatus `xml:"PaymentDeviceStatus" json:"Items"`
|
||||
}
|
||||
|
||||
PaymentDeviceStatus struct {
|
||||
ConfiguredDeviceId string `xml:"ConfiguredDeviceId" json:"ConfiguredDeviceId"`
|
||||
ConfiguredDeviceModel string `xml:"ConfiguredDeviceModel" json:"ConfiguredDeviceModel"`
|
||||
ProcessingTransaction bool `xml:"ProcessingTransaction" json:"ProcessingTransaction"`
|
||||
AvailabilityError string `xml:"AvailabilityError" json:"AvailabilityError"`
|
||||
AvailabilityErrorInformation string `xml:"AvailabilityErrorInformation" json:"AvailabilityErrorInformation"`
|
||||
ConfigurationState string `xml:"ConfigurationState" json:"ConfigurationState"`
|
||||
IsAvailable bool `xml:"IsAvailable" json:"IsAvailable"`
|
||||
BatteryPercentage int `xml:"BatteryPercentage" json:"BatteryPercentage"`
|
||||
BatteryChargingStatus string `xml:"BatteryChargingStatus" json:"BatteryChargingStatus"`
|
||||
BatteryStatusUpdateDateTime string `xml:"BatteryStatusUpdateDateTime" json:"BatteryStatusUpdateDateTime"`
|
||||
BatteryStatusUpdateDateTimeFormat string `xml:"BatteryStatusUpdateDateTimeFormat" json:"BatteryStatusUpdateDateTimeFormat"`
|
||||
}
|
||||
|
||||
RequestQueueStatus struct {
|
||||
CreditRequestCount int `xml:"CreditRequestCount" json:"CreditRequestCount"`
|
||||
CreditConfirmRequestCount int `xml:"CreditConfirmRequestCount" json:"CreditConfirmRequestCount"`
|
||||
CreditVoidRequestCount int `xml:"CreditVoidRequestCount" json:"CreditVoidRequestCount"`
|
||||
DebitRequestCount int `xml:"DebitRequestCount" json:"DebitRequestCount"`
|
||||
DebitConfirmRequestCount int `xml:"DebitConfirmRequestCount" json:"DebitConfirmRequestCount"`
|
||||
DebitVoidRequestCount int `xml:"DebitVoidRequestCount" json:"DebitVoidRequestCount"`
|
||||
}
|
||||
|
||||
TmsStatus struct {
|
||||
LastConfigUpdateDateTime string `xml:"LastConfigUpdateDateTime" json:"LastConfigUpdateDateTime"`
|
||||
DaysUntilConfigUpdateIsRequired int `xml:"DaysUntilConfigUpdateIsRequired" json:"DaysUntilConfigUpdateIsRequired"`
|
||||
RequiredConfigUpdateDateTime string `xml:"RequiredConfigUpdateDateTime" json:"RequiredConfigUpdateDateTime"`
|
||||
}
|
||||
|
||||
PaymentPlatformStatus struct {
|
||||
MachineLocalDateTime string `xml:"MachineLocalDateTime" json:"MachineLocalDateTime"`
|
||||
PaymentPlatformLocalDateTime string `xml:"PaymentPlatformLocalDateTime" json:"PaymentPlatformLocalDateTime"`
|
||||
PaymentPlatformLocalDateTimeFormat string `xml:"PaymentPlatformLocalDateTimeFormat" json:"PaymentPlatformLocalDateTimeFormat"`
|
||||
State string `xml:"State" json:"State"`
|
||||
}
|
||||
|
||||
ParsedStatus struct {
|
||||
Errors []string `json:"Errors"`
|
||||
VersionInfo map[string]string `json:"VersionInfo"`
|
||||
ChipDnaStatus *ServerStatus `json:"ChipDnaStatus"`
|
||||
PaymentDevices []PaymentDeviceStatus `json:"PaymentDevices"`
|
||||
RequestQueue *RequestQueueStatus `json:"RequestQueue"`
|
||||
TMS *TmsStatus `json:"TMS"`
|
||||
PaymentPlatform *PaymentPlatformStatus `json:"PaymentPlatform"`
|
||||
Unknown map[string]string `json:"Unknown"`
|
||||
}
|
||||
)
|
||||
|
||||
// ===========================
|
||||
// Parser
|
||||
// ===========================
|
||||
|
||||
func ParseStatusResult(data []byte) (*ParsedStatus, error) {
|
||||
var tr TransactionResultXML
|
||||
if err := tr.ParseTransactionResult(data); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal TransactionResult: %w", err)
|
||||
}
|
||||
|
||||
out := &ParsedStatus{
|
||||
VersionInfo: make(map[string]string),
|
||||
Unknown: make(map[string]string),
|
||||
}
|
||||
|
||||
for _, e := range tr.Entries {
|
||||
switch e.Key {
|
||||
|
||||
// Some responses return plain text (not escaped XML) for ERRORS.
|
||||
case KeyErrors:
|
||||
msg := html.UnescapeString(e.Value) // safe even if not escaped
|
||||
if msg != "" {
|
||||
out.Errors = append(out.Errors, msg)
|
||||
}
|
||||
|
||||
// Everything below is escaped XML inside <Value>
|
||||
case KeyVersionInformation:
|
||||
unescaped := html.UnescapeString(e.Value)
|
||||
|
||||
var a ArrayOfParameter
|
||||
if err := xml.Unmarshal([]byte(unescaped), &a); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal %s: %w", e.Key, err)
|
||||
}
|
||||
for _, p := range a.Parameters {
|
||||
out.VersionInfo[p.Key] = p.Value
|
||||
}
|
||||
|
||||
case KeyChipDnaStatus:
|
||||
unescaped := html.UnescapeString(e.Value)
|
||||
|
||||
var s ServerStatus
|
||||
if err := xml.Unmarshal([]byte(unescaped), &s); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal %s: %w", e.Key, err)
|
||||
}
|
||||
out.ChipDnaStatus = &s
|
||||
|
||||
case KeyPaymentDeviceStatus:
|
||||
unescaped := html.UnescapeString(e.Value)
|
||||
|
||||
var a ArrayOfPaymentDeviceStatus
|
||||
if err := xml.Unmarshal([]byte(unescaped), &a); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal %s: %w", e.Key, err)
|
||||
}
|
||||
out.PaymentDevices = append(out.PaymentDevices, a.Items...)
|
||||
|
||||
case KeyRequestQueueStatus:
|
||||
unescaped := html.UnescapeString(e.Value)
|
||||
|
||||
var s RequestQueueStatus
|
||||
if err := xml.Unmarshal([]byte(unescaped), &s); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal %s: %w", e.Key, err)
|
||||
}
|
||||
out.RequestQueue = &s
|
||||
|
||||
case KeyTmsStatus:
|
||||
unescaped := html.UnescapeString(e.Value)
|
||||
|
||||
var s TmsStatus
|
||||
if err := xml.Unmarshal([]byte(unescaped), &s); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal %s: %w", e.Key, err)
|
||||
}
|
||||
out.TMS = &s
|
||||
|
||||
case KeyPaymentPlatform:
|
||||
unescaped := html.UnescapeString(e.Value)
|
||||
|
||||
var s PaymentPlatformStatus
|
||||
if err := xml.Unmarshal([]byte(unescaped), &s); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal %s: %w", e.Key, err)
|
||||
}
|
||||
out.PaymentPlatform = &s
|
||||
|
||||
default:
|
||||
// Keep for logging / future additions. Unescape so it's readable XML if it was escaped.
|
||||
out.Unknown[e.Key] = html.UnescapeString(e.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func fetchChipDNAStatus() (*ParsedStatus, error) {
|
||||
const op = logging.Op("fetchChipDNAStatus")
|
||||
|
||||
body := []byte{}
|
||||
client := &http.Client{Timeout: 300 * time.Second}
|
||||
response, err := client.Post(types.LinkChipDNAStatus, "text/xml", bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "error fetching ChipDNA status", string(op), "", "", 0)
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err = io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Read response body error", string(op), "", "", 0)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := ParseStatusResult(body)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Parse ChipDNA status error", string(op), "", "", 0)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ReadPdqStatus(hotel string, kiosk int) (PaymentDeviceStatus, error) {
|
||||
const op = logging.Op("readPdqStatus")
|
||||
|
||||
status, err := fetchChipDNAStatus()
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, "pdq_unavailable", "Failed to fetch ChipDNA status: "+err.Error(), string(op), "", hotel, kiosk)
|
||||
return PaymentDeviceStatus{}, fmt.Errorf("error fetch ChipDNA status: %w", err)
|
||||
}
|
||||
|
||||
if len(status.Errors) > 0 {
|
||||
msg := strings.Join(status.Errors, "; ")
|
||||
logging.Error(types.ServiceName, "pdq_unavailable", "ChipDNA status errors: "+msg, string(op), "", hotel, kiosk)
|
||||
return PaymentDeviceStatus{}, fmt.Errorf("ChipDNA status errors: %s", msg)
|
||||
}
|
||||
|
||||
if len(status.PaymentDevices) == 0 {
|
||||
logging.Error(types.ServiceName, "pdq_unavailable", "ChipDNA status has no PAYMENT_DEVICE_STATUS items", string(op), "", hotel, kiosk)
|
||||
return PaymentDeviceStatus{}, fmt.Errorf("no payment devices returned")
|
||||
}
|
||||
|
||||
dev := status.PaymentDevices[0]
|
||||
if !dev.IsAvailable {
|
||||
logging.Error(types.ServiceName, "pdq_unavailable", "Payment device unavailable", string(op), "", hotel, kiosk)
|
||||
return dev, fmt.Errorf("device unavailable")
|
||||
}
|
||||
|
||||
return dev, nil
|
||||
}
|
||||
|
||||
func StartPdqHourlyCheck(ctx context.Context, hotel string, kiosk int) {
|
||||
// waitUntilNextHour(ctx)
|
||||
|
||||
// First execution exactly at round hour
|
||||
_, _ = ReadPdqStatus(hotel, kiosk)
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_, _ = ReadPdqStatus(hotel, kiosk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilNextHour(ctx context.Context) {
|
||||
now := time.Now()
|
||||
next := now.Truncate(time.Hour).Add(time.Hour)
|
||||
d := time.Until(next)
|
||||
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
187
internal/creditcall/creditcall.go
Normal file
187
internal/creditcall/creditcall.go
Normal file
@ -0,0 +1,187 @@
|
||||
package creditcall
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
_ "github.com/denisenkom/go-mssqldb"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// XML parsing structs
|
||||
type (
|
||||
TransactionRec struct {
|
||||
XMLName xml.Name `xml:"TransactionPayload"`
|
||||
AmountMinorUnits string `xml:"amount"`
|
||||
TransactionType string `xml:"transactionType"`
|
||||
}
|
||||
|
||||
TransactionResultXML struct {
|
||||
XMLName xml.Name `xml:"TransactionResult"`
|
||||
Entries []EntryXML `xml:"Entry"`
|
||||
}
|
||||
|
||||
EntryXML struct {
|
||||
Key string `xml:"Key"`
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
TransactionConfirmation struct {
|
||||
XMLName xml.Name `xml:"TransactionConfirmation"`
|
||||
Result string `xml:"Result"`
|
||||
Errors string `xml:"Errors"`
|
||||
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
|
||||
}
|
||||
|
||||
ConfirmTransactionRequest struct {
|
||||
XMLName xml.Name `xml:"ConfirmTransactionRequest"`
|
||||
Amount string `xml:"Amount"`
|
||||
Reference string `xml:"TransactionReference"`
|
||||
}
|
||||
)
|
||||
|
||||
// ParseTransactionResult parses the XML into entries.
|
||||
func (tr *TransactionResultXML) ParseTransactionResult(data []byte) error {
|
||||
if err := xml.Unmarshal(data, &tr); err != nil {
|
||||
return fmt.Errorf("XML unmarshal: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PaymentResult) FillFromTransactionResult(trResult TransactionResultXML) {
|
||||
r.Fields = make(map[string]string)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BuildPaymentRedirectURL builds the redirect URL to send the guest to after payment.
|
||||
func BuildPaymentRedirectURL(result map[string]string) string {
|
||||
res := result[types.TransactionResult]
|
||||
|
||||
// Transaction approved?
|
||||
if strings.EqualFold(res, types.ResultApproved) {
|
||||
// Transaction confirmed?
|
||||
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{types.LogFieldError: result[types.ConfirmResult], types.LogFieldDescription: result[types.ConfirmErrors]}).
|
||||
Error("Transaction approved but not confirmed")
|
||||
|
||||
return BuildFailureURL(result[types.ConfirmResult], result[types.ConfirmErrors])
|
||||
}
|
||||
|
||||
// Not approved
|
||||
return BuildFailureURL(res, result[types.Errors])
|
||||
}
|
||||
|
||||
func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
|
||||
res := result[types.TransactionResult]
|
||||
tType := result[types.TransactionType]
|
||||
|
||||
// Transaction approved?
|
||||
if strings.EqualFold(res, types.ResultApproved) {
|
||||
switch {
|
||||
// Transaction type AccountVerification?
|
||||
case strings.EqualFold(tType, types.AccountVerificationType):
|
||||
log.WithField(types.LogResult, result[types.TransactionResult]).
|
||||
Info("Account verification approved")
|
||||
|
||||
return BuildSuccessURL(result), false
|
||||
|
||||
// Transaction type Sale?
|
||||
case strings.EqualFold(tType, types.SaleTransactionType):
|
||||
// Transaction confirmed?
|
||||
log.WithField(types.LogResult, result[types.ConfirmResult]).
|
||||
Info("Amount preauthorized successfully")
|
||||
|
||||
return BuildSuccessURL(result), true
|
||||
}
|
||||
}
|
||||
|
||||
// Not approved
|
||||
return BuildFailureURL(res, result[types.Errors]), false
|
||||
}
|
||||
|
||||
func BuildSuccessURL(result map[string]string) string {
|
||||
q := url.Values{}
|
||||
q.Set("CardNumber", hex.EncodeToString([]byte(result[types.PanMasked])))
|
||||
q.Set("CardType", hex.EncodeToString([]byte(result[types.CardType])))
|
||||
q.Set("ExpiryDate", hex.EncodeToString([]byte(result[types.ExpiryDate])))
|
||||
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: types.CheckinSuccessfulEndpoint,
|
||||
RawQuery: q.Encode(),
|
||||
}).String()
|
||||
}
|
||||
|
||||
func BuildFailureURL(msgType, description string) string {
|
||||
q := url.Values{}
|
||||
if msgType != "" {
|
||||
description = fmt.Sprintf("Transaction %s", strings.ToLower(msgType))
|
||||
}
|
||||
if description != "" {
|
||||
msgType = types.ResultError
|
||||
}
|
||||
|
||||
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: types.CheckinUnsuccessfulEndpoint,
|
||||
RawQuery: q.Encode(),
|
||||
}).String()
|
||||
}
|
||||
237
internal/creditcall/preauthReleaser.go
Normal file
237
internal/creditcall/preauthReleaser.go
Normal file
@ -0,0 +1,237 @@
|
||||
package creditcall
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/db"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
httpTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
/* ==============================
|
||||
Public Entry Point
|
||||
============================== */
|
||||
|
||||
func ReleasePreauthorizations(database *sql.DB) error {
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
client := &http.Client{Timeout: httpTimeout}
|
||||
|
||||
preauths, err := fetchDuePreauths(ctx, database, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(preauths) == 0 {
|
||||
log.Info("No preauthorizations due for release")
|
||||
return nil
|
||||
} else {
|
||||
log.Infof("%d preauthorizations due for release", len(preauths))
|
||||
}
|
||||
|
||||
var failed []string
|
||||
var completed int
|
||||
for _, p := range preauths {
|
||||
if err := handlePreauthRelease(ctx, database, client, p, now); err != nil {
|
||||
log.Errorf("Preauth %s failed: %v", p.TxnReference, err)
|
||||
failed = append(failed, p.TxnReference)
|
||||
}
|
||||
completed++
|
||||
}
|
||||
log.Infof("Preauth release completed: %d processed, %d failed", completed, len(failed))
|
||||
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("preauth release incomplete, failed refs: %s", strings.Join(failed, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/* ==============================
|
||||
Core Business Logic
|
||||
============================== */
|
||||
|
||||
func handlePreauthRelease(
|
||||
ctx context.Context,
|
||||
dbConn *sql.DB,
|
||||
client *http.Client,
|
||||
preauth types.PreauthRec,
|
||||
now time.Time,
|
||||
) error {
|
||||
|
||||
ref := preauth.TxnReference
|
||||
log.Infof("Evaluating preauth %s", ref)
|
||||
|
||||
info, err := fetchTransactionInfo(ctx, client, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Infof("res=%s state=%s", info.transactionRes, info.transactionState)
|
||||
|
||||
// If already voided or declined → mark released
|
||||
if isAlreadyReleased(info) {
|
||||
return markReleased(ctx, dbConn, ref, now)
|
||||
}
|
||||
|
||||
// Only void approved + uncommitted
|
||||
if !isVoidable(info) {
|
||||
log.Infof("Preauth %s not eligible for void (res=%s state=%s)", ref, info.transactionRes, info.transactionState)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Void transaction
|
||||
if err := voidPreauth(ctx, client, ref); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify final state
|
||||
finalInfo, err := fetchTransactionInfo(ctx, client, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !isSuccessfullyVoided(finalInfo) {
|
||||
return fmt.Errorf("unexpected final state res=%s state=%s",
|
||||
finalInfo.transactionRes, finalInfo.transactionState)
|
||||
}
|
||||
|
||||
log.Infof("Preauth %s successfully voided", ref)
|
||||
return markReleased(ctx, dbConn, ref, now)
|
||||
}
|
||||
|
||||
/* ==============================
|
||||
State Evaluation Helpers
|
||||
============================== */
|
||||
|
||||
func isVoidable(info TransactionInfo) bool {
|
||||
return strings.EqualFold(info.transactionRes, types.ResultApproved) &&
|
||||
strings.EqualFold(info.transactionState, types.ResultStateUncommitted)
|
||||
}
|
||||
|
||||
func isAlreadyReleased(info TransactionInfo) bool {
|
||||
return strings.EqualFold(info.transactionState, types.ResultStateVoided) ||
|
||||
strings.EqualFold(info.transactionRes, types.ResultDeclined)
|
||||
}
|
||||
|
||||
func isSuccessfullyVoided(info TransactionInfo) bool {
|
||||
return strings.EqualFold(info.transactionRes, types.ResultDeclined) &&
|
||||
strings.EqualFold(info.transactionState, types.ResultStateVoided)
|
||||
}
|
||||
|
||||
/* ==============================
|
||||
External Operations
|
||||
============================== */
|
||||
|
||||
func fetchDuePreauths(
|
||||
ctx context.Context,
|
||||
dbConn *sql.DB,
|
||||
now time.Time,
|
||||
) ([]types.PreauthRec, error) {
|
||||
|
||||
return db.GetDuePreauths(ctx, dbConn, now, 0)
|
||||
}
|
||||
|
||||
func markReleased(
|
||||
ctx context.Context,
|
||||
dbConn *sql.DB,
|
||||
ref string,
|
||||
now time.Time,
|
||||
) error {
|
||||
|
||||
return db.MarkPreauthReleased(ctx, dbConn, ref, now)
|
||||
}
|
||||
|
||||
func fetchTransactionInfo(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
ref string,
|
||||
) (TransactionInfo, error) {
|
||||
|
||||
var tr TransactionResultXML
|
||||
var info TransactionInfo
|
||||
|
||||
payload, _ := xml.Marshal(types.TransactionReferenceRequest{
|
||||
TransactionReference: ref,
|
||||
})
|
||||
|
||||
body, err := postXML(ctx, client, types.LinkTransactionInformation, payload)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
if err := tr.ParseTransactionResult(body); err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
info.FillFromTransactionResult(tr)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func voidPreauth(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
ref string,
|
||||
) error {
|
||||
|
||||
var tr TransactionResultXML
|
||||
var info TransactionInfo
|
||||
|
||||
payload, _ := xml.Marshal(types.TransactionReferenceRequest{
|
||||
TransactionReference: ref,
|
||||
})
|
||||
|
||||
body, err := postXML(ctx, client, types.LinkVoidTransaction, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tr.ParseTransactionResult(body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info.FillFromTransactionResult(tr)
|
||||
|
||||
if !strings.EqualFold(info.transactionRes, types.ResultApproved) {
|
||||
return fmt.Errorf("void rejected: %s", info.transactionRes)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/* ==============================
|
||||
Low-level HTTP
|
||||
============================== */
|
||||
|
||||
func postXML(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
url string,
|
||||
payload []byte,
|
||||
) ([]byte, error) {
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/xml")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
201
internal/db/db.go
Normal file
201
internal/db/db.go
Normal file
@ -0,0 +1,201 @@
|
||||
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/internal/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
|
||||
}
|
||||
|
||||
289
internal/dispenser/dispenser.go
Normal file
289
internal/dispenser/dispenser.go
Normal file
@ -0,0 +1,289 @@
|
||||
package dispenser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tarm/serial"
|
||||
)
|
||||
|
||||
// Control characters.
|
||||
const (
|
||||
STX = 0x02 // Start of Text
|
||||
ETX = 0x03 // End of Text
|
||||
ACK = 0x06 // Positive response
|
||||
NAK = 0x15 // Negative response
|
||||
ENQ = 0x05 // Enquiry from host
|
||||
space = 0x00 // Space character
|
||||
baudRate = 9600 // Baud rate for serial communication
|
||||
delay = 500 * time.Millisecond // Delay for processing commands
|
||||
|
||||
// cache freshness for "continuous status" reads (tune as you wish)
|
||||
defaultStatusTTL = 1500 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
SerialPort string
|
||||
Address []byte
|
||||
|
||||
commandFC7 = []byte{ETX, 0x46, 0x43, 0x37} // "FC7"
|
||||
commandFC0 = []byte{ETX, 0x46, 0x43, 0x30} // "FC0"
|
||||
|
||||
statusPos0 = map[byte]string{
|
||||
0x38: "Keep",
|
||||
0x34: "Command cannot execute",
|
||||
0x32: "Preparing card fails",
|
||||
0x31: "Preparing card",
|
||||
0x30: "Normal",
|
||||
0x36: "Command cannot execute; Preparing card fails",
|
||||
}
|
||||
statusPos1 = map[byte]string{
|
||||
0x38: "Dispensing card",
|
||||
0x34: "Capturing card",
|
||||
0x32: "Dispense card error",
|
||||
0x31: "Capture card error",
|
||||
0x30: "Normal",
|
||||
}
|
||||
statusPos2 = map[byte]string{
|
||||
0x38: "No captured card",
|
||||
0x34: "Card overlapped",
|
||||
0x32: "Card jammed",
|
||||
0x31: "Card pre-empty",
|
||||
0x30: "Normal",
|
||||
}
|
||||
statusPos3 = map[byte]string{
|
||||
0x38: "Card empty",
|
||||
0x34: "Card ready position",
|
||||
0x33: "Card at encoder position",
|
||||
0x32: "Card at hold card position",
|
||||
0x31: "Card out of card mouth position",
|
||||
0x30: "Normal",
|
||||
}
|
||||
)
|
||||
|
||||
// --------------------
|
||||
// Status helpers
|
||||
// --------------------
|
||||
|
||||
func logStatus(statusBytes []byte) {
|
||||
if len(statusBytes) < 4 {
|
||||
log.Infof("Dispenser status: <invalid len=%d>", len(statusBytes))
|
||||
return
|
||||
}
|
||||
|
||||
posStatus := []struct {
|
||||
pos int
|
||||
value byte
|
||||
mapper map[byte]string
|
||||
}{
|
||||
{pos: 1, value: statusBytes[0], mapper: statusPos0},
|
||||
{pos: 2, value: statusBytes[1], mapper: statusPos1},
|
||||
{pos: 3, value: statusBytes[2], mapper: statusPos2},
|
||||
{pos: 4, value: statusBytes[3], mapper: statusPos3},
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
for _, p := range posStatus {
|
||||
statusMsg, exists := p.mapper[p.value]
|
||||
if !exists {
|
||||
statusMsg = fmt.Sprintf("Unknown status 0x%X at position %d", p.value, p.pos)
|
||||
}
|
||||
if p.value != 0x30 {
|
||||
result.WriteString(statusMsg + "; ")
|
||||
}
|
||||
}
|
||||
log.Infof("Dispenser status: %s", result.String())
|
||||
}
|
||||
|
||||
func isAtEncoderPosition(statusBytes []byte) bool {
|
||||
return len(statusBytes) >= 4 && statusBytes[3] == 0x33
|
||||
}
|
||||
|
||||
func stockTake(statusBytes []byte) string {
|
||||
if len(statusBytes) < 4 {
|
||||
return ""
|
||||
}
|
||||
status := ""
|
||||
if statusBytes[0] == 0x32 || statusBytes[0] == 0x36 {
|
||||
status = statusPos0[statusBytes[0]]
|
||||
}
|
||||
if statusBytes[2] != 0x30 {
|
||||
status = statusPos2[statusBytes[2]]
|
||||
}
|
||||
if statusBytes[3] == 0x38 {
|
||||
status = statusPos3[statusBytes[3]]
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func isCardWellEmpty(statusBytes []byte) bool {
|
||||
return len(statusBytes) >= 4 && statusBytes[3] == 0x38
|
||||
}
|
||||
|
||||
func checkACK(statusResp []byte) error {
|
||||
if len(statusResp) == 3 &&
|
||||
statusResp[0] == ACK &&
|
||||
len(Address) >= 2 &&
|
||||
statusResp[1] == Address[0] &&
|
||||
statusResp[2] == Address[1] {
|
||||
return nil
|
||||
}
|
||||
if len(statusResp) > 0 && statusResp[0] == NAK {
|
||||
return fmt.Errorf("negative response from dispenser")
|
||||
}
|
||||
return fmt.Errorf("unexpected response status: % X", statusResp)
|
||||
}
|
||||
|
||||
// calculateBCC computes BCC as XOR of all bytes from STX to ETX.
|
||||
func calculateBCC(data []byte) byte {
|
||||
var bcc byte
|
||||
for _, b := range data {
|
||||
bcc ^= b
|
||||
}
|
||||
return bcc
|
||||
}
|
||||
|
||||
func createPacket(address []byte, command []byte) []byte {
|
||||
packet := []byte{STX}
|
||||
packet = append(packet, address...)
|
||||
packet = append(packet, space)
|
||||
packet = append(packet, command...)
|
||||
packet = append(packet, ETX)
|
||||
bcc := calculateBCC(packet)
|
||||
packet = append(packet, bcc)
|
||||
return packet
|
||||
}
|
||||
|
||||
func buildCheckAP(address []byte) []byte { return createPacket(address, []byte{STX, 0x41, 0x50}) }
|
||||
|
||||
func sendAndReceive(port *serial.Port, packet []byte, delay time.Duration) ([]byte, error) {
|
||||
_, err := port.Write(packet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error writing to port: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(delay)
|
||||
|
||||
buf := make([]byte, 128)
|
||||
n, err := port.Read(buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading from port: %w", err)
|
||||
}
|
||||
return buf[:n], nil
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// Serial init (3 attempts)
|
||||
// --------------------
|
||||
|
||||
func InitializeDispenser() (*serial.Port, error) {
|
||||
const (
|
||||
funcName = "InitializeDispenser"
|
||||
maxRetries = 3
|
||||
retryDelay = 4 * time.Second
|
||||
)
|
||||
|
||||
if SerialPort == "" {
|
||||
return nil, fmt.Errorf("%s: SerialPort is empty", funcName)
|
||||
}
|
||||
if len(Address) < 2 {
|
||||
return nil, fmt.Errorf("%s: Address must be at least 2 bytes", funcName)
|
||||
}
|
||||
|
||||
serialConfig := &serial.Config{
|
||||
Name: SerialPort,
|
||||
Baud: baudRate,
|
||||
ReadTimeout: 2 * time.Second,
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
port, err := serial.OpenPort(serialConfig)
|
||||
if err == nil {
|
||||
log.Infof("%s: dispenser opened on %s (attempt %d/%d)", funcName, SerialPort, attempt, maxRetries)
|
||||
return port, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Warnf("%s: failed to open dispenser on %s (attempt %d/%d): %v", funcName, SerialPort, attempt, maxRetries, err)
|
||||
if attempt < maxRetries {
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%s: failed to open dispenser on %s after %d attempts: %w", funcName, SerialPort, maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// Internal (port-owner only) operations
|
||||
// --------------------
|
||||
|
||||
// checkDispenserStatus talks to the device and returns the 4 status bytes [pos0..pos3].
|
||||
func checkDispenserStatus(port *serial.Port) ([]byte, error) {
|
||||
checkCmd := buildCheckAP(Address)
|
||||
enq := append([]byte{ENQ}, Address...)
|
||||
|
||||
statusResp, err := sendAndReceive(port, checkCmd, delay)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error sending check command: %w", err)
|
||||
}
|
||||
if len(statusResp) == 0 {
|
||||
return nil, fmt.Errorf("no response from dispenser")
|
||||
}
|
||||
if err := checkACK(statusResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
statusResp, err = sendAndReceive(port, enq, delay)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error sending ENQ: %w", err)
|
||||
}
|
||||
if len(statusResp) < 13 {
|
||||
return nil, fmt.Errorf("incomplete status response from dispenser: % X", statusResp)
|
||||
}
|
||||
return statusResp[7:11], nil
|
||||
}
|
||||
|
||||
func cardToEncoderPosition(port *serial.Port) error {
|
||||
enq := append([]byte{ENQ}, Address...)
|
||||
|
||||
dispenseCmd := createPacket(Address, commandFC7)
|
||||
log.Println("Send card to encoder position")
|
||||
|
||||
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending card to encoder position: %w", err)
|
||||
}
|
||||
if err := checkACK(statusResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = port.Write(enq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending ENQ to prompt device: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cardOutOfMouth(port *serial.Port) error {
|
||||
enq := append([]byte{ENQ}, Address...)
|
||||
|
||||
dispenseCmd := createPacket(Address, commandFC0)
|
||||
log.Println("Send card to out mouth position")
|
||||
|
||||
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending out of mouth command: %w", err)
|
||||
}
|
||||
if err := checkACK(statusResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = port.Write(enq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending ENQ to prompt device: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
380
internal/dispenser/dispenserclient.go
Normal file
380
internal/dispenser/dispenserclient.go
Normal file
@ -0,0 +1,380 @@
|
||||
// Package dispenser provides a queue-based client (single owner of port).
|
||||
package dispenser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tarm/serial"
|
||||
)
|
||||
|
||||
type cmdType int
|
||||
|
||||
const (
|
||||
cmdStatus cmdType = iota
|
||||
cmdToEncoder
|
||||
cmdOutOfMouth
|
||||
)
|
||||
|
||||
type cmdReq struct {
|
||||
typ cmdType
|
||||
ctx context.Context
|
||||
respCh chan cmdResp
|
||||
}
|
||||
|
||||
type cmdResp struct {
|
||||
status []byte
|
||||
err error
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
port *serial.Port
|
||||
|
||||
reqCh chan cmdReq
|
||||
done chan struct{}
|
||||
|
||||
// status cache
|
||||
mu sync.RWMutex
|
||||
lastStatus []byte
|
||||
lastStatusT time.Time
|
||||
statusTTL time.Duration
|
||||
|
||||
// published "stock/cardwell" cache + callback
|
||||
lastStockMu sync.RWMutex
|
||||
lastStock string
|
||||
onStock func(string)
|
||||
}
|
||||
|
||||
// NewClient starts the worker that owns the serial port.
|
||||
func NewClient(port *serial.Port, queueSize int) *Client {
|
||||
if queueSize <= 0 {
|
||||
queueSize = 16
|
||||
}
|
||||
c := &Client{
|
||||
port: port,
|
||||
reqCh: make(chan cmdReq, queueSize),
|
||||
done: make(chan struct{}),
|
||||
statusTTL: defaultStatusTTL,
|
||||
}
|
||||
go c.loop()
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
default:
|
||||
close(c.done)
|
||||
}
|
||||
}
|
||||
|
||||
// SetStatusTTL sets the duration for which cached status is considered fresh.
|
||||
func (c *Client) SetStatusTTL(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.statusTTL = d
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// OnStockUpdate registers a callback called whenever polling (or status reads) produce a stock status string.
|
||||
func (c *Client) OnStockUpdate(fn func(string)) {
|
||||
c.lastStockMu.Lock()
|
||||
c.onStock = fn
|
||||
c.lastStockMu.Unlock()
|
||||
}
|
||||
|
||||
// LastStock returns the most recently computed stock/card-well status string.
|
||||
func (c *Client) LastStock() string {
|
||||
c.lastStockMu.RLock()
|
||||
defer c.lastStockMu.RUnlock()
|
||||
return c.lastStock
|
||||
}
|
||||
|
||||
func (c *Client) setStock(statusBytes []byte) {
|
||||
stock := stockTake(statusBytes)
|
||||
|
||||
c.lastStockMu.Lock()
|
||||
c.lastStock = stock
|
||||
fn := c.onStock
|
||||
c.lastStockMu.Unlock()
|
||||
|
||||
// call outside lock
|
||||
if fn != nil {
|
||||
fn(stock)
|
||||
}
|
||||
}
|
||||
|
||||
// StartPolling performs a periodic status refresh.
|
||||
// It will NOT interrupt commands: it enqueues only when queue is idle.
|
||||
func (c *Client) StartPolling(interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-t.C:
|
||||
// enqueue only if idle to avoid delaying real commands
|
||||
if len(c.reqCh) != 0 {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
_, err := c.CheckStatus(ctx)
|
||||
if err != nil {
|
||||
log.Debugf("dispenser polling: %v", err)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Client) loop() {
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case req := <-c.reqCh:
|
||||
c.handle(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handle(req cmdReq) {
|
||||
select {
|
||||
case <-req.ctx.Done():
|
||||
req.respCh <- cmdResp{err: req.ctx.Err()}
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
switch req.typ {
|
||||
case cmdStatus:
|
||||
st, err := checkDispenserStatus(c.port)
|
||||
if err == nil && len(st) == 4 {
|
||||
c.mu.Lock()
|
||||
c.lastStatus = append([]byte(nil), st...)
|
||||
c.lastStatusT = time.Now()
|
||||
c.mu.Unlock()
|
||||
|
||||
// publish stock/cardwell
|
||||
c.setStock(st)
|
||||
}
|
||||
req.respCh <- cmdResp{status: st, err: err}
|
||||
|
||||
case cmdToEncoder:
|
||||
err := cardToEncoderPosition(c.port)
|
||||
req.respCh <- cmdResp{err: err}
|
||||
|
||||
case cmdOutOfMouth:
|
||||
err := cardOutOfMouth(c.port)
|
||||
req.respCh <- cmdResp{err: err}
|
||||
|
||||
default:
|
||||
req.respCh <- cmdResp{err: fmt.Errorf("unknown command")}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, typ cmdType) ([]byte, error) {
|
||||
rch := make(chan cmdResp, 1)
|
||||
req := cmdReq{typ: typ, ctx: ctx, respCh: rch}
|
||||
|
||||
select {
|
||||
case c.reqCh <- req:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-rch:
|
||||
return r.status, r.err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// CheckStatus returns cached status if fresh, otherwise enqueues a device status read.
|
||||
func (c *Client) CheckStatus(ctx context.Context) ([]byte, error) {
|
||||
c.mu.RLock()
|
||||
ttl := c.statusTTL
|
||||
st := append([]byte(nil), c.lastStatus...)
|
||||
ts := c.lastStatusT
|
||||
c.mu.RUnlock()
|
||||
|
||||
if len(st) == 4 && time.Since(ts) <= ttl {
|
||||
// even when returning cached, keep stock in sync
|
||||
c.setStock(st)
|
||||
return st, nil
|
||||
}
|
||||
return c.do(ctx, cmdStatus)
|
||||
}
|
||||
|
||||
func (c *Client) ToEncoder(ctx context.Context) error {
|
||||
_, err := c.do(ctx, cmdToEncoder)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) OutOfMouth(ctx context.Context) error {
|
||||
_, err := c.do(ctx, cmdOutOfMouth)
|
||||
return err
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// Public sequences updated to use Client (queue)
|
||||
// --------------------
|
||||
|
||||
// DispenserPrepare checks status; if empty => ok; else ensure at encoder.
|
||||
func (c *Client) DispenserPrepare(ctx context.Context) (string, error) {
|
||||
const funcName = "DispenserPrepare"
|
||||
stockStatus := ""
|
||||
|
||||
status, err := c.CheckStatus(ctx)
|
||||
if err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err)
|
||||
}
|
||||
|
||||
logStatus(status)
|
||||
stockStatus = stockTake(status)
|
||||
c.setStock(status)
|
||||
|
||||
if isCardWellEmpty(status) {
|
||||
return stockStatus, nil
|
||||
}
|
||||
if isAtEncoderPosition(status) {
|
||||
return stockStatus, nil
|
||||
}
|
||||
|
||||
if err := c.ToEncoder(ctx); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
|
||||
}
|
||||
|
||||
time.Sleep(delay)
|
||||
status, err = c.CheckStatus(ctx)
|
||||
if err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] re-check status: %w", funcName, err)
|
||||
}
|
||||
logStatus(status)
|
||||
stockStatus = stockTake(status)
|
||||
c.setStock(status)
|
||||
|
||||
return stockStatus, nil
|
||||
}
|
||||
|
||||
func (c *Client) DispenserStart(ctx context.Context) (string, error) {
|
||||
const funcName = "DispenserStart"
|
||||
stockStatus := ""
|
||||
|
||||
status, err := c.CheckStatus(ctx)
|
||||
if err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] check status: %w", funcName, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
logStatus(status)
|
||||
}()
|
||||
stockStatus = stockTake(status)
|
||||
c.setStock(status)
|
||||
|
||||
if isCardWellEmpty(status) {
|
||||
return stockStatus, fmt.Errorf(stockStatus)
|
||||
}
|
||||
if isAtEncoderPosition(status) {
|
||||
return stockStatus, nil
|
||||
}
|
||||
|
||||
if err := c.ToEncoder(ctx); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
|
||||
}
|
||||
|
||||
halfway := time.Now().Add(6 * time.Second)
|
||||
deadline := time.Now().Add(12 * time.Second)
|
||||
|
||||
for {
|
||||
time.Sleep(delay * 2)
|
||||
switch {
|
||||
case time.Now().After(halfway):
|
||||
if err := c.ToEncoder(ctx); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
|
||||
}
|
||||
case time.Now().After(deadline):
|
||||
return stockStatus, nil
|
||||
}
|
||||
|
||||
status, _ = c.do(ctx, cmdStatus)
|
||||
|
||||
stockStatus = stockTake(status)
|
||||
c.setStock(status)
|
||||
logStatus(status)
|
||||
|
||||
// error states first
|
||||
if isCardWellEmpty(status) {
|
||||
return stockStatus, fmt.Errorf(stockStatus)
|
||||
}
|
||||
|
||||
if isAtEncoderPosition(status) {
|
||||
return stockStatus, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) DispenserFinal(ctx context.Context) (string, error) {
|
||||
const funcName = "DispenserFinal"
|
||||
stockStatus := ""
|
||||
var status []byte
|
||||
|
||||
if err := c.OutOfMouth(ctx); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] out of mouth: %w", funcName, err)
|
||||
}
|
||||
|
||||
time.Sleep(delay)
|
||||
status, err := c.do(ctx, cmdStatus)
|
||||
if err == nil && len(status) >= 4 {
|
||||
c.setStock(status)
|
||||
}
|
||||
|
||||
time.Sleep(delay)
|
||||
if err := c.ToEncoder(ctx); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
logStatus(status)
|
||||
}()
|
||||
|
||||
halfway := time.Now().Add(6 * time.Second)
|
||||
deadline := time.Now().Add(12 * time.Second)
|
||||
|
||||
for {
|
||||
time.Sleep(delay * 2)
|
||||
switch {
|
||||
case time.Now().After(halfway):
|
||||
if err := c.ToEncoder(ctx); err != nil {
|
||||
return stockStatus, fmt.Errorf("[%s] to encoder: %w", funcName, err)
|
||||
}
|
||||
case time.Now().After(deadline):
|
||||
return stockStatus, nil
|
||||
}
|
||||
|
||||
status, _ = c.do(ctx, cmdStatus)
|
||||
|
||||
stockStatus = stockTake(status)
|
||||
c.setStock(status)
|
||||
logStatus(status)
|
||||
|
||||
if isCardWellEmpty(status) {
|
||||
return stockStatus, nil
|
||||
}
|
||||
|
||||
if isAtEncoderPosition(status) {
|
||||
return stockStatus, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
326
internal/dojo/client.go
Normal file
326
internal/dojo/client.go
Normal file
@ -0,0 +1,326 @@
|
||||
package dojo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
SoftwareHouseID string
|
||||
Version string
|
||||
TerminalID string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
version string
|
||||
softwareHouseID string
|
||||
terminalID string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg Config) (*Client, error) {
|
||||
if cfg.BaseURL == "" {
|
||||
return nil, fmt.Errorf("dojo base_url is required")
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("dojo api_key is required")
|
||||
}
|
||||
if cfg.Version == "" {
|
||||
cfg.Version = "2026-02-27"
|
||||
}
|
||||
if cfg.SoftwareHouseID == "" {
|
||||
return nil, fmt.Errorf("dojo software_house_id is required")
|
||||
}
|
||||
if cfg.TerminalID == "" {
|
||||
return nil, fmt.Errorf("dojo terminal_id is required")
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
version: cfg.Version,
|
||||
softwareHouseID: cfg.SoftwareHouseID,
|
||||
terminalID: cfg.TerminalID,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*paymentsvc.Result, error) {
|
||||
if req.Currency == "" {
|
||||
req.Currency = "GBP"
|
||||
}
|
||||
|
||||
intent, err := c.createPaymentIntent(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session, err := c.createTerminalSession(ctx, intent.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session, err = c.waitForTerminalSession(ctx, session.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch strings.ToLower(session.Status) {
|
||||
case types.ResultCaptured, types.ResultSignatureAccepted:
|
||||
intent, err = c.getPaymentIntent(ctx, intent.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.ToLower(intent.Status) != types.ResultCaptured {
|
||||
return nil, fmt.Errorf("Dojo terminal session is %s but payment intent is %s", session.Status, intent.Status)
|
||||
}
|
||||
return c.mapCapturedResult(req, intent), nil
|
||||
|
||||
case types.ResultCancelled, types.ResultCanceled, types.ResultDeclined, types.ResultExpired, types.ResultSignatureRejected:
|
||||
return c.mapTerminalFailure(req, session), nil
|
||||
|
||||
case types.ResultSignatureRequired:
|
||||
result := c.baseResult(req, session.PaymentDetails)
|
||||
result.Status = "SIGNATURE_VERIFICATION_REQUIRED"
|
||||
result.ErrorMessage = "Dojo signature verification is required but is not supported"
|
||||
return result, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected final Dojo terminal session status %q", session.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) createPaymentIntent(ctx context.Context, req paymentsvc.SaleRequest) (*paymentIntentResponse, error) {
|
||||
payload := createPaymentIntentRequest{
|
||||
Amount: money{
|
||||
Value: req.Amount,
|
||||
CurrencyCode: req.Currency,
|
||||
},
|
||||
Reference: dojoReference(req),
|
||||
CaptureMode: "Auto",
|
||||
}
|
||||
|
||||
var response paymentIntentResponse
|
||||
if err := c.doJSON(ctx, http.MethodPost, "/payment-intents", payload, false, &response); err != nil {
|
||||
return nil, fmt.Errorf("create Dojo payment intent: %w", err)
|
||||
}
|
||||
if response.ID == "" {
|
||||
return nil, fmt.Errorf("create Dojo payment intent: response did not contain id")
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (c *Client) createTerminalSession(ctx context.Context, paymentIntentID string) (*terminalSessionResponse, error) {
|
||||
payload := createTerminalSessionRequest{
|
||||
TerminalID: c.terminalID,
|
||||
Details: terminalSessionDetails{
|
||||
SessionType: "Sale",
|
||||
Sale: terminalSessionSale{
|
||||
PaymentIntentID: paymentIntentID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var response terminalSessionResponse
|
||||
if err := c.doJSON(ctx, http.MethodPost, "/terminal-sessions", payload, true, &response); err != nil {
|
||||
return nil, fmt.Errorf("create Dojo terminal session: %w", err)
|
||||
}
|
||||
if response.ID == "" {
|
||||
return nil, fmt.Errorf("create Dojo terminal session: response did not contain id")
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (c *Client) waitForTerminalSession(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
session, err := c.getTerminalSession(ctx, terminalSessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch strings.ToLower(session.Status) {
|
||||
case types.ResultInitiateRequested, types.ResultInitiated, types.ResultAuthorized, types.ResultCancelRequested:
|
||||
// Still processing. For this integration payment intents use captureMode Auto,
|
||||
// so Authorized is expected to continue to Captured.
|
||||
|
||||
case types.ResultCaptured, types.ResultCancelled, types.ResultCanceled, types.ResultDeclined, types.ResultExpired,
|
||||
types.ResultSignatureRequired, types.ResultSignatureAccepted,
|
||||
types.ResultSignatureRejected:
|
||||
return session, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected Dojo terminal session status %q", session.Status)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) getTerminalSession(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) {
|
||||
var response terminalSessionResponse
|
||||
path := "/terminal-sessions/" + url.PathEscape(terminalSessionID)
|
||||
if err := c.doJSON(ctx, http.MethodGet, path, nil, true, &response); err != nil {
|
||||
return nil, fmt.Errorf("get Dojo terminal session: %w", err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (c *Client) getPaymentIntent(ctx context.Context, paymentIntentID string) (*paymentIntentResponse, error) {
|
||||
var response paymentIntentResponse
|
||||
path := "/payment-intents/" + url.PathEscape(paymentIntentID) + "?returnCanceled=true"
|
||||
if err := c.doJSON(ctx, http.MethodGet, path, nil, false, &response); err != nil {
|
||||
return nil, fmt.Errorf("get Dojo payment intent: %w", err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (c *Client) mapCapturedResult(req paymentsvc.SaleRequest, intent *paymentIntentResponse) *paymentsvc.Result {
|
||||
result := c.baseResult(req, intent.PaymentDetails)
|
||||
result.Success = true
|
||||
result.Status = "APPROVED"
|
||||
|
||||
if intent.Amount.Value != 0 {
|
||||
result.Amount = intent.Amount.Value
|
||||
}
|
||||
if intent.Amount.CurrencyCode != "" {
|
||||
result.Currency = intent.Amount.CurrencyCode
|
||||
}
|
||||
if result.Message == "" {
|
||||
result.Message = "Payment approved"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *Client) mapTerminalFailure(req paymentsvc.SaleRequest, session *terminalSessionResponse) *paymentsvc.Result {
|
||||
result := c.baseResult(req, session.PaymentDetails)
|
||||
result.Status = strings.ToUpper(session.Status)
|
||||
result.ErrorMessage = "Dojo terminal session ended with status " + session.Status
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *Client) baseResult(req paymentsvc.SaleRequest, details *paymentDetails) *paymentsvc.Result {
|
||||
result := &paymentsvc.Result{
|
||||
RequestID: req.RequestID,
|
||||
Operation: "SALE",
|
||||
Amount: req.Amount,
|
||||
Currency: req.Currency,
|
||||
DeviceUsed: c.terminalID,
|
||||
DeviceType: "Dojo Terminal",
|
||||
}
|
||||
|
||||
if details == nil {
|
||||
return result
|
||||
}
|
||||
|
||||
result.TransactionID = details.TransactionID
|
||||
result.ReferenceNumber = req.RequestID
|
||||
result.AuthCode = details.AuthCode
|
||||
result.Message = details.Message
|
||||
result.CardNumber = details.Card.CardNumber
|
||||
result.CardType = details.Card.CardType
|
||||
result.ExpiryDate = details.Card.ExpiryDate
|
||||
result.LastFourDigits = details.Card.CardNumber
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method, path string, payload any, terminalRequest bool, target any) error {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode request: %w", err)
|
||||
}
|
||||
body = bytes.NewReader(encoded)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Basic "+c.apiKey)
|
||||
req.Header.Set("Version", c.version)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if terminalRequest {
|
||||
req.Header.Set("software-house-id", c.softwareHouseID)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < http.StatusOK ||
|
||||
resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return fmt.Errorf("Dojo returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
|
||||
}
|
||||
|
||||
if target == nil || len(responseBody) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(responseBody, target); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func dojoReference(req paymentsvc.SaleRequest) string {
|
||||
reference := req.RequestID
|
||||
if reference == "" {
|
||||
reference = req.Reference
|
||||
}
|
||||
|
||||
runes := []rune(reference)
|
||||
if len(runes) > 60 {
|
||||
runes = runes[:60]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func lastFour(cardNumber string) string {
|
||||
digits := make([]byte, 0, len(cardNumber))
|
||||
for i := 0; i < len(cardNumber); i++ {
|
||||
if cardNumber[i] >= '0' && cardNumber[i] <= '9' {
|
||||
digits = append(digits, cardNumber[i])
|
||||
}
|
||||
}
|
||||
if len(digits) <= 4 {
|
||||
return string(digits)
|
||||
}
|
||||
return string(digits[len(digits)-4:])
|
||||
}
|
||||
59
internal/dojo/types.go
Normal file
59
internal/dojo/types.go
Normal file
@ -0,0 +1,59 @@
|
||||
package dojo
|
||||
|
||||
type money struct {
|
||||
Value int64 `json:"value"`
|
||||
CurrencyCode string `json:"currencyCode"`
|
||||
}
|
||||
|
||||
type createPaymentIntentRequest struct {
|
||||
Amount money `json:"amount"`
|
||||
Reference string `json:"reference"`
|
||||
CaptureMode string `json:"captureMode"`
|
||||
}
|
||||
|
||||
type createTerminalSessionRequest struct {
|
||||
TerminalID string `json:"terminalId"`
|
||||
Details terminalSessionDetails `json:"details"`
|
||||
}
|
||||
|
||||
type terminalSessionDetails struct {
|
||||
SessionType string `json:"sessionType"`
|
||||
Sale terminalSessionSale `json:"sale"`
|
||||
}
|
||||
|
||||
type terminalSessionSale struct {
|
||||
PaymentIntentID string `json:"paymentIntentId"`
|
||||
}
|
||||
|
||||
type terminalSessionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
TerminalID string `json:"terminalId"`
|
||||
PaymentDetails *paymentDetails `json:"paymentDetails"`
|
||||
}
|
||||
|
||||
type paymentIntentResponse struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Reference string `json:"reference"`
|
||||
Amount money `json:"amount"`
|
||||
PaymentDetails *paymentDetails `json:"paymentDetails"`
|
||||
}
|
||||
|
||||
type paymentDetails struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
TransactionDateTime string `json:"transactionDateTime"`
|
||||
Message string `json:"message"`
|
||||
AuthCode string `json:"authCode"`
|
||||
Card dojoCard `json:"card"`
|
||||
}
|
||||
|
||||
type dojoCard struct {
|
||||
CardNumber string `json:"cardNumber"`
|
||||
CardName string `json:"cardName"`
|
||||
ExpiryDate string `json:"expiryDate"`
|
||||
CardType string `json:"cardType"`
|
||||
CardFundingType string `json:"cardFundingType"`
|
||||
EntryMode string `json:"entryMode"`
|
||||
VerificationMethod string `json:"verificationMethod"`
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package handlers
|
||||
package errorhandlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@ -7,14 +7,12 @@ import (
|
||||
"os"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/logging"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const serviceName = "hardlink"
|
||||
|
||||
// writeError is a helper to send a JSON error and HTTP status in one go.
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
func WriteError(w http.ResponseWriter, status int, msg string) {
|
||||
theResponse := cmstypes.StatusRec{
|
||||
Code: status,
|
||||
Message: msg,
|
||||
@ -24,14 +22,6 @@ func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
json.NewEncoder(w).Encode(theResponse)
|
||||
}
|
||||
|
||||
func writeTransactionResult(w http.ResponseWriter, status int, theResponse cmstypes.ResponseRec) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(theResponse); err != nil {
|
||||
logging.Error(serviceName, err.Error(), "JSON encode error", "startTransaction", "", "", 0)
|
||||
}
|
||||
}
|
||||
|
||||
func FatalError(err error) {
|
||||
fmt.Println(err.Error())
|
||||
log.Errorf(err.Error())
|
||||
@ -39,3 +29,8 @@ func FatalError(err error) {
|
||||
fmt.Scanln()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func FatalErrorWithMail(hotel string, kiosk int, title string, err error) {
|
||||
mail.SendEmailOnError(hotel, kiosk, title, err.Error())
|
||||
FatalError(err)
|
||||
}
|
||||
206
internal/handlers/db_helpers.go
Normal file
206
internal/handlers/db_helpers.go
Normal file
@ -0,0 +1,206 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/db"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type preauthSpoolRecord struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CheckoutDate string `json:"checkoutDate"` // keep as received
|
||||
Fields map[string]string `json:"fields"` // ChipDNA result.Fields
|
||||
}
|
||||
|
||||
func (app *App) getDB(ctx context.Context) (*sql.DB, error) {
|
||||
app.dbMu.Lock()
|
||||
defer app.dbMu.Unlock()
|
||||
|
||||
// Fast path: db exists and is alive
|
||||
if app.db != nil {
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := app.db.PingContext(pingCtx); err == nil {
|
||||
return app.db, nil
|
||||
}
|
||||
|
||||
// stale handle
|
||||
_ = app.db.Close()
|
||||
app.db = nil
|
||||
}
|
||||
|
||||
// Reconnect once, bounded
|
||||
dialCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dbConn, err := db.InitMSSQL(
|
||||
app.cfg.Dbport,
|
||||
app.cfg.Dbuser,
|
||||
app.cfg.Dbpassword,
|
||||
app.cfg.Dbname,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pingCtx, cancel2 := context.WithTimeout(dialCtx, 1*time.Second)
|
||||
defer cancel2()
|
||||
|
||||
if err := dbConn.PingContext(pingCtx); err != nil {
|
||||
_ = dbConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
app.db = dbConn
|
||||
return app.db, nil
|
||||
}
|
||||
|
||||
func (app *App) spoolPath() string {
|
||||
// keep it near logs; adjust if you prefer a dedicated dir
|
||||
// ensure LogDir ends with separator in your config loader
|
||||
return filepath.Join(app.cfg.LogDir, "preauth_spool.ndjson")
|
||||
}
|
||||
|
||||
// persistPreauth tries DB first; if DB is down or insert fails, it spools to file.
|
||||
// It never returns an error to the caller (so your HTTP flow stays simple),
|
||||
// but it logs failures.
|
||||
func (app *App) persistPreauth(ctx context.Context, fields map[string]string, checkoutDate string) {
|
||||
// First, try DB (with your reconnect logic inside getDB)
|
||||
dbConn, err := app.getDB(ctx)
|
||||
if err == nil && dbConn != nil {
|
||||
if err := db.InsertPreauth(ctx, dbConn, fields, checkoutDate); err == nil {
|
||||
// opportunistic drain once DB is alive
|
||||
go app.drainPreauthSpool(context.Background())
|
||||
return
|
||||
} else {
|
||||
log.WithError(err).Warn("DB insert failed; will spool preauth")
|
||||
}
|
||||
} else {
|
||||
log.WithError(err).Warn("DB unavailable; will spool preauth")
|
||||
}
|
||||
|
||||
// Fallback: spool to file
|
||||
rec := preauthSpoolRecord{
|
||||
CreatedAt: time.Now().UTC(),
|
||||
CheckoutDate: checkoutDate,
|
||||
Fields: fields,
|
||||
}
|
||||
if spErr := app.spoolPreauth(rec); spErr != nil {
|
||||
log.WithError(spErr).Error("failed to spool preauth")
|
||||
}
|
||||
}
|
||||
|
||||
// append one line JSON (NDJSON)
|
||||
func (app *App) spoolPreauth(rec preauthSpoolRecord) error {
|
||||
p := app.spoolPath()
|
||||
|
||||
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open spool file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
b, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal spool record: %w", err)
|
||||
}
|
||||
|
||||
if _, err := f.Write(append(b, '\n')); err != nil {
|
||||
return fmt.Errorf("write spool record: %w", err)
|
||||
}
|
||||
|
||||
return f.Sync() // ensure it's on disk
|
||||
}
|
||||
|
||||
// Drain spool into DB.
|
||||
// Strategy: read all lines, insert each; keep failures in a temp file; then replace original.
|
||||
func (app *App) drainPreauthSpool(ctx context.Context) {
|
||||
dbConn, err := app.getDB(ctx)
|
||||
if err != nil {
|
||||
return // still down, nothing to do
|
||||
}
|
||||
|
||||
spool := app.spoolPath()
|
||||
in, err := os.Open(spool)
|
||||
if err != nil {
|
||||
// no spool is fine
|
||||
return
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
tmp := spool + ".tmp"
|
||||
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0666)
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("drain spool: open tmp failed")
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
sc := bufio.NewScanner(in)
|
||||
// allow long lines if receipts ever sneak in (shouldn't, but safe)
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
sc.Buffer(buf, 2*1024*1024)
|
||||
|
||||
var (
|
||||
okCount int
|
||||
failCount int
|
||||
)
|
||||
|
||||
for sc.Scan() {
|
||||
line := sc.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var rec preauthSpoolRecord
|
||||
if err := json.Unmarshal(line, &rec); err != nil {
|
||||
// malformed line: keep it so we don't lose evidence
|
||||
_, _ = out.Write(append(line, '\n'))
|
||||
failCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// attempt insert
|
||||
if err := db.InsertPreauth(ctx, dbConn, rec.Fields, rec.CheckoutDate); err != nil {
|
||||
// DB still flaky or data issue: keep it for later retry
|
||||
_, _ = out.Write(append(line, '\n'))
|
||||
failCount++
|
||||
continue
|
||||
}
|
||||
|
||||
okCount++
|
||||
}
|
||||
|
||||
if err := sc.Err(); err != nil {
|
||||
log.WithError(err).Warn("drain spool: scanner error")
|
||||
// best effort; do not replace spool
|
||||
return
|
||||
}
|
||||
|
||||
_ = out.Sync()
|
||||
|
||||
// Replace original spool with temp (atomic on Windows is best-effort; still OK here)
|
||||
_ = in.Close()
|
||||
_ = out.Close()
|
||||
|
||||
if err := os.Rename(tmp, spool); err != nil {
|
||||
log.WithError(err).Warn("drain spool: rename failed")
|
||||
return
|
||||
}
|
||||
|
||||
if okCount > 0 || failCount > 0 {
|
||||
log.WithFields(log.Fields{
|
||||
"inserted": okCount,
|
||||
"remaining": failCount,
|
||||
}).Info("preauth spool drained")
|
||||
}
|
||||
}
|
||||
503
internal/handlers/handlers.go
Normal file
503
internal/handlers/handlers.go
Normal file
@ -0,0 +1,503 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/dispenser"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/printer"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
"gitea.futuresens.co.uk/futuresens/logging"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
disp *dispenser.Client
|
||||
lockserver lockserver.LockServer
|
||||
paymentService *paymentsvc.Service
|
||||
isPayment bool
|
||||
db *sql.DB
|
||||
cfg *config.ConfigRec
|
||||
dbMu sync.Mutex
|
||||
cardWellMu sync.RWMutex
|
||||
cardWellStatus string
|
||||
availabilityMu sync.Mutex
|
||||
availabilityTimers map[string]*time.Timer
|
||||
}
|
||||
|
||||
func NewApp(disp *dispenser.Client, lockType, encoderAddress, cardWellStatus string, db *sql.DB, cfg *config.ConfigRec) *App {
|
||||
app := &App{
|
||||
isPayment: cfg.IsPayment,
|
||||
disp: disp,
|
||||
lockserver: lockserver.NewLockServer(lockType, encoderAddress, errorhandlers.FatalError),
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
availabilityTimers: make(map[string]*time.Timer),
|
||||
}
|
||||
app.SetCardWellStatus(cardWellStatus)
|
||||
return app
|
||||
}
|
||||
|
||||
func (app *App) SetPaymentService(service *paymentsvc.Service) {
|
||||
app.paymentService = service
|
||||
}
|
||||
|
||||
func (app *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/issuedoorcard", app.issueDoorCard)
|
||||
mux.HandleFunc("/printroomticket", app.printRoomTicket)
|
||||
mux.HandleFunc("/takepreauth", app.takePreauthorization)
|
||||
mux.HandleFunc("/takepayment", app.takePayment)
|
||||
mux.HandleFunc("/dispenserstatus", app.reportDispenserStatus)
|
||||
mux.HandleFunc("/testissuedoorcard", app.testIssueDoorCard)
|
||||
mux.HandleFunc("/ping-pdq", app.fetchChipDNAStatus)
|
||||
mux.HandleFunc("/logerror", app.onChipDNAError)
|
||||
mux.HandleFunc("/api/payment/sale", app.salePayment)
|
||||
}
|
||||
|
||||
func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("takePreauthorization")
|
||||
|
||||
var (
|
||||
theResponse cmstypes.ResponseRec
|
||||
theRequest cmstypes.TransactionRec
|
||||
trResult creditcall.TransactionResultXML
|
||||
result creditcall.PaymentResult
|
||||
save bool
|
||||
)
|
||||
|
||||
theResponse.Status.Code = http.StatusInternalServerError
|
||||
theResponse.Status.Message = "500 Internal server error"
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if !app.isPayment {
|
||||
if !app.cfg.TestMode {
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted preauthorization while payment processing is disabled")
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Payment processing is disabled")
|
||||
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("takePreauthorization called")
|
||||
if r.Method != http.MethodPost {
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Method not allowed; use POST")
|
||||
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("Content-Type") != "text/xml" {
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
|
||||
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Read body error", string(op), "", "", 0)
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Failed to read request body")
|
||||
writeTransactionResult(w, http.StatusBadRequest, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &theRequest); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Invalid XML payload")
|
||||
writeTransactionResult(w, http.StatusBadRequest, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"Preauthorization payload: Amount=%s, Type=%s",
|
||||
theRequest.AmountMinorUnits,
|
||||
theRequest.TransactionType,
|
||||
)
|
||||
|
||||
client := &http.Client{Timeout: 300 * time.Second}
|
||||
|
||||
// ---- START TRANSACTION ----
|
||||
|
||||
body, err = callChipDNA(client, types.LinkStartTransaction, body)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Preauth processing error", string(op), "", "", 0)
|
||||
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "No response from payment processor")
|
||||
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
if err := trResult.ParseTransactionResult(body); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
|
||||
}
|
||||
|
||||
result.FillFromTransactionResult(trResult)
|
||||
|
||||
// ---- PRINT RECEIPT ----
|
||||
|
||||
printer.PrintReceipt(result.CardholderReceipt)
|
||||
|
||||
// ---- REDIRECT ----
|
||||
|
||||
theResponse.Status = result.Status
|
||||
theResponse.Data, save = creditcall.BuildPreauthRedirectURL(result.Fields)
|
||||
|
||||
if save {
|
||||
go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate)
|
||||
}
|
||||
|
||||
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||
}
|
||||
|
||||
func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("takePayment")
|
||||
|
||||
var (
|
||||
theResponse cmstypes.ResponseRec
|
||||
theRequest cmstypes.TransactionRec
|
||||
trResult creditcall.TransactionResultXML
|
||||
result creditcall.PaymentResult
|
||||
)
|
||||
|
||||
theResponse.Status.Code = http.StatusInternalServerError
|
||||
theResponse.Status.Message = "500 Internal server error"
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if !app.isPayment {
|
||||
if !app.cfg.TestMode {
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted payment while payment processing is disabled")
|
||||
theResponse.Status.Code = http.StatusServiceUnavailable
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Payment processing is disabled")
|
||||
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("takePayment called")
|
||||
if r.Method != http.MethodPost {
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Method not allowed; use POST")
|
||||
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("Content-Type") != "text/xml" {
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
|
||||
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Read body error", string(op), "", "", 0)
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "Failed to read request body")
|
||||
writeTransactionResult(w, http.StatusBadRequest, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &theRequest); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
||||
theResponse.Data = creditcall.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}
|
||||
|
||||
// ---- START TRANSACTION ----
|
||||
|
||||
body, err = callChipDNA(client, types.LinkStartTransaction, body)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Start transaction error", string(op), "", "", 0)
|
||||
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "No response from payment processor")
|
||||
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
if err := trResult.ParseTransactionResult(body); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
|
||||
}
|
||||
|
||||
result.FillFromTransactionResult(trResult)
|
||||
|
||||
res := result.Fields[types.TransactionResult]
|
||||
|
||||
if !strings.EqualFold(res, types.ResultApproved) {
|
||||
printer.PrintReceipt(result.CardholderReceipt)
|
||||
desc := result.Fields[types.ErrorDescription]
|
||||
if desc == "" {
|
||||
desc = result.Fields[types.Errors]
|
||||
}
|
||||
logging.Error(types.ServiceName, "Preauthorization failed", "Result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
|
||||
theResponse.Status = result.Status
|
||||
theResponse.Data = creditcall.BuildFailureURL(res, result.Fields[types.Errors])
|
||||
|
||||
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
// ---- CONFIRM TRANSACTION ----
|
||||
|
||||
ref := result.Fields[types.Reference]
|
||||
log.Printf("Preauth approved, reference: %s. Sending confirm...", ref)
|
||||
confirmReq := creditcall.ConfirmTransactionRequest{
|
||||
Amount: theRequest.AmountMinorUnits,
|
||||
Reference: ref,
|
||||
}
|
||||
|
||||
body, err = confirmWithRetry(client, confirmReq, 2)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Confirm transaction error", string(op), "", "", 0)
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Error: "+err.Error())
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "ConfirmTransactionError")
|
||||
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
if err := trResult.ParseTransactionResult(body); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Parse confirm result error", string(op), "", "", 0)
|
||||
}
|
||||
|
||||
result.FillFromTransactionResult(trResult)
|
||||
|
||||
res = result.Fields[types.TransactionResult]
|
||||
|
||||
if !strings.EqualFold(res, types.ResultApproved) {
|
||||
printer.PrintReceipt(result.CardholderReceipt)
|
||||
desc := result.Fields[types.ErrorDescription]
|
||||
if desc == "" {
|
||||
desc = result.Fields[types.Errors]
|
||||
}
|
||||
logging.Error(types.ServiceName, "Transaction not approved after confirm", "Confirm result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Confirm result: "+res+" Description: "+desc)
|
||||
theResponse.Status = result.Status
|
||||
theResponse.Data = creditcall.BuildFailureURL(res, result.Fields[types.Errors])
|
||||
|
||||
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
// ---- SUCCESS ----
|
||||
|
||||
printer.PrintReceipt(result.CardholderReceipt)
|
||||
log.Printf("Transaction approved and confirmed, reference: %s", ref)
|
||||
theResponse.Status = result.Status
|
||||
theResponse.Data = creditcall.BuildSuccessURL(result.Fields)
|
||||
|
||||
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||
}
|
||||
|
||||
func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("issueDoorCard")
|
||||
var (
|
||||
doorReq lockserver.DoorCardRequest
|
||||
theResponse cmstypes.StatusRec
|
||||
)
|
||||
|
||||
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("issueDoorCard called")
|
||||
if r.Method != http.MethodPost {
|
||||
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
errorhandlers.WriteError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "ReadJSON", string(op), "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// parse times
|
||||
checkIn, err := time.Parse(types.CustomLayout, doorReq.CheckinTime)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Invalid checkinTime format", string(op), "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error())
|
||||
return
|
||||
}
|
||||
checkOut, err := time.Parse(types.CustomLayout, doorReq.CheckoutTime)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure dispenser ready (card at encoder) BEFORE we attempt encoding.
|
||||
// With queued dispenser ops, this will not clash with polling.
|
||||
status, err := app.disp.DispenserStart(r.Context())
|
||||
app.SetCardWellStatus(status)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Always attempt to finalize after we have moved a card / started an issuance flow.
|
||||
// This guarantees we eject and prepare the next card even on lock failures.
|
||||
finalize := func() {
|
||||
if app.disp == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
status, ferr := app.disp.DispenserFinal(ctx)
|
||||
if ferr != nil {
|
||||
logging.Error(types.ServiceName, ferr.Error(), "Dispenser final error", string(op), "", "", 0)
|
||||
return
|
||||
}
|
||||
app.SetCardWellStatus(status)
|
||||
}
|
||||
|
||||
doorReq.RoomField = "104"
|
||||
// build lock server command
|
||||
app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
|
||||
|
||||
// lock server sequence
|
||||
if err := app.lockserver.LockSequence(); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Key encoding", string(op), "", "", 0)
|
||||
finalize()
|
||||
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// final dispenser steps
|
||||
finalize()
|
||||
|
||||
theResponse.Code = http.StatusOK
|
||||
theResponse.Message = "Card issued successfully"
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(theResponse)
|
||||
}
|
||||
|
||||
func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("printRoomTicket")
|
||||
var roomDetails printer.RoomDetailsRec
|
||||
// Allow CORS preflight if needed
|
||||
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
|
||||
}
|
||||
log.Println("printRoomTicket called")
|
||||
if r.Method != http.MethodPost {
|
||||
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
|
||||
return
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "xml") {
|
||||
errorhandlers.WriteError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/xml")
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
if err := xml.NewDecoder(r.Body).Decode(&roomDetails); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid XML payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
data, err := printer.BuildRoomTicket(roomDetails)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "BuildRoomTicket", string(op), "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusInternalServerError, "BuildRoomTicket failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Send to the Windows Epson TM-T82II via the printer package
|
||||
if err := printer.SendToPrinter(data); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "printRoomTicket", "printRoomTicket", "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusInternalServerError, "Print failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(cmstypes.StatusRec{
|
||||
Code: http.StatusOK,
|
||||
Message: "Print job sent successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) reportDispenserStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(cmstypes.StatusRec{
|
||||
Code: http.StatusOK,
|
||||
Message: app.CardWellStatus(),
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) SetCardWellStatus(s string) {
|
||||
app.cardWellMu.Lock()
|
||||
prev := app.cardWellStatus
|
||||
app.cardWellStatus = s
|
||||
app.cardWellMu.Unlock()
|
||||
|
||||
if s != "" && prev != s {
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Dispenser Error Status", "Status: "+s)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) CardWellStatus() string {
|
||||
app.cardWellMu.RLock()
|
||||
defer app.cardWellMu.RUnlock()
|
||||
return app.cardWellStatus
|
||||
}
|
||||
82
internal/handlers/http_helpers.go
Normal file
82
internal/handlers/http_helpers.go
Normal file
@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
"gitea.futuresens.co.uk/futuresens/logging"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func writeTransactionResult(w http.ResponseWriter, status int, theResponse cmstypes.ResponseRec) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(theResponse); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "JSON encode error", "startTransaction", "", "", 0)
|
||||
}
|
||||
}
|
||||
|
||||
func callChipDNA(client *http.Client, url string, payload []byte) ([]byte, error) {
|
||||
|
||||
resp, err := client.Post(url, "text/xml", bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func confirmWithRetry(client *http.Client, req creditcall.ConfirmTransactionRequest, attempts int) ([]byte, error) {
|
||||
|
||||
payload, err := xml.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
|
||||
for i := 1; i <= attempts; i++ {
|
||||
|
||||
resp, err := client.Post(types.LinkConfirmTransaction, "text/xml", bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if readErr != nil {
|
||||
lastErr = readErr
|
||||
} else {
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Warnf("ConfirmTransaction attempt %d/%d failed: %v", i, attempts, lastErr)
|
||||
|
||||
if i < attempts {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if payload == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
204
internal/handlers/payment_handlers.go
Normal file
204
internal/handlers/payment_handlers.go
Normal file
@ -0,0 +1,204 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
"gitea.futuresens.co.uk/futuresens/logging"
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type SalePaymentRequest struct {
|
||||
Reference string `json:"reference,omitempty"`
|
||||
ConfirmNo string `json:"confirmNo,omitempty"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("salePayment")
|
||||
var response = cmstypes.ResponseRec{
|
||||
Status: cmstypes.StatusRec{
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: http.StatusText(http.StatusInternalServerError),
|
||||
},
|
||||
}
|
||||
|
||||
setPaymentCORS(w)
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, "Method not allowed; use POST")
|
||||
writeTransactionResult(w, http.StatusMethodNotAllowed, response)
|
||||
return
|
||||
}
|
||||
if app.paymentService == nil {
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Service Not Configured", "Payment service is not configured; cannot process payment requests")
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, "Payment service is not configured")
|
||||
writeTransactionResult(w, http.StatusInternalServerError, response)
|
||||
return
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") {
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, "Content-Type must be application/json")
|
||||
writeTransactionResult(w, http.StatusUnsupportedMediaType, response)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var req SalePaymentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "ReadJSON", string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, "invalid JSON payload: "+err.Error())
|
||||
writeTransactionResult(w, http.StatusBadRequest, response)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount <= 0 {
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, "Amount must be greater than zero")
|
||||
writeTransactionResult(w, http.StatusBadRequest, response)
|
||||
return
|
||||
}
|
||||
if req.Currency == "" {
|
||||
req.Currency = "GBP"
|
||||
}
|
||||
if req.Reference == "" {
|
||||
req.Reference = req.ConfirmNo
|
||||
}
|
||||
if req.Reference == "" {
|
||||
req.Reference = uuid.NewString()
|
||||
}
|
||||
|
||||
requestID := buildPaymentRequestID(req.Reference)
|
||||
timeoutSeconds := app.cfg.TimeoutSeconds
|
||||
if timeoutSeconds <= 0 {
|
||||
timeoutSeconds = 300
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := app.paymentService.Sale(ctx, paymentsvc.SaleRequest{
|
||||
RequestID: requestID,
|
||||
Reference: req.Reference,
|
||||
Amount: req.Amount,
|
||||
Currency: req.Currency,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
status := http.StatusBadGateway
|
||||
if errors.Is(err, paymentsvc.ErrPaymentInProgress) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
|
||||
logging.Error(types.ServiceName, err.Error(), "Payment provider error", string(op), req.Reference, app.cfg.Hotel, app.cfg.Kiosk)
|
||||
|
||||
response.Status.Code = status
|
||||
response.Status.Message = http.StatusText(status)
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, err.Error())
|
||||
writeTransactionResult(w, status, response)
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
response.Status.Code = http.StatusBadGateway
|
||||
response.Status.Message = "Empty payment result"
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, "Payment provider returned an empty result")
|
||||
writeTransactionResult(w, http.StatusBadGateway, response)
|
||||
return
|
||||
}
|
||||
|
||||
response.Status.Code = http.StatusOK
|
||||
|
||||
if result.Success && strings.EqualFold(result.Status, "APPROVED") {
|
||||
response.Status.Message = result.Message
|
||||
response.Data = buildPaymentSuccessURL(result)
|
||||
writeTransactionResult(w, http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
description := result.ErrorMessage
|
||||
if description == "" {
|
||||
description = result.Message
|
||||
}
|
||||
if description == "" {
|
||||
description = result.Status
|
||||
}
|
||||
|
||||
response.Status.Message = "Payment unsuccessful"
|
||||
response.Data = buildPaymentFailureURL(types.ResultError, description)
|
||||
writeTransactionResult(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func buildPaymentRequestID(reference string) string {
|
||||
const prefix = "REQ_"
|
||||
const maxLength = 60
|
||||
|
||||
suffix := fmt.Sprintf("_%d", time.Now().UnixMilli())
|
||||
maxReferenceLength := maxLength - len(prefix) - len(suffix)
|
||||
|
||||
runes := []rune(reference)
|
||||
if len(runes) > maxReferenceLength {
|
||||
runes = runes[:maxReferenceLength]
|
||||
}
|
||||
|
||||
return prefix + string(runes) + suffix
|
||||
}
|
||||
|
||||
func setPaymentCORS(w http.ResponseWriter) {
|
||||
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")
|
||||
}
|
||||
|
||||
func buildPaymentSuccessURL(result *paymentsvc.Result) string {
|
||||
txnReference := result.ReferenceNumber
|
||||
if txnReference == "" {
|
||||
txnReference = result.TransactionID
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("CardNumber", hex.EncodeToString([]byte(result.CardNumber)))
|
||||
q.Set("CardType", hex.EncodeToString([]byte(result.CardType)))
|
||||
q.Set("ExpiryDate", hex.EncodeToString([]byte(result.ExpiryDate)))
|
||||
q.Set("TxnReference", txnReference)
|
||||
q.Set("CardHash", hex.EncodeToString([]byte(result.CardHash)))
|
||||
q.Set("CardReference", hex.EncodeToString([]byte(result.CardReference)))
|
||||
|
||||
return (&url.URL{
|
||||
Path: types.CheckinSuccessfulEndpoint,
|
||||
RawQuery: q.Encode(),
|
||||
}).String()
|
||||
}
|
||||
|
||||
func buildPaymentFailureURL(msgType, description string) string {
|
||||
log.WithFields(log.Fields{
|
||||
types.LogFieldError: msgType,
|
||||
types.LogFieldDescription: description,
|
||||
}).Error("Transaction failed")
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("MsgType", msgType)
|
||||
q.Set("Description", description)
|
||||
|
||||
return (&url.URL{
|
||||
Path: types.CheckinUnsuccessfulEndpoint,
|
||||
RawQuery: q.Encode(),
|
||||
}).String()
|
||||
}
|
||||
252
internal/handlers/testhandlers.go
Normal file
252
internal/handlers/testhandlers.go
Normal file
@ -0,0 +1,252 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
"gitea.futuresens.co.uk/futuresens/logging"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (app *App) testIssueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("issueDoorCard")
|
||||
var (
|
||||
doorReq lockserver.DoorCardRequest
|
||||
theResponse cmstypes.StatusRec
|
||||
)
|
||||
|
||||
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("issueDoorCard called")
|
||||
if r.Method != http.MethodPost {
|
||||
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
errorhandlers.WriteError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "ReadJSON", string(op), "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
checkIn := time.Date(now.Year(), now.Month(), now.Day(), 23, 0, 0, 0, now.Location())
|
||||
checkOut := checkIn.Add(2 * time.Hour)
|
||||
|
||||
// Ensure dispenser ready (card at encoder) BEFORE we attempt encoding.
|
||||
// With queued dispenser ops, this will not clash with polling.
|
||||
status, err := app.disp.DispenserStart(r.Context())
|
||||
app.SetCardWellStatus(status)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// build lock server command
|
||||
app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
|
||||
|
||||
// lock server sequence
|
||||
if err := app.lockserver.LockSequence(); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Key encoding", string(op), "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
theResponse.Code = http.StatusOK
|
||||
theResponse.Message = "Card issued successfully"
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(theResponse)
|
||||
}
|
||||
|
||||
func (app *App) fetchChipDNAStatus(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("fetchChipDNAStatus")
|
||||
var theResponse cmstypes.StatusRec
|
||||
|
||||
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")
|
||||
status, err := creditcall.ReadPdqStatus(app.cfg.Hotel, app.cfg.Kiosk)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "fetchChipDNAStatus", string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
|
||||
errorhandlers.WriteError(w, http.StatusServiceUnavailable, err.Error())
|
||||
return
|
||||
}
|
||||
b, err := json.MarshalIndent(status, "", " ")
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "MarshalIndent", string(op), "", "", 0)
|
||||
errorhandlers.WriteError(w, http.StatusInternalServerError, "Failed to marshal status data")
|
||||
return
|
||||
}
|
||||
theResponse.Code = http.StatusOK
|
||||
theResponse.Message = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(theResponse)
|
||||
}
|
||||
|
||||
func (app *App) onChipDNAError(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("onChipDNAError")
|
||||
var tr creditcall.TransactionResultXML
|
||||
title := "ChipDNA Error"
|
||||
message := ""
|
||||
|
||||
log.Println("onChipDNAError called")
|
||||
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
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
errorhandlers.WriteError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
message = "Failed to read request body: " + err.Error()
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, title, message)
|
||||
errorhandlers.WriteError(w, http.StatusBadRequest, "Unable to read request body")
|
||||
return
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
message = "Received empty request body"
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, title, message)
|
||||
errorhandlers.WriteError(w, http.StatusBadRequest, "Empty body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tr.ParseTransactionResult(body); err != nil {
|
||||
logging.Error(
|
||||
types.ServiceName,
|
||||
err.Error(),
|
||||
"Parse transaction result error",
|
||||
string(op),
|
||||
"",
|
||||
app.cfg.Hotel,
|
||||
app.cfg.Kiosk,
|
||||
)
|
||||
message = "Failed to parse transaction result: " + err.Error()
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, title, message)
|
||||
errorhandlers.WriteError(w, http.StatusBadRequest, "Invalid XML")
|
||||
return
|
||||
}
|
||||
|
||||
for _, e := range tr.Entries {
|
||||
|
||||
switch e.Key {
|
||||
|
||||
case creditcall.KeyErrors:
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, title, e.Value)
|
||||
|
||||
case creditcall.KeyIsAvailable:
|
||||
isAvailable := strings.EqualFold(e.Value, "true")
|
||||
app.handleAvailabilityDebounced(isAvailable)
|
||||
}
|
||||
|
||||
logging.Error(
|
||||
types.ServiceName,
|
||||
e.Value,
|
||||
e.Key,
|
||||
string(op),
|
||||
"",
|
||||
app.cfg.Hotel,
|
||||
app.cfg.Kiosk,
|
||||
)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"received"}`))
|
||||
}
|
||||
|
||||
func (app *App) handleAvailabilityDebounced(isAvailable bool) {
|
||||
const (
|
||||
debounceDay = 30
|
||||
debounceNight = 600
|
||||
title = "ChipDNA Error"
|
||||
)
|
||||
|
||||
key := app.availabilityKey()
|
||||
|
||||
app.availabilityMu.Lock()
|
||||
defer app.availabilityMu.Unlock()
|
||||
|
||||
// If device becomes available -> cancel pending timer
|
||||
if isAvailable {
|
||||
if t, exists := app.availabilityTimers[key]; exists {
|
||||
t.Stop()
|
||||
delete(app.availabilityTimers, key)
|
||||
log.Println("PDQ availability restored - debounce timer cancelled")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Device became unavailable -> start 10s debounce if not already started
|
||||
if _, exists := app.availabilityTimers[key]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
debounce := debounceDay
|
||||
|
||||
hour := time.Now().Hour()
|
||||
if hour < 6 {
|
||||
debounce = debounceNight
|
||||
}
|
||||
|
||||
log.Printf("PDQ reported unavailable - starting %ds debounce timer", debounce)
|
||||
|
||||
timer := time.AfterFunc(time.Duration(debounce)*time.Second, func() {
|
||||
mail.SendEmailOnError(
|
||||
app.cfg.Hotel,
|
||||
app.cfg.Kiosk,
|
||||
title,
|
||||
fmt.Sprintf("ChipDNA PDQ unavailable for more than %d seconds", debounce),
|
||||
)
|
||||
|
||||
app.availabilityMu.Lock()
|
||||
delete(app.availabilityTimers, key)
|
||||
app.availabilityMu.Unlock()
|
||||
})
|
||||
|
||||
app.availabilityTimers[key] = timer
|
||||
}
|
||||
|
||||
func (app *App) availabilityKey() string {
|
||||
return fmt.Sprintf("hotel=%s|kiosk=%d|app=%p",
|
||||
strings.TrimSpace(app.cfg.Hotel),
|
||||
app.cfg.Kiosk,
|
||||
app,
|
||||
)
|
||||
}
|
||||
@ -1,15 +1,16 @@
|
||||
// Package lockserver provides functionality for interacting with Assa Abloy lock servers.
|
||||
package lockserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Build key encoding request command for the Assa Abloy lock server.
|
||||
// BuildCommand builds a key encoding request command for the Assa Abloy lock server.
|
||||
func (lock *AssaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
|
||||
ci := checkIn.Format("200601021504")
|
||||
co := checkOut.Format("200601021504")
|
||||
@ -18,7 +19,7 @@ func (lock *AssaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
|
||||
return nil
|
||||
}
|
||||
|
||||
// Checks heart beat of the Assa Abloy lock server and perform key encoding
|
||||
// LockSequence checks heartbeat of the Assa Abloy lock server and performs key encoding
|
||||
func (lock *AssaLockServer) LockSequence() error {
|
||||
const funcName = "AssaLockServer.LockSequence"
|
||||
|
||||
@ -71,4 +72,3 @@ func parseAssaResponse(raw string) (string, error) {
|
||||
}
|
||||
return "Success: " + clean, nil
|
||||
}
|
||||
|
||||
291
internal/lockserver/dormakabalockserver.go
Normal file
291
internal/lockserver/dormakabalockserver.go
Normal file
@ -0,0 +1,291 @@
|
||||
package lockserver
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
kabaSTX = 0x02
|
||||
kabaETX = 0x03
|
||||
kabaACK = 0x06
|
||||
kabaNAK = 0x15
|
||||
)
|
||||
|
||||
// BuildCommand builds a key encoding request command for the dormakaba/Kaba lock server.
|
||||
// KR|KTD|WS192.168.135.20|KC2|RN41|KO000000|GA241213|TI16:56|GD241214|DT11:00|G#75|
|
||||
func (lock *KabaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
|
||||
const funcName = "DormakabaLockServer.BuildCommand"
|
||||
|
||||
room := strings.TrimSpace(doorReq.RoomField)
|
||||
if room == "" {
|
||||
return fmt.Errorf("[%s] roomField is required", funcName)
|
||||
}
|
||||
|
||||
if checkIn.IsZero() {
|
||||
return fmt.Errorf("[%s] checkin time is required", funcName)
|
||||
}
|
||||
|
||||
if checkOut.IsZero() {
|
||||
return fmt.Errorf("[%s] checkout time is required", funcName)
|
||||
}
|
||||
|
||||
ws := dormakabaWorkstationID()
|
||||
|
||||
ga := checkIn.Format("060102") // yyMMdd, example: 241213
|
||||
gd := checkOut.Format("060102") // yyMMdd, example: 241214
|
||||
ti := checkIn.Format("15:04") // HH:mm, example: 16:56
|
||||
dt := checkOut.Format("15:04") // HH:mm, example: 11:00
|
||||
|
||||
payload := fmt.Sprintf(
|
||||
"KR|KTD|WS%s|KC%s|RN%s|KO000000|GA%s|TI%s|GD%s|DT%s|G#75|",
|
||||
ws,
|
||||
lock.encoderAddr,
|
||||
room,
|
||||
ga,
|
||||
ti,
|
||||
gd,
|
||||
dt,
|
||||
)
|
||||
|
||||
lock.command = wrapKabaFrame(payload)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LockSequence starts the link and performs key encoding.
|
||||
func (lock *KabaLockServer) LockSequence() error {
|
||||
const funcName = "KabaLockServer.LockSequence"
|
||||
|
||||
conn, err := InitializeServerConnection(LockServerURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
regs, err := lock.linkStart(conn, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[%s] linkStart failed: %v", funcName, err)
|
||||
}
|
||||
|
||||
for _, reg := range regs {
|
||||
log.Printf("Received: %q", reg)
|
||||
}
|
||||
|
||||
raw, err := lock.requestEncoding(conn, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[%s] request encoding failed: %v", funcName, err)
|
||||
}
|
||||
|
||||
log.Infof("Encoding response: %s", raw)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// linkStart sends the dormakaba/Kaba LS command.
|
||||
// LS|DA241213|TI165607|WS192.168.135.20|PW1234|
|
||||
func (lock *KabaLockServer) linkStart(conn net.Conn, reader *bufio.Reader) ([]string, error) {
|
||||
ws := dormakabaWorkstationID()
|
||||
pw := dormakabaPassword()
|
||||
|
||||
payload := fmt.Sprintf(
|
||||
"LS|DA%s|TI%s|WS%s|PW%s|",
|
||||
time.Now().Format("060102"), // yyMMdd
|
||||
time.Now().Format("150405"), // HHmmss
|
||||
ws,
|
||||
pw,
|
||||
)
|
||||
|
||||
command := wrapKabaFrame(payload)
|
||||
|
||||
log.Printf("Sending Link Start command: %q", command)
|
||||
|
||||
if _, err := conn.Write(command); err != nil {
|
||||
return nil, fmt.Errorf("failed to send Link Start command: %v", err)
|
||||
}
|
||||
|
||||
var registers []string
|
||||
timeout := 10 * time.Second
|
||||
|
||||
for {
|
||||
conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
|
||||
b, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
if len(registers) > 0 {
|
||||
return registers, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("error reading Link Start response: %v", err)
|
||||
}
|
||||
|
||||
switch b {
|
||||
case kabaACK:
|
||||
registers = append(registers, "ACK")
|
||||
continue
|
||||
|
||||
case kabaNAK:
|
||||
return registers, fmt.Errorf("received NAK after Link Start")
|
||||
|
||||
case kabaSTX:
|
||||
frame, err := readKabaFrame(conn, reader, b, timeout)
|
||||
if err != nil {
|
||||
return registers, fmt.Errorf("failed to read Link Start frame: %v", err)
|
||||
}
|
||||
|
||||
frameText := string(frame)
|
||||
registers = append(registers, frameText)
|
||||
|
||||
clean := cleanKabaFrame(frameText)
|
||||
if strings.HasPrefix(clean, "LA|") {
|
||||
return registers, nil
|
||||
}
|
||||
|
||||
default:
|
||||
log.Warnf("Ignoring unexpected byte during Link Start: 0x%X", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (lock *KabaLockServer) requestEncoding(conn net.Conn, reader *bufio.Reader) (string, error) {
|
||||
log.Printf("Sending Encoding command: %q", lock.command)
|
||||
|
||||
if _, err := conn.Write(lock.command); err != nil {
|
||||
return "", fmt.Errorf("failed to send Encoding command: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
|
||||
for {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return "", fmt.Errorf("timeout waiting for dormakaba encoding response")
|
||||
}
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(remaining))
|
||||
|
||||
b, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error reading encoding response: %v", err)
|
||||
}
|
||||
|
||||
switch b {
|
||||
case kabaACK:
|
||||
log.Debug("Received ACK after Encoding command")
|
||||
continue
|
||||
|
||||
case kabaNAK:
|
||||
return "", fmt.Errorf("received NAK after Encoding command")
|
||||
|
||||
case kabaSTX:
|
||||
frame, err := readKabaFrame(conn, reader, b, 60*time.Second)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read encoding response frame: %v", err)
|
||||
}
|
||||
|
||||
raw := string(frame)
|
||||
clean := cleanKabaFrame(raw)
|
||||
|
||||
log.Printf("Received Encoding frame: %q", clean)
|
||||
|
||||
if strings.HasPrefix(clean, "KA|") {
|
||||
return parseDormakabaEncodingResponse(clean)
|
||||
}
|
||||
|
||||
log.Warnf("Ignoring non-KA frame while waiting for encoding result: %q", clean)
|
||||
|
||||
default:
|
||||
log.Warnf("Ignoring unexpected byte while waiting for encoding response: 0x%X", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseDormakabaEncodingResponse(clean string) (string, error) {
|
||||
if strings.Contains(clean, "|ASOK|") {
|
||||
return "Success: " + clean, nil
|
||||
}
|
||||
|
||||
if strings.Contains(clean, "|AS") {
|
||||
return "", fmt.Errorf("negative dormakaba response: %s", clean)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unexpected dormakaba response: %s", clean)
|
||||
}
|
||||
|
||||
func readKabaFrame(conn net.Conn, reader *bufio.Reader, firstByte byte, timeout time.Duration) ([]byte, error) {
|
||||
frame := []byte{firstByte}
|
||||
|
||||
for {
|
||||
conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
|
||||
b, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return frame, fmt.Errorf("error reading frame body: %w", err)
|
||||
}
|
||||
|
||||
frame = append(frame, b)
|
||||
|
||||
if b == kabaETX {
|
||||
return frame, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func wrapKabaFrame(payload string) []byte {
|
||||
command := make([]byte, 0, len(payload)+2)
|
||||
command = append(command, kabaSTX)
|
||||
command = append(command, []byte(payload)...)
|
||||
command = append(command, kabaETX)
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func cleanKabaFrame(raw string) string {
|
||||
return strings.Trim(raw, string([]byte{kabaSTX, kabaETX}))
|
||||
}
|
||||
|
||||
func dormakabaPassword() string {
|
||||
if strings.TrimSpace(Cert) != "" {
|
||||
return strings.TrimSpace(Cert)
|
||||
}
|
||||
|
||||
return "1234"
|
||||
}
|
||||
|
||||
func dormakabaWorkstationID() string {
|
||||
parsed, err := url.Parse(LockServerURL)
|
||||
if err == nil && parsed.Host != "" {
|
||||
host := parsed.Host
|
||||
|
||||
if h, _, splitErr := net.SplitHostPort(host); splitErr == nil {
|
||||
return h
|
||||
}
|
||||
|
||||
return strings.Trim(host, "/")
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(LockServerURL)
|
||||
raw = strings.TrimPrefix(raw, "http://")
|
||||
raw = strings.TrimPrefix(raw, "https://")
|
||||
raw = strings.Trim(raw, "/")
|
||||
|
||||
if h, _, splitErr := net.SplitHostPort(raw); splitErr == nil {
|
||||
return h
|
||||
}
|
||||
|
||||
if idx := strings.Index(raw, ":"); idx >= 0 {
|
||||
return raw[:idx]
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
@ -25,10 +25,11 @@ const (
|
||||
Omnitec = "omnitec"
|
||||
Salto = "salto"
|
||||
TLJ = "tlj"
|
||||
Dormakaba = "kaba"
|
||||
)
|
||||
|
||||
var (
|
||||
Cert string
|
||||
Cert string
|
||||
LockServerURL string
|
||||
)
|
||||
|
||||
@ -38,6 +39,11 @@ type (
|
||||
LockSequence() error
|
||||
}
|
||||
|
||||
KabaLockServer struct {
|
||||
encoderAddr string
|
||||
command []byte
|
||||
}
|
||||
|
||||
AssaLockServer struct {
|
||||
encoderAddr string
|
||||
command string
|
||||
@ -69,22 +75,24 @@ func NewLockServer(lockType, encoderAddr string, fatalError func(error)) LockSer
|
||||
return &SaltoLockServer{encoderAddr: encoderAddr}
|
||||
case TLJ:
|
||||
return &TLJLockServer{encoderAddr: encoderAddr}
|
||||
case Dormakaba:
|
||||
return &KabaLockServer{encoderAddr: encoderAddr}
|
||||
default:
|
||||
fatalError(fmt.Errorf("unsupported LockType: %s; must be 'assaabloy' or 'omnitec'", lockType))
|
||||
return nil // This line will never be reached, but is needed to satisfy the compiler
|
||||
}
|
||||
}
|
||||
|
||||
func InitializeServerConnection(LockserverUrl string) (net.Conn, error) {
|
||||
func InitializeServerConnection(LockserverURL string) (net.Conn, error) {
|
||||
const funcName = "InitializeServerConnection"
|
||||
// Parse the URL to extract host and port
|
||||
parsedUrl, err := url.Parse(LockserverUrl)
|
||||
parsedURL, err := url.Parse(LockserverURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[%s] failed to parse LockserverUrl: %v", funcName, err)
|
||||
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, "/")
|
||||
address := strings.Trim(parsedURL.Host, "/")
|
||||
|
||||
// Establish a TCP connection to the Visionline server
|
||||
conn, err := net.Dial("tcp", address)
|
||||
@ -102,7 +110,7 @@ func sendAndReceive(conn net.Conn, command []byte) (string, error) {
|
||||
return "", fmt.Errorf("failed to send command: %v", err)
|
||||
}
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
conn.SetReadDeadline(time.Now().Add(20 * time.Second))
|
||||
|
||||
buf := make([]byte, 128)
|
||||
reader := bufio.NewReader(conn)
|
||||
@ -7,12 +7,12 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Build key encoding request command for the Omnitec lock server.
|
||||
// BuildCommand builds key encoding request command for the Omnitec lock server.
|
||||
func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
|
||||
const funcName = "OmniLockServer.BuildCommand"
|
||||
hostname, err := os.Hostname()
|
||||
@ -25,7 +25,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
|
||||
if err != nil {
|
||||
return fmt.Errorf("[%s] failed to convert lockId to integer: %v", funcName, err)
|
||||
}
|
||||
formattedLockId := fmt.Sprintf("%04d", idInt)
|
||||
formattedLockID := fmt.Sprintf("%04d", idInt)
|
||||
|
||||
// Format date/time parts
|
||||
dt := checkOut.Format("15:04") // DT = HH:mm
|
||||
@ -37,7 +37,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
|
||||
payload := fmt.Sprintf(
|
||||
"KR|KC%s|KTD|RN%s|%s|DT%s|G#75|GA%s|GD%s|KO0000|DA%s|TI%s|",
|
||||
lock.encoderAddr,
|
||||
formattedLockId,
|
||||
formattedLockID,
|
||||
hostname,
|
||||
dt,
|
||||
ga,
|
||||
@ -52,7 +52,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
|
||||
return nil
|
||||
}
|
||||
|
||||
// Starts link to the Omnitec lock server and perform key encoding
|
||||
// LockSequence starts link to the Omnitec lock server and perform key encoding
|
||||
func (lock *OmniLockServer) LockSequence() error {
|
||||
const funcName = "OmniLockServer.LockSequence"
|
||||
|
||||
@ -136,4 +136,4 @@ func parseOmniResponse(raw string) (string, error) {
|
||||
return "", fmt.Errorf("negative response code: %s", clean)
|
||||
}
|
||||
return "Success: " + clean, nil
|
||||
}
|
||||
}
|
||||
@ -155,7 +155,7 @@ func (lock *SaltoLockServer) LockSequence() error {
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
// 1. Send ENQ
|
||||
log.Infof("Sending ENQ")
|
||||
log.Infof("LockSequence: sending ENQ")
|
||||
if _, e := conn.Write([]byte{ENQ}); e != nil {
|
||||
return fmt.Errorf("failed to send ENQ: %w", e)
|
||||
}
|
||||
@ -166,7 +166,7 @@ func (lock *SaltoLockServer) LockSequence() error {
|
||||
}
|
||||
|
||||
// 3. Send command frame
|
||||
log.Infof("Sending encoding command: %q", string(lock.command))
|
||||
log.Infof("LockSequence: sending encoding command: %q", string(lock.command))
|
||||
if _, e := conn.Write(lock.command); e != nil {
|
||||
return fmt.Errorf("failed to send command frame: %w", e)
|
||||
}
|
||||
31
internal/logging/logging.go
Normal file
31
internal/logging/logging.go
Normal file
@ -0,0 +1,31 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// setupLogging ensures log directory, opens log file, and configures logrus.
|
||||
// Returns the *os.File so caller can defer its Close().
|
||||
func SetupLogging(logDir, serviceName, buildVersion string) (*os.File, error) {
|
||||
fileName := logDir + serviceName + ".log"
|
||||
f, err := os.OpenFile(fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open log file: %w", err)
|
||||
}
|
||||
|
||||
log.SetOutput(f)
|
||||
log.SetFormatter(&log.JSONFormatter{
|
||||
TimestampFormat: time.RFC3339,
|
||||
})
|
||||
log.SetLevel(log.InfoLevel)
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"buildVersion": buildVersion,
|
||||
}).Info("Logging initialized")
|
||||
|
||||
return f, nil
|
||||
}
|
||||
68
internal/mail/mail.go
Normal file
68
internal/mail/mail.go
Normal file
@ -0,0 +1,68 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/logging"
|
||||
mailjet "github.com/mailjet/mailjet-apiv3-go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
apiKey = "60f358a27e98562641c08f51e5450c9e"
|
||||
secretKey = "068b65c3b337a0e3c14389544ecd771f"
|
||||
)
|
||||
|
||||
const (
|
||||
moduleName = "mail"
|
||||
)
|
||||
|
||||
var (
|
||||
// sendErrorEmail is the e-mail address to which to send an e-mail if there is an error during checkin or payment
|
||||
SendErrorEmails []string
|
||||
)
|
||||
|
||||
// SendMail will send reception an e-mail
|
||||
func SendMail(recipient, title, message string) {
|
||||
const funcName = "SendMail"
|
||||
|
||||
mailjetClient := mailjet.NewMailjetClient(apiKey, secretKey)
|
||||
messagesInfo := []mailjet.InfoMessagesV31{
|
||||
mailjet.InfoMessagesV31{
|
||||
From: &mailjet.RecipientV31{
|
||||
Email: "kiosk@cms.futuresens.co.uk",
|
||||
Name: "Futuresens Kiosk",
|
||||
},
|
||||
To: &mailjet.RecipientsV31{
|
||||
mailjet.RecipientV31{
|
||||
Email: recipient,
|
||||
Name: "",
|
||||
},
|
||||
},
|
||||
Subject: title,
|
||||
TextPart: message,
|
||||
},
|
||||
}
|
||||
messages := mailjet.MessagesV31{Info: messagesInfo}
|
||||
_, err := mailjetClient.SendMailV31(&messages)
|
||||
if err != nil {
|
||||
theFields := log.Fields{}
|
||||
theFields["mailerror"] = true
|
||||
theFields["recipient"] = recipient
|
||||
theFields[logging.LogFunction] = funcName
|
||||
theFields[logging.LogModule] = moduleName
|
||||
theFields[logging.LogError] = err.Error()
|
||||
theFields["error"] = err.Error()
|
||||
|
||||
log.WithFields(theFields).Error("sendmail error")
|
||||
}
|
||||
}
|
||||
|
||||
func SendEmailOnError(hotel string, kiosk int, title, errMsg string) {
|
||||
log.Println("sendEmailOnError called")
|
||||
|
||||
message := fmt.Sprintf("Hotel: %s, kiosk: %d.\n%s", hotel, kiosk, errMsg)
|
||||
for _, recipient := range SendErrorEmails {
|
||||
SendMail(recipient, title, message)
|
||||
}
|
||||
}
|
||||
9
internal/mail/mail_test.go
Normal file
9
internal/mail/mail_test.go
Normal file
@ -0,0 +1,9 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_SendMail(t *testing.T) {
|
||||
SendMail("zotacrtx5@gmail.com", "Test Subjectp", "Test Message")
|
||||
}
|
||||
182
internal/paybridge/client.go
Normal file
182
internal/paybridge/client.go
Normal file
@ -0,0 +1,182 @@
|
||||
package paybridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
URL string
|
||||
APIKey string
|
||||
TimeoutSeconds int
|
||||
}
|
||||
|
||||
func NewClient(url, apiKey string, timeoutSeconds int) *Client {
|
||||
if timeoutSeconds <= 0 {
|
||||
timeoutSeconds = 300
|
||||
}
|
||||
|
||||
return &Client{
|
||||
URL: url,
|
||||
APIKey: apiKey,
|
||||
TimeoutSeconds: timeoutSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Sale(ctx context.Context, req paymentsvc.SaleRequest) (*paymentsvc.Result, error) {
|
||||
if req.Currency == "" {
|
||||
req.Currency = "GBP"
|
||||
}
|
||||
|
||||
payBridgeReq := PaymentRequest{
|
||||
RequestID: req.RequestID,
|
||||
Amount: req.Amount,
|
||||
Currency: req.Currency,
|
||||
Operation: "SALE",
|
||||
TimeoutSeconds: c.TimeoutSeconds,
|
||||
}
|
||||
|
||||
return c.doPayment(ctx, payBridgeReq)
|
||||
}
|
||||
|
||||
func (c *Client) doPayment(ctx context.Context, req PaymentRequest) (*paymentsvc.Result, error) {
|
||||
connectURL, err := c.connectURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ws, _, err := websocket.DefaultDialer.DialContext(ctx, connectURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err)
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = ws.Close()
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
|
||||
jwt, err := c.readAuthSuccess(ws)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ws.WriteJSON(Envelope{
|
||||
Type: types.MesTypePaymentRequest,
|
||||
JWT: jwt,
|
||||
Data: req,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("send PayBridge payment_request: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
_, raw, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, fmt.Errorf("read PayBridge message: %w", err)
|
||||
}
|
||||
|
||||
var head struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
return nil, fmt.Errorf("decode PayBridge message header: %w", err)
|
||||
}
|
||||
|
||||
switch strings.ToLower(head.Type) {
|
||||
case types.MesTypePaymentResult:
|
||||
var result PaymentResultEnvelope
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode payment_result: %w", err)
|
||||
}
|
||||
return mapPaymentResult(result), nil
|
||||
|
||||
case types.MesTypePaymentError:
|
||||
var paymentErr PaymentErrorEnvelope
|
||||
if err := json.Unmarshal(raw, &paymentErr); err != nil {
|
||||
return nil, fmt.Errorf("decode payment_error: %w", err)
|
||||
}
|
||||
return mapPaymentError(req, paymentErr), nil
|
||||
|
||||
case types.ResultError:
|
||||
var genericErr struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &genericErr); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnexpectedMessage, string(raw))
|
||||
}
|
||||
if genericErr.Error.Message != "" {
|
||||
return nil, fmt.Errorf("%w: %s: %s", ErrUnexpectedMessage, genericErr.Error.Code, genericErr.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnexpectedMessage, string(raw))
|
||||
|
||||
case types.MesTypePaymentStatusUpdate, types.MesTypePaymentAccepted, types.MesTypeAuthSuccess:
|
||||
// Intermediate message. Keep waiting for payment_result or payment_error.
|
||||
|
||||
default:
|
||||
// PayBridge may introduce additional intermediate message types.
|
||||
// Ignore them and continue waiting for the final result.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) connectURL() (string, error) {
|
||||
u, err := url.Parse(c.URL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse PayBridge WebSocket URL: %w", err)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
q.Set("api_key", c.APIKey)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c *Client) readAuthSuccess(ws *websocket.Conn) (string, error) {
|
||||
if err := ws.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||
return "", fmt.Errorf("%w: set auth read deadline: %v", ErrAuthFailed, err)
|
||||
}
|
||||
defer ws.SetReadDeadline(time.Time{})
|
||||
|
||||
_, raw, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: read auth_success: %v", ErrAuthFailed, err)
|
||||
}
|
||||
|
||||
var auth struct {
|
||||
Type string `json:"type"`
|
||||
JWT string `json:"jwt"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &auth); err != nil {
|
||||
return "", fmt.Errorf("%w: decode auth_success: %v", ErrAuthFailed, err)
|
||||
}
|
||||
if !strings.EqualFold(auth.Type, types.MesTypeAuthSuccess) {
|
||||
return "", fmt.Errorf("%w: expected auth_success, got %s: %s", ErrAuthFailed, auth.Type, string(raw))
|
||||
}
|
||||
if auth.JWT == "" {
|
||||
return "", fmt.Errorf("%w: auth_success did not contain jwt", ErrAuthFailed)
|
||||
}
|
||||
|
||||
return auth.JWT, nil
|
||||
}
|
||||
9
internal/paybridge/errors.go
Normal file
9
internal/paybridge/errors.go
Normal file
@ -0,0 +1,9 @@
|
||||
package paybridge
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrConnectionFailed = errors.New("paybridge connection failed")
|
||||
ErrAuthFailed = errors.New("paybridge authentication failed")
|
||||
ErrUnexpectedMessage = errors.New("paybridge unexpected message")
|
||||
)
|
||||
47
internal/paybridge/mapper.go
Normal file
47
internal/paybridge/mapper.go
Normal file
@ -0,0 +1,47 @@
|
||||
package paybridge
|
||||
|
||||
import "gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
|
||||
|
||||
func mapPaymentResult(res PaymentResultEnvelope) *paymentsvc.Result {
|
||||
var merchantReceipt string
|
||||
if res.Data.ReceiptData.Merchant != nil {
|
||||
merchantReceipt = *res.Data.ReceiptData.Merchant
|
||||
}
|
||||
|
||||
return &paymentsvc.Result{
|
||||
Success: res.Data.Success,
|
||||
TransactionID: res.Data.TransactionID,
|
||||
RequestID: res.Data.RequestID,
|
||||
Operation: res.Data.Operation,
|
||||
Status: res.Data.Status,
|
||||
Message: res.Data.Message,
|
||||
ErrorMessage: res.Data.ErrorMessage,
|
||||
Amount: res.Data.Amount,
|
||||
Currency: res.Data.Currency,
|
||||
AuthCode: res.Data.AuthCode,
|
||||
DeviceUsed: res.Data.DeviceUsed,
|
||||
DeviceType: res.Data.DeviceType,
|
||||
ReferenceNumber: res.Data.ReferenceNumber,
|
||||
LastFourDigits: res.Data.LastFourDigits,
|
||||
CardType: res.Data.CardType,
|
||||
CardNumber: res.Data.CardNumber,
|
||||
ExpiryDate: res.Data.ExpiryDate,
|
||||
CardHash: res.Data.CardHash,
|
||||
CardReference: res.Data.CardReference,
|
||||
CustomerReceipt: res.Data.ReceiptData.Customer,
|
||||
MerchantReceipt: merchantReceipt,
|
||||
}
|
||||
}
|
||||
|
||||
func mapPaymentError(req PaymentRequest, res PaymentErrorEnvelope) *paymentsvc.Result {
|
||||
return &paymentsvc.Result{
|
||||
Success: false,
|
||||
TransactionID: res.Data.TransactionID,
|
||||
RequestID: req.RequestID,
|
||||
Operation: req.Operation,
|
||||
Status: res.Data.Status,
|
||||
ErrorMessage: res.Data.Error,
|
||||
Amount: req.Amount,
|
||||
Currency: req.Currency,
|
||||
}
|
||||
}
|
||||
55
internal/paybridge/types.go
Normal file
55
internal/paybridge/types.go
Normal file
@ -0,0 +1,55 @@
|
||||
package paybridge
|
||||
|
||||
type Envelope struct {
|
||||
Type string `json:"type"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
JWT string `json:"jwt,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Operation string `json:"operation"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentResultEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
Success bool `json:"success"`
|
||||
TransactionID string `json:"transactionId"`
|
||||
RequestID string `json:"requestId"`
|
||||
Operation string `json:"operation"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
AuthCode string `json:"authCode"`
|
||||
DeviceUsed string `json:"deviceUsed"`
|
||||
DeviceType string `json:"deviceType"`
|
||||
ReferenceNumber string `json:"referenceNumber"`
|
||||
LastFourDigits string `json:"lastFourDigits"`
|
||||
CardType string `json:"cardType"`
|
||||
CardNumber string `json:"cardNumber"`
|
||||
ExpiryDate string `json:"expiryDate"`
|
||||
CardHash string `json:"cardHash"`
|
||||
CardReference string `json:"cardReference"`
|
||||
|
||||
ReceiptData struct {
|
||||
Merchant *string `json:"merchant"`
|
||||
Customer string `json:"customer"`
|
||||
} `json:"receiptData"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type PaymentErrorEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
Error string `json:"error"`
|
||||
Status string `json:"status"`
|
||||
} `json:"data"`
|
||||
}
|
||||
42
internal/paymentsvc/service.go
Normal file
42
internal/paymentsvc/service.go
Normal file
@ -0,0 +1,42 @@
|
||||
package paymentsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var ErrPaymentInProgress = errors.New("payment is already in progress")
|
||||
|
||||
type Provider interface {
|
||||
Sale(ctx context.Context, req SaleRequest) (*Result, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
provider Provider
|
||||
|
||||
mu sync.Mutex
|
||||
busy bool
|
||||
}
|
||||
|
||||
func NewService(provider Provider) *Service {
|
||||
return &Service{provider: provider}
|
||||
}
|
||||
|
||||
func (s *Service) Sale(ctx context.Context, req SaleRequest) (*Result, error) {
|
||||
s.mu.Lock()
|
||||
if s.busy {
|
||||
s.mu.Unlock()
|
||||
return nil, ErrPaymentInProgress
|
||||
}
|
||||
s.busy = true
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.busy = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
return s.provider.Sale(ctx, req)
|
||||
}
|
||||
37
internal/paymentsvc/types.go
Normal file
37
internal/paymentsvc/types.go
Normal file
@ -0,0 +1,37 @@
|
||||
package paymentsvc
|
||||
|
||||
type SaleRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Reference string `json:"reference"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Success bool `json:"success"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
Operation string `json:"operation,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
|
||||
TransactionID string `json:"transactionId,omitempty"`
|
||||
ReferenceNumber string `json:"referenceNumber,omitempty"`
|
||||
AuthCode string `json:"authCode,omitempty"`
|
||||
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
|
||||
DeviceUsed string `json:"deviceUsed,omitempty"`
|
||||
DeviceType string `json:"deviceType,omitempty"`
|
||||
|
||||
CardNumber string `json:"cardNumber,omitempty"`
|
||||
LastFourDigits string `json:"lastFourDigits,omitempty"`
|
||||
CardType string `json:"cardType,omitempty"`
|
||||
ExpiryDate string `json:"expiryDate,omitempty"`
|
||||
CardHash string `json:"cardHash,omitempty"`
|
||||
CardReference string `json:"cardReference,omitempty"`
|
||||
|
||||
CustomerReceipt string `json:"customerReceipt,omitempty"`
|
||||
MerchantReceipt string `json:"merchantReceipt,omitempty"`
|
||||
}
|
||||
@ -39,6 +39,7 @@ type (
|
||||
Name string `xml:"customername"`
|
||||
Checkout string `xml:"checkoutdatetime"`
|
||||
RoomID string `xml:"roomno"`
|
||||
Voucher string `xml:"voucher"`
|
||||
Map string `xml:"roommap"`
|
||||
Directions string `xml:"roomdirections"`
|
||||
}
|
||||
@ -141,6 +142,11 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
|
||||
write([]byte{ESC, 'a', CENTER})
|
||||
writeStr(Layout.HotelSpecificDetails + "\n\n")
|
||||
|
||||
if details.Voucher != "" {
|
||||
s := strings.Repeat("*", 44)
|
||||
writeStr(fmt.Sprintf("%s\n\n%s\n\n%s\n\n", s, details.Voucher, s))
|
||||
}
|
||||
|
||||
// 8) Room map image
|
||||
mapPath := filepath.Join(Layout.RoomMapFolderPath, details.Map)
|
||||
mapBytes, err := printMap(mapPath)
|
||||
@ -167,7 +173,19 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func PrintCardholderReceipt(cardholderReceipt string) error {
|
||||
func PrintReceipt(receipt string) {
|
||||
|
||||
if len(receipt) == 0 {
|
||||
log.Warn("Empty cardholder receipt, skipping print")
|
||||
return
|
||||
}
|
||||
|
||||
if err := printCardholderReceipt(receipt); err != nil {
|
||||
log.Errorf("PrintCardholderReceipt error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func printCardholderReceipt(cardholderReceipt string) error {
|
||||
receiptEntries, err := ParseCardholderReceipt([]byte(cardholderReceipt))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ParseCardholderReceipt: %w", err)
|
||||
95
internal/types/types.go
Normal file
95
internal/types/types.go
Normal file
@ -0,0 +1,95 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/xml"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ServiceName = "hardlink"
|
||||
DateOnly = "2006-01-02"
|
||||
CustomLayout = "2006-01-02 15:04:05 -0700"
|
||||
LinkStartTransaction = "http://127.0.0.1:18181/start-transaction/"
|
||||
LinkConfirmTransaction = "http://127.0.0.1:18181/confirm-transaction/"
|
||||
LinkTransactionInformation = "http://127.0.0.1:18181/transaction-information/"
|
||||
LinkChipDNAStatus = "http://127.0.0.1:18181/chipdna-status/"
|
||||
LinkVoidTransaction = "http://127.0.0.1:18181/void-transaction/"
|
||||
// Transaction types
|
||||
SaleTransactionType = "sale"
|
||||
AccountVerificationType = "account verification"
|
||||
|
||||
// Transaction results
|
||||
ResultApproved = "approved"
|
||||
ResultDeclined = "declined"
|
||||
ResultCancelled = "cancelled"
|
||||
ResultCanceled = "canceled"
|
||||
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"
|
||||
ErrorDescription = "ERROR_DESCRIPTION"
|
||||
ReceiptData = "RECEIPT_DATA"
|
||||
ReceiptDataMerchant = "RECEIPT_DATA_MERCHANT"
|
||||
ReceiptDataCardholder = "RECEIPT_DATA_CARDHOLDER"
|
||||
Reference = "REFERENCE"
|
||||
CardType = "CARD_SCHEME_ID"
|
||||
PanMasked = "PAN_MASKED"
|
||||
ExpiryDate = "EXPIRY_DATE"
|
||||
TransactionResult = "TRANSACTION_RESULT"
|
||||
TransactionType = "TRANSACTION_TYPE"
|
||||
TransactionState = "TRANSACTION_STATE"
|
||||
ConfirmResult = "CONFIRM_RESULT"
|
||||
ConfirmErrors = "CONFIRM_ERRORS"
|
||||
TotalAmount = "TOTAL_AMOUNT"
|
||||
|
||||
// Dojo terminal session statuses
|
||||
ResultCaptured = "captured"
|
||||
ResultSignatureAccepted = "signatureverificationaccepted"
|
||||
ResultInitiateRequested = "initiaterequested"
|
||||
ResultInitiated = "initiated"
|
||||
ResultAuthorized = "authorized"
|
||||
ResultCancelRequested = "cancelrequested"
|
||||
ResultExpired = "expired"
|
||||
ResultSignatureRejected = "signatureverificationrejected"
|
||||
ResultSignatureRequired = "signatureverificationrequired"
|
||||
|
||||
//PayBridge message types
|
||||
MesTypePaymentRequest = "payment_request"
|
||||
MesTypePaymentResult = "payment_result"
|
||||
MesTypePaymentError = "payment_error"
|
||||
MesTypePaymentStatusUpdate = "payment_status_update"
|
||||
MesTypePaymentAccepted = "payment_accepted"
|
||||
MesTypeAuthSuccess = "auth_success"
|
||||
|
||||
// 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
|
||||
}
|
||||
)
|
||||
250
main.go
250
main.go
@ -1,250 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/tarm/serial"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
yaml "gopkg.in/yaml.v3"
|
||||
|
||||
"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/printer"
|
||||
)
|
||||
|
||||
const (
|
||||
buildVersion = "1.0.24"
|
||||
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()
|
||||
printer.Layout = readTicketLayout()
|
||||
printer.PrinterName = config.PrinterName
|
||||
lockserver.Cert = config.Cert
|
||||
lockserver.LockServerURL = config.LockserverUrl
|
||||
dispHandle := &serial.Port{}
|
||||
|
||||
// Setup logging and get file handle
|
||||
logFile, err := setupLogging(config.LogDir)
|
||||
if err != nil {
|
||||
log.Printf("Failed to set up logging: %v\n", err)
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
// Initialize dispenser
|
||||
if !config.TestMode {
|
||||
dispenser.SerialPort = config.DispenserPort
|
||||
dispenser.Address = []byte(config.DispenserAdrr)
|
||||
dispHandle, err = dispenser.InitializeDispenser()
|
||||
if err != nil {
|
||||
handlers.FatalError(err)
|
||||
}
|
||||
defer dispHandle.Close()
|
||||
|
||||
status, err := dispenser.CheckDispenserStatus(dispHandle)
|
||||
if err != nil {
|
||||
if len(status) == 0 {
|
||||
err = fmt.Errorf("%s; wrong dispenser address: %s", err, config.DispenserAdrr)
|
||||
handlers.FatalError(err)
|
||||
} else {
|
||||
fmt.Println(status)
|
||||
fmt.Println(err.Error())
|
||||
}
|
||||
}
|
||||
log.Infof("Dispenser initialized on port %s, %s", config.DispenserPort, status)
|
||||
}
|
||||
|
||||
// Test lock-server connection
|
||||
switch strings.ToLower(config.LockType) {
|
||||
case lockserver.TLJ:
|
||||
|
||||
default:
|
||||
lockConn, err := lockserver.InitializeServerConnection(config.LockserverUrl)
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
log.Errorf(err.Error())
|
||||
} else {
|
||||
fmt.Printf("Connected to the lock server successfuly at %s\n", config.LockserverUrl)
|
||||
log.Infof("Connected to the lock server successfuly at %s", config.LockserverUrl)
|
||||
lockConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if config.IsPayment {
|
||||
fmt.Println("Payment processing is enabled")
|
||||
log.Info("Payment processing is enabled")
|
||||
startChipDnaClient()
|
||||
} else {
|
||||
fmt.Println("Payment processing is disabled")
|
||||
log.Info("Payment processing is disabled")
|
||||
}
|
||||
|
||||
// Create App and wire routes
|
||||
app := handlers.NewApp(dispHandle, config.LockType, config.EncoderAddress, config.IsPayment)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
app.RegisterRoutes(mux)
|
||||
|
||||
addr := fmt.Sprintf(":%d", config.Port)
|
||||
log.Infof("Starting HTTP server on http://localhost%s", addr)
|
||||
fmt.Printf("Starting HTTP server on http://localhost%s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
handlers.FatalError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// setupLogging ensures log directory, opens log file, and configures logrus.
|
||||
// Returns the *os.File so caller can defer its Close().
|
||||
func setupLogging(logDir string) (*os.File, error) {
|
||||
fileName := logDir + serviceName + ".log"
|
||||
f, err := os.OpenFile(fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open log file: %w", err)
|
||||
}
|
||||
|
||||
log.SetOutput(f)
|
||||
log.SetFormatter(&log.JSONFormatter{
|
||||
TimestampFormat: time.RFC3339,
|
||||
})
|
||||
log.SetLevel(log.InfoLevel)
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"buildVersion": buildVersion,
|
||||
}).Info("Logging initialized")
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// readConfig reads config.yml and applies defaults.
|
||||
func readConfig() configRec {
|
||||
var cfg configRec
|
||||
const configName = "config.yml"
|
||||
defaultPort := 9091
|
||||
sep := string(os.PathSeparator)
|
||||
|
||||
data, err := os.ReadFile(configName)
|
||||
if err != nil {
|
||||
log.Warnf("ReadConfig %s: %v", configName, err)
|
||||
} else if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
log.Warnf("Unmarshal config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = defaultPort
|
||||
}
|
||||
|
||||
if cfg.LockType == "" {
|
||||
err = fmt.Errorf("LockType is required in %s", configName)
|
||||
handlers.FatalError(err)
|
||||
}
|
||||
cfg.LockType = strings.ToLower(cfg.LockType)
|
||||
|
||||
if cfg.LogDir == "" {
|
||||
cfg.LogDir = "./logs" + sep
|
||||
} else if !strings.HasSuffix(cfg.LogDir, sep) {
|
||||
cfg.LogDir += sep
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func readTicketLayout() printer.LayoutOptions {
|
||||
const layoutName = "TicketLayout.xml"
|
||||
var layout printer.LayoutOptions
|
||||
|
||||
// 1) Read the file
|
||||
data, err := os.ReadFile(layoutName)
|
||||
if err != nil {
|
||||
handlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err))
|
||||
}
|
||||
|
||||
// 2) Unmarshal into your struct
|
||||
if err := xml.Unmarshal(data, &layout); err != nil {
|
||||
handlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err))
|
||||
}
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
func startChipDnaClient() {
|
||||
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 {
|
||||
handlers.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)
|
||||
}()
|
||||
}
|
||||
@ -1,316 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "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"
|
||||
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 {
|
||||
XMLName xml.Name `xml:"TransactionPayload"`
|
||||
AmountMinorUnits string `xml:"amount"`
|
||||
TransactionType string `xml:"transactionType"`
|
||||
}
|
||||
|
||||
TransactionResultXML struct {
|
||||
XMLName xml.Name `xml:"TransactionResult"`
|
||||
Entries []EntryXML `xml:"Entry"`
|
||||
}
|
||||
|
||||
EntryXML struct {
|
||||
Key string `xml:"Key"`
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
TransactionConfirmation struct {
|
||||
XMLName xml.Name `xml:"TransactionConfirmation"`
|
||||
Result string `xml:"Result"`
|
||||
Errors string `xml:"Errors"`
|
||||
ErrorDescription string `xml:"ErrorDescription"`
|
||||
ReceiptDataCardholder string `xml:"ReceiptDataCardholder"`
|
||||
}
|
||||
)
|
||||
|
||||
// ParseTransactionResult parses the XML into entries.
|
||||
func (tr *TransactionResultXML) ParseTransactionResult(data []byte) error {
|
||||
if err := xml.Unmarshal(data, &tr); err != nil {
|
||||
return fmt.Errorf("XML unmarshal: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 4. MerchantId <- MERCHANT_ID_MASKED
|
||||
merchantId := sql.NullString{String: m["MERCHANT_ID_MASKED"], Valid: m["MERCHANT_ID_MASKED"] != ""}
|
||||
|
||||
// 5. TerminalId <- TERMINAL_ID_MASKED
|
||||
terminalId := sql.NullString{String: m["TERMINAL_ID_MASKED"], Valid: m["TERMINAL_ID_MASKED"] != ""}
|
||||
|
||||
// 6. CardSchemeName <- CARD_SCHEME
|
||||
cardScheme := sql.NullString{String: m["CARD_SCHEME"], Valid: m["CARD_SCHEME"] != ""}
|
||||
|
||||
// 7. ExpiryDate <- EXPIRY_DATE
|
||||
expiryDate := sql.NullString{String: m["EXPIRY_DATE"], Valid: m["EXPIRY_DATE"] != ""}
|
||||
|
||||
// 8. RecordReference <- CARD_REFERENCE
|
||||
recordRef := sql.NullString{String: m["CARD_REFERENCE"], Valid: m["CARD_REFERENCE"] != ""}
|
||||
|
||||
// 9. Token1 <- CARD_HASH
|
||||
token1 := sql.NullString{String: m["CARD_HASH"], Valid: m["CARD_HASH"] != ""}
|
||||
|
||||
// 10. Token2 <- CARDEASE_REFERENCE
|
||||
token2 := sql.NullString{String: m["CARDEASE_REFERENCE"], Valid: m["CARDEASE_REFERENCE"] != ""}
|
||||
|
||||
// 11. PanMasked <- PAN_MASKED
|
||||
panMasked := sql.NullString{String: m["PAN_MASKED"], Valid: m["PAN_MASKED"] != ""}
|
||||
|
||||
// 12. AuthCode <- AUTH_CODE
|
||||
authCode := sql.NullString{String: m["AUTH_CODE"], Valid: m["AUTH_CODE"] != ""}
|
||||
|
||||
// 13. TransactionResult <- TRANSACTION_RESULT
|
||||
txnResult := sql.NullString{String: m["TRANSACTION_RESULT"], Valid: m["TRANSACTION_RESULT"] != ""}
|
||||
|
||||
// Build INSERT statement with named parameters.
|
||||
// Assuming your table is [TransactionDatabase].[dbo].[TransactionRecords].
|
||||
const stmt = `
|
||||
INSERT INTO [TransactionDatabase].[dbo].[TransactionRecords]
|
||||
(
|
||||
[TxnReference],
|
||||
[TxnDateTime],
|
||||
[TotalAmount],
|
||||
[MerchantId],
|
||||
[TerminalId],
|
||||
[CardSchemeName],
|
||||
[ExpiryDate],
|
||||
[RecordReference],
|
||||
[Token1],
|
||||
[Token2],
|
||||
[PanMasked],
|
||||
[AuthCode],
|
||||
[TransactionResult]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@TxnReference,
|
||||
@TxnDateTime,
|
||||
@TotalAmount,
|
||||
@MerchantId,
|
||||
@TerminalId,
|
||||
@CardSchemeName,
|
||||
@ExpiryDate,
|
||||
@RecordReference,
|
||||
@Token1,
|
||||
@Token2,
|
||||
@PanMasked,
|
||||
@AuthCode,
|
||||
@TransactionResult
|
||||
);
|
||||
`
|
||||
// Execute with sql.Named parameters:
|
||||
_, err := db.ExecContext(ctx, stmt,
|
||||
sql.Named("TxnReference", ref),
|
||||
sql.Named("TxnDateTime", txnTime),
|
||||
sql.Named("TotalAmount", nullableFloatArg(totalAmount)),
|
||||
sql.Named("MerchantId", nullableStringArg(merchantId)),
|
||||
sql.Named("TerminalId", nullableStringArg(terminalId)),
|
||||
sql.Named("CardSchemeName", nullableStringArg(cardScheme)),
|
||||
sql.Named("ExpiryDate", nullableStringArg(expiryDate)),
|
||||
sql.Named("RecordReference", nullableStringArg(recordRef)),
|
||||
sql.Named("Token1", nullableStringArg(token1)),
|
||||
sql.Named("Token2", nullableStringArg(token2)),
|
||||
sql.Named("PanMasked", nullableStringArg(panMasked)),
|
||||
sql.Named("AuthCode", nullableStringArg(authCode)),
|
||||
sql.Named("TransactionResult", nullableStringArg(txnResult)),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert TransactionRecords: %w", err)
|
||||
}
|
||||
// Successfully inserted
|
||||
log.Infof("Inserted transaction record for reference %s", ref)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helpers to pass NULL when appropriate:
|
||||
func nullableStringArg(ns sql.NullString) interface{} {
|
||||
if ns.Valid {
|
||||
return ns.String
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func nullableFloatArg(nf sql.NullFloat64) interface{} {
|
||||
if nf.Valid {
|
||||
return nf.Float64
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildRedirectURL builds the redirect URL to send the guest to after payment.
|
||||
func BuildRedirectURL(result map[string]string) string {
|
||||
res := result[TransactionResult]
|
||||
tType := result[TransactionType]
|
||||
|
||||
// Transaction approved?
|
||||
if strings.EqualFold(res, ResultApproved) {
|
||||
switch {
|
||||
// Transaction type AccountVerification?
|
||||
case strings.EqualFold(tType, AccountVerificationType):
|
||||
log.WithField(LogResult, result[TransactionResult]).
|
||||
Info("Account verification approved")
|
||||
|
||||
return buildSuccessURL(result)
|
||||
|
||||
// Transaction type Sale?
|
||||
case strings.EqualFold(tType, SaleTransactionType):
|
||||
// Transaction confirmed?
|
||||
if strings.EqualFold(result[ConfirmResult], ResultApproved) {
|
||||
log.WithField(LogResult, result[ConfirmResult]).
|
||||
Info("Transaction approved and confirmed")
|
||||
|
||||
return buildSuccessURL(result)
|
||||
}
|
||||
|
||||
// Not confirmed
|
||||
log.WithFields(log.Fields{LogFieldError: result[ConfirmResult], LogFieldDescription: result[ConfirmErrors]}).
|
||||
Error("Transaction approved but not confirmed")
|
||||
|
||||
return BuildFailureURL(result[ConfirmResult], result[ConfirmErrors])
|
||||
}
|
||||
}
|
||||
|
||||
// Not approved
|
||||
return BuildFailureURL(res, result[Errors])
|
||||
}
|
||||
|
||||
func buildSuccessURL(result map[string]string) string {
|
||||
q := url.Values{}
|
||||
q.Set("TxnReference", result[Reference])
|
||||
q.Set("CardHash", hex.EncodeToString([]byte(result[CardHash])))
|
||||
q.Set("CardReference", hex.EncodeToString([]byte(result[CardReference])))
|
||||
return (&url.URL{
|
||||
Path: CheckinSuccessfulEndpoint,
|
||||
RawQuery: q.Encode(),
|
||||
}).String()
|
||||
}
|
||||
|
||||
func BuildFailureURL(msgType, description string) string {
|
||||
q := url.Values{}
|
||||
if msgType != "" {
|
||||
description = fmt.Sprintf("Transaction %s", strings.ToLower(msgType))
|
||||
}
|
||||
if description != "" {
|
||||
msgType = ResultError
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{LogFieldError: msgType, LogFieldDescription: description}).
|
||||
Error("Transaction failed")
|
||||
|
||||
q.Set("MsgType", msgType)
|
||||
q.Set("Description", description)
|
||||
return (&url.URL{
|
||||
Path: CheckinUnsuccessfulEndpoint,
|
||||
RawQuery: q.Encode(),
|
||||
}).String()
|
||||
}
|
||||
@ -2,6 +2,86 @@
|
||||
|
||||
builtVersion is a const in main.go
|
||||
|
||||
#### 1.3.0 - 03 July 2026
|
||||
added pluggable PayBridge and Dojo payment flow with CMS credentials
|
||||
|
||||
#### 1.2.11 - 26 June 2026
|
||||
added PayBridge integration for payment processing functionality
|
||||
|
||||
#### 1.2.10 - 26 June 2026
|
||||
added voucher field to the guest receipt
|
||||
|
||||
#### 1.2.9 - 02 June 2026
|
||||
added Dormakaba lock server integration
|
||||
|
||||
#### 1.2.8 - 14 May 2026
|
||||
Updated hardlink source layout to use cmd/hardlink for the main application entry point and internal/ for application packages. Runtime files and preauth-release layout remain unchanged. No functional changes.
|
||||
|
||||
#### 1.2.7 - 13 May 2026
|
||||
retrieve CardType from the ChepDNA response
|
||||
|
||||
#### 1.2.6 - 20 April 2026
|
||||
added the second attempt to send the card to the encoder after 6 seconds
|
||||
|
||||
#### 1.2.5 - 20 March 2026
|
||||
removed early return on error when checking dispenser status in the start and final loops.
|
||||
|
||||
#### 1.2.4 - 18 March 2026
|
||||
added check if keycard at the encoder position before trying to encode key
|
||||
|
||||
#### 1.2.3 - 17 March 2026
|
||||
added check if keycard at the encoder position before trying to encode key
|
||||
|
||||
#### 1.2.2 - 11 February 2026
|
||||
increased waiting time befor sending email on PDQ unavailability to 30 seconds day time and 10 minutes night time
|
||||
to give it a chance to become available again
|
||||
|
||||
#### 1.2.1 - 09 February 2026
|
||||
increased waiting time befor sending email on PDQ unavailability to 60 seconds
|
||||
|
||||
#### 1.2.0 - 09 February 2026
|
||||
added testissuedoorcard endpoint for testing the full workflow of encoding a door card without moving the card out
|
||||
added ping-pdq endpoint to check the status of the pdq terminal
|
||||
added sending the email on the pdq disconnect event to notify support about the issue
|
||||
added sending the email on the dispenser error status to notify support about the issue
|
||||
|
||||
#### 1.1.3 - 02 February 2026
|
||||
increased timeout for reading response from the Assa abloy lock server to 20 seconds
|
||||
|
||||
#### 1.1.2 - 02 February 2026
|
||||
added logging for unknown dispenser status positions
|
||||
|
||||
#### 1.1.1 - 02 February 2026
|
||||
added contionuous polling of the dispenser status every 8 seconds to update the card well status
|
||||
|
||||
#### 1.1.0 - 26 January 2026
|
||||
divided `/starttransaction` endpoint into two separate endpoints:
|
||||
`/takepreauth` to request preauthorization payment
|
||||
`/takepayment` to request taking payment
|
||||
added preauth releaser functionality to release preauthorization payments after a defined time period
|
||||
added db connection check before adding a transaction to the database
|
||||
and reconnection functionality if the connection to the database is lost
|
||||
added `/dispenserstatus` endpoint
|
||||
key card always stays at encoder position
|
||||
|
||||
#### 1.0.30 - 09 January 2026
|
||||
improved logging for preauth releaser
|
||||
|
||||
#### 1.0.29 - 08 January 2026
|
||||
added count down before exiting the preauth releaser 20 seconds
|
||||
|
||||
#### 1.0.28 - 10 December 2025
|
||||
added preauth releaser
|
||||
|
||||
#### 1.0.27 - 10 December 2025
|
||||
updated handling AmountTooSmall response from creditcall
|
||||
|
||||
#### 1.0.26 - 10 December 2025
|
||||
added route for taking preauthorization payment
|
||||
|
||||
#### 1.0.25 - 08 December 2025
|
||||
return masked card number and expiry date in the payment success URL
|
||||
|
||||
#### 1.0.24 - 13 November 2025
|
||||
improved logging for creditcall payment processing
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user