Compare commits

..

1 Commits

Author SHA1 Message Date
975707de4c start 2025-12-23 16:23:11 +00:00
71 changed files with 1077 additions and 11839 deletions

3
.gitignore vendored
View File

@ -29,7 +29,6 @@ _obj
_test
.vscode/
ChipDNAClient/
docs/
# Architecture specific extensions/prefixes
*.[568vq]
@ -43,6 +42,6 @@ _cgo_export.*
_testmain.go
*.exe*
*.exe
*.test
*.prof

View File

@ -1,124 +0,0 @@
# CreditCall payment streaming
Deploy ChipDNAClientCLI, then hardlink, then Operafyne. No database or configuration
migration is needed. The existing CreditCall provider selection enables the new
SALE and PREAUTH routes; CreditCall does not implement the generic payment provider interface.
## Contracts
`POST /api/payment/sale` accepts the existing JSON sale request, for example
`{"amount":1234,"confirmNo":"BOOKING-123","currency":"GBP"}`. Amount remains in
minor units. CreditCall uses its existing SDK transaction reference generation.
`POST /api/payment/preauth` accepts string fields `amount`, `transactionType`
and `checkoutDate`. Positive preauth sends the existing `Sale` input and checkout
string; zero-value verification sends `{"amount":"","transactionType":"AccountVerification","checkoutDate":""}`.
The returned `ACCOUNT VERIFICATION` type is matched case-insensitively and succeeds
without persistence. Returned approved `SALE` schedules existing SQL persistence
using provider-returned `TOTAL_AMOUNT`, never the request amount. PREAUTH does not
confirm. Checkout remains departure-derived midnight UTC in the existing string
format; SQL's existing date conversion and 48-hour release calculation are unchanged.
For CreditCall, responses contain newline-delimited JSON:
```json
{"type":"status","code":"PAYMENT_PRESENT_CARD"}
{"type":"result","result":{"outcome":"approved","message":"Payment approved","httpStatus":200,"status":{"code":200,"message":"Approved"},"transactionReference":"native-reference","cardType":"Visa","maskedCardNumber":"************1234","expiryDate":"1228","cardHash":"existing-hash","cardReference":"existing-card-reference"}}
```
`outcome` is `approved`, `declined`, `cancelled`, `timeout`, or `error`. Approval
is produced only by the shared CreditCall transaction/finalization core, including
SALE confirmation behavior; PREAUTH uses its existing unconfirmed-result rules. `httpStatus` preserves the equivalent legacy
operation status even after streaming commits HTTP 200. `status` preserves the
existing processor `StatusRec`. Failure `message` preserves the plain description
used by the legacy flow, including its existing interpretation quirks.
Card fields are emitted only on success. Unmasked or unexpected card-number data
is omitted; encoding is not masking. Receipts and raw SDK parameter bags are not
sent to Operafyne. The new adapter never builds a redirect URL. Dojo and PayBridge
continue using their existing `{"type":"result","response":...}` contract.
Hardlink calls `POST /start-transaction-stream/` on ChipDNAClientCLI with
`{"amount":"1234","transactionType":"Sale"}`. Its NDJSON consists of allowlisted
`status` frames (`source`, `value`), a single `result` containing allowlisted SDK
fields, or a sanitized `error`. `TRANSACTION_TYPE` and optional `TOTAL_AMOUNT`
are included only when returned by the provider. They are never synthesized and
are not added to the kiosk-facing result. AccountVerification normally omits
`TOTAL_AMOUNT`. Receipt XML may be a JSON string field needed by
hardlink's existing receipt handler; it is never a raw line in the stream.
SALE confirmation continues through the existing, separate XML endpoint.
## Lifetime and compatibility
- `/start-transaction/`, `/takepayment`, and `/takepreauth` retain their existing
external contracts. Both SALE and PREAUTH now stream to Operafyne; existing SQL
persistence/release behavior remains unchanged.
- Operafyne uses a private 120-second CreditCall client for SALE and PREAUTH.
This is only the kiosk wait boundary; Dojo/PayBridge clients are unchanged.
Timeout/cancellation is technical, not an authoritative decline or automatically
retryable result. It never triggers a second start or legacy endpoint fallback.
- Validation uses the inbound request. At financial dispatch handoff, hardlink
detaches cancellation with `context.WithoutCancel`. Inbound cancellation and
write failures control only delivery; result processing, SALE confirmation,
receipts and PREAUTH persistence scheduling continue independently.
- Start and each SALE confirmation call retain their independent 300-second timeout.
Confirmation still uses two attempts, retrying transport/read errors with the
existing two-second delay. The generic whole-operation timeout is not used.
- Kiosk cancellation or failed/blocked kiosk delivery does not cancel upstream reading,
confirmation, receipt handling, or PREAUTH persistence scheduling. Progress queues may drop hints when full;
final results have a separate slot and are delivered at most once.
- Only the response writer writes frames. Transaction execution never waits
for progress delivery. No additional delivery timer or whole-operation deadline
shortens the existing HTTP call bounds.
- Native SDK 3.17 updates use `UPDATE`; card notifications use `NOTIFICATION`,
retained as `CARD_STATUS` on the internal wire. Normal progress does not require
a reference: it is accepted only inside the existing serial transaction's active
observational window. A supplied nonempty reference must match. The window opens
immediately before the SDK start call and closes immediately at
`TransactionFinished`; finalization never waits for card removal.
- `CardRemovalRequested` and `CardRemovalEnforced` follow the same active-window
rule as other progress, including when no reference is supplied. Real Miura
insertion-recovery sequences can repeat present-card and remove-card prompts
within one transaction; these repeated prompts are not deduplicated. Outside
the active window they are discarded. `Removed` remains omitted even with a
reference, and the existing `Inserted` mapping is unchanged.
- Ownership is best-effort UI observation. SDK 3.17 does not establish that every
queued reference-less callback, including removal, has drained before another transaction
starts. Such a callback may briefly display stale progress in a later active
window. This accepted limitation must never affect success, decline, cancellation,
timeout outcomes, confirmation, retry, receipts, PMS posting, or business state.
- After timeout, an ambiguous start error, an asynchronous SDK error during an
active window, or reference reuse/overlap, progress remains suppressed for that
`Client` lifetime across SALE and PREAUTH. New transactions, elapsed time, callbacks and automatic
reconnect do not reset the guard. Payments remain enabled.
- Only the exact synchronous `ClientNotConnectedToServer` error safely clears an
unsuppressed window and releases its unused reference: SDK 3.17 `StartCommand`
returns it before `SendRequest`. Validation errors, mixed errors and all other
unproven errors suppress observation. Safe pre-dispatch rejection never clears
an existing suppression latch.
- Progress cannot approve, decline, confirm, cancel, or retry a transaction.
`OnlineAuthCompleted` means waiting; `Removed` and unknown events are omitted.
## Automated verification
In hardlink, use `go test -count=1 -skip '^Test_SendMail$' ./...` to exclude the
existing test that sends real email. Focused tests cover legacy parity, malformed
XML/NDJSON, retained confirmation fields and receipts, retries, disconnects,
backpressure, field filtering, and conservative status mapping. Race checks cover
the changed handler and mapper packages. No test proves absence of orphan holds.
In ChipDNAClientCLI, build `ChipDnaClient.sln` and `Tests/StreamingTests.csproj`
with MSBuild, then run `Tests/bin/Debug/ChipDNAClient.StreamingTests.exe`. The
test executable links the production streaming code and needs no terminal.
In Operafyne, run `go test -count=1 ./...`; the service tests cover structured
CreditCall SALE/PREAUTH, unchanged legacy requests, the 120-second UX boundary,
cancellation after dispatch, failure/retry parity, and existing
Dojo/PayBridge response handling. Run `go vet ./...`, `go build ./...`, and
`git diff --check` in both Go repositories.
Physical account-verification testing confirmed approved `ACCOUNT VERIFICATION`
with no `TOTAL_AMOUNT`. A monetary `/takepayment` test confirmed returned minor
units; positive PREAUTH through the new transport still needs physical Miura
validation. Automated tests do not replace that check. Receipt fields such as
`NoChargeDeclaration` retain the existing provider-entry rendering behavior.

View File

@ -5,7 +5,7 @@ import (
"database/sql"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/db"
"gitea.futuresens.co.uk/futuresens/hardlink/db"
)
func OpenDB(cfg *config.ConfigRec) (*sql.DB, error) {

View File

@ -1,330 +0,0 @@
package main
import (
"context"
"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 = "v2.1.3"
serviceName = "hardlink"
pollingFrequency = 8 * time.Second
)
func main() {
// Load config
cfg := config.ReadHardlinkConfig()
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 streaming shares its proven legacy transaction core.
app.EnableCreditCallStreaming()
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:
websocketURL := pdqSerialForKiosk(reservationSystem.PDQs, cfg.Kiosk)
if websocketURL == "" {
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "PayBridge websocket 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(
websocketURL,
reservationSystem.PaymentGatewayAPIKeyWeb,
cfg.TimeoutSeconds,
)
case cmstypes.PaySystemDojo:
TerminalID := pdqSerialForKiosk(reservationSystem.PDQs, cfg.Kiosk)
if TerminalID == "" {
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Dojo Terminal ID not found", fmt.Errorf("payment provider dojo requires a terminal ID for kiosk %q in the CMS", cfg.Kiosk))
}
if reservationSystem.PaymentGatewayURL == "" {
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "Payment Gateway URL not found", fmt.Errorf("payment provider dojo requires dojo.websocket_url"))
}
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,
))
}
// PayBridge and Dojo use the generic payment service.
if provider != nil {
app.SetPaymentService(paymentsvc.NewService(provider))
log.Infof("Payment provider enabled for POST /api/payment/sale: %s", cmstypes.PaySystemNames[paymentProvider])
fmt.Printf("Payment provider enabled for POST /api/payment/sale: %s\n", cmstypes.PaySystemNames[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)
disp.StartMaintenance()
}
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)
}()
}
func pdqSerialForKiosk(pdqs []cmstypes.PDQRec, kiosk int) string {
for _, item := range pdqs {
if item.Kiosk == kiosk {
return item.Serial
}
}
return ""
}

View File

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

View File

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

View File

@ -1,4 +1,3 @@
// Package config handles reading and parsing configuration from config.yml.
package config
import (
@ -6,41 +5,35 @@ import (
"os"
"strings"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
"gitea.futuresens.co.uk/futuresens/hardlink/handlers"
log "github.com/sirupsen/logrus"
yaml "gopkg.in/yaml.v3"
)
// ConfigRec holds values from config.yml.
// 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"`
Port int `yaml:"port"`
LockserverUrl string `yaml:"lockservUrl"`
LockType string `yaml:"lockType"`
EncoderAddress string `yaml:"encoderAddr"`
Cert string `yaml:"cert"`
DispenserPort string `yaml:"dispensPort"`
DispenserAdrr string `yaml:"dispensAddr"`
PrinterName string `yaml:"printerName"`
LogDir string `yaml:"logdir"`
Dbport int `yaml:"dbport"` // Port for the database connection
Dbname string `yaml:"dbname"` // Database name for the connection
Dbuser string `yaml:"dbuser"` // User for the database connection
Dbpassword string `yaml:"dbpassword"` // Password for the database connection
IsPayment bool `yaml:"isPayment"`
TestMode bool `yaml:"testMode"`
}
// ReadHardlinkConfig reads config.yml and applies defaults.
// ReadConfig reads config.yml and applies defaults.
func ReadHardlinkConfig() ConfigRec {
var cfg ConfigRec
const configName = "config.yml"
const defaultPort = 9091
defaultPort := 9091
sep := string(os.PathSeparator)
data, err := os.ReadFile(configName)
@ -55,7 +48,8 @@ func ReadHardlinkConfig() ConfigRec {
}
if cfg.LockType == "" {
errorhandlers.FatalError(fmt.Errorf("LockType is required in %s", configName))
err = fmt.Errorf("LockType is required in %s", configName)
handlers.FatalError(err)
}
cfg.LockType = strings.ToLower(cfg.LockType)
@ -66,13 +60,8 @@ func ReadHardlinkConfig() ConfigRec {
}
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
err = fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName)
log.Warnf(err.Error())
}
return cfg
@ -91,7 +80,8 @@ func ReadPreauthReleaserConfig() ConfigRec {
}
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))
err = fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName)
handlers.FatalError(err)
}
if cfg.LogDir == "" {

View File

@ -12,7 +12,7 @@ import (
mssqldb "github.com/denisenkom/go-mssqldb" // for error inspection
log "github.com/sirupsen/logrus"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/hardlink/types"
)
// InitMSSQL opens and pings the SQL Server instance (keeps your original behaviour)
@ -198,4 +198,3 @@ WHERE TxnReference = @TxnReference AND Released = 0;
log.Infof("Marked preauth %s released at %s", txnReference, releasedAt.Format(time.RFC3339))
return nil
}

294
dispenser/dispenser.go Normal file
View File

@ -0,0 +1,294 @@
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 := make([]string, len(posStatus))
for _, p := range posStatus {
statusMsg, exists := p.mapper[p.value]
if !exists {
statusMsg = "Unknown status"
}
if p.value != 0x30 {
result = append(result, fmt.Sprintf("Status: %s; ", statusMsg))
}
if p.pos == 4 && p.value == 0x38 {
return nil, 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
View File

@ -3,13 +3,10 @@ module gitea.futuresens.co.uk/futuresens/hardlink
go 1.23.2
require (
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.200
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190
gitea.futuresens.co.uk/futuresens/logging v1.0.9
github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb
github.com/denisenkom/go-mssqldb v0.12.3
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
View File

@ -1,5 +1,5 @@
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/cmstypes v1.0.190 h1:OxP911wT8HQqBJ20KIZcBxi898rsYHhhCkne2u45p1A=
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.190/go.mod h1:ABMUkdm+3VGrkuoCJsXMfPPud9GHDOwBb1NiifFqxes=
gitea.futuresens.co.uk/futuresens/fscrypto v0.0.0-20221125125050-9acaffd21362 h1:MnhYo7XtsECCU+5yVMo3tZZOOSOKGkl7NpOvTAieBTo=
gitea.futuresens.co.uk/futuresens/fscrypto v0.0.0-20221125125050-9acaffd21362/go.mod h1:p95ouVfK4qyC20D3/k9QLsWSxD2pdweWiY6vcYi9hpM=
gitea.futuresens.co.uk/futuresens/logging v1.0.9 h1:uvCQq/plecB0z/bUWOhFhwyYUWGPkTBZHsYNL+3RFvI=
@ -19,12 +19,6 @@ 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=

382
handlers/handlers.go Normal file
View File

@ -0,0 +1,382 @@
package handlers
import (
"bytes"
"database/sql"
"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/db"
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/payment"
"gitea.futuresens.co.uk/futuresens/hardlink/printer"
"gitea.futuresens.co.uk/futuresens/hardlink/types"
"gitea.futuresens.co.uk/futuresens/logging"
log "github.com/sirupsen/logrus"
)
type App struct {
dispPort *serial.Port
lockserver lockserver.LockServer
isPayment bool
db *sql.DB
}
func NewApp(dispPort *serial.Port, lockType, encoderAddress string, db *sql.DB, isPayment bool) *App {
return &App{
isPayment: isPayment,
dispPort: dispPort,
lockserver: lockserver.NewLockServer(lockType, encoderAddress, FatalError),
db: db,
}
}
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)
}
func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("takePreauthorization")
var (
theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec
trResult payment.TransactionResultXML
result payment.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 {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("takePreauthorization called")
if r.Method != http.MethodPost {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Method not allowed; use POST")
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
return
}
defer r.Body.Close()
if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return
}
body, _ := io.ReadAll(r.Body)
err := xml.Unmarshal(body, &theRequest)
if err != nil {
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse)
return
}
log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType)
client := &http.Client{Timeout: 300 * time.Second}
response, err := client.Post(types.LinkTakePreauthorization, "text/xml", bytes.NewBuffer(body))
if err != nil {
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return
}
defer response.Body.Close()
body, err = io.ReadAll(response.Body)
if err != nil {
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read response body")
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
return
}
if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(serviceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
}
// Compose JSON from responseEntries
result.FillFromTransactionResult(trResult)
if err := printer.PrintCardholderReceipt(result.CardholderReceipt); err != nil {
log.Errorf("PrintCardholderReceipt error: %v", err)
}
theResponse.Status = result.Status
theResponse.Data, save = payment.BuildPreauthRedirectURL(result.Fields)
if save {
db.InsertPreauth(r.Context(), app.db, result.Fields, theRequest.CheckoutDate)
}
writeTransactionResult(w, http.StatusOK, theResponse)
}
func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("takePayment")
var (
theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec
trResult payment.TransactionResultXML
result payment.PaymentResult
)
theResponse.Status.Code = http.StatusInternalServerError
theResponse.Status.Message = "500 Internal server error"
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Content-Type", "application/json")
if !app.isPayment {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Payment processing is disabled")
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
return
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
log.Println("takePayment called")
if r.Method != http.MethodPost {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Method not allowed; use POST")
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
return
}
defer r.Body.Close()
if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Content-Type must be text/xml")
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
return
}
body, _ := io.ReadAll(r.Body)
err := xml.Unmarshal(body, &theRequest)
if err != nil {
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Invalid XML payload")
writeTransactionResult(w, http.StatusBadRequest, theResponse)
return
}
log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType)
client := &http.Client{Timeout: 300 * time.Second}
response, err := client.Post(types.LinkTakePayment, "text/xml", bytes.NewBuffer(body))
if err != nil {
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return
}
defer response.Body.Close()
body, err = io.ReadAll(response.Body)
if err != nil {
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
theResponse.Data = payment.BuildFailureURL(types.ResultError, "Failed to read response body")
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
return
}
if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(serviceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
}
// Compose JSON from responseEntries
result.FillFromTransactionResult(trResult)
if err := printer.PrintCardholderReceipt(result.CardholderReceipt); err != nil {
log.Errorf("PrintCardholderReceipt error: %v", err)
}
theResponse.Status = result.Status
theResponse.Data = payment.BuildPaymentRedirectURL(result.Fields)
writeTransactionResult(w, http.StatusOK, theResponse)
}
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(types.CustomLayout, doorReq.CheckinTime)
if err != nil {
logging.Error(serviceName, err.Error(), "Invalid checkinTime format", string(op), "", "", 0)
writeError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error())
return
}
checkOut, err := time.Parse(types.CustomLayout, doorReq.CheckoutTime)
if err != nil {
logging.Error(serviceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0)
writeError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error())
return
}
// card to encoder position if not there already
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)
dispenser.DispenserSequence(app.dispPort)
return
}
// card out of mouth
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)
}
// card to encoder position
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)
}
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",
})
}

View File

@ -1,4 +1,4 @@
package errorhandlers
package handlers
import (
"encoding/json"
@ -7,12 +7,14 @@ import (
"os"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
"gitea.futuresens.co.uk/futuresens/logging"
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,
@ -22,6 +24,14 @@ 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())
@ -29,8 +39,3 @@ 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)
}

View File

@ -4,17 +4,16 @@ package main
import (
"fmt"
"os"
"time"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/bootstrap"
"gitea.futuresens.co.uk/futuresens/hardlink/bootstrap"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/logging"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
"gitea.futuresens.co.uk/futuresens/hardlink/logging"
"gitea.futuresens.co.uk/futuresens/hardlink/payment"
log "github.com/sirupsen/logrus"
)
const (
buildVersion = "1.0.2"
buildVersion = "1.0.0"
serviceName = "preauth-release"
)
@ -32,18 +31,12 @@ func main() {
}
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")
if err := payment.ReleasePreauthorizations(database); err != nil {
log.WithError(err).Fatal("Preauth release failed")
}
for i := 20; i > 0; i-- {
fmt.Printf("\rExiting in %2d seconds... ", i)
time.Sleep(time.Second)
}
fmt.Println("\rExiting now. ")
log.Info("Task completed successfully")
fmt.Println(". Press Enter to exit...")
fmt.Scanln()
os.Exit(0)
}

View File

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

View File

@ -1,35 +0,0 @@
package creditcall
import (
"strings"
"testing"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
)
func TestPreauthLegacyDecision(t *testing.T) {
for _, tc := range []struct {
name, result, kind string
approved, save bool
}{
{"sale", "APPROVED", "SALE", true, true},
{"verification", "APPROVED", "ACCOUNT VERIFICATION", true, false},
{"mixed case", "Approved", "Account Verification", true, false},
{"request spelling is not result spelling", "APPROVED", "AccountVerification", false, false},
{"missing type", "APPROVED", "", false, false},
{"declined sale", "DECLINED", "SALE", false, false},
{"declined verification", "DECLINED", "ACCOUNT VERIFICATION", false, false},
{"missing result", "", "SALE", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
fields := map[string]string{types.TransactionResult: tc.result, types.TransactionType: tc.kind, types.Errors: "provider detail"}
redirect, save := BuildPreauthRedirectURL(fields)
if strings.HasPrefix(redirect, "/successful?") != tc.approved || save != tc.save {
t.Errorf("BuildPreauthRedirectURL(%v) = %q,%t, want approved=%t save=%t", fields, redirect, save, tc.approved, tc.save)
}
if _, ok := fields[types.TotalAmount]; ok {
t.Error("BuildPreauthRedirectURL fabricated TOTAL_AMOUNT")
}
})
}
}

View File

@ -1,64 +0,0 @@
package creditcall
import "gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
// ProgressStatus maps informational SDK events, never financial decisions.
func ProgressStatus(source, value string) string {
switch source {
case "UPDATE":
switch value {
case "CardRequested", "ProvideCard", "InsertOrSwipeCard", "InsertOrContactless":
return paymentstatus.PresentCard
case "InsertCard":
return paymentstatus.InsertCard
case "SwipeCard":
return paymentstatus.SwipeCard
case "CardRemovalRequested", "CardRemovalEnforced":
return paymentstatus.RemoveCard
case "PinEntryStarted", "PinEntryPrompt", "PinEntryInProgress":
return paymentstatus.EnterPIN
case "PinEntryFailed":
return paymentstatus.EnterPINAgain
case "PinEntrySuccessful", "TransactionStarted", "OnlineAuthRequested", "OnlineAuthorizeRequest":
return paymentstatus.Processing
case "OnlineAuthCompleted":
return paymentstatus.PleaseWait
}
case "CARD_STATUS":
switch value {
case "Inserted":
return paymentstatus.DoNotRemoveCard
case "Tapped", "Swiped":
return paymentstatus.Processing
}
case "SIGNATURE":
if value == "Requested" {
return paymentstatus.SignatureRequired
}
case "PAUSE":
if value == "Paused" {
return paymentstatus.PleaseWait
}
}
return ""
}
// MaskedCardNumber excludes unmasked or unexpected SDK data from the new wire contract.
func MaskedCardNumber(value string) string {
digits, masks := 0, 0
for _, ch := range value {
switch {
case ch >= '0' && ch <= '9':
digits++
case ch == '*' || ch == 'x' || ch == 'X':
masks++
case ch == ' ' || ch == '-':
default:
return ""
}
}
if masks < 4 || digits > 10 {
return ""
}
return value
}

View File

@ -1,52 +0,0 @@
package creditcall
import (
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
"testing"
)
func TestProgressStatus(t *testing.T) {
for _, test := range []struct{ source, value, want string }{
{"UPDATE", "CardRequested", paymentstatus.PresentCard},
{"UPDATE", "ProvideCard", paymentstatus.PresentCard},
{"UPDATE", "InsertOrSwipeCard", paymentstatus.PresentCard},
{"UPDATE", "InsertOrContactless", paymentstatus.PresentCard},
{"UPDATE", "InsertCard", paymentstatus.InsertCard},
{"UPDATE", "SwipeCard", paymentstatus.SwipeCard},
{"UPDATE", "CardRemovalRequested", paymentstatus.RemoveCard},
{"UPDATE", "CardRemovalEnforced", paymentstatus.RemoveCard},
{"UPDATE", "PinEntryStarted", paymentstatus.EnterPIN},
{"UPDATE", "PinEntryPrompt", paymentstatus.EnterPIN},
{"UPDATE", "PinEntryInProgress", paymentstatus.EnterPIN},
{"UPDATE", "PinEntryFailed", paymentstatus.EnterPINAgain},
{"UPDATE", "PinEntrySuccessful", paymentstatus.Processing},
{"UPDATE", "TransactionStarted", paymentstatus.Processing},
{"UPDATE", "OnlineAuthRequested", paymentstatus.Processing},
{"UPDATE", "OnlineAuthorizeRequest", paymentstatus.Processing},
{"UPDATE", "OnlineAuthCompleted", paymentstatus.PleaseWait},
{"CARD_STATUS", "Inserted", paymentstatus.DoNotRemoveCard},
{"CARD_STATUS", "Tapped", paymentstatus.Processing},
{"CARD_STATUS", "Swiped", paymentstatus.Processing},
{"CARD_STATUS", "Removed", ""},
{"SIGNATURE", "Requested", paymentstatus.SignatureRequired},
{"PAUSE", "Paused", paymentstatus.PleaseWait},
{"UPDATE", "Approved", ""}, {"UPDATE", "Declined", ""},
{"UPDATE", "unknown", ""}, {"PAN", "CardRequested", ""},
} {
if got := ProgressStatus(test.source, test.value); got != test.want {
t.Errorf("ProgressStatus(%q, %q) = %q, want %q", test.source, test.value, got, test.want)
}
}
}
func TestMaskedCardNumber(t *testing.T) {
for _, test := range []struct{ value, want string }{
{"123456******1234", "123456******1234"}, {"************1234", "************1234"},
{"1234567890123456", ""}, {"1234567890123456****", ""}, {"3132333435363738", ""},
{"%B1234567890123456^NAME", ""}, {"", ""},
} {
if got := MaskedCardNumber(test.value); got != test.want {
t.Errorf("MaskedCardNumber(%q) = %q, want %q", test.value, got, test.want)
}
}
}

View File

@ -1,335 +0,0 @@
# K720 dispenser issuance contract
Hardlink treats the K720 as an unreliable mechanical peripheral with a strict transport
protocol and imperfect status telemetry. The implementation must keep transport
validation strict while making mechanical decisions from fresh, bounded observations.
Diagnostic bytes are useful evidence, but they must not override a valid physical
position or turn an uncertain observation into a fatal guest-facing error.
This document records the dispenser invariants agreed for the clean implementation.
It is intended to be a design contract: future changes should preserve these rules
unless production evidence justifies changing them explicitly.
## Ownership and concurrency
- The dispenser worker is the single owner of serial commands and `deliveryPending`.
New code must not access the serial port directly from handlers or background
goroutines.
- Commands that can move a card are serialized through the worker. There must never
be concurrent AP/FC7/FC0/RS traffic from separate flows.
- `deliveryPending` is worker-owned state. It must not be duplicated or mutated by
HTTP handlers.
- A real `/issuedoorcard` operation always has priority over maintenance/prestage
work.
- Cancellation of a real caller must stop caller-owned work. Internal bounded
observation timeouts must remain distinguishable from caller cancellation.
- Do not create overlapping maintenance goroutines or timers per request. Any
background maintenance must have one clear owner and lifecycle.
## Status and observation model
AP transport parsing is strict. A response is usable only when its framing and
contents are valid, including ACK/address/header/length/ETX/BCC/type checks.
Malformed, truncated, timed-out or otherwise invalid AP responses are unusable
observations. They are logged and discarded. They are never converted into a
fabricated physical state such as `0x30` or `0x38`.
Every fresh valid AP observation reclassifies the current mechanical state. No
physical position is latched across later observations.
The fourth AP status byte is the physical position used by the preparation logic:
- Encoder confirmed:
`0x32, 0x33, 0x36, 0x37, 0x3A, 0x3B, 0x3E, 0x3F`.
A card may be handed to `LockSequence` immediately.
- Valid but uncertain:
`0x31, 0x34, 0x35, 0x39, 0x3C, 0x3D`.
Continue bounded fresh observation; if no stronger state appears, one encoding
opportunity may still be granted after the uncertainty window.
- No card on sensors:
exact `0x30`.
This may trigger the bounded mechanical recovery sequence.
- Card well empty:
exact `0x38`.
This is the only physical state that becomes `ErrCardWellEmpty`.
The first three diagnostic bytes are advisory. Values such as `Preparing card fails`,
`Dispense card error`, `Card jammed`, or `Command cannot execute` may coexist with a
usable physical position. They may be logged, but they must not automatically block
encoding when the fourth byte confirms a usable card position.
## Preparing the current card
A `/issuedoorcard` request receives at most one `LockSequence` opportunity.
Preparation uses bounded fresh observation:
- Poll interval: 1 second.
- After a successful FC7, allow 3 seconds of observation before shake recovery is
eligible for a persistent exact `0x30`.
- For uncertain or unusable observations after a successful FC7, use one shared
4-second uncertainty window. Switching between uncertain and unusable observations
does not restart that window.
- A later usable observation always takes precedence:
encoder confirmed -> handoff;
exact `0x38` -> empty;
exact `0x30` -> resume the remaining shake budget and reset uncertainty timing.
- Total preparation budget: 32 seconds.
For persistent exact `0x30`, the request may perform at most three shake recoveries:
`RS -> 2 second settle -> FC7`
There is no additional plain FC7 retry. Including the initial dispatch, the maximum
per request is four FC7 commands and three RS commands.
If all three shakes are exhausted while the latest usable physical state remains
exact `0x30`, preparation fails. If the internal preparation deadline is reached
while the latest state is uncertain or unusable after a successful FC7, the request
may hand off one encoding opportunity. Exact `0x38` remains empty.
FC7 or RS dispatch failures are real transport/command failures. They are not
converted into status uncertainty and are not retried merely because their dispatch
failed.
## Encoding and delivery
`LockSequence` runs exactly once for a prepared card in a single `/issuedoorcard`
request. The dispenser layer does not implement a second encoding attempt on the same
physical card.
After `LockSequence`, FC0 is dispatched exactly once to move the current card toward
the guest:
- `LockSequence` success -> FC0 once -> successful issuance remains successful.
- `LockSequence` failure -> FC0 once -> return the original encoding error.
- FC0 errors are logged only. They do not replace the original encoding result and
they do not become `ErrCardWellEmpty`.
A failed `LockSequence` does not start next-card prestaging.
The design intentionally does not add retained-card state, per-card failure counters,
provider-specific encoder retries, special bad-card recovery, CP/capture recovery, or
multiple encoding attempts per physical card without production evidence requiring
them.
## Delivery clearance
After FC0, the worker marks the previous delivery as pending. A later FC7 must not be
sent until the previous delivery has been considered clear.
Delivery clearance uses fresh AP observations and is owned by the worker.
Current bounds:
- Minimum clearance wait: 2 seconds.
- Clearance timeout: 6 seconds.
- Poll interval: 1 second.
A valid physical position of `0x30` or `0x34` may clear `deliveryPending` after the
minimum wait unless the same fresh observation explicitly indicates active
preparing/dispensing/capturing movement.
Exact `0x38` reports physical empty and does not silently clear the state.
If the internal clearance timeout is reached while the caller context is still
valid, the worker may make the bounded assumption that delivery has cleared unless
the latest fresh usable observation establishes exact physical empty (`0x38`) or
explicit active movement. This fallback may therefore occur while the latest
physical position is `0x33`; `0x33` itself is not positive clearance evidence.
The fallback is an internal timeout policy, not a reclassification of the observed
position.
If the latest fresh usable observation still explicitly indicates movement, that is
not treated as unknown. `deliveryPending` remains set and the clearance attempt
times out.
Caller cancellation or deadline always takes precedence over the internal clearance
fallback. If the caller context expires, return the caller error and do not admit a
subsequent FC7 from that caller-owned operation.
A later unusable observation supersedes older movement evidence; stale movement
information must not be latched indefinitely.
## Next-card prestage and guest UX
Preparing the next card is an optimization for the next guest, not part of the
business success of the current guest's issuance.
After a successful `LockSequence` and FC0, Hardlink may make a short best-effort
attempt to prestage the next card:
- wait for worker-owned delivery clearance;
- if clearance is obtained within the bounded prestage context, send exactly one FC7;
- do not run readiness polling, RS/shake recovery, or the full current-card
preparation flow;
- prestage failure is log-only and must not change the successful `/issuedoorcard`
result.
Do not extend the current guest's screen by 15-20 seconds merely to guarantee that
the next card reaches the encoder. The guest-facing flow must remain bounded even
when the dispenser is slow to become ready for prestage.
If the short prestage window expires, skip that prestage attempt. A later request or
maintenance cycle may prepare the next card.
## HTTP contract
Physical empty and operational failure are deliberately different outcomes.
- HTTP 503 is reserved for `ErrCardWellEmpty`, derived only from an exact valid
physical `0x38`.
- All other dispenser preparation, transport, command and encoding failures return
HTTP 502.
- Normal request/protocol validation keeps its existing 400/405/415 behavior.
- Diagnostic text such as `Preparing card fails` must never by itself produce 503.
Operafyne treats:
- 502 as retryable while attempts remain;
- 503 as terminal `dispenser_failed`;
- a maximum of three total issue attempts: the initial attempt plus up to two user
retries.
The dispenser layer must preserve this distinction.
## Passive status and alerts
Ordinary status queries remain strict and passive. They must not reuse tolerant
issuance semantics to fabricate a status or hide malformed transport.
Passive polling captures the current foreground activity generation when the poll is
queued. The worker skips a passive poll if foreground activity is active when it is
dispatched or if the captured generation has become obsolete. Passive polling does
not reset the idle-maintenance clock.
Status/diagnostic observations may be useful for logs and support alerts, but alerts
must not alter physical state classification or issuance success.
Idle maintenance observations are intentionally quiet: they do not invoke the normal
stock-update callback or generate repeated support email such as `Preparing card
fails`. Maintenance failures are local diagnostic events only.
## Idle prestage maintenance
Idle prestaging recovers from a short post-FC0 prestage timeout without keeping the
current guest waiting.
The implementation uses the existing dispenser worker as the single maintenance
owner:
- one resettable maintenance timer is owned by the serial-worker loop; there is no
maintenance goroutine and no timer created per request;
- `/issuedoorcard` and `/testissuedoorcard` register foreground activity before
using the dispenser/encoder and release it when the handler finishes;
- foreground activity is reference-counted so overlapping requests suppress
maintenance until the last active request finishes;
- each foreground registration increments an activity generation and invalidates the
previous idle deadline;
- when the last foreground request finishes, the next maintenance attempt is
scheduled one minute later;
- enabling maintenance at startup schedules the first check one minute later when
no foreground request is active;
- a stale timer or stale generation cannot perform maintenance;
- after a maintenance attempt, the next check is scheduled one minute from completion
only if the same generation is still authoritative. A later foreground completion
therefore cannot have its newer deadline overwritten by an older maintenance
attempt.
Foreground registration and maintenance transaction admission use the same activity
guard. The guard is used only for admission/state bookkeeping and is never held
across serial I/O, queue waits, sleeps, callbacks, or `LockSequence`.
A maintenance attempt is deliberately weaker than foreground preparation:
1. create a bounded 5-second maintenance context;
2. recheck maintenance admission before AP;
3. obtain one fresh AP using the existing strict transport parser;
4. classify the fresh status using the common physical classifier;
5. do nothing for encoder-present, exact `0x38`, unusable, explicit-movement, or
otherwise ineligible observations;
6. when `deliveryPending` is set, require fresh qualifying clearance evidence and the
existing minimum clearance wait before clearing it;
7. never use the foreground six-second assumed-clearance fallback;
8. recheck foreground count, activity generation, maintenance enabled state, and
maintenance context immediately before FC7 admission;
9. dispatch at most one FC7 and return immediately.
If foreground activity begins while an already-admitted maintenance AP is in
progress, that AP may finish, but the second admission check prevents maintenance
from sending FC7 afterward. An FC7 that was already admitted and dispatched before
foreground registration cannot be recalled.
Maintenance never performs:
- RS or shake recovery;
- `LockSequence`;
- `PrepareCurrentCard`;
- `PrepareNextCard` or `BeginPrepareNextCard`;
- readiness polling after FC7;
- the 32-second foreground preparation flow;
- repeated FC7 attempts;
- the foreground assumed-clearance fallback.
The 5-second maintenance context bounds new admissions and context-aware waits. An
already-admitted serial read may still finish according to the existing serial read
timeout, but no subsequent maintenance transaction is admitted after cancellation or
expiry.
`StartMaintenance` and `StopMaintenance` are idempotent lifecycle operations.
`Client.Close` disables maintenance and stops the timer.
Stopping maintenance cancels the current maintenance context and waits only for an
already-admitted maintenance transaction to finish under the existing bounded serial
behavior.
## Automated verification
Tests should preserve the behavioural contract rather than only exercise individual
functions.
At minimum, cover:
- strict AP framing and malformed/truncated/timeout responses;
- tolerant issuance observations never fabricating `0x30` or `0x38`;
- every physical position class and transitions between classes;
- exact `0x38` as the only `ErrCardWellEmpty` path;
- persistent `0x30` using at most three `RS -> settle -> FC7` recoveries;
- no extra FC7 after the shake budget;
- uncertain/unusable shared timing and later usable-state precedence;
- caller cancellation versus internal preparation timeout;
- exactly one `LockSequence` opportunity per request;
- FC0 exactly once after encoding success or failure;
- FC0 failure remaining log-only;
- failed encoding never starting prestage;
- worker-owned `deliveryPending` and guarded FC7 dispatch;
- bounded delivery-clearance fallback and explicit-movement timeout;
- successful issuance remaining successful when next-card prestage fails;
- HTTP 503 only for exact physical empty and 502 for other issuance failures;
- Operafyne retry semantics remaining three total attempts.
Idle maintenance verification additionally covers:
- first attempt occurs one minute after the latest issuance finishes;
- a new issuance resets that idle interval;
- overlapping foreground requests suppress maintenance until the last request
finishes;
- both `/issuedoorcard` and `/testissuedoorcard` participate in foreground activity
registration;
- stale timer events and stale activity generations cannot perform maintenance;
- foreground registration before maintenance AP prevents AP admission;
- foreground registration during an admitted AP prevents the later FC7;
- passive polls are skipped while foreground activity is active or when their
captured generation is stale;
- maintenance performs one AP and at most one FC7, with no RS/shake/encoding;
- encoder-present, exact-empty, movement and unusable observations are no-ops;
- pending delivery requires fresh normal clearance plus the minimum wait and never
uses the foreground assumed-clearance fallback;
- maintenance errors do not produce guest-facing failures, stock callbacks or
repeated alert email;
- repeated maintenance start/stop calls are safe and do not accumulate work;
- cancellation, `StopMaintenance` and `Client.Close` prevent subsequent maintenance
commands after shutdown admission is revoked.
For Hardlink verification, continue excluding the existing test that sends real
email when running the full suite.

