feat: stream CreditCall payment progress and results
This commit is contained in:
parent
ea578fe03e
commit
93ff67fbb1
93
CREDITCALL_STREAMING.md
Normal file
93
CREDITCALL_STREAMING.md
Normal file
@ -0,0 +1,93 @@
|
||||
# CreditCall payment streaming
|
||||
|
||||
Deploy ChipDNAClientCLI, then hardlink, then Operafyne. No database or configuration
|
||||
migration is needed. The existing CreditCall provider selection enables the new
|
||||
sale route; CreditCall does not implement the generic payment provider interface.
|
||||
|
||||
## Contracts
|
||||
|
||||
`POST /api/payment/sale` accepts the existing JSON sale request, for example
|
||||
`{"amount":1234,"confirmNo":"BOOKING-123","currency":"GBP"}`. Amount remains in
|
||||
minor units. CreditCall uses its existing SDK transaction reference generation.
|
||||
|
||||
For CreditCall, responses contain newline-delimited JSON:
|
||||
|
||||
```json
|
||||
{"type":"status","code":"PAYMENT_PRESENT_CARD"}
|
||||
{"type":"result","result":{"outcome":"approved","message":"Payment approved","httpStatus":200,"status":{"code":200,"message":"Approved"},"transactionReference":"native-reference","cardType":"Visa","maskedCardNumber":"************1234","expiryDate":"1228","cardHash":"existing-hash","cardReference":"existing-card-reference"}}
|
||||
```
|
||||
|
||||
`outcome` is `approved`, `declined`, `cancelled`, `timeout`, or `error`. Approval
|
||||
is produced only by the shared CreditCall transaction/finalization core, including
|
||||
the existing confirmation behavior. `httpStatus` preserves the equivalent legacy
|
||||
operation status even after streaming commits HTTP 200. `status` preserves the
|
||||
existing processor `StatusRec`. Failure `message` preserves the plain description
|
||||
used by the legacy flow, including its existing interpretation quirks.
|
||||
|
||||
Card fields are emitted only on success. Unmasked or unexpected card-number data
|
||||
is omitted; encoding is not masking. Receipts and raw SDK parameter bags are not
|
||||
sent to Operafyne. The new adapter never builds a redirect URL. Dojo and PayBridge
|
||||
continue using their existing `{"type":"result","response":...}` contract.
|
||||
|
||||
Hardlink calls `POST /start-transaction-stream/` on ChipDNAClientCLI with
|
||||
`{"amount":"1234","transactionType":"Sale"}`. Its NDJSON consists of allowlisted
|
||||
`status` frames (`source`, `value`), a single `result` containing the required SDK
|
||||
fields, or a sanitized `error`. Receipt XML may be a JSON string field needed by
|
||||
hardlink's existing receipt handler; it is never a raw line in the stream.
|
||||
Confirmation continues through the existing, separate XML endpoint.
|
||||
|
||||
## Lifetime and compatibility
|
||||
|
||||
- `/start-transaction/`, `/takepayment`, and `/takepreauth` retain their existing
|
||||
external contracts. Preauthorization and SQL persistence/release remain legacy.
|
||||
- Start and each confirmation call retain their independent 300-second timeout.
|
||||
Confirmation still uses two attempts, retrying transport/read errors with the
|
||||
existing two-second delay. The generic whole-operation timeout is not used.
|
||||
- Kiosk cancellation or failed/blocked kiosk delivery does not cancel upstream reading,
|
||||
confirmation, or receipt handling. Progress queues may drop hints when full;
|
||||
final results have a separate slot and are delivered at most once.
|
||||
- Only the response writer writes frames. Transaction execution never waits
|
||||
for progress delivery. No additional delivery timer or whole-operation deadline
|
||||
shortens the existing HTTP call bounds.
|
||||
- Native SDK 3.17 updates use `UPDATE`; card notifications use `NOTIFICATION`,
|
||||
retained as `CARD_STATUS` on the internal wire. Normal progress does not require
|
||||
a reference: it is accepted only inside the existing serial transaction's active
|
||||
observational window. A supplied nonempty reference must match. The window opens
|
||||
immediately before the SDK start call and closes immediately at
|
||||
`TransactionFinished`; finalization never waits for card removal.
|
||||
- Unidentified `CardRemovalRequested` and `CardRemovalEnforced` updates are omitted
|
||||
because they can arrive after financial completion. They require a matching
|
||||
explicit reference. `Removed` remains omitted even with a reference.
|
||||
- Ownership is best-effort UI observation. SDK 3.17 does not establish that every
|
||||
queued reference-less non-removal callback has drained before another transaction
|
||||
starts. Such a callback may briefly display stale progress in a later active
|
||||
window. This accepted limitation must never affect success, decline, cancellation,
|
||||
timeout outcomes, confirmation, retry, receipts, PMS posting, or business state.
|
||||
- After timeout, an ambiguous start error, an asynchronous SDK error during an
|
||||
active window, or reference reuse/overlap, progress remains suppressed for that
|
||||
`Client` lifetime. New transactions, elapsed time, callbacks and automatic
|
||||
reconnect do not reset the guard. Payments remain enabled.
|
||||
- Only the exact synchronous `ClientNotConnectedToServer` error safely clears an
|
||||
unsuppressed window and releases its unused reference: SDK 3.17 `StartCommand`
|
||||
returns it before `SendRequest`. Validation errors, mixed errors and all other
|
||||
unproven errors suppress observation. Safe pre-dispatch rejection never clears
|
||||
an existing suppression latch.
|
||||
- Progress cannot approve, decline, confirm, cancel, or retry a transaction.
|
||||
`OnlineAuthCompleted` means waiting; `Removed` and unknown events are omitted.
|
||||
|
||||
## Automated verification
|
||||
|
||||
In hardlink, use `go test -count=1 -skip '^Test_SendMail$' ./...` to exclude the
|
||||
existing test that sends real email. Focused tests cover legacy parity, malformed
|
||||
XML/NDJSON, retained confirmation fields and receipts, retries, disconnects,
|
||||
backpressure, field filtering, and conservative status mapping. Race checks cover
|
||||
the changed handler and mapper packages. No test proves absence of orphan holds.
|
||||
|
||||
In ChipDNAClientCLI, build `ChipDnaClient.sln` and `Tests/StreamingTests.csproj`
|
||||
with MSBuild, then run `Tests/bin/Debug/ChipDNAClient.StreamingTests.exe`. The
|
||||
test executable links the production streaming code and needs no terminal.
|
||||
|
||||
In Operafyne, run `go test -count=1 ./...`; the service tests cover structured
|
||||
CreditCall sales, unchanged preauth requests, failure/retry parity, and existing
|
||||
Dojo/PayBridge response handling. Run `go vet ./...`, `go build ./...`, and
|
||||
`git diff --check` in both Go repositories.
|
||||
@ -33,7 +33,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
buildVersion = "v1.3.6"
|
||||
buildVersion = "v2.0.0"
|
||||
serviceName = "hardlink"
|
||||
pollingFrequency = 8 * time.Second
|
||||
)
|
||||
@ -135,7 +135,8 @@ func main() {
|
||||
|
||||
switch paymentProvider {
|
||||
case cmstypes.PaySystemCreditCall:
|
||||
// CreditCall keeps using the existing /takepayment and /takepreauth endpoints.
|
||||
// CreditCall streaming shares its proven legacy transaction core.
|
||||
app.EnableCreditCallStreaming()
|
||||
startChipDnaClient()
|
||||
|
||||
go func() {
|
||||
@ -203,7 +204,7 @@ func main() {
|
||||
))
|
||||
}
|
||||
|
||||
// Only PayBridge and Dojo use POST /api/payment/sale.
|
||||
// PayBridge and Dojo use the generic payment service.
|
||||
if provider != nil {
|
||||
app.SetPaymentService(paymentsvc.NewService(provider))
|
||||
log.Infof("Payment provider enabled for POST /api/payment/sale: %s", cmstypes.PaySystemNames[paymentProvider])
|
||||
@ -325,4 +326,4 @@ func pdqSerialForKiosk(pdqs []cmstypes.PDQRec, kiosk int) string {
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,9 +168,7 @@ func BuildSuccessURL(result map[string]string) string {
|
||||
|
||||
func BuildFailureURL(msgType, description string) string {
|
||||
q := url.Values{}
|
||||
if msgType != "" {
|
||||
description = fmt.Sprintf("Transaction %s", strings.ToLower(msgType))
|
||||
}
|
||||
description = FailureDescription(msgType, description)
|
||||
if description != "" {
|
||||
msgType = types.ResultError
|
||||
}
|
||||
@ -185,3 +183,12 @@ func BuildFailureURL(msgType, description string) string {
|
||||
RawQuery: q.Encode(),
|
||||
}).String()
|
||||
}
|
||||
|
||||
// FailureDescription preserves the description used by the legacy payment flow
|
||||
// without requiring structured consumers to construct a redirect URL.
|
||||
func FailureDescription(msgType, description string) string {
|
||||
if msgType != "" {
|
||||
return fmt.Sprintf("Transaction %s", strings.ToLower(msgType))
|
||||
}
|
||||
return description
|
||||
}
|
||||
|
||||
64
internal/creditcall/statuses.go
Normal file
64
internal/creditcall/statuses.go
Normal file
@ -0,0 +1,64 @@
|
||||
package creditcall
|
||||
|
||||
import "gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
||||
|
||||
// ProgressStatus maps informational SDK events, never financial decisions.
|
||||
func ProgressStatus(source, value string) string {
|
||||
switch source {
|
||||
case "UPDATE":
|
||||
switch value {
|
||||
case "CardRequested", "ProvideCard", "InsertOrSwipeCard", "InsertOrContactless":
|
||||
return paymentstatus.PresentCard
|
||||
case "InsertCard":
|
||||
return paymentstatus.InsertCard
|
||||
case "SwipeCard":
|
||||
return paymentstatus.SwipeCard
|
||||
case "CardRemovalRequested", "CardRemovalEnforced":
|
||||
return paymentstatus.RemoveCard
|
||||
case "PinEntryStarted", "PinEntryPrompt", "PinEntryInProgress":
|
||||
return paymentstatus.EnterPIN
|
||||
case "PinEntryFailed":
|
||||
return paymentstatus.EnterPINAgain
|
||||
case "PinEntrySuccessful", "TransactionStarted", "OnlineAuthRequested", "OnlineAuthorizeRequest":
|
||||
return paymentstatus.Processing
|
||||
case "OnlineAuthCompleted":
|
||||
return paymentstatus.PleaseWait
|
||||
}
|
||||
case "CARD_STATUS":
|
||||
switch value {
|
||||
case "Inserted":
|
||||
return paymentstatus.DoNotRemoveCard
|
||||
case "Tapped", "Swiped":
|
||||
return paymentstatus.Processing
|
||||
}
|
||||
case "SIGNATURE":
|
||||
if value == "Requested" {
|
||||
return paymentstatus.SignatureRequired
|
||||
}
|
||||
case "PAUSE":
|
||||
if value == "Paused" {
|
||||
return paymentstatus.PleaseWait
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MaskedCardNumber excludes unmasked or unexpected SDK data from the new wire contract.
|
||||
func MaskedCardNumber(value string) string {
|
||||
digits, masks := 0, 0
|
||||
for _, ch := range value {
|
||||
switch {
|
||||
case ch >= '0' && ch <= '9':
|
||||
digits++
|
||||
case ch == '*' || ch == 'x' || ch == 'X':
|
||||
masks++
|
||||
case ch == ' ' || ch == '-':
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if masks < 4 || digits > 10 {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
52
internal/creditcall/statuses_test.go
Normal file
52
internal/creditcall/statuses_test.go
Normal file
@ -0,0 +1,52 @@
|
||||
package creditcall
|
||||
|
||||
import (
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProgressStatus(t *testing.T) {
|
||||
for _, test := range []struct{ source, value, want string }{
|
||||
{"UPDATE", "CardRequested", paymentstatus.PresentCard},
|
||||
{"UPDATE", "ProvideCard", paymentstatus.PresentCard},
|
||||
{"UPDATE", "InsertOrSwipeCard", paymentstatus.PresentCard},
|
||||
{"UPDATE", "InsertOrContactless", paymentstatus.PresentCard},
|
||||
{"UPDATE", "InsertCard", paymentstatus.InsertCard},
|
||||
{"UPDATE", "SwipeCard", paymentstatus.SwipeCard},
|
||||
{"UPDATE", "CardRemovalRequested", paymentstatus.RemoveCard},
|
||||
{"UPDATE", "CardRemovalEnforced", paymentstatus.RemoveCard},
|
||||
{"UPDATE", "PinEntryStarted", paymentstatus.EnterPIN},
|
||||
{"UPDATE", "PinEntryPrompt", paymentstatus.EnterPIN},
|
||||
{"UPDATE", "PinEntryInProgress", paymentstatus.EnterPIN},
|
||||
{"UPDATE", "PinEntryFailed", paymentstatus.EnterPINAgain},
|
||||
{"UPDATE", "PinEntrySuccessful", paymentstatus.Processing},
|
||||
{"UPDATE", "TransactionStarted", paymentstatus.Processing},
|
||||
{"UPDATE", "OnlineAuthRequested", paymentstatus.Processing},
|
||||
{"UPDATE", "OnlineAuthorizeRequest", paymentstatus.Processing},
|
||||
{"UPDATE", "OnlineAuthCompleted", paymentstatus.PleaseWait},
|
||||
{"CARD_STATUS", "Inserted", paymentstatus.DoNotRemoveCard},
|
||||
{"CARD_STATUS", "Tapped", paymentstatus.Processing},
|
||||
{"CARD_STATUS", "Swiped", paymentstatus.Processing},
|
||||
{"CARD_STATUS", "Removed", ""},
|
||||
{"SIGNATURE", "Requested", paymentstatus.SignatureRequired},
|
||||
{"PAUSE", "Paused", paymentstatus.PleaseWait},
|
||||
{"UPDATE", "Approved", ""}, {"UPDATE", "Declined", ""},
|
||||
{"UPDATE", "unknown", ""}, {"PAN", "CardRequested", ""},
|
||||
} {
|
||||
if got := ProgressStatus(test.source, test.value); got != test.want {
|
||||
t.Errorf("ProgressStatus(%q, %q) = %q, want %q", test.source, test.value, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskedCardNumber(t *testing.T) {
|
||||
for _, test := range []struct{ value, want string }{
|
||||
{"123456******1234", "123456******1234"}, {"************1234", "************1234"},
|
||||
{"1234567890123456", ""}, {"1234567890123456****", ""}, {"3132333435363738", ""},
|
||||
{"%B1234567890123456^NAME", ""}, {"", ""},
|
||||
} {
|
||||
if got := MaskedCardNumber(test.value); got != test.want {
|
||||
t.Errorf("MaskedCardNumber(%q) = %q, want %q", test.value, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
130
internal/handlers/creditcall_sale.go
Normal file
130
internal/handlers/creditcall_sale.go
Normal file
@ -0,0 +1,130 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/printer"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
"gitea.futuresens.co.uk/futuresens/logging"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// creditCallSaleOutcome is the transaction outcome before either wire format is applied.
|
||||
type creditCallSaleOutcome struct {
|
||||
HTTPStatus int
|
||||
Status cmstypes.StatusRec
|
||||
Payment creditcall.PaymentResult
|
||||
Approved bool
|
||||
FailureType string
|
||||
FailureDescription string
|
||||
}
|
||||
|
||||
func (app *App) creditCallClient() *http.Client {
|
||||
// Each start/confirm call gets the original independent timeout. In particular,
|
||||
// neither a kiosk disconnect nor a whole-sale deadline bounds confirmation.
|
||||
return &http.Client{Timeout: 300 * time.Second, Transport: app.creditCallTransport}
|
||||
}
|
||||
|
||||
func (app *App) printCreditCallReceipt(receipt string) {
|
||||
if app.creditCallReceipt != nil {
|
||||
app.creditCallReceipt(receipt)
|
||||
return
|
||||
}
|
||||
printer.PrintReceipt(receipt)
|
||||
}
|
||||
|
||||
func (app *App) executeCreditCallSale(
|
||||
request cmstypes.TransactionRec,
|
||||
start func(*http.Client) (creditcall.TransactionResultXML, error),
|
||||
) creditCallSaleOutcome {
|
||||
const op = logging.Op("takePayment")
|
||||
outcome := creditCallSaleOutcome{
|
||||
HTTPStatus: http.StatusBadGateway,
|
||||
Status: cmstypes.StatusRec{Code: http.StatusInternalServerError, Message: "500 Internal server error"},
|
||||
}
|
||||
client := app.creditCallClient()
|
||||
trResult, err := start(client)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Start transaction error", string(op), "", "", 0)
|
||||
outcome.FailureType = types.ResultError
|
||||
outcome.FailureDescription = "No response from payment processor"
|
||||
return outcome
|
||||
}
|
||||
|
||||
var result creditcall.PaymentResult
|
||||
result.FillFromTransactionResult(trResult)
|
||||
res := result.Fields[types.TransactionResult]
|
||||
if !strings.EqualFold(res, types.ResultApproved) {
|
||||
app.printCreditCallReceipt(result.CardholderReceipt)
|
||||
desc := result.Fields[types.ErrorDescription]
|
||||
if desc == "" {
|
||||
desc = result.Fields[types.Errors]
|
||||
}
|
||||
logging.Error(types.ServiceName, "Preauthorization failed", "Result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
|
||||
outcome.HTTPStatus = http.StatusOK
|
||||
outcome.Status = result.Status
|
||||
outcome.Payment = result
|
||||
outcome.FailureType = res
|
||||
outcome.FailureDescription = result.Fields[types.Errors]
|
||||
return outcome
|
||||
}
|
||||
|
||||
ref := result.Fields[types.Reference]
|
||||
log.Printf("Preauth approved, reference: %s. Sending confirm...", ref)
|
||||
body, err := confirmWithRetry(client, creditcall.ConfirmTransactionRequest{
|
||||
Amount: request.AmountMinorUnits, Reference: ref,
|
||||
}, 2)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Confirm transaction error", string(op), "", "", 0)
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Error: "+err.Error())
|
||||
outcome.FailureType = types.ResultError
|
||||
outcome.FailureDescription = "ConfirmTransactionError"
|
||||
return outcome
|
||||
}
|
||||
|
||||
// Intentionally reuse both records: XML appends confirmation entries, and
|
||||
// missing confirmation fields/receipts retain their existing legacy behavior.
|
||||
if err := trResult.ParseTransactionResult(body); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Parse confirm result error", string(op), "", "", 0)
|
||||
}
|
||||
result.FillFromTransactionResult(trResult)
|
||||
res = result.Fields[types.TransactionResult]
|
||||
if !strings.EqualFold(res, types.ResultApproved) {
|
||||
app.printCreditCallReceipt(result.CardholderReceipt)
|
||||
desc := result.Fields[types.ErrorDescription]
|
||||
if desc == "" {
|
||||
desc = result.Fields[types.Errors]
|
||||
}
|
||||
logging.Error(types.ServiceName, "Transaction not approved after confirm", "Confirm result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Confirm result: "+res+" Description: "+desc)
|
||||
outcome.HTTPStatus = http.StatusOK
|
||||
outcome.Status = result.Status
|
||||
outcome.Payment = result
|
||||
outcome.FailureType = res
|
||||
outcome.FailureDescription = result.Fields[types.Errors]
|
||||
return outcome
|
||||
}
|
||||
|
||||
app.printCreditCallReceipt(result.CardholderReceipt)
|
||||
log.Printf("Transaction approved and confirmed, reference: %s", ref)
|
||||
outcome.HTTPStatus = http.StatusOK
|
||||
outcome.Status = result.Status
|
||||
outcome.Payment = result
|
||||
outcome.Approved = true
|
||||
return outcome
|
||||
}
|
||||
|
||||
func (outcome creditCallSaleOutcome) legacyResponse() cmstypes.ResponseRec {
|
||||
response := cmstypes.ResponseRec{Status: outcome.Status}
|
||||
if outcome.Approved {
|
||||
response.Data = creditcall.BuildSuccessURL(outcome.Payment.Fields)
|
||||
} else {
|
||||
response.Data = creditcall.BuildFailureURL(outcome.FailureType, outcome.FailureDescription)
|
||||
}
|
||||
return response
|
||||
}
|
||||
480
internal/handlers/creditcall_sale_test.go
Normal file
480
internal/handlers/creditcall_sale_test.go
Normal file
@ -0,0 +1,480 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
||||
)
|
||||
|
||||
type failedCreditCallBody struct{ closed *bool }
|
||||
|
||||
func (b failedCreditCallBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
|
||||
func (b failedCreditCallBody) Close() error { *b.closed = true; return nil }
|
||||
|
||||
func TestCreditCallConfirmationReadFailureAndHTTPStatus(t *testing.T) {
|
||||
for _, failFirstRead := range []bool{false, true} {
|
||||
calls, receipts, firstClosed := 0, 0, false
|
||||
app := &App{cfg: &config.ConfigRec{}, creditCallReceipt: func(string) { receipts++ }}
|
||||
app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if r.URL.Path != "/confirm-transaction/" {
|
||||
t.Errorf("confirmation path=%s", r.URL.Path)
|
||||
}
|
||||
deadline, ok := r.Context().Deadline()
|
||||
if !ok || time.Until(deadline) < 299*time.Second {
|
||||
t.Errorf("confirmation call has no fresh 300-second deadline: %v", deadline)
|
||||
}
|
||||
var body io.ReadCloser = io.NopCloser(strings.NewReader(chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved"})))
|
||||
if failFirstRead && calls == 1 {
|
||||
body = failedCreditCallBody{closed: &firstClosed}
|
||||
}
|
||||
// Legacy confirmation ignores HTTP status when the body can be read.
|
||||
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: body}, nil
|
||||
})
|
||||
outcome := app.executeCreditCallSale(cmstypes.TransactionRec{AmountMinorUnits: "1234"}, func(*http.Client) (creditcall.TransactionResultXML, error) {
|
||||
return creditcall.TransactionResultXML{Entries: []creditcall.EntryXML{{Key: types.TransactionResult, Value: "Approved"}, {Key: types.Reference, Value: "ref"}}}, nil
|
||||
})
|
||||
wantCalls := 1
|
||||
if failFirstRead {
|
||||
wantCalls = 2
|
||||
}
|
||||
if calls != wantCalls || receipts != 1 || !outcome.Approved || (failFirstRead && !firstClosed) {
|
||||
t.Errorf("readFailure=%v calls/receipts/approved/closed=%d/%d/%v/%v", failFirstRead, calls, receipts, outcome.Approved, firstClosed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditCallStreamFailureNeverRestartsOrConfirms(t *testing.T) {
|
||||
calls := 0
|
||||
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.URL.Path != "/start-transaction-stream/" {
|
||||
t.Errorf("unexpected fallback/confirmation %s", r.URL.Path)
|
||||
}
|
||||
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n{partial")
|
||||
})
|
||||
app.creditCallReceipt = func(string) { t.Error("invalid upstream stream printed receipt") }
|
||||
recorder := httptest.NewRecorder()
|
||||
app.salePayment(recorder, saleRequest(true))
|
||||
frames := decodePaymentStream(t, recorder.Body)
|
||||
if calls != 1 || len(frames) != 2 || frames[1].Result == nil || frames[1].Result.Outcome != "error" || frames[1].Result.HTTPStatus != 502 {
|
||||
t.Errorf("malformed stream calls=%d frames=%+v", calls, frames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditCallStructuredFieldsExcludeRawData(t *testing.T) {
|
||||
result := (creditCallSaleOutcome{Approved: true, Payment: creditcall.PaymentResult{Fields: map[string]string{
|
||||
types.Reference: "ref", types.PanMasked: "1234567890123456", types.CardHash: "hash", types.CardReference: "card-ref",
|
||||
"PAN": "pan-secret", "CVV": "cvv-secret", "PIN": "pin-secret", "TRACK_DATA": "track-secret", types.ReceiptDataCardholder: "receipt-secret",
|
||||
}}}).streamResult()
|
||||
body, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"1234567890123456", "pan-secret", "cvv-secret", "pin-secret", "track-secret", "receipt-secret", "/successful", "/unsuccessful"} {
|
||||
if bytes.Contains(body, []byte(forbidden)) {
|
||||
t.Errorf("structured result exposed %q", forbidden)
|
||||
}
|
||||
}
|
||||
if result.CardHash != "hash" || result.CardReference != "card-ref" || result.TransactionReference != "ref" {
|
||||
t.Errorf("required identifiers missing: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
type blockedPaymentWriter struct {
|
||||
*httptest.ResponseRecorder
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (w *blockedPaymentWriter) Write(data []byte) (int, error) {
|
||||
w.once.Do(func() { close(w.started) })
|
||||
<-w.release
|
||||
return w.ResponseRecorder.Write(data)
|
||||
}
|
||||
|
||||
func TestCreditCallBackpressureCannotBlockFinalizationOrLoseFinal(t *testing.T) {
|
||||
nativeRelease, receiptPrinted := make(chan struct{}), make(chan struct{})
|
||||
var nativeOnce, deliveryOnce sync.Once
|
||||
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/confirm-transaction/" {
|
||||
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved"}))
|
||||
return
|
||||
}
|
||||
line := "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n"
|
||||
io.WriteString(w, line)
|
||||
w.(http.Flusher).Flush()
|
||||
<-nativeRelease
|
||||
for i := 0; i < 1000; i++ {
|
||||
io.WriteString(w, line)
|
||||
}
|
||||
io.WriteString(w, "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"ref\"}}\n")
|
||||
})
|
||||
app.creditCallReceipt = func(string) { close(receiptPrinted) }
|
||||
writer := &blockedPaymentWriter{ResponseRecorder: httptest.NewRecorder(), started: make(chan struct{}), release: make(chan struct{})}
|
||||
t.Cleanup(func() {
|
||||
nativeOnce.Do(func() { close(nativeRelease) })
|
||||
deliveryOnce.Do(func() { close(writer.release) })
|
||||
})
|
||||
done := make(chan struct{})
|
||||
go func() { app.salePayment(writer, saleRequest(true)); close(done) }()
|
||||
select {
|
||||
case <-writer.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("progress was not live")
|
||||
}
|
||||
nativeOnce.Do(func() { close(nativeRelease) })
|
||||
select {
|
||||
case <-receiptPrinted:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("blocked writer stopped financial finalization")
|
||||
}
|
||||
deliveryOnce.Do(func() { close(writer.release) })
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("stream did not deliver final")
|
||||
}
|
||||
frames := decodePaymentStream(t, writer.Body)
|
||||
if len(frames) != 130 || frames[len(frames)-1].Result == nil || frames[len(frames)-1].Result.Outcome != "approved" {
|
||||
t.Errorf("backpressure frames=%d, last=%+v, want 129 statuses and final approval", len(frames), frames[len(frames)-1])
|
||||
}
|
||||
}
|
||||
|
||||
type creditCallRoundTrip func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f creditCallRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func chipDNAFixture(t *testing.T, fields map[string]string) string {
|
||||
t.Helper()
|
||||
var result creditcall.TransactionResultXML
|
||||
for key, value := range fields {
|
||||
result.Entries = append(result.Entries, creditcall.EntryXML{Key: key, Value: value})
|
||||
}
|
||||
data, err := xml.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func newCreditCallTestApp(t *testing.T, handler http.HandlerFunc) *App {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
target, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &App{isPayment: true, creditCallStreamEnabled: true, cfg: &config.ConfigRec{TimeoutSeconds: 1},
|
||||
creditCallTransport: creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
clone := r.Clone(r.Context())
|
||||
clone.URL.Scheme, clone.URL.Host = target.Scheme, target.Host
|
||||
return server.Client().Transport.RoundTrip(clone)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func saleRequest(stream bool) *http.Request {
|
||||
if stream {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/payment/sale", strings.NewReader(`{"amount":1234}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
return r
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/takepayment", strings.NewReader(`<TransactionPayload><Amount>1234</Amount><TransactionType>Sale</TransactionType></TransactionPayload>`))
|
||||
r.Header.Set("Content-Type", "text/xml")
|
||||
return r
|
||||
}
|
||||
|
||||
func TestCreditCallLegacyAndStreamingSaleParity(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, startResult, confirmation string
|
||||
confirms int
|
||||
approved bool
|
||||
receipt string
|
||||
}{
|
||||
{"approved", "Approved", "Approved", 1, true, "confirm receipt"},
|
||||
{"declined", "Declined", "", 0, false, "start receipt"},
|
||||
{"cancelled", "Cancelled", "", 0, false, "start receipt"},
|
||||
{"timeout", "TIMEOUT", "", 0, false, "start receipt"},
|
||||
{"confirmation declined", "Approved", "Declined", 1, false, "confirm receipt"},
|
||||
{"confirmation omissions", "Approved", "omitted", 1, true, "start receipt"},
|
||||
{"malformed confirmation", "Approved", "malformed", 1, true, "start receipt"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fields := map[string]string{types.TransactionResult: test.startResult, types.Reference: "native-ref",
|
||||
types.CardType: "Visa", types.PanMasked: "************1234", types.ExpiryDate: "1228",
|
||||
types.CardHash: "hash", types.CardReference: "card-ref", types.ReceiptDataCardholder: "start receipt"}
|
||||
var legacy cmstypes.ResponseRec
|
||||
for _, streaming := range []bool{false, true} {
|
||||
var starts, confirms atomic.Int32
|
||||
var receipts []string
|
||||
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/start-transaction/":
|
||||
starts.Add(1)
|
||||
io.WriteString(w, chipDNAFixture(t, fields))
|
||||
case "/start-transaction-stream/":
|
||||
starts.Add(1)
|
||||
var input struct{ Amount, TransactionType string }
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.Amount != "1234" || input.TransactionType != "Sale" {
|
||||
t.Errorf("stream start input = %+v, error %v", input, err)
|
||||
}
|
||||
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n")
|
||||
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n")
|
||||
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"Approved\"}\n")
|
||||
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
|
||||
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
|
||||
case "/confirm-transaction/":
|
||||
confirms.Add(1)
|
||||
var input creditcall.ConfirmTransactionRequest
|
||||
if err := xml.NewDecoder(r.Body).Decode(&input); err != nil || input.Amount != "1234" || input.Reference != "native-ref" {
|
||||
t.Errorf("confirmation input = %+v, error %v", input, err)
|
||||
}
|
||||
switch test.confirmation {
|
||||
case "omitted":
|
||||
io.WriteString(w, "<TransactionResult/>")
|
||||
case "malformed":
|
||||
io.WriteString(w, "<TransactionResult><Entry>")
|
||||
default:
|
||||
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: test.confirmation, types.ReceiptDataCardholder: "confirm receipt"}))
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected ChipDNA path %q", r.URL.Path)
|
||||
}
|
||||
})
|
||||
app.creditCallReceipt = func(receipt string) { receipts = append(receipts, receipt) }
|
||||
if got := app.creditCallClient().Timeout; got != 300*time.Second {
|
||||
t.Fatalf("per-call timeout = %v, want 300s", got)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
if streaming {
|
||||
app.salePayment(recorder, saleRequest(true))
|
||||
} else {
|
||||
app.takePayment(recorder, saleRequest(false))
|
||||
}
|
||||
if starts.Load() != 1 || int(confirms.Load()) != test.confirms {
|
||||
t.Errorf("stream=%v starts/confirms=%d/%d, want 1/%d", streaming, starts.Load(), confirms.Load(), test.confirms)
|
||||
}
|
||||
if !reflect.DeepEqual(receipts, []string{test.receipt}) {
|
||||
t.Errorf("stream=%v receipts=%v, want [%s]", streaming, receipts, test.receipt)
|
||||
}
|
||||
if !streaming {
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.HasPrefix(legacy.Data, "/successful") != test.approved {
|
||||
t.Errorf("legacy result = %+v, approved want %v", legacy, test.approved)
|
||||
}
|
||||
continue
|
||||
}
|
||||
frames := decodePaymentStream(t, recorder.Body)
|
||||
if len(frames) != 3 || frames[0].Code != paymentstatus.PresentCard || frames[1].Code != paymentstatus.PleaseWait || frames[2].Result == nil {
|
||||
t.Fatalf("frames = %+v, want two ordered hints and one final", frames)
|
||||
}
|
||||
final := frames[2].Result
|
||||
if final.HTTPStatus != http.StatusOK || final.Status != legacy.Status || (final.Outcome == "approved") != test.approved {
|
||||
t.Errorf("structured final = %+v, legacy = %+v", final, legacy)
|
||||
}
|
||||
parsed, err := url.Parse(legacy.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.approved {
|
||||
if final.TransactionReference != "native-ref" || final.CardType != "Visa" || final.MaskedCardNumber != "************1234" || final.CardHash != "hash" || final.CardReference != "card-ref" || final.ExpiryDate != "1228" {
|
||||
t.Errorf("retained structured fields = %+v", final)
|
||||
}
|
||||
} else if final.Message != parsed.Query().Get("Description") {
|
||||
t.Errorf("structured message = %q, legacy description = %q", final.Message, parsed.Query().Get("Description"))
|
||||
}
|
||||
if frames[2].Response != nil {
|
||||
t.Error("CreditCall stream included legacy response")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditCallConfirmationRetryAndFailureParity(t *testing.T) {
|
||||
for _, streaming := range []bool{false, true} {
|
||||
var attempts int
|
||||
var times []time.Time
|
||||
app := &App{isPayment: true, creditCallStreamEnabled: true, cfg: &config.ConfigRec{}}
|
||||
app.creditCallReceipt = func(string) { t.Error("transport-failed confirmation must not print a receipt") }
|
||||
app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Context().Err() != nil {
|
||||
t.Error("transaction context was cancelled")
|
||||
}
|
||||
if r.URL.Path == "/confirm-transaction/" {
|
||||
attempts++
|
||||
times = append(times, time.Now())
|
||||
return nil, errors.New("test transport failure")
|
||||
}
|
||||
body := chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved", types.Reference: "native-ref"})
|
||||
if streaming {
|
||||
body = "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"native-ref\"}}\n"
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
if streaming {
|
||||
app.salePayment(recorder, saleRequest(true))
|
||||
} else {
|
||||
app.takePayment(recorder, saleRequest(false))
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("stream=%v confirms=%d, want 2", streaming, attempts)
|
||||
}
|
||||
if times[1].Sub(times[0]) < 2*time.Second {
|
||||
t.Errorf("retry delay=%v, want at least 2s", times[1].Sub(times[0]))
|
||||
}
|
||||
if streaming {
|
||||
frames := decodePaymentStream(t, recorder.Body)
|
||||
if len(frames) != 1 || frames[0].Result.HTTPStatus != 502 || frames[0].Result.Status.Code != 500 || frames[0].Result.Message != "Transaction error" {
|
||||
t.Fatalf("confirmation failure frames=%+v", frames)
|
||||
}
|
||||
} else if recorder.Code != 502 {
|
||||
t.Errorf("legacy confirm failure HTTP=%d, want 502", recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditCallLegacyMalformedStartAndPreauth(t *testing.T) {
|
||||
for _, test := range []struct{ name, path, result, transactionType string }{
|
||||
{"malformed sale", "/takepayment", "malformed", ""},
|
||||
{"approved account verification", "/takepreauth", "Approved", types.AccountVerificationType},
|
||||
{"declined preauth", "/takepreauth", "Declined", "Sale"},
|
||||
{"malformed preauth", "/takepreauth", "malformed", ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
calls, prints := 0, 0
|
||||
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.URL.Path != "/start-transaction/" {
|
||||
t.Errorf("legacy/preauth path=%s", r.URL.Path)
|
||||
}
|
||||
if test.result == "malformed" {
|
||||
io.WriteString(w, "<TransactionResult><Entry>")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: test.result, types.TransactionType: test.transactionType}))
|
||||
})
|
||||
app.creditCallReceipt = func(string) { prints++ }
|
||||
recorder := httptest.NewRecorder()
|
||||
request := saleRequest(false)
|
||||
if test.path == "/takepreauth" {
|
||||
app.takePreauthorization(recorder, request)
|
||||
} else {
|
||||
app.takePayment(recorder, request)
|
||||
}
|
||||
var response cmstypes.ResponseRec
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recorder.Code != 200 || calls != 1 || prints != 1 {
|
||||
t.Errorf("legacy HTTP/calls/receipts=%d/%d/%d", recorder.Code, calls, prints)
|
||||
}
|
||||
if test.result == "malformed" && (response.Status.Code != 0 || response.Data != "/unsuccessful?Description=&MsgType=") {
|
||||
t.Errorf("malformed legacy result=%+v", response)
|
||||
}
|
||||
if test.result == "Approved" && !strings.HasPrefix(response.Data, "/successful?") {
|
||||
t.Errorf("approved preauth=%+v", response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChipDNAStreamRejectsIncompleteOrInvalidFrames(t *testing.T) {
|
||||
for _, body := range []string{"", "{", "{}\n", "<xml/>\n", "{\"type\":\"result\"}\n", "{\"type\":\"result\",\"result\":{}}\n", "{\"type\":\"error\",\"error\":\"sensitive diagnostic\"}\n", "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\"}}", "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"Approved\"}\n"} {
|
||||
client := &http.Client{Transport: creditCallRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
})}
|
||||
_, err := callChipDNAStream(client, 1234, func(code string) { t.Errorf("invalid stream produced status %q", code) })
|
||||
if err == nil {
|
||||
t.Errorf("callChipDNAStream(%q) succeeded, want error", body)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), "sensitive diagnostic") {
|
||||
t.Error("raw diagnostic leaked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type disconnectedPaymentWriter struct {
|
||||
header http.Header
|
||||
once sync.Once
|
||||
failed chan struct{}
|
||||
writes atomic.Int32
|
||||
}
|
||||
|
||||
func (w *disconnectedPaymentWriter) Header() http.Header { return w.header }
|
||||
func (w *disconnectedPaymentWriter) WriteHeader(int) {}
|
||||
func (w *disconnectedPaymentWriter) Flush() {}
|
||||
func (w *disconnectedPaymentWriter) Write([]byte) (int, error) {
|
||||
w.writes.Add(1)
|
||||
w.once.Do(func() { close(w.failed) })
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
func TestCreditCallDisconnectStillConfirmsAndPrints(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
var confirms atomic.Int32
|
||||
var receipts atomic.Int32
|
||||
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/confirm-transaction/" {
|
||||
confirms.Add(1)
|
||||
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved", types.ReceiptDataCardholder: "receipt"}))
|
||||
return
|
||||
}
|
||||
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n")
|
||||
w.(http.Flusher).Flush()
|
||||
<-release
|
||||
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n")
|
||||
io.WriteString(w, "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"ref\"}}\n")
|
||||
})
|
||||
app.creditCallReceipt = func(receipt string) {
|
||||
if receipt != "receipt" {
|
||||
t.Errorf("receipt=%q", receipt)
|
||||
}
|
||||
receipts.Add(1)
|
||||
}
|
||||
writer := &disconnectedPaymentWriter{header: make(http.Header), failed: make(chan struct{})}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() { app.salePayment(writer, saleRequest(true).WithContext(ctx)); close(done) }()
|
||||
select {
|
||||
case <-writer.failed:
|
||||
case <-time.After(5 * time.Second):
|
||||
close(release)
|
||||
t.Fatal("no live progress before final result")
|
||||
}
|
||||
cancel()
|
||||
close(release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("finalization stopped after disconnect")
|
||||
}
|
||||
if confirms.Load() != 1 || receipts.Load() != 1 || writer.writes.Load() != 1 {
|
||||
t.Errorf("disconnect confirms/receipts/writes=%d/%d/%d, want 1/1/1", confirms.Load(), receipts.Load(), writer.writes.Load())
|
||||
}
|
||||
}
|
||||
226
internal/handlers/creditcall_stream.go
Normal file
226
internal/handlers/creditcall_stream.go
Normal file
@ -0,0 +1,226 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/mail"
|
||||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
||||
)
|
||||
|
||||
type creditCallStreamResult struct {
|
||||
Outcome string `json:"outcome"`
|
||||
Message string `json:"message"`
|
||||
HTTPStatus int `json:"httpStatus"`
|
||||
Status cmstypes.StatusRec `json:"status"`
|
||||
TransactionReference string `json:"transactionReference,omitempty"`
|
||||
CardType string `json:"cardType,omitempty"`
|
||||
MaskedCardNumber string `json:"maskedCardNumber,omitempty"`
|
||||
ExpiryDate string `json:"expiryDate,omitempty"`
|
||||
CardHash string `json:"cardHash,omitempty"`
|
||||
CardReference string `json:"cardReference,omitempty"`
|
||||
}
|
||||
|
||||
func (outcome creditCallSaleOutcome) streamResult() creditCallStreamResult {
|
||||
result := creditCallStreamResult{
|
||||
Outcome: "error", HTTPStatus: outcome.HTTPStatus, Status: outcome.Status,
|
||||
Message: creditcall.FailureDescription(outcome.FailureType, outcome.FailureDescription),
|
||||
}
|
||||
if !outcome.Approved {
|
||||
switch strings.ToLower(outcome.FailureType) {
|
||||
case "declined":
|
||||
result.Outcome = "declined"
|
||||
case "cancelled", "canceled":
|
||||
result.Outcome = "cancelled"
|
||||
case "timeout":
|
||||
result.Outcome = "timeout"
|
||||
}
|
||||
return result
|
||||
}
|
||||
fields := outcome.Payment.Fields
|
||||
result.Outcome = "approved"
|
||||
result.Message = "Payment approved"
|
||||
result.TransactionReference = fields[types.Reference]
|
||||
result.CardType = fields[types.CardType]
|
||||
result.MaskedCardNumber = creditcall.MaskedCardNumber(fields[types.PanMasked])
|
||||
result.ExpiryDate = fields[types.ExpiryDate]
|
||||
result.CardHash = fields[types.CardHash]
|
||||
result.CardReference = fields[types.CardReference]
|
||||
return result
|
||||
}
|
||||
|
||||
// EnableCreditCallStreaming selects the CreditCall core without a generic provider.
|
||||
func (app *App) EnableCreditCallStreaming() { app.creditCallStreamEnabled = true }
|
||||
|
||||
func (app *App) streamCreditCallSale(w http.ResponseWriter, r *http.Request) {
|
||||
setPaymentCORS(w)
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
controller := http.NewResponseController(w)
|
||||
encoder := json.NewEncoder(w)
|
||||
deliveryFailed := false
|
||||
// Only this handler writes the response. The transaction worker never waits
|
||||
// for network delivery, including when the peer stops reading without closing.
|
||||
send := func(frame paymentStreamMessage) {
|
||||
if deliveryFailed || r.Context().Err() != nil {
|
||||
deliveryFailed = true
|
||||
return
|
||||
}
|
||||
if err := encoder.Encode(frame); err != nil {
|
||||
deliveryFailed = true
|
||||
return
|
||||
}
|
||||
if err := controller.Flush(); err != nil {
|
||||
deliveryFailed = true
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
reject := func(status int, message string) {
|
||||
result := creditCallStreamResult{Outcome: "error", Message: message, HTTPStatus: status,
|
||||
Status: cmstypes.StatusRec{Code: status, Message: http.StatusText(status)}}
|
||||
w.WriteHeader(status)
|
||||
send(paymentStreamMessage{Type: "result", Result: &result})
|
||||
}
|
||||
if !app.isPayment && !app.cfg.TestMode {
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted payment while payment processing is disabled")
|
||||
reject(http.StatusServiceUnavailable, "Payment processing is disabled")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
reject(http.StatusMethodNotAllowed, "Method not allowed; use POST")
|
||||
return
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") {
|
||||
reject(http.StatusUnsupportedMediaType, "Content-Type must be application/json")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
var request SalePaymentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
reject(http.StatusBadRequest, "Invalid JSON payload")
|
||||
return
|
||||
}
|
||||
if request.Amount <= 0 {
|
||||
reject(http.StatusBadRequest, "Amount must be greater than zero")
|
||||
return
|
||||
}
|
||||
if _, ok := w.(http.Flusher); !ok {
|
||||
reject(http.StatusInternalServerError, "Streaming payment updates are not supported")
|
||||
return
|
||||
}
|
||||
|
||||
progress := make(chan string, 128)
|
||||
finished := make(chan creditCallSaleOutcome, 1)
|
||||
go func() {
|
||||
outcome := app.executeCreditCallSale(cmstypes.TransactionRec{
|
||||
AmountMinorUnits: strconv.FormatInt(request.Amount, 10), TransactionType: "Sale",
|
||||
}, func(client *http.Client) (creditcall.TransactionResultXML, error) {
|
||||
return callChipDNAStream(client, request.Amount, func(code string) {
|
||||
select {
|
||||
case progress <- code:
|
||||
default: // Observational progress may be dropped under backpressure.
|
||||
}
|
||||
})
|
||||
})
|
||||
close(progress)
|
||||
finished <- outcome // Independent of the bounded progress queue.
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case code, ok := <-progress:
|
||||
if !ok {
|
||||
progress = nil
|
||||
continue
|
||||
}
|
||||
send(paymentStreamMessage{Type: "status", Code: code})
|
||||
case outcome := <-finished:
|
||||
if progress != nil {
|
||||
for code := range progress {
|
||||
send(paymentStreamMessage{Type: "status", Code: code})
|
||||
}
|
||||
}
|
||||
result := outcome.streamResult()
|
||||
send(paymentStreamMessage{Type: "result", Result: &result})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func callChipDNAStream(client *http.Client, amount int64, onStatus func(string)) (creditcall.TransactionResultXML, error) {
|
||||
var result creditcall.TransactionResultXML
|
||||
payload, err := json.Marshal(struct {
|
||||
Amount string `json:"amount"`
|
||||
TransactionType string `json:"transactionType"`
|
||||
}{strconv.FormatInt(amount, 10), "Sale"})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
// Background plus Client.Timeout reproduces the independent per-call bound.
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, types.LinkStartTransactionStream, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/x-ndjson")
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return result, fmt.Errorf("chipdna stream returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
reader := bufio.NewReader(response.Body)
|
||||
for {
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return result, fmt.Errorf("chipdna stream ended before a complete final frame")
|
||||
}
|
||||
return result, fmt.Errorf("read chipdna stream: %w", err)
|
||||
}
|
||||
var frame struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Value string `json:"value"`
|
||||
Result map[string]string `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &frame); err != nil {
|
||||
return result, fmt.Errorf("invalid chipdna JSON frame")
|
||||
}
|
||||
switch frame.Type {
|
||||
case "status":
|
||||
if code := creditcall.ProgressStatus(frame.Source, frame.Value); code != "" && onStatus != nil {
|
||||
onStatus(code)
|
||||
}
|
||||
case "result":
|
||||
if len(frame.Result) == 0 {
|
||||
return result, fmt.Errorf("chipdna final frame has no transaction fields")
|
||||
}
|
||||
for key, value := range frame.Result {
|
||||
result.Entries = append(result.Entries, creditcall.EntryXML{Key: key, Value: value})
|
||||
}
|
||||
return result, nil // Later frames cannot repeat confirmation/finalization.
|
||||
case "error":
|
||||
return result, fmt.Errorf("chipdna stream reported an error")
|
||||
default:
|
||||
return result, fmt.Errorf("unknown chipdna frame type")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -35,17 +35,20 @@ type doorCardDispenser interface {
|
||||
}
|
||||
|
||||
type App struct {
|
||||
disp doorCardDispenser
|
||||
lockserver lockserver.LockServer
|
||||
paymentService *paymentsvc.Service
|
||||
isPayment bool
|
||||
db *sql.DB
|
||||
cfg *config.ConfigRec
|
||||
dbMu sync.Mutex
|
||||
cardWellMu sync.RWMutex
|
||||
cardWellStatus string
|
||||
availabilityMu sync.Mutex
|
||||
availabilityTimers map[string]*time.Timer
|
||||
disp doorCardDispenser
|
||||
lockserver lockserver.LockServer
|
||||
paymentService *paymentsvc.Service
|
||||
creditCallStreamEnabled bool
|
||||
creditCallTransport http.RoundTripper
|
||||
creditCallReceipt func(string)
|
||||
isPayment bool
|
||||
db *sql.DB
|
||||
cfg *config.ConfigRec
|
||||
dbMu sync.Mutex
|
||||
cardWellMu sync.RWMutex
|
||||
cardWellStatus string
|
||||
availabilityMu sync.Mutex
|
||||
availabilityTimers map[string]*time.Timer
|
||||
}
|
||||
|
||||
func NewApp(disp *dispenser.Client, lockType, encoderAddress, cardWellStatus string, db *sql.DB, cfg *config.ConfigRec) *App {
|
||||
@ -146,7 +149,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
||||
theRequest.TransactionType,
|
||||
)
|
||||
|
||||
client := &http.Client{Timeout: 300 * time.Second}
|
||||
client := app.creditCallClient()
|
||||
|
||||
// ---- START TRANSACTION ----
|
||||
|
||||
@ -167,7 +170,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ---- PRINT RECEIPT ----
|
||||
|
||||
printer.PrintReceipt(result.CardholderReceipt)
|
||||
app.printCreditCallReceipt(result.CardholderReceipt)
|
||||
|
||||
// ---- REDIRECT ----
|
||||
|
||||
@ -187,8 +190,6 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
theResponse cmstypes.ResponseRec
|
||||
theRequest cmstypes.TransactionRec
|
||||
trResult creditcall.TransactionResultXML
|
||||
result creditcall.PaymentResult
|
||||
)
|
||||
|
||||
theResponse.Status.Code = http.StatusInternalServerError
|
||||
@ -250,90 +251,18 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
||||
theRequest.TransactionType,
|
||||
)
|
||||
|
||||
client := &http.Client{Timeout: 300 * time.Second}
|
||||
|
||||
// ---- START TRANSACTION ----
|
||||
|
||||
body, err = callChipDNA(client, types.LinkStartTransaction, body)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Start transaction error", string(op), "", "", 0)
|
||||
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "No response from payment processor")
|
||||
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
if err := trResult.ParseTransactionResult(body); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
|
||||
}
|
||||
|
||||
result.FillFromTransactionResult(trResult)
|
||||
|
||||
res := result.Fields[types.TransactionResult]
|
||||
|
||||
if !strings.EqualFold(res, types.ResultApproved) {
|
||||
printer.PrintReceipt(result.CardholderReceipt)
|
||||
desc := result.Fields[types.ErrorDescription]
|
||||
if desc == "" {
|
||||
desc = result.Fields[types.Errors]
|
||||
outcome := app.executeCreditCallSale(theRequest, func(client *http.Client) (creditcall.TransactionResultXML, error) {
|
||||
var trResult creditcall.TransactionResultXML
|
||||
body, err := callChipDNA(client, types.LinkStartTransaction, body)
|
||||
if err != nil {
|
||||
return trResult, err
|
||||
}
|
||||
logging.Error(types.ServiceName, "Preauthorization failed", "Result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
|
||||
theResponse.Status = result.Status
|
||||
theResponse.Data = creditcall.BuildFailureURL(res, result.Fields[types.Errors])
|
||||
|
||||
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
// ---- CONFIRM TRANSACTION ----
|
||||
|
||||
ref := result.Fields[types.Reference]
|
||||
log.Printf("Preauth approved, reference: %s. Sending confirm...", ref)
|
||||
confirmReq := creditcall.ConfirmTransactionRequest{
|
||||
Amount: theRequest.AmountMinorUnits,
|
||||
Reference: ref,
|
||||
}
|
||||
|
||||
body, err = confirmWithRetry(client, confirmReq, 2)
|
||||
if err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Confirm transaction error", string(op), "", "", 0)
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Error: "+err.Error())
|
||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "ConfirmTransactionError")
|
||||
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
if err := trResult.ParseTransactionResult(body); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Parse confirm result error", string(op), "", "", 0)
|
||||
}
|
||||
|
||||
result.FillFromTransactionResult(trResult)
|
||||
|
||||
res = result.Fields[types.TransactionResult]
|
||||
|
||||
if !strings.EqualFold(res, types.ResultApproved) {
|
||||
printer.PrintReceipt(result.CardholderReceipt)
|
||||
desc := result.Fields[types.ErrorDescription]
|
||||
if desc == "" {
|
||||
desc = result.Fields[types.Errors]
|
||||
if err := trResult.ParseTransactionResult(body); err != nil {
|
||||
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
|
||||
}
|
||||
logging.Error(types.ServiceName, "Transaction not approved after confirm", "Confirm result: "+res+" Description: "+desc, string(op), "", app.cfg.Hotel, app.cfg.Kiosk)
|
||||
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment confirmation failed", "Reference: "+ref+", Confirm result: "+res+" Description: "+desc)
|
||||
theResponse.Status = result.Status
|
||||
theResponse.Data = creditcall.BuildFailureURL(res, result.Fields[types.Errors])
|
||||
|
||||
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||
return
|
||||
}
|
||||
|
||||
// ---- SUCCESS ----
|
||||
|
||||
printer.PrintReceipt(result.CardholderReceipt)
|
||||
log.Printf("Transaction approved and confirmed, reference: %s", ref)
|
||||
theResponse.Status = result.Status
|
||||
theResponse.Data = creditcall.BuildSuccessURL(result.Fields)
|
||||
|
||||
writeTransactionResult(w, http.StatusOK, theResponse)
|
||||
return trResult, nil
|
||||
})
|
||||
writeTransactionResult(w, outcome.HTTPStatus, outcome.legacyResponse())
|
||||
}
|
||||
|
||||
func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@ -30,14 +30,20 @@ type SalePaymentRequest struct {
|
||||
}
|
||||
|
||||
type paymentStreamMessage struct {
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Response *cmstypes.ResponseRec `json:"response,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Response *cmstypes.ResponseRec `json:"response,omitempty"`
|
||||
Result *creditCallStreamResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
||||
const op = logging.Op("salePayment")
|
||||
|
||||
if app.creditCallStreamEnabled {
|
||||
app.streamCreditCallSale(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
response := cmstypes.ResponseRec{
|
||||
Status: cmstypes.StatusRec{
|
||||
Code: http.StatusInternalServerError,
|
||||
|
||||
@ -10,6 +10,7 @@ const (
|
||||
ServiceName = "hardlink"
|
||||
DateOnly = "2006-01-02"
|
||||
CustomLayout = "2006-01-02 15:04:05 -0700"
|
||||
LinkStartTransactionStream = "http://127.0.0.1:18181/start-transaction-stream/"
|
||||
LinkStartTransaction = "http://127.0.0.1:18181/start-transaction/"
|
||||
LinkConfirmTransaction = "http://127.0.0.1:18181/confirm-transaction/"
|
||||
LinkTransactionInformation = "http://127.0.0.1:18181/transaction-information/"
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
builtVersion is a const in main.go
|
||||
|
||||
#### v2.0.0 - 08 September 2026
|
||||
feat: stream CreditCall payment progress and results
|
||||
|
||||
#### v1.3.6 - 28 August 2026
|
||||
Refactor PDQ lookup for payment providers
|
||||
|
||||
|
||||
#### v1.3.5 - 23 July 2026
|
||||
fixed dispenser delivery confirmation
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user