210 lines
7.7 KiB
Python
210 lines
7.7 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import requests
|
|
from requests.auth import HTTPBasicAuth
|
|
|
|
|
|
@dataclass
|
|
class BankTerminalConfig:
|
|
terminal_type: str
|
|
url: str = ""
|
|
terminal_id: str = ""
|
|
sale_id: str = "Alto/foodw32"
|
|
user: str = ""
|
|
password: str = ""
|
|
currency: str = "CZK"
|
|
timeout: int = 60
|
|
log_path: str = "terminal_log.jsonl"
|
|
extra: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class BankTerminalResult:
|
|
success: bool
|
|
raw: dict[str, Any] | None = None
|
|
error: str = ""
|
|
service_id: str = ""
|
|
|
|
def legacy_dict(self) -> dict[str, Any]:
|
|
result: dict[str, Any] = {
|
|
"success": self.success,
|
|
"service_id": self.service_id,
|
|
}
|
|
if self.raw is not None:
|
|
result["raw"] = self.raw
|
|
if self.error:
|
|
result["error"] = self.error
|
|
return result
|
|
|
|
|
|
class BankTerminalClient:
|
|
def __init__(self, config: BankTerminalConfig):
|
|
self.config = config
|
|
|
|
def payment(self, amount: float) -> BankTerminalResult:
|
|
raise NotImplementedError
|
|
|
|
def abort(self, service_id: str) -> BankTerminalResult:
|
|
return BankTerminalResult(success=False, error="Abort nie je podporovany.", service_id=service_id)
|
|
|
|
def refund(self, original_service_id: str, amount: float) -> BankTerminalResult:
|
|
return BankTerminalResult(success=False, error="Refund nie je podporovany.", service_id=original_service_id)
|
|
|
|
def extract_receipt_text(self, response: dict[str, Any]) -> str:
|
|
return ""
|
|
|
|
def _log_transaction(self, data: dict[str, Any]) -> None:
|
|
log_path = Path(self.config.log_path or "terminal_log.jsonl")
|
|
with log_path.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps({
|
|
"time": datetime.utcnow().isoformat(),
|
|
"terminal_type": self.config.terminal_type,
|
|
"data": data,
|
|
}, ensure_ascii=False) + "\n")
|
|
|
|
|
|
class BesteronTerminalClient(BankTerminalClient):
|
|
def _message_header(self, category: str, service_id: str) -> dict[str, str]:
|
|
return {
|
|
"ProtocolVersion": "3.0",
|
|
"MessageClass": "Service",
|
|
"MessageCategory": category,
|
|
"MessageType": "Request",
|
|
"ServiceID": service_id,
|
|
"SaleID": self.config.sale_id,
|
|
"POIID": self.config.terminal_id,
|
|
}
|
|
|
|
def payment(self, amount: float) -> BankTerminalResult:
|
|
service_id = str(uuid.uuid4())[:10]
|
|
payload = {
|
|
"SaleToPOIRequest": {
|
|
"MessageHeader": self._message_header("Payment", service_id),
|
|
"PaymentRequest": {
|
|
"SaleData": {
|
|
"SaleTransactionID": {
|
|
"TransactionID": service_id,
|
|
"TimeStamp": datetime.utcnow().isoformat(),
|
|
}
|
|
},
|
|
"PaymentTransaction": {
|
|
"AmountsReq": {
|
|
"Currency": self.config.currency,
|
|
"RequestedAmount": float(amount),
|
|
}
|
|
},
|
|
},
|
|
}
|
|
}
|
|
try:
|
|
response = requests.post(
|
|
self.config.url,
|
|
json=payload,
|
|
auth=HTTPBasicAuth(self.config.user, self.config.password),
|
|
headers={"Content-Type": "application/json"},
|
|
timeout=self.config.timeout,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
self._log_transaction(data)
|
|
payment_response = data.get("SaleToPOIResponse", {}).get("PaymentResponse", {})
|
|
result = payment_response.get("Response", {}).get("Result")
|
|
return BankTerminalResult(success=result == "Success", raw=data, service_id=service_id)
|
|
except Exception as exc:
|
|
return BankTerminalResult(success=False, error=str(exc), service_id=service_id)
|
|
|
|
def abort(self, service_id: str) -> BankTerminalResult:
|
|
if not service_id:
|
|
return BankTerminalResult(success=False, error="Chyba service_id pre abort.")
|
|
payload = {
|
|
"SaleToPOIRequest": {
|
|
"MessageHeader": self._message_header("Abort", service_id),
|
|
"AbortRequest": {
|
|
"AbortReason": "MerchantAbort",
|
|
},
|
|
}
|
|
}
|
|
try:
|
|
response = requests.post(
|
|
self.config.url,
|
|
json=payload,
|
|
auth=HTTPBasicAuth(self.config.user, self.config.password),
|
|
headers={"Content-Type": "application/json"},
|
|
timeout=min(self.config.timeout, 10),
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json() if response.text else {}
|
|
if data:
|
|
self._log_transaction(data)
|
|
return BankTerminalResult(success=True, raw=data, service_id=service_id)
|
|
except Exception as exc:
|
|
return BankTerminalResult(success=False, error=str(exc), service_id=service_id)
|
|
|
|
def refund(self, original_service_id: str, amount: float) -> BankTerminalResult:
|
|
service_id = str(uuid.uuid4())[:10]
|
|
payload = {
|
|
"SaleToPOIRequest": {
|
|
"MessageHeader": self._message_header("Reversal", service_id),
|
|
"ReversalRequest": {
|
|
"OriginalPOITransaction": {
|
|
"POITransactionID": {
|
|
"TransactionID": original_service_id,
|
|
}
|
|
},
|
|
"ReversedAmount": float(amount),
|
|
},
|
|
}
|
|
}
|
|
try:
|
|
response = requests.post(
|
|
self.config.url,
|
|
json=payload,
|
|
auth=HTTPBasicAuth(self.config.user, self.config.password),
|
|
headers={"Content-Type": "application/json"},
|
|
timeout=min(self.config.timeout, 30),
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
self._log_transaction(data)
|
|
reversal_response = data.get("SaleToPOIResponse", {}).get("ReversalResponse", {})
|
|
result = reversal_response.get("Response", {}).get("Result")
|
|
return BankTerminalResult(success=result in ("Success", None), raw=data, service_id=service_id)
|
|
except Exception as exc:
|
|
return BankTerminalResult(success=False, error=str(exc), service_id=service_id)
|
|
|
|
def extract_receipt_text(self, response: dict[str, Any]) -> str:
|
|
receipts = response.get("PaymentReceipt")
|
|
if receipts is None:
|
|
receipts = (
|
|
response.get("SaleToPOIResponse", {})
|
|
.get("PaymentResponse", {})
|
|
.get("PaymentReceipt", [])
|
|
)
|
|
lines: list[str] = []
|
|
for receipt in receipts:
|
|
content = receipt.get("OutputContent", {}).get("OutputText", [])
|
|
lines.extend(str(line.get("Text", "")) for line in content if line.get("Text"))
|
|
return "\n".join(lines)
|
|
|
|
|
|
class UnsupportedBankTerminalClient(BankTerminalClient):
|
|
def payment(self, amount: float) -> BankTerminalResult:
|
|
return BankTerminalResult(
|
|
success=False,
|
|
error=f"Terminal {self.config.terminal_type or '-'} zatial nie je implementovany.",
|
|
)
|
|
|
|
|
|
def create_bank_terminal_client(config: BankTerminalConfig) -> BankTerminalClient:
|
|
terminal_type = (config.terminal_type or "").strip().upper()
|
|
if terminal_type == "BESTERON":
|
|
return BesteronTerminalClient(config)
|
|
return UnsupportedBankTerminalClient(config)
|