View File

@ -1,363 +0,0 @@
package dispenser
import (
"context"
"errors"
"io"
"reflect"
"testing"
"time"
)
func apReply(st []byte) []byte {
frame := []byte{STX, 0x30, 0x30, 0, byte(len(st) + 2), 'S', 'F'}
frame = append(frame, st...)
frame = append(frame, ETX)
return append(frame, calculateBCC(frame))
}
func workerRequest(c *Client, ctx context.Context, typ cmdType) cmdResp {
ch := make(chan cmdResp, 1)
c.handle(cmdReq{typ: typ, ctx: ctx, respCh: ch})
return <-ch
}
func TestDeliveryDispatchBoundary(t *testing.T) {
transportAddress(t)
for _, tc := range []struct {
name string
short int
badACK bool
cancelAfter int
pending bool
wantErr bool
}{
{name: "success", pending: true},
{name: "command short write", short: 1, wantErr: true},
{name: "bad ACK", badACK: true, wantErr: true},
{name: "ENQ short write", short: 2, pending: true, wantErr: true},
{name: "cancel before ENQ", cancelAfter: 1, wantErr: true},
{name: "cancel after ENQ", cancelAfter: 2, pending: true, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ack := append([]byte(nil), vendorACK...)
if tc.badACK {
ack[0] = 0
}
p := &scriptedTransport{chunks: [][]byte{ack}, shortWrite: tc.short}
p.afterWrite = func() {
if len(p.writes) == tc.cancelAfter {
cancel()
}
}
c := &Client{port: p}
r := workerRequest(c, ctx, cmdOutOfMouth)
if (r.err != nil) != tc.wantErr || c.deliveryPending != tc.pending {
t.Errorf("FC0(%s): err=%v pending=%v, want error=%v pending=%v", tc.name, r.err, c.deliveryPending, tc.wantErr, tc.pending)
}
if len(p.writes) > 2 {
t.Errorf("FC0 writes=%d, want no resend", len(p.writes))
}
})
}
t.Run("definite failure preserves previous pending", func(t *testing.T) {
c := &Client{port: &scriptedTransport{writeErr: io.ErrClosedPipe}, deliveryPending: true}
workerRequest(c, context.Background(), cmdOutOfMouth)
if !c.deliveryPending {
t.Error("failed FC0 erased previous pending")
}
})
t.Run("ENQ transport error remains pending", func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK}}
p.afterWrite = func() {
if len(p.writes) == 2 {
p.writeErr = io.ErrClosedPipe
}
}
c := &Client{port: p}
r := workerRequest(c, context.Background(), cmdOutOfMouth)
if !errors.Is(r.err, io.ErrClosedPipe) || !c.deliveryPending {
t.Errorf("ENQ error=%v pending=%v, want closed pipe and pending", r.err, c.deliveryPending)
}
})
}
func TestWorkerClearanceValidation(t *testing.T) {
transportAddress(t)
for _, tc := range []struct {
name string
st []byte
clear bool
}{
{"clear", status(0x30), true},
{"low stock clear", []byte{0x30, 0x30, 0x31, 0x30}, true},
{"residual encoder", status(0x33), false},
{"all sensors", status(0x37), false},
{"ready", status(0x34), true},
{"unknown diagnostic", []byte{0x30, 0x30, 0xFF, 0x30}, true},
{"jam", []byte{0x30, 0x30, 0x32, 0x30}, true},
{"overlap", []byte{0x30, 0x30, 0x34, 0x30}, true},
{"rejection", []byte{0x36, 0x30, 0x30, 0x30}, true},
{"empty", status(0x38), false},
{"preparing", []byte{0x31, 0x30, 0x30, 0x30}, false},
{"dispensing", []byte{0x30, 0x38, 0x30, 0x34}, false},
{"capturing", []byte{0x30, 0x34, 0x30, 0x30}, false},
{"ready with stale errors", []byte{0x36, 0x32, 0x34, 0x34}, true},
{"short payload", []byte{0x30, 0x30, 0x30}, false},
{"unknown position", status(0x40), false},
} {
t.Run(tc.name, func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(tc.st), vendorACK}}
c := &Client{port: p, deliveryPending: true, deliveryStarted: time.Now().Add(-deliveryMinimumWait), lastStatus: status(0x30), lastStatusT: time.Now(), statusTTL: time.Hour}
r := workerRequest(c, context.Background(), cmdStatus)
if c.deliveryPending == tc.clear {
t.Errorf("FC7(% X): err=%v pending=%v, want clear=%v", tc.st, r.err, c.deliveryPending, tc.clear)
}
want := []string{"AP"}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("commands=%v, want %v", got, want)
}
})
}
t.Run("bad BCC cannot clear", func(t *testing.T) {
frame := apReply(status(0x30))
frame[len(frame)-1] ^= 1
p := &scriptedTransport{chunks: [][]byte{vendorACK, frame}}
c := &Client{port: p, deliveryPending: true}
r := workerRequest(c, context.Background(), cmdStatus)
if r.err == nil || !c.deliveryPending {
t.Errorf("bad BCC: err=%v pending=%v", r.err, c.deliveryPending)
}
})
}
func wireCommands(p *scriptedTransport) []string {
var commands []string
for _, w := range p.writes {
if len(w) > 6 && w[0] == STX {
commands = append(commands, string(w[5:len(w)-2]))
}
}
return commands
}
func deliveryWorkerClient(t *testing.T, p *scriptedTransport) (*Client, *time.Time) {
t.Helper()
transportAddress(t)
now := time.Unix(0, 0)
c := &Client{port: p, reqCh: make(chan cmdReq, 16), done: make(chan struct{})}
c.sequenceTiming = sequenceTiming{now: func() time.Time { return now }, wait: func(ctx context.Context, d time.Duration) error {
if err := ctx.Err(); err != nil {
return err
}
now = now.Add(d)
return nil
}}
stopped := make(chan struct{})
go func() { defer close(stopped); c.loop() }()
t.Cleanup(func() { c.Close(); <-stopped })
return c, &now
}
func TestDeliveryIncidentReplay(t *testing.T) {
for _, next := range []bool{false, true} {
t.Run(map[bool]string{false: "current", true: "next"}[next], func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{
vendorACK, apReply(status(0x33)), // current card at encoder; encoding succeeds
vendorACK, // FC0
vendorACK, apReply(status(0x37)), // best effort must defer
vendorACK, apReply(status(0x33)), // previous encoder sensor must not succeed
vendorACK, apReply(status(0x34)),
vendorACK, // FC7
vendorACK, apReply(status(0x33)),
}}
c, now := deliveryWorkerClient(t, p)
if _, err := c.PrepareCurrentCard(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := c.DeliverCurrentCard(context.Background()); err != nil {
t.Fatal(err)
}
// A cached clear result must not authorize FC7.
c.mu.Lock()
c.lastStatus = status(0x30)
c.lastStatusT = time.Now()
c.statusTTL = time.Hour
c.mu.Unlock()
if err := c.BeginPrepareNextCard(context.Background()); err != nil {
t.Fatal(err)
}
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP", "FC0", "AP", "AP", "AP", "FC7"}) {
t.Fatalf("prestaging commands=%v, want clearance APs then one FC7", got)
}
prepare := c.PrepareCurrentCard
if next {
prepare = c.PrepareNextCard
}
if _, err := prepare(context.Background()); err != nil {
t.Fatal(err)
}
want := []string{"AP", "FC0", "AP", "AP", "AP", "FC7", "AP"}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("incident commands=%v, want %v", got, want)
}
if elapsed := now.Sub(time.Unix(0, 0)); elapsed != 2*time.Second {
t.Errorf("clearance waits=%s, want 2s", elapsed)
}
})
}
}
func TestDeliveryClearWaitsTwoSeconds(t *testing.T) {
for _, position := range []byte{0x30, 0x34} {
p := &scriptedTransport{chunks: [][]byte{vendorACK,
vendorACK, apReply(status(position)), vendorACK, apReply(status(position)), vendorACK, apReply(status(position)),
vendorACK, vendorACK, apReply(status(0x37))}}
c, now := deliveryWorkerClient(t, p)
if _, err := c.DeliverCurrentCard(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := c.PrepareCurrentCard(context.Background()); err != nil {
t.Fatal(err)
}
want := []string{"FC0", "AP", "AP", "AP", "FC7", "AP"}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("clearance %X commands=%v, want %v", position, got, want)
}
if elapsed := now.Sub(time.Unix(0, 0)); elapsed != deliveryMinimumWait {
t.Errorf("clearance %X elapsed=%s, want 2s", position, elapsed)
}
}
}
func TestDeliveryPendingIsNotPersisted(t *testing.T) {
c := NewClient(nil, 1)
defer c.Close()
if c.deliveryPending {
t.Error("new Client has pending delivery")
}
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x37))}}
fresh, _ := deliveryWorkerClient(t, p)
if _, err := fresh.PrepareCurrentCard(context.Background()); err != nil {
t.Errorf("fresh client encoder status: %v", err)
}
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP"}) {
t.Errorf("restart commands=%v, want AP only", got)
}
}
func TestDeliveryCancellationAfterACKDoesNotSetPending(t *testing.T) {
transportAddress(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := &scriptedTransport{chunks: [][]byte{vendorACK}, afterRead: cancel}
c := &Client{port: p}
r := workerRequest(c, ctx, cmdOutOfMouth)
if !errors.Is(r.err, context.Canceled) || c.deliveryPending || len(p.writes) != 1 {
t.Errorf("cancel after ACK: err=%v pending=%v writes=%d, want canceled, false, 1", r.err, c.deliveryPending, len(p.writes))
}
}
func TestWorkerSerializesClearanceAndFC7(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK, vendorACK, vendorACK, apReply(status(0x37))}}
c, _ := deliveryWorkerClient(t, p)
// Set initial state before any request can reach the worker.
c.deliveryPending = true
c.deliveryStarted = c.now().Add(-deliveryMinimumWait)
reading := make(chan struct{})
resume := make(chan struct{})
p.afterWrite = func() {
if len(p.writes) == 1 {
close(reading)
<-resume
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
fc7 := make(chan error, 1)
go func() { fc7 <- c.BeginPrepareNextCard(ctx) }()
select {
case <-reading:
case <-ctx.Done():
t.Fatal("worker did not begin AP")
}
fc0 := make(chan cmdResp, 1)
// This FC0 is queued while AP is in progress. It must not slip between
// the clearance observation and FC7 dispatch.
c.reqCh <- cmdReq{typ: cmdOutOfMouth, ctx: ctx, respCh: fc0}
close(resume)
if err := <-fc7; err != nil {
t.Fatal(err)
}
if r := <-fc0; r.err != nil {
t.Fatal(r.err)
}
want := []string{"AP", "FC7", "FC0"}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("serialized commands=%v, want %v", got, want)
}
}
func TestDeliveryCancellationAfterFreshAPKeepsPending(t *testing.T) {
transportAddress(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x34))}}
p.afterRead = func() {
if len(p.chunks) == 0 {
cancel()
}
}
c := &Client{port: p, deliveryPending: true, deliveryStarted: time.Now().Add(-time.Minute)}
r := workerRequest(c, ctx, cmdToEncoder)
if !errors.Is(r.err, context.Canceled) || !c.deliveryPending {
t.Errorf("AP cancellation err=%v pending=%t, want canceled and pending", r.err, c.deliveryPending)
}
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP"}) {
t.Errorf("cancelled clearance commands=%v, want AP only", got)
}
}
func TestPreparationWireFailuresNeverShake(t *testing.T) {
badBCC := apReply(status(0x30))
badBCC[len(badBCC)-1] ^= 1
for _, frame := range [][]byte{nil, apReply(status(0x30))[:8], badBCC, apReply(status(0x40))} {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK, vendorACK}}
if frame != nil {
p.chunks = append(p.chunks, frame)
}
c, _ := deliveryWorkerClient(t, p)
if _, err := c.PrepareCurrentCard(context.Background()); err != nil {
t.Errorf("frame % X: err=%v, want encoder opportunity", frame, err)
}
want := []string{"AP", "FC7", "AP", "AP", "AP", "AP", "AP"}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("frame % X commands=%v, want %v without RS or resends", frame, got, want)
}
}
}
func TestBeginPrepareNextCardWaitsForClearanceWithoutReadinessPolling(t *testing.T) {
for _, position := range []byte{0x30, 0x34} {
p := &scriptedTransport{chunks: [][]byte{vendorACK,
vendorACK, apReply(status(position)), vendorACK, apReply(status(position)), vendorACK, apReply(status(position)),
vendorACK}}
c, now := deliveryWorkerClient(t, p)
if _, err := c.DeliverCurrentCard(context.Background()); err != nil {
t.Fatal(err)
}
if err := c.BeginPrepareNextCard(context.Background()); err != nil {
t.Fatal(err)
}
want := []string{"FC0", "AP", "AP", "AP", "FC7"}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("BeginPrepareNextCard(%X) commands = %v, want %v", position, got, want)
}
if elapsed := now.Sub(time.Unix(0, 0)); elapsed != deliveryMinimumWait {
t.Errorf("BeginPrepareNextCard(%X) waited %s, want %s", position, elapsed, deliveryMinimumWait)
}
}
}

View File

@ -1,469 +0,0 @@
package dispenser
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"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
CardWellEmptyMessage = "Card well is empty"
)
const (
positionPreDispense = 0x01
positionEncoder = 0x02
positionMouth = 0x04
positionEmpty = 0x08
)
var (
ErrCardWellEmpty = errors.New(CardWellEmptyMessage)
// ErrPreparationExhausted permits a new, independent UI preparation attempt.
ErrPreparationExhausted = errors.New("card preparation exhausted")
SerialPort string
Address []byte
commandFC7 = []byte{ETX, 0x46, 0x43, 0x37} // "FC7"
commandFC0 = []byte{ETX, 0x46, 0x43, 0x30} // "FC0"
commandRS = []byte{0x02, 0x52, 0x53} // Length 2, "RS"
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",
}
)
// --------------------
// Status helpers
// --------------------
// decodePositionStatus decodes the manual's 0x30 + combined sensor/empty flags.
// Sensor 2 (0x02) is the read position; sensor 1 and sensor 2 together give 0x33.
func decodePositionStatus(value byte) (flags byte, valid bool) {
if value&0xF0 != 0x30 {
return 0, false
}
return value & 0x0F, true
}
func statusDescription(statusBytes []byte) string {
if len(statusBytes) < 4 {
return fmt.Sprintf("<invalid len=%d>", len(statusBytes))
}
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},
}
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 + "; ")
}
}
flags, valid := decodePositionStatus(statusBytes[3])
if !valid {
fmt.Fprintf(&result, "Unknown status 0x%X at position 4; ", statusBytes[3])
} else {
for _, position := range []struct {
mask byte
text string
}{
{positionPreDispense, "Card at pre-dispense position"},
{positionEncoder, "Card at encoder position"},
{positionMouth, "Card at mouth position"},
{positionEmpty, "Card empty"},
} {
if flags&position.mask != 0 {
result.WriteString(position.text + "; ")
}
}
}
return result.String()
}
func logStatus(statusBytes []byte) {
log.Infof("Dispenser status: %s", statusDescription(statusBytes))
}
func isAtEncoderPosition(statusBytes []byte) bool {
if len(statusBytes) < 4 {
return false
}
flags, valid := decodePositionStatus(statusBytes[3])
return valid && flags&positionEncoder != 0
}
type preparationClass string
const (
encoderConfirmed preparationClass = "encoder confirmed"
positionUncertain preparationClass = "valid but uncertain"
positionWellEmpty preparationClass = "empty"
positionNoCard preparationClass = "no card on sensors"
)
// classifyPreparationStatus is the only readiness gate after AP wire validation.
// Diagnostics describe the device; they do not veto a physical position class.
func classifyPreparationStatus(status []byte) (preparationClass, error) {
if len(status) != 4 {
return "", fmt.Errorf("malformed dispenser status: got %d bytes, want 4", len(status))
}
flags, valid := decodePositionStatus(status[3])
if !valid {
return "", fmt.Errorf("malformed dispenser position encoding: 0x%X", status[3])
}
switch {
case flags&positionEncoder != 0:
return encoderConfirmed, nil
case status[3] == 0x38:
return positionWellEmpty, nil
case status[3] == 0x30:
return positionNoCard, nil
default:
return positionUncertain, nil
}
}
func isPreparationMoving(statusBytes []byte) bool {
return len(statusBytes) == 4 &&
(statusBytes[0] == 0x31 || statusBytes[1] == 0x38)
}
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 isCardWellEmpty(statusBytes) {
status = "Card empty"
}
return status
}
func isCardWellEmpty(statusBytes []byte) bool {
if len(statusBytes) < 4 {
return false
}
flags, valid := decodePositionStatus(statusBytes[3])
return valid && flags&positionEmpty != 0
}
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}) }
// serialTransport is used only by the serial-port owner.
type serialTransport interface {
io.Reader
io.Writer
}
func writePacket(ctx context.Context, port serialTransport, packet []byte) error {
_, err := writePacketAttempt(ctx, port, packet)
return err
}
// writePacketAttempt distinguishes cancellation before Write from an ambiguous write.
func writePacketAttempt(ctx context.Context, port serialTransport, packet []byte) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
n, err := port.Write(packet)
if err != nil {
return true, fmt.Errorf("write dispenser packet: %w", err)
}
if n != len(packet) {
return true, fmt.Errorf("write dispenser packet (%d/%d bytes): %w", n, len(packet), io.ErrShortWrite)
}
return true, ctx.Err()
}
func readExact(ctx context.Context, port serialTransport, data []byte) error {
for len(data) > 0 {
if err := ctx.Err(); err != nil {
return err
}
n, err := port.Read(data)
data = data[n:]
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if err != nil {
return fmt.Errorf("read dispenser response: %w", err)
}
if n == 0 {
return fmt.Errorf("read dispenser response: %w", io.ErrNoProgress)
}
}
return nil
}
func sendAndReadACK(ctx context.Context, port serialTransport, packet []byte, processingDelay time.Duration) error {
if err := writePacket(ctx, port, packet); err != nil {
return err
}
if err := waitForSequence(ctx, processingDelay); err != nil {
return err
}
return readACK(ctx, port)
}
// readACK scans sequentially without reading beyond a complete candidate.
// Wrong-address candidates are consumed in full; no bytes persist across calls.
func readACK(ctx context.Context, port serialTransport) (err error) {
const scanLimit = 64
seen := make([]byte, 0, scanLimit)
defer func() {
if err != nil {
log.Warnf("dispenser ACK acquisition failed: address=% X seen=% X error=%v", Address, seen, err)
}
}()
var candidate [3]byte
used := 0
for len(seen) < scanLimit {
if err := ctx.Err(); err != nil {
return err
}
need := 1
if used > 0 {
need = len(candidate) - used
}
need = min(need, scanLimit-len(seen))
chunk := candidate[used : used+need]
n, readErr := port.Read(chunk)
seen = append(seen, chunk[:n]...)
log.Debugf("dispenser ACK RX: address=% X n=%d bytes=% X error=%v", Address, n, chunk[:n], readErr)
if err := ctx.Err(); err != nil {
return err
}
used += n
if used == 1 && candidate[0] != ACK && candidate[0] != NAK {
log.Debugf("dispenser ACK ignored leading byte: % X", candidate[:1])
used = 0
} else if used == len(candidate) {
if len(Address) >= 2 && candidate[1] == Address[0] && candidate[2] == Address[1] {
log.Debugf("dispenser ACK/NAK accepted: address=% X token=% X", Address, candidate[:])
if candidate[0] == ACK && len(seen) > len(candidate) {
log.Warnf("dispenser ACK resynchronized: address=% X ignored=% X accepted=% X", Address, seen[:len(seen)-len(candidate)], candidate[:])
}
return checkACK(candidate[:])
}
log.Debugf("dispenser ACK ignored wrong-address candidate: address=% X candidate=% X", Address, candidate[:])
used = 0
}
if readErr != nil {
return fmt.Errorf("read ACK: %w", readErr)
}
if n == 0 {
return fmt.Errorf("read ACK: %w", io.ErrNoProgress)
}
}
return fmt.Errorf("read ACK: scan limit of %d bytes exhausted", scanLimit)
}
// queryStatus accepts only the fixed RF/AP payload sizes, before reading a body.
func queryStatus(ctx context.Context, port serialTransport, command []byte, statusCount int, processingDelay time.Duration) ([]byte, error) {
if err := sendAndReadACK(ctx, port, createPacket(Address, command), processingDelay); err != nil {
return nil, err
}
if err := writePacket(ctx, port, append([]byte{ENQ}, Address...)); err != nil {
return nil, err
}
if err := waitForSequence(ctx, processingDelay); err != nil {
return nil, err
}
header := make([]byte, 5)
if err := readExact(ctx, port, header); err != nil {
return nil, fmt.Errorf("read status header: %w", err)
}
if header[0] != STX {
return nil, fmt.Errorf("invalid status STX: % X", header)
}
if len(Address) != 2 || header[1] != Address[0] || header[2] != Address[1] {
return nil, fmt.Errorf("unexpected status address: % X", header[1:3])
}
length := int(binary.BigEndian.Uint16(header[3:5]))
if length != statusCount+2 {
return nil, fmt.Errorf("invalid status payload length: got %d, want %d", length, statusCount+2)
}
frame := append(header, make([]byte, length+2)...)
if err := readExact(ctx, port, frame[5:]); err != nil {
return nil, fmt.Errorf("read status body: %w", err)
}
if frame[len(frame)-2] != ETX {
return nil, fmt.Errorf("invalid status ETX: % X", frame)
}
if calculateBCC(frame[:len(frame)-1]) != frame[len(frame)-1] {
return nil, fmt.Errorf("invalid status BCC: % X", frame)
}
if frame[5] != 'S' || frame[6] != 'F' {
return nil, fmt.Errorf("unexpected status response type: % X", frame[5:7])
}
return frame[7 : 7+statusCount], 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(ctx context.Context, port serialTransport) ([]byte, error) {
return queryStatus(ctx, port, []byte{0x02, 'A', 'P'}, 4, delay)
}
// dispatchCommand confirms ACK and sends ENQ; it does not wait for movement.
func dispatchCommand(ctx context.Context, port serialTransport, command []byte, processingDelay time.Duration) error {
if err := sendAndReadACK(ctx, port, createPacket(Address, command), processingDelay); err != nil {
return err
}
return writePacket(ctx, port, append([]byte{ENQ}, Address...))
}
func cardToEncoderPosition(ctx context.Context, port serialTransport) error {
log.Println("Send card to encoder position")
return dispatchCommand(ctx, port, commandFC7, delay)
}
func resetDispenser(ctx context.Context, port serialTransport) error {
return dispatchCommand(ctx, port, commandRS, delay)
}
func cardOutOfMouth(ctx context.Context, port serialTransport) (bool, error) {
log.Println("Send card to out mouth position")
if err := sendAndReadACK(ctx, port, createPacket(Address, commandFC0), delay); err != nil {
return false, err
}
return writePacketAttempt(ctx, port, append([]byte{ENQ}, Address...))
}

View File

@ -1,731 +0,0 @@
// Package dispenser provides a queue-based client (single owner of port).
package dispenser
import (
"context"
"errors"
"fmt"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/tarm/serial"
)
type cmdType int
const (
cmdStatus cmdType = iota
cmdToEncoder
cmdOutOfMouth
cmdReset
cmdDeliveryClearance
)
type cmdReq struct {
passive bool
generation uint64
typ cmdType
ctx context.Context
respCh chan cmdResp
}
type cmdResp struct {
deliveryPending bool
status []byte
err error
}
type sequenceTiming struct {
now func() time.Time
wait func(context.Context, time.Duration) error
}
const (
sequencePollInterval = time.Second
sequenceShakeAfter = 3 * time.Second
sequenceResetWait = 2 * time.Second
sequenceTimeout = 32 * time.Second
sequenceUncertainWait = 4 * time.Second
sequenceMaxShakes = 3
deliveryClearanceTimeout = 6 * time.Second
deliveryMinimumWait = 2 * time.Second
)
type Client struct {
activity activityGuard
activityWake chan struct{}
closeOnce sync.Once
port serialTransport
reqCh chan cmdReq
done chan struct{}
// Owned exclusively by the serial worker.
deliveryPending bool
deliveryStarted time.Time
sequenceTiming sequenceTiming
// 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{
activityWake: make(chan struct{}, 1),
port: port,
reqCh: make(chan cmdReq, queueSize),
done: make(chan struct{}),
sequenceTiming: sequenceTiming{
now: time.Now,
wait: waitForSequence,
},
statusTTL: defaultStatusTTL,
}
go c.loop()
return c
}
func waitForSequence(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (c *Client) Close() {
c.closeOnce.Do(func() {
c.activity.Lock()
c.activity.closed = true
c.activity.Unlock()
c.StopMaintenance()
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.
// Passive requests are admitted by the worker only for the captured idle generation.
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
}
generation, idle := c.passiveGeneration()
if !idle {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
response := c.sendRequest(cmdReq{typ: cmdStatus, ctx: ctx, passive: true, generation: generation})
err := response.err
if err != nil {
log.Debugf("dispenser polling: %v", err)
}
cancel()
}
}
}()
}
func (c *Client) loop() {
timer := time.NewTimer(time.Hour)
defer timer.Stop()
for {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
var tick <-chan time.Time
if delay, enabled := c.maintenanceDelay(); enabled {
timer.Reset(delay)
tick = timer.C
}
select {
case <-c.done:
return
case <-c.activityWake:
case req := <-c.reqCh:
c.handle(req)
case <-tick:
c.maintainIdleCard()
}
}
}
func (c *Client) handle(req cmdReq) {
select {
case <-req.ctx.Done():
req.respCh <- cmdResp{err: req.ctx.Err()}
return
default:
}
if req.passive {
if !c.admitPassive(req.ctx, req.generation) {
req.respCh <- cmdResp{}
return
}
c.mu.RLock()
st := append([]byte(nil), c.lastStatus...)
fresh := len(st) == 4 && time.Since(c.lastStatusT) <= c.statusTTL
c.mu.RUnlock()
if fresh {
c.setStock(st)
req.respCh <- cmdResp{status: st}
return
}
}
switch req.typ {
case cmdStatus:
st, err := c.readWorkerStatus(req.ctx)
req.respCh <- cmdResp{status: st, err: err, deliveryPending: c.deliveryPending}
case cmdDeliveryClearance:
st, err := c.waitWorkerDeliveryClearance(req.ctx)
req.respCh <- cmdResp{status: st, err: err, deliveryPending: c.deliveryPending}
case cmdToEncoder:
if c.deliveryPending {
if _, err := c.waitWorkerDeliveryClearance(req.ctx); err != nil {
req.respCh <- cmdResp{err: err}
return
}
}
err := cardToEncoderPosition(req.ctx, c.port)
log.Infof("FC7 dispatch finished; dispatched=%t error=%v", err == nil, err)
c.invalidateStatusCache()
req.respCh <- cmdResp{err: err}
case cmdReset:
err := resetDispenser(req.ctx, c.port)
log.Infof("RS dispatch finished; dispatched=%t error=%v", err == nil, err)
c.invalidateStatusCache()
req.respCh <- cmdResp{err: err}
case cmdOutOfMouth:
attempted, err := cardOutOfMouth(req.ctx, c.port)
log.Infof("FC0 dispatch finished; ENQ_attempted=%t error=%v", attempted, err)
if attempted {
c.deliveryPending = true
c.deliveryStarted = c.now()
log.Info("delivery ENQ attempted; awaiting mechanical clearance")
}
// A movement command makes any previously cached position unreliable.
c.invalidateStatusCache()
req.respCh <- cmdResp{err: err}
default:
req.respCh <- cmdResp{err: fmt.Errorf("unknown command")}
}
}
// deliveryClearance deliberately does not apply encoder-success precedence.
func deliveryClearance(status []byte) (bool, error) {
class, err := classifyPreparationStatus(status)
if err != nil {
return false, err
}
if class == positionWellEmpty {
return false, ErrCardWellEmpty
}
if isPreparationMoving(status) || status[1] == 0x34 {
return false, nil
}
return status[3] == 0x30 || status[3] == 0x34, nil
}
func (c *Client) now() time.Time {
if c.sequenceTiming.now != nil {
return c.sequenceTiming.now()
}
return time.Now()
}
// readWorkerStatus is called only by the serial worker and always reads fresh AP.
func (c *Client) readWorkerStatus(ctx context.Context) ([]byte, error) {
st, err := checkDispenserStatus(ctx, c.port)
if err != nil {
return st, err
}
if err := ctx.Err(); err != nil {
return st, err
}
if c.deliveryPending {
class, _ := classifyPreparationStatus(st)
log.Infof("delivery clearance AP; class=%s elapsed=%s raw status: % X", class, c.now().Sub(c.deliveryStarted), st)
clear, clearanceErr := deliveryClearance(st)
if clearanceErr != nil {
log.Warnf("delivery clearance failed: %v; %s raw status: % X", clearanceErr, statusDescription(st), st)
} else if clear && c.now().Sub(c.deliveryStarted) >= deliveryMinimumWait {
c.deliveryPending = false
log.Infof("previous delivery cleared after %s; FC7 permitted", c.now().Sub(c.deliveryStarted))
}
}
if len(st) == 4 {
c.mu.Lock()
c.lastStatus = append([]byte(nil), st...)
c.lastStatusT = time.Now()
c.mu.Unlock()
c.setStock(st)
}
return st, nil
}
func (c *Client) do(ctx context.Context, typ cmdType) ([]byte, error) {
r := c.doResponse(ctx, typ)
return r.status, r.err
}
func (c *Client) doResponse(ctx context.Context, typ cmdType) cmdResp {
return c.sendRequest(cmdReq{typ: typ, ctx: ctx})
}
func (c *Client) sendRequest(req cmdReq) cmdResp {
if err := req.ctx.Err(); err != nil {
return cmdResp{err: err}
}
req.respCh = make(chan cmdResp, 1)
select {
case <-c.done:
return cmdResp{err: context.Canceled}
default:
}
select {
case c.reqCh <- req:
case <-c.done:
return cmdResp{err: context.Canceled}
case <-req.ctx.Done():
return cmdResp{err: req.ctx.Err()}
}
select {
case r := <-req.respCh:
return r
case <-c.done:
return cmdResp{err: context.Canceled}
case <-req.ctx.Done():
return cmdResp{err: req.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) invalidateStatusCache() {
c.mu.Lock()
c.lastStatus = nil
c.lastStatusT = time.Time{}
c.mu.Unlock()
}
func (c *Client) ToEncoder(ctx context.Context) error {
_, err := c.do(ctx, cmdToEncoder)
return err
}
// Reset dispatches RS through the port owner; acceptance does not imply mechanical completion.
func (c *Client) Reset(ctx context.Context) error {
_, err := c.do(ctx, cmdReset)
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) readSequenceStatus(ctx context.Context, operation string) ([]byte, string, error) {
response := c.doResponse(ctx, cmdStatus)
if err := ctx.Err(); err != nil {
return nil, "", err
}
if response.deliveryPending {
return c.waitForDeliveryClearance(ctx, operation)
}
status := usableObservation(response.status, response.err, operation)
stockStatus := ""
if len(status) == 4 {
stockStatus = stockTake(status)
c.setStock(status)
logStatus(status)
}
return status, stockStatus, nil
}
// waitWorkerDeliveryClearance runs only inside the serial worker.
// An unusable observation never becomes a fabricated physical position.
func (c *Client) waitWorkerDeliveryClearance(parent context.Context) ([]byte, error) {
ctx, cancel := context.WithTimeout(parent, deliveryClearanceTimeout)
defer cancel()
deadline := c.now().Add(deliveryClearanceTimeout)
var latest []byte
for {
if err := parent.Err(); err != nil {
return nil, err
}
if !c.now().Before(deadline) || ctx.Err() != nil {
class, err := classifyPreparationStatus(latest)
if err == nil && class == positionWellEmpty {
return latest, ErrCardWellEmpty
}
if err == nil && (isPreparationMoving(latest) || latest[1] == 0x34) {
return latest, context.DeadlineExceeded
}
c.deliveryPending = false
log.Warn("delivery clearance assumed after bounded observation fallback")
return latest, nil
}
st, err := c.readWorkerStatus(ctx)
if parent.Err() != nil {
return nil, parent.Err()
}
latest = usableObservation(st, err, "delivery clearance")
if latest != nil && latest[3] == 0x38 {
return latest, ErrCardWellEmpty
}
if !c.deliveryPending {
return latest, nil
}
remaining := deadline.Sub(c.now())
if remaining <= 0 || ctx.Err() != nil {
continue
}
wait := sequencePollInterval
if remaining < wait {
wait = remaining
}
if err := c.sequenceTiming.wait(ctx, wait); err != nil && parent.Err() != nil {
return nil, parent.Err()
}
}
}
// usableObservation preserves strict validation while discarding unusable telemetry.
func usableObservation(status []byte, err error, operation string) []byte {
if err == nil {
_, err = classifyPreparationStatus(status)
}
if err != nil {
log.Warnf("[%s] unusable AP observation; raw status: % X error=%v", operation, status, err)
return nil
}
return status
}
// waitForDeliveryClearance uses a worker request; no worker recursively enqueues.
func (c *Client) waitForDeliveryClearance(ctx context.Context, operation string) ([]byte, string, error) {
r := c.doResponse(ctx, cmdDeliveryClearance)
stock := ""
if len(r.status) == 4 {
stock = stockTake(r.status)
}
if r.err != nil {
return nil, stock, fmt.Errorf("[%s] delivery clearance: %w", operation, r.err)
}
return r.status, stock, nil
}
func (c *Client) prepareCardAtEncoder(parent context.Context, operation string) (stock string, resultErr error) {
status, stock, err := c.waitForDeliveryClearance(parent, operation)
if err != nil {
return stock, err
}
status = usableObservation(status, nil, operation)
started := c.now()
deadline := started.Add(sequenceTimeout)
ctx, cancel := context.WithTimeoutCause(parent, sequenceTimeout, ErrPreparationExhausted)
defer cancel()
shakes := 0
stage := "initial status"
var lastFC7, uncertainSince time.Time
var previous preparationClass
defer func() {
log.Infof("[%s] preparation finished; stage=%s shakes=%d elapsed=%s raw status: % X error=%v", operation, stage, shakes, c.now().Sub(started), status, resultErr)
}()
checkDeadline := func() error {
if err := parent.Err(); err != nil {
return err
}
if !c.now().Before(deadline) || errors.Is(context.Cause(ctx), ErrPreparationExhausted) {
return ErrPreparationExhausted
}
return ctx.Err()
}
// Only observation exhaustion can grant a deadline handoff. Command and
// reset-settle failures never pass through this policy.
observationResult := func(err error) error {
if parent.Err() != nil {
return parent.Err()
}
if !errors.Is(err, ErrPreparationExhausted) || lastFC7.IsZero() {
return err
}
class, _ := classifyPreparationStatus(status)
switch class {
case positionWellEmpty:
return ErrCardWellEmpty
case positionNoCard:
return err
default:
stage = "observation deadline encoder handoff"
return nil
}
}
wait := func(duration time.Duration) error {
if err := checkDeadline(); err != nil {
return err
}
if remaining := deadline.Sub(c.now()); remaining < duration {
duration = remaining
}
if err := c.sequenceTiming.wait(ctx, duration); err != nil {
if deadlineErr := checkDeadline(); deadlineErr != nil {
return deadlineErr
}
return err
}
return checkDeadline()
}
sendFC7 := func() error {
stage = "FC7 dispatch"
if err := checkDeadline(); err != nil {
return err
}
err := c.ToEncoder(ctx)
if deadlineErr := checkDeadline(); deadlineErr != nil {
return deadlineErr
}
if err != nil {
return fmt.Errorf("[%s] FC7 dispatch: %w", operation, err)
}
lastFC7 = c.now()
log.Infof("[%s] FC7 dispatched; shake=%d elapsed=%s", operation, shakes, lastFC7.Sub(started))
return nil
}
for {
if err := checkDeadline(); err != nil {
return stock, observationResult(err)
}
class, _ := classifyPreparationStatus(status)
// No class means unusable telemetry, sharing the uncertainty timer.
log.Infof("[%s] fresh AP; class=%s previous=%s elapsed=%s shake=%d raw status: % X", operation, class, previous, c.now().Sub(started), shakes, status)
previous = class
switch class {
case encoderConfirmed:
stage = "encoder sensor handoff"
return stock, observationResult(checkDeadline())
case positionWellEmpty:
stage = "empty"
return stock, ErrCardWellEmpty
}
if lastFC7.IsZero() {
if err := sendFC7(); err != nil {
return stock, err
}
} else if class == positionNoCard && c.now().Sub(lastFC7) >= sequenceShakeAfter {
uncertainSince = time.Time{}
if shakes == sequenceMaxShakes {
stage = "three shakes exhausted"
return stock, ErrPreparationExhausted
}
shakes++
stage = "RS dispatch"
if err := checkDeadline(); err != nil {
return stock, err
}
err := c.Reset(ctx)
if deadlineErr := checkDeadline(); deadlineErr != nil {
return stock, deadlineErr
}
if err != nil {
return stock, fmt.Errorf("[%s] RS dispatch: %w", operation, err)
}
stage = "reset settling"
log.Infof("[%s] RS dispatched; shake=%d settling=%s", operation, shakes, sequenceResetWait)
if err := wait(sequenceResetWait); err != nil {
return stock, err
}
log.Infof("[%s] reset settle wait completed; shake=%d", operation, shakes)
if err := sendFC7(); err != nil {
return stock, err
}
} else {
if class == positionUncertain || class == "" {
if uncertainSince.IsZero() {
uncertainSince = c.now()
}
if c.now().Sub(uncertainSince) >= sequenceUncertainWait {
stage = "uncertain position handoff"
return stock, observationResult(checkDeadline())
}
} else {
uncertainSince = time.Time{}
}
stage = "polling"
if err := wait(sequencePollInterval); err != nil {
return stock, observationResult(err)
}
}
stage = "fresh AP"
if err := checkDeadline(); err != nil {
return stock, observationResult(err)
}
status, stock, err = c.readSequenceStatus(ctx, operation)
if deadlineErr := checkDeadline(); deadlineErr != nil {
return stock, observationResult(deadlineErr)
}
if err != nil {
return stock, err
}
}
}
// PrepareCurrentCard grants one encoder opportunity after bounded physical preparation.
func (c *Client) PrepareCurrentCard(ctx context.Context) (string, error) {
return c.prepareCardAtEncoder(ctx, "PrepareCurrentCard")
}
// DeliverCurrentCard presents the encoded card and confirms command acceptance.
func (c *Client) DeliverCurrentCard(ctx context.Context) (string, error) {
const operation = "DeliverCurrentCard"
if err := c.OutOfMouth(ctx); err != nil {
return "", fmt.Errorf("[%s] out of mouth: %w", operation, err)
}
return "", nil
}
// BeginPrepareNextCard waits for worker-owned clearance and dispatches one FC7.
// It does not wait for encoder readiness.
func (c *Client) BeginPrepareNextCard(ctx context.Context) error {
if err := c.ToEncoder(ctx); err != nil {
return fmt.Errorf("[BeginPrepareNextCard] to encoder: %w", err)
}
return nil
}
// PrepareNextCard runs the same bounded physical preparation for a later issuance attempt.
func (c *Client) PrepareNextCard(ctx context.Context) (string, error) {
return c.prepareCardAtEncoder(ctx, "PrepareNextCard")
}

View File

@ -1,495 +0,0 @@
package dispenser
import (
"bytes"
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"time"
)
type fakeSequenceDevice struct {
statusResponses []cmdResp
commandErrors map[cmdType][]error
commands []cmdType
commandTimes []time.Time
onCommand func(cmdType)
}
func newSequenceTestClient(t *testing.T, statusResponses ...cmdResp) (*Client, *fakeSequenceDevice) {
t.Helper()
device := &fakeSequenceDevice{
statusResponses: append([]cmdResp(nil), statusResponses...),
commandErrors: make(map[cmdType][]error),
}
now := time.Date(2026, time.July, 23, 12, 0, 0, 0, time.UTC)
client := &Client{
reqCh: make(chan cmdReq),
done: make(chan struct{}),
sequenceTiming: sequenceTiming{
now: func() time.Time {
return now
},
wait: func(ctx context.Context, duration time.Duration) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
now = now.Add(duration)
return nil
}
},
},
}
go func() {
for {
select {
case <-client.done:
return
case request := <-client.reqCh:
clearance := request.typ == cmdDeliveryClearance
if clearance {
request.typ = cmdStatus
}
device.commands = append(device.commands, request.typ)
device.commandTimes = append(device.commandTimes, now)
if device.onCommand != nil {
device.onCommand(request.typ)
}
if request.typ == cmdStatus {
if len(device.statusResponses) == 0 {
request.respCh <- cmdResp{err: errors.New("unexpected status read")}
continue
}
response := device.statusResponses[0]
if clearance && request.ctx.Err() == nil {
response.status = usableObservation(response.status, response.err, "fake clearance")
response.err = nil
}
device.statusResponses = device.statusResponses[1:]
request.respCh <- response
continue
}
var err error
if queued := device.commandErrors[request.typ]; len(queued) > 0 {
err = queued[0]
device.commandErrors[request.typ] = queued[1:]
}
request.respCh <- cmdResp{err: err}
}
}
}()
t.Cleanup(client.Close)
return client, device
}
func status(position byte) []byte {
return []byte{0x30, 0x30, 0x30, position}
}
func commandCount(commands []cmdType, target cmdType) int {
count := 0
for _, command := range commands {
if command == target {
count++
}
}
return count
}
func TestResetPacket(t *testing.T) {
got := createPacket([]byte{0x30, 0x30}, commandRS)
want := []byte{0x02, 0x30, 0x30, 0x00, 0x02, 0x52, 0x53, 0x03, 0x02}
if !bytes.Equal(got, want) {
t.Errorf("createPacket(RS) = % X, want % X", got, want)
}
}
func TestDeliverCurrentCardSucceedsAfterCommandAcceptanceWithoutStatusPolling(t *testing.T) {
staleStatus := []byte{0x30, 0x32, 0x32, 0x33}
client, device := newSequenceTestClient(t, cmdResp{status: staleStatus})
stock, err := client.DeliverCurrentCard(context.Background())
if err != nil {
t.Fatal(err)
}
if stock != "" {
t.Fatalf("stock status = %q, want empty", stock)
}
if got := commandCount(device.commands, cmdOutOfMouth); got != 1 {
t.Fatalf("out-of-mouth commands = %d, want 1", got)
}
if got := commandCount(device.commands, cmdStatus); got != 0 {
t.Fatalf("status reads = %d, want 0", got)
}
if len(device.statusResponses) != 1 {
t.Fatalf("queued status responses = %d, want stale status unread", len(device.statusResponses))
}
}
func TestDeliverCurrentCardRejectsCommandFailures(t *testing.T) {
tests := []struct {
name string
err error
}{
{name: "serial dispatch failure", err: errors.New("serial write failed")},
{name: "invalid acknowledgement", err: errors.New("unexpected response status")},
{name: "missing acknowledgement", err: errors.New("no response from dispenser")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client, device := newSequenceTestClient(t)
device.commandErrors[cmdOutOfMouth] = []error{test.err}
_, err := client.DeliverCurrentCard(context.Background())
if err == nil || !strings.Contains(err.Error(), test.err.Error()) {
t.Fatalf("error = %v, want containing %q", err, test.err)
}
if got := commandCount(device.commands, cmdOutOfMouth); got != 1 {
t.Fatalf("out-of-mouth commands = %d, want 1", got)
}
if got := commandCount(device.commands, cmdStatus); got != 0 {
t.Fatalf("status reads = %d, want 0", got)
}
})
}
}
func TestDeliverCurrentCardPreservesContextCancellation(t *testing.T) {
client, _ := newSequenceTestClient(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := client.DeliverCurrentCard(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
}
func TestBeginPrepareNextCardDispatchesWithoutReadinessPolling(t *testing.T) {
client, device := newSequenceTestClient(t)
if err := client.BeginPrepareNextCard(context.Background()); err != nil {
t.Fatal(err)
}
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
t.Fatalf("to-encoder commands = %d, want 1", got)
}
if got := commandCount(device.commands, cmdStatus); got != 0 {
t.Fatalf("status reads = %d, want 0", got)
}
}
func TestBeginPrepareNextCardReturnsDispatchFailureWithoutPolling(t *testing.T) {
client, device := newSequenceTestClient(t)
device.commandErrors[cmdToEncoder] = []error{errors.New("dispatch failed")}
err := client.BeginPrepareNextCard(context.Background())
if err == nil || !strings.Contains(err.Error(), "dispatch failed") {
t.Fatalf("error = %v, want dispatch failure", err)
}
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
t.Fatalf("to-encoder commands = %d, want 1", got)
}
if got := commandCount(device.commands, cmdStatus); got != 0 {
t.Fatalf("status reads = %d, want 0", got)
}
}
func TestPositionDescriptions(t *testing.T) {
for _, test := range []struct {
position byte
want string
}{
{0x30, ""},
{0x31, "Card at pre-dispense position; "},
{0x32, "Card at encoder position; "},
{0x34, "Card at mouth position; "},
{0x37, "Card at pre-dispense position; Card at encoder position; Card at mouth position; "},
{0x39, "Card at pre-dispense position; Card empty; "},
{0x3F, "Card at pre-dispense position; Card at encoder position; Card at mouth position; Card empty; "},
{0x40, "Unknown status 0x40 at position 4; "},
} {
if got := statusDescription(status(test.position)); got != test.want {
t.Errorf("statusDescription(position %X) = %q, want %q", test.position, got, test.want)
}
}
}
func positionResponses(position byte, count int) []cmdResp {
responses := make([]cmdResp, count)
for i := range responses {
responses[i] = cmdResp{status: status(position)}
}
return responses
}
func TestPreparationClasses(t *testing.T) {
classes := map[preparationClass][]byte{
encoderConfirmed: {0x32, 0x33, 0x36, 0x37, 0x3A, 0x3B, 0x3E, 0x3F},
positionUncertain: {0x31, 0x34, 0x35, 0x39, 0x3C, 0x3D},
positionWellEmpty: {0x38}, positionNoCard: {0x30},
}
for want, positions := range classes {
for _, position := range positions {
st := []byte{0xFF, 0x36, 0x32, position}
if got, err := classifyPreparationStatus(st); got != want || err != nil {
t.Errorf("classify(% X)=%s,%v, want %s", st, got, err, want)
}
}
}
for _, st := range [][]byte{nil, {0x30}, status(0x02), status(0x40), status(0xFF), {0x30, 0x30, 0x30, 0x37, 0x30}} {
if got, err := classifyPreparationStatus(st); err == nil {
t.Errorf("classify(% X)=%s,nil, want malformed error", st, got)
}
}
}
func TestPreparationAllPositionsBothMethods(t *testing.T) {
for _, next := range []bool{false, true} {
for position := byte(0x30); position <= 0x3F; position++ {
t.Run(fmt.Sprintf("next=%t/position=%X", next, position), func(t *testing.T) {
responses := positionResponses(position, 40)
for i := range responses {
responses[i].status[0], responses[i].status[1], responses[i].status[2] = 0xFF, 0x38, 0x34
}
c, d := newSequenceTestClient(t, responses...)
// Passive encoder data must never replace fresh AP.
c.lastStatus, c.lastStatusT, c.statusTTL = status(0x33), time.Now(), time.Hour
prepare := c.PrepareCurrentCard
if next {
prepare = c.PrepareNextCard
}
started := c.now()
_, err := prepare(context.Background())
wantFC7, wantRS, wantElapsed := 0, 0, time.Duration(0)
var wantErr error
switch position {
case 0x30:
wantFC7, wantRS, wantElapsed, wantErr = 4, 3, 18*time.Second, ErrPreparationExhausted
case 0x38:
wantErr = ErrCardWellEmpty
case 0x31, 0x34, 0x35, 0x39, 0x3C, 0x3D:
wantFC7, wantElapsed = 1, 4*time.Second
}
if !errors.Is(err, wantErr) {
t.Errorf("prepare(%X) error=%v, want %v", position, err, wantErr)
}
if commandCount(d.commands, cmdToEncoder) != wantFC7 || commandCount(d.commands, cmdReset) != wantRS {
t.Errorf("prepare(%X) commands=%v, want FC7=%d RS=%d", position, d.commands, wantFC7, wantRS)
}
if got := c.now().Sub(started); got != wantElapsed {
t.Errorf("prepare(%X) elapsed=%s, want %s", position, got, wantElapsed)
}
})
}
}
}
func TestThreeShakesAreRequestLocalAndSettled(t *testing.T) {
c, d := newSequenceTestClient(t, positionResponses(0x30, 34)...)
for request := 0; request < 2; request++ {
start := len(d.commands)
if _, err := c.PrepareCurrentCard(context.Background()); !errors.Is(err, ErrPreparationExhausted) {
t.Fatalf("request %d error=%v, want exhausted", request, err)
}
want := []cmdType{cmdStatus, cmdToEncoder}
for attempt := 0; attempt < 4; attempt++ {
want = append(want, cmdStatus, cmdStatus, cmdStatus, cmdStatus)
if attempt < 3 {
want = append(want, cmdReset, cmdToEncoder)
}
}
if !reflect.DeepEqual(d.commands[start:], want) {
t.Errorf("request %d commands=%v, want %v", request, d.commands[start:], want)
}
var lastFC7 time.Time
for i := start; i < len(d.commands); i++ {
if d.commands[i] == cmdReset && d.commandTimes[i].Sub(lastFC7) != 3*time.Second {
t.Errorf("RS at %s after FC7, want 3s", d.commandTimes[i].Sub(lastFC7))
}
if d.commands[i] == cmdToEncoder {
if i > start && d.commands[i-1] == cmdReset && d.commandTimes[i].Sub(d.commandTimes[i-1]) != 2*time.Second {
t.Errorf("FC7 settle=%s, want 2s", d.commandTimes[i].Sub(d.commandTimes[i-1]))
}
lastFC7 = d.commandTimes[i]
}
}
}
}
func TestPreparationReclassifiesEverySample(t *testing.T) {
for _, tc := range []struct {
name string
positions []byte
wantRS int
wantTime time.Duration
wantErr error
}{
{"uncertain changes do not restart timer", []byte{0x30, 0x31, 0x34, 0x35, 0x39, 0x3C}, 0, 4 * time.Second, nil},
{"uncertain becomes encoder", []byte{0x30, 0x34, 0x37}, 0, time.Second, nil},
{"uncertain becomes empty", []byte{0x30, 0x34, 0x38}, 0, time.Second, ErrCardWellEmpty},
{"no sensors becomes empty", []byte{0x30, 0x30, 0x38}, 0, time.Second, ErrCardWellEmpty},
{"uncertain returns to shake path twice", []byte{0x30, 0x34, 0x30, 0x30, 0x30, 0x34, 0x34, 0x30, 0x30, 0x37}, 2, 10 * time.Second, nil},
} {
t.Run(tc.name, func(t *testing.T) {
var responses []cmdResp
for _, p := range tc.positions {
responses = append(responses, cmdResp{status: status(p)})
}
c, d := newSequenceTestClient(t, responses...)
start := c.now()
_, err := c.PrepareCurrentCard(context.Background())
if !errors.Is(err, tc.wantErr) || c.now().Sub(start) != tc.wantTime || commandCount(d.commands, cmdReset) != tc.wantRS {
t.Errorf("prepare(%s) error=%v elapsed=%s commands=%v, want error=%v time=%s RS=%d", tc.name, err, c.now().Sub(start), d.commands, tc.wantErr, tc.wantTime, tc.wantRS)
}
})
}
}
func TestEveryShakeReclassifiesEncoderEmptyAndUncertain(t *testing.T) {
for shake := 1; shake <= 3; shake++ {
for _, position := range []byte{0x37, 0x38, 0x35} {
responses := positionResponses(0x30, 1+4*shake)
responses = append(responses, positionResponses(position, 5)...)
c, d := newSequenceTestClient(t, responses...)
_, err := c.PrepareCurrentCard(context.Background())
var wantErr error
if position == 0x38 {
wantErr = ErrCardWellEmpty
}
if !errors.Is(err, wantErr) || commandCount(d.commands, cmdReset) != shake || commandCount(d.commands, cmdToEncoder) != shake+1 {
t.Errorf("shake %d position=%X error=%v commands=%v, want %v and no further shake", shake, position, err, d.commands, wantErr)
}
}
}
}
func TestPreparationTransportAndDispatchFailuresDoNotShake(t *testing.T) {
failure := errors.New("serial failure")
for _, response := range []cmdResp{{err: failure}, {status: []byte{0x30}}, {status: status(0x40)}} {
c, d := newSequenceTestClient(t, cmdResp{status: status(0x30)}, response)
if _, err := c.PrepareCurrentCard(context.Background()); err != nil {
t.Errorf("AP failure %v error=%v, want encoder opportunity", response, err)
}
if commandCount(d.commands, cmdToEncoder) != 1 || commandCount(d.commands, cmdReset) != 0 {
t.Errorf("AP failure commands=%v, want FC7 once and no RS", d.commands)
}
}
for _, command := range []cmdType{cmdToEncoder, cmdReset} {
c, d := newSequenceTestClient(t, positionResponses(0x30, 10)...)
d.commandErrors[command] = []error{failure}
if _, err := c.PrepareCurrentCard(context.Background()); !errors.Is(err, failure) {
t.Errorf("command %v error=%v, want serial failure", command, err)
}
if commandCount(d.commands, command) != 1 {
t.Errorf("command %v retried: %v", command, d.commands)
}
}
}
func TestPreparationStopsAtCancellationAndDeadlineStages(t *testing.T) {
for _, stage := range []string{"initial AP", "FC7", "poll", "RS", "settle"} {
for _, callerCanceled := range []bool{false, true} {
if stage == "initial AP" && !callerCanceled {
continue
}
t.Run(fmt.Sprintf("%s/cancel=%t", stage, callerCanceled), func(t *testing.T) {
c, d := newSequenceTestClient(t, positionResponses(0x30, 30)...)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
now := c.now()
c.sequenceTiming.now = func() time.Time { return now }
stoppedAt := -1
stop := func() {
if stoppedAt >= 0 {
return
}
stoppedAt = len(d.commands)
if callerCanceled {
cancel()
} else {
now = now.Add(sequenceTimeout)
}
}
c.sequenceTiming.wait = func(_ context.Context, duration time.Duration) error {
now = now.Add(duration)
if (stage == "poll" && duration == sequencePollInterval) || (stage == "settle" && duration == sequenceResetWait) {
stop()
}
return nil
}
d.onCommand = func(cmd cmdType) {
if (stage == "initial AP" && cmd == cmdStatus) || (stage == "FC7" && cmd == cmdToEncoder) || (stage == "RS" && cmd == cmdReset) {
stop()
}
}
_, err := c.PrepareCurrentCard(ctx)
wantErr := error(ErrPreparationExhausted)
if callerCanceled {
wantErr = context.Canceled
} else if stage == "initial AP" {
wantErr = context.DeadlineExceeded
}
if !errors.Is(err, wantErr) || len(d.commands) != stoppedAt {
t.Errorf("stage=%s cancel=%t error=%v commands=%v stoppedAt=%d, want %v and no commands after stop", stage, callerCanceled, err, d.commands, stoppedAt, wantErr)
}
})
}
}
}
func TestPositionFlagsRemainDiagnostic(t *testing.T) {
for position := byte(0x30); position <= 0x3F; position++ {
st := status(position)
if got := isAtEncoderPosition(st); got != (position&0x02 != 0) {
t.Errorf("encoder sensor(%X)=%t", position, got)
}
if got := isCardWellEmpty(st); got != (position&0x08 != 0) {
t.Errorf("stock empty flag(%X)=%t", position, got)
}
wantStock := ""
if position&0x08 != 0 {
wantStock = "Card empty"
}
if got := stockTake(st); got != wantStock {
t.Errorf("stockTake(%X)=%q, want %q", position, got, wantStock)
}
}
}
func TestPreparationCallerDeadlineIsNotRetryableExhaustion(t *testing.T) {
c, d := newSequenceTestClient(t)
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
_, err := c.PrepareCurrentCard(ctx)
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrPreparationExhausted) || len(d.commands) != 0 {
t.Errorf("expired caller: err=%v commands=%v, want caller deadline and no commands", err, d.commands)
}
}
func TestPreparationExpiredEncoderSampleHandsOff(t *testing.T) {
c, d := newSequenceTestClient(t, cmdResp{status: status(0x30)}, cmdResp{status: status(0x37)})
now := c.now()
c.sequenceTiming.now = func() time.Time { return now }
d.onCommand = func(cmd cmdType) {
if cmd == cmdStatus && commandCount(d.commands, cmdStatus) == 2 {
now = now.Add(sequenceTimeout)
}
}
_, err := c.PrepareCurrentCard(context.Background())
if err != nil {
t.Errorf("late encoder sample: err=%v, want handoff", err)
}
if len(d.commands) != 3 {
t.Errorf("late encoder sample commands=%v, want AP FC7 AP", d.commands)
}
}

