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") } } }