feat: add remaining api endpoints for comgate

This commit is contained in:
Radek Goláň jr. 2025-09-04 17:00:18 +02:00
parent 90fe4ac2ad
commit cf35d48a2c
Signed by: shield
GPG Key ID: 863526835ABF7BBD
3 changed files with 431 additions and 11 deletions

216
.gitignore vendored Normal file
View File

@ -0,0 +1,216 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml

View File

@ -5,7 +5,20 @@ from typing import final
from requests import Session from requests import Session
from requests.auth import HTTPBasicAuth from requests.auth import HTTPBasicAuth
from .models import Country, Currency, Payment, PaymentMethod, PaymentRequest from .models import (
Cancellation,
Country,
Currency,
Payment,
PaymentMethod,
PaymentRequest,
RecurringRequest,
RefundRequest,
Status,
Recurring,
Refund,
Capture,
)
@final @final
@ -26,7 +39,9 @@ class Comgate:
self.base_url = "https://payments.comgate.cz" self.base_url = "https://payments.comgate.cz"
self.create_endpoint = "/v2.0/payment.json" self.create_endpoint = "/v2.0/payment.json"
self.recurring_endpoint = "/v2.0/recurring.json"
self.payment_endpoint = "/v2.0/payment/transId/%s.json" self.payment_endpoint = "/v2.0/payment/transId/%s.json"
self.preauth_endpoint = "/v2.0/preauth/transId/%s.json"
self.refund_endpoint = "/v2.0/refund.json" self.refund_endpoint = "/v2.0/refund.json"
def create( def create(
@ -35,21 +50,37 @@ class Comgate:
price: int, price: int,
currency: Currency, currency: Currency,
label: str, label: str,
refId: str, ref_id: str,
fullName: str, full_name: str,
email: str | None = None, email: str | None = None,
phone: str | None = None, phone: str | None = None,
method: PaymentMethod = PaymentMethod.ALL, method: PaymentMethod = PaymentMethod.ALL,
**kwargs, # pyright: ignore[reportMissingParameterType, reportUnknownParameterType] **kwargs, # pyright: ignore[reportMissingParameterType, reportUnknownParameterType]
) -> Payment: ) -> Payment:
"""Create a Payment Request
Args:
country (Country): Customer country. Determines what payment methods will be offered.
price (int): Price in cents.
currency (Currency): Currency of the payment
label (str): Short product description (up to 16 characters)
ref_id (str): Reference
full_name (str): Name of the Payer
email (str | None, optional): Payer's email - Required if phone is not provided. Defaults to None.
phone (str | None, optional): Payer's phone - Required if email is not provided. Defaults to None.
method (PaymentMethod, optional): Restrict to specific payment methods by type. Defaults to PaymentMethod.ALL.
Returns:
Payment: Structured response from the Comgate API
"""
request = PaymentRequest( request = PaymentRequest(
test=self.test, test=self.test,
country=country, country=country,
price=price * 100, price=price * 100,
curr=currency, curr=currency,
label=label, label=label,
refId=refId, refId=ref_id,
fullName=fullName, fullName=full_name,
email=email, email=email,
phone=phone, phone=phone,
method=method, method=method,
@ -62,3 +93,130 @@ class Comgate:
if not response.ok: if not response.ok:
logging.root.error("Failed Comgate Request: %s", request.model_dump_json()) logging.root.error("Failed Comgate Request: %s", request.model_dump_json())
return Payment.model_validate_json(response.content) return Payment.model_validate_json(response.content)
def cancel(self, trans_id: str) -> Cancellation:
"""Cancel a payment that is yet to be acquired
Args:
transId (str): Payment (transaction) ID
Returns:
Cancellation: Structured response from the Comgate API
"""
response = self.session.delete(
self.base_url + (self.payment_endpoint % trans_id)
)
if not response.ok:
logging.root.error("Failed Comgate Request: DELETE %s", trans_id)
return Cancellation.model_validate_json(response.content)
def status(self, trans_id: str) -> Status:
"""Get payment status
Args:
trans_id (str): Payment (transaction) ID
Returns:
Status: Structured response from the Comgate API
"""
response = self.session.get(self.base_url + (self.payment_endpoint % trans_id))
if not response.ok:
logging.root.error("Failed Comgate Request: GET %s", trans_id)
return Status.model_validate_json(response.content)
def recurring(
self,
price: int,
curr: Currency,
label: str,
ref_id: str,
init_recurring_id: str,
**kwargs, # pyright: ignore[reportMissingParameterType, reportUnknownParameterType]
) -> Recurring:
"""Initiate a charge to previously saved payment details
Args:
price (int): Price in cents
curr (Currency): Currency of the payment
label (str): Short product description (up to 16 chars)
ref_id (str): Reference
init_recurring_id (str): ID of the transaction where details were saved
Returns:
Recurring: Structured response from the Comgate API
"""
request = RecurringRequest(
test=self.test,
price=price,
curr=curr,
label=label,
refId=ref_id,
initRecurringId=init_recurring_id,
**kwargs,
)
response = self.session.post(
self.base_url + self.recurring_endpoint,
json=json.loads(request.model_dump_json()), # pyright: ignore[reportAny]
)
if not response.ok:
logging.root.error("Failed Comgate Request: %s", request.model_dump_json())
return Recurring.model_validate_json(response.content)
def refund(self, trans_id: str, amount: int, ref_id: str | None = None) -> Refund:
"""Initiate a refund
Args:
trans_id (str): Transaction to refund
amount (int): Amount to be refunded
ref_id (str | None, optional): Reference. Defaults to None.
Returns:
Refund: Structured response from the Comgate API
"""
request = RefundRequest(
test=self.test, transId=trans_id, amount=amount, refId=ref_id
)
response = self.session.post(
self.base_url + self.refund_endpoint,
json=json.loads(request.model_dump_json()), # pyright: ignore[reportAny]
)
if not response.ok:
logging.root.error("Failed Comgate Request: %s", request.model_dump_json())
return Refund.model_validate_json(response.content)
def capture(self, trans_id: str, amount: int) -> Capture:
"""Capture a pre-authorized payment
Args:
trans_id (str): Pre-authorizing payment ID
amount (int): Amount to capture
Returns:
Capture: Structured response from the Comgate API
"""
response = self.session.put(
self.base_url + (self.preauth_endpoint % trans_id), json={"amount": amount}
)
if not response.ok:
logging.root.error("Failed Comgate Request: PUT %s, %s", trans_id, amount)
return Capture.model_validate_json(response.content)
def cancel_preauth(
self,
trans_id: str,
) -> Capture:
"""Cancel a pre-authorized payment
Args:
trans_id (str): Pre-authorizing payment ID
Returns:
Capture: Structured response from the Comgate API
"""
response = self.session.delete(
self.base_url + (self.preauth_endpoint % trans_id)
)
if not response.ok:
logging.root.error("Failed Comgate Request: DELETE %s", trans_id)
return Capture.model_validate_json(response.content)

View File

@ -171,7 +171,7 @@ class PaymentCode(IntEnum):
class Payment(BaseModel): class Payment(BaseModel):
code: PaymentCode code: PaymentCode | int
message: str message: str
transId: str | None = None transId: str | None = None
redirect: HttpUrl | None = None redirect: HttpUrl | None = None
@ -183,7 +183,7 @@ class CancellationCode(IntEnum):
class Cancellation(BaseModel): class Cancellation(BaseModel):
code: CancellationCode code: CancellationCode | int
message: str message: str
@ -233,7 +233,7 @@ class PaymentErrReason(Enum):
class Status(BaseModel): class Status(BaseModel):
code: StatusCode code: StatusCode | int
message: str message: str
test: bool test: bool
price: int price: int
@ -241,7 +241,7 @@ class Status(BaseModel):
label: str label: str
refId: str refId: str
payerId: str | None = None payerId: str | None = None
method: PaymentMethod | None = None method: PaymentMethod | str | None = None
account: str | None = None account: str | None = None
email: EmailStr email: EmailStr
name: str | None = None name: str | None = None
@ -259,6 +259,34 @@ class Status(BaseModel):
paymentErrorReason: PaymentErrReason | None = None paymentErrorReason: PaymentErrReason | None = None
class RecurringRequest(BaseModel):
test: bool = False
price: int
curr: Currency
label: str = Field(max_length=16, min_length=1)
refId: str
account: str | None = None
name: str | None = None
initRecurringId: str
@model_validator(mode="after")
def _check_price(self):
v_min, v_max = PRICE_RANGE[self.curr]
if self.price not in range(v_min, v_max):
raise ValueError(
f"Unsupported price value: {self.price}, it must be between {v_min} amd {v_max}"
)
return self
class Recurring(BaseModel):
code: PaymentCode | int
message: str
transId: str | None = None
class RefundRequest(BaseModel): class RefundRequest(BaseModel):
transId: str transId: str
amount: int amount: int
@ -271,11 +299,29 @@ class RefundCode(IntEnum):
UNKNOWN_ERROR = 1100 UNKNOWN_ERROR = 1100
DATABASE_ERROR = 1200 DATABASE_ERROR = 1200
WRONG_QUERY = 1400 WRONG_QUERY = 1400
PAYMENT_CANCELLED = 1401 PAYMENT_CANCELLE = 1401
AMOUNT_TOO_HIGH = 1402 AMOUNT_TOO_HIGH = 1402
BAD_AMOUNT = 1405
UNEXPECTED_ERROR = 1500 UNEXPECTED_ERROR = 1500
class Refund(BaseModel): class Refund(BaseModel):
code: RefundCode code: RefundCode | int
message: str
class CaptureCode(IntEnum):
OK = 0
UNKNOWN_ERROR = 1100
UNABLE_TO_LOAD_PAYMENT = 1104
DATABASE_ERROR = 1200
UNKNOWN_SHOP = 1301
MISSING_LINK = 1303
UNEXPECTED_DB_RESULT = 1399
WRONG_QUERY = 1400
UNEXPECTED_ERROR = 1500
class Capture(BaseModel):
code: CaptureCode | int
message: str message: str