View File

@ -1,180 +0,0 @@
package dispenser
import (
"context"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
const maintenanceInterval = time.Minute
const maintenanceTimeout = 5 * time.Second
// activityGuard arbitrates admission, not the duration of serial transactions.
// The serial worker remains the sole owner of the port and deliveryPending.
type activityGuard struct {
sync.Mutex
foreground int
generation uint64
enabled bool
closed bool
next time.Time
cancel context.CancelFunc
finished chan struct{}
}
func (c *Client) wakeWorker() {
select {
case c.activityWake <- struct{}{}:
default:
}
}
// BeginForeground suppresses idle work until the returned release function runs.
func (c *Client) BeginForeground() func() {
c.activity.Lock()
c.activity.foreground++
c.activity.generation++
c.activity.next = time.Time{}
c.activity.Unlock()
c.wakeWorker()
var once sync.Once
return func() {
once.Do(func() {
c.activity.Lock()
c.activity.foreground--
if c.activity.foreground == 0 && c.activity.enabled && !c.activity.closed {
c.activity.next = c.now().Add(maintenanceInterval)
}
c.activity.Unlock()
c.wakeWorker()
})
}
}
// StartMaintenance enables the worker timer once, with an initial one-minute delay.
func (c *Client) StartMaintenance() {
c.activity.Lock()
if !c.activity.enabled && !c.activity.closed {
c.activity.enabled = true
c.activity.generation++
if c.activity.foreground == 0 {
c.activity.next = c.now().Add(maintenanceInterval)
}
}
c.activity.Unlock()
c.wakeWorker()
}
// StopMaintenance prevents new admissions and waits for an admitted attempt to exit.
// An underlying serial read can finish only according to its existing read timeout.
func (c *Client) StopMaintenance() {
c.activity.Lock()
c.activity.enabled = false
c.activity.generation++
c.activity.next = time.Time{}
if c.activity.cancel != nil {
c.activity.cancel()
}
finished := c.activity.finished
c.activity.Unlock()
c.wakeWorker()
if finished != nil {
<-finished
}
}
func (c *Client) maintenanceDelay() (time.Duration, bool) {
c.activity.Lock()
defer c.activity.Unlock()
if !c.activity.enabled || c.activity.closed || c.activity.foreground != 0 || c.activity.next.IsZero() {
return 0, false
}
remaining := c.activity.next.Sub(c.now())
if remaining < 0 {
remaining = 0
}
return remaining, true
}
func (c *Client) passiveGeneration() (uint64, bool) {
c.activity.Lock()
defer c.activity.Unlock()
return c.activity.generation, c.activity.foreground == 0 && !c.activity.closed
}
func (c *Client) admitPassive(ctx context.Context, generation uint64) bool {
c.activity.Lock()
defer c.activity.Unlock()
return ctx.Err() == nil && !c.activity.closed && c.activity.foreground == 0 && c.activity.generation == generation
}
func (c *Client) admitMaintenance(ctx context.Context, generation uint64) bool {
c.activity.Lock()
defer c.activity.Unlock()
return ctx.Err() == nil && c.activity.enabled && !c.activity.closed && c.activity.foreground == 0 && c.activity.generation == generation
}
// maintainIdleCard is called only by the serial worker, never through its queue.
func (c *Client) maintainIdleCard() {
c.activity.Lock()
if !c.activity.enabled || c.activity.closed || c.activity.foreground != 0 || c.activity.next.IsZero() || c.now().Before(c.activity.next) {
c.activity.Unlock()
return
}
ctx, cancel := context.WithTimeout(context.Background(), maintenanceTimeout)
generation := c.activity.generation
finished := make(chan struct{})
c.activity.cancel, c.activity.finished = cancel, finished
c.activity.Unlock()
defer func() {
cancel()
c.activity.Lock()
c.activity.cancel, c.activity.finished = nil, nil
if c.activity.enabled && !c.activity.closed && c.activity.foreground == 0 && c.activity.generation == generation {
c.activity.next = c.now().Add(maintenanceInterval)
}
close(finished)
c.activity.Unlock()
}()
if !c.admitMaintenance(ctx, generation) {
return
}
// Quiet observation: do not publish stock callbacks or manufacture cached status.
st, err := checkDispenserStatus(ctx, c.port)
if err != nil {
log.Debugf("idle dispenser maintenance AP: %v", err)
return
}
class, err := classifyPreparationStatus(st)
if err != nil {
log.Debugf("idle dispenser maintenance unusable AP: %v", err)
return
}
log.Debugf("idle dispenser maintenance AP; class=%s raw status: % X", class, st)
if class == encoderConfirmed || class == positionWellEmpty {
return
}
clear, err := deliveryClearance(st)
if err != nil || !clear {
return
}
if c.deliveryPending && c.now().Sub(c.deliveryStarted) < deliveryMinimumWait {
return
}
// This is the admission boundary shared with foreground registration and stop.
if !c.admitMaintenance(ctx, generation) {
return
}
if c.deliveryPending {
c.deliveryPending = false
}
err = cardToEncoderPosition(ctx, c.port)
c.invalidateStatusCache()
if err != nil {
log.Warnf("idle dispenser maintenance FC7 dispatch: %v", err)
return
}
log.Info("idle dispenser maintenance FC7 dispatched")
}

View File

@ -1,362 +0,0 @@
package dispenser
import (
"context"
"errors"
"reflect"
"sync"
"testing"
"time"
)
func maintenanceClient(t *testing.T, p *scriptedTransport) (*Client, *time.Time) {
t.Helper()
transportAddress(t)
now := time.Unix(100, 0)
c := &Client{port: p, done: make(chan struct{}), activityWake: make(chan struct{}, 1), sequenceTiming: sequenceTiming{now: func() time.Time { return now }, wait: waitForSequence}}
t.Cleanup(c.Close)
return c, &now
}
func TestMaintenanceIdleScheduling(t *testing.T) {
c, now := maintenanceClient(t, &scriptedTransport{})
c.StartMaintenance()
first := c.activity.next
c.StartMaintenance()
if c.activity.next != first {
t.Fatal("repeated start reset the timer")
}
*now = now.Add(59 * time.Second)
c.maintainIdleCard()
if len(c.port.(*scriptedTransport).writes) != 0 {
t.Fatal("maintenance ran before one minute")
}
release1 := c.BeginForeground()
release2 := c.BeginForeground()
release1()
if _, enabled := c.maintenanceDelay(); enabled {
t.Fatal("timer enabled while another issuance active")
}
*now = now.Add(20 * time.Second)
release2()
release2()
if delay, enabled := c.maintenanceDelay(); !enabled || delay != time.Minute {
t.Fatalf("after last release delay=%v enabled=%t", delay, enabled)
}
c.StopMaintenance()
if _, enabled := c.maintenanceDelay(); enabled {
t.Fatal("stop left timer enabled")
}
}
func TestMaintenanceUsesCommonEligibility(t *testing.T) {
for _, tc := range []struct {
name string
st []byte
pending bool
age time.Duration
fc7 bool
}{
{"clear", status(0x30), false, 0, true},
{"staging", status(0x34), true, 3 * time.Second, true},
{"too early", status(0x30), true, time.Second, false},
{"encoder", status(0x33), true, time.Minute, false},
{"empty", status(0x38), false, 0, false},
{"uncertain", status(0x35), false, 0, false},
{"movement", []byte{0x31, 0x30, 0x30, 0x34}, true, time.Minute, false},
{"invalid", status(0x40), true, time.Minute, false},
} {
t.Run(tc.name, func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(tc.st), vendorACK}}
c, now := maintenanceClient(t, p)
c.StartMaintenance()
*now = now.Add(time.Minute)
c.deliveryPending = tc.pending
c.deliveryStarted = now.Add(-tc.age)
callbacks := 0
c.OnStockUpdate(func(string) { callbacks++ })
c.maintainIdleCard()
want := []string{"AP"}
if tc.fc7 {
want = append(want, "FC7")
}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("commands=%v want %v", got, want)
}
if callbacks != 0 {
t.Errorf("maintenance published %d stock callbacks", callbacks)
}
if tc.pending && !tc.fc7 && !c.deliveryPending {
t.Error("ineligible maintenance cleared pending")
}
if delay, enabled := c.maintenanceDelay(); !enabled || delay != time.Minute {
t.Errorf("next maintenance delay=%v enabled=%t", delay, enabled)
}
})
}
}
func TestMaintenanceForegroundBetweenAPAndFC7(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK}}
c, now := maintenanceClient(t, p)
c.StartMaintenance()
*now = now.Add(time.Minute)
var release func()
p.afterRead = func() {
if len(p.chunks) == 1 && release == nil {
release = c.BeginForeground()
}
}
c.maintainIdleCard()
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP"}) {
t.Errorf("foreground during AP commands=%v want AP only", got)
}
if release == nil {
t.Fatal("barrier not reached")
}
release()
if delay, _ := c.maintenanceDelay(); delay != time.Minute {
t.Errorf("foreground completion delay=%v", delay)
}
}
func TestMaintenanceAdmissionConditions(t *testing.T) {
c, _ := maintenanceClient(t, &scriptedTransport{})
c.StartMaintenance()
generation := c.activity.generation
ctx, cancel := context.WithCancel(context.Background())
if !c.admitMaintenance(ctx, generation) {
t.Fatal("idle admission rejected")
}
cancel()
if c.admitMaintenance(ctx, generation) {
t.Fatal("canceled admission accepted")
}
expired, stop := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer stop()
if c.admitMaintenance(expired, generation) {
t.Fatal("expired admission accepted")
}
release := c.BeginForeground()
if c.admitMaintenance(context.Background(), generation) {
t.Fatal("foreground/stale admission accepted")
}
release()
if c.admitMaintenance(context.Background(), generation) {
t.Fatal("obsolete generation accepted")
}
generation = c.activity.generation
c.StopMaintenance()
if c.admitMaintenance(context.Background(), generation) {
t.Fatal("stopped admission accepted")
}
}
func TestPassivePollGenerationAtDispatch(t *testing.T) {
c, _ := maintenanceClient(t, &scriptedTransport{})
generation, _ := c.passiveGeneration()
release := c.BeginForeground()
for _, active := range []bool{true, false} {
if !active {
release()
}
response := make(chan cmdResp, 1)
c.handle(cmdReq{ctx: context.Background(), typ: cmdStatus, passive: true, generation: generation, respCh: response})
<-response
if len(c.port.(*scriptedTransport).writes) != 0 {
t.Errorf("active=%t stale passive request touched port", active)
}
}
}
func TestMaintenanceStopWaitsForAdmittedRead(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK}}
c, now := maintenanceClient(t, p)
c.StartMaintenance()
*now = now.Add(time.Minute)
entered, resume := make(chan struct{}), make(chan struct{})
var once sync.Once
p.afterRead = func() { once.Do(func() { close(entered); <-resume }) }
finished := make(chan struct{})
go func() { c.maintainIdleCard(); close(finished) }()
<-entered
stopped := make(chan struct{})
go func() { c.StopMaintenance(); close(stopped) }()
// Wait until stop has disabled admission.
for {
c.activity.Lock()
enabled := c.activity.enabled
c.activity.Unlock()
if !enabled {
break
}
time.Sleep(time.Millisecond)
}
select {
case <-stopped:
t.Fatal("stop returned before admitted read finished")
default:
}
close(resume)
<-finished
<-stopped
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP"}) {
t.Errorf("stop commands=%v want AP only", got)
}
c.StopMaintenance()
c.Close()
c.Close()
}
func TestMaintenanceWorkerTimerWake(t *testing.T) {
c := NewClient(nil, 1)
c.StartMaintenance()
release := c.BeginForeground()
// An immediate obsolete timer is harmless while foreground is registered.
c.activity.Lock()
c.activity.next = time.Now().Add(-time.Second)
c.activity.Unlock()
c.wakeWorker()
release()
if delay, enabled := c.maintenanceDelay(); !enabled || delay <= 59*time.Second {
t.Errorf("worker schedule delay=%v enabled=%t", delay, enabled)
}
c.Close()
if r := c.doResponse(context.Background(), cmdStatus); r.err == nil {
t.Fatal("closed client accepted a request")
}
}
func TestMaintenanceWorkerTimerDispatch(t *testing.T) {
transportAddress(t)
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK}}
c := &Client{port: p, reqCh: make(chan cmdReq, 1), done: make(chan struct{}), activityWake: make(chan struct{}, 1), sequenceTiming: sequenceTiming{now: time.Now, wait: waitForSequence}}
c.StartMaintenance()
c.activity.Lock()
c.activity.next = time.Now().Add(-time.Second)
c.activity.Unlock()
workerDone := make(chan struct{})
go func() { defer close(workerDone); c.loop() }()
t.Cleanup(func() { c.Close(); <-workerDone })
dispatched := make(chan struct{})
// Observe completion through the activity guard, without touching the transport concurrently.
go func() {
for {
c.activity.Lock()
next := c.activity.next
c.activity.Unlock()
if time.Until(next) > 50*time.Second {
close(dispatched)
return
}
select {
case <-c.done:
return
case <-time.After(time.Millisecond):
}
}
}()
select {
case <-dispatched:
case <-time.After(4 * time.Second):
t.Fatal("worker timer did not finish maintenance")
}
c.StopMaintenance()
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP", "FC7"}) {
t.Errorf("timer commands=%v", got)
}
}
func TestForegroundEncoderClearanceCharacterization(t *testing.T) {
for _, movement := range []bool{false, true} {
st := status(0x33)
if movement {
st[0] = 0x31
}
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(st), vendorACK, apReply(st), vendorACK, apReply(st), vendorACK}}
c, now := maintenanceClient(t, p)
c.deliveryPending = true
c.deliveryStarted = now.Add(-time.Minute)
c.sequenceTiming.wait = func(context.Context, time.Duration) error { *now = now.Add(2 * time.Second); return nil }
r := workerRequest(c, context.Background(), cmdToEncoder)
want := []string{"AP", "AP", "AP"}
if movement {
if !errors.Is(r.err, context.DeadlineExceeded) || !c.deliveryPending {
t.Errorf("movement err=%v pending=%t", r.err, c.deliveryPending)
}
} else {
want = append(want, "FC7")
if r.err != nil || c.deliveryPending {
t.Errorf("internal fallback err=%v pending=%t", r.err, c.deliveryPending)
}
}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("movement=%t commands=%v want %v", movement, got, want)
}
}
}
func TestForegroundEncoderClearanceCallerDeadline(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x33)), vendorACK, apReply(status(0x33)), vendorACK, apReply(status(0x33))}}
c, now := maintenanceClient(t, p)
c.deliveryPending = true
c.deliveryStarted = *now
start := *now
samples := 0
p.afterRead = func() {
if len(p.chunks)%2 == 0 { // Only complete frame reads advance the observation clock.
if len(p.chunks) == 4 || len(p.chunks) == 2 || len(p.chunks) == 0 {
samples++
*now = now.Add(time.Second)
}
}
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
c.sequenceTiming.wait = func(context.Context, time.Duration) error {
if samples == 3 {
<-ctx.Done()
return ctx.Err()
}
*now = now.Add(time.Second)
return nil
}
r := workerRequest(c, ctx, cmdToEncoder)
if !errors.Is(r.err, context.DeadlineExceeded) || !c.deliveryPending {
t.Errorf("caller deadline err=%v pending=%t", r.err, c.deliveryPending)
}
if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP", "AP", "AP"}) {
t.Errorf("caller deadline commands=%v want AP only", got)
}
if elapsed := now.Sub(start); elapsed != 5*time.Second {
t.Errorf("last observation elapsed=%v want 5s", elapsed)
}
}
func TestMaintenanceFailuresAreQuietAndRetryLater(t *testing.T) {
for _, mechanical := range []bool{false, true} {
p := &scriptedTransport{chunks: [][]byte{{0x10, 0x06, 0x30}}}
if mechanical {
p.chunks = [][]byte{vendorACK, apReply(status(0x30))}
}
c, now := maintenanceClient(t, p)
c.StartMaintenance()
*now = now.Add(time.Minute)
callbacks := 0
c.OnStockUpdate(func(string) { callbacks++ })
c.maintainIdleCard()
want := []string{"AP"}
if mechanical {
want = append(want, "FC7")
}
if got := wireCommands(p); !reflect.DeepEqual(got, want) {
t.Errorf("mechanical=%t commands=%v want %v", mechanical, got, want)
}
if callbacks != 0 {
t.Errorf("maintenance failure callbacks=%d", callbacks)
}
if delay, enabled := c.maintenanceDelay(); !enabled || delay != time.Minute {
t.Errorf("retry delay=%v enabled=%t", delay, enabled)
}
}
}

View File

