diff --git a/CREDITCALL_STREAMING.md b/CREDITCALL_STREAMING.md
new file mode 100644
index 0000000..9c3a8d2
--- /dev/null
+++ b/CREDITCALL_STREAMING.md
@@ -0,0 +1,93 @@
+# 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 route; 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.
+
+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
+the existing confirmation behavior. `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 the required SDK
+fields, or a sanitized `error`. Receipt XML may be a JSON string field needed by
+hardlink's existing receipt handler; it is never a raw line in the stream.
+Confirmation continues through the existing, separate XML endpoint.
+
+## Lifetime and compatibility
+
+- `/start-transaction/`, `/takepayment`, and `/takepreauth` retain their existing
+ external contracts. Preauthorization and SQL persistence/release remain legacy.
+- Start and each 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, or receipt handling. 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.
+- Unidentified `CardRemovalRequested` and `CardRemovalEnforced` updates are omitted
+ because they can arrive after financial completion. They require a matching
+ explicit reference. `Removed` remains omitted even with a reference.
+- Ownership is best-effort UI observation. SDK 3.17 does not establish that every
+ queued reference-less non-removal callback 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. 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 sales, unchanged preauth requests, failure/retry parity, and existing
+Dojo/PayBridge response handling. Run `go vet ./...`, `go build ./...`, and
+`git diff --check` in both Go repositories.
diff --git a/cmd/hardlink/main.go b/cmd/hardlink/main.go
index daf5ce6..e6ad59c 100644
--- a/cmd/hardlink/main.go
+++ b/cmd/hardlink/main.go
@@ -33,7 +33,7 @@ import (
)
const (
- buildVersion = "v1.3.6"
+ buildVersion = "v2.0.0"
serviceName = "hardlink"
pollingFrequency = 8 * time.Second
)
@@ -135,7 +135,8 @@ func main() {
switch paymentProvider {
case cmstypes.PaySystemCreditCall:
- // CreditCall keeps using the existing /takepayment and /takepreauth endpoints.
+ // CreditCall streaming shares its proven legacy transaction core.
+ app.EnableCreditCallStreaming()
startChipDnaClient()
go func() {
@@ -203,7 +204,7 @@ func main() {
))
}
- // Only PayBridge and Dojo use POST /api/payment/sale.
+ // 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])
@@ -325,4 +326,4 @@ func pdqSerialForKiosk(pdqs []cmstypes.PDQRec, kiosk int) string {
}
return ""
-}
\ No newline at end of file
+}
diff --git a/internal/creditcall/creditcall.go b/internal/creditcall/creditcall.go
index e65eb84..9ef92c6 100644
--- a/internal/creditcall/creditcall.go
+++ b/internal/creditcall/creditcall.go
@@ -168,9 +168,7 @@ func BuildSuccessURL(result map[string]string) string {
func BuildFailureURL(msgType, description string) string {
q := url.Values{}
- if msgType != "" {
- description = fmt.Sprintf("Transaction %s", strings.ToLower(msgType))
- }
+ description = FailureDescription(msgType, description)
if description != "" {
msgType = types.ResultError
}
@@ -185,3 +183,12 @@ func BuildFailureURL(msgType, description string) string {
RawQuery: q.Encode(),
}).String()
}
+
+// FailureDescription preserves the description used by the legacy payment flow
+// without requiring structured consumers to construct a redirect URL.
+func FailureDescription(msgType, description string) string {
+ if msgType != "" {
+ return fmt.Sprintf("Transaction %s", strings.ToLower(msgType))
+ }
+ return description
+}
diff --git a/internal/creditcall/statuses.go b/internal/creditcall/statuses.go
new file mode 100644
index 0000000..ec7993e
--- /dev/null
+++ b/internal/creditcall/statuses.go
@@ -0,0 +1,64 @@
+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
+}
diff --git a/internal/creditcall/statuses_test.go b/internal/creditcall/statuses_test.go
new file mode 100644
index 0000000..f7093ba
--- /dev/null
+++ b/internal/creditcall/statuses_test.go
@@ -0,0 +1,52 @@
+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)
+ }
+ }
+}
diff --git a/internal/handlers/creditcall_sale.go b/internal/handlers/creditcall_sale.go
new file mode 100644
index 0000000..cde5e9d
--- /dev/null
+++ b/internal/handlers/creditcall_sale.go
@@ -0,0 +1,130 @@
+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
+}
diff --git a/internal/handlers/creditcall_sale_test.go b/internal/handlers/creditcall_sale_test.go
new file mode 100644
index 0000000..c792e74
--- /dev/null
+++ b/internal/handlers/creditcall_sale_test.go
@@ -0,0 +1,480 @@
+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(`1234Sale`))
+ 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, "")
+ case "malformed":
+ io.WriteString(w, "")
+ 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, "")
+ 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", "\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())
+ }
+}
diff --git a/internal/handlers/creditcall_stream.go b/internal/handlers/creditcall_stream.go
new file mode 100644
index 0000000..27000b9
--- /dev/null
+++ b/internal/handlers/creditcall_stream.go
@@ -0,0 +1,226 @@
+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) {
+ 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 !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 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
+ }
+ if _, ok := w.(http.Flusher); !ok {
+ reject(http.StatusInternalServerError, "Streaming payment updates are not supported")
+ return
+ }
+
+ progress := make(chan string, 128)
+ finished := make(chan creditCallSaleOutcome, 1)
+ go func() {
+ outcome := app.executeCreditCallSale(cmstypes.TransactionRec{
+ AmountMinorUnits: strconv.FormatInt(request.Amount, 10), TransactionType: "Sale",
+ }, func(client *http.Client) (creditcall.TransactionResultXML, error) {
+ return callChipDNAStream(client, request.Amount, func(code string) {
+ select {
+ case progress <- code:
+ default: // Observational progress may be dropped under backpressure.
+ }
+ })
+ })
+ 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) {
+ var result creditcall.TransactionResultXML
+ payload, err := json.Marshal(struct {
+ Amount string `json:"amount"`
+ TransactionType string `json:"transactionType"`
+ }{strconv.FormatInt(amount, 10), "Sale"})
+ if err != nil {
+ return result, err
+ }
+ // Background plus Client.Timeout reproduces the independent per-call bound.
+ req, err := http.NewRequestWithContext(context.Background(), 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")
+ }
+ }
+}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index d6e61b5..528c6dd 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -35,17 +35,20 @@ type doorCardDispenser interface {
}
type App struct {
- disp doorCardDispenser
- lockserver lockserver.LockServer
- paymentService *paymentsvc.Service
- isPayment bool
- db *sql.DB
- cfg *config.ConfigRec
- dbMu sync.Mutex
- cardWellMu sync.RWMutex
- cardWellStatus string
- availabilityMu sync.Mutex
- availabilityTimers map[string]*time.Timer
+ 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 {
@@ -146,7 +149,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
theRequest.TransactionType,
)
- client := &http.Client{Timeout: 300 * time.Second}
+ client := app.creditCallClient()
// ---- START TRANSACTION ----
@@ -167,7 +170,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
// ---- PRINT RECEIPT ----
- printer.PrintReceipt(result.CardholderReceipt)
+ app.printCreditCallReceipt(result.CardholderReceipt)
// ---- REDIRECT ----
@@ -187,8 +190,6 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
var (
theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec
- trResult creditcall.TransactionResultXML
- result creditcall.PaymentResult
)
theResponse.Status.Code = http.StatusInternalServerError
@@ -250,90 +251,18 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
theRequest.TransactionType,
)
- client := &http.Client{Timeout: 300 * time.Second}
-
- // ---- START TRANSACTION ----
-
- body, err = callChipDNA(client, types.LinkStartTransaction, body)
- if err != nil {
- logging.Error(types.ServiceName, err.Error(), "Start transaction error", string(op), "", "", 0)
-
- theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "No response from payment processor")
- writeTransactionResult(w, http.StatusBadGateway, theResponse)
- return
- }
-
- if err := trResult.ParseTransactionResult(body); err != nil {
- logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
- }
-
- result.FillFromTransactionResult(trResult)
-
- res := result.Fields[types.TransactionResult]
-
- if !strings.EqualFold(res, types.ResultApproved) {
- printer.PrintReceipt(result.CardholderReceipt)
- desc := result.Fields[types.ErrorDescription]
- if desc == "" {
- desc = result.Fields[types.Errors]
+ 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
}
- logging.Error(types.ServiceName, "Preauthorization failed", "Result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
- theResponse.Status = result.Status
- theResponse.Data = creditcall.BuildFailureURL(res, result.Fields[types.Errors])
-
- writeTransactionResult(w, http.StatusOK, theResponse)
- return
- }
-
- // ---- CONFIRM TRANSACTION ----
-
- ref := result.Fields[types.Reference]
- log.Printf("Preauth approved, reference: %s. Sending confirm...", ref)
- confirmReq := creditcall.ConfirmTransactionRequest{
- Amount: theRequest.AmountMinorUnits,
- Reference: ref,
- }
-
- body, err = confirmWithRetry(client, confirmReq, 2)
- if err != nil {
- logging.Error(types.ServiceName, err.Error(), "Confirm transaction error", string(op), "", "", 0)
- mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Error: "+err.Error())
- theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "ConfirmTransactionError")
- writeTransactionResult(w, http.StatusBadGateway, theResponse)
- return
- }
-
- if err := trResult.ParseTransactionResult(body); err != nil {
- logging.Error(types.ServiceName, err.Error(), "Parse confirm result error", string(op), "", "", 0)
- }
-
- result.FillFromTransactionResult(trResult)
-
- res = result.Fields[types.TransactionResult]
-
- if !strings.EqualFold(res, types.ResultApproved) {
- printer.PrintReceipt(result.CardholderReceipt)
- desc := result.Fields[types.ErrorDescription]
- if desc == "" {
- desc = result.Fields[types.Errors]
+ if err := trResult.ParseTransactionResult(body); err != nil {
+ logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
}
- logging.Error(types.ServiceName, "Transaction not approved after confirm", "Confirm result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
- mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Confirm result: "+res+" Description: "+desc)
- theResponse.Status = result.Status
- theResponse.Data = creditcall.BuildFailureURL(res, result.Fields[types.Errors])
-
- writeTransactionResult(w, http.StatusOK, theResponse)
- return
- }
-
- // ---- SUCCESS ----
-
- printer.PrintReceipt(result.CardholderReceipt)
- log.Printf("Transaction approved and confirmed, reference: %s", ref)
- theResponse.Status = result.Status
- theResponse.Data = creditcall.BuildSuccessURL(result.Fields)
-
- writeTransactionResult(w, http.StatusOK, theResponse)
+ return trResult, nil
+ })
+ writeTransactionResult(w, outcome.HTTPStatus, outcome.legacyResponse())
}
func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/handlers/payment_handlers.go b/internal/handlers/payment_handlers.go
index 0580051..4e4be4a 100644
--- a/internal/handlers/payment_handlers.go
+++ b/internal/handlers/payment_handlers.go
@@ -30,14 +30,20 @@ type SalePaymentRequest struct {
}
type paymentStreamMessage struct {
- Type string `json:"type"`
- Code string `json:"code,omitempty"`
- Response *cmstypes.ResponseRec `json:"response,omitempty"`
+ 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,
diff --git a/internal/types/types.go b/internal/types/types.go
index f1364cb..52ee6a1 100644
--- a/internal/types/types.go
+++ b/internal/types/types.go
@@ -10,6 +10,7 @@ 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/"
diff --git a/release notes.md b/release notes.md
index 65f3ed2..5707445 100644
--- a/release notes.md
+++ b/release notes.md
@@ -2,10 +2,12 @@
builtVersion is a const in main.go
+#### 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