feat: add CreditCall preauth streaming

This commit is contained in:
yurii 2026-09-10 00:46:57 +01:00
parent c8cbd1e7a3
commit 1969aaa1d2
9 changed files with 598 additions and 66 deletions

View File

@ -2,7 +2,7 @@
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.
SALE and PREAUTH routes; CreditCall does not implement the generic payment provider interface.
## Contracts
@ -10,6 +10,15 @@ sale route; CreditCall does not implement the generic payment provider interface
`{"amount":1234,"confirmNo":"BOOKING-123","currency":"GBP"}`. Amount remains in
minor units. CreditCall uses its existing SDK transaction reference generation.
`POST /api/payment/preauth` accepts string fields `amount`, `transactionType`
and `checkoutDate`. Positive preauth sends the existing `Sale` input and checkout
string; zero-value verification sends `{"amount":"","transactionType":"AccountVerification","checkoutDate":""}`.
The returned `ACCOUNT VERIFICATION` type is matched case-insensitively and succeeds
without persistence. Returned approved `SALE` schedules existing SQL persistence
using provider-returned `TOTAL_AMOUNT`, never the request amount. PREAUTH does not
confirm. Checkout remains departure-derived midnight UTC in the existing string
format; SQL's existing date conversion and 48-hour release calculation are unchanged.
For CreditCall, responses contain newline-delimited JSON:
```json
@ -19,7 +28,7 @@ For CreditCall, responses contain newline-delimited JSON:
`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
SALE confirmation behavior; PREAUTH uses its existing unconfirmed-result rules. `httpStatus` preserves the equivalent legacy
operation status even after streaming commits HTTP 200. `status` preserves the
existing processor `StatusRec`. Failure `message` preserves the plain description
used by the legacy flow, including its existing interpretation quirks.
@ -31,20 +40,32 @@ 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
`status` frames (`source`, `value`), a single `result` containing allowlisted SDK
fields, or a sanitized `error`. `TRANSACTION_TYPE` and optional `TOTAL_AMOUNT`
are included only when returned by the provider. They are never synthesized and
are not added to the kiosk-facing result. AccountVerification normally omits
`TOTAL_AMOUNT`. Receipt XML may be a JSON string field needed by
hardlink's existing receipt handler; it is never a raw line in the stream.
Confirmation continues through the existing, separate XML endpoint.
SALE 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.
external contracts. Both SALE and PREAUTH now stream to Operafyne; existing SQL
persistence/release behavior remains unchanged.
- Operafyne uses a private 120-second CreditCall client for SALE and PREAUTH.
This is only the kiosk wait boundary; Dojo/PayBridge clients are unchanged.
Timeout/cancellation is technical, not an authoritative decline or automatically
retryable result. It never triggers a second start or legacy endpoint fallback.
- Validation uses the inbound request. At financial dispatch handoff, hardlink
detaches cancellation with `context.WithoutCancel`. Inbound cancellation and
write failures control only delivery; result processing, SALE confirmation,
receipts and PREAUTH persistence scheduling continue independently.
- Start and each SALE confirmation call retain their independent 300-second timeout.
Confirmation still uses two attempts, retrying transport/read errors with the
existing two-second delay. The generic whole-operation timeout is not used.
- Kiosk cancellation or failed/blocked kiosk delivery does not cancel upstream reading,
confirmation, or receipt handling. Progress queues may drop hints when full;
confirmation, receipt handling, or PREAUTH persistence scheduling. Progress queues may drop hints when full;
final results have a separate slot and are delivered at most once.
- Only the response writer writes frames. Transaction execution never waits
for progress delivery. No additional delivery timer or whole-operation deadline
@ -68,7 +89,7 @@ Confirmation continues through the existing, separate XML endpoint.
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
`Client` lifetime across SALE and PREAUTH. New transactions, elapsed time, callbacks and automatic
reconnect do not reset the guard. Payments remain enabled.
- Only the exact synchronous `ClientNotConnectedToServer` error safely clears an
unsuppressed window and releases its unused reference: SDK 3.17 `StartCommand`
@ -91,6 +112,13 @@ 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
CreditCall SALE/PREAUTH, unchanged legacy requests, the 120-second UX boundary,
cancellation after dispatch, failure/retry parity, and existing
Dojo/PayBridge response handling. Run `go vet ./...`, `go build ./...`, and
`git diff --check` in both Go repositories.
Physical account-verification testing confirmed approved `ACCOUNT VERIFICATION`
with no `TOTAL_AMOUNT`. A monetary `/takepayment` test confirmed returned minor
units; positive PREAUTH through the new transport still needs physical Miura
validation. Automated tests do not replace that check. Receipt fields such as
`NoChargeDeclaration` retain the existing provider-entry rendering behavior.