@ -1,242 +0,0 @@
package dispenser
import (
"context"
"errors"
"fmt"
"io"
"testing"
"time"
)
func TestStrictAPFailuresBecomeUnusablePreparationObservations(t *testing.T) {
transportAddress(t)
cases := []struct {
name string
chunks [][]byte
readErr error
}{
{"invalid ACK 10 06 30", [][]byte{{0x10, 0x06, 0x30}}, nil},
{"truncated", [][]byte{vendorACK, vendorAP[:8]}, nil},
{"no response", nil, nil},
{"timeout", nil, context.DeadlineExceeded},
{"read failure", nil, io.ErrClosedPipe},
}
for _, offset := range []int{0, 1, 2, 3, 4, 5, 6, 11, 12} {
frame := append([]byte(nil), vendorAP...)
frame[offset] ^= 0xff
if offset == 5 || offset == 6 || offset == 11 {
frame[12] = calculateBCC(frame[:12])
}
cases = append(cases, struct {
name string
chunks [][]byte
readErr error
}{fmt.Sprintf("frame byte %d", offset), [][]byte{vendorACK, frame}, nil})
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := &scriptedTransport{chunks: tc.chunks, readErr: tc.readErr}
st, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0)
if err == nil {
t.Fatal("strict queryStatus accepted invalid transaction")
}
c, d := newSequenceTestClient(t, cmdResp{status: st, err: err}, cmdResp{status: status(0x37)})
if _, err := c.PrepareCurrentCard(context.Background()); err != nil {
t.Errorf("preparation after %s = %v, want opportunity", tc.name, err)
}
if commandCount(d.commands, cmdToEncoder) != 1 || commandCount(d.commands, cmdReset) != 0 {
t.Errorf("commands=%v, want one FC7 and no RS", d.commands)
}
})
}
}
func TestUnusableObservationsReclassifyAndShareUncertainty(t *testing.T) {
for _, later := range []byte{0x38, 0x30, 0x37, 0x35, 0x40} {
t.Run(fmt.Sprintf("later %X", later), func(t *testing.T) {
responses := []cmdResp{{err: io.ErrUnexpectedEOF}, {status: status(0x40)}}
responses = append(responses, positionResponses(later, 25)...)
c, d := newSequenceTestClient(t, responses...)
_, err := c.PrepareCurrentCard(context.Background())
var want error
if later == 0x38 {
want = ErrCardWellEmpty
}
if later == 0x30 {
want = ErrPreparationExhausted
}
if !errors.Is(err, want) {
t.Errorf("later %X error=%v, want %v", later, err, want)
}
resets := 0
if later == 0x30 {
resets = 3
}
if commandCount(d.commands, cmdReset) != resets {
t.Errorf("later %X commands=%v, want %d RS", later, d.commands, resets)
}
})
}
c, d := newSequenceTestClient(t, cmdResp{err: io.EOF}, cmdResp{err: io.EOF}, cmdResp{status: status(0x35)}, cmdResp{status: status(0x40)}, cmdResp{err: io.EOF}, cmdResp{status: status(0x39)})
start := c.now()
if _, err := c.PrepareCurrentCard(context.Background()); err != nil {
t.Fatal(err)
}
if elapsed := c.now().Sub(start); elapsed != sequenceUncertainWait {
t.Errorf("mixed uncertainty elapsed=%v, want %v", elapsed, sequenceUncertainWait)
}
if commandCount(d.commands, cmdToEncoder) != 1 {
t.Errorf("mixed uncertainty commands=%v, want one FC7", d.commands)
}
}
func TestObservationAtPreparationDeadline(t *testing.T) {
for _, last := range []cmdResp{{err: io.EOF}, {status: status(0x40)}, {status: status(0x35)}, {status: status(0x30)}, {status: status(0x38)}} {
c, d := newSequenceTestClient(t, cmdResp{status: status(0x30)}, last)
now := c.now()
c.sequenceTiming.now = func() time.Time { return now }
d.onCommand = func(cmd cmdType) {
if cmd == cmdStatus && commandCount(d.commands, cmdStatus) == 2 {
now = now.Add(sequenceTimeout)
}
}
_, err := c.PrepareCurrentCard(context.Background())
var want error
if len(last.status) == 4 && last.status[3] == 0x30 {
want = ErrPreparationExhausted
}
if len(last.status) == 4 && last.status[3] == 0x38 {
want = ErrCardWellEmpty
}
if !errors.Is(err, want) {
t.Errorf("deadline observation=%v error=%v, want %v", last, err, want)
}
}
}
func TestWorkerClearanceFallbackAndMovement(t *testing.T) {
for _, mode := range []string{"unusable", "ambiguous", "movement", "movement then unusable", "empty", "cancel"} {
t.Run(mode, func(t *testing.T) {
p := &scriptedTransport{}
for i := 0; i < 6; i++ {
switch mode {
case "unusable", "cancel":
p.chunks = append(p.chunks, []byte{0x10, 0x06, 0x30})
case "ambiguous":
p.chunks = append(p.chunks, vendorACK, apReply(status(0x37)))
case "empty":
p.chunks = append(p.chunks, vendorACK, apReply(status(0x38)))
default:
if mode == "movement then unusable" && i > 0 {
p.chunks = append(p.chunks, []byte{0x10, 0x06, 0x30})
} else {
p.chunks = append(p.chunks, vendorACK, apReply([]byte{0x31, 0x30, 0x30, 0x34}))
}
}
}
c, now := deliveryWorkerClient(t, p)
c.sequenceTiming.wait = func(ctx context.Context, d time.Duration) error {
if ctx.Err() != nil {
return ctx.Err()
}
*now = now.Add(2 * d)
return nil
}
c.deliveryPending = true
c.deliveryStarted = c.now()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if mode == "cancel" {
p.afterRead = cancel
}
r := c.doResponse(ctx, cmdDeliveryClearance)
if mode == "cancel" {
if !errors.Is(r.err, context.Canceled) {
t.Errorf("cancel=%v", r.err)
}
return
}
var want error
if mode == "movement" {
want = context.DeadlineExceeded
}
if mode == "empty" {
want = ErrCardWellEmpty
}
if !errors.Is(r.err, want) {
t.Errorf("clearance %s error=%v want %v", mode, r.err, want)
}
if c.deliveryPending != (want != nil) {
t.Errorf("clearance %s pending=%t", mode, c.deliveryPending)
}
if mode != "empty" && now.Sub(time.Unix(0, 0)) != deliveryClearanceTimeout {
t.Errorf("clearance %s wait=%v want 6s", mode, now.Sub(time.Unix(0, 0)))
}
for _, command := range wireCommands(p) {
if command != "AP" {
t.Errorf("clearance issued %s, want AP only", command)
}
}
})
}
}
func TestWorkerAssumedClearanceAllowsOneFC7(t *testing.T) {
for _, prepare := range []bool{false, true} {
p := &scriptedTransport{}
dispatched := false
p.afterWrite = func() {
w := p.writes[len(p.writes)-1]
if len(w) <= 6 || w[0] != STX {
return
}
switch string(w[5 : len(w)-2]) {
case "AP":
if dispatched {
p.chunks = append(p.chunks, vendorACK, apReply(status(0x35)))
} else {
p.chunks = append(p.chunks, []byte{0x10, 0x06, 0x30})
}
case "FC7":
dispatched = true
p.chunks = append(p.chunks, vendorACK)
}
}
c, now := deliveryWorkerClient(t, p)
c.deliveryPending = true
c.deliveryStarted = c.now()
var err error
if prepare {
_, err = c.PrepareCurrentCard(context.Background())
} else {
err = c.BeginPrepareNextCard(context.Background())
}
if err != nil {
t.Fatalf("prepare=%t error=%v", prepare, err)
}
wantElapsed := deliveryClearanceTimeout
if prepare {
wantElapsed += sequenceUncertainWait
}
if elapsed := now.Sub(time.Unix(0, 0)); elapsed != wantElapsed {
t.Errorf("prepare=%t elapsed=%v want %v", prepare, elapsed, wantElapsed)
}
commands := wireCommands(p)
fc7 := 0
for _, cmd := range commands {
if cmd == "FC7" {
fc7++
}
if cmd == "RS" {
t.Errorf("unexpected RS: %v", commands)
}
}
if fc7 != 1 || c.deliveryPending {
t.Errorf("prepare=%t commands=%v pending=%t, want one FC7 and cleared", prepare, commands, c.deliveryPending)
}
if !prepare && commands[len(commands)-1] != "FC7" {
t.Errorf("prestaging polled after FC7: %v", commands)
}
}
}

View File

@ -1,417 +0,0 @@
package dispenser
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"testing"
"time"
)
// Golden frames reconstructed from K720_Dll.dll's big-endian length and XOR
// algorithm (SendCmd 0x100050C0, Query 0x10005280, SensorQuery 0x10005420).
var vendorFrames = []struct {
name string
command []byte
frame []byte
}{
{"AP", []byte{2, 'A', 'P'}, []byte{2, 0x30, 0x30, 0, 2, 0x41, 0x50, 3, 0x12}},
{"RF", []byte{2, 'R', 'F'}, []byte{2, 0x30, 0x30, 0, 2, 0x52, 0x46, 3, 0x17}},
{"FC7", commandFC7, []byte{2, 0x30, 0x30, 0, 3, 0x46, 0x43, 0x37, 3, 0x30}},
{"FC0", commandFC0, []byte{2, 0x30, 0x30, 0, 3, 0x46, 0x43, 0x30, 3, 0x37}},
{"RS", commandRS, []byte{2, 0x30, 0x30, 0, 2, 0x52, 0x53, 3, 2}},
}
func TestACKScannerFragmentation(t *testing.T) {
transportAddress(t)
for _, address := range []string{"00", "15"} {
Address = []byte(address)
wrong := []byte("15")
if address == "15" {
wrong = []byte("00")
}
for _, prefix := range [][]byte{nil, {0x30}, {0x10}, {address[1]}, {ACK, wrong[0], wrong[1], 0x30}, {NAK, wrong[0], wrong[1]}} {
for _, token := range []byte{ACK, NAK} {
wire := append(append([]byte(nil), prefix...), token, Address[0], Address[1])
for mask := 0; mask < 1<<(len(wire)-1); mask++ {
t.Run(fmt.Sprintf("%s/%X/%d", address, wire, mask), func(t *testing.T) {
p := &scriptedTransport{}
start := 0
for i := 1; i < len(wire); i++ {
if mask&(1<<(i-1)) != 0 {
p.chunks = append(p.chunks, wire[start:i])
start = i
}
}
p.chunks = append(p.chunks, wire[start:])
err := dispatchCommand(context.Background(), p, commandFC7, 0)
if (err != nil) != (token == NAK) {
t.Fatalf("dispatchCommand(% X)=%v", wire, err)
}
want := [][]byte{createPacket(Address, commandFC7)}
if token == ACK {
want = append(want, append([]byte{ENQ}, Address...))
}
if len(p.writes) != len(want) {
t.Fatalf("writes=% X want=% X", p.writes, want)
}
for i := range want {
if !bytes.Equal(p.writes[i], want[i]) {
t.Errorf("write=% X want=% X", p.writes[i], want[i])
}
}
if len(p.chunks) != 0 {
t.Errorf("unread token bytes=% X", p.chunks)
}
})
}
}
}
}
}
func TestACKScannerBoundsAndFollowingTransaction(t *testing.T) {
transportAddress(t)
for _, address := range []string{"00", "15"} {
Address = []byte(address)
ack := append([]byte{ACK}, Address...)
for _, leading := range []int{61, 62, 64} {
wire := append(bytes.Repeat([]byte{0xff}, leading), ack...)
p := &scriptedTransport{chunks: [][]byte{wire}}
err := dispatchCommand(context.Background(), p, commandRS, 0)
if (err == nil) != (leading == 61) {
t.Errorf("leading=%d error=%v", leading, err)
}
if leading != 61 && (len(p.writes) != 1 || len(bytes.Join(p.chunks, nil)) != len(wire)-64) {
t.Fatalf("scan exceeded bound: writes=% X remaining=% X", p.writes, p.chunks)
}
}
wire := append(append(append([]byte{0x10}, ack...), ack...), 0xfe)
p := &scriptedTransport{chunks: [][]byte{wire}}
for i := 0; i < 2; i++ {
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err != nil {
t.Fatal(err)
}
}
if len(p.writes) != 4 || !bytes.Equal(bytes.Join(p.chunks, nil), []byte{0xfe}) {
t.Fatalf("transaction boundary lost: writes=% X remaining=% X", p.writes, p.chunks)
}
}
}
type ackReadErrorTransport struct {
*scriptedTransport
reads, failAt int
err error
}
func (p *ackReadErrorTransport) Read(b []byte) (int, error) {
p.reads++
n, err := p.scriptedTransport.Read(b)
if p.reads == p.failAt {
return n, p.err
}
return n, err
}
func TestACKScannerReadErrors(t *testing.T) {
transportAddress(t)
for _, address := range []string{"00", "15"} {
Address = []byte(address)
for _, token := range []byte{ACK, NAK} {
for _, readErr := range []error{io.EOF, os.ErrDeadlineExceeded, io.ErrClosedPipe} {
for _, failAt := range []int{1, 2} {
p := &ackReadErrorTransport{scriptedTransport: &scriptedTransport{chunks: [][]byte{{token, Address[0], Address[1]}}}, failAt: failAt, err: readErr}
err := dispatchCommand(context.Background(), p, commandFC7, 0)
if failAt == 1 && !errors.Is(err, readErr) {
t.Fatalf("incomplete token error=%v want=%v", err, readErr)
}
if failAt == 2 && ((err == nil) != (token == ACK) || errors.Is(err, readErr)) {
t.Fatalf("complete token %02X with read error returned %v", token, err)
}
wantWrites := 1
if failAt == 2 && token == ACK {
wantWrites = 2
}
if len(p.writes) != wantWrites || p.reads != failAt {
t.Fatalf("writes=%d reads=%d want=%d/%d", len(p.writes), p.reads, wantWrites, failAt)
}
}
}
}
for _, wire := range [][]byte{nil, {0x30, ACK, Address[0]}, {0xff, 0xfe}, {ACK, Address[0]}} {
p := &scriptedTransport{chunks: [][]byte{wire}}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err == nil || len(p.writes) != 1 {
t.Fatalf("incomplete/garbage % X: err=%v writes=% X", wire, err, p.writes)
}
}
for _, cancelAt := range []int{1, 2} {
ctx, cancel := context.WithCancel(context.Background())
p := &ackReadErrorTransport{scriptedTransport: &scriptedTransport{chunks: [][]byte{{ACK, Address[0], Address[1]}}}, failAt: 2, err: io.EOF}
p.afterRead = func() {
if p.reads == cancelAt {
cancel()
}
}
err := dispatchCommand(ctx, p, commandFC7, 0)
cancel()
if !errors.Is(err, context.Canceled) || len(p.writes) != 1 || p.reads != cancelAt {
t.Fatalf("cancel read %d: err=%v writes=%d reads=%d", cancelAt, err, len(p.writes), p.reads)
}
}
}
}
func TestVendorOutboundFrames(t *testing.T) {
for _, tc := range vendorFrames {
t.Run(tc.name, func(t *testing.T) {
if got := createPacket([]byte("00"), tc.command); !bytes.Equal(got, tc.frame) {
t.Errorf("createPacket(%s) = % X, want % X", tc.name, got, tc.frame)
}
if got := calculateBCC(tc.frame[:len(tc.frame)-1]); got != tc.frame[len(tc.frame)-1] {
t.Errorf("calculateBCC(%s) = %02X, want %02X", tc.name, got, tc.frame[len(tc.frame)-1])
}
})
}
}
type scriptedTransport struct {
chunks [][]byte
writes [][]byte
readErr error
writeErr error
shortWrite int
afterRead func()
afterWrite func()
}
func (p *scriptedTransport) Read(b []byte) (int, error) {
if len(p.chunks) == 0 {
if p.readErr != nil {
return 0, p.readErr
}
return 0, io.EOF
}
n := copy(b, p.chunks[0])
p.chunks[0] = p.chunks[0][n:]
if len(p.chunks[0]) == 0 {
p.chunks = p.chunks[1:]
}
if p.afterRead != nil {
p.afterRead()
}
return n, nil
}
func (p *scriptedTransport) Write(b []byte) (int, error) {
p.writes = append(p.writes, append([]byte(nil), b...))
if p.afterWrite != nil {
p.afterWrite()
}
if p.writeErr != nil {
return 0, p.writeErr
}
if p.shortWrite == len(p.writes) {
return len(b) - 1, nil
}
return len(b), nil
}
func transportAddress(t *testing.T) {
t.Helper()
old := Address
Address = []byte("00")
t.Cleanup(func() { Address = old })
}
// Independent SF response vectors: status is 30 30 30 [33].
var vendorAP = []byte{2, 0x30, 0x30, 0, 6, 'S', 'F', 0x30, 0x30, 0x30, 0x33, 3, 0x11}
var vendorRF = []byte{2, 0x30, 0x30, 0, 5, 'S', 'F', 0x30, 0x30, 0x30, 3, 0x21}
var vendorACK = []byte{6, 0x30, 0x30}
func TestVendorQueryFragmentation(t *testing.T) {
transportAddress(t)
for i, frame := range [][]byte{vendorAP, vendorRF} {
wire := append(append([]byte(nil), vendorACK...), frame...)
for split := 1; split < len(wire); split++ {
t.Run(fmt.Sprintf("%s/split%d", vendorFrames[i].name, split), func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{wire[:split], wire[split:]}}
got, err := queryStatus(context.Background(), p, vendorFrames[i].command, 4-i, 0)
if err != nil || !bytes.Equal(got, frame[7:len(frame)-2]) {
t.Fatalf("queryStatus(split=%d) = % X, %v, want % X, nil", split, got, err, frame[7:len(frame)-2])
}
if len(p.writes) != 2 || !bytes.Equal(p.writes[0], vendorFrames[i].frame) || !bytes.Equal(p.writes[1], []byte{5, 0x30, 0x30}) {
t.Errorf("queryStatus writes = % X, want command then ENQ", p.writes)
}
})
}
t.Run(vendorFrames[i].name+"/one-byte", func(t *testing.T) {
p := &scriptedTransport{}
for _, b := range wire {
p.chunks = append(p.chunks, []byte{b})
}
if _, err := queryStatus(context.Background(), p, vendorFrames[i].command, 4-i, 0); err != nil {
t.Errorf("queryStatus(one-byte reads) = %v, want nil", err)
}
})
}
}
func TestVendorQueryRejectsInvalidFrames(t *testing.T) {
transportAddress(t)
for _, tc := range []struct {
name string
offset int
value byte
}{
{"STX", 0, 1}, {"address high", 1, '1'}, {"address low", 2, '1'},
{"zero length", 4, 0}, {"short length", 4, 5}, {"long length", 4, 7}, {"high length", 3, 0xff},
{"type S", 5, 'X'}, {"type F", 6, 'X'}, {"ETX", 11, 4}, {"BCC", 12, 0},
} {
t.Run(tc.name, func(t *testing.T) {
frame := append([]byte(nil), vendorAP...)
frame[tc.offset] = tc.value
// Keep checksum valid when testing type/ETX to isolate those checks.
if tc.offset == 5 || tc.offset == 6 || tc.offset == 11 {
frame[12] = 0
for _, b := range frame[:12] {
frame[12] ^= b
}
}
p := &scriptedTransport{chunks: [][]byte{vendorACK, frame}}
if got, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0); err == nil || got != nil {
t.Errorf("queryStatus(%s) = % X, %v, want nil/error", tc.name, got, err)
}
if len(p.writes) != 2 {
t.Errorf("queryStatus(%s) writes=%d, want 2 without resend", tc.name, len(p.writes))
}
})
}
for n := 0; n < len(vendorAP); n++ {
t.Run(fmt.Sprintf("truncated%d", n), func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, vendorAP[:n]}}
if _, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0); err == nil {
t.Errorf("queryStatus(%d-byte frame) succeeded, want error", n)
}
})
}
}
func TestTransportACKAndMechanicalDispatch(t *testing.T) {
transportAddress(t)
for _, tc := range vendorFrames[2:] {
p := &scriptedTransport{chunks: [][]byte{{6}, {'0'}, {'0'}}}
if err := dispatchCommand(context.Background(), p, tc.command, 0); err != nil {
t.Fatalf("dispatchCommand(%s)=%v, want nil", tc.name, err)
}
if len(p.writes) != 2 || !bytes.Equal(p.writes[0], tc.frame) || !bytes.Equal(p.writes[1], []byte{5, '0', '0'}) {
t.Errorf("dispatchCommand(%s) writes=% X, want command then ENQ", tc.name, p.writes)
}
}
for _, ack := range [][]byte{{0x15, '0', '0'}, {6, '1', '0'}, {6, '0', '1'}, {6}, {6, '0'}, {}} {
p := &scriptedTransport{chunks: [][]byte{ack}}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err == nil {
t.Errorf("dispatchCommand(ACK=% X) succeeded, want error", ack)
}
if len(p.writes) != 1 {
t.Errorf("dispatchCommand(ACK=% X) writes=%d, want 1", ack, len(p.writes))
}
}
}
func TestTransportShortWritesAndErrors(t *testing.T) {
transportAddress(t)
failure := errors.New("serial failure")
for _, stage := range []int{1, 2} {
p := &scriptedTransport{chunks: [][]byte{vendorACK}, shortWrite: stage}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); !errors.Is(err, io.ErrShortWrite) {
t.Errorf("dispatchCommand(short write %d)=%v, want ErrShortWrite", stage, err)
}
if len(p.writes) != stage {
t.Errorf("short write %d writes=%d, want %d", stage, len(p.writes), stage)
}
}
for _, p := range []*scriptedTransport{{writeErr: failure}, {readErr: failure}, {chunks: [][]byte{{}}}} {
if err := dispatchCommand(context.Background(), p, commandRS, 0); err == nil {
t.Error("dispatchCommand(I/O failure) succeeded, want error")
}
if len(p.writes) != 1 {
t.Errorf("dispatchCommand(I/O failure) writes=%d, want 1", len(p.writes))
}
}
}
func TestTransportCancellation(t *testing.T) {
transportAddress(t)
for _, stage := range []string{"before write", "processing wait", "after ACK", "after ENQ", "after header"} {
t.Run(stage, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := &scriptedTransport{chunks: [][]byte{vendorACK, vendorAP}}
wantWrites := 1
switch stage {
case "before write":
cancel()
wantWrites = 0
case "processing wait":
p.afterWrite = cancel
case "after ACK":
p.afterRead = func() {
if len(p.chunks) == 1 {
cancel()
}
}
case "after ENQ":
wantWrites = 2
p.afterWrite = func() {
if len(p.writes) == 2 {
cancel()
}
}
case "after header":
wantWrites = 2
reads := 0
p.afterRead = func() {
reads++
if reads == 3 {
cancel()
}
}
}
if _, err := queryStatus(ctx, p, vendorFrames[0].command, 4, 0); !errors.Is(err, context.Canceled) {
t.Errorf("queryStatus(cancel %s)=%v, want Canceled", stage, err)
}
if len(p.writes) != wantWrites {
t.Errorf("queryStatus(cancel %s) writes=%d, want %d", stage, len(p.writes), wantWrites)
}
})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
p := &scriptedTransport{}
if err := dispatchCommand(ctx, p, commandRS, time.Second); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("dispatchCommand(deadline in wait)=%v, want DeadlineExceeded", err)
}
if len(p.writes) != 1 {
t.Errorf("dispatchCommand(deadline) writes=%d, want 1", len(p.writes))
}
}
func TestTransportTrailingDataNotConsumedAsStatus(t *testing.T) {
transportAddress(t)
frame := append(append([]byte(nil), vendorAP...), 0xff, 0xfe, 0xfd)
p := &scriptedTransport{chunks: [][]byte{vendorACK, frame}}
if _, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0); err != nil {
t.Fatalf("queryStatus(frame with trailing bytes)=%v, want nil", err)
}
if len(p.chunks) != 1 || !bytes.Equal(p.chunks[0], []byte{0xff, 0xfe, 0xfd}) {
t.Fatalf("remaining bytes=% X, want FF FE FD", p.chunks)
}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err == nil {
t.Error("dispatchCommand(trailing garbage) succeeded, want invalid ACK")
}
if len(p.writes) != 3 {
t.Errorf("writes after trailing garbage=%d, want 3 (no ENQ/resend)", len(p.writes))
}
}

View File

@ -1,674 +0,0 @@
package dojo
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
log "github.com/sirupsen/logrus"
)
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
}
const (
dojoTerminalUnavailableStatus = "TERMINAL_UNAVAILABLE"
dojoTerminalUnavailableMessage = "Payment terminal is unavailable"
)
type httpResponseError struct {
StatusCode int
Body string
}
func (e *httpResponseError) Error() string {
return fmt.Sprintf("Dojo returned HTTP %d: %s", e.StatusCode, e.Body)
}
type terminalSessionWithUpdates struct {
terminalSessionResponse
NotificationEvents []terminalNotificationEvent `json:"notificationEvents"`
StatusEvents []terminalStatusEvent `json:"statusEvents"`
}
type terminalNotificationEvent struct {
CreatedAt time.Time `json:"createdAt"`
NotificationType string `json:"notificationType"`
}
type terminalStatusEvent struct {
CreatedAt time.Time `json:"createdAt"`
Status string `json:"status"`
}
type terminalDisplayEvent struct {
createdAt time.Time
code string
sessionStatus string
}
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,
onStatus paymentsvc.StatusHandler,
) (*paymentsvc.Result, error) {
if req.Currency == "" {
req.Currency = "GBP"
}
sendPaymentStatus(onStatus, paymentstatus.Starting)
intent, err := c.createPaymentIntent(ctx, req)
if err != nil {
return nil, err
}
session, err := c.createTerminalSession(ctx, intent.ID)
if err != nil {
var responseErr *httpResponseError
if errors.As(err, &responseErr) && responseErr.StatusCode == http.StatusConflict {
log.WithFields(log.Fields{
"provider": "Dojo",
"operation": "createTerminalSession",
"status": responseErr.StatusCode,
"terminal_unavailable": true,
}).Warn(dojoTerminalUnavailableMessage)
sendPaymentStatus(onStatus, paymentstatus.TerminalUnavailable)
result := c.baseResult(req, nil)
result.Status = dojoTerminalUnavailableStatus
result.ErrorMessage = dojoTerminalUnavailableMessage
return result, nil
}
return nil, err
}
session, err = c.waitForTerminalSession(ctx, session.ID, onStatus)
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)
}
result := c.mapCapturedResult(req, intent)
result.CustomerReceipt = receiptToText(session.Receipt)
return result, 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,
onStatus paymentsvc.StatusHandler,
) (*terminalSessionResponse, error) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
notificationCount := 0
statusCount := 0
lastSessionStatus := ""
signatureRejected := false
for {
session, err := c.getTerminalSession(ctx, terminalSessionID)
if err != nil {
return nil, err
}
log.Println("session status:", session.Status)
emitNewTerminalSessionEvents(
session,
&notificationCount,
&statusCount,
&lastSessionStatus,
onStatus,
)
currentStatus := strings.ToLower(session.Status)
if currentStatus != lastSessionStatus {
lastSessionStatus = currentStatus
sendPaymentStatus(
onStatus,
mapDojoSessionStatus(session.Status),
)
}
switch currentStatus {
case types.ResultInitiateRequested,
types.ResultInitiated,
types.ResultAuthorized,
types.ResultCancelRequested:
case types.ResultSignatureRequired:
if !signatureRejected {
sendPaymentStatus(
onStatus,
paymentstatus.SignatureRejecting,
)
rejectedSession, err := c.rejectSignature(
ctx,
terminalSessionID,
)
if err != nil {
return nil, err
}
signatureRejected = true
if rejectedSession != nil {
rejectedStatus := strings.ToLower(
rejectedSession.Status,
)
if rejectedStatus != lastSessionStatus {
lastSessionStatus = rejectedStatus
sendPaymentStatus(
onStatus,
mapDojoSessionStatus(
rejectedSession.Status,
),
)
}
switch rejectedStatus {
case types.ResultCaptured,
types.ResultCancelled,
types.ResultCanceled,
types.ResultDeclined,
types.ResultExpired,
types.ResultSignatureAccepted,
types.ResultSignatureRejected:
return rejectedSession, nil
}
}
}
case types.ResultCaptured,
types.ResultCancelled,
types.ResultCanceled,
types.ResultDeclined,
types.ResultExpired,
types.ResultSignatureAccepted,
types.ResultSignatureRejected:
return &session.terminalSessionResponse, 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 emitNewTerminalSessionEvents(
session *terminalSessionWithUpdates,
notificationCount *int,
statusCount *int,
lastSessionStatus *string,
onStatus paymentsvc.StatusHandler,
) {
if *notificationCount > len(session.NotificationEvents) {
*notificationCount = len(session.NotificationEvents)
}
if *statusCount > len(session.StatusEvents) {
*statusCount = len(session.StatusEvents)
}
newEventCount := len(session.NotificationEvents) - *notificationCount +
len(session.StatusEvents) - *statusCount
events := make([]terminalDisplayEvent, 0, newEventCount)
// Add status events first so a notification wins when timestamps are equal.
for *statusCount < len(session.StatusEvents) {
event := session.StatusEvents[*statusCount]
*statusCount = *statusCount + 1
events = append(events, terminalDisplayEvent{
createdAt: event.CreatedAt,
code: mapDojoSessionStatus(event.Status),
sessionStatus: event.Status,
})
}
for *notificationCount < len(session.NotificationEvents) {
event := session.NotificationEvents[*notificationCount]
*notificationCount = *notificationCount + 1
events = append(events, terminalDisplayEvent{
createdAt: event.CreatedAt,
code: mapDojoNotification(event.NotificationType),
})
}
sort.SliceStable(events, func(i, j int) bool {
return events[i].createdAt.Before(events[j].createdAt)
})
for _, event := range events {
if event.sessionStatus != "" {
*lastSessionStatus = strings.ToLower(event.sessionStatus)
}
sendPaymentStatus(onStatus, event.code)
}
}
func sendPaymentStatus(
handler paymentsvc.StatusHandler,
code string,
) {
if handler == nil || code == "" {
return
}
handler(paymentsvc.StatusUpdate{
Code: code,
})
}
func mapDojoNotification(notification string) string {
switch notification {
case "PresentCard":
return paymentstatus.PresentCard
case "InsertCard":
return paymentstatus.InsertCard
case "SwipeCard":
return paymentstatus.SwipeCard
case "EnterPin":
return paymentstatus.EnterPIN
case "RemoveCard":
return paymentstatus.RemoveCard
case "PleaseWait":
return paymentstatus.PleaseWait
default:
return dojoFallbackStatusCode(
paymentstatus.DojoNotificationPrefix,
notification,
)
}
}
func mapDojoSessionStatus(status string) string {
switch strings.ToLower(status) {
case types.ResultInitiateRequested:
return paymentstatus.Starting
case types.ResultInitiated:
return paymentstatus.Started
case types.ResultAuthorized:
return paymentstatus.Authorized
case types.ResultCancelRequested:
return paymentstatus.Cancelling
case types.ResultSignatureRequired:
return paymentstatus.SignatureRequired
case types.ResultCaptured, types.ResultSignatureAccepted:
return paymentstatus.Approved
case types.ResultCancelled, types.ResultCanceled:
return paymentstatus.Cancelled
case types.ResultDeclined:
return paymentstatus.Declined
case types.ResultExpired:
return paymentstatus.Expired
case types.ResultSignatureRejected:
return paymentstatus.SignatureRejected
default:
return dojoFallbackStatusCode(
paymentstatus.DojoStatusPrefix,
status,
)
}
}
func dojoFallbackStatusCode(prefix, value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
replacer := strings.NewReplacer(
" ", "_",
"-", "_",
)
return prefix + strings.ToUpper(replacer.Replace(value))
}
func (c *Client) rejectSignature(ctx context.Context, terminalSessionID string) (*terminalSessionResponse, error) {
payload := signatureVerificationRequest{
Accepted: false,
}
var response terminalSessionResponse
path := "/terminal-sessions/" + url.PathEscape(terminalSessionID) + "/signature"
if err := c.doJSON(ctx, http.MethodPut, path, payload, true, &response); err != nil {
return nil, fmt.Errorf("reject Dojo signature verification: %w", err)
}
return &response, nil
}
func (c *Client) getTerminalSession(
ctx context.Context,
terminalSessionID string,
) (*terminalSessionWithUpdates, error) {
var response terminalSessionWithUpdates
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
result.CustomerReceipt = receiptToText(session.Receipt)
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.Last4PAN
if result.LastFourDigits == "" {
result.LastFourDigits = lastFour(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)
}
// log.Println("Dojo payment result raw:", string(responseBody))
if resp.StatusCode < http.StatusOK ||
resp.StatusCode >= http.StatusMultipleChoices {
return &httpResponseError{
StatusCode: resp.StatusCode,
Body: 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 receiptToText(receipt *receipt) string {
if receipt == nil {
return ""
}
var builder strings.Builder
for _, line := range receipt.Lines {
if line.LineType != "Text" || line.Text == nil {
continue
}
builder.WriteString(normalizeReceiptText(line.Text.Value))
builder.WriteByte('\n')
}
return builder.String()
}
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:])
}
func normalizeReceiptText(value string) string {
replacer := strings.NewReplacer(
"£", "GBP ",
"€", "EUR ",
"$", "USD ",
)
return replacer.Replace(value)
}

View File

@ -1,199 +0,0 @@
package dojo
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync/atomic"
"testing"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
)
const terminalUnavailableBody = `{"detail":"the terminal is either offline or currently in use","errors":{},"status":409,"title":"terminal unavailable","traceId":"trace-secret","type":"https://docs.dojo.tech/problems/terminal-unavailable"}`
func TestDoJSONReturnsStructuredHTTPResponseError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(" " + terminalUnavailableBody + " "))
}))
defer server.Close()
client := newDojoTestClient(t, server)
err := client.doJSON(context.Background(), http.MethodPost, "/payment-intents", nil, false, nil)
var responseErr *httpResponseError
if !errors.As(err, &responseErr) {
t.Fatalf("error = %T %v, want *httpResponseError", err, err)
}
if responseErr.StatusCode != http.StatusConflict {
t.Fatalf("StatusCode = %d, want %d", responseErr.StatusCode, http.StatusConflict)
}
if responseErr.Body != terminalUnavailableBody {
t.Fatalf("Body = %q, want retained trimmed response", responseErr.Body)
}
}
func TestSaleMapsCreateTerminalSessionConflictToTerminalUnavailable(t *testing.T) {
var paymentIntentRequests atomic.Int32
var terminalSessionRequests atomic.Int32
var unexpectedRequests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/payment-intents":
paymentIntentRequests.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintln(w, `{"id":"intent-123"}`)
case r.Method == http.MethodPost && r.URL.Path == "/terminal-sessions":
terminalSessionRequests.Add(1)
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusConflict)
_, _ = fmt.Fprintln(w, terminalUnavailableBody)
default:
unexpectedRequests.Add(1)
http.Error(w, "unexpected request", http.StatusInternalServerError)
}
}))
defer server.Close()
client := newDojoTestClient(t, server)
request := paymentsvc.SaleRequest{
RequestID: "REQ-123",
Reference: "BOOKING-123",
Amount: 10852,
Currency: "GBP",
}
var statuses []string
result, err := client.Sale(context.Background(), request, func(update paymentsvc.StatusUpdate) {
statuses = append(statuses, update.Code)
})
if err != nil {
t.Fatal(err)
}
if result == nil {
t.Fatal("Sale returned nil result")
}
if result.Success || result.Status == "APPROVED" {
t.Fatalf("result was accidentally successful: %+v", result)
}
if result.Status != dojoTerminalUnavailableStatus || result.ErrorMessage != dojoTerminalUnavailableMessage {
t.Fatalf("terminal-unavailable result = %+v", result)
}
if result.RequestID != request.RequestID ||
result.Operation != "SALE" ||
result.Amount != request.Amount ||
result.Currency != request.Currency ||
result.DeviceUsed != "terminal-1" ||
result.DeviceType != "Dojo Terminal" {
t.Fatalf("base result fields were not retained: %+v", result)
}
if result.TransactionID != "" || result.CustomerReceipt != "" || result.MerchantReceipt != "" {
t.Fatalf("terminal-unavailable result invented transaction data: %+v", result)
}
if !reflect.DeepEqual(statuses, []string{paymentstatus.Starting, paymentstatus.TerminalUnavailable}) {
t.Fatalf("statuses = %#v", statuses)
}
if paymentIntentRequests.Load() != 1 || terminalSessionRequests.Load() != 1 || unexpectedRequests.Load() != 0 {
t.Fatalf(
"requests: intent=%d terminal=%d unexpected=%d",
paymentIntentRequests.Load(),
terminalSessionRequests.Load(),
unexpectedRequests.Load(),
)
}
resultText := fmt.Sprintf("%+v", result)
for _, forbidden := range []string{"offline or currently in use", "trace-secret", "docs.dojo.tech", terminalUnavailableBody} {
if strings.Contains(resultText, forbidden) {
t.Fatalf("result exposed %q: %s", forbidden, resultText)
}
}
}
func TestSaleDoesNotClassifyPaymentIntentConflictAsTerminalUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/payment-intents" {
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusConflict)
_, _ = fmt.Fprintln(w, terminalUnavailableBody)
}))
defer server.Close()
client := newDojoTestClient(t, server)
var statuses []string
result, err := client.Sale(context.Background(), paymentsvc.SaleRequest{
RequestID: "REQ-123",
Amount: 10852,
Currency: "GBP",
}, func(update paymentsvc.StatusUpdate) {
statuses = append(statuses, update.Code)
})
if result != nil || err == nil {
t.Fatalf("Sale result/error = %+v/%v, want nil generic error", result, err)
}
var responseErr *httpResponseError
if !errors.As(err, &responseErr) || responseErr.StatusCode != http.StatusConflict {
t.Fatalf("error = %T %v, want wrapped HTTP 409", err, err)
}
if !reflect.DeepEqual(statuses, []string{paymentstatus.Starting}) {
t.Fatalf("statuses = %#v, want only PAYMENT_STARTING", statuses)
}
}
func TestSaleRetainsGenericCreateTerminalSessionErrors(t *testing.T) {
for _, statusCode := range []int{http.StatusBadRequest, http.StatusBadGateway} {
t.Run(http.StatusText(statusCode), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/payment-intents":
_, _ = fmt.Fprintln(w, `{"id":"intent-123"}`)
case "/terminal-sessions":
w.WriteHeader(statusCode)
_, _ = fmt.Fprintln(w, `{"detail":"provider failure"}`)
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
client := newDojoTestClient(t, server)
result, err := client.Sale(context.Background(), paymentsvc.SaleRequest{
RequestID: "REQ-123",
Amount: 10852,
Currency: "GBP",
}, nil)
if result != nil || err == nil {
t.Fatalf("Sale result/error = %+v/%v, want nil generic error", result, err)
}
var responseErr *httpResponseError
if !errors.As(err, &responseErr) || responseErr.StatusCode != statusCode {
t.Fatalf("error = %T %v, want wrapped HTTP %d", err, err, statusCode)
}
})
}
}
func newDojoTestClient(t *testing.T, server *httptest.Server) *Client {
t.Helper()
client, err := NewClient(Config{
BaseURL: server.URL,
APIKey: "test-api-key",
SoftwareHouseID: "software-house-1",
TerminalID: "terminal-1",
})
if err != nil {
t.Fatal(err)
}
client.httpClient = server.Client()
return client
}

