hardlink/internal/handlers/creditcall_stream.go

261 lines
9.0 KiB
Go

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) {
app.streamCreditCall(w, r, false)
}
func (app *App) streamCreditCall(w http.ResponseWriter, r *http.Request, preauth bool) {
setPaymentCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
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 preauth && !app.creditCallStreamEnabled {
reject(http.StatusServiceUnavailable, "CreditCall preauthorization streaming is not enabled")
return
}
if !app.isPayment && !app.cfg.TestMode {
mail.SendEmailOnError(app.cfg.Hotel, app.cfg.Kiosk, "Payment Error", "Attempted payment while payment processing is disabled")
reject(http.StatusServiceUnavailable, "Payment processing is disabled")
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 transaction cmstypes.TransactionRec
if preauth {
var request creditCallPreauthRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
reject(http.StatusBadRequest, "Invalid JSON payload")
return
}
transaction = cmstypes.TransactionRec{AmountMinorUnits: request.Amount, TransactionType: request.TransactionType, CheckoutDate: request.CheckoutDate}
} else {
var request SalePaymentRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
reject(http.StatusBadRequest, "Invalid JSON payload")
return
}
if request.Amount <= 0 {
reject(http.StatusBadRequest, "Amount must be greater than zero")
return
}
transaction = cmstypes.TransactionRec{AmountMinorUnits: strconv.FormatInt(request.Amount, 10), TransactionType: "Sale"}
}
if _, ok := w.(http.Flusher); !ok {
reject(http.StatusInternalServerError, "Streaming payment updates are not supported")
return
}
// Validation belongs to the incoming request. This handoff owns financial dispatch.
if r.Context().Err() != nil {
return
}
financialContext := context.WithoutCancel(r.Context())
progress := make(chan string, 128)
finished := make(chan creditCallSaleOutcome, 1)
go func() {
start := func(client *http.Client) (creditcall.TransactionResultXML, error) {
return callChipDNATransactionStream(financialContext, client, transaction, func(code string) {
select {
case progress <- code:
default: // Observational progress may be dropped under backpressure.
}
})
}
var outcome creditCallSaleOutcome
if preauth {
outcome = app.executeCreditCallPreauth(financialContext, transaction, start)
} else {
outcome = app.executeCreditCallSale(transaction, start)
}
close(progress)
finished <- outcome // Independent of the bounded progress queue.
}()
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) {
return callChipDNATransactionStream(context.Background(), client, cmstypes.TransactionRec{
AmountMinorUnits: strconv.FormatInt(amount, 10), TransactionType: "Sale",
}, onStatus)
}
func callChipDNATransactionStream(ctx context.Context, client *http.Client, transaction cmstypes.TransactionRec, onStatus func(string)) (creditcall.TransactionResultXML, error) {
var result creditcall.TransactionResultXML
payload, err := json.Marshal(struct {
Amount string `json:"amount"`
TransactionType string `json:"transactionType"`
}{transaction.AmountMinorUnits, transaction.TransactionType})
if err != nil {
return result, err
}
// Dispatch owns ctx; Client.Timeout retains the independent 300-second call bound.
req, err := http.NewRequestWithContext(ctx, http.MethodPost, types.LinkStartTransactionStream, bytes.NewReader(payload))
if err != nil {
return result, err
}
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")
}
}
}