diff --git a/liqpy/api.py b/liqpy/api.py index d17cf5e..c2cac80 100644 --- a/liqpy/api.py +++ b/liqpy/api.py @@ -2,18 +2,19 @@ from functools import singledispatchmethod from enum import Enum +from dataclasses import asdict from urllib.parse import urljoin from base64 import b64encode, b64decode from hashlib import sha1 -from json import dumps, loads, JSONEncoder +from json import loads, JSONEncoder from uuid import UUID from decimal import Decimal from datetime import date, datetime, UTC from .data import FiscalItem, DetailAddenda, SplitRule -from .convert import to_datetime, to_milliseconds +from .preprocess import Preprocessor, BasePreprocessor from .validation import Validator, BaseValidator if TYPE_CHECKING: @@ -59,6 +60,18 @@ def url(self) -> str: class LiqPayJSONEncoder(JSONEncoder): date_fmt = r"%Y-%m-%d %H:%M:%S" + def __init__(self) -> None: + super().__init__( + skipkeys=False, + ensure_ascii=True, + check_circular=True, + allow_nan=False, + sort_keys=False, + indent=None, + separators=None, + default=None, + ) + @singledispatchmethod def default(self, o): return super().default(o) @@ -85,35 +98,15 @@ def _(self, o: UUID) -> str: @default.register def _(self, o: DetailAddenda) -> str: - return encode( - { - "airLine": o.air_line, - "ticketNumber": o.ticket_number, - "passengerName": o.passenger_name, - "flightNumber": o.flight_number, - "originCity": o.origin_city, - "destinationCity": o.destination_city, - "departureDate": o.departure_date.strftime(r"%d%m%y"), - } - ) + return b64encode(self.encode(o.to_dict()).encode()).decode() @default.register def _(self, o: SplitRule) -> dict: - return { - "public_key": o.public_key, - "amount": o.amount, - "commission_payer": o.commission_payer, - "server_url": o.server_url, - } + return asdict(o) @default.register def _(self, o: FiscalItem) -> dict: - return { - "id": o.id, - "amount": o.amount, - "cost": o.cost, - "price": o.price, - } + return asdict(o) def is_sandbox(key: str, /) -> bool: @@ -204,7 +197,13 @@ def sign(data: bytes, /, key: bytes) -> bytes: def encode( - params: "LiqpayRequestDict", /, *, validator: type[BaseValidator] = Validator + params: "LiqpayRequestDict", + /, + *, + filter_none: bool = True, + validator: Optional[BaseValidator] = None, + encoder: Optional[JSONEncoder] = None, + preprocessor: Optional[BasePreprocessor] = None, ) -> bytes: """ Encode parameters into base64 encoded JSON. @@ -212,32 +211,23 @@ def encode( >>> encode({"action": "status", "version": 3}) b'eyJhY3Rpb24iOiAic3RhdHVzIiwgInZlcnNpb24iOiAzfQ==' """ - validator()(params) - - dae = params.get("dae") - if isinstance(dae, dict): - params["dae"] = DetailAddenda(**dae) - - split_rules = params.get("split_rules") - if split_rules is not None and isinstance(split_rules, list): - params["split_rules"] = dumps(split_rules, cls=LiqPayJSONEncoder) - - paytypes = params.get("paytypes") - if paytypes is not None and isinstance(paytypes, list): - params["paytypes"] = ",".join(paytypes) - - s = dumps( - obj=params, - skipkeys=False, - ensure_ascii=True, - check_circular=True, - indent=None, - allow_nan=False, - separators=None, - sort_keys=False, - cls=LiqPayJSONEncoder, - ) - return b64encode(s.encode()) + if filter_none: + params = {key: value for key, value in params.items() if value is not None} + + if encoder is None: + encoder = LiqPayJSONEncoder() + + if preprocessor is None: + preprocessor = Preprocessor() + + preprocessor(params, encoder=encoder) + + if validator is None: + validator = Validator() + + validator(params) + + return b64encode(encoder.encode(params).encode()) def decode(data: bytes, /) -> dict[str, Any]: @@ -259,43 +249,23 @@ def request( >>> request("status", key="...", order_id="a1a1a1a1") {'action': 'status', 'public_key': '...', 'version': 3, 'order_id': 'a1a1a1a1'} """ - params = {k: v for k, v in params.items() if v is not None} params.update(action=action, public_key=public_key, version=version) match action: - case "reports": - params.update( - date_from=to_milliseconds(params["date_from"]), - date_to=to_milliseconds(params["date_to"]), - ) - return params - - case "status" | "invoice_cancel" | "unsubscribe" | "refund" | "data": - return params - - case "auth": - if params.get("verifycode", False): - params["verifycode"] = "Y" - case "subscribe": subscribe_date_start = params.get("subscribe_date_start") if subscribe_date_start is None: subscribe_date_start = datetime.now(UTC) + + assert "subscribe_periodicity" in params, "subscribe_periodicity is required" params.update( - subscribe=1, - subscribe_date_start=to_datetime(subscribe_date_start), + subscribe=True, + subscribe_date_start=subscribe_date_start, ) - + case "letter_of_credit": - params.update( - letter_of_credit=1, - letter_of_credit_date=to_datetime(params.get("letter_of_credit_date")), - ) - - if params.get("recurringbytoken", False): - assert "server_url" in params, "server_url must be specified" - params["reccuringbytoken"] = "1" + params["letter_of_credit"] = True return params diff --git a/liqpy/client.py b/liqpy/client.py index 66e7f25..dbed7e6 100644 --- a/liqpy/client.py +++ b/liqpy/client.py @@ -1,7 +1,7 @@ -from typing import Optional, Literal, Union, TYPE_CHECKING, Unpack +from typing import Optional, Literal, Union, TYPE_CHECKING, Unpack, AnyStr from os import environ from logging import getLogger -from datetime import datetime +from datetime import datetime, timedelta from numbers import Number from re import search from uuid import UUID @@ -13,11 +13,17 @@ from .api import post, Endpoint, sign, request, encode, decode, VERSION, is_sandbox from .exceptions import exception_factory +from .data import LiqpayCallback if TYPE_CHECKING: - from .types.common import Language, Currency, SubscribePeriodicity + from json import JSONEncoder + + from .preprocess import BasePreprocessor + from .validation import BaseValidator + + from .types.common import Language, Currency, SubscribePeriodicity, PayOption from .types.request import Format, Language, LiqpayRequestDict - from .types.callback import CallbackDict + from .types.callback import LiqpayCallbackDict __all__ = ["Client"] @@ -61,6 +67,10 @@ class Client: _public_key: str _private_key: Secret[bytes] + validator: Optional["BaseValidator"] = None + preprocessor: Optional["BasePreprocessor"] = None + encoder: Optional["JSONEncoder"] = None + def __init__( self, /, @@ -68,10 +78,17 @@ def __init__( private_key: str | None = None, *, session: Session = None, + validator: Optional["BaseValidator"] = None, + preprocessor: Optional["BasePreprocessor"] = None, + encoder: Optional["JSONEncoder"] = None, ): self.update_keys(public_key=public_key, private_key=private_key) self.session = session + self.validator = validator + self.preprocessor = preprocessor + self.encoder = encoder + @property def public_key(self) -> str: """Public key used for requests.""" @@ -132,7 +149,7 @@ def __del__(self): def _callback( self, /, data: bytes, signature: bytes, *, verify: bool = True - ) -> "CallbackDict": + ) -> "LiqpayCallbackDict": if verify: self.verify(data, signature) else: @@ -160,7 +177,11 @@ def encode( See `liqpy.api.encode` for more information. """ data = encode( - request(action, public_key=self._public_key, version=VERSION, **kwargs) + request(action, public_key=self._public_key, version=VERSION, **kwargs), + filter_none=True, + validator=self.validator, + preprocessor=self.preprocessor, + encoder=self.encoder, ) signature = self.sign(data) @@ -196,20 +217,23 @@ def request(self, action: str, **kwargs: "LiqpayRequestDict") -> dict: raise exception_factory(response=response) data: dict = response.json() + result: Optional[Literal["ok", "error"]] = data.pop("result", None) status = data.get("status") + err_code = data.pop("err_code", data.pop("code", None)) - if result == "ok" or action in ("status", "data"): + if result == "ok" or (action in ("status", "data") and err_code is None): return data - elif status in ("error", "failure"): + + if status in ("error", "failure") or result == "error": raise exception_factory( - code=data.pop("err_code", None), + code=err_code, description=data.pop("err_description", None), response=response, details=data, ) - else: - return data + + return data def pay( self, @@ -245,6 +269,8 @@ def checkout( amount: Number, currency: "Currency", description: str, + expired_date: str | datetime | None = None, + paytypes: Optional[list["PayOption"]] = None, **kwargs: Unpack["LiqpayRequestDict"], ) -> str: """ @@ -257,13 +283,19 @@ def checkout( assert ( action in CHECKOUT_ACTIONS ), "Invalid action. Must be one of: %s" % ",".join(CHECKOUT_ACTIONS) - kwargs.update( - order_id=order_id, amount=amount, currency=currency, description=description - ) response = post( Endpoint.CHECKOUT, - *self.encode(action, **kwargs), + *self.encode( + action, + order_id=order_id, + amount=amount, + currency=currency, + description=description, + expired_date=expired_date, + paytypes=paytypes, + **kwargs, + ), session=self._session, allow_redirects=False, ) @@ -354,6 +386,7 @@ def subscribe( currency: "Currency", description: str, subscribe_periodicity: "SubscribePeriodicity", + subscribe_date_start: datetime | str | timedelta | None | Number, **kwargs: Unpack["LiqpayRequestDict"], ) -> dict: return self.request( @@ -366,6 +399,7 @@ def subscribe( card_exp_year=card_exp_year, currency=currency, description=description, + subscribe_date_start=subscribe_date_start, subscribe_periodicity=subscribe_periodicity, **kwargs, ) @@ -408,7 +442,7 @@ def status(self, order_id: str | UUID, /) -> dict: """ return self.request("status", order_id=order_id) - def callback(self, /, data: str, signature: str, *, verify: bool = True): + def callback(self, /, data: AnyStr, signature: AnyStr, *, verify: bool = True): """ Verify and decode the callback data. @@ -433,10 +467,19 @@ def callback(self, /, data: str, signature: str, *, verify: bool = True): [Documentation](https://www.liqpay.ua/en/documentation/api/callback) """ - result = self._callback(data.encode(), signature.encode(), verify=verify) + if isinstance(data, str): + data = data.encode() + + if isinstance(signature, str): + signature = signature.encode() + + result = self._callback(data, signature, verify=verify) version = result.get("version") if version != VERSION: logger.warning("Callback version mismatch: %s != %s", version, VERSION) - return result + try: + return LiqpayCallback(**result) + finally: + logger.warning("Failed to parse callback data.", extra=result) diff --git a/liqpy/convert.py b/liqpy/convert.py index d112a03..7f28a9b 100644 --- a/liqpy/convert.py +++ b/liqpy/convert.py @@ -1,7 +1,7 @@ from typing import overload, TYPE_CHECKING from functools import singledispatch, cache from numbers import Number -from datetime import datetime, UTC +from datetime import datetime, UTC, timedelta from re import compile @@ -29,8 +29,13 @@ def _(value: str, **kwargs): @to_datetime.register -def _(value: Number, **kwargs): - return datetime.fromtimestamp(float(value), tz=UTC) +def _(value: Number, tz=UTC, **kwargs): + return datetime.fromtimestamp(float(value), tz=tz) + + +@to_datetime.register +def _(value: timedelta, tz=UTC, **kwargs): + return datetime.now(tz) + value @singledispatch @@ -53,6 +58,11 @@ def _(value: str, **kwargs): return to_milliseconds(to_datetime(value, **kwargs)) +@to_milliseconds.register +def _(value: timedelta, **kwargs): + return to_milliseconds(to_datetime(value, **kwargs)) + + if TYPE_CHECKING: @overload @@ -67,6 +77,10 @@ def to_datetime(value: datetime, **kwargs) -> datetime: def to_datetime(value: str, **kwargs) -> datetime: ... + @overload + def to_datetime(value: timedelta, **kwargs) -> datetime: + ... + @overload def to_milliseconds(value: datetime, **kwargs) -> int: ... @@ -78,3 +92,7 @@ def to_milliseconds(value: str, **kwargs) -> int: @overload def to_milliseconds(value: int, **kwargs) -> int: ... + + @overload + def to_milliseconds(value: timedelta, **kwargs) -> int: + ... diff --git a/liqpy/data.py b/liqpy/data.py index 8d135c6..184304c 100644 --- a/liqpy/data.py +++ b/liqpy/data.py @@ -1,7 +1,20 @@ -from typing import Literal -from dataclasses import dataclass +from typing import Literal, TYPE_CHECKING, Optional +from dataclasses import dataclass, asdict from datetime import datetime from numbers import Number +from ipaddress import ip_address, IPv4Address + +from .convert import to_datetime + +if TYPE_CHECKING: + from .types.common import Currency, Language, PayType + from .types.callback import ThreeDS, CallbackAction + from .types.error import LiqPayErrcode + from .types import status + + +def from_milliseconds(value: int) -> datetime: + return datetime.fromtimestamp(value / 1000) @dataclass(kw_only=True) @@ -15,7 +28,19 @@ class DetailAddenda: departure_date: datetime def __post_init__(self): - self.departure_date = self.departure_date.astimezone() + if not isinstance(self.departure_date, datetime): + self.departure_date = to_datetime(self.departure_date) + + def to_dict(self): + return { + "airLine": self.air_line, + "ticketNumber": self.ticket_number, + "passengerName": self.passenger_name, + "flightNumber": self.flight_number, + "originCity": self.origin_city, + "destinationCity": self.destination_city, + "departureDate": self.departure_date.strftime(r"%d%m%y"), + } @dataclass(kw_only=True) @@ -38,3 +63,88 @@ class FiscalItem: class FiscalInfo: items: list[FiscalItem] delivery_emails: list[str] + + +@dataclass(init=False) +class LiqpayCallback: + acq_id: int + action: "CallbackAction" + agent_commission: Number + amount: Number + amount_bonus: Number + amount_credit: Number + amount_debit: Number + authcode_credit: str | None = None + authcode_debit: str | None = None + card_token: str | None = None + commission_credit: Number + commission_debit: Number + completion_date: datetime | None = None + create_date: datetime + currency: "Currency" + currency_credit: "Currency" + currency_debit: "Currency" + customer: str | None = None + description: str + end_date: datetime + err_code: Optional["LiqPayErrcode"] = None + err_description: str | None = None + info: str | None = None + ip: IPv4Address | None = None + is_3ds: bool + language: "Language" + liqpay_order_id: str + mpi_eci: "ThreeDS" + order_id: str + payment_id: int + paytype: "PayType" + public_key: str + receiver_commission: Number + redirect_to: str | None = None + refund_date_last: datetime | None = None + rrn_credit: str | None = None + rrn_debit: str | None = None + sender_bonus: Number + sender_card_bank: str + sender_card_country: int + sender_card_mask2: str + sender_card_type: str + sender_commission: Number + sender_first_name: str | None = None + sender_last_name: str | None = None + sender_phone: str | None = None + status: "status.CallbackStatus" + transaction_id: str | None = None + token: str | None = None + type: str + version: Literal[3] + err_erc: Optional["LiqPayErrcode"] = None + product_category: str | None = None + product_description: str | None = None + product_name: str | None = None + product_url: str | None = None + refund_amount: Number | None = None + verifycode: str | None = None + + code: str | None = None + + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + self.__post_init__() + + def __post_init__(self): + self.create_date = from_milliseconds(self.create_date) + self.end_date = from_milliseconds(self.end_date) + + if self.completion_date is not None: + self.completion_date = from_milliseconds(self.completion_date) + + if self.refund_date_last is not None: + self.refund_date_last = from_milliseconds(self.refund_date_last) + + if self.ip is not None: + self.ip = ip_address(self.ip) + + self.mpi_eci = int(self.mpi_eci) diff --git a/liqpy/exceptions.py b/liqpy/exceptions.py index e71c1dc..fb19a4e 100644 --- a/liqpy/exceptions.py +++ b/liqpy/exceptions.py @@ -17,6 +17,10 @@ UNKNOWN_ERRCODE = "unknown" UNKNOWN_ERRMSG = "Unknown error" +TRANSLATIONS = { + "Платеж не найден": "Payment not found", +} + class LiqPayException(Exception): code: "LiqPayErrcode" @@ -32,6 +36,8 @@ def __init__( response: Optional["Response"] = None, details: Optional[dict] = None, ): + description = TRANSLATIONS.get(description, description) + super().__init__(description or UNKNOWN_ERRMSG) self.code = code or UNKNOWN_ERRCODE self.response = response diff --git a/liqpy/preprocess.py b/liqpy/preprocess.py new file mode 100644 index 0000000..3f12fcc --- /dev/null +++ b/liqpy/preprocess.py @@ -0,0 +1,79 @@ +from typing import TYPE_CHECKING, Optional +from datetime import timedelta + +from .convert import to_datetime, to_milliseconds +from .data import DetailAddenda + +if TYPE_CHECKING: + from json import JSONEncoder + from .types.request import LiqpayRequestDict + + +class BasePreprocessor: + def __call__( + self, o: "LiqpayRequestDict", /, encoder: Optional["JSONEncoder"], **kwargs + ): + if encoder is None: + encoder = JSONEncoder() + + for key, value in o.items(): + try: + fn = getattr(self, key, None) + + if not callable(fn): + continue + + processed = fn(value, encoder=encoder, **kwargs.get(key, {})) + + if processed is not None: + o[key] = processed + + except Exception as e: + raise Exception(f"Failed to convert {key} parameter.") from e + + +class Preprocessor(BasePreprocessor): + def dae(self, value, /, **kwargs): + if isinstance(value, DetailAddenda): + return value + else: + return DetailAddenda(**value) + + def split_rules(self, value, /, encoder: "JSONEncoder", **kwargs): + if isinstance(value, list): + return encoder.encode(value) + + def paytypes(self, value, /, **kwargs): + if isinstance(value, list): + return ",".join(value) + + def date_from(self, value, /, **kwargs): + return to_milliseconds(value, **kwargs) + + def date_to(self, value, /, **kwargs): + return to_milliseconds(value, **kwargs) + + def subscribe_date_start(self, value, /, **kwargs): + return to_datetime(value, **kwargs) + + def letter_of_credit_date(self, value, /, **kwargs): + return to_datetime(value, **kwargs) + + def expired_date(self, value, /, **kwargs): + return to_datetime(value, **kwargs) + + def verifycode(self, value, /, **kwargs): + if value: + return "Y" + + def subscribe(self, value, /, **kwargs): + if value: + return 1 + + def letter_of_credit(self, value, /, **kwargs): + if value: + return 1 + + def recurringbytoken(self, value, /, **kwargs): + if value: + return "1" diff --git a/liqpy/server.py b/liqpy/server.py index 84b339d..75e9f32 100644 --- a/liqpy/server.py +++ b/liqpy/server.py @@ -6,7 +6,7 @@ from .client import Client if TYPE_CHECKING: - from .types import CallbackDict + from .types import LiqpayCallbackDict class LiqpayHandler(BaseHTTPRequestHandler): @@ -41,7 +41,7 @@ def _handle_webhook(self): data, signature = self._parse_body() return self.client.callback(data, signature, verify=True) - def _push_callback(self, callback: "CallbackDict"): + def _push_callback(self, callback: "LiqpayCallbackDict"): pprint(callback) self.server.callback_history.append(callback) @@ -58,7 +58,7 @@ def do_POST(self): class LiqpayServer(HTTPServer): client: "Client" - callback_history: List["CallbackDict"] + callback_history: List["LiqpayCallbackDict"] """Liqpay server for testing. Do not use in production!""" @@ -82,7 +82,7 @@ def __init__( self.allow_reuse_port = True @property - def last_callback(self) -> "CallbackDict": + def last_callback(self) -> "LiqpayCallbackDict": return self.callback_history[-1] diff --git a/liqpy/types/__init__.py b/liqpy/types/__init__.py index edb1584..f52bdad 100644 --- a/liqpy/types/__init__.py +++ b/liqpy/types/__init__.py @@ -2,5 +2,5 @@ from . import action from . import status -from .callback import CallbackDict -from .request import RequestDict, LiqpayRequestDict +from .callback import LiqpayCallbackDict +from .request import LiqpayRequestDict diff --git a/liqpy/validation.py b/liqpy/validation.py index 4d6a4c7..593d329 100644 --- a/liqpy/validation.py +++ b/liqpy/validation.py @@ -188,7 +188,7 @@ def paytype(self, value, /, **kwargs): "tavv", ), "paytype must be one of: apay, gpay, apay_tavv, gpay_tavv, tavv" - def paytypes(self, value, /, **kwargs): + def payoption(self, value, /, **kwargs): assert value in ( "apay", "gpay", @@ -201,6 +201,14 @@ def paytypes(self, value, /, **kwargs): "qr", ), "paytypes must be one of: apay, gpay, card, liqpay, moment_part, paypart, cash, invoice, qr" + def paytypes(self, value, /, **kwargs): + if isinstance(value, list): + for i, item in enumerate(value): + try: + self.payoption(item, **kwargs) + except AssertionError as e: + raise AssertionError(f"Invalid paytypes element {i}.") from e + def customer(self, value, /, **kwargs): string(value, max_len=100) @@ -281,6 +289,7 @@ def server_url(self, value, /, **kwargs): url(value, max_len=510) def result_url(self, value, /, **kwargs): + # string(value, max_len=510) url(value, max_len=510) def product_url(self, value, /, **kwargs): diff --git a/readme.ipynb b/readme.ipynb index bb694b5..648ec92 100644 --- a/readme.ipynb +++ b/readme.ipynb @@ -31,7 +31,7 @@ "%autoreload 2\n", "\n", "%load_ext dotenv\n", - "%dotenv" + "%dotenv -o" ] }, { @@ -185,6 +185,7 @@ " card_cvv=gen_card_cvv(),\n", " )\n", "except LiqPayException as e:\n", + " print(e.code, e)\n", " print(e.response)\n", " pprint(e.details)\n", " raise e" @@ -222,28 +223,6 @@ "client.data(order_id, \"Lorem Ipsum\")" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Create checkout link" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "client.checkout(\n", - " \"pay\",\n", - " amount=1,\n", - " order_id=str(uuid4()),\n", - " description=\"Test Checkout\",\n", - " currency=\"USD\",\n", - ")" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -260,22 +239,34 @@ "from liqpy.server import LiqpayServer\n", "from webbrowser import open_new_tab\n", "\n", + "action = \"auth\"\n", "order_id = uuid4()\n", + "\n", + "expire = timedelta(seconds=20)\n", + "timeout = 10 \n", + "timeout = (expire + timedelta(seconds=timeout)).total_seconds()\n", + "\n", + "server_url = getenv(\"SERVER_URL\") or None\n", + "\n", "checkout_url = client.checkout(\n", - " \"pay\",\n", + " action,\n", " amount=1,\n", " order_id=uuid4(),\n", - " description=\"Test Checkout\",\n", + " description=f\"test {action} checkout\",\n", " currency=\"USD\",\n", - " server_url=getenv(\"SERVER_URL\", \"\"),\n", - " expired_date=(datetime.now(UTC) + timedelta(seconds=30)),\n", + " expired_date=expire,\n", + " # subscribe_date_start=timedelta(days=7),\n", + " # subscribe_periodicity=\"month\",\n", + " result_url=\"https://example.com/result\",\n", + " server_url=server_url,\n", ")\n", "\n", + "print(\"checkout link\\n\", checkout_url)\n", "open_new_tab(checkout_url)\n", - "print(\"checkout link\", checkout_url)\n", "\n", - "with LiqpayServer(client=client, timeout=60) as server:\n", - " server.handle_request()" + "if server_url is not None:\n", + " with LiqpayServer(client=client, timeout=timeout) as server:\n", + " server.handle_request()" ] }, { @@ -344,8 +335,9 @@ " card_exp_month=card_exp_month,\n", " card_exp_year=card_exp_year,\n", " card_cvv=gen_card_cvv(),\n", - " phone=\"+380661234567\",\n", + " # phone=\"+380661234567\",\n", " subscribe_periodicity=\"month\",\n", + " subscribe_date_start=timedelta()\n", ")" ] }, @@ -364,13 +356,6 @@ "source": [ "client.unsubscribe(order_id=order_id)" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": {