View File

@ -1,81 +0,0 @@
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 signatureVerificationRequest struct {
Accepted bool `json:"accepted"`
}
type terminalSessionResponse struct {
ID string `json:"id"`
Status string `json:"status"`
TerminalID string `json:"terminalId"`
PaymentDetails *paymentDetails `json:"paymentDetails"`
Receipt *receipt `json:"receipt,omitempty"`
}
type receipt struct {
Lines []receiptLine `json:"lines"`
}
type receiptLine struct {
LineType string `json:"lineType"`
Text *receiptText `json:"text,omitempty"`
}
type receiptText struct {
Align string `json:"align"`
EmphasisBold bool `json:"emphasisBold"`
Size string `json:"size"`
Value string `json:"value"`
}
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"`
Last4PAN string `json:"last4PAN"`
EntryMode string `json:"entryMode"`
VerificationMethod string `json:"verificationMethod"`
}

View File

@ -1,53 +0,0 @@
package handlers
import (
"context"
"net/http"
"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"
)
type creditCallPreauthRequest struct {
Amount string `json:"amount"`
TransactionType string `json:"transactionType"`
CheckoutDate string `json:"checkoutDate"`
}
func (app *App) streamCreditCallPreauth(w http.ResponseWriter, r *http.Request) {
app.streamCreditCall(w, r, true)
}
// executeCreditCallPreauth finalizes the existing unconfirmed transaction.
// The outcome container and its wire projections are shared with SALE; its execution is not.
func (app *App) executeCreditCallPreauth(ctx context.Context, request cmstypes.TransactionRec,
start func(*http.Client) (creditcall.TransactionResultXML, error)) creditCallSaleOutcome {
const op = logging.Op("takePreauthorization")
outcome := creditCallSaleOutcome{
HTTPStatus: http.StatusBadGateway,
Status: cmstypes.StatusRec{Code: http.StatusInternalServerError, Message: "500 Internal server error"},
}
transaction, err := start(app.creditCallClient())
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Preauth processing error", string(op), "", "", 0)
outcome.FailureType = types.ResultError
outcome.FailureDescription = "No response from payment processor"
return outcome
}
var result creditcall.PaymentResult
result.FillFromTransactionResult(transaction)
app.printCreditCallReceipt(result.CardholderReceipt)
approved, persist := creditcall.PreauthDecision(result.Fields)
outcome.HTTPStatus = http.StatusOK
outcome.Status = result.Status
outcome.Payment = result
outcome.Approved = approved
outcome.FailureType = result.Fields[types.TransactionResult]
outcome.FailureDescription = result.Fields[types.Errors]
if persist {
go app.persistPreauth(ctx, result.Fields, request.CheckoutDate)
}
return outcome
}

View File

@ -1,393 +0,0 @@
package handlers
import (
"context"
"database/sql"
"database/sql/driver"
"encoding/json"
"encoding/xml"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
)
type preauthTestConnector struct {
inserts chan []driver.NamedValue
count atomic.Int32
}
func (c *preauthTestConnector) Connect(context.Context) (driver.Conn, error) {
return &preauthTestConn{c}, nil
}
func (c *preauthTestConnector) Driver() driver.Driver { return preauthTestDriver{c} }
type preauthTestDriver struct{ c *preauthTestConnector }
func (d preauthTestDriver) Open(string) (driver.Conn, error) { return &preauthTestConn{d.c}, nil }
type preauthTestConn struct{ c *preauthTestConnector }
func (*preauthTestConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("unexpected prepare")
}
func (*preauthTestConn) Close() error { return nil }
func (*preauthTestConn) Begin() (driver.Tx, error) { return nil, errors.New("unexpected begin") }
func (*preauthTestConn) Ping(context.Context) error { return nil }
func (c *preauthTestConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if !strings.Contains(query, "INSERT INTO dbo.Preauthorizations") {
return nil, errors.New("unexpected SQL")
}
c.c.count.Add(1)
c.c.inserts <- append([]driver.NamedValue(nil), args...)
return driver.RowsAffected(1), nil
}
func preauthTestDatabase(t *testing.T, app *App) *preauthTestConnector {
t.Helper()
c := &preauthTestConnector{inserts: make(chan []driver.NamedValue, 4)}
app.db = sql.OpenDB(c)
app.cfg.LogDir = t.TempDir()
t.Cleanup(func() { app.db.Close() })
return c
}
func waitPreauthInsert(t *testing.T, c *preauthTestConnector) map[string]any {
t.Helper()
select {
case args := <-c.inserts:
result := map[string]any{}
for _, arg := range args {
result[arg.Name] = arg.Value
}
return result
case <-time.After(3 * time.Second):
t.Fatal("preauth persistence did not reach SQL")
}
return nil
}
func preauthLegacyRequest(amount, kind, checkout string) *http.Request {
body, _ := xml.Marshal(cmstypes.TransactionRec{AmountMinorUnits: amount, TransactionType: kind, CheckoutDate: checkout})
r := httptest.NewRequest(http.MethodPost, "/takepreauth", strings.NewReader(string(body)))
r.Header.Set("Content-Type", "text/xml")
return r
}
func TestCreditCallPreauthLegacyCharacterization(t *testing.T) {
for _, tc := range []struct {
name, amount, requestType, result, resultType, total string
save, approved bool
}{
{"positive", "12000", "Sale", "APPROVED", "SALE", "3100", true, true},
{"declined", "12000", "Sale", "DECLINED", "SALE", "", false, false},
{"verification", "", "AccountVerification", "APPROVED", "ACCOUNT VERIFICATION", "", false, true},
{"verification failure", "", "AccountVerification", "DECLINED", "ACCOUNT VERIFICATION", "", false, false},
{"unexpected type", "12000", "Sale", "APPROVED", "Refund", "", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
var starts, prints int
checkout := ""
if tc.amount != "" {
checkout = "2026-09-11 00:00:00 +0000"
}
fields := map[string]string{types.TransactionResult: tc.result, types.TransactionType: tc.resultType, types.Reference: "preauth-ref", types.PanMasked: "************1133", types.CardType: "Visa", types.ExpiryDate: "1228", types.CardHash: "card-hash", types.CardReference: "card-reference", types.ReceiptDataCardholder: "receipt"}
if tc.total != "" {
fields[types.TotalAmount] = tc.total
}
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
starts++
if r.URL.Path != "/start-transaction/" {
t.Errorf("PREAUTH upstream path=%s, want start only", r.URL.Path)
}
var input cmstypes.TransactionRec
if err := xml.NewDecoder(r.Body).Decode(&input); err != nil {
t.Error(err)
}
if input.AmountMinorUnits != tc.amount || input.TransactionType != tc.requestType || input.CheckoutDate != checkout {
t.Errorf("PREAUTH input=%+v, want amount=%q type=%q checkout=%q", input, tc.amount, tc.requestType, checkout)
}
// Legacy ignores upstream HTTP status when XML contains a result.
w.WriteHeader(http.StatusBadGateway)
io.WriteString(w, chipDNAFixture(t, fields))
})
c := preauthTestDatabase(t, app)
app.creditCallReceipt = func(receipt string) {
prints++
if receipt != "receipt" {
t.Errorf("receipt=%q, want receipt", receipt)
}
}
recorder := httptest.NewRecorder()
app.takePreauthorization(recorder, preauthLegacyRequest(tc.amount, tc.requestType, checkout))
var response cmstypes.ResponseRec
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if recorder.Code != 200 || response.Status.Code != 200 || starts != 1 || prints != 1 || strings.HasPrefix(response.Data, "/successful?") != tc.approved {
t.Errorf("PREAUTH response=%+v HTTP=%d starts=%d prints=%d, want approved=%t one start/receipt", response, recorder.Code, starts, prints, tc.approved)
}
if tc.save {
got := waitPreauthInsert(t, c)
if got["TotalMinorUnits"] != "3100" || got["TxnReference"] != "preauth-ref" {
t.Errorf("persisted=%v, want provider amount/reference", got)
}
departure := time.Date(2026, 9, 11, 0, 0, 0, 0, time.Local).UTC()
if got["DepartureDate"] != departure || got["ReleaseDate"] != departure.Add(48*time.Hour) {
t.Errorf("persisted dates=%v, want existing local midnight plus 48 hours", got)
}
} else if c.count.Load() != 0 {
t.Error("non-persisting result inserted SQL")
}
})
}
}
func preauthStreamRequest(amount, kind, checkout string) *http.Request {
body, _ := json.Marshal(creditCallPreauthRequest{Amount: amount, TransactionType: kind, CheckoutDate: checkout})
r := httptest.NewRequest(http.MethodPost, "/api/payment/preauth", strings.NewReader(string(body)))
r.Header.Set("Content-Type", "application/json")
return r
}
func TestCreditCallPreauthStreamingParity(t *testing.T) {
for _, tc := range []struct {
name, result, kind, total string
approved, persist bool
}{
{"monetary", "APPROVED", "SALE", "3100", true, true},
{"verification", "APPROVED", "ACCOUNT VERIFICATION", "", true, false},
{"declined", "DECLINED", "SALE", "", false, false},
{"verification failed", "DECLINED", "ACCOUNT VERIFICATION", "", false, false},
{"unknown approved type", "APPROVED", "AccountVerification", "", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
fields := map[string]string{types.TransactionResult: tc.result, types.TransactionType: tc.kind, types.Reference: "preauth-ref", types.PanMasked: "************1133", types.CardType: "Visa", types.ExpiryDate: "1228", types.CardHash: "hash", types.CardReference: "cardref", types.ReceiptDataCardholder: "receipt"}
if tc.total != "" {
fields[types.TotalAmount] = tc.total
}
amount, kind, date := "12000", "Sale", "2026-09-11 00:00:00 +0000"
if strings.Contains(tc.name, "verification") {
amount, kind, date = "", "AccountVerification", ""
}
var legacy cmstypes.ResponseRec
for _, stream := range []bool{false, true} {
calls, prints := 0, 0
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if stream {
if r.URL.Path != "/start-transaction-stream/" {
t.Errorf("stream called %s, want one generic start", r.URL.Path)
}
var input creditCallPreauthRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
t.Error(err)
}
if input.Amount != amount || input.TransactionType != kind {
t.Errorf("upstream input=%+v, want %q/%q", input, amount, kind)
}
for _, event := range []struct{ source, value string }{{"UPDATE", "CardRequested"}, {"CARD_STATUS", "Inserted"}, {"UPDATE", "CardRemovalRequested"}, {"CARD_STATUS", "Removed"}, {"UPDATE", "CardRequested"}, {"UPDATE", "CardRemovalEnforced"}, {"UPDATE", "PinEntryStarted"}, {"UPDATE", "OnlineAuthCompleted"}} {
json.NewEncoder(w).Encode(map[string]any{"type": "status", "source": event.source, "value": event.value})
}
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
} else {
io.WriteString(w, chipDNAFixture(t, fields))
}
})
c := preauthTestDatabase(t, app)
app.creditCallReceipt = func(receipt string) {
prints++
if receipt != "receipt" {
t.Errorf("receipt=%q, want retained receipt", receipt)
}
}
recorder := httptest.NewRecorder()
if stream {
app.streamCreditCallPreauth(recorder, preauthStreamRequest(amount, kind, date))
frames := decodePaymentStream(t, recorder.Body)
var statuses []string
for _, frame := range frames {
if frame.Type == "status" {
statuses = append(statuses, frame.Code)
}
}
want := []string{paymentstatus.PresentCard, paymentstatus.DoNotRemoveCard, paymentstatus.RemoveCard, paymentstatus.PresentCard, paymentstatus.RemoveCard, paymentstatus.EnterPIN, paymentstatus.PleaseWait}
if !reflect.DeepEqual(statuses, want) {
t.Errorf("preauth statuses=%v, want %v", statuses, want)
}
final := frames[len(frames)-1].Result
if final == nil {
t.Fatal("missing structured final")
}
if (final.Outcome == "approved") != tc.approved || final.Status != legacy.Status || final.HTTPStatus != 200 {
t.Errorf("stream final=%+v, legacy=%+v", final, legacy)
}
if tc.approved && (final.TransactionReference != "preauth-ref" || final.CardType != "Visa" || final.MaskedCardNumber != "************1133" || final.ExpiryDate != "1228" || final.CardHash != "hash" || final.CardReference != "cardref") {
t.Errorf("preauth fields lost: %+v", final)
}
if len(frames) != len(want)+1 {
t.Errorf("frames=%d, want one final", len(frames))
}
} else {
app.takePreauthorization(recorder, preauthLegacyRequest(amount, kind, date))
if err := json.Unmarshal(recorder.Body.Bytes(), &legacy); err != nil {
t.Fatal(err)
}
}
if calls != 1 || prints != 1 {
t.Errorf("calls/prints=%d/%d, want 1/1", calls, prints)
}
if tc.persist {
got := waitPreauthInsert(t, c)
if got["TotalMinorUnits"] != tc.total {
t.Errorf("SQL amount=%v, want provider %q", got["TotalMinorUnits"], tc.total)
}
} else if c.count.Load() != 0 {
t.Error("verification/failure persisted")
}
}
})
}
}
func TestCreditCallInboundCancellationAfterDispatch(t *testing.T) {
for _, preauth := range []bool{false, true} {
t.Run(map[bool]string{false: "sale", true: "preauth"}[preauth], func(t *testing.T) {
dispatched := make(chan struct{})
release := make(chan struct{})
var starts, confirms, receipts atomic.Int32
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/confirm-transaction/" {
confirms.Add(1)
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: "APPROVED", types.ReceiptDataCardholder: "receipt"}))
return
}
starts.Add(1)
close(dispatched)
<-release
if r.Context().Err() != nil {
t.Errorf("financial upstream was cancelled: %v", r.Context().Err())
return
}
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": map[string]string{types.TransactionResult: "APPROVED", types.TransactionType: "SALE", types.TotalAmount: "3100", types.Reference: "delayed-ref", types.ReceiptDataCardholder: "receipt"}})
})
c := preauthTestDatabase(t, app)
app.creditCallReceipt = func(receipt string) {
if receipt != "receipt" {
t.Errorf("receipt=%q", receipt)
}
receipts.Add(1)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
recorder := httptest.NewRecorder()
done := make(chan struct{})
go func() {
defer close(done)
if preauth {
app.streamCreditCallPreauth(recorder, preauthStreamRequest("12000", "Sale", "2026-09-11 00:00:00 +0000").WithContext(ctx))
} else {
app.streamCreditCallSale(recorder, saleRequest(true).WithContext(ctx))
}
}()
select {
case <-dispatched:
case <-time.After(3 * time.Second):
close(release)
t.Fatal("no upstream dispatch")
}
cancel() // Exact acceptance boundary: upstream has observed StartTransaction.
close(release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("cancelled inbound request stopped finalization")
}
wantConfirms := int32(1)
if preauth {
wantConfirms = 0
got := waitPreauthInsert(t, c)
if got["TotalMinorUnits"] != "3100" {
t.Errorf("delayed persistence=%v", got)
}
if c.count.Load() != 1 {
t.Errorf("delayed persistence count=%d, want exactly one", c.count.Load())
}
}
if starts.Load() != 1 || receipts.Load() != 1 || confirms.Load() != wantConfirms {
t.Errorf("starts/receipts/confirms=%d/%d/%d, want 1/1/%d", starts.Load(), receipts.Load(), confirms.Load(), wantConfirms)
}
if recorder.Body.Len() != 0 {
t.Errorf("cancelled delivery wrote %s", recorder.Body.String())
}
})
}
}
func TestCreditCallPreauthMissingAmountNeverSynthesized(t *testing.T) {
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": map[string]string{types.TransactionResult: "APPROVED", types.TransactionType: "SALE", types.Reference: "missing-amount"}})
})
c := preauthTestDatabase(t, app)
app.creditCallReceipt = func(string) {}
recorder := httptest.NewRecorder()
app.streamCreditCallPreauth(recorder, preauthStreamRequest("9999", "Sale", "2026-09-11 00:00:00 +0000"))
frames := decodePaymentStream(t, recorder.Body)
if frames[0].Result.Outcome != "approved" {
t.Fatalf("missing amount changed approval: %+v", frames[0].Result)
}
deadline := time.Now().Add(3 * time.Second)
for {
data, err := os.ReadFile(app.spoolPath())
var record preauthSpoolRecord
if err == nil && json.Unmarshal(data, &record) == nil {
if _, ok := record.Fields[types.TotalAmount]; ok {
t.Errorf("spool fabricated amount: %v", record.Fields)
}
if record.CheckoutDate != "2026-09-11 00:00:00 +0000" {
t.Errorf("spool checkout=%q", record.CheckoutDate)
}
break
}
if time.Now().After(deadline) {
t.Fatal("missing provider amount did not retain existing spool fallback")
}
time.Sleep(time.Millisecond)
}
if c.count.Load() != 0 {
t.Error("SQL inserted fabricated amount")
}
}
func TestCreditCallPreauthStreamFailureAndCancelledBeforeDispatch(t *testing.T) {
for _, body := range []string{"", "{", "{}\n", "<xml/>\n", "{\"type\":\"result\",\"result\":{}}\n", "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"APPROVED\"}}"} {
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, body) })
calls := 0
transport := app.creditCallTransport
app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) { calls++; return transport.RoundTrip(r) })
app.creditCallReceipt = func(string) { t.Error("malformed stream printed receipt") }
recorder := httptest.NewRecorder()
app.streamCreditCallPreauth(recorder, preauthStreamRequest("", "AccountVerification", ""))
frames := decodePaymentStream(t, recorder.Body)
if calls != 1 || len(frames) != 1 || frames[0].Result.Outcome != "error" || frames[0].Result.HTTPStatus != 502 {
t.Errorf("invalid stream %q: calls=%d frames=%+v", body, calls, frames)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
app.streamCreditCallPreauth(httptest.NewRecorder(), preauthStreamRequest("", "AccountVerification", "").WithContext(ctx))
if calls != 1 {
t.Error("already-cancelled request dispatched a transaction")
}
}
}

View File

@ -1,130 +0,0 @@
package handlers
import (
"net/http"
"strings"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
"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"
)
// creditCallSaleOutcome is the transaction outcome before either wire format is applied.
type creditCallSaleOutcome struct {
HTTPStatus int
Status cmstypes.StatusRec
Payment creditcall.PaymentResult
Approved bool
FailureType string
FailureDescription string
}
func (app *App) creditCallClient() *http.Client {
// Each start/confirm call gets the original independent timeout. In particular,
// neither a kiosk disconnect nor a whole-sale deadline bounds confirmation.
return &http.Client{Timeout: 300 * time.Second, Transport: app.creditCallTransport}
}
func (app *App) printCreditCallReceipt(receipt string) {
if app.creditCallReceipt != nil {
app.creditCallReceipt(receipt)
return
}
printer.PrintReceipt(receipt)
}
func (app *App) executeCreditCallSale(
request cmstypes.TransactionRec,
start func(*http.Client) (creditcall.TransactionResultXML, error),
) creditCallSaleOutcome {
const op = logging.Op("takePayment")
outcome := creditCallSaleOutcome{
HTTPStatus: http.StatusBadGateway,
Status: cmstypes.StatusRec{Code: http.StatusInternalServerError, Message: "500 Internal server error"},
}
client := app.creditCallClient()
trResult, err := start(client)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Start transaction error", string(op), "", "", 0)
outcome.FailureType = types.ResultError
outcome.FailureDescription = "No response from payment processor"
return outcome
}
var result creditcall.PaymentResult
result.FillFromTransactionResult(trResult)
res := result.Fields[types.TransactionResult]
if !strings.EqualFold(res, types.ResultApproved) {
app.printCreditCallReceipt(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)
outcome.HTTPStatus = http.StatusOK
outcome.Status = result.Status
outcome.Payment = result
outcome.FailureType = res
outcome.FailureDescription = result.Fields[types.Errors]
return outcome
}
ref := result.Fields[types.Reference]
log.Printf("Preauth approved, reference: %s. Sending confirm...", ref)
body, err := confirmWithRetry(client, creditcall.ConfirmTransactionRequest{
Amount: request.AmountMinorUnits, Reference: ref,
}, 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())
outcome.FailureType = types.ResultError
outcome.FailureDescription = "ConfirmTransactionError"
return outcome
}
// Intentionally reuse both records: XML appends confirmation entries, and
// missing confirmation fields/receipts retain their existing legacy behavior.
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) {
app.printCreditCallReceipt(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)
outcome.HTTPStatus = http.StatusOK
outcome.Status = result.Status
outcome.Payment = result
outcome.FailureType = res
outcome.FailureDescription = result.Fields[types.Errors]
return outcome
}
app.printCreditCallReceipt(result.CardholderReceipt)
log.Printf("Transaction approved and confirmed, reference: %s", ref)
outcome.HTTPStatus = http.StatusOK
outcome.Status = result.Status
outcome.Payment = result
outcome.Approved = true
return outcome
}
func (outcome creditCallSaleOutcome) legacyResponse() cmstypes.ResponseRec {
response := cmstypes.ResponseRec{Status: outcome.Status}
if outcome.Approved {
response.Data = creditcall.BuildSuccessURL(outcome.Payment.Fields)
} else {
response.Data = creditcall.BuildFailureURL(outcome.FailureType, outcome.FailureDescription)
}
return response
}

View File

@ -1,480 +0,0 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"encoding/xml"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"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/types"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
)
type failedCreditCallBody struct{ closed *bool }
func (b failedCreditCallBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
func (b failedCreditCallBody) Close() error { *b.closed = true; return nil }
func TestCreditCallConfirmationReadFailureAndHTTPStatus(t *testing.T) {
for _, failFirstRead := range []bool{false, true} {
calls, receipts, firstClosed := 0, 0, false
app := &App{cfg: &config.ConfigRec{}, creditCallReceipt: func(string) { receipts++ }}
app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
calls++
if r.URL.Path != "/confirm-transaction/" {
t.Errorf("confirmation path=%s", r.URL.Path)
}
deadline, ok := r.Context().Deadline()
if !ok || time.Until(deadline) < 299*time.Second {
t.Errorf("confirmation call has no fresh 300-second deadline: %v", deadline)
}
var body io.ReadCloser = io.NopCloser(strings.NewReader(chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved"})))
if failFirstRead && calls == 1 {
body = failedCreditCallBody{closed: &firstClosed}
}
// Legacy confirmation ignores HTTP status when the body can be read.
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: body}, nil
})
outcome := app.executeCreditCallSale(cmstypes.TransactionRec{AmountMinorUnits: "1234"}, func(*http.Client) (creditcall.TransactionResultXML, error) {
return creditcall.TransactionResultXML{Entries: []creditcall.EntryXML{{Key: types.TransactionResult, Value: "Approved"}, {Key: types.Reference, Value: "ref"}}}, nil
})
wantCalls := 1
if failFirstRead {
wantCalls = 2
}
if calls != wantCalls || receipts != 1 || !outcome.Approved || (failFirstRead && !firstClosed) {
t.Errorf("readFailure=%v calls/receipts/approved/closed=%d/%d/%v/%v", failFirstRead, calls, receipts, outcome.Approved, firstClosed)
}
}
}
func TestCreditCallStreamFailureNeverRestartsOrConfirms(t *testing.T) {
calls := 0
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/start-transaction-stream/" {
t.Errorf("unexpected fallback/confirmation %s", r.URL.Path)
}
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n{partial")
})
app.creditCallReceipt = func(string) { t.Error("invalid upstream stream printed receipt") }
recorder := httptest.NewRecorder()
app.salePayment(recorder, saleRequest(true))
frames := decodePaymentStream(t, recorder.Body)
if calls != 1 || len(frames) != 2 || frames[1].Result == nil || frames[1].Result.Outcome != "error" || frames[1].Result.HTTPStatus != 502 {
t.Errorf("malformed stream calls=%d frames=%+v", calls, frames)
}
}
func TestCreditCallStructuredFieldsExcludeRawData(t *testing.T) {
result := (creditCallSaleOutcome{Approved: true, Payment: creditcall.PaymentResult{Fields: map[string]string{
types.Reference: "ref", types.PanMasked: "1234567890123456", types.CardHash: "hash", types.CardReference: "card-ref",
"PAN": "pan-secret", "CVV": "cvv-secret", "PIN": "pin-secret", "TRACK_DATA": "track-secret", types.ReceiptDataCardholder: "receipt-secret",
}}}).streamResult()
body, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"1234567890123456", "pan-secret", "cvv-secret", "pin-secret", "track-secret", "receipt-secret", "/successful", "/unsuccessful"} {
if bytes.Contains(body, []byte(forbidden)) {
t.Errorf("structured result exposed %q", forbidden)
}
}
if result.CardHash != "hash" || result.CardReference != "card-ref" || result.TransactionReference != "ref" {
t.Errorf("required identifiers missing: %+v", result)
}
}
type blockedPaymentWriter struct {
*httptest.ResponseRecorder
started chan struct{}
release chan struct{}
once sync.Once
}
func (w *blockedPaymentWriter) Write(data []byte) (int, error) {
w.once.Do(func() { close(w.started) })
<-w.release
return w.ResponseRecorder.Write(data)
}
func TestCreditCallBackpressureCannotBlockFinalizationOrLoseFinal(t *testing.T) {
nativeRelease, receiptPrinted := make(chan struct{}), make(chan struct{})
var nativeOnce, deliveryOnce sync.Once
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/confirm-transaction/" {
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved"}))
return
}
line := "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n"
io.WriteString(w, line)
w.(http.Flusher).Flush()
<-nativeRelease
for i := 0; i < 1000; i++ {
io.WriteString(w, line)
}
io.WriteString(w, "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"ref\"}}\n")
})
app.creditCallReceipt = func(string) { close(receiptPrinted) }
writer := &blockedPaymentWriter{ResponseRecorder: httptest.NewRecorder(), started: make(chan struct{}), release: make(chan struct{})}
t.Cleanup(func() {
nativeOnce.Do(func() { close(nativeRelease) })
deliveryOnce.Do(func() { close(writer.release) })
})
done := make(chan struct{})
go func() { app.salePayment(writer, saleRequest(true)); close(done) }()
select {
case <-writer.started:
case <-time.After(5 * time.Second):
t.Fatal("progress was not live")
}
nativeOnce.Do(func() { close(nativeRelease) })
select {
case <-receiptPrinted:
case <-time.After(5 * time.Second):
t.Fatal("blocked writer stopped financial finalization")
}
deliveryOnce.Do(func() { close(writer.release) })
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("stream did not deliver final")
}
frames := decodePaymentStream(t, writer.Body)
if len(frames) != 130 || frames[len(frames)-1].Result == nil || frames[len(frames)-1].Result.Outcome != "approved" {
t.Errorf("backpressure frames=%d, last=%+v, want 129 statuses and final approval", len(frames), frames[len(frames)-1])
}
}
type creditCallRoundTrip func(*http.Request) (*http.Response, error)
func (f creditCallRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func chipDNAFixture(t *testing.T, fields map[string]string) string {
t.Helper()
var result creditcall.TransactionResultXML
for key, value := range fields {
result.Entries = append(result.Entries, creditcall.EntryXML{Key: key, Value: value})
}
data, err := xml.Marshal(result)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func newCreditCallTestApp(t *testing.T, handler http.HandlerFunc) *App {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
target, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
return &App{isPayment: true, creditCallStreamEnabled: true, cfg: &config.ConfigRec{TimeoutSeconds: 1},
creditCallTransport: creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
clone := r.Clone(r.Context())
clone.URL.Scheme, clone.URL.Host = target.Scheme, target.Host
return server.Client().Transport.RoundTrip(clone)
}),
}
}
func saleRequest(stream bool) *http.Request {
if stream {
r := httptest.NewRequest(http.MethodPost, "/api/payment/sale", strings.NewReader(`{"amount":1234}`))
r.Header.Set("Content-Type", "application/json")
return r
}
r := httptest.NewRequest(http.MethodPost, "/takepayment", strings.NewReader(`<TransactionPayload><Amount>1234</Amount><TransactionType>Sale</TransactionType></TransactionPayload>`))
r.Header.Set("Content-Type", "text/xml")
return r
}
func TestCreditCallLegacyAndStreamingSaleParity(t *testing.T) {
for _, test := range []struct {
name, startResult, confirmation string
confirms int
approved bool
receipt string
}{
{"approved", "Approved", "Approved", 1, true, "confirm receipt"},
{"declined", "Declined", "", 0, false, "start receipt"},
{"cancelled", "Cancelled", "", 0, false, "start receipt"},
{"timeout", "TIMEOUT", "", 0, false, "start receipt"},
{"confirmation declined", "Approved", "Declined", 1, false, "confirm receipt"},
{"confirmation omissions", "Approved", "omitted", 1, true, "start receipt"},
{"malformed confirmation", "Approved", "malformed", 1, true, "start receipt"},
} {
t.Run(test.name, func(t *testing.T) {
fields := map[string]string{types.TransactionResult: test.startResult, types.Reference: "native-ref",
types.CardType: "Visa", types.PanMasked: "************1234", types.ExpiryDate: "1228",
types.CardHash: "hash", types.CardReference: "card-ref", types.ReceiptDataCardholder: "start receipt"}
var legacy cmstypes.ResponseRec
for _, streaming := range []bool{false, true} {
var starts, confirms atomic.Int32
var receipts []string
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/start-transaction/":
starts.Add(1)
io.WriteString(w, chipDNAFixture(t, fields))
case "/start-transaction-stream/":
starts.Add(1)
var input struct{ Amount, TransactionType string }
if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.Amount != "1234" || input.TransactionType != "Sale" {
t.Errorf("stream start input = %+v, error %v", input, err)
}
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n")
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n")
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"Approved\"}\n")
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
case "/confirm-transaction/":
confirms.Add(1)
var input creditcall.ConfirmTransactionRequest
if err := xml.NewDecoder(r.Body).Decode(&input); err != nil || input.Amount != "1234" || input.Reference != "native-ref" {
t.Errorf("confirmation input = %+v, error %v", input, err)
}
switch test.confirmation {
case "omitted":
io.WriteString(w, "<TransactionResult/>")
case "malformed":
io.WriteString(w, "<TransactionResult><Entry>")
default:
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: test.confirmation, types.ReceiptDataCardholder: "confirm receipt"}))
}
default:
t.Errorf("unexpected ChipDNA path %q", r.URL.Path)
}
})
app.creditCallReceipt = func(receipt string) { receipts = append(receipts, receipt) }
if got := app.creditCallClient().Timeout; got != 300*time.Second {
t.Fatalf("per-call timeout = %v, want 300s", got)
}
recorder := httptest.NewRecorder()
if streaming {
app.salePayment(recorder, saleRequest(true))
} else {
app.takePayment(recorder, saleRequest(false))
}
if starts.Load() != 1 || int(confirms.Load()) != test.confirms {
t.Errorf("stream=%v starts/confirms=%d/%d, want 1/%d", streaming, starts.Load(), confirms.Load(), test.confirms)
}
if !reflect.DeepEqual(receipts, []string{test.receipt}) {
t.Errorf("stream=%v receipts=%v, want [%s]", streaming, receipts, test.receipt)
}
if !streaming {
if err := json.Unmarshal(recorder.Body.Bytes(), &legacy); err != nil {
t.Fatal(err)
}
if strings.HasPrefix(legacy.Data, "/successful") != test.approved {
t.Errorf("legacy result = %+v, approved want %v", legacy, test.approved)
}
continue
}
frames := decodePaymentStream(t, recorder.Body)
if len(frames) != 3 || frames[0].Code != paymentstatus.PresentCard || frames[1].Code != paymentstatus.PleaseWait || frames[2].Result == nil {
t.Fatalf("frames = %+v, want two ordered hints and one final", frames)
}
final := frames[2].Result
if final.HTTPStatus != http.StatusOK || final.Status != legacy.Status || (final.Outcome == "approved") != test.approved {
t.Errorf("structured final = %+v, legacy = %+v", final, legacy)
}
parsed, err := url.Parse(legacy.Data)
if err != nil {
t.Fatal(err)
}
if test.approved {
if final.TransactionReference != "native-ref" || final.CardType != "Visa" || final.MaskedCardNumber != "************1234" || final.CardHash != "hash" || final.CardReference != "card-ref" || final.ExpiryDate != "1228" {
t.Errorf("retained structured fields = %+v", final)
}
} else if final.Message != parsed.Query().Get("Description") {
t.Errorf("structured message = %q, legacy description = %q", final.Message, parsed.Query().Get("Description"))
}
if frames[2].Response != nil {
t.Error("CreditCall stream included legacy response")
}
}
})
}
}
func TestCreditCallConfirmationRetryAndFailureParity(t *testing.T) {
for _, streaming := range []bool{false, true} {
var attempts int
var times []time.Time
app := &App{isPayment: true, creditCallStreamEnabled: true, cfg: &config.ConfigRec{}}
app.creditCallReceipt = func(string) { t.Error("transport-failed confirmation must not print a receipt") }
app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
if r.Context().Err() != nil {
t.Error("transaction context was cancelled")
}
if r.URL.Path == "/confirm-transaction/" {
attempts++
times = append(times, time.Now())
return nil, errors.New("test transport failure")
}
body := chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved", types.Reference: "native-ref"})
if streaming {
body = "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"native-ref\"}}\n"
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})
recorder := httptest.NewRecorder()
if streaming {
app.salePayment(recorder, saleRequest(true))
} else {
app.takePayment(recorder, saleRequest(false))
}
if attempts != 2 {
t.Fatalf("stream=%v confirms=%d, want 2", streaming, attempts)
}
if times[1].Sub(times[0]) < 2*time.Second {
t.Errorf("retry delay=%v, want at least 2s", times[1].Sub(times[0]))
}
if streaming {
frames := decodePaymentStream(t, recorder.Body)
if len(frames) != 1 || frames[0].Result.HTTPStatus != 502 || frames[0].Result.Status.Code != 500 || frames[0].Result.Message != "Transaction error" {
t.Fatalf("confirmation failure frames=%+v", frames)
}
} else if recorder.Code != 502 {
t.Errorf("legacy confirm failure HTTP=%d, want 502", recorder.Code)
}
}
}
func TestCreditCallLegacyMalformedStartAndPreauth(t *testing.T) {
for _, test := range []struct{ name, path, result, transactionType string }{
{"malformed sale", "/takepayment", "malformed", ""},
{"approved account verification", "/takepreauth", "Approved", types.AccountVerificationType},
{"declined preauth", "/takepreauth", "Declined", "Sale"},
{"malformed preauth", "/takepreauth", "malformed", ""},
} {
t.Run(test.name, func(t *testing.T) {
calls, prints := 0, 0
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/start-transaction/" {
t.Errorf("legacy/preauth path=%s", r.URL.Path)
}
if test.result == "malformed" {
io.WriteString(w, "<TransactionResult><Entry>")
return
}
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: test.result, types.TransactionType: test.transactionType}))
})
app.creditCallReceipt = func(string) { prints++ }
recorder := httptest.NewRecorder()
request := saleRequest(false)
if test.path == "/takepreauth" {
app.takePreauthorization(recorder, request)
} else {
app.takePayment(recorder, request)
}
var response cmstypes.ResponseRec
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if recorder.Code != 200 || calls != 1 || prints != 1 {
t.Errorf("legacy HTTP/calls/receipts=%d/%d/%d", recorder.Code, calls, prints)
}
if test.result == "malformed" && (response.Status.Code != 0 || response.Data != "/unsuccessful?Description=&MsgType=") {
t.Errorf("malformed legacy result=%+v", response)
}
if test.result == "Approved" && !strings.HasPrefix(response.Data, "/successful?") {
t.Errorf("approved preauth=%+v", response)
}
})
}
}
func TestChipDNAStreamRejectsIncompleteOrInvalidFrames(t *testing.T) {
for _, body := range []string{"", "{", "{}\n", "<xml/>\n", "{\"type\":\"result\"}\n", "{\"type\":\"result\",\"result\":{}}\n", "{\"type\":\"error\",\"error\":\"sensitive diagnostic\"}\n", "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\"}}", "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"Approved\"}\n"} {
client := &http.Client{Transport: creditCallRoundTrip(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil
})}
_, err := callChipDNAStream(client, 1234, func(code string) { t.Errorf("invalid stream produced status %q", code) })
if err == nil {
t.Errorf("callChipDNAStream(%q) succeeded, want error", body)
}
if err != nil && strings.Contains(err.Error(), "sensitive diagnostic") {
t.Error("raw diagnostic leaked")
}
}
}
type disconnectedPaymentWriter struct {
header http.Header
once sync.Once
failed chan struct{}
writes atomic.Int32
}
func (w *disconnectedPaymentWriter) Header() http.Header { return w.header }
func (w *disconnectedPaymentWriter) WriteHeader(int) {}
func (w *disconnectedPaymentWriter) Flush() {}
func (w *disconnectedPaymentWriter) Write([]byte) (int, error) {
w.writes.Add(1)
w.once.Do(func() { close(w.failed) })
return 0, io.ErrClosedPipe
}
func TestCreditCallDisconnectStillConfirmsAndPrints(t *testing.T) {
release := make(chan struct{})
var confirms atomic.Int32
var receipts atomic.Int32
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/confirm-transaction/" {
confirms.Add(1)
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved", types.ReceiptDataCardholder: "receipt"}))
return
}
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n")
w.(http.Flusher).Flush()
<-release
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n")
io.WriteString(w, "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"ref\"}}\n")
})
app.creditCallReceipt = func(receipt string) {
if receipt != "receipt" {
t.Errorf("receipt=%q", receipt)
}
receipts.Add(1)
}
writer := &disconnectedPaymentWriter{header: make(http.Header), failed: make(chan struct{})}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() { app.salePayment(writer, saleRequest(true).WithContext(ctx)); close(done) }()
select {
case <-writer.failed:
case <-time.After(5 * time.Second):
close(release)
t.Fatal("no live progress before final result")
}
cancel()
close(release)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("finalization stopped after disconnect")
}
if confirms.Load() != 1 || receipts.Load() != 1 || writer.writes.Load() != 1 {
t.Errorf("disconnect confirms/receipts/writes=%d/%d/%d, want 1/1/1", confirms.Load(), receipts.Load(), writer.writes.Load())
}
}

