feat: stream CreditCall payment progress and results

This commit is contained in:
yurii 2026-09-08 13:19:32 +01:00
parent ea578fe03e
commit 93ff67fbb1
12 changed files with 1099 additions and 108 deletions

93
CREDITCALL_STREAMING.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(`<TransactionPayload><Amount>1234</Amount><TransactionType>Sale</TransactionType></TransactionPayload>`))
r.Header.Set("Content-Type", "text/xml")
return r
}
func TestCreditCallLegacyAndStreamingSaleParity(t *testing.T) {
for _, test := range []struct {
name, startResult, confirmation string
confirms int
approved bool
receipt string
}{
{"approved", "Approved", "Approved", 1, true, "confirm receipt"},
{"declined", "Declined", "", 0, false, "start receipt"},
{"cancelled", "Cancelled", "", 0, false, "start receipt"},
{"timeout", "TIMEOUT", "", 0, false, "start receipt"},
{"confirmation declined", "Approved", "Declined", 1, false, "confirm receipt"},
{"confirmation omissions", "Approved", "omitted", 1, true, "start receipt"},
{"malformed confirmation", "Approved", "malformed", 1, true, "start receipt"},
} {
t.Run(test.name, func(t *testing.T) {
fields := map[string]string{types.TransactionResult: test.startResult, types.Reference: "native-ref",
types.CardType: "Visa", types.PanMasked: "************1234", types.ExpiryDate: "1228",
types.CardHash: "hash", types.CardReference: "card-ref", types.ReceiptDataCardholder: "start receipt"}
var legacy cmstypes.ResponseRec
for _, streaming := range []bool{false, true} {
var starts, confirms atomic.Int32
var receipts []string
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/start-transaction/":
starts.Add(1)
io.WriteString(w, chipDNAFixture(t, fields))
case "/start-transaction-stream/":
starts.Add(1)
var input struct{ Amount, TransactionType string }
if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.Amount != "1234" || input.TransactionType != "Sale" {
t.Errorf("stream start input = %+v, error %v", input, err)
}
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n")
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n")
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"Approved\"}\n")
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
case "/confirm-transaction/":
confirms.Add(1)
var input creditcall.ConfirmTransactionRequest
if err := xml.NewDecoder(r.Body).Decode(&input); err != nil || input.Amount != "1234" || input.Reference != "native-ref" {
t.Errorf("confirmation input = %+v, error %v", input, err)
}
switch test.confirmation {
case "omitted":
io.WriteString(w, "<TransactionResult/>")
case "malformed":
io.WriteString(w, "<TransactionResult><Entry>")
default:
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: test.confirmation, types.ReceiptDataCardholder: "confirm receipt"}))
}
default:
t.Errorf("unexpected ChipDNA path %q", r.URL.Path)
}
})
app.creditCallReceipt = func(receipt string) { receipts = append(receipts, receipt) }
if got := app.creditCallClient().Timeout; got != 300*time.Second {
t.Fatalf("per-call timeout = %v, want 300s", got)
}
recorder := httptest.NewRecorder()
if streaming {
app.salePayment(recorder, saleRequest(true))
} else {
app.takePayment(recorder, saleRequest(false))
}
if starts.Load() != 1 || int(confirms.Load()) != test.confirms {
t.Errorf("stream=%v starts/confirms=%d/%d, want 1/%d", streaming, starts.Load(), confirms.Load(), test.confirms)
}
if !reflect.DeepEqual(receipts, []string{test.receipt}) {
t.Errorf("stream=%v receipts=%v, want [%s]", streaming, receipts, test.receipt)
}
if !streaming {
if err := json.Unmarshal(recorder.Body.Bytes(), &legacy); err != nil {
t.Fatal(err)
}
if strings.HasPrefix(legacy.Data, "/successful") != test.approved {
t.Errorf("legacy result = %+v, approved want %v", legacy, test.approved)
}
continue
}
frames := decodePaymentStream(t, recorder.Body)
if len(frames) != 3 || frames[0].Code != paymentstatus.PresentCard || frames[1].Code != paymentstatus.PleaseWait || frames[2].Result == nil {
t.Fatalf("frames = %+v, want two ordered hints and one final", frames)
}
final := frames[2].Result
if final.HTTPStatus != http.StatusOK || final.Status != legacy.Status || (final.Outcome == "approved") != test.approved {
t.Errorf("structured final = %+v, legacy = %+v", final, legacy)
}
parsed, err := url.Parse(legacy.Data)
if err != nil {
t.Fatal(err)
}
if test.approved {
if final.TransactionReference != "native-ref" || final.CardType != "Visa" || final.MaskedCardNumber != "************1234" || final.CardHash != "hash" || final.CardReference != "card-ref" || final.ExpiryDate != "1228" {
t.Errorf("retained structured fields = %+v", final)
}
} else if final.Message != parsed.Query().Get("Description") {
t.Errorf("structured message = %q, legacy description = %q", final.Message, parsed.Query().Get("Description"))
}
if frames[2].Response != nil {
t.Error("CreditCall stream included legacy response")
}
}
})
}
}
func TestCreditCallConfirmationRetryAndFailureParity(t *testing.T) {
for _, streaming := range []bool{false, true} {
var attempts int
var times []time.Time
app := &App{isPayment: true, creditCallStreamEnabled: true, cfg: &config.ConfigRec{}}
app.creditCallReceipt = func(string) { t.Error("transport-failed confirmation must not print a receipt") }
app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
if r.Context().Err() != nil {
t.Error("transaction context was cancelled")
}
if r.URL.Path == "/confirm-transaction/" {
attempts++
times = append(times, time.Now())
return nil, errors.New("test transport failure")
}
body := chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved", types.Reference: "native-ref"})
if streaming {
body = "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"native-ref\"}}\n"
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})
recorder := httptest.NewRecorder()
if streaming {
app.salePayment(recorder, saleRequest(true))
} else {
app.takePayment(recorder, saleRequest(false))
}
if attempts != 2 {
t.Fatalf("stream=%v confirms=%d, want 2", streaming, attempts)
}
if times[1].Sub(times[0]) < 2*time.Second {
t.Errorf("retry delay=%v, want at least 2s", times[1].Sub(times[0]))
}
if streaming {
frames := decodePaymentStream(t, recorder.Body)
if len(frames) != 1 || frames[0].Result.HTTPStatus != 502 || frames[0].Result.Status.Code != 500 || frames[0].Result.Message != "Transaction error" {
t.Fatalf("confirmation failure frames=%+v", frames)
}
} else if recorder.Code != 502 {
t.Errorf("legacy confirm failure HTTP=%d, want 502", recorder.Code)
}
}
}
func TestCreditCallLegacyMalformedStartAndPreauth(t *testing.T) {
for _, test := range []struct{ name, path, result, transactionType string }{
{"malformed sale", "/takepayment", "malformed", ""},
{"approved account verification", "/takepreauth", "Approved", types.AccountVerificationType},
{"declined preauth", "/takepreauth", "Declined", "Sale"},
{"malformed preauth", "/takepreauth", "malformed", ""},
} {
t.Run(test.name, func(t *testing.T) {
calls, prints := 0, 0
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/start-transaction/" {
t.Errorf("legacy/preauth path=%s", r.URL.Path)
}
if test.result == "malformed" {
io.WriteString(w, "<TransactionResult><Entry>")
return
}
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: test.result, types.TransactionType: test.transactionType}))
})
app.creditCallReceipt = func(string) { prints++ }
recorder := httptest.NewRecorder()
request := saleRequest(false)
if test.path == "/takepreauth" {
app.takePreauthorization(recorder, request)
} else {
app.takePayment(recorder, request)
}
var response cmstypes.ResponseRec
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if recorder.Code != 200 || calls != 1 || prints != 1 {
t.Errorf("legacy HTTP/calls/receipts=%d/%d/%d", recorder.Code, calls, prints)
}
if test.result == "malformed" && (response.Status.Code != 0 || response.Data != "/unsuccessful?Description=&MsgType=") {
t.Errorf("malformed legacy result=%+v", response)
}
if test.result == "Approved" && !strings.HasPrefix(response.Data, "/successful?") {
t.Errorf("approved preauth=%+v", response)
}
})
}
}
func TestChipDNAStreamRejectsIncompleteOrInvalidFrames(t *testing.T) {
for _, body := range []string{"", "{", "{}\n", "<xml/>\n", "{\"type\":\"result\"}\n", "{\"type\":\"result\",\"result\":{}}\n", "{\"type\":\"error\",\"error\":\"sensitive diagnostic\"}\n", "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\"}}", "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"Approved\"}\n"} {
client := &http.Client{Transport: creditCallRoundTrip(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil
})}
_, err := callChipDNAStream(client, 1234, func(code string) { t.Errorf("invalid stream produced status %q", code) })
if err == nil {
t.Errorf("callChipDNAStream(%q) succeeded, want error", body)
}
if err != nil && strings.Contains(err.Error(), "sensitive diagnostic") {
t.Error("raw diagnostic leaked")
}
}
}
type disconnectedPaymentWriter struct {
header http.Header
once sync.Once
failed chan struct{}
writes atomic.Int32
}
func (w *disconnectedPaymentWriter) Header() http.Header { return w.header }
func (w *disconnectedPaymentWriter) WriteHeader(int) {}
func (w *disconnectedPaymentWriter) Flush() {}
func (w *disconnectedPaymentWriter) Write([]byte) (int, error) {
w.writes.Add(1)
w.once.Do(func() { close(w.failed) })
return 0, io.ErrClosedPipe
}
func TestCreditCallDisconnectStillConfirmsAndPrints(t *testing.T) {
release := make(chan struct{})
var confirms atomic.Int32
var receipts atomic.Int32
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/confirm-transaction/" {
confirms.Add(1)
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved", types.ReceiptDataCardholder: "receipt"}))
return
}
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n")
w.(http.Flusher).Flush()
<-release
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n")
io.WriteString(w, "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"ref\"}}\n")
})
app.creditCallReceipt = func(receipt string) {
if receipt != "receipt" {
t.Errorf("receipt=%q", receipt)
}
receipts.Add(1)
}
writer := &disconnectedPaymentWriter{header: make(http.Header), failed: make(chan struct{})}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() { app.salePayment(writer, saleRequest(true).WithContext(ctx)); close(done) }()
select {
case <-writer.failed:
case <-time.After(5 * time.Second):
close(release)
t.Fatal("no live progress before final result")
}
cancel()
close(release)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("finalization stopped after disconnect")
}
if confirms.Load() != 1 || receipts.Load() != 1 || writer.writes.Load() != 1 {
t.Errorf("disconnect confirms/receipts/writes=%d/%d/%d, want 1/1/1", confirms.Load(), receipts.Load(), writer.writes.Load())
}
}

View File

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

View File

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

View File

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

View File

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

View File

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