261 lines
9.0 KiB
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")
|
|
}
|
|
}
|
|
}
|