View File

@ -1,260 +0,0 @@
package handlers
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
)
type creditCallStreamResult struct {
Outcome string `json:"outcome"`
Message string `json:"message"`
HTTPStatus int `json:"httpStatus"`
Status cmstypes.StatusRec `json:"status"`
TransactionReference string `json:"transactionReference,omitempty"`
CardType string `json:"cardType,omitempty"`
MaskedCardNumber string `json:"maskedCardNumber,omitempty"`
ExpiryDate string `json:"expiryDate,omitempty"`
CardHash string `json:"cardHash,omitempty"`
CardReference string `json:"cardReference,omitempty"`
}
func (outcome creditCallSaleOutcome) streamResult() creditCallStreamResult {
result := creditCallStreamResult{
Outcome: "error", HTTPStatus: outcome.HTTPStatus, Status: outcome.Status,
Message: creditcall.FailureDescription(outcome.FailureType, outcome.FailureDescription),
}
if !outcome.Approved {
switch strings.ToLower(outcome.FailureType) {
case "declined":
result.Outcome = "declined"
case "cancelled", "canceled":
result.Outcome = "cancelled"
case "timeout":
result.Outcome = "timeout"
}
return result
}
fields := outcome.Payment.Fields
result.Outcome = "approved"
result.Message = "Payment approved"
result.TransactionReference = fields[types.Reference]
result.CardType = fields[types.CardType]
result.MaskedCardNumber = creditcall.MaskedCardNumber(fields[types.PanMasked])
result.ExpiryDate = fields[types.ExpiryDate]
result.CardHash = fields[types.CardHash]
result.CardReference = fields[types.CardReference]
return result
}
// EnableCreditCallStreaming selects the CreditCall core without a generic provider.
func (app *App) EnableCreditCallStreaming() { app.creditCallStreamEnabled = true }
func (app *App) streamCreditCallSale(w http.ResponseWriter, r *http.Request) {
app.streamCreditCall(w, r, false)
}
func (app *App) streamCreditCall(w http.ResponseWriter, r *http.Request, preauth bool) {
setPaymentCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Content-Type-Options", "nosniff")
controller := http.NewResponseController(w)
encoder := json.NewEncoder(w)
deliveryFailed := false
// Only this handler writes the response. The transaction worker never waits
// for network delivery, including when the peer stops reading without closing.
send := func(frame paymentStreamMessage) {
if deliveryFailed || r.Context().Err() != nil {
deliveryFailed = true
return
}
if err := encoder.Encode(frame); err != nil {
deliveryFailed = true
return
}
if err := controller.Flush(); err != nil {
deliveryFailed = true
return
}
}
reject := func(status int, message string) {
result := creditCallStreamResult{Outcome: "error", Message: message, HTTPStatus: status,
Status: cmstypes.StatusRec{Code: status, Message: http.StatusText(status)}}
w.WriteHeader(status)
send(paymentStreamMessage{Type: "result", Result: &result})
}
if preauth && !app.creditCallStreamEnabled {
reject(http.StatusServiceUnavailable, "CreditCall preauthorization streaming is not enabled")
return
}
if !app.isPayment && !app.cfg.TestMode {
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted payment while payment processing is disabled")
reject(http.StatusServiceUnavailable, "Payment processing is disabled")
return
}
if r.Method != http.MethodPost {
reject(http.StatusMethodNotAllowed, "Method not allowed; use POST")
return
}
if ct := r.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") {
reject(http.StatusUnsupportedMediaType, "Content-Type must be application/json")
return
}
defer r.Body.Close()
var transaction cmstypes.TransactionRec
if preauth {
var request creditCallPreauthRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
reject(http.StatusBadRequest, "Invalid JSON payload")
return
}
transaction = cmstypes.TransactionRec{AmountMinorUnits: request.Amount, TransactionType: request.TransactionType, CheckoutDate: request.CheckoutDate}
} else {
var request SalePaymentRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
reject(http.StatusBadRequest, "Invalid JSON payload")
return
}
if request.Amount <= 0 {
reject(http.StatusBadRequest, "Amount must be greater than zero")
return
}
transaction = cmstypes.TransactionRec{AmountMinorUnits: strconv.FormatInt(request.Amount, 10), TransactionType: "Sale"}
}
if _, ok := w.(http.Flusher); !ok {
reject(http.StatusInternalServerError, "Streaming payment updates are not supported")
return
}
// Validation belongs to the incoming request. This handoff owns financial dispatch.
if r.Context().Err() != nil {
return
}
financialContext := context.WithoutCancel(r.Context())
progress := make(chan string, 128)
finished := make(chan creditCallSaleOutcome, 1)
go func() {
start := func(client *http.Client) (creditcall.TransactionResultXML, error) {
return callChipDNATransactionStream(financialContext, client, transaction, func(code string) {
select {
case progress <- code:
default: // Observational progress may be dropped under backpressure.
}
})
}
var outcome creditCallSaleOutcome
if preauth {
outcome = app.executeCreditCallPreauth(financialContext, transaction, start)
} else {
outcome = app.executeCreditCallSale(transaction, start)
}
close(progress)
finished <- outcome // Independent of the bounded progress queue.
}()
for {
select {
case code, ok := <-progress:
if !ok {
progress = nil
continue
}
send(paymentStreamMessage{Type: "status", Code: code})
case outcome := <-finished:
if progress != nil {
for code := range progress {
send(paymentStreamMessage{Type: "status", Code: code})
}
}
result := outcome.streamResult()
send(paymentStreamMessage{Type: "result", Result: &result})
return
}
}
}
func callChipDNAStream(client *http.Client, amount int64, onStatus func(string)) (creditcall.TransactionResultXML, error) {
return callChipDNATransactionStream(context.Background(), client, cmstypes.TransactionRec{
AmountMinorUnits: strconv.FormatInt(amount, 10), TransactionType: "Sale",
}, onStatus)
}
func callChipDNATransactionStream(ctx context.Context, client *http.Client, transaction cmstypes.TransactionRec, onStatus func(string)) (creditcall.TransactionResultXML, error) {
var result creditcall.TransactionResultXML
payload, err := json.Marshal(struct {
Amount string `json:"amount"`
TransactionType string `json:"transactionType"`
}{transaction.AmountMinorUnits, transaction.TransactionType})
if err != nil {
return result, err
}
// Dispatch owns ctx; Client.Timeout retains the independent 300-second call bound.
req, err := http.NewRequestWithContext(ctx, http.MethodPost, types.LinkStartTransactionStream, bytes.NewReader(payload))
if err != nil {
return result, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/x-ndjson")
response, err := client.Do(req)
if err != nil {
return result, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return result, fmt.Errorf("chipdna stream returned HTTP %d", response.StatusCode)
}
reader := bufio.NewReader(response.Body)
for {
line, err := reader.ReadBytes('\n')
if err != nil {
if errors.Is(err, io.EOF) {
return result, fmt.Errorf("chipdna stream ended before a complete final frame")
}
return result, fmt.Errorf("read chipdna stream: %w", err)
}
var frame struct {
Type string `json:"type"`
Source string `json:"source"`
Value string `json:"value"`
Result map[string]string `json:"result"`
}
if err := json.Unmarshal(line, &frame); err != nil {
return result, fmt.Errorf("invalid chipdna JSON frame")
}
switch frame.Type {
case "status":
if code := creditcall.ProgressStatus(frame.Source, frame.Value); code != "" && onStatus != nil {
onStatus(code)
}
case "result":
if len(frame.Result) == 0 {
return result, fmt.Errorf("chipdna final frame has no transaction fields")
}
for key, value := range frame.Result {
result.Entries = append(result.Entries, creditcall.EntryXML{Key: key, Value: value})
}
return result, nil // Later frames cannot repeat confirmation/finalization.
case "error":
return result, fmt.Errorf("chipdna stream reported an error")
default:
return result, fmt.Errorf("unknown chipdna frame type")
}
}
}

View File

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

View File

@ -1,411 +0,0 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/lockserver"
log "github.com/sirupsen/logrus"
)
type dispenserCallResult struct {
status string
err error
}
type fakeDoorCardDispenser struct {
prepareCurrent dispenserCallResult
deliverCurrent dispenserCallResult
beginNextErr error
beginNext func() error
prepareNext dispenserCallResult
activity int
registrations int
releases int
outsideActivity bool
calls []string
}
func (d *fakeDoorCardDispenser) PrepareCurrentCard(context.Context) (string, error) {
if d.activity == 0 {
d.outsideActivity = true
}
d.calls = append(d.calls, "prepare current")
return d.prepareCurrent.status, d.prepareCurrent.err
}
func (d *fakeDoorCardDispenser) DeliverCurrentCard(context.Context) (string, error) {
d.calls = append(d.calls, "deliver current")
return d.deliverCurrent.status, d.deliverCurrent.err
}
func (d *fakeDoorCardDispenser) BeginPrepareNextCard(context.Context) error {
d.calls = append(d.calls, "begin prepare next")
if d.beginNext != nil {
return d.beginNext()
}
return d.beginNextErr
}
func (d *fakeDoorCardDispenser) PrepareNextCard(context.Context) (string, error) {
d.calls = append(d.calls, "prepare next")
return d.prepareNext.status, d.prepareNext.err
}
type fakeDoorCardLockServer struct {
sequenceErr error
buildCalls int
sequenceCalls int
}
func (l *fakeDoorCardLockServer) BuildCommand(lockserver.DoorCardRequest, time.Time, time.Time) error {
l.buildCalls++
return nil
}
func (l *fakeDoorCardLockServer) LockSequence() error {
l.sequenceCalls++
return l.sequenceErr
}
func performIssueDoorCardRequest(
t *testing.T,
dispenser *fakeDoorCardDispenser,
lock *fakeDoorCardLockServer,
) (*httptest.ResponseRecorder, cmstypes.StatusRec, *App) {
t.Helper()
payload := lockserver.DoorCardRequest{
RoomField: "101",
CheckinTime: "2026-07-23 15:00:00 +0100",
CheckoutTime: "2026-07-24 11:00:00 +0100",
FollowStr: "0",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
app := &App{
disp: dispenser,
lockserver: lock,
cfg: &config.ConfigRec{},
}
request := httptest.NewRequest(http.MethodPost, "/issuedoorcard", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
app.issueDoorCard(recorder, request)
var response cmstypes.StatusRec
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response %q: %v", recorder.Body.String(), err)
}
return recorder, response, app
}
func TestIssueDoorCardPhysicalOutcomeContract(t *testing.T) {
encodingErr := errors.New("key encoding failed")
tests := []struct {
name string
dispenser fakeDoorCardDispenser
lockErr error
wantHTTP int
wantMessage string
wantCalls []string
wantLockSequence int
wantCardWell string
}{
{
name: "initial dispenser preparation failure is retryable",
dispenser: fakeDoorCardDispenser{
prepareCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("card jammed")},
},
wantHTTP: http.StatusBadGateway,
wantMessage: "Dispense error: card jammed",
wantCalls: []string{"prepare current"},
wantCardWell: "Card jammed",
},
{
name: "initial empty card well has stable message",
dispenser: fakeDoorCardDispenser{
prepareCurrent: dispenserCallResult{status: "Card empty", err: dispenser.ErrCardWellEmpty},
},
wantHTTP: http.StatusServiceUnavailable,
wantMessage: dispenser.CardWellEmptyMessage,
wantCalls: []string{"prepare current"},
wantCardWell: "Card empty",
},
{
name: "exhausted preparation is retryable without encoding or delivery",
dispenser: fakeDoorCardDispenser{prepareCurrent: dispenserCallResult{err: errors.Join(errors.New("preparation"), dispenser.ErrPreparationExhausted)}},
wantHTTP: http.StatusBadGateway,
wantMessage: "preparation\ncard preparation exhausted",
wantCalls: []string{"prepare current"},
},
{
name: "encoding and accepted delivery command succeed",
wantHTTP: http.StatusOK,
wantMessage: "Card issued successfully",
wantCalls: []string{"prepare current", "deliver current", "begin prepare next"},
wantLockSequence: 1,
},
{
name: "successful encoding with delivery command failure still prestages and succeeds",
dispenser: fakeDoorCardDispenser{
deliverCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("delivery jammed")},
},
wantHTTP: http.StatusOK,
wantMessage: "Card issued successfully",
wantCalls: []string{"prepare current", "deliver current", "begin prepare next"},
wantLockSequence: 1,
wantCardWell: "Card jammed",
},
{
name: "failed next-card dispatch does not undo delivered card",
dispenser: fakeDoorCardDispenser{
beginNextErr: errors.New("dispatch failed"),
},
wantHTTP: http.StatusOK,
wantMessage: "Card issued successfully",
wantCalls: []string{"prepare current", "deliver current", "begin prepare next"},
wantLockSequence: 1,
},
{
name: "delivery clearance deferral does not undo successful issuance",
dispenser: fakeDoorCardDispenser{
beginNextErr: errors.New("next-card preparation deferred: previous delivery is not clear"),
},
wantHTTP: http.StatusOK,
wantMessage: "Card issued successfully",
wantCalls: []string{"prepare current", "deliver current", "begin prepare next"},
wantLockSequence: 1,
},
{
name: "encoding failure ends after delivery",
lockErr: encodingErr,
wantHTTP: http.StatusBadGateway,
wantMessage: encodingErr.Error(),
wantCalls: []string{"prepare current", "deliver current"},
wantLockSequence: 1,
},
{
name: "encoding failure remains retryable despite FC0 failure",
dispenser: fakeDoorCardDispenser{
deliverCurrent: dispenserCallResult{status: "Card jammed", err: errors.New("delivery jammed")},
},
lockErr: encodingErr,
wantHTTP: http.StatusBadGateway,
wantMessage: encodingErr.Error(),
wantCalls: []string{"prepare current", "deliver current"},
wantLockSequence: 1,
wantCardWell: "Card jammed",
},
{
name: "encoding failure never prepares next card",
dispenser: fakeDoorCardDispenser{
prepareNext: dispenserCallResult{status: "Card empty", err: dispenser.ErrCardWellEmpty},
},
lockErr: encodingErr,
wantHTTP: http.StatusBadGateway,
wantMessage: encodingErr.Error(),
wantCalls: []string{"prepare current", "deliver current"},
wantLockSequence: 1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dispenser := test.dispenser
lock := &fakeDoorCardLockServer{sequenceErr: test.lockErr}
recorder, response, app := performIssueDoorCardRequest(t, &dispenser, lock)
if recorder.Code != test.wantHTTP {
t.Fatalf("HTTP status = %d, want %d", recorder.Code, test.wantHTTP)
}
if response.Code != test.wantHTTP {
t.Fatalf("response code = %d, want %d", response.Code, test.wantHTTP)
}
if response.Message != test.wantMessage {
t.Fatalf("response message = %q, want %q", response.Message, test.wantMessage)
}
if !reflect.DeepEqual(dispenser.calls, test.wantCalls) {
t.Fatalf("dispenser calls = %#v, want %#v", dispenser.calls, test.wantCalls)
}
if lock.sequenceCalls != test.wantLockSequence {
t.Fatalf("lock sequence calls = %d, want %d", lock.sequenceCalls, test.wantLockSequence)
}
if test.wantLockSequence > 0 && lock.buildCalls != 1 {
t.Fatalf("build command calls = %d, want 1", lock.buildCalls)
}
if test.wantLockSequence == 0 && lock.buildCalls != 0 {
t.Fatalf("build command calls = %d, want 0", lock.buildCalls)
}
if got := app.CardWellStatus(); got != test.wantCardWell {
t.Fatalf("card-well status = %q, want %q", got, test.wantCardWell)
}
})
}
}
type responseTrackingRecorder struct {
*httptest.ResponseRecorder
wroteResponse bool
}
func (r *responseTrackingRecorder) WriteHeader(statusCode int) {
r.wroteResponse = true
r.ResponseRecorder.WriteHeader(statusCode)
}
func (r *responseTrackingRecorder) Write(body []byte) (int, error) {
r.wroteResponse = true
return r.ResponseRecorder.Write(body)
}
func TestIssueDoorCardDispatchesNextCardBeforeHTTP200(t *testing.T) {
var recorder *responseTrackingRecorder
dispatchCalled := false
dispenser := &fakeDoorCardDispenser{
beginNext: func() error {
dispatchCalled = true
if recorder.wroteResponse {
t.Error("HTTP response started before next-card preparation was dispatched")
}
return nil
},
}
lock := &fakeDoorCardLockServer{}
payload := lockserver.DoorCardRequest{
RoomField: "101",
CheckinTime: "2026-07-23 15:00:00 +0100",
CheckoutTime: "2026-07-24 11:00:00 +0100",
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
app := &App{disp: dispenser, lockserver: lock, cfg: &config.ConfigRec{}}
request := httptest.NewRequest(http.MethodPost, "/issuedoorcard", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
recorder = &responseTrackingRecorder{ResponseRecorder: httptest.NewRecorder()}
app.issueDoorCard(recorder, request)
if !dispatchCalled {
t.Fatal("next-card preparation was not dispatched")
}
if recorder.Code != http.StatusOK {
t.Fatalf("HTTP status = %d, want %d", recorder.Code, http.StatusOK)
}
}
func TestIssueDoorCardLogsNextCardDispatchFailureAndStillSucceeds(t *testing.T) {
var logOutput bytes.Buffer
standardLogger := log.StandardLogger()
previousOutput := standardLogger.Out
standardLogger.SetOutput(&logOutput)
t.Cleanup(func() {
standardLogger.SetOutput(previousOutput)
})
dispenser := &fakeDoorCardDispenser{beginNextErr: errors.New("dispatch failed")}
lock := &fakeDoorCardLockServer{}
recorder, _, _ := performIssueDoorCardRequest(t, dispenser, lock)
if recorder.Code != http.StatusOK {
t.Fatalf("HTTP status = %d, want %d", recorder.Code, http.StatusOK)
}
logged := logOutput.String()
if !strings.Contains(logged, "dispatch failed") ||
!strings.Contains(logged, "Next card preparation dispatch") {
t.Fatalf("dispatch failure log = %q", logged)
}
}
func TestIssueDoorCardLogsDeliveryFailureWithoutReplacingEncoderOutcome(t *testing.T) {
for _, encodingErr := range []error{nil, errors.New("original encoder failure")} {
var output bytes.Buffer
logger := log.StandardLogger()
previous := logger.Out
logger.SetOutput(&output)
d := &fakeDoorCardDispenser{deliverCurrent: dispenserCallResult{err: errors.New("FC0 dispatch failed")}}
lock := &fakeDoorCardLockServer{sequenceErr: encodingErr}
recorder, response, _ := performIssueDoorCardRequest(t, d, lock)
logger.SetOutput(previous)
wantHTTP := http.StatusOK
if encodingErr != nil {
wantHTTP = http.StatusBadGateway
}
if recorder.Code != wantHTTP {
t.Errorf("issueDoorCard(%v) HTTP = %d, want %d", encodingErr, recorder.Code, wantHTTP)
}
if encodingErr != nil && response.Message != encodingErr.Error() {
t.Errorf("issueDoorCard message = %q, want %q", response.Message, encodingErr.Error())
}
if !strings.Contains(output.String(), "FC0 dispatch failed") || !strings.Contains(output.String(), "Card delivery") {
t.Errorf("issueDoorCard delivery log = %q, want FC0 failure and Card delivery", output.String())
}
}
}
func TestIssueDoorCardOnlyEmptyPreparationReturns503(t *testing.T) {
for _, failure := range []error{
dispenser.ErrCardWellEmpty, errors.Join(errors.New("wrapped"), dispenser.ErrCardWellEmpty),
dispenser.ErrPreparationExhausted, context.Canceled, context.DeadlineExceeded,
errors.New("malformed AP"), errors.New("truncated AP"), errors.New("no response"),
errors.New("serial read failed"), errors.New("serial write failed"), errors.New("FC7 dispatch failed"),
errors.New("RS dispatch failed"), errors.New(dispenser.CardWellEmptyMessage), errors.New("other preparation error"),
} {
d := &fakeDoorCardDispenser{prepareCurrent: dispenserCallResult{err: failure}}
lock := &fakeDoorCardLockServer{}
recorder, response, _ := performIssueDoorCardRequest(t, d, lock)
want := http.StatusBadGateway
if errors.Is(failure, dispenser.ErrCardWellEmpty) {
want = http.StatusServiceUnavailable
}
if recorder.Code != want || response.Code != want {
t.Errorf("preparation %v HTTP=%d body=%d, want %d", failure, recorder.Code, response.Code, want)
}
if lock.sequenceCalls != 0 || !reflect.DeepEqual(d.calls, []string{"prepare current"}) {
t.Errorf("preparation %v encoder=%d calls=%v, want no physical continuation", failure, lock.sequenceCalls, d.calls)
}
}
}
func (d *fakeDoorCardDispenser) BeginForeground() func() {
d.activity++
d.registrations++
return func() { d.activity--; d.releases++ }
}
func TestDoorCardForegroundLifecycle(t *testing.T) {
for _, testEndpoint := range []bool{false, true} {
d := &fakeDoorCardDispenser{prepareCurrent: dispenserCallResult{err: errors.New("stop before encoding")}}
lock := &fakeDoorCardLockServer{}
_, _, app := performIssueDoorCardRequest(t, d, lock)
if d.registrations != 1 || d.releases != 1 || d.activity != 0 || d.outsideActivity {
t.Fatalf("issue registration=%d release=%d active=%d outside=%t", d.registrations, d.releases, d.activity, d.outsideActivity)
}
if testEndpoint {
req := httptest.NewRequest(http.MethodPost, "/testissuedoorcard", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
app.testIssueDoorCard(httptest.NewRecorder(), req)
if d.registrations != 2 || d.releases != 2 || d.activity != 0 || d.outsideActivity {
t.Errorf("test endpoint registration=%d release=%d active=%d outside=%t", d.registrations, d.releases, d.activity, d.outsideActivity)
}
}
}
}

View File

@ -1,428 +0,0 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"encoding/xml"
"errors"
"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 doorCardDispenser interface {
BeginForeground() func()
PrepareCurrentCard(context.Context) (string, error)
DeliverCurrentCard(context.Context) (string, error)
BeginPrepareNextCard(context.Context) error
PrepareNextCard(context.Context) (string, error)
}
type App struct {
disp doorCardDispenser
lockserver lockserver.LockServer
paymentService *paymentsvc.Service
creditCallStreamEnabled bool
creditCallTransport http.RoundTripper
creditCallReceipt func(string)
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)
mux.HandleFunc("/api/payment/preauth", app.streamCreditCallPreauth)
}
func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("takePreauthorization")
var (
theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec
)
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,
)
outcome := app.executeCreditCallPreauth(context.Background(), theRequest, func(client *http.Client) (creditcall.TransactionResultXML, error) {
var transaction creditcall.TransactionResultXML
response, err := callChipDNA(client, types.LinkStartTransaction, body)
if err != nil {
return transaction, err
}
if err := transaction.ParseTransactionResult(response); err != nil {
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
}
return transaction, nil
})
writeTransactionResult(w, outcome.HTTPStatus, outcome.legacyResponse())
}
func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("takePayment")
var (
theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec
)
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,
)
outcome := app.executeCreditCallSale(theRequest, func(client *http.Client) (creditcall.TransactionResultXML, error) {
var trResult creditcall.TransactionResultXML
body, err := callChipDNA(client, types.LinkStartTransaction, body)
if err != nil {
return trResult, err
}
if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
}
return trResult, nil
})
writeTransactionResult(w, outcome.HTTPStatus, outcome.legacyResponse())
}
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
}
release := app.disp.BeginForeground()
defer release()
status, err := app.disp.PrepareCurrentCard(r.Context())
app.SetCardWellStatus(status)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Dispense error", string(op), "", "", 0)
if errors.Is(err, dispenser.ErrCardWellEmpty) {
errorhandlers.WriteError(w, http.StatusServiceUnavailable, dispenser.CardWellEmptyMessage)
return
}
if errors.Is(err, dispenser.ErrPreparationExhausted) {
errorhandlers.WriteError(w, http.StatusBadGateway, err.Error())
return
}
errorhandlers.WriteError(w, http.StatusBadGateway, "Dispense error: "+err.Error())
return
}
// doorReq.RoomField = "104"
// build lock server command
app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
// Each request performs at most one encoder operation.
encodingStarted := time.Now()
log.Info("LockSequence started")
encodingErr := app.lockserver.LockSequence()
log.Infof("LockSequence finished; success=%t duration=%s", encodingErr == nil, time.Since(encodingStarted))
if encodingErr != nil {
logging.Error(types.ServiceName, encodingErr.Error(), "Key encoding", string(op), "", "", 0)
}
// Once encoding has started, finish the physical dispenser sequence even if
// the HTTP client disconnects. Finalization remains bounded by one shared timeout.
finalizeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
status, deliveryErr := app.disp.DeliverCurrentCard(finalizeCtx)
app.SetCardWellStatus(status)
if deliveryErr != nil {
logging.Error(types.ServiceName, deliveryErr.Error(), "Card delivery", string(op), "", "", 0)
}
if encodingErr != nil {
// FC0 is attempted once; the next UI request owns all further preparation.
errorhandlers.WriteError(w, http.StatusBadGateway, encodingErr.Error())
return
}
if preparationErr := app.disp.BeginPrepareNextCard(finalizeCtx); preparationErr != nil {
logging.Error(types.ServiceName, preparationErr.Error(), "Next card preparation dispatch", string(op), "", "", 0)
}
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
}

View File

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

View File

