318 lines
9.2 KiB
Go
318 lines
9.2 KiB
Go
package payment
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/denisenkom/go-mssqldb"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
const (
|
|
// Transaction types
|
|
SaleTransactionType = "sale"
|
|
AccountVerificationType = "account verification"
|
|
|
|
// Transaction results
|
|
ResultApproved = "approved"
|
|
ResultDeclined = "declined"
|
|
ResultCancelled = "cancelled"
|
|
ResultPending = "pending"
|
|
ResultError = "error"
|
|
CheckinSuccessfulEndpoint = "/successful" // Endpoint to send guest to after successful payment
|
|
CheckinUnsuccessfulEndpoint = "/unsuccessful"
|
|
|
|
// Response map keys
|
|
CardReference = "CARD_REFERENCE"
|
|
CardHash = "CARD_HASH"
|
|
Errors = "ERRORS"
|
|
ReceiptData = "RECEIPT_DATA"
|
|
ReceiptDataMerchant = "RECEIPT_DATA_MERCHANT"
|
|
ReceiptDataCardholder = "RECEIPT_DATA_CARDHOLDER"
|
|
Reference = "REFERENCE"
|
|
TransactionResult = "TRANSACTION_RESULT"
|
|
TransactionType = "TRANSACTION_TYPE"
|
|
ConfirmResult = "CONFIRM_RESULT"
|
|
ConfirmErrors = "CONFIRM_ERRORS"
|
|
|
|
// Log field keys
|
|
LogFieldError = "error"
|
|
LogFieldDescription = "description"
|
|
LogResult = "transactionResult"
|
|
)
|
|
|
|
// XML parsing structs
|
|
type (
|
|
TransactionRec struct {
|
|
XMLName xml.Name `xml:"TransactionPayload"`
|
|
AmountMinorUnits string `xml:"amount"`
|
|
TransactionType string `xml:"transactionType"`
|
|
}
|
|
|
|
TransactionResultXML struct {
|
|
XMLName xml.Name `xml:"TransactionResult"`
|
|
Entries []EntryXML `xml:"Entry"`
|
|
}
|
|
|
|
EntryXML struct {
|
|
Key string `xml:"Key"`
|
|
Value string `xml:"Value"`
|
|
}
|
|
|
|
TransactionConfirmation struct {
|
|
XMLName xml.Name `xml:"TransactionConfirmation"`
|
|
Result string `xml:"Result"`
|
|
Errors string `xml:"Errors"`
|
|
ErrorDescription string `xml:"ErrorDescription"`
|
|
ReceiptDataCardholder string `xml:"ReceiptDataCardholder"`
|
|
}
|
|
)
|
|
|
|
// ParseTransactionResult parses the XML into entries.
|
|
func ParseTransactionResult(data []byte) ([]EntryXML, error) {
|
|
var tr TransactionResultXML
|
|
if err := xml.Unmarshal(data, &tr); err != nil {
|
|
return nil, fmt.Errorf("XML unmarshal: %w", err)
|
|
}
|
|
return tr.Entries, nil
|
|
}
|
|
|
|
// initMSSQL opens and pings the SQL Server instance localhost\SQLEXPRESS
|
|
// using user=Kiosk, password=Gr33nfarm, database=TransactionDatabase.
|
|
func InitMSSQL(port int, user, password, database string) (*sql.DB, error) {
|
|
const server = "localhost"
|
|
|
|
// Use TCP; drop the \SQLEXPRESS instance name
|
|
connString := fmt.Sprintf(
|
|
"sqlserver://%s:%s@%s:%d?database=%s&encrypt=disable",
|
|
user, password, server, port, database,
|
|
)
|
|
|
|
db, err := sql.Open("sqlserver", connString)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening DB: %w", err)
|
|
}
|
|
|
|
// Verify connectivity
|
|
if err := db.PingContext(context.Background()); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("pinging DB: %w", err)
|
|
}
|
|
|
|
return db, nil
|
|
}
|
|
|
|
// insertTransactionRecord inserts one row into TransactionRecords.
|
|
// m is the map from keys to string values as returned by ChipDNA.
|
|
func InsertTransactionRecord(ctx context.Context, db *sql.DB, m map[string]string) error {
|
|
// Extract fields with defaults or NULL handling.
|
|
|
|
// 1. TxnReference <- REFERENCE
|
|
ref, ok := m["REFERENCE"]
|
|
if !ok || ref == "" {
|
|
return fmt.Errorf("missing REFERENCE in result map")
|
|
}
|
|
|
|
// 2. TxnDateTime <- parse AUTH_DATE_TIME (layout "20060102150405"), else use now
|
|
var txnTime time.Time
|
|
if s, ok := m["AUTH_DATE_TIME"]; ok && s != "" {
|
|
t, err := time.ParseInLocation("20060102150405", s, time.UTC)
|
|
if err != nil {
|
|
// fallback: use now
|
|
txnTime = time.Now().UTC()
|
|
} else {
|
|
txnTime = t
|
|
}
|
|
} else {
|
|
txnTime = time.Now().UTC()
|
|
}
|
|
|
|
// 3. TotalAmount <- parse TOTAL_AMOUNT minor units into float (divide by 100)
|
|
var totalAmount sql.NullFloat64
|
|
if s, ok := m["TOTAL_AMOUNT"]; ok && s != "" {
|
|
if iv, err := strconv.ParseInt(s, 10, 64); err == nil {
|
|
// convert minor units to major (e.g. 150 -> 1.50)
|
|
totalAmount.Float64 = float64(iv) / 100.0
|
|
totalAmount.Valid = true
|
|
}
|
|
}
|
|
|
|
// 4. MerchantId <- MERCHANT_ID_MASKED
|
|
merchantId := sql.NullString{String: m["MERCHANT_ID_MASKED"], Valid: m["MERCHANT_ID_MASKED"] != ""}
|
|
|
|
// 5. TerminalId <- TERMINAL_ID_MASKED
|
|
terminalId := sql.NullString{String: m["TERMINAL_ID_MASKED"], Valid: m["TERMINAL_ID_MASKED"] != ""}
|
|
|
|
// 6. CardSchemeName <- CARD_SCHEME
|
|
cardScheme := sql.NullString{String: m["CARD_SCHEME"], Valid: m["CARD_SCHEME"] != ""}
|
|
|
|
// 7. ExpiryDate <- EXPIRY_DATE
|
|
expiryDate := sql.NullString{String: m["EXPIRY_DATE"], Valid: m["EXPIRY_DATE"] != ""}
|
|
|
|
// 8. RecordReference <- CARD_REFERENCE
|
|
recordRef := sql.NullString{String: m["CARD_REFERENCE"], Valid: m["CARD_REFERENCE"] != ""}
|
|
|
|
// 9. Token1 <- CARD_HASH
|
|
token1 := sql.NullString{String: m["CARD_HASH"], Valid: m["CARD_HASH"] != ""}
|
|
|
|
// 10. Token2 <- CARDEASE_REFERENCE
|
|
token2 := sql.NullString{String: m["CARDEASE_REFERENCE"], Valid: m["CARDEASE_REFERENCE"] != ""}
|
|
|
|
// 11. PanMasked <- PAN_MASKED
|
|
panMasked := sql.NullString{String: m["PAN_MASKED"], Valid: m["PAN_MASKED"] != ""}
|
|
|
|
// 12. AuthCode <- AUTH_CODE
|
|
authCode := sql.NullString{String: m["AUTH_CODE"], Valid: m["AUTH_CODE"] != ""}
|
|
|
|
// 13. TransactionResult <- TRANSACTION_RESULT
|
|
txnResult := sql.NullString{String: m["TRANSACTION_RESULT"], Valid: m["TRANSACTION_RESULT"] != ""}
|
|
|
|
// Build INSERT statement with named parameters.
|
|
// Assuming your table is [TransactionDatabase].[dbo].[TransactionRecords].
|
|
const stmt = `
|
|
INSERT INTO [TransactionDatabase].[dbo].[TransactionRecords]
|
|
(
|
|
[TxnReference],
|
|
[TxnDateTime],
|
|
[TotalAmount],
|
|
[MerchantId],
|
|
[TerminalId],
|
|
[CardSchemeName],
|
|
[ExpiryDate],
|
|
[RecordReference],
|
|
[Token1],
|
|
[Token2],
|
|
[PanMasked],
|
|
[AuthCode],
|
|
[TransactionResult]
|
|
)
|
|
VALUES
|
|
(
|
|
@TxnReference,
|
|
@TxnDateTime,
|
|
@TotalAmount,
|
|
@MerchantId,
|
|
@TerminalId,
|
|
@CardSchemeName,
|
|
@ExpiryDate,
|
|
@RecordReference,
|
|
@Token1,
|
|
@Token2,
|
|
@PanMasked,
|
|
@AuthCode,
|
|
@TransactionResult
|
|
);
|
|
`
|
|
// Execute with sql.Named parameters:
|
|
_, err := db.ExecContext(ctx, stmt,
|
|
sql.Named("TxnReference", ref),
|
|
sql.Named("TxnDateTime", txnTime),
|
|
sql.Named("TotalAmount", nullableFloatArg(totalAmount)),
|
|
sql.Named("MerchantId", nullableStringArg(merchantId)),
|
|
sql.Named("TerminalId", nullableStringArg(terminalId)),
|
|
sql.Named("CardSchemeName", nullableStringArg(cardScheme)),
|
|
sql.Named("ExpiryDate", nullableStringArg(expiryDate)),
|
|
sql.Named("RecordReference", nullableStringArg(recordRef)),
|
|
sql.Named("Token1", nullableStringArg(token1)),
|
|
sql.Named("Token2", nullableStringArg(token2)),
|
|
sql.Named("PanMasked", nullableStringArg(panMasked)),
|
|
sql.Named("AuthCode", nullableStringArg(authCode)),
|
|
sql.Named("TransactionResult", nullableStringArg(txnResult)),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert TransactionRecords: %w", err)
|
|
}
|
|
// Successfully inserted
|
|
log.Infof("Inserted transaction record for reference %s", ref)
|
|
return nil
|
|
}
|
|
|
|
// Helpers to pass NULL when appropriate:
|
|
func nullableStringArg(ns sql.NullString) interface{} {
|
|
if ns.Valid {
|
|
return ns.String
|
|
}
|
|
return nil
|
|
}
|
|
func nullableFloatArg(nf sql.NullFloat64) interface{} {
|
|
if nf.Valid {
|
|
return nf.Float64
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BuildRedirectURL builds the redirect URL to send the guest to after payment.
|
|
func BuildRedirectURL(result map[string]string) string {
|
|
res := result[TransactionResult]
|
|
tType := result[TransactionType]
|
|
|
|
// Transaction approved?
|
|
if strings.EqualFold(res, ResultApproved) {
|
|
switch {
|
|
// Transaction type AccountVerification?
|
|
case strings.EqualFold(tType, AccountVerificationType):
|
|
log.WithField(LogResult, result[TransactionResult]).
|
|
Info("Account verification approved")
|
|
|
|
return buildSuccessURL(result)
|
|
|
|
// Transaction type Sale?
|
|
case strings.EqualFold(tType, SaleTransactionType):
|
|
// Transaction confirmed?
|
|
if strings.EqualFold(result[ConfirmResult], ResultApproved) {
|
|
log.WithField(LogResult, result[ConfirmResult]).
|
|
Info("Transaction approved and confirmed")
|
|
|
|
return buildSuccessURL(result)
|
|
}
|
|
|
|
// Not confirmed
|
|
log.WithFields(log.Fields{LogFieldError: result[ConfirmResult], LogFieldDescription: result[ConfirmErrors]}).
|
|
Error("Transaction approved but not confirmed")
|
|
|
|
return BuildFailureURL(result[ConfirmResult], result[ConfirmErrors])
|
|
}
|
|
}
|
|
|
|
// Not approved
|
|
return BuildFailureURL(res, result[Errors])
|
|
}
|
|
|
|
func buildSuccessURL(result map[string]string) string {
|
|
q := url.Values{}
|
|
q.Set("TxnReference", result[Reference])
|
|
q.Set("CardHash", hex.EncodeToString([]byte(result[CardHash])))
|
|
q.Set("CardReference", hex.EncodeToString([]byte(result[CardReference])))
|
|
return (&url.URL{
|
|
Path: CheckinSuccessfulEndpoint,
|
|
RawQuery: q.Encode(),
|
|
}).String()
|
|
}
|
|
|
|
func BuildFailureURL(msgType, description string) string {
|
|
q := url.Values{}
|
|
if msgType == "" {
|
|
msgType = ResultError
|
|
}
|
|
if description == "" {
|
|
description = "Transaction failed"
|
|
}
|
|
|
|
log.WithFields(log.Fields{LogFieldError: msgType, LogFieldDescription: description}).
|
|
Error("Transaction failed")
|
|
|
|
q.Set("MsgType", msgType)
|
|
q.Set("Description", description)
|
|
return (&url.URL{
|
|
Path: CheckinUnsuccessfulEndpoint,
|
|
RawQuery: q.Encode(),
|
|
}).String()
|
|
}
|