feat: add CreditCall preauth streaming
This commit is contained in:
parent
c8cbd1e7a3
commit
1969aaa1d2
@ -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.
|
||||
|
||||
@ -33,7 +33,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
buildVersion = "v2.0.0"
|
||||
buildVersion = "v2.0.1"
|
||||
serviceName = "hardlink"
|
||||
pollingFrequency = 8 * time.Second
|
||||
)
|
||||
|
||||
@ -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 {
|
||||
|
||||
35
internal/creditcall/preauth_test.go
Normal file
35
internal/creditcall/preauth_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
53
internal/handlers/creditcall_preauth.go
Normal file
53
internal/handlers/creditcall_preauth.go
Normal 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
|
||||
}
|
||||
393
internal/handlers/creditcall_preauth_test.go
Normal file
393
internal/handlers/creditcall_preauth_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user