@ -1,340 +0,0 @@
package handlers
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"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/printer"
"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"`
}
type paymentStreamMessage struct {
Type string `json:"type"`
Code string `json:"code,omitempty"`
Response *cmstypes.ResponseRec `json:"response,omitempty"`
Result *creditCallStreamResult `json:"result,omitempty"`
}
func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
const op = logging.Op("salePayment")
if app.creditCallStreamEnabled {
app.streamCreditCallSale(w, r)
return
}
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()
flusher, ok := w.(http.Flusher)
if !ok {
response.Data = buildPaymentFailureURL(
types.ResultError,
"Streaming payment updates are not supported",
)
writeTransactionResult(w, http.StatusInternalServerError, response)
return
}
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Content-Type-Options", "nosniff")
encoder := json.NewEncoder(w)
var streamMu sync.Mutex
streamStarted := false
streamFailed := false
sendStreamMessage := func(message paymentStreamMessage) {
streamMu.Lock()
defer streamMu.Unlock()
if streamFailed {
return
}
if err := encoder.Encode(message); err != nil {
streamFailed = true
return
}
streamStarted = true
flusher.Flush()
}
onStatus := func(update paymentsvc.StatusUpdate) {
if update.Code == "" {
return
}
sendStreamMessage(paymentStreamMessage{
Type: "status",
Code: update.Code,
})
}
result, err := app.paymentService.Sale(
ctx,
paymentsvc.SaleRequest{
RequestID: requestID,
Reference: req.Reference,
Amount: req.Amount,
Currency: req.Currency,
},
onStatus,
)
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())
if !streamStarted {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
writeTransactionResult(w, status, response)
return
}
sendStreamMessage(paymentStreamMessage{
Type: "result",
Response: &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",
)
if !streamStarted {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
writeTransactionResult(w, http.StatusBadGateway, response)
return
}
sendStreamMessage(paymentStreamMessage{
Type: "result",
Response: &response,
})
return
}
response.Status.Code = http.StatusOK
if result.Success && strings.EqualFold(result.Status, "APPROVED") {
printer.PrintSaleReceipt(result.CustomerReceipt)
response.Status.Message = result.Message
response.Data = buildPaymentSuccessURL(result)
sendStreamMessage(paymentStreamMessage{
Type: "result",
Response: &response,
})
return
}
description := result.ErrorMessage
if description == "" {
description = result.Message
}
if description == "" {
description = result.Status
}
log.Printf("Transaction failed: %s", description)
printer.PrintSaleReceipt(result.CustomerReceipt)
response.Status.Message = "Payment unsuccessful"
response.Data = buildPaymentFailureURL(types.ResultError, description)
sendStreamMessage(paymentStreamMessage{
Type: "result",
Response: &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", result.RequestID)
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()
}

View File

@ -1,162 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
)
type paymentProviderFunc func(
context.Context,
paymentsvc.SaleRequest,
paymentsvc.StatusHandler,
) (*paymentsvc.Result, error)
func (f paymentProviderFunc) Sale(
ctx context.Context,
request paymentsvc.SaleRequest,
onStatus paymentsvc.StatusHandler,
) (*paymentsvc.Result, error) {
return f(ctx, request, onStatus)
}
func TestSalePaymentStreamsTerminalUnavailableAsAuthoritativeUnsuccessfulResult(t *testing.T) {
provider := paymentProviderFunc(func(
_ context.Context,
request paymentsvc.SaleRequest,
onStatus paymentsvc.StatusHandler,
) (*paymentsvc.Result, error) {
onStatus(paymentsvc.StatusUpdate{Code: paymentstatus.Starting})
onStatus(paymentsvc.StatusUpdate{Code: paymentstatus.TerminalUnavailable})
return &paymentsvc.Result{
RequestID: request.RequestID,
Operation: "SALE",
Status: "TERMINAL_UNAVAILABLE",
ErrorMessage: "Payment terminal is unavailable",
Amount: request.Amount,
Currency: request.Currency,
DeviceUsed: "terminal-1",
DeviceType: "Dojo Terminal",
}, nil
})
recorder := performSalePaymentRequest(t, provider)
if recorder.Code != http.StatusOK {
t.Fatalf("outer status = %d, want %d", recorder.Code, http.StatusOK)
}
messages := decodePaymentStream(t, recorder.Body)
if len(messages) != 3 {
t.Fatalf("stream messages = %#v, want two statuses and one result", messages)
}
if messages[0].Type != "status" || messages[0].Code != paymentstatus.Starting ||
messages[1].Type != "status" || messages[1].Code != paymentstatus.TerminalUnavailable {
t.Fatalf("status frames = %#v", messages[:2])
}
final := messages[2]
if final.Type != "result" || final.Response == nil {
t.Fatalf("final frame = %#v", final)
}
if final.Response.Status.Code != http.StatusOK {
t.Fatalf("nested status = %d, want %d", final.Response.Status.Code, http.StatusOK)
}
resultURL, err := url.Parse(final.Response.Data)
if err != nil {
t.Fatal(err)
}
if resultURL.Path != types.CheckinUnsuccessfulEndpoint {
t.Fatalf("result path = %q, want %q", resultURL.Path, types.CheckinUnsuccessfulEndpoint)
}
if got := resultURL.Query().Get("Description"); got != "Payment terminal is unavailable" {
t.Fatalf("Description = %q", got)
}
body := recorder.Body.String()
for _, forbidden := range []string{
"Dojo returned HTTP 409",
"offline or currently in use",
"traceId",
"docs.dojo.tech",
} {
if strings.Contains(body, forbidden) {
t.Fatalf("stream exposed %q: %s", forbidden, body)
}
}
}
func TestSalePaymentRetainsNestedBadGatewayForProviderErrorsAfterStreamingStarts(t *testing.T) {
provider := paymentProviderFunc(func(
_ context.Context,
_ paymentsvc.SaleRequest,
onStatus paymentsvc.StatusHandler,
) (*paymentsvc.Result, error) {
onStatus(paymentsvc.StatusUpdate{Code: paymentstatus.Starting})
return nil, errors.New("provider failed")
})
recorder := performSalePaymentRequest(t, provider)
if recorder.Code != http.StatusOK {
t.Fatalf("outer status = %d, want streaming status %d", recorder.Code, http.StatusOK)
}
messages := decodePaymentStream(t, recorder.Body)
if len(messages) != 2 || messages[1].Type != "result" || messages[1].Response == nil {
t.Fatalf("stream messages = %#v", messages)
}
if messages[1].Response.Status.Code != http.StatusBadGateway {
t.Fatalf("nested status = %d, want %d", messages[1].Response.Status.Code, http.StatusBadGateway)
}
resultURL, err := url.Parse(messages[1].Response.Data)
if err != nil {
t.Fatal(err)
}
if resultURL.Path != types.CheckinUnsuccessfulEndpoint {
t.Fatalf("result path = %q, want %q", resultURL.Path, types.CheckinUnsuccessfulEndpoint)
}
}
func performSalePaymentRequest(t *testing.T, provider paymentsvc.Provider) *httptest.ResponseRecorder {
t.Helper()
app := &App{
paymentService: paymentsvc.NewService(provider),
cfg: &config.ConfigRec{
Hotel: "HOTEL",
Kiosk: 7,
TimeoutSeconds: 5,
},
}
request := httptest.NewRequest(
http.MethodPost,
"/api/payment/sale",
strings.NewReader(`{"reference":"BOOKING-123","amount":10852,"currency":"GBP"}`),
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
app.salePayment(recorder, request)
return recorder
}
func decodePaymentStream(t *testing.T, body io.Reader) []paymentStreamMessage {
t.Helper()
decoder := json.NewDecoder(body)
var messages []paymentStreamMessage
for {
var message paymentStreamMessage
if err := decoder.Decode(&message); err != nil {
if errors.Is(err, io.EOF) {
return messages
}
t.Fatal(err)
}
messages = append(messages, message)
}
}

View File

@ -1,255 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
"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/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.
release := app.disp.BeginForeground()
defer release()
status, err := app.disp.PrepareCurrentCard(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,
)
}

View File

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

View File

@ -1,36 +0,0 @@
package logging
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
log "github.com/sirupsen/logrus"
)
// SetupLogging ensures the log directory, opens the rotating log writer, and configures logrus.
func SetupLogging(logDir, serviceName, buildVersion string) (io.WriteCloser, error) {
if err := os.MkdirAll(logDir, 0o755); err != nil {
return nil, fmt.Errorf("create log directory: %w", err)
}
fileName := filepath.Join(logDir, serviceName+".log")
f, err := newWeeklyLogWriter(fileName, time.Local, defaultWeeklyLogRuntime())
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
}

View File

@ -1,319 +0,0 @@
package logging
import (
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
_ "time/tzdata"
)
const weeklyLogRetention = 13
type logRotationTimer interface {
C() <-chan time.Time
Stop() bool
}
type realLogRotationTimer struct {
*time.Timer
}
func (t realLogRotationTimer) C() <-chan time.Time {
return t.Timer.C
}
type weeklyLogRuntime struct {
now func() time.Time
newTimer func(time.Duration) logRotationTimer
rename func(string, string) error
diagnostic io.Writer
}
func defaultWeeklyLogRuntime() weeklyLogRuntime {
return weeklyLogRuntime{
now: time.Now,
newTimer: func(duration time.Duration) logRotationTimer {
return realLogRotationTimer{Timer: time.NewTimer(duration)}
},
rename: os.Rename,
diagnostic: os.Stderr,
}
}
type weeklyLogWriter struct {
path string
location *time.Location
now func() time.Time
newTimer func(time.Duration) logRotationTimer
rename func(string, string) error
diagnostic io.Writer
mu sync.Mutex
file *os.File
weekStart time.Time
closed bool
stop chan struct{}
done chan struct{}
closeOnce sync.Once
closeErr error
}
func newWeeklyLogWriter(path string, location *time.Location, runtime weeklyLogRuntime) (*weeklyLogWriter, error) {
if location == nil {
location = time.Local
}
if runtime.now == nil {
runtime.now = time.Now
}
if runtime.newTimer == nil {
runtime.newTimer = defaultWeeklyLogRuntime().newTimer
}
if runtime.rename == nil {
runtime.rename = os.Rename
}
if runtime.diagnostic == nil {
runtime.diagnostic = os.Stderr
}
now := runtime.now().In(location)
weekStart := logWeekStart(now, location)
if info, err := os.Stat(path); err == nil {
weekStart = logWeekStart(info.ModTime(), location)
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("inspect active log: %w", err)
}
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o666)
if err != nil {
return nil, err
}
writer := &weeklyLogWriter{
path: path,
location: location,
now: runtime.now,
newTimer: runtime.newTimer,
rename: runtime.rename,
diagnostic: runtime.diagnostic,
file: file,
weekStart: weekStart,
stop: make(chan struct{}),
done: make(chan struct{}),
}
writer.rotateIfNeeded(now)
go writer.run()
return writer, nil
}
func (w *weeklyLogWriter) Write(data []byte) (int, error) {
now := w.now().In(w.location)
w.mu.Lock()
rotationErr := w.rotateIfNeededLocked(now)
if w.closed || w.file == nil {
w.mu.Unlock()
w.reportRotationError(rotationErr)
return 0, os.ErrClosed
}
n, writeErr := w.file.Write(data)
w.mu.Unlock()
w.reportRotationError(rotationErr)
return n, writeErr
}
func (w *weeklyLogWriter) Close() error {
w.closeOnce.Do(func() {
close(w.stop)
<-w.done
w.mu.Lock()
w.closed = true
if w.file != nil {
w.closeErr = w.file.Close()
w.file = nil
}
w.mu.Unlock()
})
return w.closeErr
}
func (w *weeklyLogWriter) run() {
defer close(w.done)
for {
now := w.now().In(w.location)
next := nextLogWeekStart(now, w.location)
duration := next.Sub(now)
if duration <= 0 {
duration = time.Nanosecond
}
timer := w.newTimer(duration)
select {
case <-timer.C():
w.rotateIfNeeded(w.now().In(w.location))
case <-w.stop:
timer.Stop()
return
}
}
}
func (w *weeklyLogWriter) rotateIfNeeded(now time.Time) {
w.mu.Lock()
err := w.rotateIfNeededLocked(now)
w.mu.Unlock()
w.reportRotationError(err)
}
func (w *weeklyLogWriter) rotateIfNeededLocked(now time.Time) error {
if w.closed {
return nil
}
targetWeek := logWeekStart(now, w.location)
if !targetWeek.After(w.weekStart) {
return nil
}
// Record the attempted week even if rotation fails so every write in a
// broken environment does not retry and emit another diagnostic.
w.weekStart = targetWeek
return w.rotateLocked()
}
func (w *weeklyLogWriter) rotateLocked() error {
if w.file == nil {
return fmt.Errorf("active log file is unavailable")
}
if err := w.file.Close(); err != nil {
w.file = nil
return errors.Join(fmt.Errorf("close active log: %w", err), w.reopenActiveLocked())
}
w.file = nil
if err := w.cleanupOlderGenerationsLocked(); err != nil {
return errors.Join(err, w.reopenActiveLocked())
}
if err := removeIfExists(w.generationPath(weeklyLogRetention)); err != nil {
return errors.Join(fmt.Errorf("remove oldest weekly log: %w", err), w.reopenActiveLocked())
}
for generation := weeklyLogRetention - 1; generation >= 1; generation-- {
source := w.generationPath(generation)
if _, err := os.Stat(source); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return errors.Join(fmt.Errorf("inspect weekly log generation %d: %w", generation, err), w.reopenActiveLocked())
}
if err := w.rename(source, w.generationPath(generation+1)); err != nil {
return errors.Join(fmt.Errorf("shift weekly log generation %d: %w", generation, err), w.reopenActiveLocked())
}
}
activeRenamed := false
if _, err := os.Stat(w.path); err == nil {
if err := w.rename(w.path, w.generationPath(1)); err != nil {
return errors.Join(fmt.Errorf("archive active log: %w", err), w.reopenActiveLocked())
}
activeRenamed = true
} else if !errors.Is(err, os.ErrNotExist) {
return errors.Join(fmt.Errorf("inspect active log: %w", err), w.reopenActiveLocked())
}
file, err := os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o666)
if err == nil {
w.file = file
return nil
}
rotationErr := fmt.Errorf("open new active log: %w", err)
if activeRenamed {
if rollbackErr := w.rename(w.generationPath(1), w.path); rollbackErr != nil {
rotationErr = errors.Join(rotationErr, fmt.Errorf("restore archived active log: %w", rollbackErr))
}
}
return errors.Join(rotationErr, w.reopenActiveLocked())
}
func (w *weeklyLogWriter) cleanupOlderGenerationsLocked() error {
directory := filepath.Dir(w.path)
entries, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("list weekly log directory: %w", err)
}
base := filepath.Base(w.path)
extension := filepath.Ext(base)
stem := strings.TrimSuffix(base, extension)
prefix := stem + "."
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, extension) {
continue
}
generationText := strings.TrimSuffix(strings.TrimPrefix(name, prefix), extension)
generation, err := strconv.Atoi(generationText)
if err != nil || generation <= weeklyLogRetention {
continue
}
if err := os.Remove(filepath.Join(directory, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove weekly log generation %d: %w", generation, err)
}
}
return nil
}
func (w *weeklyLogWriter) reopenActiveLocked() error {
file, err := os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o666)
if err != nil {
return fmt.Errorf("reopen active log: %w", err)
}
w.file = file
return nil
}
func (w *weeklyLogWriter) generationPath(generation int) string {
extension := filepath.Ext(w.path)
stem := strings.TrimSuffix(w.path, extension)
return fmt.Sprintf("%s.%d%s", stem, generation, extension)
}
func (w *weeklyLogWriter) reportRotationError(err error) {
if err == nil {
return
}
slog.New(slog.NewJSONHandler(w.diagnostic, nil)).Warn(
"weekly log rotation failed",
"module", "logging",
"action", "weekly_log_rotation_failed",
"path", w.path,
"error.message", err.Error(),
)
}
func logWeekStart(value time.Time, location *time.Location) time.Time {
local := value.In(location)
daysSinceMonday := (int(local.Weekday()) + 6) % 7
monday := local.AddDate(0, 0, -daysSinceMonday)
return time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, location)
}
func nextLogWeekStart(value time.Time, location *time.Location) time.Time {
return logWeekStart(value, location).AddDate(0, 0, 7)
}
func removeIfExists(path string) error {
err := os.Remove(path)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}

View File

@ -1,387 +0,0 @@
package logging
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
log "github.com/sirupsen/logrus"
)
func TestWeeklyLogRotationShiftsAndRetainsExactGenerations(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "hardlink.log")
writeLogTestFile(t, path, "active")
for generation := 1; generation <= 14; generation++ {
writeLogTestFile(t, weeklyLogTestGeneration(path, generation), fmt.Sprintf("generation-%d", generation))
}
writeLogTestFile(t, weeklyLogTestGeneration(path, 19), "generation-19")
for name, contents := range map[string]string{
"hardlink.backup.14.log": "related-looking backup",
"another.14.log": "unrelated log",
"hardlink.14.log.bak": "different extension",
} {
writeLogTestFile(t, filepath.Join(directory, name), contents)
}
sunday := time.Date(2026, time.August, 9, 12, 0, 0, 0, time.UTC)
setLogTestModTime(t, path, sunday)
writer := newLogTestWriter(t, path, sunday, weeklyLogRuntime{})
writer.rotateIfNeeded(time.Date(2026, time.August, 10, 0, 0, 0, 0, time.UTC))
if err := writer.Close(); err != nil {
t.Fatal(err)
}
requireLogTestContents(t, path, "")
requireLogTestContents(t, weeklyLogTestGeneration(path, 1), "active")
requireLogTestContents(t, weeklyLogTestGeneration(path, 2), "generation-1")
requireLogTestContents(t, weeklyLogTestGeneration(path, 13), "generation-12")
for _, generation := range []int{14, 19} {
if _, err := os.Stat(weeklyLogTestGeneration(path, generation)); !errors.Is(err, os.ErrNotExist) {
t.Errorf("generation %d exists after rotation, want removed: %v", generation, err)
}
}
for name, contents := range map[string]string{
"hardlink.backup.14.log": "related-looking backup",
"another.14.log": "unrelated log",
"hardlink.14.log.bak": "different extension",
} {
requireLogTestContents(t, filepath.Join(directory, name), contents)
}
}
func TestWeeklyLogRotationOccursOncePerWeek(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "hardlink.log")
writeLogTestFile(t, path, "week-zero\n")
sunday := time.Date(2026, time.January, 4, 18, 0, 0, 0, time.UTC)
setLogTestModTime(t, path, sunday)
writer := newLogTestWriter(t, path, sunday, weeklyLogRuntime{})
writer.rotateIfNeeded(time.Date(2026, time.January, 4, 23, 59, 59, 0, time.UTC))
if _, err := os.Stat(weeklyLogTestGeneration(path, 1)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("rotation before Monday: %v, want no generation", err)
}
writer.rotateIfNeeded(time.Date(2026, time.January, 5, 0, 0, 0, 0, time.UTC))
if _, err := writer.Write([]byte("week-one\n")); err != nil {
t.Fatal(err)
}
writer.rotateIfNeeded(time.Date(2026, time.January, 6, 12, 0, 0, 0, time.UTC))
if err := writer.Close(); err != nil {
t.Fatal(err)
}
requireLogTestContents(t, weeklyLogTestGeneration(path, 1), "week-zero\n")
requireLogTestContents(t, path, "week-one\n")
if _, err := os.Stat(weeklyLogTestGeneration(path, 2)); !errors.Is(err, os.ErrNotExist) {
t.Errorf("same-week rotation created generation 2: %v", err)
}
}
func TestWeeklyLogStartupRotatesOnceAfterSeveralMissedWeeks(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "hardlink.log")
writeLogTestFile(t, path, "old active")
writeLogTestFile(t, weeklyLogTestGeneration(path, 1), "older history")
setLogTestModTime(t, path, time.Date(2026, time.January, 5, 12, 0, 0, 0, time.UTC))
now := time.Date(2026, time.February, 23, 9, 0, 0, 0, time.UTC)
writer := newLogTestWriter(t, path, now, weeklyLogRuntime{})
if err := writer.Close(); err != nil {
t.Fatal(err)
}
requireLogTestContents(t, weeklyLogTestGeneration(path, 1), "old active")
requireLogTestContents(t, weeklyLogTestGeneration(path, 2), "older history")
if _, err := os.Stat(weeklyLogTestGeneration(path, 3)); !errors.Is(err, os.ErrNotExist) {
t.Errorf("missed weeks created generation 3: %v", err)
}
}
func TestWeeklyLogSchedulerUsesLocalMondayBoundary(t *testing.T) {
location, err := time.LoadLocation("Europe/London")
if err != nil {
t.Fatal(err)
}
directory := t.TempDir()
path := filepath.Join(directory, "hardlink.log")
writeLogTestFile(t, path, "sunday")
sunday := time.Date(2026, time.March, 29, 0, 0, 0, 0, location)
setLogTestModTime(t, path, sunday)
clock := &atomic.Pointer[time.Time]{}
clock.Store(&sunday)
created := make(chan *logTestTimer, 2)
rotated := make(chan struct{})
runtime := weeklyLogRuntime{
now: func() time.Time { return *clock.Load() },
newTimer: func(duration time.Duration) logRotationTimer {
timer := &logTestTimer{duration: duration, ch: make(chan time.Time, 1)}
created <- timer
return timer
},
rename: func(oldPath, newPath string) error {
err := os.Rename(oldPath, newPath)
if oldPath == path && err == nil {
close(rotated)
}
return err
},
}
writer, err := newWeeklyLogWriter(path, location, runtime)
if err != nil {
t.Fatal(err)
}
timer := <-created
if timer.duration != 23*time.Hour {
t.Fatalf("timer duration = %v, want 23h across DST boundary", timer.duration)
}
monday := time.Date(2026, time.March, 30, 0, 0, 0, 0, location)
clock.Store(&monday)
timer.ch <- monday
<-rotated
if err := writer.Close(); err != nil {
t.Fatal(err)
}
requireLogTestContents(t, weeklyLogTestGeneration(path, 1), "sunday")
}
func TestWeeklyLogConcurrentWritesRemainCompleteAcrossRotation(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "hardlink.log")
sunday := time.Date(2026, time.August, 9, 12, 0, 0, 0, time.UTC)
writer := newLogTestWriter(t, path, sunday, weeklyLogRuntime{})
const goroutines = 8
const recordsPerGoroutine = 100
writeErrors := make(chan error, goroutines)
var writers sync.WaitGroup
writers.Add(goroutines)
for worker := 0; worker < goroutines; worker++ {
go func() {
defer writers.Done()
for record := 0; record < recordsPerGoroutine; record++ {
if _, err := writer.Write([]byte(fmt.Sprintf("%d:%d\n", worker, record))); err != nil {
writeErrors <- err
return
}
}
}()
}
writer.rotateIfNeeded(time.Date(2026, time.August, 10, 0, 0, 0, 0, time.UTC))
writers.Wait()
close(writeErrors)
for err := range writeErrors {
t.Errorf("weeklyLogWriter.Write() error = %v, want nil", err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
contents := readLogTestFile(t, weeklyLogTestGeneration(path, 1)) + readLogTestFile(t, path)
lines := strings.Split(strings.TrimSpace(contents), "\n")
if len(lines) != goroutines*recordsPerGoroutine {
t.Fatalf("record count = %d, want %d", len(lines), goroutines*recordsPerGoroutine)
}
seen := make(map[string]bool, len(lines))
for _, line := range lines {
if _, _, ok := strings.Cut(line, ":"); !ok {
t.Fatalf("record %q has no separator", line)
}
seen[line] = true
}
if len(seen) != goroutines*recordsPerGoroutine {
t.Errorf("unique record count = %d, want %d", len(seen), goroutines*recordsPerGoroutine)
}
}
func TestWeeklyLogCloseWaitsForRotationAndIsIdempotent(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "hardlink.log")
writeLogTestFile(t, path, "active")
sunday := time.Date(2026, time.August, 9, 12, 0, 0, 0, time.UTC)
setLogTestModTime(t, path, sunday)
started := make(chan struct{})
release := make(chan struct{})
runtime := weeklyLogRuntime{rename: func(oldPath, newPath string) error {
if oldPath == path {
close(started)
<-release
}
return os.Rename(oldPath, newPath)
}}
writer := newLogTestWriter(t, path, sunday, runtime)
rotationDone := make(chan struct{})
go func() {
writer.rotateIfNeeded(time.Date(2026, time.August, 10, 0, 0, 0, 0, time.UTC))
close(rotationDone)
}()
<-started
closeDone := make(chan error, 1)
go func() { closeDone <- writer.Close() }()
select {
case err := <-closeDone:
t.Fatalf("weeklyLogWriter.Close() during rotation returned %v, want blocked", err)
case <-time.After(20 * time.Millisecond):
}
close(release)
<-rotationDone
if err := <-closeDone; err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Errorf("second weeklyLogWriter.Close() error = %v, want nil", err)
}
if _, err := writer.Write([]byte("after close")); !errors.Is(err, os.ErrClosed) {
t.Errorf("weeklyLogWriter.Write() after close error = %v, want os.ErrClosed", err)
}
}
func TestWeeklyLogRotationFailureIsNonRecursiveAndPreservesWrites(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "hardlink.log")
writeLogTestFile(t, path, "before\n")
sunday := time.Date(2026, time.August, 9, 12, 0, 0, 0, time.UTC)
setLogTestModTime(t, path, sunday)
var diagnostics bytes.Buffer
runtime := weeklyLogRuntime{
rename: func(oldPath, newPath string) error {
if oldPath == path {
return errors.New("injected rename failure")
}
return os.Rename(oldPath, newPath)
},
diagnostic: &diagnostics,
}
writer := newLogTestWriter(t, path, sunday, runtime)
writer.rotateIfNeeded(time.Date(2026, time.August, 10, 0, 0, 0, 0, time.UTC))
if _, err := writer.Write([]byte("after\n")); err != nil {
t.Fatalf("weeklyLogWriter.Write() after failed rotation error = %v, want nil", err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
requireLogTestContents(t, path, "before\nafter\n")
if !strings.Contains(diagnostics.String(), "weekly_log_rotation_failed") || !strings.Contains(diagnostics.String(), "injected rename failure") {
t.Errorf("rotation diagnostic = %q, want action and injected error", diagnostics.String())
}
if strings.Contains(readLogTestFile(t, path), "weekly log rotation failed") {
t.Error("rotation diagnostic recursively entered active log")
}
}
func TestSetupLoggingKeepsFilenameAndJSONFormat(t *testing.T) {
logger := log.StandardLogger()
previousOutput := logger.Out
previousFormatter := logger.Formatter
previousLevel := logger.Level
t.Cleanup(func() {
logger.SetOutput(previousOutput)
logger.SetFormatter(previousFormatter)
logger.SetLevel(previousLevel)
})
directory := filepath.Join(t.TempDir(), "nested", "logs")
writer, err := SetupLogging(directory, "hardlink", "v-test")
if err != nil {
t.Fatal(err)
}
log.WithField("probe", "value").Info("test record")
if err := writer.Close(); err != nil {
t.Fatal(err)
}
contents := readLogTestFile(t, filepath.Join(directory, "hardlink.log"))
for _, want := range []string{`"buildVersion":"v-test"`, `"msg":"Logging initialized"`, `"probe":"value"`, `"msg":"test record"`} {
if !strings.Contains(contents, want) {
t.Errorf("hardlink.log contents missing %q: %s", want, contents)
}
}
if _, err := os.Stat(filepath.Join(directory, "hardlink.1.log")); !errors.Is(err, os.ErrNotExist) {
t.Errorf("SetupLogging() created archive during current week: %v", err)
}
}
type logTestTimer struct {
duration time.Duration
ch chan time.Time
stopped atomic.Bool
}
func (t *logTestTimer) C() <-chan time.Time {
return t.ch
}
func (t *logTestTimer) Stop() bool {
return !t.stopped.Swap(true)
}
func newLogTestWriter(t *testing.T, path string, now time.Time, overrides weeklyLogRuntime) *weeklyLogWriter {
t.Helper()
runtime := defaultWeeklyLogRuntime()
runtime.now = func() time.Time { return now }
if overrides.now != nil {
runtime.now = overrides.now
}
if overrides.newTimer != nil {
runtime.newTimer = overrides.newTimer
}
if overrides.rename != nil {
runtime.rename = overrides.rename
}
if overrides.diagnostic != nil {
runtime.diagnostic = overrides.diagnostic
}
writer, err := newWeeklyLogWriter(path, now.Location(), runtime)
if err != nil {
t.Fatal(err)
}
return writer
}
func weeklyLogTestGeneration(path string, generation int) string {
extension := filepath.Ext(path)
return fmt.Sprintf("%s.%d%s", strings.TrimSuffix(path, extension), generation, extension)
}
func writeLogTestFile(t *testing.T, path, contents string) {
t.Helper()
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
func setLogTestModTime(t *testing.T, path string, value time.Time) {
t.Helper()
if err := os.Chtimes(path, value, value); err != nil {
t.Fatal(err)
}
}
func readLogTestFile(t *testing.T, path string) string {
t.Helper()
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(contents)
}
func requireLogTestContents(t *testing.T, path, want string) {
t.Helper()
if got := readLogTestFile(t, path); got != want {
t.Errorf("%s contents = %q, want %q", filepath.Base(path), got, want)
}
}
var _ io.WriteCloser = (*weeklyLogWriter)(nil)

View File

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

View File

@ -1,9 +0,0 @@
package mail
import (
"testing"
)
func Test_SendMail(t *testing.T) {
SendMail("zotacrtx5@gmail.com", "Test Subjectp", "Test Message")
}

View File

@ -1,449 +0,0 @@
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"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
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, onStatus paymentsvc.StatusHandler) (*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, onStatus)
}
func (c *Client) doPayment(ctx context.Context, req PaymentRequest, onStatus paymentsvc.StatusHandler) (*paymentsvc.Result, error) {
emitStatus(onStatus, paymentstatus.Starting)
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
}
log.Info("PayBridge authentication successful")
emitStatus(onStatus, paymentstatus.Started)
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)
}
log.WithFields(log.Fields{
"requestId": req.RequestID,
"operation": req.Operation,
"amount": req.Amount,
"currency": req.Currency,
}).Info("PayBridge payment request sent")
emitStatus(onStatus, paymentstatus.RequestSent)
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)
}
messageType := strings.ToLower(head.Type)
switch messageType {
case types.MesTypePaymentStatusUpdate:
var update StatusUpdateEnvelope
if err := json.Unmarshal(raw, &update); err != nil {
return nil, fmt.Errorf("decode payment_status_update: %w", err)
}
log.WithFields(log.Fields{
"status": update.Data.Status,
"code": update.Data.Code,
}).Info("PayBridge status update")
statusCode, known := mapPayBridgeStatus(
update.Data.Status,
update.Data.Code,
)
if !known {
log.WithFields(log.Fields{
"status": update.Data.Status,
"code": update.Data.Code,
}).Warn("Unknown PayBridge status update")
}
emitStatus(onStatus, statusCode)
case types.MesTypePaymentAccepted:
log.Info("PayBridge payment request accepted")
emitStatus(onStatus, paymentstatus.Accepted)
case types.MesTypePaymentResult:
var result PaymentResultEnvelope
// log.Println("PayBridge payment result raw:", string(raw))
if err := json.Unmarshal(raw, &result); err != nil {
return nil, fmt.Errorf("decode payment_result: %w", err)
}
mapped := mapPaymentResult(result)
log.WithFields(log.Fields{
"requestId": mapped.RequestID,
"transactionId": mapped.TransactionID,
"status": mapped.Status,
"success": mapped.Success,
}).Info("PayBridge payment result received")
emitStatus(
onStatus,
mapPayBridgeFinalStatus(mapped.Status, mapped.Success),
)
return mapped, nil
case types.MesTypePaymentError:
var paymentErr PaymentErrorEnvelope
if err := json.Unmarshal(raw, &paymentErr); err != nil {
return nil, fmt.Errorf("decode payment_error: %w", err)
}
mapped := mapPaymentError(req, paymentErr)
log.WithFields(log.Fields{
"requestId": mapped.RequestID,
"transactionId": mapped.TransactionID,
"status": mapped.Status,
"error": mapped.ErrorMessage,
}).Warn("PayBridge payment error received")
emitStatus(
onStatus,
mapPayBridgeFinalStatus(mapped.Status, false),
)
return mapped, 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),
)
}
log.WithFields(log.Fields{
"code": genericErr.Error.Code,
"message": genericErr.Error.Message,
}).Error("PayBridge error message received")
emitStatus(onStatus, paymentstatus.Error)
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.MesTypeAuthSuccess:
log.Info("Additional PayBridge auth_success message received")
default:
log.WithField(
"messageType",
head.Type,
).Warn("Unknown PayBridge intermediate message type")
emitStatus(onStatus, paymentstatus.Processing)
}
}
}
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
}
func emitStatus(
onStatus paymentsvc.StatusHandler,
code string,
) {
if onStatus == nil || code == "" {
return
}
onStatus(paymentsvc.StatusUpdate{
Code: code,
})
}
func mapPayBridgeStatus(
status string,
code string,
) (string, bool) {
normalizedStatus := strings.ToLower(
strings.TrimSpace(status),
)
switch normalizedStatus {
case payBridgeMessageInsertOrSwipeCard,
payBridgeMessageInsertSwipeOrPresentCard:
return paymentstatus.PresentCard, true
case payBridgeMessageInsertCard:
return paymentstatus.InsertCard, true
case payBridgeMessagePleaseWait:
return paymentstatus.PleaseWait, true
case payBridgeMessageDoNotRemoveCard:
return paymentstatus.DoNotRemoveCard, true
case payBridgeMessageTryAnotherInterface:
return paymentstatus.TryAnotherInterface, true
case payBridgeMessagePIN:
return paymentstatus.EnterPIN, true
case payBridgeMessagePINAgain:
return paymentstatus.EnterPINAgain, true
case payBridgeMessageTransactionCancelled:
return paymentstatus.Cancelled, true
case payBridgeMessageProcessing:
return paymentstatus.Processing, true
case payBridgeMessageApproved:
// Это ещё промежуточный статус.
// Финальный успех определяется только по payment_result.
return paymentstatus.Authorized, true
case payBridgeMessageDeclined:
return paymentstatus.Declined, true
}
// Fallback по фактически обнаруженным кодам терминала.
switch strings.TrimSpace(code) {
case payBridgeCodeInsertCard:
return paymentstatus.InsertCard, true
case payBridgeCodeEnterPIN:
return paymentstatus.EnterPIN, true
case payBridgeCodeCancelled:
return paymentstatus.Cancelled, true
case payBridgeCodePleaseWait:
return paymentstatus.PleaseWait, true
case payBridgeCodePresentCard, payBridgeCodePresentCardAlternate:
return paymentstatus.PresentCard, true
case payBridgeCodeEnterPINAgain:
return paymentstatus.EnterPINAgain, true
case payBridgeCodeTryAnotherInterface:
return paymentstatus.TryAnotherInterface, true
case payBridgeCodeDoNotRemoveCard:
return paymentstatus.DoNotRemoveCard, true
}
// Пока назначение кодов 200, 201, 205 и 210 неизвестно.
// Они останутся в логах, но на экране будет общий статус.
return paymentstatus.Processing, false
}
func mapPayBridgeFinalStatus(
status string,
success bool,
) string {
if success && strings.EqualFold(status, payBridgeFinalStatusApproved) {
return paymentstatus.Approved
}
switch strings.ToUpper(status) {
case payBridgeFinalStatusDeclined:
return paymentstatus.Declined
case payBridgeFinalStatusCancelled:
return paymentstatus.Cancelled
case payBridgeFinalStatusTimeout:
return paymentstatus.Timeout
case payBridgeFinalStatusVoided:
return paymentstatus.Voided
case payBridgeFinalStatusVoidedDailyLimitExceeded:
return paymentstatus.DailyLimitExceeded
case payBridgeFinalStatusDailyLimitVoidFailed:
return paymentstatus.VoidFailed
case payBridgeFinalStatusDailyLimitValidationError:
return paymentstatus.LimitValidationError
case payBridgeFinalStatusError, payBridgeFinalStatusFailed:
return paymentstatus.Error
default:
return paymentstatus.Error
}
}

View File

@ -1,9 +0,0 @@
package paybridge
import "errors"
var (
ErrConnectionFailed = errors.New("paybridge connection failed")
ErrAuthFailed = errors.New("paybridge authentication failed")
ErrUnexpectedMessage = errors.New("paybridge unexpected message")
)

View File

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

View File

@ -1,41 +0,0 @@
package paybridge
const (
payBridgeFinalStatusApproved = "APPROVED"
payBridgeFinalStatusDeclined = "DECLINED"
payBridgeFinalStatusCancelled = "CANCELLED"
payBridgeFinalStatusTimeout = "TIMEOUT"
payBridgeFinalStatusVoided = "VOIDED"
payBridgeFinalStatusVoidedDailyLimitExceeded = "VOIDED_DAILY_LIMIT_EXCEEDED"
payBridgeFinalStatusDailyLimitVoidFailed = "DAILY_LIMIT_EXCEEDED_VOID_FAILED"
payBridgeFinalStatusDailyLimitValidationError = "DAILY_LIMIT_VALIDATION_ERROR"
payBridgeFinalStatusError = "ERROR"
payBridgeFinalStatusFailed = "FAILED"
)
const (
payBridgeMessageInsertOrSwipeCard = "insert or swipe card"
payBridgeMessageInsertSwipeOrPresentCard = "insert, swipe or present card"
payBridgeMessageInsertCard = "insert card"
payBridgeMessagePleaseWait = "please wait"
payBridgeMessageDoNotRemoveCard = "please wait. do not remove card"
payBridgeMessageTryAnotherInterface = "please try another interface"
payBridgeMessagePIN = "pin"
payBridgeMessagePINAgain = "pin again"
payBridgeMessageTransactionCancelled = "transaction cancelled"
payBridgeMessageProcessing = "processing"
payBridgeMessageApproved = "approved"
payBridgeMessageDeclined = "declined"
)
const (
payBridgeCodeInsertCard = "101"
payBridgeCodeEnterPIN = "106"
payBridgeCodeCancelled = "124"
payBridgeCodePleaseWait = "1017"
payBridgeCodePresentCard = "1109"
payBridgeCodePresentCardAlternate = "1113"
payBridgeCodeEnterPINAgain = "1129"
payBridgeCodeTryAnotherInterface = "1274"
payBridgeCodeDoNotRemoveCard = "1312"
)

View File

@ -1,125 +0,0 @@
package paybridge
import (
"testing"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
)
func TestMapPayBridgeFinalStatus(t *testing.T) {
tests := []struct {
name string
status string
success bool
want string
}{
{"approved success", "APPROVED", true, paymentstatus.Approved},
{"approved without success", "APPROVED", false, paymentstatus.Error},
{"declined", "DECLINED", false, paymentstatus.Declined},
{"cancelled", "CANCELLED", false, paymentstatus.Cancelled},
{"timeout", "TIMEOUT", false, paymentstatus.Timeout},
{"voided", "VOIDED", false, paymentstatus.Voided},
{"voided daily limit exceeded", "VOIDED_DAILY_LIMIT_EXCEEDED", false, paymentstatus.DailyLimitExceeded},
{"daily limit void failed", "DAILY_LIMIT_EXCEEDED_VOID_FAILED", false, paymentstatus.VoidFailed},
{"daily limit validation error", "DAILY_LIMIT_VALIDATION_ERROR", false, paymentstatus.LimitValidationError},
{"error", "ERROR", false, paymentstatus.Error},
{"failed", "FAILED", false, paymentstatus.Error},
{"unknown", "UNKNOWN", false, paymentstatus.Error},
{"approved case insensitive", "approved", true, paymentstatus.Approved},
{"declined case insensitive", "declined", false, paymentstatus.Declined},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := mapPayBridgeFinalStatus(test.status, test.success)
if got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
func TestMapPayBridgeStatusMessages(t *testing.T) {
tests := []struct {
name string
status string
want string
}{
{"insert or swipe card", "insert or swipe card", paymentstatus.PresentCard},
{"insert swipe or present card", "insert, swipe or present card", paymentstatus.PresentCard},
{"insert card", "insert card", paymentstatus.InsertCard},
{"please wait", "please wait", paymentstatus.PleaseWait},
{"do not remove card", "please wait. do not remove card", paymentstatus.DoNotRemoveCard},
{"try another interface", "please try another interface", paymentstatus.TryAnotherInterface},
{"pin", "pin", paymentstatus.EnterPIN},
{"pin again", "pin again", paymentstatus.EnterPINAgain},
{"transaction cancelled", "transaction cancelled", paymentstatus.Cancelled},
{"processing", "processing", paymentstatus.Processing},
{"approved", "approved", paymentstatus.Authorized},
{"declined", "declined", paymentstatus.Declined},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, matched := mapPayBridgeStatus(test.status, "")
if !matched {
t.Fatal("expected status to match")
}
if got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
func TestMapPayBridgeStatusCodes(t *testing.T) {
tests := []struct {
name string
code string
want string
}{
{"insert card", "101", paymentstatus.InsertCard},
{"enter pin", "106", paymentstatus.EnterPIN},
{"cancelled", "124", paymentstatus.Cancelled},
{"please wait", "1017", paymentstatus.PleaseWait},
{"present card", "1109", paymentstatus.PresentCard},
{"present card alternate", "1113", paymentstatus.PresentCard},
{"enter pin again", "1129", paymentstatus.EnterPINAgain},
{"try another interface", "1274", paymentstatus.TryAnotherInterface},
{"do not remove card", "1312", paymentstatus.DoNotRemoveCard},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, matched := mapPayBridgeStatus("", test.code)
if !matched {
t.Fatal("expected code to match")
}
if got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}
}
func TestMapPayBridgeStatusNormalization(t *testing.T) {
got, matched := mapPayBridgeStatus(" PiN AgAiN ", "")
if !matched || got != paymentstatus.EnterPINAgain {
t.Fatalf("normalized message got (%q, %t), want (%q, true)", got, matched, paymentstatus.EnterPINAgain)
}
got, matched = mapPayBridgeStatus("", " 101 ")
if !matched || got != paymentstatus.InsertCard {
t.Fatalf("trimmed code got (%q, %t), want (%q, true)", got, matched, paymentstatus.InsertCard)
}
}
func TestMapPayBridgeStatusUnknown(t *testing.T) {
got, matched := mapPayBridgeStatus("unknown message", "999")
if matched {
t.Fatal("unknown status and code must not match")
}
if got != paymentstatus.Processing {
t.Fatalf("got %q, want %q", got, paymentstatus.Processing)
}
}

View File

@ -1,65 +0,0 @@
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"`
}
type StatusUpdateEnvelope struct {
Type string `json:"type"`
Data struct {
Status string `json:"status"`
Code string `json:"code"`
Timestamp int64 `json:"timestamp"`
} `json:"data"`
}

View File

@ -1,65 +0,0 @@
package paymentsvc
import (
"context"
"errors"
"sync"
)
var (
ErrPaymentInProgress = errors.New("payment is already in progress")
ErrProviderNotConfigured = errors.New("payment provider is not configured")
)
type StatusUpdate struct {
Code string `json:"code"`
}
type StatusHandler func(StatusUpdate)
type Provider interface {
Sale(
ctx context.Context,
req SaleRequest,
onStatus StatusHandler,
) (*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,
onStatus StatusHandler,
) (*Result, error) {
if s == nil || s.provider == nil {
return nil, ErrProviderNotConfigured
}
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, onStatus)
}

View File

@ -1,37 +0,0 @@
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"`
}

View File