View File

@ -33,7 +33,7 @@ import (
)
const (
buildVersion = "v2.0.0"
buildVersion = "v2.0.1"
serviceName = "hardlink"
pollingFrequency = 8 * time.Second
)

View File

@ -125,6 +125,15 @@ func BuildPaymentRedirectURL(result map[string]string) string {
}
func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
approved, persist := PreauthDecision(result)
if approved {
return BuildSuccessURL(result), persist
}
return BuildFailureURL(result[types.TransactionResult], result[types.Errors]), false
}
// PreauthDecision preserves the returned-type approval and persistence rules.
func PreauthDecision(result map[string]string) (approved, persist bool) {
res := result[types.TransactionResult]
tType := result[types.TransactionType]
@ -136,7 +145,7 @@ func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
log.WithField(types.LogResult, result[types.TransactionResult]).
Info("Account verification approved")
return BuildSuccessURL(result), false
return true, false
// Transaction type Sale?
case strings.EqualFold(tType, types.SaleTransactionType):
@ -144,12 +153,12 @@ func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
log.WithField(types.LogResult, result[types.ConfirmResult]).
Info("Amount preauthorized successfully")
return BuildSuccessURL(result), true
return true, true
}
}
// Not approved
return BuildFailureURL(res, result[types.Errors]), false
return false, false
}
func BuildSuccessURL(result map[string]string) string {

View File

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

View File

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

View File

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

View File

@ -63,6 +63,10 @@ func (outcome creditCallSaleOutcome) streamResult() creditCallStreamResult {
func (app *App) EnableCreditCallStreaming() { app.creditCallStreamEnabled = true }
func (app *App) streamCreditCallSale(w http.ResponseWriter, r *http.Request) {
app.streamCreditCall(w, r, false)
}
func (app *App) streamCreditCall(w http.ResponseWriter, r *http.Request, preauth bool) {
setPaymentCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
@ -97,6 +101,10 @@ func (app *App) streamCreditCallSale(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
send(paymentStreamMessage{Type: "result", Result: &result})
}
if preauth && !app.creditCallStreamEnabled {
reject(http.StatusServiceUnavailable, "CreditCall preauthorization streaming is not enabled")
return
}
if !app.isPayment && !app.cfg.TestMode {
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted payment while payment processing is disabled")
reject(http.StatusServiceUnavailable, "Payment processing is disabled")
@ -111,33 +119,53 @@ func (app *App) streamCreditCallSale(w http.ResponseWriter, r *http.Request) {
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
var transaction cmstypes.TransactionRec
if preauth {
var request creditCallPreauthRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
reject(http.StatusBadRequest, "Invalid JSON payload")
return
}
transaction = cmstypes.TransactionRec{AmountMinorUnits: request.Amount, TransactionType: request.TransactionType, CheckoutDate: request.CheckoutDate}
} else {
var request SalePaymentRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
reject(http.StatusBadRequest, "Invalid JSON payload")
return
}
if request.Amount <= 0 {
reject(http.StatusBadRequest, "Amount must be greater than zero")
return
}
transaction = cmstypes.TransactionRec{AmountMinorUnits: strconv.FormatInt(request.Amount, 10), TransactionType: "Sale"}
}
if _, ok := w.(http.Flusher); !ok {
reject(http.StatusInternalServerError, "Streaming payment updates are not supported")
return
}
// Validation belongs to the incoming request. This handoff owns financial dispatch.
if r.Context().Err() != nil {
return
}
financialContext := context.WithoutCancel(r.Context())
progress := make(chan string, 128)
finished := make(chan creditCallSaleOutcome, 1)
go func() {
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) {
start := func(client *http.Client) (creditcall.TransactionResultXML, error) {
return callChipDNATransactionStream(financialContext, client, transaction, func(code string) {
select {
case progress <- code:
default: // Observational progress may be dropped under backpressure.
}
})
})
}
var outcome creditCallSaleOutcome
if preauth {
outcome = app.executeCreditCallPreauth(financialContext, transaction, start)
} else {
outcome = app.executeCreditCallSale(transaction, start)
}
close(progress)
finished <- outcome // Independent of the bounded progress queue.
}()
@ -163,16 +191,22 @@ func (app *App) streamCreditCallSale(w http.ResponseWriter, r *http.Request) {
}
func callChipDNAStream(client *http.Client, amount int64, onStatus func(string)) (creditcall.TransactionResultXML, error) {
return callChipDNATransactionStream(context.Background(), client, cmstypes.TransactionRec{
AmountMinorUnits: strconv.FormatInt(amount, 10), TransactionType: "Sale",
}, onStatus)
}
func callChipDNATransactionStream(ctx context.Context, client *http.Client, transaction cmstypes.TransactionRec, onStatus func(string)) (creditcall.TransactionResultXML, error) {
var result creditcall.TransactionResultXML
payload, err := json.Marshal(struct {
Amount string `json:"amount"`
TransactionType string `json:"transactionType"`
}{strconv.FormatInt(amount, 10), "Sale"})
}{transaction.AmountMinorUnits, transaction.TransactionType})
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))
// Dispatch owns ctx; Client.Timeout retains the independent 300-second call bound.
req, err := http.NewRequestWithContext(ctx, http.MethodPost, types.LinkStartTransactionStream, bytes.NewReader(payload))
if err != nil {
return result, err
}

