Compare commits
1 Commits
developmen
...
preauth
| Author | SHA1 | Date | |
|---|---|---|---|
| 56d33b167a |
3
.gitignore
vendored
3
.gitignore
vendored
@ -29,7 +29,6 @@ _obj
|
|||||||
_test
|
_test
|
||||||
.vscode/
|
.vscode/
|
||||||
ChipDNAClient/
|
ChipDNAClient/
|
||||||
docs/
|
|
||||||
|
|
||||||
# Architecture specific extensions/prefixes
|
# Architecture specific extensions/prefixes
|
||||||
*.[568vq]
|
*.[568vq]
|
||||||
@ -43,6 +42,6 @@ _cgo_export.*
|
|||||||
|
|
||||||
_testmain.go
|
_testmain.go
|
||||||
|
|
||||||
*.exe*
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.prof
|
*.prof
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -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 ""
|
|
||||||
}
|
|
||||||
120
cms/cms.go
120
cms/cms.go
@ -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
|
|
||||||
}
|
|
||||||
233
cms/cms_test.go
233
cms/cms_test.go
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
103
config/config.go
103
config/config.go
@ -1,103 +0,0 @@
|
|||||||
// Package config handles reading and parsing configuration from config.yml.
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
yaml "gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConfigRec holds values from config.yml.
|
|
||||||
type ConfigRec struct {
|
|
||||||
Port int `yaml:"port"`
|
|
||||||
LockserverURL string `yaml:"lockservUrl"`
|
|
||||||
LockType string `yaml:"lockType"`
|
|
||||||
EncoderAddress string `yaml:"encoderAddr"`
|
|
||||||
Cert string `yaml:"cert"`
|
|
||||||
DispenserPort string `yaml:"dispensPort"`
|
|
||||||
DispenserAdrr string `yaml:"dispensAddr"`
|
|
||||||
PrinterName string `yaml:"printerName"`
|
|
||||||
LogDir string `yaml:"logdir"`
|
|
||||||
Dbport int `yaml:"dbport"`
|
|
||||||
Dbname string `yaml:"dbname"`
|
|
||||||
Dbuser string `yaml:"dbuser"`
|
|
||||||
Dbpassword string `yaml:"dbpassword"`
|
|
||||||
CMSBaseURL string `yaml:"cmsurl"`
|
|
||||||
IsPayment bool `yaml:"isPayment"`
|
|
||||||
TestMode bool `yaml:"testMode"`
|
|
||||||
Hotel string `yaml:"hotel"`
|
|
||||||
Kiosk int `yaml:"kiosk"`
|
|
||||||
SendErrorEmails []string `yaml:"senderroremails"`
|
|
||||||
PaymentProvider string `yaml:"paymentProvider"`
|
|
||||||
TimeoutSeconds int `yaml:"timeoutSeconds"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadHardlinkConfig reads config.yml and applies defaults.
|
|
||||||
func ReadHardlinkConfig() ConfigRec {
|
|
||||||
var cfg ConfigRec
|
|
||||||
const configName = "config.yml"
|
|
||||||
const defaultPort = 9091
|
|
||||||
sep := string(os.PathSeparator)
|
|
||||||
|
|
||||||
data, err := os.ReadFile(configName)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("ReadConfig %s: %v", configName, err)
|
|
||||||
} else if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
||||||
log.Warnf("Unmarshal config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Port == 0 {
|
|
||||||
cfg.Port = defaultPort
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.LockType == "" {
|
|
||||||
errorhandlers.FatalError(fmt.Errorf("LockType is required in %s", configName))
|
|
||||||
}
|
|
||||||
cfg.LockType = strings.ToLower(cfg.LockType)
|
|
||||||
|
|
||||||
if cfg.LogDir == "" {
|
|
||||||
cfg.LogDir = "./logs" + sep
|
|
||||||
} else if !strings.HasSuffix(cfg.LogDir, sep) {
|
|
||||||
cfg.LogDir += sep
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Dbport <= 0 || cfg.Dbuser == "" || cfg.Dbname == "" || cfg.Dbpassword == "" {
|
|
||||||
log.Warnf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.PaymentProvider = strings.ToLower(strings.TrimSpace(cfg.PaymentProvider))
|
|
||||||
|
|
||||||
if cfg.TimeoutSeconds <= 0 {
|
|
||||||
cfg.TimeoutSeconds = 300
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func ReadPreauthReleaserConfig() ConfigRec {
|
|
||||||
var cfg ConfigRec
|
|
||||||
const configName = "config.yml"
|
|
||||||
sep := string(os.PathSeparator)
|
|
||||||
|
|
||||||
data, err := os.ReadFile(configName)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("ReadConfig %s: %v", configName, err)
|
|
||||||
} else if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
||||||
log.Warnf("Unmarshal config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Dbport <= 0 || cfg.Dbuser == "" || cfg.Dbname == "" || cfg.Dbpassword == "" {
|
|
||||||
errorhandlers.FatalErrorWithMail(cfg.Hotel, cfg.Kiosk, "PreauthReleaser Database Configuration Error", fmt.Errorf("Database config (dbport, dbuser, dbname, dbpassword) are required in %s", configName))
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.LogDir == "" {
|
|
||||||
cfg.LogDir = "./logs" + sep
|
|
||||||
} else if !strings.HasSuffix(cfg.LogDir, sep) {
|
|
||||||
cfg.LogDir += sep
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
294
dispenser/dispenser.go
Normal file
294
dispenser/dispenser.go
Normal 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 := ""
|
||||||
|
for _, p := range posStatus {
|
||||||
|
statusMsg, exists := p.mapper[p.value]
|
||||||
|
if !exists {
|
||||||
|
statusMsg = "Unknown status"
|
||||||
|
}
|
||||||
|
if p.value != 0x30 {
|
||||||
|
result += fmt.Sprintf("Status: %s; ", statusMsg)
|
||||||
|
}
|
||||||
|
if p.pos == 4 && p.value == 0x38 {
|
||||||
|
return result, fmt.Errorf("Card well empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
if len(statusResp) == 3 && statusResp[0] == ACK && statusResp[1] == Address[0] && statusResp[2] == Address[1] {
|
||||||
|
return "active;", nil
|
||||||
|
} else if len(statusResp) > 0 && statusResp[0] == NAK {
|
||||||
|
return "", fmt.Errorf("negative response from dispenser")
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("unexpected response status: % X", statusResp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateBCC computes the Block Check Character (BCC) as the XOR of all bytes from STX to ETX.
|
||||||
|
func calculateBCC(data []byte) byte {
|
||||||
|
var bcc byte
|
||||||
|
for _, b := range data {
|
||||||
|
bcc ^= b
|
||||||
|
}
|
||||||
|
return bcc
|
||||||
|
}
|
||||||
|
|
||||||
|
func createPacket(address []byte, command []byte) []byte {
|
||||||
|
packet := []byte{STX}
|
||||||
|
packet = append(packet, address...) // Address bytes
|
||||||
|
packet = append(packet, space) // Space character
|
||||||
|
packet = append(packet, command...)
|
||||||
|
packet = append(packet, ETX)
|
||||||
|
bcc := calculateBCC(packet)
|
||||||
|
packet = append(packet, bcc)
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCheckRF(address []byte) []byte {
|
||||||
|
return createPacket(address, []byte{STX, 0x52, 0x46})
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCheckAP(address []byte) []byte {
|
||||||
|
return createPacket(address, []byte{STX, 0x41, 0x50})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendAndReceive(port *serial.Port, packet []byte, delay time.Duration) ([]byte, error) {
|
||||||
|
n, err := port.Write(packet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error writing to port: %w", err)
|
||||||
|
}
|
||||||
|
// log.Printf("TX %d bytes: % X", n, packet[:n])
|
||||||
|
|
||||||
|
time.Sleep(delay) // Wait for the dispenser to process the command
|
||||||
|
|
||||||
|
buf := make([]byte, 128)
|
||||||
|
n, err = port.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading from port: %w", err)
|
||||||
|
}
|
||||||
|
resp := buf[:n]
|
||||||
|
// log.Printf("RX %d bytes: % X", n, buf[:n])
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitializeDispenser() (*serial.Port, error) {
|
||||||
|
const funcName = "initializeDispenser"
|
||||||
|
serialConfig := &serial.Config{
|
||||||
|
Name: SerialPort,
|
||||||
|
Baud: baudRate,
|
||||||
|
ReadTimeout: time.Second * 2,
|
||||||
|
}
|
||||||
|
port, err := serial.OpenPort(serialConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error opening dispenser COM port: %w", err)
|
||||||
|
}
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DispenserSequence(port *serial.Port) (string, error) {
|
||||||
|
const funcName = "dispenserSequence"
|
||||||
|
var result string
|
||||||
|
|
||||||
|
// Check dispenser status
|
||||||
|
status, err := CheckDispenserStatus(port)
|
||||||
|
if err != nil {
|
||||||
|
return status, fmt.Errorf("[%s] error checking dispenser status: %v", funcName, err)
|
||||||
|
}
|
||||||
|
result += status
|
||||||
|
|
||||||
|
// Send card to encoder position
|
||||||
|
status, err = CardToEncoderPosition(port)
|
||||||
|
if err != nil {
|
||||||
|
return status, fmt.Errorf("[%s] error sending card to encoder position: %v", funcName, err)
|
||||||
|
}
|
||||||
|
result += "; " + status
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// if dispenser is not responding, I should repeat the command
|
||||||
|
func CheckDispenserStatus(port *serial.Port) (string, error) {
|
||||||
|
const funcName = "checkDispenserStatus"
|
||||||
|
var result string
|
||||||
|
checkCmd := buildCheckAP(Address)
|
||||||
|
enq := append([]byte{ENQ}, Address...)
|
||||||
|
|
||||||
|
// Send check command (AP)
|
||||||
|
statusResp, err := sendAndReceive(port, checkCmd, delay)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error sending check command: %v", err)
|
||||||
|
}
|
||||||
|
if len(statusResp) == 0 {
|
||||||
|
return "", fmt.Errorf("no response from dispenser")
|
||||||
|
}
|
||||||
|
status, err := checkStatus(statusResp)
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
result += "; " + status
|
||||||
|
|
||||||
|
// Send ENQ+ADDR to prompt device to execute the command.
|
||||||
|
statusResp, err = sendAndReceive(port, enq, delay)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("error sending ENQ: %v", err)
|
||||||
|
}
|
||||||
|
if len(statusResp) == 0 {
|
||||||
|
return "", fmt.Errorf("no response from dispenser")
|
||||||
|
}
|
||||||
|
status, err = checkStatus(statusResp)
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
result += status
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CardToEncoderPosition(port *serial.Port) (string, error) {
|
||||||
|
const funcName = "cartToEncoderPosition"
|
||||||
|
enq := append([]byte{ENQ}, Address...)
|
||||||
|
|
||||||
|
//Send Dispense card to encoder position (FC7) ---
|
||||||
|
dispenseCmd := createPacket(Address, commandFC7)
|
||||||
|
log.Println("Send card to encoder position")
|
||||||
|
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error sending card to encoder position: %v", err)
|
||||||
|
}
|
||||||
|
_, err = checkStatus(statusResp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
//Send ENQ to prompt device ---
|
||||||
|
_, err = port.Write(enq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(delay)
|
||||||
|
|
||||||
|
//Check card position status
|
||||||
|
status, err := CheckDispenserStatus(port)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CardOutOfMouth(port *serial.Port) (string, error) {
|
||||||
|
const funcName = "CardOutOfMouth"
|
||||||
|
enq := append([]byte{ENQ}, Address...)
|
||||||
|
|
||||||
|
// Send card out of card mouth (FC0) ---
|
||||||
|
dispenseCmd := createPacket(Address, commandFC0)
|
||||||
|
log.Println("Send card to out mouth position")
|
||||||
|
statusResp, err := sendAndReceive(port, dispenseCmd, delay)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error sending out of mouth command: %v", err)
|
||||||
|
}
|
||||||
|
_, err = checkStatus(statusResp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
//Send ENQ to prompt device ---
|
||||||
|
_, err = port.Write(enq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error sending ENQ to prompt device: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(delay)
|
||||||
|
|
||||||
|
//Check card position status
|
||||||
|
status, err := CheckDispenserStatus(port)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
5
go.mod
5
go.mod
@ -3,13 +3,10 @@ module gitea.futuresens.co.uk/futuresens/hardlink
|
|||||||
go 1.23.2
|
go 1.23.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.200
|
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.179
|
||||||
gitea.futuresens.co.uk/futuresens/logging v1.0.9
|
gitea.futuresens.co.uk/futuresens/logging v1.0.9
|
||||||
github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb
|
github.com/alexbrainman/printer v0.0.0-20200912035444-f40f26f0bdeb
|
||||||
github.com/denisenkom/go-mssqldb v0.12.3
|
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/sirupsen/logrus v1.9.3
|
||||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
||||||
golang.org/x/image v0.27.0
|
golang.org/x/image v0.27.0
|
||||||
|
|||||||
10
go.sum
10
go.sum
@ -1,5 +1,5 @@
|
|||||||
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.200 h1:CRGAuhwecpOwY1CAuC038NFyw6EFulVG554HbUqfezI=
|
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.179 h1:3OLzX6jJ2dwfZ9Fcijk5z6/GUdTl5FUNw3eWuRkDhZw=
|
||||||
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.200/go.mod h1:ABMUkdm+3VGrkuoCJsXMfPPud9GHDOwBb1NiifFqxes=
|
gitea.futuresens.co.uk/futuresens/cmstypes v1.0.179/go.mod h1:ABMUkdm+3VGrkuoCJsXMfPPud9GHDOwBb1NiifFqxes=
|
||||||
gitea.futuresens.co.uk/futuresens/fscrypto v0.0.0-20221125125050-9acaffd21362 h1:MnhYo7XtsECCU+5yVMo3tZZOOSOKGkl7NpOvTAieBTo=
|
gitea.futuresens.co.uk/futuresens/fscrypto v0.0.0-20221125125050-9acaffd21362 h1:MnhYo7XtsECCU+5yVMo3tZZOOSOKGkl7NpOvTAieBTo=
|
||||||
gitea.futuresens.co.uk/futuresens/fscrypto v0.0.0-20221125125050-9acaffd21362/go.mod h1:p95ouVfK4qyC20D3/k9QLsWSxD2pdweWiY6vcYi9hpM=
|
gitea.futuresens.co.uk/futuresens/fscrypto v0.0.0-20221125125050-9acaffd21362/go.mod h1:p95ouVfK4qyC20D3/k9QLsWSxD2pdweWiY6vcYi9hpM=
|
||||||
gitea.futuresens.co.uk/futuresens/logging v1.0.9 h1:uvCQq/plecB0z/bUWOhFhwyYUWGPkTBZHsYNL+3RFvI=
|
gitea.futuresens.co.uk/futuresens/logging v1.0.9 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/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 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
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/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/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|||||||
389
handlers/handlers.go
Normal file
389
handlers/handlers.go
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tarm/serial"
|
||||||
|
|
||||||
|
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||||
|
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
|
||||||
|
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver"
|
||||||
|
"gitea.futuresens.co.uk/futuresens/hardlink/payment"
|
||||||
|
"gitea.futuresens.co.uk/futuresens/hardlink/printer"
|
||||||
|
"gitea.futuresens.co.uk/futuresens/logging"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
customLayout = "2006-01-02 15:04:05 -0700"
|
||||||
|
transactionUrl = "http://127.0.0.1:18181/start-transaction/"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
dispPort *serial.Port
|
||||||
|
lockserver lockserver.LockServer
|
||||||
|
isPayment bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp(dispPort *serial.Port, lockType, encoderAddress string, isPayment bool) *App {
|
||||||
|
return &App{
|
||||||
|
isPayment: isPayment,
|
||||||
|
dispPort: dispPort,
|
||||||
|
lockserver: lockserver.NewLockServer(lockType, encoderAddress, FatalError),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/issuedoorcard", app.issueDoorCard)
|
||||||
|
mux.HandleFunc("/printroomticket", app.printRoomTicket)
|
||||||
|
mux.HandleFunc("/starttransaction", app.startTransaction)
|
||||||
|
mux.HandleFunc("/startandconfirmtransaction", app.startAndConfirmTransaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) startTransaction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
const op = logging.Op("startTransaction")
|
||||||
|
var (
|
||||||
|
theResponse cmstypes.ResponseRec
|
||||||
|
cardholderReceipt string
|
||||||
|
theRequest cmstypes.TransactionRec
|
||||||
|
trResult payment.TransactionResultXML
|
||||||
|
)
|
||||||
|
|
||||||
|
theResponse.Status.Code = http.StatusInternalServerError
|
||||||
|
theResponse.Status.Message = "500 Internal server error"
|
||||||
|
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if !app.isPayment {
|
||||||
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Payment processing is disabled")
|
||||||
|
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("startTransaction called")
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Method not allowed; use POST")
|
||||||
|
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
|
||||||
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Content-Type must be text/xml")
|
||||||
|
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
err := xml.Unmarshal(body, &theRequest)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
||||||
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Invalid XML payload")
|
||||||
|
writeTransactionResult(w, http.StatusBadRequest, theResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 300 * time.Second}
|
||||||
|
response, err := client.Post(transactionUrl, "text/xml", bytes.NewBuffer(body))
|
||||||
|
if err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
|
||||||
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "No response from payment processor")
|
||||||
|
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
body, err = io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
|
||||||
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Failed to read response body")
|
||||||
|
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := trResult.ParseTransactionResult(body); err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose JSON from responseEntries
|
||||||
|
result := make(map[string]string)
|
||||||
|
for _, e := range trResult.Entries {
|
||||||
|
switch e.Key {
|
||||||
|
case payment.ReceiptData, payment.ReceiptDataMerchant:
|
||||||
|
// ignore these
|
||||||
|
case payment.ReceiptDataCardholder:
|
||||||
|
cardholderReceipt = e.Value
|
||||||
|
case payment.TransactionResult:
|
||||||
|
theResponse.Status.Message = e.Value
|
||||||
|
theResponse.Status.Code = http.StatusOK
|
||||||
|
result[e.Key] = e.Value
|
||||||
|
default:
|
||||||
|
result[e.Key] = e.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := printer.PrintCardholderReceipt(cardholderReceipt); err != nil {
|
||||||
|
log.Errorf("PrintCardholderReceipt error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
theResponse.Data = payment.BuildPaymentRedirectURL(result)
|
||||||
|
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) startAndConfirmTransaction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
const op = logging.Op("startAndConfirmTransaction")
|
||||||
|
var (
|
||||||
|
theResponse cmstypes.ResponseRec
|
||||||
|
cardholderReceipt string
|
||||||
|
theRequest cmstypes.TransactionRec
|
||||||
|
trResult payment.TransactionResultXML
|
||||||
|
)
|
||||||
|
|
||||||
|
theResponse.Status.Code = http.StatusInternalServerError
|
||||||
|
theResponse.Status.Message = "500 Internal server error"
|
||||||
|
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if !app.isPayment {
|
||||||
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Payment processing is disabled")
|
||||||
|
writeTransactionResult(w, http.StatusServiceUnavailable, theResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("startAndConfirmTransaction called")
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Method not allowed; use POST")
|
||||||
|
writeTransactionResult(w, http.StatusMethodNotAllowed, theResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
if ct := r.Header.Get("Content-Type"); ct != "text/xml" {
|
||||||
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Content-Type must be text/xml")
|
||||||
|
writeTransactionResult(w, http.StatusUnsupportedMediaType, theResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
err := xml.Unmarshal(body, &theRequest)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
||||||
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Invalid XML payload")
|
||||||
|
writeTransactionResult(w, http.StatusBadRequest, theResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Transaction payload: Amount=%s, Type=%s", theRequest.AmountMinorUnits, theRequest.TransactionType)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 300 * time.Second}
|
||||||
|
response, err := client.Post(transactionUrl, "text/xml", bytes.NewBuffer(body))
|
||||||
|
if err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "Payment processing error", string(op), "", "", 0)
|
||||||
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "No response from payment processor")
|
||||||
|
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
body, err = io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "Read response body error", string(op), "", "", 0)
|
||||||
|
theResponse.Data = payment.BuildFailureURL(payment.ResultError, "Failed to read response body")
|
||||||
|
writeTransactionResult(w, http.StatusInternalServerError, theResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := trResult.ParseTransactionResult(body); err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose JSON from responseEntries
|
||||||
|
result := make(map[string]string)
|
||||||
|
for _, e := range trResult.Entries {
|
||||||
|
switch e.Key {
|
||||||
|
case payment.ReceiptData, payment.ReceiptDataMerchant:
|
||||||
|
// ignore these
|
||||||
|
case payment.ReceiptDataCardholder:
|
||||||
|
cardholderReceipt = e.Value
|
||||||
|
case payment.TransactionResult:
|
||||||
|
theResponse.Status.Message = e.Value
|
||||||
|
theResponse.Status.Code = http.StatusOK
|
||||||
|
result[e.Key] = e.Value
|
||||||
|
default:
|
||||||
|
result[e.Key] = e.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := printer.PrintCardholderReceipt(cardholderReceipt); err != nil {
|
||||||
|
log.Errorf("PrintCardholderReceipt error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
theResponse.Data = payment.BuildRedirectURL(result)
|
||||||
|
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
const op = logging.Op("issueDoorCard")
|
||||||
|
var (
|
||||||
|
doorReq lockserver.DoorCardRequest
|
||||||
|
theResponse cmstypes.StatusRec
|
||||||
|
)
|
||||||
|
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("issueDoorCard called")
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||||
|
writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&doorReq); err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "ReadJSON", string(op), "", "", 0)
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse times
|
||||||
|
checkIn, err := time.Parse(customLayout, doorReq.CheckinTime)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "Invalid checkinTime format", string(op), "", "", 0)
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid checkinTime format: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkOut, err := time.Parse(customLayout, doorReq.CheckoutTime)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "Invalid checkoutTime format", string(op), "", "", 0)
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid checkoutTime format: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispenser sequence
|
||||||
|
if status, err := dispenser.DispenserSequence(app.dispPort); err != nil {
|
||||||
|
if status != "" {
|
||||||
|
logging.Error(serviceName, status, "Dispense error", string(op), "", "", 0)
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error())
|
||||||
|
} else {
|
||||||
|
logging.Error(serviceName, err.Error(), "Dispense error", string(op), "", "", 0)
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "Dispense error: "+err.Error()+"; check card stock")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
log.Info(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// build lock server command
|
||||||
|
app.lockserver.BuildCommand(doorReq, checkIn, checkOut)
|
||||||
|
|
||||||
|
// lock server sequence
|
||||||
|
err = app.lockserver.LockSequence()
|
||||||
|
if err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "Key encoding", string(op), "", "", 0)
|
||||||
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
dispenser.CardOutOfMouth(app.dispPort)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// final dispenser steps
|
||||||
|
if status, err := dispenser.CardOutOfMouth(app.dispPort); err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "Dispenser eject error", string(op), "", "", 0)
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "Dispenser eject error: "+err.Error())
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
log.Info(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
theResponse.Code = http.StatusOK
|
||||||
|
theResponse.Message = "Card issued successfully"
|
||||||
|
// success! return 200 and any data you like
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(theResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) printRoomTicket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
const op = logging.Op("printRoomTicket")
|
||||||
|
var roomDetails printer.RoomDetailsRec
|
||||||
|
// Allow CORS preflight if needed
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("printRoomTicket called")
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "Method not allowed; use POST")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "xml") {
|
||||||
|
writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/xml")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer r.Body.Close()
|
||||||
|
if err := xml.NewDecoder(r.Body).Decode(&roomDetails); err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "ReadXML", string(op), "", "", 0)
|
||||||
|
writeError(w, http.StatusBadRequest, "Invalid XML payload: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := printer.BuildRoomTicket(roomDetails)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "BuildRoomTicket", string(op), "", "", 0)
|
||||||
|
writeError(w, http.StatusInternalServerError, "BuildRoomTicket failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to the Windows Epson TM-T82II via the printer package
|
||||||
|
if err := printer.SendToPrinter(data); err != nil {
|
||||||
|
logging.Error(serviceName, err.Error(), "printRoomTicket", "printRoomTicket", "", "", 0)
|
||||||
|
writeError(w, http.StatusInternalServerError, "Print failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(cmstypes.StatusRec{
|
||||||
|
Code: http.StatusOK,
|
||||||
|
Message: "Print job sent successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package errorhandlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@ -7,12 +7,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
"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"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const serviceName = "hardlink"
|
||||||
|
|
||||||
// writeError is a helper to send a JSON error and HTTP status in one go.
|
// 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{
|
theResponse := cmstypes.StatusRec{
|
||||||
Code: status,
|
Code: status,
|
||||||
Message: msg,
|
Message: msg,
|
||||||
@ -22,6 +24,14 @@ func WriteError(w http.ResponseWriter, status int, msg string) {
|
|||||||
json.NewEncoder(w).Encode(theResponse)
|
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) {
|
func FatalError(err error) {
|
||||||
fmt.Println(err.Error())
|
fmt.Println(err.Error())
|
||||||
log.Errorf(err.Error())
|
log.Errorf(err.Error())
|
||||||
@ -29,8 +39,3 @@ func FatalError(err error) {
|
|||||||
fmt.Scanln()
|
fmt.Scanln()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func FatalErrorWithMail(hotel string, kiosk int, title string, err error) {
|
|
||||||
mail.SendEmailOnError(hotel, kiosk, title, err.Error())
|
|
||||||
FatalError(err)
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
// cmd/hardlink-preauth-release/main.go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/bootstrap"
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/logging"
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
buildVersion = "1.0.2"
|
|
||||||
serviceName = "preauth-release"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
config := config.ReadPreauthReleaserConfig()
|
|
||||||
// Setup logging and get file handle
|
|
||||||
logFile, err := logging.SetupLogging(config.LogDir, serviceName, buildVersion)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to set up logging: %v\n", err)
|
|
||||||
}
|
|
||||||
defer logFile.Close()
|
|
||||||
database, err := bootstrap.OpenDB(&config)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Fatal("DB init failed")
|
|
||||||
}
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
if err := creditcall.ReleasePreauthorizations(database); err != nil {
|
|
||||||
log.Error(err)
|
|
||||||
fmt.Println(err)
|
|
||||||
} else {
|
|
||||||
log.Info("Task completed successfully")
|
|
||||||
fmt.Println("Task completed successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 20; i > 0; i-- {
|
|
||||||
fmt.Printf("\rExiting in %2d seconds... ", i)
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
}
|
|
||||||
fmt.Println("\rExiting now. ")
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
// internal/bootstrap/db.go
|
|
||||||
package bootstrap
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
func OpenDB(cfg *config.ConfigRec) (*sql.DB, error) {
|
|
||||||
return db.InitMSSQL(
|
|
||||||
cfg.Dbport,
|
|
||||||
cfg.Dbuser,
|
|
||||||
cfg.Dbpassword,
|
|
||||||
cfg.Dbname,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
package creditcall
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/xml"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
|
||||||
_ "github.com/denisenkom/go-mssqldb"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
// XML parsing structs
|
|
||||||
type (
|
|
||||||
TransactionRec struct {
|
|
||||||
XMLName xml.Name `xml:"TransactionPayload"`
|
|
||||||
AmountMinorUnits string `xml:"amount"`
|
|
||||||
TransactionType string `xml:"transactionType"`
|
|
||||||
}
|
|
||||||
|
|
||||||
TransactionResultXML struct {
|
|
||||||
XMLName xml.Name `xml:"TransactionResult"`
|
|
||||||
Entries []EntryXML `xml:"Entry"`
|
|
||||||
}
|
|
||||||
|
|
||||||
EntryXML struct {
|
|
||||||
Key string `xml:"Key"`
|
|
||||||
Value string `xml:"Value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
TransactionConfirmation struct {
|
|
||||||
XMLName xml.Name `xml:"TransactionConfirmation"`
|
|
||||||
Result string `xml:"Result"`
|
|
||||||
Errors string `xml:"Errors"`
|
|
||||||
ErrorDescription string `xml:"ErrorDescription"`
|
|
||||||
ReceiptDataCardholder string `xml:"ReceiptDataCardholder"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PaymentResult struct {
|
|
||||||
Fields map[string]string
|
|
||||||
CardholderReceipt string
|
|
||||||
Status cmstypes.StatusRec
|
|
||||||
}
|
|
||||||
|
|
||||||
TransactionInfo struct {
|
|
||||||
transactionRes string
|
|
||||||
transactionState string
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfirmTransactionRequest struct {
|
|
||||||
XMLName xml.Name `xml:"ConfirmTransactionRequest"`
|
|
||||||
Amount string `xml:"Amount"`
|
|
||||||
Reference string `xml:"TransactionReference"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseTransactionResult parses the XML into entries.
|
|
||||||
func (tr *TransactionResultXML) ParseTransactionResult(data []byte) error {
|
|
||||||
if err := xml.Unmarshal(data, &tr); err != nil {
|
|
||||||
return fmt.Errorf("XML unmarshal: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ti *TransactionInfo) FillFromTransactionResult(trResult TransactionResultXML) {
|
|
||||||
for _, e := range trResult.Entries {
|
|
||||||
switch e.Key {
|
|
||||||
case types.TransactionResult:
|
|
||||||
ti.transactionRes = e.Value
|
|
||||||
case types.TransactionState:
|
|
||||||
ti.transactionState = e.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *PaymentResult) FillFromTransactionResult(trResult TransactionResultXML) {
|
|
||||||
r.Fields = make(map[string]string)
|
|
||||||
|
|
||||||
for _, e := range trResult.Entries {
|
|
||||||
switch e.Key {
|
|
||||||
case types.ReceiptData, types.ReceiptDataMerchant:
|
|
||||||
// intentionally ignored
|
|
||||||
|
|
||||||
case types.ReceiptDataCardholder:
|
|
||||||
r.CardholderReceipt = e.Value
|
|
||||||
|
|
||||||
case types.TransactionResult:
|
|
||||||
r.Status.Message = e.Value
|
|
||||||
r.Status.Code = http.StatusOK
|
|
||||||
r.Fields[e.Key] = e.Value
|
|
||||||
|
|
||||||
default:
|
|
||||||
r.Fields[e.Key] = e.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildPaymentRedirectURL builds the redirect URL to send the guest to after payment.
|
|
||||||
func BuildPaymentRedirectURL(result map[string]string) string {
|
|
||||||
res := result[types.TransactionResult]
|
|
||||||
|
|
||||||
// Transaction approved?
|
|
||||||
if strings.EqualFold(res, types.ResultApproved) {
|
|
||||||
// Transaction confirmed?
|
|
||||||
if strings.EqualFold(result[types.ConfirmResult], types.ResultApproved) {
|
|
||||||
log.WithField(types.LogResult, result[types.ConfirmResult]).
|
|
||||||
Info("Transaction approved and confirmed")
|
|
||||||
|
|
||||||
return BuildSuccessURL(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not confirmed
|
|
||||||
log.WithFields(log.Fields{types.LogFieldError: result[types.ConfirmResult], types.LogFieldDescription: result[types.ConfirmErrors]}).
|
|
||||||
Error("Transaction approved but not confirmed")
|
|
||||||
|
|
||||||
return BuildFailureURL(result[types.ConfirmResult], result[types.ConfirmErrors])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not approved
|
|
||||||
return BuildFailureURL(res, result[types.Errors])
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
|
|
||||||
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]
|
|
||||||
|
|
||||||
// Transaction approved?
|
|
||||||
if strings.EqualFold(res, types.ResultApproved) {
|
|
||||||
switch {
|
|
||||||
// Transaction type AccountVerification?
|
|
||||||
case strings.EqualFold(tType, types.AccountVerificationType):
|
|
||||||
log.WithField(types.LogResult, result[types.TransactionResult]).
|
|
||||||
Info("Account verification approved")
|
|
||||||
|
|
||||||
return true, false
|
|
||||||
|
|
||||||
// Transaction type Sale?
|
|
||||||
case strings.EqualFold(tType, types.SaleTransactionType):
|
|
||||||
// Transaction confirmed?
|
|
||||||
log.WithField(types.LogResult, result[types.ConfirmResult]).
|
|
||||||
Info("Amount preauthorized successfully")
|
|
||||||
|
|
||||||
return true, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not approved
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildSuccessURL(result map[string]string) string {
|
|
||||||
q := url.Values{}
|
|
||||||
q.Set("CardNumber", hex.EncodeToString([]byte(result[types.PanMasked])))
|
|
||||||
q.Set("CardType", hex.EncodeToString([]byte(result[types.CardType])))
|
|
||||||
q.Set("ExpiryDate", hex.EncodeToString([]byte(result[types.ExpiryDate])))
|
|
||||||
q.Set("TxnReference", result[types.Reference])
|
|
||||||
q.Set("CardHash", hex.EncodeToString([]byte(result[types.CardHash])))
|
|
||||||
q.Set("CardReference", hex.EncodeToString([]byte(result[types.CardReference])))
|
|
||||||
return (&url.URL{
|
|
||||||
Path: types.CheckinSuccessfulEndpoint,
|
|
||||||
RawQuery: q.Encode(),
|
|
||||||
}).String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildFailureURL(msgType, description string) string {
|
|
||||||
q := url.Values{}
|
|
||||||
description = FailureDescription(msgType, description)
|
|
||||||
if description != "" {
|
|
||||||
msgType = types.ResultError
|
|
||||||
}
|
|
||||||
|
|
||||||
log.WithFields(log.Fields{types.LogFieldError: msgType, types.LogFieldDescription: description}).
|
|
||||||
Error("Transaction failed")
|
|
||||||
|
|
||||||
q.Set("MsgType", msgType)
|
|
||||||
q.Set("Description", description)
|
|
||||||
return (&url.URL{
|
|
||||||
Path: types.CheckinUnsuccessfulEndpoint,
|
|
||||||
RawQuery: q.Encode(),
|
|
||||||
}).String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,237 +0,0 @@
|
|||||||
package creditcall
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/xml"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/db"
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
httpTimeout = 120 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ==============================
|
|
||||||
Public Entry Point
|
|
||||||
============================== */
|
|
||||||
|
|
||||||
func ReleasePreauthorizations(database *sql.DB) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
client := &http.Client{Timeout: httpTimeout}
|
|
||||||
|
|
||||||
preauths, err := fetchDuePreauths(ctx, database, now)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(preauths) == 0 {
|
|
||||||
log.Info("No preauthorizations due for release")
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
log.Infof("%d preauthorizations due for release", len(preauths))
|
|
||||||
}
|
|
||||||
|
|
||||||
var failed []string
|
|
||||||
var completed int
|
|
||||||
for _, p := range preauths {
|
|
||||||
if err := handlePreauthRelease(ctx, database, client, p, now); err != nil {
|
|
||||||
log.Errorf("Preauth %s failed: %v", p.TxnReference, err)
|
|
||||||
failed = append(failed, p.TxnReference)
|
|
||||||
}
|
|
||||||
completed++
|
|
||||||
}
|
|
||||||
log.Infof("Preauth release completed: %d processed, %d failed", completed, len(failed))
|
|
||||||
|
|
||||||
if len(failed) > 0 {
|
|
||||||
return fmt.Errorf("preauth release incomplete, failed refs: %s", strings.Join(failed, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==============================
|
|
||||||
Core Business Logic
|
|
||||||
============================== */
|
|
||||||
|
|
||||||
func handlePreauthRelease(
|
|
||||||
ctx context.Context,
|
|
||||||
dbConn *sql.DB,
|
|
||||||
client *http.Client,
|
|
||||||
preauth types.PreauthRec,
|
|
||||||
now time.Time,
|
|
||||||
) error {
|
|
||||||
|
|
||||||
ref := preauth.TxnReference
|
|
||||||
log.Infof("Evaluating preauth %s", ref)
|
|
||||||
|
|
||||||
info, err := fetchTransactionInfo(ctx, client, ref)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Infof("res=%s state=%s", info.transactionRes, info.transactionState)
|
|
||||||
|
|
||||||
// If already voided or declined → mark released
|
|
||||||
if isAlreadyReleased(info) {
|
|
||||||
return markReleased(ctx, dbConn, ref, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only void approved + uncommitted
|
|
||||||
if !isVoidable(info) {
|
|
||||||
log.Infof("Preauth %s not eligible for void (res=%s state=%s)", ref, info.transactionRes, info.transactionState)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Void transaction
|
|
||||||
if err := voidPreauth(ctx, client, ref); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify final state
|
|
||||||
finalInfo, err := fetchTransactionInfo(ctx, client, ref)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isSuccessfullyVoided(finalInfo) {
|
|
||||||
return fmt.Errorf("unexpected final state res=%s state=%s",
|
|
||||||
finalInfo.transactionRes, finalInfo.transactionState)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("Preauth %s successfully voided", ref)
|
|
||||||
return markReleased(ctx, dbConn, ref, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==============================
|
|
||||||
State Evaluation Helpers
|
|
||||||
============================== */
|
|
||||||
|
|
||||||
func isVoidable(info TransactionInfo) bool {
|
|
||||||
return strings.EqualFold(info.transactionRes, types.ResultApproved) &&
|
|
||||||
strings.EqualFold(info.transactionState, types.ResultStateUncommitted)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isAlreadyReleased(info TransactionInfo) bool {
|
|
||||||
return strings.EqualFold(info.transactionState, types.ResultStateVoided) ||
|
|
||||||
strings.EqualFold(info.transactionRes, types.ResultDeclined)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSuccessfullyVoided(info TransactionInfo) bool {
|
|
||||||
return strings.EqualFold(info.transactionRes, types.ResultDeclined) &&
|
|
||||||
strings.EqualFold(info.transactionState, types.ResultStateVoided)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==============================
|
|
||||||
External Operations
|
|
||||||
============================== */
|
|
||||||
|
|
||||||
func fetchDuePreauths(
|
|
||||||
ctx context.Context,
|
|
||||||
dbConn *sql.DB,
|
|
||||||
now time.Time,
|
|
||||||
) ([]types.PreauthRec, error) {
|
|
||||||
|
|
||||||
return db.GetDuePreauths(ctx, dbConn, now, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func markReleased(
|
|
||||||
ctx context.Context,
|
|
||||||
dbConn *sql.DB,
|
|
||||||
ref string,
|
|
||||||
now time.Time,
|
|
||||||
) error {
|
|
||||||
|
|
||||||
return db.MarkPreauthReleased(ctx, dbConn, ref, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchTransactionInfo(
|
|
||||||
ctx context.Context,
|
|
||||||
client *http.Client,
|
|
||||||
ref string,
|
|
||||||
) (TransactionInfo, error) {
|
|
||||||
|
|
||||||
var tr TransactionResultXML
|
|
||||||
var info TransactionInfo
|
|
||||||
|
|
||||||
payload, _ := xml.Marshal(types.TransactionReferenceRequest{
|
|
||||||
TransactionReference: ref,
|
|
||||||
})
|
|
||||||
|
|
||||||
body, err := postXML(ctx, client, types.LinkTransactionInformation, payload)
|
|
||||||
if err != nil {
|
|
||||||
return info, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tr.ParseTransactionResult(body); err != nil {
|
|
||||||
return info, err
|
|
||||||
}
|
|
||||||
|
|
||||||
info.FillFromTransactionResult(tr)
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func voidPreauth(
|
|
||||||
ctx context.Context,
|
|
||||||
client *http.Client,
|
|
||||||
ref string,
|
|
||||||
) error {
|
|
||||||
|
|
||||||
var tr TransactionResultXML
|
|
||||||
var info TransactionInfo
|
|
||||||
|
|
||||||
payload, _ := xml.Marshal(types.TransactionReferenceRequest{
|
|
||||||
TransactionReference: ref,
|
|
||||||
})
|
|
||||||
|
|
||||||
body, err := postXML(ctx, client, types.LinkVoidTransaction, payload)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tr.ParseTransactionResult(body); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
info.FillFromTransactionResult(tr)
|
|
||||||
|
|
||||||
if !strings.EqualFold(info.transactionRes, types.ResultApproved) {
|
|
||||||
return fmt.Errorf("void rejected: %s", info.transactionRes)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==============================
|
|
||||||
Low-level HTTP
|
|
||||||
============================== */
|
|
||||||
|
|
||||||
func postXML(
|
|
||||||
ctx context.Context,
|
|
||||||
client *http.Client,
|
|
||||||
url string,
|
|
||||||
payload []byte,
|
|
||||||
) ([]byte, error) {
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "text/xml")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
return io.ReadAll(resp.Body)
|
|
||||||
}
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,201 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
mssqldb "github.com/denisenkom/go-mssqldb" // for error inspection
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// InitMSSQL opens and pings the SQL Server instance (keeps your original behaviour)
|
|
||||||
func InitMSSQL(port int, user, password, database string) (*sql.DB, error) {
|
|
||||||
if port <= 0 || user == "" || database == "" {
|
|
||||||
return nil, errors.New("incomplete database configuration")
|
|
||||||
}
|
|
||||||
|
|
||||||
dsn := fmt.Sprintf(
|
|
||||||
"sqlserver://%s:%s@%s:%d?database=%s&encrypt=disable",
|
|
||||||
user, password, "localhost", port, database,
|
|
||||||
)
|
|
||||||
|
|
||||||
db, err := sql.Open("sqlserver", dsn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := db.PingContext(ctx); err != nil {
|
|
||||||
db.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.QueryRowContext(ctx, "SELECT 1").Scan(new(int)); err != nil {
|
|
||||||
db.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
log.Info("Database connection established")
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDateOnly(s string) (time.Time, error) {
|
|
||||||
parsed, err := time.Parse(types.CustomLayout, s)
|
|
||||||
if err == nil {
|
|
||||||
// construct midnight in local timezone, then convert to UTC for storage consistency
|
|
||||||
localMidnight := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.Local)
|
|
||||||
return localMidnight.UTC(), nil
|
|
||||||
}
|
|
||||||
return time.Time{}, fmt.Errorf("parseDateOnly error: parsing %q: %w", s, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// InsertPreauth stores a preauthorization record. It is idempotent: duplicate TxnReference is ignored.
|
|
||||||
func InsertPreauth(ctx context.Context, db *sql.DB, m map[string]string, checkoutDate string) error {
|
|
||||||
const funcName = "InsertPreauth"
|
|
||||||
|
|
||||||
totalMinorUnits := m[types.TotalAmount]
|
|
||||||
txnReference := m[types.Reference]
|
|
||||||
if txnReference == "" {
|
|
||||||
return fmt.Errorf("%s: missing REFERENCE", funcName)
|
|
||||||
}
|
|
||||||
if totalMinorUnits == "" {
|
|
||||||
return fmt.Errorf("%s: missing TotalAmount", funcName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse minor units, fallback to 0 on parse error but report it
|
|
||||||
amountInt, err := strconv.ParseInt(totalMinorUnits, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
log.WithFields(log.Fields{
|
|
||||||
"func": funcName,
|
|
||||||
"value": totalMinorUnits,
|
|
||||||
"error": err,
|
|
||||||
}).Warnf("parsing TotalAmount, defaulting to 0")
|
|
||||||
amountInt = 0
|
|
||||||
}
|
|
||||||
totalAmount := float64(amountInt) / 100.0
|
|
||||||
|
|
||||||
txnTime := time.Now().UTC()
|
|
||||||
|
|
||||||
// parse departure / checkout date and compute release date (48h after departure)
|
|
||||||
checkOutDate, err := parseDateOnly(checkoutDate)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("InsertPreauth: parsing checkoutDate %q: %w", checkoutDate, err)
|
|
||||||
}
|
|
||||||
releaseDate := checkOutDate.Add(48 * time.Hour)
|
|
||||||
|
|
||||||
const stmt = `
|
|
||||||
INSERT INTO dbo.Preauthorizations
|
|
||||||
(TxnReference, TotalMinorUnits, TotalAmount, TxnDateTime, DepartureDate, ReleaseDate)
|
|
||||||
VALUES
|
|
||||||
(@TxnReference, @TotalMinorUnits, @TotalAmount, @TxnDateTime, @DepartureDate, @ReleaseDate);
|
|
||||||
`
|
|
||||||
_, err = db.ExecContext(ctx, stmt,
|
|
||||||
sql.Named("TxnReference", txnReference),
|
|
||||||
sql.Named("TotalMinorUnits", totalMinorUnits),
|
|
||||||
sql.Named("TotalAmount", totalAmount),
|
|
||||||
sql.Named("TxnDateTime", txnTime),
|
|
||||||
sql.Named("DepartureDate", checkOutDate),
|
|
||||||
sql.Named("ReleaseDate", releaseDate),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
// handle duplicate-key (unique constraint) gracefully: SQL Server error numbers 2601/2627
|
|
||||||
var sqlErr mssqldb.Error
|
|
||||||
if errors.As(err, &sqlErr) {
|
|
||||||
if sqlErr.Number == 2627 || sqlErr.Number == 2601 {
|
|
||||||
log.Infof("InsertPreauth: preauth %s already exists (duplicate key) - ignoring", txnReference)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("InsertPreauth exec: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("Inserted preauth %s amount=%s minorUnits release=%s", txnReference, totalMinorUnits, releaseDate.Format(time.RFC3339))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDuePreauths returns preauths with ReleaseDate <= now where Released = 0.
|
|
||||||
// If limit > 0, the query uses TOP(limit) to bound results at DB level.
|
|
||||||
func GetDuePreauths(ctx context.Context, db *sql.DB, now time.Time, limit int) ([]types.PreauthRec, error) {
|
|
||||||
baseQuery := `
|
|
||||||
SELECT Id, TxnReference, TotalMinorUnits, TotalAmount, TxnDateTime, DepartureDate, ReleaseDate, Released, ReleasedAt
|
|
||||||
FROM dbo.Preauthorizations
|
|
||||||
WHERE Released = 0 AND ReleaseDate <= @Now
|
|
||||||
ORDER BY ReleaseDate ASC
|
|
||||||
`
|
|
||||||
query := baseQuery
|
|
||||||
if limit > 0 {
|
|
||||||
// embed TOP to keep DB from returning everything; limit is controlled by the caller.
|
|
||||||
query = strings.Replace(baseQuery, "SELECT", fmt.Sprintf("SELECT TOP (%d)", limit), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := db.QueryContext(ctx, query, sql.Named("Now", now))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("GetDuePreauths query: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []types.PreauthRec
|
|
||||||
for rows.Next() {
|
|
||||||
var r types.PreauthRec
|
|
||||||
var departure sql.NullTime
|
|
||||||
var releasedAt sql.NullTime
|
|
||||||
// Note: adjust scanning targets if types.PreauthRec fields differ
|
|
||||||
if err := rows.Scan(
|
|
||||||
&r.Id,
|
|
||||||
&r.TxnReference,
|
|
||||||
&r.TotalMinorUnits,
|
|
||||||
&r.TotalAmount,
|
|
||||||
&r.TxnDateTime,
|
|
||||||
&departure,
|
|
||||||
&r.ReleaseDate,
|
|
||||||
&r.Released,
|
|
||||||
&releasedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("GetDuePreauths scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if departure.Valid {
|
|
||||||
r.DepartureDate = departure.Time
|
|
||||||
} else {
|
|
||||||
r.DepartureDate = time.Time{}
|
|
||||||
}
|
|
||||||
r.ReleasedAt = releasedAt
|
|
||||||
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("GetDuePreauths rows: %w", err)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkPreauthReleased sets Released=1 and stores the timestamp. Returns error if no rows updated.
|
|
||||||
func MarkPreauthReleased(ctx context.Context, db *sql.DB, txnReference string, releasedAt time.Time) error {
|
|
||||||
const stmt = `
|
|
||||||
UPDATE dbo.Preauthorizations
|
|
||||||
SET Released = 1, ReleasedAt = @ReleasedAt
|
|
||||||
WHERE TxnReference = @TxnReference AND Released = 0;
|
|
||||||
`
|
|
||||||
res, err := db.ExecContext(ctx, stmt, sql.Named("ReleasedAt", releasedAt), sql.Named("TxnReference", txnReference))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("MarkPreauthReleased exec: %w", err)
|
|
||||||
}
|
|
||||||
n, err := res.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("MarkPreauthReleased RowsAffected: %w", err)
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("no rows updated for %s (maybe already released)", txnReference)
|
|
||||||
}
|
|
||||||
log.Infof("Marked preauth %s released at %s", txnReference, releasedAt.Format(time.RFC3339))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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.
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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...))
|
|
||||||
}
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
¬ificationCount,
|
|
||||||
&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)
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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"`
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -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()
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package mail
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Test_SendMail(t *testing.T) {
|
|
||||||
SendMail("zotacrtx5@gmail.com", "Test Subjectp", "Test Message")
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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")
|
|
||||||
)
|
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
)
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"`
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
@ -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"`
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
package types
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/xml"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
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/"
|
|
||||||
LinkTransactionInformation = "http://127.0.0.1:18181/transaction-information/"
|
|
||||||
LinkChipDNAStatus = "http://127.0.0.1:18181/chipdna-status/"
|
|
||||||
LinkVoidTransaction = "http://127.0.0.1:18181/void-transaction/"
|
|
||||||
// Transaction types
|
|
||||||
SaleTransactionType = "sale"
|
|
||||||
AccountVerificationType = "account verification"
|
|
||||||
|
|
||||||
// Transaction results
|
|
||||||
ResultApproved = "approved"
|
|
||||||
ResultDeclined = "declined"
|
|
||||||
ResultCancelled = "cancelled"
|
|
||||||
ResultCanceled = "canceled"
|
|
||||||
ResultPending = "pending"
|
|
||||||
ResultStateUncommitted = "uncommitted"
|
|
||||||
ResultStateVoided = "voided"
|
|
||||||
ResultError = "error"
|
|
||||||
CheckinSuccessfulEndpoint = "/successful" // Endpoint to send guest to after successful payment
|
|
||||||
CheckinUnsuccessfulEndpoint = "/unsuccessful"
|
|
||||||
|
|
||||||
// Response map keys
|
|
||||||
CardReference = "CARD_REFERENCE"
|
|
||||||
CardHash = "CARD_HASH"
|
|
||||||
Errors = "ERRORS"
|
|
||||||
ErrorDescription = "ERROR_DESCRIPTION"
|
|
||||||
ReceiptData = "RECEIPT_DATA"
|
|
||||||
ReceiptDataMerchant = "RECEIPT_DATA_MERCHANT"
|
|
||||||
ReceiptDataCardholder = "RECEIPT_DATA_CARDHOLDER"
|
|
||||||
Reference = "REFERENCE"
|
|
||||||
CardType = "CARD_SCHEME_ID"
|
|
||||||
PanMasked = "PAN_MASKED"
|
|
||||||
ExpiryDate = "EXPIRY_DATE"
|
|
||||||
TransactionResult = "TRANSACTION_RESULT"
|
|
||||||
TransactionType = "TRANSACTION_TYPE"
|
|
||||||
TransactionState = "TRANSACTION_STATE"
|
|
||||||
ConfirmResult = "CONFIRM_RESULT"
|
|
||||||
ConfirmErrors = "CONFIRM_ERRORS"
|
|
||||||
TotalAmount = "TOTAL_AMOUNT"
|
|
||||||
|
|
||||||
// Dojo terminal session statuses
|
|
||||||
ResultCaptured = "captured"
|
|
||||||
ResultSignatureAccepted = "signatureverificationaccepted"
|
|
||||||
ResultInitiateRequested = "initiaterequested"
|
|
||||||
ResultInitiated = "initiated"
|
|
||||||
ResultAuthorized = "authorized"
|
|
||||||
ResultCancelRequested = "cancelrequested"
|
|
||||||
ResultExpired = "expired"
|
|
||||||
ResultSignatureRejected = "signatureverificationrejected"
|
|
||||||
ResultSignatureRequired = "signatureverificationrequired"
|
|
||||||
|
|
||||||
//PayBridge message types
|
|
||||||
MesTypePaymentRequest = "payment_request"
|
|
||||||
MesTypePaymentResult = "payment_result"
|
|
||||||
MesTypePaymentError = "payment_error"
|
|
||||||
MesTypePaymentStatusUpdate = "payment_status_update"
|
|
||||||
MesTypePaymentAccepted = "payment_accepted"
|
|
||||||
MesTypeAuthSuccess = "auth_success"
|
|
||||||
|
|
||||||
// Log field keys
|
|
||||||
LogFieldError = "error"
|
|
||||||
LogFieldDescription = "description"
|
|
||||||
LogResult = "transactionResult"
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
TransactionReferenceRequest struct {
|
|
||||||
XMLName xml.Name `xml:"TransactionReferenceRequest"`
|
|
||||||
TransactionReference string `xml:"TransactionReference"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PreauthRec struct {
|
|
||||||
Id int64
|
|
||||||
TxnReference string
|
|
||||||
TotalMinorUnits int64
|
|
||||||
TotalAmount float64
|
|
||||||
TxnDateTime time.Time
|
|
||||||
DepartureDate time.Time // date-only (00:00)
|
|
||||||
ReleaseDate time.Time
|
|
||||||
Released bool
|
|
||||||
ReleasedAt sql.NullTime
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@ -1,16 +1,15 @@
|
|||||||
// Package lockserver provides functionality for interacting with Assa Abloy lock servers.
|
|
||||||
package lockserver
|
package lockserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
"strings"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
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 {
|
func (lock *AssaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
|
||||||
ci := checkIn.Format("200601021504")
|
ci := checkIn.Format("200601021504")
|
||||||
co := checkOut.Format("200601021504")
|
co := checkOut.Format("200601021504")
|
||||||
@ -19,7 +18,7 @@ func (lock *AssaLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
|
|||||||
return nil
|
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 {
|
func (lock *AssaLockServer) LockSequence() error {
|
||||||
const funcName = "AssaLockServer.LockSequence"
|
const funcName = "AssaLockServer.LockSequence"
|
||||||
|
|
||||||
@ -72,3 +71,4 @@ func parseAssaResponse(raw string) (string, error) {
|
|||||||
}
|
}
|
||||||
return "Success: " + clean, nil
|
return "Success: " + clean, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,11 +25,10 @@ const (
|
|||||||
Omnitec = "omnitec"
|
Omnitec = "omnitec"
|
||||||
Salto = "salto"
|
Salto = "salto"
|
||||||
TLJ = "tlj"
|
TLJ = "tlj"
|
||||||
Dormakaba = "kaba"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Cert string
|
Cert string
|
||||||
LockServerURL string
|
LockServerURL string
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -39,11 +38,6 @@ type (
|
|||||||
LockSequence() error
|
LockSequence() error
|
||||||
}
|
}
|
||||||
|
|
||||||
KabaLockServer struct {
|
|
||||||
encoderAddr string
|
|
||||||
command []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
AssaLockServer struct {
|
AssaLockServer struct {
|
||||||
encoderAddr string
|
encoderAddr string
|
||||||
command string
|
command string
|
||||||
@ -75,24 +69,22 @@ func NewLockServer(lockType, encoderAddr string, fatalError func(error)) LockSer
|
|||||||
return &SaltoLockServer{encoderAddr: encoderAddr}
|
return &SaltoLockServer{encoderAddr: encoderAddr}
|
||||||
case TLJ:
|
case TLJ:
|
||||||
return &TLJLockServer{encoderAddr: encoderAddr}
|
return &TLJLockServer{encoderAddr: encoderAddr}
|
||||||
case Dormakaba:
|
|
||||||
return &KabaLockServer{encoderAddr: encoderAddr}
|
|
||||||
default:
|
default:
|
||||||
fatalError(fmt.Errorf("unsupported LockType: %s; must be 'assaabloy' or 'omnitec'", lockType))
|
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
|
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"
|
const funcName = "InitializeServerConnection"
|
||||||
// Parse the URL to extract host and port
|
// Parse the URL to extract host and port
|
||||||
parsedURL, err := url.Parse(LockserverURL)
|
parsedUrl, err := url.Parse(LockserverUrl)
|
||||||
if err != nil {
|
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
|
// 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
|
// Establish a TCP connection to the Visionline server
|
||||||
conn, err := net.Dial("tcp", address)
|
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)
|
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)
|
buf := make([]byte, 128)
|
||||||
reader := bufio.NewReader(conn)
|
reader := bufio.NewReader(conn)
|
||||||
@ -7,12 +7,12 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
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 {
|
func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, checkOut time.Time) error {
|
||||||
const funcName = "OmniLockServer.BuildCommand"
|
const funcName = "OmniLockServer.BuildCommand"
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
@ -25,7 +25,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("[%s] failed to convert lockId to integer: %v", funcName, err)
|
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
|
// Format date/time parts
|
||||||
dt := checkOut.Format("15:04") // DT = HH:mm
|
dt := checkOut.Format("15:04") // DT = HH:mm
|
||||||
@ -37,7 +37,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
|
|||||||
payload := fmt.Sprintf(
|
payload := fmt.Sprintf(
|
||||||
"KR|KC%s|KTD|RN%s|%s|DT%s|G#75|GA%s|GD%s|KO0000|DA%s|TI%s|",
|
"KR|KC%s|KTD|RN%s|%s|DT%s|G#75|GA%s|GD%s|KO0000|DA%s|TI%s|",
|
||||||
lock.encoderAddr,
|
lock.encoderAddr,
|
||||||
formattedLockID,
|
formattedLockId,
|
||||||
hostname,
|
hostname,
|
||||||
dt,
|
dt,
|
||||||
ga,
|
ga,
|
||||||
@ -52,7 +52,7 @@ func (lock *OmniLockServer) BuildCommand(doorReq DoorCardRequest, checkIn, check
|
|||||||
return nil
|
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 {
|
func (lock *OmniLockServer) LockSequence() error {
|
||||||
const funcName = "OmniLockServer.LockSequence"
|
const funcName = "OmniLockServer.LockSequence"
|
||||||
|
|
||||||
@ -136,4 +136,4 @@ func parseOmniResponse(raw string) (string, error) {
|
|||||||
return "", fmt.Errorf("negative response code: %s", clean)
|
return "", fmt.Errorf("negative response code: %s", clean)
|
||||||
}
|
}
|
||||||
return "Success: " + clean, nil
|
return "Success: " + clean, nil
|
||||||
}
|
}
|
||||||
@ -155,7 +155,7 @@ func (lock *SaltoLockServer) LockSequence() error {
|
|||||||
reader := bufio.NewReader(conn)
|
reader := bufio.NewReader(conn)
|
||||||
|
|
||||||
// 1. Send ENQ
|
// 1. Send ENQ
|
||||||
log.Infof("LockSequence: sending ENQ")
|
log.Infof("Sending ENQ")
|
||||||
if _, e := conn.Write([]byte{ENQ}); e != nil {
|
if _, e := conn.Write([]byte{ENQ}); e != nil {
|
||||||
return fmt.Errorf("failed to send ENQ: %w", e)
|
return fmt.Errorf("failed to send ENQ: %w", e)
|
||||||
}
|
}
|
||||||
@ -166,7 +166,7 @@ func (lock *SaltoLockServer) LockSequence() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Send command frame
|
// 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 {
|
if _, e := conn.Write(lock.command); e != nil {
|
||||||
return fmt.Errorf("failed to send command frame: %w", e)
|
return fmt.Errorf("failed to send command frame: %w", e)
|
||||||
}
|
}
|
||||||
250
main.go
Normal file
250
main.go
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tarm/serial"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
yaml "gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"gitea.futuresens.co.uk/futuresens/hardlink/dispenser"
|
||||||
|
"gitea.futuresens.co.uk/futuresens/hardlink/handlers"
|
||||||
|
"gitea.futuresens.co.uk/futuresens/hardlink/lockserver"
|
||||||
|
"gitea.futuresens.co.uk/futuresens/hardlink/printer"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
buildVersion = "1.0.24"
|
||||||
|
serviceName = "hardlink"
|
||||||
|
)
|
||||||
|
|
||||||
|
// configRec holds values from config.yml.
|
||||||
|
type configRec struct {
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
LockserverUrl string `yaml:"lockservUrl"`
|
||||||
|
LockType string `yaml:"lockType"`
|
||||||
|
EncoderAddress string `yaml:"encoderAddr"`
|
||||||
|
Cert string `yaml:"cert"`
|
||||||
|
DispenserPort string `yaml:"dispensPort"`
|
||||||
|
DispenserAdrr string `yaml:"dispensAddr"`
|
||||||
|
PrinterName string `yaml:"printerName"`
|
||||||
|
LogDir string `yaml:"logdir"`
|
||||||
|
IsPayment bool `yaml:"isPayment"`
|
||||||
|
TestMode bool `yaml:"testMode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load config
|
||||||
|
config := readConfig()
|
||||||
|
printer.Layout = readTicketLayout()
|
||||||
|
printer.PrinterName = config.PrinterName
|
||||||
|
lockserver.Cert = config.Cert
|
||||||
|
lockserver.LockServerURL = config.LockserverUrl
|
||||||
|
dispHandle := &serial.Port{}
|
||||||
|
|
||||||
|
// Setup logging and get file handle
|
||||||
|
logFile, err := setupLogging(config.LogDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to set up logging: %v\n", err)
|
||||||
|
}
|
||||||
|
defer logFile.Close()
|
||||||
|
|
||||||
|
// Initialize dispenser
|
||||||
|
if !config.TestMode {
|
||||||
|
dispenser.SerialPort = config.DispenserPort
|
||||||
|
dispenser.Address = []byte(config.DispenserAdrr)
|
||||||
|
dispHandle, err = dispenser.InitializeDispenser()
|
||||||
|
if err != nil {
|
||||||
|
handlers.FatalError(err)
|
||||||
|
}
|
||||||
|
defer dispHandle.Close()
|
||||||
|
|
||||||
|
status, err := dispenser.CheckDispenserStatus(dispHandle)
|
||||||
|
if err != nil {
|
||||||
|
if len(status) == 0 {
|
||||||
|
err = fmt.Errorf("%s; wrong dispenser address: %s", err, config.DispenserAdrr)
|
||||||
|
handlers.FatalError(err)
|
||||||
|
} else {
|
||||||
|
fmt.Println(status)
|
||||||
|
fmt.Println(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Infof("Dispenser initialized on port %s, %s", config.DispenserPort, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test lock-server connection
|
||||||
|
switch strings.ToLower(config.LockType) {
|
||||||
|
case lockserver.TLJ:
|
||||||
|
|
||||||
|
default:
|
||||||
|
lockConn, err := lockserver.InitializeServerConnection(config.LockserverUrl)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err.Error())
|
||||||
|
log.Errorf(err.Error())
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Connected to the lock server successfuly at %s\n", config.LockserverUrl)
|
||||||
|
log.Infof("Connected to the lock server successfuly at %s", config.LockserverUrl)
|
||||||
|
lockConn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.IsPayment {
|
||||||
|
fmt.Println("Payment processing is enabled")
|
||||||
|
log.Info("Payment processing is enabled")
|
||||||
|
startChipDnaClient()
|
||||||
|
} else {
|
||||||
|
fmt.Println("Payment processing is disabled")
|
||||||
|
log.Info("Payment processing is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create App and wire routes
|
||||||
|
app := handlers.NewApp(dispHandle, config.LockType, config.EncoderAddress, config.IsPayment)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
app.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", config.Port)
|
||||||
|
log.Infof("Starting HTTP server on http://localhost%s", addr)
|
||||||
|
fmt.Printf("Starting HTTP server on http://localhost%s", addr)
|
||||||
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
|
handlers.FatalError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupLogging ensures log directory, opens log file, and configures logrus.
|
||||||
|
// Returns the *os.File so caller can defer its Close().
|
||||||
|
func setupLogging(logDir string) (*os.File, error) {
|
||||||
|
fileName := logDir + serviceName + ".log"
|
||||||
|
f, err := os.OpenFile(fileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open log file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.SetOutput(f)
|
||||||
|
log.SetFormatter(&log.JSONFormatter{
|
||||||
|
TimestampFormat: time.RFC3339,
|
||||||
|
})
|
||||||
|
log.SetLevel(log.InfoLevel)
|
||||||
|
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"buildVersion": buildVersion,
|
||||||
|
}).Info("Logging initialized")
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readConfig reads config.yml and applies defaults.
|
||||||
|
func readConfig() configRec {
|
||||||
|
var cfg configRec
|
||||||
|
const configName = "config.yml"
|
||||||
|
defaultPort := 9091
|
||||||
|
sep := string(os.PathSeparator)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configName)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("ReadConfig %s: %v", configName, err)
|
||||||
|
} else if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
log.Warnf("Unmarshal config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
cfg.Port = defaultPort
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.LockType == "" {
|
||||||
|
err = fmt.Errorf("LockType is required in %s", configName)
|
||||||
|
handlers.FatalError(err)
|
||||||
|
}
|
||||||
|
cfg.LockType = strings.ToLower(cfg.LockType)
|
||||||
|
|
||||||
|
if cfg.LogDir == "" {
|
||||||
|
cfg.LogDir = "./logs" + sep
|
||||||
|
} else if !strings.HasSuffix(cfg.LogDir, sep) {
|
||||||
|
cfg.LogDir += sep
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTicketLayout() printer.LayoutOptions {
|
||||||
|
const layoutName = "TicketLayout.xml"
|
||||||
|
var layout printer.LayoutOptions
|
||||||
|
|
||||||
|
// 1) Read the file
|
||||||
|
data, err := os.ReadFile(layoutName)
|
||||||
|
if err != nil {
|
||||||
|
handlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Unmarshal into your struct
|
||||||
|
if err := xml.Unmarshal(data, &layout); err != nil {
|
||||||
|
handlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
func startChipDnaClient() {
|
||||||
|
startClient := func() (*exec.Cmd, error) {
|
||||||
|
cmd := exec.Command("./ChipDNAClient/ChipDnaClient.exe")
|
||||||
|
err := cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start ChipDnaClient: %v", err)
|
||||||
|
}
|
||||||
|
log.Infof("ChipDnaClient started with PID %d", cmd.Process.Pid)
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, err := startClient()
|
||||||
|
if err != nil {
|
||||||
|
handlers.FatalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart loop
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
err := cmd.Wait()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("ChipDnaClient exited unexpectedly: %v", err)
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
cmd, err = startClient()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Restart failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info("ChipDnaClient restarted successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Handle shutdown signals
|
||||||
|
sigs := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sigs
|
||||||
|
log.Info("Shutting down...")
|
||||||
|
if cmd.Process != nil {
|
||||||
|
log.Info("Sending SIGTERM to ChipDnaClient...")
|
||||||
|
_ = cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
// wait up to 5s for graceful shutdown
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- cmd.Wait() }()
|
||||||
|
select {
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
log.Warn("ChipDnaClient did not exit in time, killing...")
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
case err := <-done:
|
||||||
|
log.Infof("ChipDnaClient exited cleanly: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
}
|
||||||
344
payment/creditcall.go
Normal file
344
payment/creditcall.go
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/denisenkom/go-mssqldb"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Transaction types
|
||||||
|
SaleTransactionType = "sale"
|
||||||
|
AccountVerificationType = "account verification"
|
||||||
|
|
||||||
|
// Transaction results
|
||||||
|
ResultApproved = "approved"
|
||||||
|
ResultDeclined = "declined"
|
||||||
|
ResultCancelled = "cancelled"
|
||||||
|
ResultPending = "pending"
|
||||||
|
ResultError = "error"
|
||||||
|
CheckinSuccessfulEndpoint = "/successful" // Endpoint to send guest to after successful payment
|
||||||
|
CheckinUnsuccessfulEndpoint = "/unsuccessful"
|
||||||
|
|
||||||
|
// Response map keys
|
||||||
|
CardReference = "CARD_REFERENCE"
|
||||||
|
CardHash = "CARD_HASH"
|
||||||
|
Errors = "ERRORS"
|
||||||
|
ReceiptData = "RECEIPT_DATA"
|
||||||
|
ReceiptDataMerchant = "RECEIPT_DATA_MERCHANT"
|
||||||
|
ReceiptDataCardholder = "RECEIPT_DATA_CARDHOLDER"
|
||||||
|
Reference = "REFERENCE"
|
||||||
|
TransactionResult = "TRANSACTION_RESULT"
|
||||||
|
TransactionType = "TRANSACTION_TYPE"
|
||||||
|
ConfirmResult = "CONFIRM_RESULT"
|
||||||
|
ConfirmErrors = "CONFIRM_ERRORS"
|
||||||
|
|
||||||
|
// Log field keys
|
||||||
|
LogFieldError = "error"
|
||||||
|
LogFieldDescription = "description"
|
||||||
|
LogResult = "transactionResult"
|
||||||
|
)
|
||||||
|
|
||||||
|
// XML parsing structs
|
||||||
|
type (
|
||||||
|
TransactionRec struct {
|
||||||
|
XMLName xml.Name `xml:"TransactionPayload"`
|
||||||
|
AmountMinorUnits string `xml:"amount"`
|
||||||
|
TransactionType string `xml:"transactionType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionResultXML struct {
|
||||||
|
XMLName xml.Name `xml:"TransactionResult"`
|
||||||
|
Entries []EntryXML `xml:"Entry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
EntryXML struct {
|
||||||
|
Key string `xml:"Key"`
|
||||||
|
Value string `xml:"Value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionConfirmation struct {
|
||||||
|
XMLName xml.Name `xml:"TransactionConfirmation"`
|
||||||
|
Result string `xml:"Result"`
|
||||||
|
Errors string `xml:"Errors"`
|
||||||
|
ErrorDescription string `xml:"ErrorDescription"`
|
||||||
|
ReceiptDataCardholder string `xml:"ReceiptDataCardholder"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseTransactionResult parses the XML into entries.
|
||||||
|
func (tr *TransactionResultXML) ParseTransactionResult(data []byte) error {
|
||||||
|
if err := xml.Unmarshal(data, &tr); err != nil {
|
||||||
|
return fmt.Errorf("XML unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// initMSSQL opens and pings the SQL Server instance localhost\SQLEXPRESS
|
||||||
|
// using user=Kiosk, password=Gr33nfarm, database=TransactionDatabase.
|
||||||
|
func InitMSSQL(port int, user, password, database string) (*sql.DB, error) {
|
||||||
|
const server = "localhost"
|
||||||
|
|
||||||
|
// Use TCP; drop the \SQLEXPRESS instance name
|
||||||
|
connString := fmt.Sprintf(
|
||||||
|
"sqlserver://%s:%s@%s:%d?database=%s&encrypt=disable",
|
||||||
|
user, password, server, port, database,
|
||||||
|
)
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlserver", connString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening DB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify connectivity
|
||||||
|
if err := db.PingContext(context.Background()); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("pinging DB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertTransactionRecord inserts one row into TransactionRecords.
|
||||||
|
// m is the map from keys to string values as returned by ChipDNA.
|
||||||
|
func InsertTransactionRecord(ctx context.Context, db *sql.DB, m map[string]string) error {
|
||||||
|
// Extract fields with defaults or NULL handling.
|
||||||
|
|
||||||
|
// 1. TxnReference <- REFERENCE
|
||||||
|
ref, ok := m["REFERENCE"]
|
||||||
|
if !ok || ref == "" {
|
||||||
|
return fmt.Errorf("missing REFERENCE in result map")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. TxnDateTime <- parse AUTH_DATE_TIME (layout "20060102150405"), else use now
|
||||||
|
var txnTime time.Time
|
||||||
|
if s, ok := m["AUTH_DATE_TIME"]; ok && s != "" {
|
||||||
|
t, err := time.ParseInLocation("20060102150405", s, time.UTC)
|
||||||
|
if err != nil {
|
||||||
|
// fallback: use now
|
||||||
|
txnTime = time.Now().UTC()
|
||||||
|
} else {
|
||||||
|
txnTime = t
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
txnTime = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. TotalAmount <- parse TOTAL_AMOUNT minor units into float (divide by 100)
|
||||||
|
var totalAmount sql.NullFloat64
|
||||||
|
if s, ok := m["TOTAL_AMOUNT"]; ok && s != "" {
|
||||||
|
if iv, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||||
|
// convert minor units to major (e.g. 150 -> 1.50)
|
||||||
|
totalAmount.Float64 = float64(iv) / 100.0
|
||||||
|
totalAmount.Valid = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. MerchantId <- MERCHANT_ID_MASKED
|
||||||
|
merchantId := sql.NullString{String: m["MERCHANT_ID_MASKED"], Valid: m["MERCHANT_ID_MASKED"] != ""}
|
||||||
|
|
||||||
|
// 5. TerminalId <- TERMINAL_ID_MASKED
|
||||||
|
terminalId := sql.NullString{String: m["TERMINAL_ID_MASKED"], Valid: m["TERMINAL_ID_MASKED"] != ""}
|
||||||
|
|
||||||
|
// 6. CardSchemeName <- CARD_SCHEME
|
||||||
|
cardScheme := sql.NullString{String: m["CARD_SCHEME"], Valid: m["CARD_SCHEME"] != ""}
|
||||||
|
|
||||||
|
// 7. ExpiryDate <- EXPIRY_DATE
|
||||||
|
expiryDate := sql.NullString{String: m["EXPIRY_DATE"], Valid: m["EXPIRY_DATE"] != ""}
|
||||||
|
|
||||||
|
// 8. RecordReference <- CARD_REFERENCE
|
||||||
|
recordRef := sql.NullString{String: m["CARD_REFERENCE"], Valid: m["CARD_REFERENCE"] != ""}
|
||||||
|
|
||||||
|
// 9. Token1 <- CARD_HASH
|
||||||
|
token1 := sql.NullString{String: m["CARD_HASH"], Valid: m["CARD_HASH"] != ""}
|
||||||
|
|
||||||
|
// 10. Token2 <- CARDEASE_REFERENCE
|
||||||
|
token2 := sql.NullString{String: m["CARDEASE_REFERENCE"], Valid: m["CARDEASE_REFERENCE"] != ""}
|
||||||
|
|
||||||
|
// 11. PanMasked <- PAN_MASKED
|
||||||
|
panMasked := sql.NullString{String: m["PAN_MASKED"], Valid: m["PAN_MASKED"] != ""}
|
||||||
|
|
||||||
|
// 12. AuthCode <- AUTH_CODE
|
||||||
|
authCode := sql.NullString{String: m["AUTH_CODE"], Valid: m["AUTH_CODE"] != ""}
|
||||||
|
|
||||||
|
// 13. TransactionResult <- TRANSACTION_RESULT
|
||||||
|
txnResult := sql.NullString{String: m["TRANSACTION_RESULT"], Valid: m["TRANSACTION_RESULT"] != ""}
|
||||||
|
|
||||||
|
// Build INSERT statement with named parameters.
|
||||||
|
// Assuming your table is [TransactionDatabase].[dbo].[TransactionRecords].
|
||||||
|
const stmt = `
|
||||||
|
INSERT INTO [TransactionDatabase].[dbo].[TransactionRecords]
|
||||||
|
(
|
||||||
|
[TxnReference],
|
||||||
|
[TxnDateTime],
|
||||||
|
[TotalAmount],
|
||||||
|
[MerchantId],
|
||||||
|
[TerminalId],
|
||||||
|
[CardSchemeName],
|
||||||
|
[ExpiryDate],
|
||||||
|
[RecordReference],
|
||||||
|
[Token1],
|
||||||
|
[Token2],
|
||||||
|
[PanMasked],
|
||||||
|
[AuthCode],
|
||||||
|
[TransactionResult]
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
@TxnReference,
|
||||||
|
@TxnDateTime,
|
||||||
|
@TotalAmount,
|
||||||
|
@MerchantId,
|
||||||
|
@TerminalId,
|
||||||
|
@CardSchemeName,
|
||||||
|
@ExpiryDate,
|
||||||
|
@RecordReference,
|
||||||
|
@Token1,
|
||||||
|
@Token2,
|
||||||
|
@PanMasked,
|
||||||
|
@AuthCode,
|
||||||
|
@TransactionResult
|
||||||
|
);
|
||||||
|
`
|
||||||
|
// Execute with sql.Named parameters:
|
||||||
|
_, err := db.ExecContext(ctx, stmt,
|
||||||
|
sql.Named("TxnReference", ref),
|
||||||
|
sql.Named("TxnDateTime", txnTime),
|
||||||
|
sql.Named("TotalAmount", nullableFloatArg(totalAmount)),
|
||||||
|
sql.Named("MerchantId", nullableStringArg(merchantId)),
|
||||||
|
sql.Named("TerminalId", nullableStringArg(terminalId)),
|
||||||
|
sql.Named("CardSchemeName", nullableStringArg(cardScheme)),
|
||||||
|
sql.Named("ExpiryDate", nullableStringArg(expiryDate)),
|
||||||
|
sql.Named("RecordReference", nullableStringArg(recordRef)),
|
||||||
|
sql.Named("Token1", nullableStringArg(token1)),
|
||||||
|
sql.Named("Token2", nullableStringArg(token2)),
|
||||||
|
sql.Named("PanMasked", nullableStringArg(panMasked)),
|
||||||
|
sql.Named("AuthCode", nullableStringArg(authCode)),
|
||||||
|
sql.Named("TransactionResult", nullableStringArg(txnResult)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert TransactionRecords: %w", err)
|
||||||
|
}
|
||||||
|
// Successfully inserted
|
||||||
|
log.Infof("Inserted transaction record for reference %s", ref)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers to pass NULL when appropriate:
|
||||||
|
func nullableStringArg(ns sql.NullString) interface{} {
|
||||||
|
if ns.Valid {
|
||||||
|
return ns.String
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func nullableFloatArg(nf sql.NullFloat64) interface{} {
|
||||||
|
if nf.Valid {
|
||||||
|
return nf.Float64
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRedirectURL builds the redirect URL to send the guest to after payment.
|
||||||
|
func BuildPaymentRedirectURL(result map[string]string) string {
|
||||||
|
res := result[TransactionResult]
|
||||||
|
tType := result[TransactionType]
|
||||||
|
|
||||||
|
// Transaction approved?
|
||||||
|
if strings.EqualFold(res, ResultApproved) {
|
||||||
|
switch {
|
||||||
|
// Transaction type AccountVerification?
|
||||||
|
case strings.EqualFold(tType, AccountVerificationType):
|
||||||
|
log.WithField(LogResult, result[TransactionResult]).
|
||||||
|
Info("Account verification approved")
|
||||||
|
|
||||||
|
return buildSuccessURL(result)
|
||||||
|
|
||||||
|
// Transaction type Sale?
|
||||||
|
case strings.EqualFold(tType, SaleTransactionType):
|
||||||
|
// Transaction confirmed?
|
||||||
|
if strings.EqualFold(result[ConfirmResult], ResultApproved) {
|
||||||
|
log.WithField(LogResult, result[ConfirmResult]).
|
||||||
|
Info("Transaction approved and confirmed")
|
||||||
|
|
||||||
|
return buildSuccessURL(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not confirmed
|
||||||
|
log.WithFields(log.Fields{LogFieldError: result[ConfirmResult], LogFieldDescription: result[ConfirmErrors]}).
|
||||||
|
Error("Transaction approved but not confirmed")
|
||||||
|
|
||||||
|
return BuildFailureURL(result[ConfirmResult], result[ConfirmErrors])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not approved
|
||||||
|
return BuildFailureURL(res, result[Errors])
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildPreauthRedirectURL(result map[string]string) string {
|
||||||
|
res := result[TransactionResult]
|
||||||
|
tType := result[TransactionType]
|
||||||
|
|
||||||
|
// Transaction approved?
|
||||||
|
if strings.EqualFold(res, ResultApproved) {
|
||||||
|
switch {
|
||||||
|
// Transaction type AccountVerification?
|
||||||
|
case strings.EqualFold(tType, AccountVerificationType):
|
||||||
|
log.WithField(LogResult, result[TransactionResult]).
|
||||||
|
Info("Account verification approved")
|
||||||
|
|
||||||
|
return buildSuccessURL(result)
|
||||||
|
|
||||||
|
// Transaction type Sale?
|
||||||
|
case strings.EqualFold(tType, SaleTransactionType):
|
||||||
|
// Transaction confirmed?
|
||||||
|
log.WithField(LogResult, result[ConfirmResult]).
|
||||||
|
Info("Transaction approved and confirmed")
|
||||||
|
|
||||||
|
return buildSuccessURL(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not approved
|
||||||
|
return BuildFailureURL(res, result[Errors])
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSuccessURL(result map[string]string) string {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("TxnReference", result[Reference])
|
||||||
|
q.Set("CardHash", hex.EncodeToString([]byte(result[CardHash])))
|
||||||
|
q.Set("CardReference", hex.EncodeToString([]byte(result[CardReference])))
|
||||||
|
return (&url.URL{
|
||||||
|
Path: CheckinSuccessfulEndpoint,
|
||||||
|
RawQuery: q.Encode(),
|
||||||
|
}).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildFailureURL(msgType, description string) string {
|
||||||
|
q := url.Values{}
|
||||||
|
if msgType != "" {
|
||||||
|
description = fmt.Sprintf("Transaction %s", strings.ToLower(msgType))
|
||||||
|
}
|
||||||
|
if description != "" {
|
||||||
|
msgType = ResultError
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithFields(log.Fields{LogFieldError: msgType, LogFieldDescription: description}).
|
||||||
|
Error("Transaction failed")
|
||||||
|
|
||||||
|
q.Set("MsgType", msgType)
|
||||||
|
q.Set("Description", description)
|
||||||
|
return (&url.URL{
|
||||||
|
Path: CheckinUnsuccessfulEndpoint,
|
||||||
|
RawQuery: q.Encode(),
|
||||||
|
}).String()
|
||||||
|
}
|
||||||
@ -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_"
|
|
||||||
)
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -13,7 +13,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
|
|
||||||
"github.com/alexbrainman/printer"
|
"github.com/alexbrainman/printer"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
imagedraw "golang.org/x/image/draw"
|
imagedraw "golang.org/x/image/draw"
|
||||||
@ -40,7 +39,6 @@ type (
|
|||||||
Name string `xml:"customername"`
|
Name string `xml:"customername"`
|
||||||
Checkout string `xml:"checkoutdatetime"`
|
Checkout string `xml:"checkoutdatetime"`
|
||||||
RoomID string `xml:"roomno"`
|
RoomID string `xml:"roomno"`
|
||||||
Voucher string `xml:"voucher"`
|
|
||||||
Map string `xml:"roommap"`
|
Map string `xml:"roommap"`
|
||||||
Directions string `xml:"roomdirections"`
|
Directions string `xml:"roomdirections"`
|
||||||
}
|
}
|
||||||
@ -95,30 +93,7 @@ func ParseCardholderReceipt(data []byte) ([]ReceiptEntryXML, error) {
|
|||||||
return tr.ReceiptEntries.ReceiptEntry, nil
|
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) {
|
func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
|
||||||
Layout = readTicketLayout()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
// shortcuts
|
// shortcuts
|
||||||
@ -166,11 +141,6 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
|
|||||||
write([]byte{ESC, 'a', CENTER})
|
write([]byte{ESC, 'a', CENTER})
|
||||||
writeStr(Layout.HotelSpecificDetails + "\n\n")
|
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
|
// 8) Room map image
|
||||||
mapPath := filepath.Join(Layout.RoomMapFolderPath, details.Map)
|
mapPath := filepath.Join(Layout.RoomMapFolderPath, details.Map)
|
||||||
mapBytes, err := printMap(mapPath)
|
mapBytes, err := printMap(mapPath)
|
||||||
@ -197,36 +167,7 @@ func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
|
|||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func PrintReceipt(receipt string) {
|
func PrintCardholderReceipt(cardholderReceipt string) error {
|
||||||
|
|
||||||
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 {
|
|
||||||
receiptEntries, err := ParseCardholderReceipt([]byte(cardholderReceipt))
|
receiptEntries, err := ParseCardholderReceipt([]byte(cardholderReceipt))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ParseCardholderReceipt: %w", err)
|
return fmt.Errorf("ParseCardholderReceipt: %w", err)
|
||||||
@ -245,174 +186,95 @@ func printCardholderReceipt(cardholderReceipt string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// receiptColumns follows the existing 44-column normal-size text convention.
|
|
||||||
const receiptColumns = 44
|
|
||||||
|
|
||||||
func BuildCardholderReceipt(entries []ReceiptEntryXML) ([]byte, error) {
|
func BuildCardholderReceipt(entries []ReceiptEntryXML) ([]byte, error) {
|
||||||
var buf bytes.Buffer
|
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.
|
write := func(b []byte) { buf.Write(b) }
|
||||||
normal := func() { buf.Write([]byte{ESC, 'a', 0, ESC, 'E', BOLD_OFF, GS, '!', NORMAL_FONT}) }
|
// writeStr := func(s string) { buf.WriteString(s) }
|
||||||
normal()
|
writeln := func(s string) {
|
||||||
hasRows, newSection := false, false
|
buf.WriteString(s)
|
||||||
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)
|
|
||||||
buf.WriteByte('\n')
|
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)
|
// Build a lookup map by ReceiptEntryId
|
||||||
line(take("MerchantName"), false, true)
|
m := make(map[string]ReceiptEntryXML, len(entries))
|
||||||
newSection = true
|
for _, e := range entries {
|
||||||
line(take("CardScheme"), false, false)
|
m[e.ReceiptEntryId] = e
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
pair(take("CardholderVerification"), take("TransactionResult"), true)
|
|
||||||
newSection = true
|
// Utility to center text
|
||||||
line(take("AuthCode"), false, false)
|
// center := func(s string) {
|
||||||
line(take("AuthReference"), false, false)
|
// write([]byte{ESC, 'a', CENTER})
|
||||||
line(take("TransactionSeqNumber"), false, false)
|
// writeln(s)
|
||||||
pair(take("MerchantIdMasked"), take("TerminalIdMasked"), false)
|
// write([]byte{ESC, 'a', 0})
|
||||||
line(take("AuthDateTime"), false, false)
|
// }
|
||||||
footer := take("RetentionMessage")
|
|
||||||
// Preserve every remaining occurrence, including unknown IDs, in provider order.
|
// Utility for bold lines
|
||||||
for i, entry := range entries {
|
// boldOn := func() { write([]byte{GS, '!', WIDER_FONT}) }
|
||||||
if !rendered[i] && entry.Value != "" {
|
// boldOff := func() { write([]byte{GS, '!', NORMAL_FONT}) }
|
||||||
line(receiptEntryText(entry), false, false)
|
|
||||||
}
|
// 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
|
writeln("TOTAL: " + total)
|
||||||
line(footer, false, true)
|
|
||||||
normal()
|
// 9) Cardholder verification
|
||||||
buf.Write([]byte{ESC, 'd', 7})
|
writeln(m["CardholderVerification"].Value)
|
||||||
buf.Write([]byte{GS, 'V', 1})
|
|
||||||
|
// 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
|
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) {
|
func printLogo(path string) ([]byte, error) {
|
||||||
const maxLogoWidth = 384
|
const maxLogoWidth = 384
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
122
release notes.md
122
release notes.md
@ -2,128 +2,6 @@
|
|||||||
|
|
||||||
builtVersion is a const in main.go
|
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
|
|
||||||
|
|
||||||
#### 1.0.27 - 10 December 2025
|
|
||||||
updated handling AmountTooSmall response from creditcall
|
|
||||||
|
|
||||||
#### 1.0.26 - 10 December 2025
|
|
||||||
added route for taking preauthorization payment
|
|
||||||
|
|
||||||
#### 1.0.25 - 08 December 2025
|
|
||||||
return masked card number and expiry date in the payment success URL
|
|
||||||
|
|
||||||
#### 1.0.24 - 13 November 2025
|
#### 1.0.24 - 13 November 2025
|
||||||
improved logging for creditcall payment processing
|
improved logging for creditcall payment processing
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user