diff --git a/CREDITCALL_STREAMING.md b/CREDITCALL_STREAMING.md new file mode 100644 index 0000000..9c3a8d2 --- /dev/null +++ b/CREDITCALL_STREAMING.md @@ -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. diff --git a/cmd/hardlink/main.go b/cmd/hardlink/main.go index daf5ce6..7880f72 100644 --- a/cmd/hardlink/main.go +++ b/cmd/hardlink/main.go @@ -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 "" -} \ No newline at end of file +} diff --git a/internal/creditcall/creditcall.go b/internal/creditcall/creditcall.go index e65eb84..9ef92c6 100644 --- a/internal/creditcall/creditcall.go +++ b/internal/creditcall/creditcall.go @@ -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 +} diff --git a/internal/creditcall/statuses.go b/internal/creditcall/statuses.go new file mode 100644 index 0000000..ec7993e --- /dev/null +++ b/internal/creditcall/statuses.go @@ -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 +} diff --git a/internal/creditcall/statuses_test.go b/internal/creditcall/statuses_test.go new file mode 100644 index 0000000..f7093ba --- /dev/null +++ b/internal/creditcall/statuses_test.go @@ -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) + } + } +} diff --git a/internal/handlers/creditcall_sale.go b/internal/handlers/creditcall_sale.go new file mode 100644 index 0000000..cde5e9d --- /dev/null +++ b/internal/handlers/creditcall_sale.go @@ -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 +} diff --git a/internal/handlers/creditcall_sale_test.go b/internal/handlers/creditcall_sale_test.go new file mode 100644 index 0000000..c792e74 --- /dev/null +++ b/internal/handlers/creditcall_sale_test.go @@ -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(`1234Sale`)) + 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, "") + case "malformed": + io.WriteString(w, "") + 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, "") + 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", "\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()) + } +} diff --git a/internal/handlers/creditcall_stream.go b/internal/handlers/creditcall_stream.go new file mode 100644 index 0000000..27000b9 --- /dev/null +++ b/internal/handlers/creditcall_stream.go @@ -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") + } + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index d6e61b5..528c6dd 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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) { diff --git a/internal/handlers/payment_handlers.go b/internal/handlers/payment_handlers.go index 0580051..4e4be4a 100644 --- a/internal/handlers/payment_handlers.go +++ b/internal/handlers/payment_handlers.go @@ -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, diff --git a/internal/types/types.go b/internal/types/types.go index f1364cb..52ee6a1 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -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/"