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 Deploy ChipDNAClientCLI, then hardlink, then Operafyne. No database or configuration
migration is needed. The existing CreditCall provider selection enables the new 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 ## 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 `{"amount":1234,"confirmNo":"BOOKING-123","currency":"GBP"}`. Amount remains in
minor units. CreditCall uses its existing SDK transaction reference generation. 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: For CreditCall, responses contain newline-delimited JSON:
```json ```json
@ -19,7 +28,7 @@ For CreditCall, responses contain newline-delimited JSON:
`outcome` is `approved`, `declined`, `cancelled`, `timeout`, or `error`. Approval `outcome` is `approved`, `declined`, `cancelled`, `timeout`, or `error`. Approval
is produced only by the shared CreditCall transaction/finalization core, including 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 operation status even after streaming commits HTTP 200. `status` preserves the
existing processor `StatusRec`. Failure `message` preserves the plain description existing processor `StatusRec`. Failure `message` preserves the plain description
used by the legacy flow, including its existing interpretation quirks. 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 Hardlink calls `POST /start-transaction-stream/` on ChipDNAClientCLI with
`{"amount":"1234","transactionType":"Sale"}`. Its NDJSON consists of allowlisted `{"amount":"1234","transactionType":"Sale"}`. Its NDJSON consists of allowlisted
`status` frames (`source`, `value`), a single `result` containing the required SDK `status` frames (`source`, `value`), a single `result` containing allowlisted SDK
fields, or a sanitized `error`. Receipt XML may be a JSON string field needed by 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. 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 ## Lifetime and compatibility
- `/start-transaction/`, `/takepayment`, and `/takepreauth` retain their existing - `/start-transaction/`, `/takepayment`, and `/takepreauth` retain their existing
external contracts. Preauthorization and SQL persistence/release remain legacy. external contracts. Both SALE and PREAUTH now stream to Operafyne; existing SQL
- Start and each confirmation call retain their independent 300-second timeout. 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 Confirmation still uses two attempts, retrying transport/read errors with the
existing two-second delay. The generic whole-operation timeout is not used. existing two-second delay. The generic whole-operation timeout is not used.
- Kiosk cancellation or failed/blocked kiosk delivery does not cancel upstream reading, - 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. final results have a separate slot and are delivered at most once.
- Only the response writer writes frames. Transaction execution never waits - Only the response writer writes frames. Transaction execution never waits
for progress delivery. No additional delivery timer or whole-operation deadline 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. timeout outcomes, confirmation, retry, receipts, PMS posting, or business state.
- After timeout, an ambiguous start error, an asynchronous SDK error during an - After timeout, an ambiguous start error, an asynchronous SDK error during an
active window, or reference reuse/overlap, progress remains suppressed for that 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. reconnect do not reset the guard. Payments remain enabled.
- Only the exact synchronous `ClientNotConnectedToServer` error safely clears an - Only the exact synchronous `ClientNotConnectedToServer` error safely clears an
unsuppressed window and releases its unused reference: SDK 3.17 `StartCommand` 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. test executable links the production streaming code and needs no terminal.
In Operafyne, run `go test -count=1 ./...`; the service tests cover structured 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 Dojo/PayBridge response handling. Run `go vet ./...`, `go build ./...`, and
`git diff --check` in both Go repositories. `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 ( const (
buildVersion = "v2.0.0" buildVersion = "v2.0.1"
serviceName = "hardlink" serviceName = "hardlink"
pollingFrequency = 8 * time.Second 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) { 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] res := result[types.TransactionResult]
tType := result[types.TransactionType] tType := result[types.TransactionType]
@ -136,7 +145,7 @@ func BuildPreauthRedirectURL(result map[string]string) (string, bool) {
log.WithField(types.LogResult, result[types.TransactionResult]). log.WithField(types.LogResult, result[types.TransactionResult]).
Info("Account verification approved") Info("Account verification approved")
return BuildSuccessURL(result), false return true, false
// Transaction type Sale? // Transaction type Sale?
case strings.EqualFold(tType, types.SaleTransactionType): 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]). log.WithField(types.LogResult, result[types.ConfirmResult]).
Info("Amount preauthorized successfully") Info("Amount preauthorized successfully")
return BuildSuccessURL(result), true return true, true
} }
} }
// Not approved // Not approved
return BuildFailureURL(res, result[types.Errors]), false return false, false
} }
func BuildSuccessURL(result map[string]string) string { 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) EnableCreditCallStreaming() { app.creditCallStreamEnabled = true }
func (app *App) streamCreditCallSale(w http.ResponseWriter, r *http.Request) { 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) setPaymentCORS(w)
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@ -97,6 +101,10 @@ func (app *App) streamCreditCallSale(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status) w.WriteHeader(status)
send(paymentStreamMessage{Type: "result", Result: &result}) 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 { if !app.isPayment && !app.cfg.TestMode {
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted payment while payment processing is disabled") mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted payment while payment processing is disabled")
reject(http.StatusServiceUnavailable, "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 return
} }
defer r.Body.Close() defer r.Body.Close()
var request SalePaymentRequest var transaction cmstypes.TransactionRec
if err := json.NewDecoder(r.Body).Decode(&request); err != nil { if preauth {
reject(http.StatusBadRequest, "Invalid JSON payload") var request creditCallPreauthRequest
return if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
} reject(http.StatusBadRequest, "Invalid JSON payload")
if request.Amount <= 0 { return
reject(http.StatusBadRequest, "Amount must be greater than zero") }
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 { if _, ok := w.(http.Flusher); !ok {
reject(http.StatusInternalServerError, "Streaming payment updates are not supported") reject(http.StatusInternalServerError, "Streaming payment updates are not supported")
return 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) progress := make(chan string, 128)
finished := make(chan creditCallSaleOutcome, 1) finished := make(chan creditCallSaleOutcome, 1)
go func() { go func() {
outcome := app.executeCreditCallSale(cmstypes.TransactionRec{ start := func(client *http.Client) (creditcall.TransactionResultXML, error) {
AmountMinorUnits: strconv.FormatInt(request.Amount, 10), TransactionType: "Sale", return callChipDNATransactionStream(financialContext, client, transaction, func(code string) {
}, func(client *http.Client) (creditcall.TransactionResultXML, error) {
return callChipDNAStream(client, request.Amount, func(code string) {
select { select {
case progress <- code: case progress <- code:
default: // Observational progress may be dropped under backpressure. 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) close(progress)
finished <- outcome // Independent of the bounded progress queue. 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) { 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 var result creditcall.TransactionResultXML
payload, err := json.Marshal(struct { payload, err := json.Marshal(struct {
Amount string `json:"amount"` Amount string `json:"amount"`
TransactionType string `json:"transactionType"` TransactionType string `json:"transactionType"`
}{strconv.FormatInt(amount, 10), "Sale"}) }{transaction.AmountMinorUnits, transaction.TransactionType})
if err != nil { if err != nil {
return result, err return result, err
} }
// Background plus Client.Timeout reproduces the independent per-call bound. // Dispatch owns ctx; Client.Timeout retains the independent 300-second call bound.
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, types.LinkStartTransactionStream, bytes.NewReader(payload)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, types.LinkStartTransactionStream, bytes.NewReader(payload))
if err != nil { if err != nil {
return result, err return result, err
} }

View File

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

View File

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