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
|
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.
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
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) 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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user