View File

@ -78,6 +78,7 @@ func (app *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/ping-pdq", app.fetchChipDNAStatus)
mux.HandleFunc("/logerror", app.onChipDNAError)
mux.HandleFunc("/api/payment/sale", app.salePayment)
mux.HandleFunc("/api/payment/preauth", app.streamCreditCallPreauth)
}
func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
@ -86,9 +87,6 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
var (
theResponse cmstypes.ResponseRec
theRequest cmstypes.TransactionRec
trResult creditcall.TransactionResultXML
result creditcall.PaymentResult
save bool
)
theResponse.Status.Code = http.StatusInternalServerError
@ -149,39 +147,18 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
theRequest.TransactionType,
)
client := app.creditCallClient()
// ---- START TRANSACTION ----
body, err = callChipDNA(client, types.LinkStartTransaction, body)
if err != nil {
logging.Error(types.ServiceName, err.Error(), "Preauth processing error", string(op), "", "", 0)
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "No response from payment processor")
writeTransactionResult(w, http.StatusBadGateway, theResponse)
return
}
if err := trResult.ParseTransactionResult(body); err != nil {
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
}
result.FillFromTransactionResult(trResult)
// ---- PRINT RECEIPT ----
app.printCreditCallReceipt(result.CardholderReceipt)
// ---- REDIRECT ----
theResponse.Status = result.Status
theResponse.Data, save = creditcall.BuildPreauthRedirectURL(result.Fields)
if save {
go app.persistPreauth(context.Background(), result.Fields, theRequest.CheckoutDate)
}
writeTransactionResult(w, http.StatusOK, theResponse)
outcome := app.executeCreditCallPreauth(context.Background(), theRequest, func(client *http.Client) (creditcall.TransactionResultXML, error) {
var transaction creditcall.TransactionResultXML
response, err := callChipDNA(client, types.LinkStartTransaction, body)
if err != nil {
return transaction, err
}
if err := transaction.ParseTransactionResult(response); err != nil {
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
}
return transaction, nil
})
writeTransactionResult(w, outcome.HTTPStatus, outcome.legacyResponse())
}
func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {

View File

@ -2,6 +2,9 @@
builtVersion is a const in main.go
#### v2.0.1 - 10 September 2026
feat: add CreditCall preauth streaming
#### v2.0.0 - 08 September 2026
feat: stream CreditCall payment progress and results