feat: stream CreditCall payment progress and results
This commit is contained in:
parent
ea578fe03e
commit
09baada9e7
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.
|
||||||
@ -135,7 +135,8 @@ func main() {
|
|||||||
|
|
||||||
switch paymentProvider {
|
switch paymentProvider {
|
||||||
case cmstypes.PaySystemCreditCall:
|
case cmstypes.PaySystemCreditCall:
|
||||||
// CreditCall keeps using the existing /takepayment and /takepreauth endpoints.
|
// CreditCall streaming shares its proven legacy transaction core.
|
||||||
|
app.EnableCreditCallStreaming()
|
||||||
startChipDnaClient()
|
startChipDnaClient()
|
||||||
|
|
||||||
go func() {
|
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 {
|
if provider != nil {
|
||||||
app.SetPaymentService(paymentsvc.NewService(provider))
|
app.SetPaymentService(paymentsvc.NewService(provider))
|
||||||
log.Infof("Payment provider enabled for POST /api/payment/sale: %s", cmstypes.PaySystemNames[paymentProvider])
|
log.Infof("Payment provider enabled for POST /api/payment/sale: %s", cmstypes.PaySystemNames[paymentProvider])
|
||||||
|
|||||||
@ -168,9 +168,7 @@ func BuildSuccessURL(result map[string]string) string {
|
|||||||
|
|
||||||
func BuildFailureURL(msgType, description string) string {
|
func BuildFailureURL(msgType, description string) string {
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
if msgType != "" {
|
description = FailureDescription(msgType, description)
|
||||||
description = fmt.Sprintf("Transaction %s", strings.ToLower(msgType))
|
|
||||||
}
|
|
||||||
if description != "" {
|
if description != "" {
|
||||||
msgType = types.ResultError
|
msgType = types.ResultError
|
||||||
}
|
}
|
||||||
@ -185,3 +183,12 @@ func BuildFailureURL(msgType, description string) string {
|
|||||||
RawQuery: q.Encode(),
|
RawQuery: q.Encode(),
|
||||||
}).String()
|
}).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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,6 +38,9 @@ type App struct {
|
|||||||
disp doorCardDispenser
|
disp doorCardDispenser
|
||||||
lockserver lockserver.LockServer
|
lockserver lockserver.LockServer
|
||||||
paymentService *paymentsvc.Service
|
paymentService *paymentsvc.Service
|
||||||
|
creditCallStreamEnabled bool
|
||||||
|
creditCallTransport http.RoundTripper
|
||||||
|
creditCallReceipt func(string)
|
||||||
isPayment bool
|
isPayment bool
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
cfg *config.ConfigRec
|
cfg *config.ConfigRec
|
||||||
@ -146,7 +149,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
|||||||
theRequest.TransactionType,
|
theRequest.TransactionType,
|
||||||
)
|
)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 300 * time.Second}
|
client := app.creditCallClient()
|
||||||
|
|
||||||
// ---- START TRANSACTION ----
|
// ---- START TRANSACTION ----
|
||||||
|
|
||||||
@ -167,7 +170,7 @@ func (app *App) takePreauthorization(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ---- PRINT RECEIPT ----
|
// ---- PRINT RECEIPT ----
|
||||||
|
|
||||||
printer.PrintReceipt(result.CardholderReceipt)
|
app.printCreditCallReceipt(result.CardholderReceipt)
|
||||||
|
|
||||||
// ---- REDIRECT ----
|
// ---- REDIRECT ----
|
||||||
|
|
||||||
@ -187,8 +190,6 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
var (
|
var (
|
||||||
theResponse cmstypes.ResponseRec
|
theResponse cmstypes.ResponseRec
|
||||||
theRequest cmstypes.TransactionRec
|
theRequest cmstypes.TransactionRec
|
||||||
trResult creditcall.TransactionResultXML
|
|
||||||
result creditcall.PaymentResult
|
|
||||||
)
|
)
|
||||||
|
|
||||||
theResponse.Status.Code = http.StatusInternalServerError
|
theResponse.Status.Code = http.StatusInternalServerError
|
||||||
@ -250,90 +251,18 @@ func (app *App) takePayment(w http.ResponseWriter, r *http.Request) {
|
|||||||
theRequest.TransactionType,
|
theRequest.TransactionType,
|
||||||
)
|
)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 300 * time.Second}
|
outcome := app.executeCreditCallSale(theRequest, func(client *http.Client) (creditcall.TransactionResultXML, error) {
|
||||||
|
var trResult creditcall.TransactionResultXML
|
||||||
// ---- START TRANSACTION ----
|
body, err := callChipDNA(client, types.LinkStartTransaction, body)
|
||||||
|
|
||||||
body, err = callChipDNA(client, types.LinkStartTransaction, body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error(types.ServiceName, err.Error(), "Start transaction error", string(op), "", "", 0)
|
return trResult, err
|
||||||
|
|
||||||
theResponse.Data = creditcall.BuildFailureURL(types.ResultError, "No response from payment processor")
|
|
||||||
writeTransactionResult(w, http.StatusBadGateway, theResponse)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := trResult.ParseTransactionResult(body); err != nil {
|
if err := trResult.ParseTransactionResult(body); err != nil {
|
||||||
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
|
logging.Error(types.ServiceName, err.Error(), "Parse transaction result error", string(op), "", "", 0)
|
||||||
}
|
}
|
||||||
|
return trResult, nil
|
||||||
result.FillFromTransactionResult(trResult)
|
})
|
||||||
|
writeTransactionResult(w, outcome.HTTPStatus, outcome.legacyResponse())
|
||||||
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]
|
|
||||||
}
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
func (app *App) issueDoorCard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -33,11 +33,17 @@ type paymentStreamMessage struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Code string `json:"code,omitempty"`
|
Code string `json:"code,omitempty"`
|
||||||
Response *cmstypes.ResponseRec `json:"response,omitempty"`
|
Response *cmstypes.ResponseRec `json:"response,omitempty"`
|
||||||
|
Result *creditCallStreamResult `json:"result,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
func (app *App) salePayment(w http.ResponseWriter, r *http.Request) {
|
||||||
const op = logging.Op("salePayment")
|
const op = logging.Op("salePayment")
|
||||||
|
|
||||||
|
if app.creditCallStreamEnabled {
|
||||||
|
app.streamCreditCallSale(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
response := cmstypes.ResponseRec{
|
response := cmstypes.ResponseRec{
|
||||||
Status: cmstypes.StatusRec{
|
Status: cmstypes.StatusRec{
|
||||||
Code: http.StatusInternalServerError,
|
Code: http.StatusInternalServerError,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const (
|
|||||||
ServiceName = "hardlink"
|
ServiceName = "hardlink"
|
||||||
DateOnly = "2006-01-02"
|
DateOnly = "2006-01-02"
|
||||||
CustomLayout = "2006-01-02 15:04:05 -0700"
|
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/"
|
LinkStartTransaction = "http://127.0.0.1:18181/start-transaction/"
|
||||||
LinkConfirmTransaction = "http://127.0.0.1:18181/confirm-transaction/"
|
LinkConfirmTransaction = "http://127.0.0.1:18181/confirm-transaction/"
|
||||||
LinkTransactionInformation = "http://127.0.0.1:18181/transaction-information/"
|
LinkTransactionInformation = "http://127.0.0.1:18181/transaction-information/"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user