package handlers import ( "context" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "sync" "time" "gitea.futuresens.co.uk/futuresens/cmstypes" "gitea.futuresens.co.uk/futuresens/hardlink/internal/mail" "gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc" "gitea.futuresens.co.uk/futuresens/hardlink/internal/printer" "gitea.futuresens.co.uk/futuresens/hardlink/internal/types" "gitea.futuresens.co.uk/futuresens/logging" "github.com/google/uuid" log "github.com/sirupsen/logrus" ) type SalePaymentRequest struct { Reference string `json:"reference,omitempty"` ConfirmNo string `json:"confirmNo,omitempty"` Amount int64 `json:"amount"` Currency string `json:"currency,omitempty"` } type paymentStreamMessage struct { Type string `json:"type"` Code string `json:"code,omitempty"` Response *cmstypes.ResponseRec `json:"response,omitempty"` } func (app *App) salePayment(w http.ResponseWriter, r *http.Request) { const op = logging.Op("salePayment") response := cmstypes.ResponseRec{ Status: cmstypes.StatusRec{ Code: http.StatusInternalServerError, Message: http.StatusText(http.StatusInternalServerError), }, } setPaymentCORS(w) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost { response.Data = buildPaymentFailureURL(types.ResultError, "Method not allowed; use POST") writeTransactionResult(w, http.StatusMethodNotAllowed, response) return } if app.paymentService == nil { mail.SendEmailOnError( app.cfg.Hotel, app.cfg.Kiosk, "Payment Service Not Configured", "Payment service is not configured; cannot process payment requests", ) response.Data = buildPaymentFailureURL(types.ResultError, "Payment service is not configured") writeTransactionResult(w, http.StatusInternalServerError, response) return } if ct := r.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") { response.Data = buildPaymentFailureURL(types.ResultError, "Content-Type must be application/json") writeTransactionResult(w, http.StatusUnsupportedMediaType, response) return } defer r.Body.Close() var req SalePaymentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { logging.Error( types.ServiceName, err.Error(), "ReadJSON", string(op), "", app.cfg.Hotel, app.cfg.Kiosk, ) response.Data = buildPaymentFailureURL(types.ResultError, "invalid JSON payload: "+err.Error()) writeTransactionResult(w, http.StatusBadRequest, response) return } if req.Amount <= 0 { response.Data = buildPaymentFailureURL(types.ResultError, "Amount must be greater than zero") writeTransactionResult(w, http.StatusBadRequest, response) return } if req.Currency == "" { req.Currency = "GBP" } if req.Reference == "" { req.Reference = req.ConfirmNo } if req.Reference == "" { req.Reference = uuid.NewString() } requestID := buildPaymentRequestID(req.Reference) timeoutSeconds := app.cfg.TimeoutSeconds if timeoutSeconds <= 0 { timeoutSeconds = 300 } ctx, cancel := context.WithTimeout( r.Context(), time.Duration(timeoutSeconds)*time.Second, ) defer cancel() flusher, ok := w.(http.Flusher) if !ok { response.Data = buildPaymentFailureURL( types.ResultError, "Streaming payment updates are not supported", ) writeTransactionResult(w, http.StatusInternalServerError, response) 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") encoder := json.NewEncoder(w) var streamMu sync.Mutex streamStarted := false streamFailed := false sendStreamMessage := func(message paymentStreamMessage) { streamMu.Lock() defer streamMu.Unlock() if streamFailed { return } if err := encoder.Encode(message); err != nil { streamFailed = true return } streamStarted = true flusher.Flush() } onStatus := func(update paymentsvc.StatusUpdate) { if update.Code == "" { return } sendStreamMessage(paymentStreamMessage{ Type: "status", Code: update.Code, }) } result, err := app.paymentService.Sale( ctx, paymentsvc.SaleRequest{ RequestID: requestID, Reference: req.Reference, Amount: req.Amount, Currency: req.Currency, }, onStatus, ) if err != nil { status := http.StatusBadGateway if errors.Is(err, paymentsvc.ErrPaymentInProgress) { status = http.StatusConflict } logging.Error( types.ServiceName, err.Error(), "Payment provider error", string(op), req.Reference, app.cfg.Hotel, app.cfg.Kiosk, ) response.Status.Code = status response.Status.Message = http.StatusText(status) response.Data = buildPaymentFailureURL(types.ResultError, err.Error()) if !streamStarted { w.Header().Set("Content-Type", "application/json; charset=utf-8") writeTransactionResult(w, status, response) return } sendStreamMessage(paymentStreamMessage{ Type: "result", Response: &response, }) return } if result == nil { response.Status.Code = http.StatusBadGateway response.Status.Message = "Empty payment result" response.Data = buildPaymentFailureURL( types.ResultError, "Payment provider returned an empty result", ) if !streamStarted { w.Header().Set("Content-Type", "application/json; charset=utf-8") writeTransactionResult(w, http.StatusBadGateway, response) return } sendStreamMessage(paymentStreamMessage{ Type: "result", Response: &response, }) return } response.Status.Code = http.StatusOK if result.Success && strings.EqualFold(result.Status, "APPROVED") { printer.PrintSaleReceipt(result.CustomerReceipt) response.Status.Message = result.Message response.Data = buildPaymentSuccessURL(result) sendStreamMessage(paymentStreamMessage{ Type: "result", Response: &response, }) return } description := result.ErrorMessage if description == "" { description = result.Message } if description == "" { description = result.Status } log.Printf("Transaction failed: %s", description) printer.PrintSaleReceipt(result.CustomerReceipt) response.Status.Message = "Payment unsuccessful" response.Data = buildPaymentFailureURL(types.ResultError, description) sendStreamMessage(paymentStreamMessage{ Type: "result", Response: &response, }) } func buildPaymentRequestID(reference string) string { const prefix = "REQ_" const maxLength = 60 suffix := fmt.Sprintf("_%d", time.Now().UnixMilli()) maxReferenceLength := maxLength - len(prefix) - len(suffix) runes := []rune(reference) if len(runes) > maxReferenceLength { runes = runes[:maxReferenceLength] } return prefix + string(runes) + suffix } func setPaymentCORS(w http.ResponseWriter) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Content-Type", "application/json") } func buildPaymentSuccessURL(result *paymentsvc.Result) string { // txnReference := result.ReferenceNumber // if txnReference == "" { // txnReference = result.TransactionID // } q := url.Values{} q.Set("CardNumber", hex.EncodeToString([]byte(result.CardNumber))) q.Set("CardType", hex.EncodeToString([]byte(result.CardType))) q.Set("ExpiryDate", hex.EncodeToString([]byte(result.ExpiryDate))) q.Set("TxnReference", result.RequestID) q.Set("CardHash", hex.EncodeToString([]byte(result.CardHash))) q.Set("CardReference", hex.EncodeToString([]byte(result.CardReference))) return (&url.URL{ Path: types.CheckinSuccessfulEndpoint, RawQuery: q.Encode(), }).String() } func buildPaymentFailureURL(msgType, description string) string { log.WithFields(log.Fields{ types.LogFieldError: msgType, types.LogFieldDescription: description, }).Error("Transaction failed") q := url.Values{} q.Set("MsgType", msgType) q.Set("Description", description) return (&url.URL{ Path: types.CheckinUnsuccessfulEndpoint, RawQuery: q.Encode(), }).String() }