335 lines
8.0 KiB
Go
335 lines
8.0 KiB
Go
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()
|
|
}
|