package handlers import ( "context" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "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/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"` } func (app *App) salePayment(w http.ResponseWriter, r *http.Request) { const op = logging.Op("salePayment") var 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() result, err := app.paymentService.Sale(ctx, paymentsvc.SaleRequest{ RequestID: requestID, Reference: req.Reference, Amount: req.Amount, Currency: req.Currency, }) 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()) writeTransactionResult(w, status, 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") writeTransactionResult(w, http.StatusBadGateway, response) return } response.Status.Code = http.StatusOK if result.Success && strings.EqualFold(result.Status, "APPROVED") { response.Status.Message = result.Message response.Data = buildPaymentSuccessURL(result) writeTransactionResult(w, http.StatusOK, response) return } description := result.ErrorMessage if description == "" { description = result.Message } if description == "" { description = result.Status } response.Status.Message = "Payment unsuccessful" response.Data = buildPaymentFailureURL(types.ResultError, description) writeTransactionResult(w, http.StatusOK, 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", txnReference) 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() }