@ -1,280 +0,0 @@
package printer
import (
"bytes"
"reflect"
"strings"
"testing"
)
type receiptTestChar struct {
value byte
center, bold bool
}
type receiptTestOutput struct {
lines []string
chars []receiptTestChar
feeds, cuts int
}
// readReceiptText understands only the commands emitted by this receipt builder.
func readReceiptText(t *testing.T, data []byte) receiptTestOutput {
t.Helper()
var out receiptTestOutput
var line strings.Builder
var center, bold bool
for i := 0; i < len(data); {
if data[i] == ESC || data[i] == GS {
if i+2 >= len(data) {
t.Fatalf("BuildCardholderReceipt control bytes = %v, want complete command", data[i:])
}
prefix, command, value := data[i], data[i+1], data[i+2]
switch {
case prefix == ESC && command == 'a':
if value != 0 && value != CENTER {
t.Errorf("receipt alignment = %d, want left or center", value)
}
center = value == CENTER
case prefix == ESC && command == 'E':
if value != BOLD_OFF && value != BOLD_ON {
t.Errorf("receipt bold = %d, want on or off", value)
}
bold = value == BOLD_ON
case prefix == GS && command == '!':
if value != NORMAL_FONT {
t.Errorf("receipt size = %d, want normal size", value)
}
case prefix == ESC && command == 'd':
out.feeds++
if value != 7 || center || bold || i != len(data)-6 {
t.Errorf("receipt feed = %d, center=%t bold=%t offset=%d, want final seven-line feed in normal state", value, center, bold, i)
}
case prefix == GS && command == 'V':
out.cuts++
if value != 1 || i != len(data)-3 {
t.Errorf("receipt cut = %d at %d, want final GS V 1", value, i)
}
default:
t.Fatalf("BuildCardholderReceipt command = %v, want supported text command", data[i:i+3])
}
i += 3
continue
}
if data[i] == '\n' {
out.lines = append(out.lines, line.String())
line.Reset()
} else {
line.WriteByte(data[i])
out.chars = append(out.chars, receiptTestChar{value: data[i], center: center, bold: bold})
}
i++
}
if line.Len() != 0 || center || bold || out.feeds != 1 || out.cuts != 1 {
t.Errorf("receipt termination: trailing=%q center=%t bold=%t feeds=%d cuts=%d, want normal state and one feed/cut", line.String(), center, bold, out.feeds, out.cuts)
}
return out
}
func buildReceiptText(t *testing.T, entries []ReceiptEntryXML) receiptTestOutput {
t.Helper()
before := append([]ReceiptEntryXML(nil), entries...)
data, err := BuildCardholderReceipt(entries)
if err != nil {
t.Fatalf("BuildCardholderReceipt(%v) error = %v, want nil", entries, err)
}
if !reflect.DeepEqual(entries, before) {
t.Errorf("BuildCardholderReceipt input = %v, want unchanged %v", entries, before)
}
if !bytes.HasSuffix(data, []byte{ESC, 'd', 7, GS, 'V', 1}) {
t.Errorf("BuildCardholderReceipt suffix = %v, want unchanged feed/cut", data)
}
return readReceiptText(t, data)
}
func sampleReceipt(recipient string) []ReceiptEntryXML {
return []ReceiptEntryXML{
{ReceiptEntryId: "Recipient", Value: recipient},
{ReceiptEntryId: "MerchantName", Value: "Standard Test Merchant"},
{ReceiptEntryId: "Aid", Value: "A0000000031010", Label: "AID"},
{ReceiptEntryId: "CardScheme", Value: "VISA DEBIT"},
{ReceiptEntryId: "PanMasked", Value: "************1133", Label: "Card"},
{ReceiptEntryId: "PanSequence", Value: "01", Label: "PAN Seq No"},
{ReceiptEntryId: "TransactionSource", Value: "ICC"},
{ReceiptEntryId: "TransactionType", Value: "SALE"},
{ReceiptEntryId: "TotalAmount", Value: "GBP120.00", Label: "TOTAL"},
{ReceiptEntryId: "CardholderVerification", Value: "PIN VERIFIED"},
{ReceiptEntryId: "TransactionResult", Value: "APPROVED"},
{ReceiptEntryId: "AuthCode", Value: "D0A35D", Label: "Auth Code"},
{ReceiptEntryId: "AuthReference", Value: "ChipDNA-Sale-20260908144731", Label: "Ref"},
{ReceiptEntryId: "MerchantIdMasked", Value: "******7897", Label: "MID"},
{ReceiptEntryId: "TerminalIdMasked", Value: "****9508", Label: "TID"},
{ReceiptEntryId: "TransactionSeqNumber", Value: "00001059", Label: "Transaction Seq. No"},
{ReceiptEntryId: "AuthDateTime", Value: "08/09/2026 14:48:06"},
{ReceiptEntryId: "RetentionMessage", Value: "Please retain for your records"},
}
}
func TestBuildCardholderReceiptCompleteLayout(t *testing.T) {
for _, recipient := range []string{"CARDHOLDER COPY", "MERCHANT COPY"} {
t.Run(recipient, func(t *testing.T) {
entries := sampleReceipt(recipient)
out := buildReceiptText(t, entries)
// Ignore padding used for left/right placement, not semantic row order.
var rows []string
for _, line := range out.lines {
rows = append(rows, strings.Join(strings.Fields(line), " "))
}
rule := strings.Repeat("-", 44)
want := []string{recipient, "Standard Test Merchant", rule, "VISA DEBIT", "Card: ************1133",
"AID: A0000000031010 PAN Seq No: 01", "ICC SALE", rule, "TOTAL: GBP120.00", "PIN VERIFIED APPROVED", rule,
"Auth Code: D0A35D", "Ref: ChipDNA-Sale-20260908144731", "Transaction Seq. No: 00001059",
"MID: ******7897 TID: ****9508", "08/09/2026 14:48:06", rule, "Please retain for your records"}
if !reflect.DeepEqual(rows, want) {
t.Errorf("BuildCardholderReceipt(%q) rows = %q, want %q", recipient, rows, want)
}
for _, entry := range entries {
if !strings.Contains(strings.Join(out.lines, "\n"), entry.Value) {
t.Errorf("BuildCardholderReceipt(%q) lost %s=%q", recipient, entry.ReceiptEntryId, entry.Value)
}
}
for _, line := range out.lines {
if line == "" || len(line) > 44 {
t.Errorf("BuildCardholderReceipt(%q) row = %q, want compact nonempty row within 44 columns", recipient, line)
}
}
if len(out.lines) > 19 {
t.Errorf("BuildCardholderReceipt(%q) rows = %d, want representative receipt at most 19", recipient, len(out.lines))
}
})
}
}
func TestBuildCardholderReceiptFormattingState(t *testing.T) {
out := buildReceiptText(t, sampleReceipt("CARDHOLDER COPY"))
var plain strings.Builder
for _, char := range out.chars {
plain.WriteByte(char.value)
}
for _, tc := range []struct {
text string
center, bold bool
}{
{"CARDHOLDER COPY", true, true}, {"Standard Test Merchant", true, false},
{"VISA DEBIT", false, false}, {"TOTAL:", false, true}, {"GBP120.00", false, true},
{"PIN VERIFIED", false, false}, {"APPROVED", false, true}, {"Auth Code:", false, false},
{"Please retain for your records", true, false},
} {
start := strings.Index(plain.String(), tc.text)
if start < 0 {
t.Fatalf("receipt text lacks %q", tc.text)
}
for _, char := range out.chars[start : start+len(tc.text)] {
if char.center != tc.center || char.bold != tc.bold {
t.Errorf("receipt style for %q = center:%t bold:%t, want center:%t bold:%t", tc.text, char.center, char.bold, tc.center, tc.bold)
break
}
}
}
}
func TestBuildCardholderReceiptMissingFields(t *testing.T) {
for _, tc := range []struct {
name string
entries []ReceiptEntryXML
want []string
}{
{name: "empty"},
{name: "empty values", entries: []ReceiptEntryXML{{ReceiptEntryId: "Aid", Label: "AID"}, {ReceiptEntryId: "Unknown", Label: "Unused"}}},
{name: "one partner", entries: []ReceiptEntryXML{{ReceiptEntryId: "PanSequence", Value: "02"}}, want: []string{"PAN Seq No: 02"}},
{name: "result only", entries: []ReceiptEntryXML{{ReceiptEntryId: "TransactionResult", Value: "DECLINED"}}, want: []string{"DECLINED"}},
} {
t.Run(tc.name, func(t *testing.T) {
out := buildReceiptText(t, tc.entries)
if !reflect.DeepEqual(out.lines, tc.want) {
t.Errorf("BuildCardholderReceipt(%v) rows = %q, want %q", tc.entries, out.lines, tc.want)
}
})
}
}
func TestBuildCardholderReceiptPreservesRemainingEntries(t *testing.T) {
entries := []ReceiptEntryXML{
{ReceiptEntryId: "AuthCode", Value: ""},
{ReceiptEntryId: "FooBar", Value: "first-extra", Priority: "9"},
{ReceiptEntryId: "AuthCode", Value: "primary", Label: "Provider authorization"},
{ReceiptEntryId: "AuthCode", Value: "duplicate", Label: "Second authorization"},
{ReceiptEntryId: "FooBar", Value: "second-extra", Label: "Foo", Priority: "1"},
{ReceiptEntryId: "RetentionMessage", Value: "Retain this"},
{ReceiptEntryId: "RetentionMessage", Value: "Extra retention"},
{ReceiptEntryId: "HTTPCode", Value: "provider-code"},
{Value: "unlabelled-value"},
}
out := buildReceiptText(t, entries)
want := []string{"Provider authorization: primary", "Foo Bar: first-extra", "Second authorization: duplicate",
"Foo: second-extra", "Extra retention", "HTTP Code: provider-code", "unlabelled-value", strings.Repeat("-", 44), "Retain this"}
if !reflect.DeepEqual(out.lines, want) {
t.Errorf("BuildCardholderReceipt remaining rows = %q, want %q", out.lines, want)
}
}
func TestBuildCardholderReceiptPairBoundaries(t *testing.T) {
for _, tc := range []struct {
name, left, right string
paired bool
}{
{"short", "ICC", "SALE", true},
{"exact width", strings.Repeat("L", 20), strings.Repeat("R", 22), true},
{"overflow", strings.Repeat("L", 21), strings.Repeat("R", 22), false},
{"unicode", "TAP \u00c9", "SALE", false},
{"multiline", "ICC\nFALLBACK", "SALE", false},
{"left missing", "", "SALE", false},
{"right missing", "ICC", "", false},
} {
t.Run(tc.name, func(t *testing.T) {
out := buildReceiptText(t, []ReceiptEntryXML{{ReceiptEntryId: "TransactionSource", Value: tc.left}, {ReceiptEntryId: "TransactionType", Value: tc.right}})
var want []string
if tc.paired {
want = []string{tc.left + strings.Repeat(" ", 44-len(tc.left)-len(tc.right)) + tc.right}
} else {
if tc.left != "" {
want = append(want, strings.Split(tc.left, "\n")...)
}
if tc.right != "" {
want = append(want, tc.right)
}
}
if !reflect.DeepEqual(out.lines, want) {
t.Errorf("BuildCardholderReceipt pair(%q,%q) rows = %q, want %q", tc.left, tc.right, out.lines, want)
}
})
}
}
func TestBuildCardholderReceiptLongValuesAndLabels(t *testing.T) {
ref := strings.Repeat("long-reference-", 8)
verify := "CONSUMER DEVICE VERIFICATION WITH ADDITIONAL PROVIDER TEXT"
out := buildReceiptText(t, []ReceiptEntryXML{
{ReceiptEntryId: "CardholderVerification", Value: verify},
{ReceiptEntryId: "TransactionResult", Value: "DECLINED"},
{ReceiptEntryId: "AuthReference", Label: "Provider reference:", Value: ref},
{ReceiptEntryId: "MerchantIdMasked", Label: strings.Repeat("Merchant", 6), Value: "******7897"},
{ReceiptEntryId: "TerminalIdMasked", Value: "****9508"},
})
want := []string{verify, "DECLINED", strings.Repeat("-", 44), "Provider reference: " + ref,
strings.Repeat("Merchant", 6) + ": ******7897", "TID: ****9508"}
if !reflect.DeepEqual(out.lines, want) {
t.Errorf("BuildCardholderReceipt long fields rows = %q, want %q", out.lines, want)
}
}
func TestBuildCardholderReceiptProviderAmounts(t *testing.T) {
for _, amount := range []string{"GBP120.00", "EUR 42,50", "USD1.23", "amount supplied verbatim", strings.Repeat("9", 50)} {
t.Run(amount, func(t *testing.T) {
out := buildReceiptText(t, []ReceiptEntryXML{{ReceiptEntryId: "TotalAmount", Label: "Provider total", Value: amount}})
if len(out.lines) != 1 || !strings.HasPrefix(out.lines[0], "Provider total:") || !strings.HasSuffix(out.lines[0], amount) {
t.Errorf("BuildCardholderReceipt amount(%q) rows = %q, want provider label and unchanged amount", amount, out.lines)
}
})
}
}

View File

@ -1,16 +1,15 @@
// Package lockserver provides functionality for interacting with Assa Abloy lock servers.
package lockserver
import (
"fmt"
"net"
"strings"
"time"
"strings"
log "github.com/sirupsen/logrus"
)
// BuildCommand builds a key encoding request command for the Assa Abloy lock server.
// Build 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")
@ -19,7 +18,7 @@ func (lock *AssaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
return nil
}
// LockSequence checks heartbeat of the Assa Abloy lock server and performs key encoding
// Checks heart beat of the Assa Abloy lock server and perform key encoding
func (lock *AssaLockServer) LockSequence() error {
const funcName = "AssaLockServer.LockSequence"
@ -72,3 +71,4 @@ func parseAssaResponse(raw string) (string, error) {
}
return "Success: " + clean, nil
}

View File

@ -25,11 +25,10 @@ const (
Omnitec = "omnitec"
Salto = "salto"
TLJ = "tlj"
Dormakaba = "kaba"
)
var (
Cert string
Cert string
LockServerURL string
)
@ -39,11 +38,6 @@ type (
LockSequence() error
}
KabaLockServer struct {
encoderAddr string
command []byte
}
AssaLockServer struct {
encoderAddr string
command string
@ -75,24 +69,22 @@ 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)
@ -110,7 +102,7 @@ func sendAndReceive(conn net.Conn, command []byte) (string, error) {
return "", fmt.Errorf("failed to send command: %v", err)
}
conn.SetReadDeadline(time.Now().Add(20 * time.Second))
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
buf := make([]byte, 128)
reader := bufio.NewReader(conn)

View File

@ -7,12 +7,12 @@ import (
"os"
"strconv"
"strings"
"time"
"time"
log "github.com/sirupsen/logrus"
)
// BuildCommand builds key encoding request command for the Omnitec lock server.
// Build 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
}
// LockSequence starts link to the Omnitec lock server and perform key encoding
// 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
}
}

View File

@ -155,7 +155,7 @@ func (lock *SaltoLockServer) LockSequence() error {
reader := bufio.NewReader(conn)
// 1. Send ENQ
log.Infof("LockSequence: sending ENQ")
log.Infof("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("LockSequence: sending encoding command: %q", string(lock.command))
log.Infof("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
logging/logging.go Normal file
View File

@ -0,0 +1,31 @@
package logging
import (
"fmt"
"os"
"time"
log "github.com/sirupsen/logrus"
)
// setupLogging ensures log directory, opens log file, and configures logrus.
// Returns the *os.File so caller can defer its Close().
func SetupLogging(logDir, serviceName, buildVersion string) (*os.File, error) {
fileName := logDir + serviceName + ".log"
f, err := os.OpenFile(fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
if err != nil {
return nil, fmt.Errorf("open log file: %w", err)
}
log.SetOutput(f)
log.SetFormatter(&log.JSONFormatter{
TimestampFormat: time.RFC3339,
})
log.SetLevel(log.InfoLevel)
log.WithFields(log.Fields{
"buildVersion": buildVersion,
}).Info("Logging initialized")
return f, nil
}

192
main.go Normal file
View File

@ -0,0 +1,192 @@
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"
"gitea.futuresens.co.uk/futuresens/hardlink/bootstrap"
"gitea.futuresens.co.uk/futuresens/hardlink/config"
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
"gitea.futuresens.co.uk/futuresens/hardlink/handlers"
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver"
"gitea.futuresens.co.uk/futuresens/hardlink/logging"
"gitea.futuresens.co.uk/futuresens/hardlink/printer"
)
const (
buildVersion = "1.0.28"
serviceName = "hardlink"
)
func main() {
// Load config
config := config.ReadHardlinkConfig()
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 := logging.SetupLogging(config.LogDir, serviceName, buildVersion)
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())
}
}
if status, err = dispenser.DispenserSequence(dispHandle); err != nil {
handlers.FatalError(err)
}
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()
}
}
database, err := bootstrap.OpenDB(&config)
if err != nil {
log.Warnf("DB init failed: %v", err)
}
defer database.Close()
if config.IsPayment {
fmt.Println("Payment processing is enabled")
log.Info("Payment processing is enabled")
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, database, 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)
}
}
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)
}()
}

View File

@ -1,4 +1,4 @@
package creditcall
package payment
import (
"encoding/hex"
@ -9,7 +9,7 @@ import (
"strings"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/hardlink/types"
_ "github.com/denisenkom/go-mssqldb"
log "github.com/sirupsen/logrus"
)
@ -50,12 +50,6 @@ type (
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.
@ -78,10 +72,13 @@ func (ti *TransactionInfo) FillFromTransactionResult(trResult TransactionResultX
}
func (r *PaymentResult) FillFromTransactionResult(trResult TransactionResultXML) {
r.Fields = make(map[string]string)
if r.Fields == nil {
r.Fields = make(map[string]string)
}
for _, e := range trResult.Entries {
switch e.Key {
case types.ReceiptData, types.ReceiptDataMerchant:
// intentionally ignored
@ -99,7 +96,7 @@ func (r *PaymentResult) FillFromTransactionResult(trResult TransactionResultXML)
}
}
// BuildPaymentRedirectURL builds the redirect URL to send the guest to after payment.
// BuildRedirectURL builds the redirect URL to send the guest to after payment.
func BuildPaymentRedirectURL(result map[string]string) string {
res := result[types.TransactionResult]
@ -110,7 +107,7 @@ func BuildPaymentRedirectURL(result map[string]string) string {
log.WithField(types.LogResult, result[types.ConfirmResult]).
Info("Transaction approved and confirmed")
return BuildSuccessURL(result)
return buildSuccessURL(result)
}
// Not confirmed
@ -125,15 +122,6 @@ func BuildPaymentRedirectURL(result map[string]string) string {
}
func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
approved, persist := PreauthDecision(result)
if approved {
return BuildSuccessURL(result), persist
}
return BuildFailureURL(result[types.TransactionResult], result[types.Errors]), false
}
// PreauthDecision preserves the returned-type approval and persistence rules.
func PreauthDecision(result map[string]string) (approved, persist bool) {
res := result[types.TransactionResult]
tType := result[types.TransactionType]
@ -145,7 +133,7 @@ func PreauthDecision(result map[string]string) (approved, persist bool) {
log.WithField(types.LogResult, result[types.TransactionResult]).
Info("Account verification approved")
return true, false
return buildSuccessURL(result), false
// Transaction type Sale?
case strings.EqualFold(tType, types.SaleTransactionType):
@ -153,19 +141,18 @@ func PreauthDecision(result map[string]string) (approved, persist bool) {
log.WithField(types.LogResult, result[types.ConfirmResult]).
Info("Amount preauthorized successfully")
return true, true
return buildSuccessURL(result), true
}
}
// Not approved
return false, false
return BuildFailureURL(res, result[types.Errors]), false
}
func BuildSuccessURL(result map[string]string) string {
func buildSuccessURL(result map[string]string) string {
q := url.Values{}
q.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("CardNumber", hex.EncodeToString([]byte(result[types.PAN_MASKED])))
q.Set("ExpiryDate", hex.EncodeToString([]byte(result[types.EXPIRY_DATE])))
q.Set("TxnReference", result[types.Reference])
q.Set("CardHash", hex.EncodeToString([]byte(result[types.CardHash])))
q.Set("CardReference", hex.EncodeToString([]byte(result[types.CardReference])))
@ -177,7 +164,9 @@ func BuildSuccessURL(result map[string]string) string {
func BuildFailureURL(msgType, description string) string {
q := url.Values{}
description = FailureDescription(msgType, description)
if msgType != "" {
description = fmt.Sprintf("Transaction %s", strings.ToLower(msgType))
}
if description != "" {
msgType = types.ResultError
}
@ -192,12 +181,3 @@ func BuildFailureURL(msgType, description string) string {
RawQuery: q.Encode(),
}).String()
}
// FailureDescription preserves the description used by the legacy payment flow
// without requiring structured consumers to construct a redirect URL.
func FailureDescription(msgType, description string) string {
if msgType != "" {
return fmt.Sprintf("Transaction %s", strings.ToLower(msgType))
}
return description
}

View File

@ -1,4 +1,4 @@
package creditcall
package payment
import (
"bytes"
@ -11,8 +11,8 @@ import (
"strings"
"time"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/db"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/hardlink/db"
"gitea.futuresens.co.uk/futuresens/hardlink/types"
log "github.com/sirupsen/logrus"
)
@ -21,7 +21,7 @@ const (
)
/* ==============================
Public Entry Point
Public Entry Point (LEGACY)
============================== */
func ReleasePreauthorizations(database *sql.DB) error {
@ -78,7 +78,6 @@ func handlePreauthRelease(
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) {
@ -87,7 +86,8 @@ func handlePreauthRelease(
// Only void approved + uncommitted
if !isVoidable(info) {
log.Infof("Preauth %s not eligible for void (res=%s state=%s)", ref, info.transactionRes, info.transactionState)
log.Infof("Preauth %s not eligible for void (res=%s state=%s)",
ref, info.transactionRes, info.transactionState)
return nil
}

View File

@ -1,41 +0,0 @@
// Package paymentstatus defines progress codes emitted by Hardlink's
// POST /api/payment/sale stream. These codes are not authoritative final
// transaction decisions: clients may continue check-in only after receiving a
// final result frame whose response.data begins with /successful.
package paymentstatus
const (
Starting = "PAYMENT_STARTING"
Started = "PAYMENT_STARTED"
RequestSent = "PAYMENT_REQUEST_SENT"
Accepted = "PAYMENT_ACCEPTED"
PresentCard = "PAYMENT_PRESENT_CARD"
InsertCard = "PAYMENT_INSERT_CARD"
SwipeCard = "PAYMENT_SWIPE_CARD"
EnterPIN = "PAYMENT_ENTER_PIN"
EnterPINAgain = "PAYMENT_ENTER_PIN_AGAIN"
PleaseWait = "PAYMENT_PLEASE_WAIT"
DoNotRemoveCard = "PAYMENT_DO_NOT_REMOVE_CARD"
RemoveCard = "PAYMENT_REMOVE_CARD"
TryAnotherInterface = "PAYMENT_TRY_ANOTHER_INTERFACE"
Processing = "PAYMENT_PROCESSING"
Authorized = "PAYMENT_AUTHORIZED"
Approved = "PAYMENT_APPROVED"
Cancelling = "PAYMENT_CANCELLING"
Cancelled = "PAYMENT_CANCELLED"
Declined = "PAYMENT_DECLINED"
Expired = "PAYMENT_EXPIRED"
Timeout = "PAYMENT_TIMEOUT"
SignatureRequired = "PAYMENT_SIGNATURE_REQUIRED"
SignatureRejecting = "PAYMENT_SIGNATURE_REJECTING"
SignatureRejected = "PAYMENT_SIGNATURE_REJECTED"
Voided = "PAYMENT_VOIDED"
DailyLimitExceeded = "PAYMENT_DAILY_LIMIT_EXCEEDED"
VoidFailed = "PAYMENT_VOID_FAILED"
LimitValidationError = "PAYMENT_LIMIT_VALIDATION_ERROR"
Error = "PAYMENT_ERROR"
TerminalUnavailable = "PAYMENT_TERMINAL_UNAVAILABLE"
DojoNotificationPrefix = "PAYMENT_DOJO_NOTIFICATION_"
DojoStatusPrefix = "PAYMENT_DOJO_STATUS_"
)

View File

@ -1,84 +0,0 @@
package paymentstatus_test
import (
"strings"
"testing"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
)
func TestFixedStatusValues(t *testing.T) {
statuses := []struct {
name string
value string
want string
}{
{"Starting", paymentstatus.Starting, "PAYMENT_STARTING"},
{"Started", paymentstatus.Started, "PAYMENT_STARTED"},
{"RequestSent", paymentstatus.RequestSent, "PAYMENT_REQUEST_SENT"},
{"Accepted", paymentstatus.Accepted, "PAYMENT_ACCEPTED"},
{"PresentCard", paymentstatus.PresentCard, "PAYMENT_PRESENT_CARD"},
{"InsertCard", paymentstatus.InsertCard, "PAYMENT_INSERT_CARD"},
{"SwipeCard", paymentstatus.SwipeCard, "PAYMENT_SWIPE_CARD"},
{"EnterPIN", paymentstatus.EnterPIN, "PAYMENT_ENTER_PIN"},
{"EnterPINAgain", paymentstatus.EnterPINAgain, "PAYMENT_ENTER_PIN_AGAIN"},
{"PleaseWait", paymentstatus.PleaseWait, "PAYMENT_PLEASE_WAIT"},
{"DoNotRemoveCard", paymentstatus.DoNotRemoveCard, "PAYMENT_DO_NOT_REMOVE_CARD"},
{"RemoveCard", paymentstatus.RemoveCard, "PAYMENT_REMOVE_CARD"},
{"TryAnotherInterface", paymentstatus.TryAnotherInterface, "PAYMENT_TRY_ANOTHER_INTERFACE"},
{"Processing", paymentstatus.Processing, "PAYMENT_PROCESSING"},
{"Authorized", paymentstatus.Authorized, "PAYMENT_AUTHORIZED"},
{"Approved", paymentstatus.Approved, "PAYMENT_APPROVED"},
{"Cancelling", paymentstatus.Cancelling, "PAYMENT_CANCELLING"},
{"Cancelled", paymentstatus.Cancelled, "PAYMENT_CANCELLED"},
{"Declined", paymentstatus.Declined, "PAYMENT_DECLINED"},
{"Expired", paymentstatus.Expired, "PAYMENT_EXPIRED"},
{"Timeout", paymentstatus.Timeout, "PAYMENT_TIMEOUT"},
{"SignatureRequired", paymentstatus.SignatureRequired, "PAYMENT_SIGNATURE_REQUIRED"},
{"SignatureRejecting", paymentstatus.SignatureRejecting, "PAYMENT_SIGNATURE_REJECTING"},
{"SignatureRejected", paymentstatus.SignatureRejected, "PAYMENT_SIGNATURE_REJECTED"},
{"Voided", paymentstatus.Voided, "PAYMENT_VOIDED"},
{"DailyLimitExceeded", paymentstatus.DailyLimitExceeded, "PAYMENT_DAILY_LIMIT_EXCEEDED"},
{"VoidFailed", paymentstatus.VoidFailed, "PAYMENT_VOID_FAILED"},
{"LimitValidationError", paymentstatus.LimitValidationError, "PAYMENT_LIMIT_VALIDATION_ERROR"},
{"Error", paymentstatus.Error, "PAYMENT_ERROR"},
{"TerminalUnavailable", paymentstatus.TerminalUnavailable, "PAYMENT_TERMINAL_UNAVAILABLE"},
}
seen := make(map[string]string, len(statuses))
for _, status := range statuses {
t.Run(status.name, func(t *testing.T) {
if status.value != status.want {
t.Fatalf("got %q, want %q", status.value, status.want)
}
if !strings.HasPrefix(status.value, "PAYMENT_") {
t.Fatalf("status %q does not have PAYMENT_ prefix", status.value)
}
})
if previous, exists := seen[status.value]; exists {
t.Errorf("%s and %s have duplicate value %q", previous, status.name, status.value)
}
seen[status.value] = status.name
}
}
func TestDojoPrefixes(t *testing.T) {
if paymentstatus.DojoNotificationPrefix != "PAYMENT_DOJO_NOTIFICATION_" {
t.Fatalf(
"DojoNotificationPrefix = %q, want %q",
paymentstatus.DojoNotificationPrefix,
"PAYMENT_DOJO_NOTIFICATION_",
)
}
if paymentstatus.DojoStatusPrefix != "PAYMENT_DOJO_STATUS_" {
t.Fatalf(
"DojoStatusPrefix = %q, want %q",
paymentstatus.DojoStatusPrefix,
"PAYMENT_DOJO_STATUS_",
)
}
if paymentstatus.DojoNotificationPrefix == paymentstatus.DojoStatusPrefix {
t.Fatal("Dojo prefixes must differ")
}
}

View File

@ -13,7 +13,6 @@ import (
"path/filepath"
"strings"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
"github.com/alexbrainman/printer"
log "github.com/sirupsen/logrus"
imagedraw "golang.org/x/image/draw"
@ -40,7 +39,6 @@ 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"`
}
@ -95,30 +93,7 @@ func ParseCardholderReceipt(data []byte) ([]ReceiptEntryXML, error) {
return tr.ReceiptEntries.ReceiptEntry, nil
}
func readTicketLayout() LayoutOptions {
var layout LayoutOptions
executablePath, err := os.Executable()
if err != nil {
errorhandlers.FatalError(fmt.Errorf("failed to locate application executable: %v", err))
}
layoutName := filepath.Join(filepath.Dir(executablePath), "TicketLayout.xml")
data, err := os.ReadFile(layoutName)
if err != nil {
errorhandlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err))
}
if err := xml.Unmarshal(data, &layout); err != nil {
errorhandlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err))
}
return layout
}
func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
Layout = readTicketLayout()
var buf bytes.Buffer
// shortcuts
@ -166,11 +141,6 @@ 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)
@ -197,36 +167,7 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
return buf.Bytes(), nil
}
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 PrintSaleReceipt(receipt string) {
if len(receipt) == 0 {
log.Warn("Empty sale receipt, skipping print")
return
}
var buf bytes.Buffer
buf.WriteString(receipt)
buf.WriteByte('\n')
buf.Write([]byte{ESC, 'd', 7})
buf.Write([]byte{GS, 'V', 1})
if err := SendToPrinter(buf.Bytes()); err != nil {
log.Errorf("SendToPrinter error: %v", err)
}
}
func printCardholderReceipt(cardholderReceipt string) error {
func PrintCardholderReceipt(cardholderReceipt string) error {
receiptEntries, err := ParseCardholderReceipt([]byte(cardholderReceipt))
if err != nil {
return fmt.Errorf("ParseCardholderReceipt: %w", err)
@ -245,174 +186,95 @@ func printCardholderReceipt(cardholderReceipt string) error {
return nil
}
// receiptColumns follows the existing 44-column normal-size text convention.
const receiptColumns = 44
func BuildCardholderReceipt(entries []ReceiptEntryXML) ([]byte, error) {
var buf bytes.Buffer
rendered := make([]bool, len(entries))
take := func(id string) string {
for i, entry := range entries {
if !rendered[i] && entry.ReceiptEntryId == id && entry.Value != "" {
rendered[i] = true
return receiptEntryText(entry)
}
}
return ""
}
// Set and restore only text state; leave printer setup and transport alone.
normal := func() { buf.Write([]byte{ESC, 'a', 0, ESC, 'E', BOLD_OFF, GS, '!', NORMAL_FONT}) }
normal()
hasRows, newSection := false, false
beginLine := func(center bool) {
if hasRows && newSection {
buf.WriteString(strings.Repeat("-", receiptColumns) + "\n")
}
hasRows, newSection = true, false
if center {
buf.Write([]byte{ESC, 'a', CENTER})
}
}
text := func(value string, bold bool) {
if bold {
buf.Write([]byte{ESC, 'E', BOLD_ON})
}
buf.WriteString(value)
if bold {
buf.Write([]byte{ESC, 'E', BOLD_OFF})
}
}
line := func(value string, bold, center bool) {
if value == "" {
return
}
beginLine(center)
text(value, bold)
write := func(b []byte) { buf.Write(b) }
// writeStr := func(s string) { buf.WriteString(s) }
writeln := func(s string) {
buf.WriteString(s)
buf.WriteByte('\n')
normal()
}
pair := func(left, right string, boldRight bool) {
if gap, fits := receiptPairGap(left, right); fits {
beginLine(false)
text(left+gap, false)
text(right, boldRight)
buf.WriteByte('\n')
normal()
return
}
line(left, false, false)
line(right, boldRight, false)
}
line(take("Recipient"), true, true)
line(take("MerchantName"), false, true)
newSection = true
line(take("CardScheme"), false, false)
line(take("PanMasked"), false, false)
pair(take("Aid"), take("PanSequence"), false)
pair(take("TransactionSource"), take("TransactionType"), false)
newSection = true
// Align the provider amount without parsing or modifying its value.
for i, entry := range entries {
if entry.ReceiptEntryId == "TotalAmount" && entry.Value != "" {
rendered[i] = true
label := entry.Label
if label == "" {
label = "TOTAL"
}
label = strings.TrimSuffix(label, ":") + ":"
if gap, fits := receiptPairGap(label, entry.Value); fits {
line(label+gap+entry.Value, true, false)
} else {
line(receiptEntryText(entry), true, false)
}
break
}
// Build a lookup map by ReceiptEntryId
m := make(map[string]ReceiptEntryXML, len(entries))
for _, e := range entries {
m[e.ReceiptEntryId] = e
}
pair(take("CardholderVerification"), take("TransactionResult"), true)
newSection = true
line(take("AuthCode"), false, false)
line(take("AuthReference"), false, false)
line(take("TransactionSeqNumber"), false, false)
pair(take("MerchantIdMasked"), take("TerminalIdMasked"), false)
line(take("AuthDateTime"), false, false)
footer := take("RetentionMessage")
// Preserve every remaining occurrence, including unknown IDs, in provider order.
for i, entry := range entries {
if !rendered[i] && entry.Value != "" {
line(receiptEntryText(entry), false, false)
}
// Utility to center text
// center := func(s string) {
// write([]byte{ESC, 'a', CENTER})
// writeln(s)
// write([]byte{ESC, 'a', 0})
// }
// Utility for bold lines
// boldOn := func() { write([]byte{GS, '!', WIDER_FONT}) }
// boldOff := func() { write([]byte{GS, '!', NORMAL_FONT}) }
// boldOn()
// 1) Header: CARDHOLDER COPY
writeln(m["Recipient"].Value)
// writeStr("\n\n")
// 2) Merchant name
writeln(m["MerchantName"].Value)
// writeStr("\n\n")
// 3) AID
writeln("AID: " + m["Aid"].Value)
// 4) Card scheme & pan
writeln(fmt.Sprintf("%s Card: %s", m["CardScheme"].Value, m["PanMasked"].Value))
// 5) PAN Seq No
writeln("PAN Seq No: " + m["PanSequence"].Value)
// 6) Transaction source
writeln(m["TransactionSource"].Value)
// 7) Transaction type (Sale/Account Verification)
writeln(m["TransactionType"].Value)
// 8) Total
// assuming value like "GBP1.50" — insert space after currency
total := m["TotalAmount"].Value
if !strings.HasPrefix(total, "GBP") && len(total) > 3 {
total = total[:3] + " " + total[3:]
}
newSection = true
line(footer, false, true)
normal()
buf.Write([]byte{ESC, 'd', 7})
buf.Write([]byte{GS, 'V', 1})
writeln("TOTAL: " + total)
// 9) Cardholder verification
writeln(m["CardholderVerification"].Value)
// 10) Approved/Declined
writeln(m["TransactionResult"].Value)
// 11) Auth code
writeln("Auth Code: " + m["AuthCode"].Value)
// 12) Reference
writeln("Ref: " + m["AuthReference"].Value)
// 13) Merchant & terminal IDs
writeln("MID: " + m["MerchantIdMasked"].Value)
writeln("TID: " + m["TerminalIdMasked"].Value)
// 14) Date/time
writeln(m["AuthDateTime"].Value)
// 15) Retention message
writeln(m["RetentionMessage"].Value)
// boldOff()
// finally feed & cut
write([]byte{ESC, 'd', 7}) // Feed 5 lines
write([]byte{GS, 'V', 1})
return buf.Bytes(), nil
}
func receiptEntryText(entry ReceiptEntryXML) string {
label := entry.Label
if label == "" {
switch entry.ReceiptEntryId {
case "Recipient", "MerchantName", "CardScheme", "TransactionSource", "TransactionType",
"CardholderVerification", "TransactionResult", "AuthDateTime", "RetentionMessage":
return entry.Value
case "PanMasked":
label = "Card"
case "Aid":
label = "AID"
case "PanSequence":
label = "PAN Seq No"
case "TotalAmount":
label = "TOTAL"
case "AuthCode":
label = "Auth Code"
case "AuthReference":
label = "Ref"
case "TransactionSeqNumber":
label = "Transaction Seq. No"
case "MerchantIdMasked":
label = "MID"
case "TerminalIdMasked":
label = "TID"
default:
var readable strings.Builder
runes := []rune(entry.ReceiptEntryId)
for i, r := range runes {
if i > 0 && r >= 'A' && r <= 'Z' &&
((runes[i-1] >= 'a' && runes[i-1] <= 'z') ||
(i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z')) {
readable.WriteByte(' ')
}
if r == '_' || r == '-' {
r = ' '
}
readable.WriteRune(r)
}
label = strings.TrimSpace(readable.String())
}
}
if label == "" {
return entry.Value
}
return strings.TrimSuffix(label, ":") + ": " + entry.Value
}
func receiptPairGap(left, right string) (string, bool) {
if left == "" || right == "" || len(left)+2+len(right) > receiptColumns {
return "", false
}
// Only printable ASCII has a predictable single-column width here.
for _, r := range left + right {
if r < ' ' || r > '~' {
return "", false
}
}
return strings.Repeat(" ", receiptColumns-len(left)-len(right)), true
}
func printLogo(path string) ([]byte, error) {
const maxLogoWidth = 384
f, err := os.Open(path)

View File

@ -2,116 +2,6 @@
builtVersion is a const in main.go
#### v2.1.3 - 30 September 2026
feat(logging): add weekly log rotation and retention
#### v2.1.2 - 30 September 2026
fix(dispenser): scan ACK responses without assuming 3-byte reads
#### v2.1.1 - 28 September 2026
feat(dispenser): add worker-owned idle card prestaging
#### v2.1.0 - 28 September 2026
fix(dispenser): tolerate unusable AP observations during card preparation
#### v2.0.3 - 24 September 2026
fix(dispenser): recover stuck card preparation with reset
#### v2.0.2 - 22 September 2026
fix(dispenser): retry on transient prepare failure
#### v2.0.1 - 10 September 2026
feat: add CreditCall preauth streaming
#### v2.0.0 - 08 September 2026
feat: stream CreditCall payment progress and results
#### v1.3.6 - 28 August 2026
Refactor PDQ lookup for payment providers
#### v1.3.5 - 23 July 2026
fixed dispenser delivery confirmation
#### v1.3.4 - 23 July 2026
added support for Dojo terminal-unavailable responses
#### v1.3.3 - 21 July 2026
added PDQ status streaming to the payment flow to allow the front end to display the status of the PDQ terminal
#### 1.3.2 - 20 July 2026
make dojo decline payment in case of signature required
#### 1.3.1 - 17 July 2026
added receipt printing functionality to the Dojo and Paybridge payment flow
#### 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

View File

@ -7,14 +7,11 @@ import (
)
const (
ServiceName = "hardlink"
DateOnly = "2006-01-02"
CustomLayout = "2006-01-02 15:04:05 -0700"
LinkStartTransactionStream = "http://127.0.0.1:18181/start-transaction-stream/"
LinkStartTransaction = "http://127.0.0.1:18181/start-transaction/"
LinkConfirmTransaction = "http://127.0.0.1:18181/confirm-transaction/"
LinkTakePreauthorization = "http://127.0.0.1:18181/start-transaction/"
LinkTakePayment = "http://127.0.0.1:18181/start-and-confirm-transaction/"
LinkTransactionInformation = "http://127.0.0.1:18181/transaction-information/"
LinkChipDNAStatus = "http://127.0.0.1:18181/chipdna-status/"
LinkVoidTransaction = "http://127.0.0.1:18181/void-transaction/"
// Transaction types
SaleTransactionType = "sale"
@ -24,7 +21,6 @@ const (
ResultApproved = "approved"
ResultDeclined = "declined"
ResultCancelled = "cancelled"
ResultCanceled = "canceled"
ResultPending = "pending"
ResultStateUncommitted = "uncommitted"
ResultStateVoided = "voided"
@ -36,14 +32,12 @@ const (
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"
PAN_MASKED = "PAN_MASKED"
EXPIRY_DATE = "EXPIRY_DATE"
TransactionResult = "TRANSACTION_RESULT"
TransactionType = "TRANSACTION_TYPE"
TransactionState = "TRANSACTION_STATE"
@ -51,25 +45,6 @@ const (
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"