diff --git a/src/unzer/__init__.py b/src/unzer/__init__.py index 0855267..e92b9c1 100644 --- a/src/unzer/__init__.py +++ b/src/unzer/__init__.py @@ -1,7 +1,7 @@ __title__ = "unzer-sdk" __author__ = "Sven Eberth" __email__ = "se@mausbrand.de" -__version__ = "1.4.0" +__version__ = "1.5.0" from .client import UnzerClient from .model import * diff --git a/src/unzer/client.py b/src/unzer/client.py index ff2d00b..8969c66 100644 --- a/src/unzer/client.py +++ b/src/unzer/client.py @@ -2,6 +2,7 @@ import time import typing as t from types import NoneType +from urllib.parse import urlencode import requests from urllib3.exceptions import TimeoutError @@ -9,8 +10,11 @@ from . import __version__ from .model import * from .model.basket import Basket +from .model.installment_plans import InstallmentPlans from .model.payment import PaymentGetResponse, PaymentRequest, PaymentResponse +from .model.payment_type import PaylaterInstallment from .model.paymentpage import PaymentPage, PaymentPageResponse +from .model.risk_check import RiskCheckResponse from .model.webhook import Webhook logger = logging.getLogger("unzer-sdk").getChild(__name__) @@ -19,9 +23,17 @@ class UnzerClient: - endpoint = "https://api.unzer.com/v1" + endpoint = "https://api.unzer.com" + """Base URL of the API, without the version segment.""" + + apiVersion = "v1" + """Default API version, used unless a request asks for another one.""" + retryDelays = (1, 2, 4, 8) + """Delays in seconds between the retries of a failed request.""" + timeout = 5 + """Timeout in seconds of a single request.""" def __init__( self, @@ -29,12 +41,27 @@ def __init__( public_key: str, sandbox: bool = False, language: str = "en", + client_ip: str = None, ): + """Create a new client for the unzer-api. + + :param private_key: The private key of the keypair. + :param public_key: The public key of the keypair. + :param sandbox: (optional) Use the sandbox environment. + :param language: (optional) Language for translations of customer messages. + :param client_ip: (optional) IP address of the customer. + Sent as ``CLIENTIP`` header with every request. + Required by the Pay later payment methods (e.g. installment) + for their risk checks. + The API documentation names this header ``x-CLIENTIP``, + but both the PHP and the Java SDK send it as ``CLIENTIP``. + """ super(UnzerClient, self).__init__() self.private_key = private_key self.public_key = public_key self.sandbox = sandbox self.language = language + self.client_ip = client_ip def request( self, @@ -42,6 +69,7 @@ def request( method: HttpMethod, payload: t.Any = None, additional_headers: dict[str, str] = None, + api_version: str = None, ) -> t.Any: """Perform a request to the unzer-api. @@ -53,15 +81,21 @@ def request( :param payload: The payload for this request. Send json-encoded as body. :param additional_headers: Additional headers for this request. + :param api_version: (optional) The API version to use for this request. + Defaults to :attr:`apiVersion` (``v1``). + Some resources are only available in a newer version + (e.g. baskets for the Pay later payment methods). :return: The json-decoded response from the api. """ - url = "%s/%s" % (self.endpoint, operation) + url = "%s/%s/%s" % (self.endpoint, api_version or self.apiVersion, operation) headers = { "user-agent": "unzer-python-sdk %s" % __version__, "content-type": "application/json; charset=UTF-8", "accept": "application/json", "accept-language": self.language, # language for translation of customerMessage in errors } + if self.client_ip: + headers["CLIENTIP"] = self.client_ip if additional_headers: headers |= additional_headers return self._request( @@ -279,8 +313,9 @@ def createBasket(self, basket): "baskets", "POST", basket.serialize(), + api_version=basket.apiVersion, ) - return self.getBasket(data["id"]) + return self.getBasket(data["id"], api_version=basket.apiVersion) def updateBasket(self, basket): """Update a basket. @@ -299,20 +334,24 @@ def updateBasket(self, basket): "baskets/%s" % basket.key, "PUT", basket.serialize(), + api_version=basket.apiVersion, ) - return self.getBasket(data["id"]) + return self.getBasket(data["id"], api_version=basket.apiVersion) - def getBasket(self, basketId): + def getBasket(self, basketId, api_version: str = None): """Fetch a basket. :param basketId: basket's id (key) :type basketId: str + :param api_version: (optional) The API version of the basket schema to fetch. + A basket created with the v3 schema should also be fetched with ``v3``. :return: The fetched basket object :rtype: Basket """ data = self.request( "baskets/%s" % basketId, "GET", + api_version=api_version, ) return Basket.fromDict(data) @@ -330,12 +369,106 @@ def createPaymentType(self, paymentType): raise TypeError("Expected a PaymentType object. Got %r" % type(paymentType)) paymentType.validateBeforeRequest() data = self.request( - "types/%s" % paymentType.method, + "types/%s" % paymentType.method_name.value, "POST", paymentType.serialize(), ) return type(paymentType).fromDict(data) + def getPaylaterInstallmentPlans( + self, + amount: float, + currency: str, + country: str, + customerType: str = None, + orderId: str = None, + startDateOfPurchase: str = None, + endDateOfPurchase: str = None, + nominalInterest: str = None, + ) -> InstallmentPlans: + """Fetch the available installment plans for a purchase. + + This is the first step of an installment payment: the plans must be presented to + the customer, and the :attr:`~unzer.model.InstallmentPlans.inquiryId` of the + response is required to create the + :class:`~unzer.model.PaylaterInstallment` payment type. + + .. seealso:: https://docs.unzer.com/payment-methods/installment/accept-unzer-installment-server-side-only-integration/ # noqa: E501 + + :param amount: Total amount of the purchase. + :param currency: ISO currency code of the transaction (``EUR`` or ``CHF``). + :param country: The customer's country in ISO 3166 ALPHA-2 format (e.g. ``DE``). + :param customerType: (optional) ``B2C`` (``B2B`` is not available yet). + :param orderId: (optional) Order id that identifies the payment on merchant side. + :param startDateOfPurchase: (optional) Start date of the purchase. + :param endDateOfPurchase: (optional) End date of the purchase. + :param nominalInterest: (optional) Nominal interest rate as percentage. + :return: The available plans + """ + query = { + "amount": amount, + "currency": currency, + "country": country, + } + for key, value in ( + ("customerType", customerType), + ("orderId", orderId), + ("startDateOfPurchase", startDateOfPurchase), + ("endDateOfPurchase", endDateOfPurchase), + ("nominalInterest", nominalInterest), + ): + if value is not None: + query[key] = value + data = self.request( + "types/%s/plans?%s" % (PaylaterInstallment.method_name.value, urlencode(query)), + "GET", + ) + return InstallmentPlans.fromDict(data) + + def riskCheckPaylaterInstallment( + self, + payment: PaymentRequest, + client_ip: str = None, + ) -> RiskCheckResponse: + """Perform a risk check for an installment payment. + + This optional call evaluates the customer data before the order is placed, + so the customer gets the feedback before finishing the checkout. + It is not part of the payment process itself. + + The request requires the customer, basket and paymentType resources + of the intended payment, therefore it takes the same + :class:`~unzer.model.PaymentRequest` as :meth:`authorize`. + + .. note:: + The endpoint is part of the API reference (``/v1/types/paylater-installment/risk-check``), + but implemented in neither the PHP nor the Java SDK, + so the payload could only be taken from the documentation. + + .. seealso:: https://docs.unzer.com/payment-methods/installment/accept-unzer-installment-server-side-only-integration/ # noqa: E501 + + :param payment: The PaymentRequest model of the intended payment. + :param client_ip: (optional) IP address of the customer, + sent as ``CLIENTIP`` header. Falls back to the client's + :attr:`client_ip`, which is required by this endpoint. + :return: The result of the risk check + :raises ErrorResponse: If the risk check was declined. + """ + if not isinstance(payment, PaymentRequest): + raise TypeError("Expected a PaymentRequest object. Got %r" % type(payment)) + if not payment.paymentType or not payment.paymentType.key: + raise ValueError("The paymentType must be created before the risk check") + payment.validateBeforeRequest() + data = self.request( + "types/%s/risk-check" % PaylaterInstallment.method_name.value, + "POST", + payment.serialize(), + additional_headers={"CLIENTIP": client_ip} if client_ip else None, + ) + if data.get("isError"): + raise ErrorResponse.fromDict(data) + return RiskCheckResponse.fromDict(data) + def createPaymentPage(self, paymentPage): """The initialize payment page call with direct charge purpose. diff --git a/src/unzer/model/__init__.py b/src/unzer/model/__init__.py index f98d2aa..44ce062 100644 --- a/src/unzer/model/__init__.py +++ b/src/unzer/model/__init__.py @@ -12,6 +12,7 @@ from .basketItem import BasketItem from .customer import Customer from .error import Error, ErrorResponse +from .installment_plans import InstallmentPlan, InstallmentPlans, InstallmentRate from .payment import ( Action, PaymentGetResponse, @@ -25,6 +26,7 @@ ) from .payment_type import * from .paymentpage import PaymentPage, PaymentPageResponse +from .risk_check import RiskCheckResponse from .webhook import Events, Webhook __all__ = [ @@ -43,6 +45,10 @@ # error "Error", "ErrorResponse", + # installment_plans + "InstallmentPlan", + "InstallmentPlans", + "InstallmentRate", # payment "Action", "PaymentGetResponse", @@ -55,18 +61,35 @@ "TransactionStatus", # payment_type "PaymentType", + "Alipay", "Applepay", "Bancontact", "Card", + "ClickToPay", + "DirectBankTransfer", + "Eps", "Googlepay", "Ideal", "Klarna", "PayPal", + "PayU", + "PaylaterDirectDebit", + "PaylaterInstallment", "PaylaterInvoice", + "PostFinanceCard", + "PostFinanceEfinance", + "Prepayment", + "Przelewy24", + "SepaDirectDebit", "Sofort", + "Twint", + "Wechatpay", + "Wero", # paymentpage "PaymentPage", "PaymentPageResponse", + # risk_check + "RiskCheckResponse", # webhook "Webhook", "Events", diff --git a/src/unzer/model/basket.py b/src/unzer/model/basket.py index ac1b1d7..ebb65b5 100644 --- a/src/unzer/model/basket.py +++ b/src/unzer/model/basket.py @@ -1,14 +1,32 @@ -from .base import BaseModel +import typing as t + +from .base import BaseModel, JSONValue from .basketItem import BasketItem +from ..utils import parseFloat class Basket(BaseModel): + """A basket resource. + + Unzer offers this resource in two incompatible schemas. Which one is used depends on + :attr:`totalValueGross`: as soon as it is set, the basket is sent to the v3 endpoint + with gross amounts, otherwise the v1 endpoint with :attr:`amountTotalGross` is used. + The basket items follow the same rule on their own + (see :class:`unzer.model.BasketItem`), so don't mix the schemas within one basket. + + The Pay later payment methods (e.g. :class:`unzer.model.PaylaterInstallment`) + require the v3 schema. + + Note that the v2 and v3 endpoints share the same schema, so the newer v3 is used here. + """ + def __init__( self, key=None, amountTotalGross=None, amountTotalVat=None, amountTotalDiscount=None, + totalValueGross=None, currencyCode=None, orderId=None, note=None, @@ -19,12 +37,15 @@ def __init__( :param key: (optional) :type key: str - :param amountTotalGross: (optional) + :param amountTotalGross: (optional) (v1) Total gross amount of the basket :type amountTotalGross: float - :param amountTotalVat: (optional) + :param amountTotalVat: (optional) (v1) :type amountTotalVat: float - :param amountTotalDiscount: (optional) + :param amountTotalDiscount: (optional) (v1) :type amountTotalDiscount: float + :param totalValueGross: (v3) Total gross amount of the basket. + Setting it switches this basket to the v3 schema. + :type totalValueGross: float :param currencyCode: (optional) example: EUR :type currencyCode: str :param orderId: example: s-bsk-XXX @@ -41,29 +62,48 @@ def __init__( self.amountTotalGross = amountTotalGross # type:float self.amountTotalVat = amountTotalVat # type:float self.amountTotalDiscount = amountTotalDiscount # type:float + self.totalValueGross = totalValueGross # type:float self.currencyCode = currencyCode # type:str self.orderId = orderId # type:str self.note = note # type:str self.basketItems = basketItems # type:list[BasketItem] - def serialize(self): - return { + def isV3(self) -> bool: + """Tell whether this basket uses the v3 schema, i.e. :attr:`totalValueGross`.""" + return self.totalValueGross is not None + + @property + def apiVersion(self) -> str: + """Provide the API version of the endpoint this basket has to be sent to.""" + return "v3" if self.isV3() else "v1" + + def serialize(self) -> dict[str, JSONValue]: + """Serialize this basket in the schema implied by :meth:`isV3`.""" + data = { "id": self.key, - "amountTotalGross": self.amountTotalGross, - "amountTotalVat": self.amountTotalVat, - "amountTotalDiscount": self.amountTotalDiscount, "currencyCode": self.getString(self.currencyCode), "orderId": self.getString(self.orderId), + # note is missing from the v3 schema of the API reference, but both the + # documented v3 example and the PHP SDK's v2 model do have it "note": self.getString(self.note), "basketItems": [bi.serialize() for bi in self.basketItems], } + if self.isV3(): + data["totalValueGross"] = self.totalValueGross + else: + data |= { + "amountTotalGross": self.amountTotalGross, + "amountTotalVat": self.amountTotalVat, + "amountTotalDiscount": self.amountTotalDiscount, + } + return data @classmethod - def fromDict(cls, data): + def fromDict(cls, data: dict[str, JSONValue]) -> t.Self: + """Unserialize a basket of either schema; missing amounts stay ``None``.""" data = data.copy() data["key"] = data["id"] - data["basketItems"] = [BasketItem.fromDict(basketItem) for basketItem in data["basketItems"]] - data["amountTotalGross"] = float(data["amountTotalGross"]) - data["amountTotalVat"] = float(data["amountTotalVat"]) - data["amountTotalDiscount"] = float(data["amountTotalDiscount"]) + data["basketItems"] = [BasketItem.fromDict(basketItem) for basketItem in data.get("basketItems") or []] + for key in ("amountTotalGross", "amountTotalVat", "amountTotalDiscount", "totalValueGross"): + data[key] = parseFloat(data.get(key)) return cls(**data) diff --git a/src/unzer/model/basketItem.py b/src/unzer/model/basketItem.py index 4fdbaed..fc6e0aa 100644 --- a/src/unzer/model/basketItem.py +++ b/src/unzer/model/basketItem.py @@ -1,4 +1,7 @@ -from .base import BaseModel +import typing as t + +from .base import BaseModel, JSONValue +from ..utils import parseFloat class BasketItem(BaseModel): @@ -13,6 +16,8 @@ def __init__( amountVat=None, amountPerUnit=None, amountNet=None, + amountPerUnitGross=None, + amountDiscountPerUnitGross=None, title=None, subTitle=None, imageUrl=None, @@ -22,27 +27,36 @@ def __init__( ): """Create a new BasketItem. + The amount attributes come in two flavours, see :class:`unzer.model.Basket`: + :attr:`amountPerUnitGross` and :attr:`amountDiscountPerUnitGross` belong to the + v3 schema, the remaining ``amount*`` attributes to the v1 schema. + :param basketItemReferenceId: (optional) Unique basket item reference ID (within the basket) :type basketItemReferenceId: str :param unit: (optional) Unit description of the item e.g. "pc" :type unit: str :param quantity: Integer Quantity of the basket item format: int32 :type quantity: int - :param amountDiscount: (optional) Discount amount for the basket item + :param amountDiscount: (optional) (v1) Discount amount for the basket item (multiplied by the :attr:`quantity`) format: float :type amountDiscount: float :param vat: (optional) Integer Vat value for the basket item in percent (0-100) format: int32 :type vat: int - :param amountGross: (optional) Gross amount (= amountNet + amountVat) in the specified currency. + :param amountGross: (optional) (v1) Gross amount (= amountNet + amountVat) in the specified currency. Equals amountNet if vat value is 0 format: float :type amountGross: float - :param amountVat: (optional) Vat amount. Equals 0 if vat value is 0. + :param amountVat: (optional) (v1) Vat amount. Equals 0 if vat value is 0. Should equal the :attr:`vat` multiplied by :attr:`amountNet` for each basket item. format: float :type amountVat: float - :param amountPerUnit: NET amount per unit format: float + :param amountPerUnit: (v1) NET amount per unit format: float :type amountPerUnit: float - :param amountNet: (optional) Net amount. Equals amountGross if vat value is 0. format: float + :param amountNet: (optional) (v1) Net amount. Equals amountGross if vat value is 0. format: float :type amountNet: str + :param amountPerUnitGross: (v3) GROSS amount per unit. + Setting it switches this item to the v3 schema. format: float + :type amountPerUnitGross: float + :param amountDiscountPerUnitGross: (optional) (v3) GROSS discount amount per unit format: float + :type amountDiscountPerUnitGross: float :param title: Title of the basket item (max. 255) :type title: str :param subTitle: (optional) The defined subTitle which is displayed on our Payment Page later on @@ -50,7 +64,7 @@ def __init__( :param imageUrl: (optional) The defined imageUrl for the related basketItem and will be displayed on our Payment Page :type imageUrl: str - :param participantId: (optional) Only valid for marketplace payment: + :param participantId: (optional) (v1) Only valid for marketplace payment: Channel Id(s) of marketplace's participant(s). :type participantId: str :param kind: (original: type) (optional) @@ -66,38 +80,63 @@ def __init__( self.amountVat = amountVat self.amountPerUnit = amountPerUnit self.amountNet = amountNet + self.amountPerUnitGross = amountPerUnitGross + self.amountDiscountPerUnitGross = amountDiscountPerUnitGross self.title = title self.subTitle = subTitle self.imageUrl = imageUrl self.participantId = participantId self.kind = kind - def serialize(self): - return { + def isV3(self) -> bool: + """Tell whether this item uses the v3 schema, i.e. gross amounts per unit.""" + return self.amountPerUnitGross is not None or self.amountDiscountPerUnitGross is not None + + def serialize(self) -> dict[str, JSONValue]: + """Serialize this item in the schema implied by :meth:`isV3`.""" + data = { "basketItemReferenceId": self.getString(self.basketItemReferenceId), "unit": self.getString(self.unit), "quantity": self.quantity, - "amountDiscount": self.amountDiscount, "vat": self.vat, - "amountGross": self.amountGross, - "amountVat": self.amountVat, - "amountPerUnit": self.amountPerUnit, - "amountNet": self.amountNet, "title": self.getString(self.title), "subTitle": self.getString(self.subTitle), "imageUrl": self.getString(self.imageUrl), - "participantId": self.getString(self.participantId), "type": self.getString(self.kind), } + if self.isV3(): + data |= { + "amountPerUnitGross": self.amountPerUnitGross, + "amountDiscountPerUnitGross": self.amountDiscountPerUnitGross, + } + else: + data |= { + "amountDiscount": self.amountDiscount, + "amountGross": self.amountGross, + "amountVat": self.amountVat, + "amountPerUnit": self.amountPerUnit, + "amountNet": self.amountNet, + # The v3 schema knows no participantId + "participantId": self.getString(self.participantId), + } + return data @classmethod - def fromDict(cls, data): + def fromDict(cls, data: dict[str, JSONValue]) -> t.Self: + """Unserialize an item of either schema; missing amounts stay ``None``.""" data = data.copy() - data["kind"] = data["type"] - data["amountGross"] = float(data["amountGross"]) - data["amountVat"] = float(data["amountVat"]) - data["amountPerUnit"] = float(data["amountPerUnit"]) - data["amountNet"] = float(data["amountNet"]) - data["quantity"] = int(data["quantity"]) - data["vat"] = float(data["vat"]) + data["kind"] = data.get("type") + for key in ( + "amountGross", + "amountVat", + "amountPerUnit", + "amountNet", + "amountDiscount", + "amountPerUnitGross", + "amountDiscountPerUnitGross", + "vat", + ): + data[key] = parseFloat(data.get(key)) + if data.get("quantity") is not None: + data["quantity"] = int(data["quantity"]) return cls(**data) diff --git a/src/unzer/model/installment_plans.py b/src/unzer/model/installment_plans.py new file mode 100644 index 0000000..f4e28ae --- /dev/null +++ b/src/unzer/model/installment_plans.py @@ -0,0 +1,162 @@ +import datetime +import typing as t + +from .base import BaseModel, JSONValue +from ..utils import parseBool, parseDate, parseFloat + + +class InstallmentRate(BaseModel): + """A single rate (monthly payment) of an :class:`InstallmentPlan`.""" + + def __init__( + self, + date: datetime.date = None, + rate: float = None, + **kwargs, + ): + """Create a new InstallmentRate. + + :param date: Due date of this rate. + :param rate: Amount payable at :attr:`date`. + """ + super().__init__(**kwargs) + self.date = date + self.rate = rate + + def serialize(self) -> dict[str, JSONValue]: + raise NotImplementedError("No serialisation for response models.") + + @classmethod + def fromDict(cls, data: dict[str, JSONValue]) -> t.Self: + data = data.copy() + data["date"] = parseDate(data.get("date")) + data["rate"] = parseFloat(data.get("rate")) + return cls(**data) + + +class InstallmentPlan(BaseModel): + """One installment plan the customer can choose from.""" + + def __init__( + self, + numberOfRates: int = None, + totalAmount: float = None, + nominalInterestRate: float = None, + effectiveInterestRate: float = None, + interestAmount: float = None, + minimumInstallmentFee: float = None, + secciUrl: str = None, + installmentRates: list[InstallmentRate] = None, + **kwargs, + ): + """Create a new InstallmentPlan. + + :param numberOfRates: Duration of this plan in months. + :param totalAmount: Total amount payable including interest. + :param nominalInterestRate: Nominal interest rate in percent. + :param effectiveInterestRate: Effective interest rate in percent. + This value must be sent as ``effectiveInterestRate`` + with the authorize call of the payment. + :param interestAmount: (optional) Interest included in :attr:`totalAmount`. + :param minimumInstallmentFee: (optional) Minimum fee per rate. + :param secciUrl: (optional) URL of the pre-contractual information + (Standard European Consumer Credit Information) to show to the customer. + :param installmentRates: The single rates of this plan. + """ + super().__init__(**kwargs) + if installmentRates is None: + installmentRates = [] + self.numberOfRates = numberOfRates + self.totalAmount = totalAmount + self.nominalInterestRate = nominalInterestRate + self.effectiveInterestRate = effectiveInterestRate + self.interestAmount = interestAmount + self.minimumInstallmentFee = minimumInstallmentFee + self.secciUrl = secciUrl + self.installmentRates = installmentRates + + def serialize(self) -> dict[str, JSONValue]: + raise NotImplementedError("No serialisation for response models.") + + @classmethod + def fromDict(cls, data: dict[str, JSONValue]) -> t.Self: + data = data.copy() + if data.get("numberOfRates") is not None: + data["numberOfRates"] = int(data["numberOfRates"]) + for key in ( + "totalAmount", + "nominalInterestRate", + "effectiveInterestRate", + "interestAmount", + "minimumInstallmentFee", + ): + data[key] = parseFloat(data.get(key)) + data["installmentRates"] = [ + InstallmentRate.fromDict(rate) + for rate in data.get("installmentRates") or [] + ] + return cls(**data) + + +class InstallmentPlans(BaseModel): + """Response of the installment plans inquiry. + + .. seealso:: :meth:`unzer.UnzerClient.getPaylaterInstallmentPlans` + """ + + def __init__( + self, + inquiryId: str = None, + amount: float = None, + currency: str = None, + expiresAt: datetime.datetime = None, + plans: list[InstallmentPlan] = None, + isSuccess: bool = None, + isPending: bool = None, + isResumed: bool = None, + isError: bool = None, + **kwargs, + ): + """Create a new InstallmentPlans. + + :param inquiryId: (original: id) Id of this inquiry (e.g. ``Tx-vyexxxzzy8p``). + Required to create the :class:`unzer.model.PaylaterInstallment` payment type. + :param amount: The amount the plans were calculated for. + :param currency: ISO currency code. + :param expiresAt: (optional) Expiry of this calculation. + :param plans: The available plans. + :param isSuccess: (optional) The calculation succeeded. + :param isPending: (optional) + :param isResumed: (optional) + :param isError: (optional) The calculation failed. + """ + super().__init__(**kwargs) + if plans is None: + plans = [] + self.inquiryId = inquiryId + self.amount = amount + self.currency = currency + self.expiresAt = expiresAt + self.plans = plans + self.isSuccess = isSuccess + self.isPending = isPending + self.isResumed = isResumed + self.isError = isError + + def serialize(self) -> dict[str, JSONValue]: + raise NotImplementedError("No serialisation for response models.") + + @classmethod + def fromDict(cls, data: dict[str, JSONValue]) -> t.Self: + data = data.copy() + data["inquiryId"] = data["id"] + data["amount"] = parseFloat(data.get("amount")) + # Unzer sends the expiry as unix timestamp (as string) + if data.get("expiresAt"): + data["expiresAt"] = datetime.datetime.fromtimestamp(int(data["expiresAt"])) + else: + data["expiresAt"] = None + data["plans"] = [InstallmentPlan.fromDict(plan) for plan in data.get("plans") or []] + for key in ("isSuccess", "isPending", "isResumed", "isError"): + data[key] = parseBool(data[key]) if key in data else None + return cls(**data) diff --git a/src/unzer/model/payment.py b/src/unzer/model/payment.py index a11c4f3..73fd949 100644 --- a/src/unzer/model/payment.py +++ b/src/unzer/model/payment.py @@ -53,6 +53,7 @@ class PaymentTypes(enum.Enum): INVOICE_FACTORING = "ivf" # deprecated INVOICE_SECURED = "ivs" # deprecated PAYPAL = "ppl" + PAYU = "pyu" PREPAYMENT = "ppy" PRZELEWY24 = "p24" SEPA_DIRECT_DEBIT = "sdd" @@ -74,6 +75,7 @@ class PaymentTypes(enum.Enum): PAYLATER_DIRECT_DEBIT = "pdd" TWINT = "twt" OPEN_BANKING = "obp" + WERO = "wro" UNKNOWN = "unknown" @@ -106,7 +108,8 @@ class PaymentMethodTypes(enum.Enum): UNZER_INVOICE = "paylater-invoice" UNZER_PREPAYMENT = "prepayment" WECHATPAY = "wechatpay" - EPS = "EPS" + WERO = "wero" + EPS = "eps" DIRECT_BANK_TRANSFER = "openbanking-pis" CLICK_TO_PAY = "clicktopay" diff --git a/src/unzer/model/payment_type/__init__.py b/src/unzer/model/payment_type/__init__.py index 09d1eaf..e10eff3 100644 --- a/src/unzer/model/payment_type/__init__.py +++ b/src/unzer/model/payment_type/__init__.py @@ -1,23 +1,53 @@ from .abstract_paymenttype import PaymentType +from .alipay import Alipay from .applepay import Applepay from .bancontact import Bancontact from .card import Card +from .clicktopay import ClickToPay +from .direct_bank_transfer import DirectBankTransfer +from .eps import Eps from .googlepay import Googlepay from .ideal import Ideal from .klarna import Klarna +from .paylater_direct_debit import PaylaterDirectDebit +from .paylater_installment import PaylaterInstallment from .paylater_invoice import PaylaterInvoice from .paypal import PayPal +from .payu import PayU +from .postfinance_card import PostFinanceCard +from .postfinance_efinance import PostFinanceEfinance +from .prepayment import Prepayment +from .przelewy24 import Przelewy24 +from .sepa_direct_debit import SepaDirectDebit from .sofort import Sofort +from .twint import Twint +from .wechatpay import Wechatpay +from .wero import Wero __all__ = [ + "Alipay", "Applepay", "Bancontact", "Card", + "ClickToPay", + "DirectBankTransfer", + "Eps", "Googlepay", "Ideal", "Klarna", "PayPal", + "PayU", + "PaylaterDirectDebit", + "PaylaterInstallment", "PaylaterInvoice", "PaymentType", + "PostFinanceCard", + "PostFinanceEfinance", + "Prepayment", + "Przelewy24", + "SepaDirectDebit", "Sofort", + "Twint", + "Wechatpay", + "Wero", ] diff --git a/src/unzer/model/payment_type/abstract_paymenttype.py b/src/unzer/model/payment_type/abstract_paymenttype.py index cd80135..ec8842d 100644 --- a/src/unzer/model/payment_type/abstract_paymenttype.py +++ b/src/unzer/model/payment_type/abstract_paymenttype.py @@ -67,7 +67,9 @@ def get_configuration(self) -> dict: key_pair_types = self._client.getKeyPairTypes() logger.debug(f"key_pair_types: {key_pair_types}") for payment_type in key_pair_types["paymentTypes"]: - if payment_type["type"] == self.method_name.value: + # Compared case-insensitive: Unzer is not consistent about the casing + # of the method names (e.g. EPS is documented as *EPS* and as *eps*) + if payment_type["type"].lower() == self.method_name.value.lower(): return payment_type raise LookupError(f"PaymentType {self.method_name} is not configured in the keypair") diff --git a/src/unzer/model/payment_type/alipay.py b/src/unzer/model/payment_type/alipay.py new file mode 100644 index 0000000..46f8ec6 --- /dev/null +++ b/src/unzer/model/payment_type/alipay.py @@ -0,0 +1,13 @@ +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class Alipay(PaymentType): + """Alipay + + Wallet of the chinese provider Alipay, available in several European countries. + Requires a redirect to the wallet. + """ + + method = PaymentTypes.ALIPAY + method_name = PaymentMethodTypes.ALI_PAY diff --git a/src/unzer/model/payment_type/clicktopay.py b/src/unzer/model/payment_type/clicktopay.py new file mode 100644 index 0000000..f2ea647 --- /dev/null +++ b/src/unzer/model/payment_type/clicktopay.py @@ -0,0 +1,15 @@ +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class ClickToPay(PaymentType): + """Click to Pay + + Card payments with a Click to Pay wallet. + + The API reference documents no ``types/clicktopay`` endpoint; + the resource name is taken from the PHP SDK (``Clicktopay``). + """ + + method = PaymentTypes.CLICK_TO_PAY + method_name = PaymentMethodTypes.CLICK_TO_PAY diff --git a/src/unzer/model/payment_type/direct_bank_transfer.py b/src/unzer/model/payment_type/direct_bank_transfer.py new file mode 100644 index 0000000..7901bb0 --- /dev/null +++ b/src/unzer/model/payment_type/direct_bank_transfer.py @@ -0,0 +1,49 @@ +import typing as t + +from unzer.model.base import JSONValue +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class DirectBankTransfer(PaymentType): + """Unzer Direct Bank Transfer + + Pay-by-bank based on a payment initiation service (PIS): the customer is redirected + to log into their own online banking and authorizes the transfer there. + Unzer builds this on Mastercard's open banking platform. + Available in Germany and Austria in EUR, and replaces Sofort. + + Charge only. Note that the charge stays *pending* after the customer returns: + it only turns into *success* once the transfer is actually settled, which takes + one up to seven business days. + + Named ``OpenbankingPis`` in the PHP SDK and ``OpenBanking`` in the Java SDK. + """ + + method = PaymentTypes.OPEN_BANKING + method_name = PaymentMethodTypes.DIRECT_BANK_TRANSFER + + def __init__( + self, + key: str = None, + ibanCountry: str = None, + **kwargs, + ): + """Create a new Direct Bank Transfer paymentType resource. + + :param key: (optional) (original: id) ID for this payment type + :param ibanCountry: (optional) Country of the customer's bank account + in ISO 3166 ALPHA-2 format (e.g. ``DE``). + """ + super().__init__(key=key, **kwargs) + self.ibanCountry = ibanCountry + + def serialize(self) -> dict[str, JSONValue]: + # Only send what is set: an empty body is valid, a null value is not + return {key: value for key, value in (("ibanCountry", self.ibanCountry),) if value is not None} + + @classmethod + def fromDict(cls, data: dict[str, JSONValue]) -> t.Self: + data = data.copy() + data["key"] = data["id"] + return cls(**data) diff --git a/src/unzer/model/payment_type/eps.py b/src/unzer/model/payment_type/eps.py new file mode 100644 index 0000000..c414f47 --- /dev/null +++ b/src/unzer/model/payment_type/eps.py @@ -0,0 +1,40 @@ +import typing as t + +from unzer.model.base import JSONValue +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class Eps(PaymentType): + """EPS + + Austrian online bank transfer in EUR, requires a redirect to the customer's bank. + """ + + method = PaymentTypes.EPS + method_name = PaymentMethodTypes.EPS + + def __init__( + self, + key: str = None, + bic: str = None, + **kwargs, + ): + """Create a new EPS paymentType resource. + + :param key: (optional) (original: id) ID for this payment type + :param bic: (optional) BIC of the customer's bank (e.g. ``STZZATWWXXX``). + Can be omitted to let the customer choose the bank on the redirect page. + """ + super().__init__(key=key, **kwargs) + self.bic = bic + + def serialize(self) -> dict[str, JSONValue]: + # Only send what is set: an empty body is valid, a null value is not + return {key: value for key, value in (("bic", self.bic),) if value is not None} + + @classmethod + def fromDict(cls, data: dict[str, JSONValue]) -> t.Self: + data = data.copy() + data["key"] = data["id"] + return cls(**data) diff --git a/src/unzer/model/payment_type/paylater_direct_debit.py b/src/unzer/model/payment_type/paylater_direct_debit.py new file mode 100644 index 0000000..13de4b3 --- /dev/null +++ b/src/unzer/model/payment_type/paylater_direct_debit.py @@ -0,0 +1,55 @@ +import typing as t + +from unzer.model.base import JSONValue +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class PaylaterDirectDebit(PaymentType): + """Paylater Direct Debit + + Direct Debit is a part of Unzer's Buy Now Pay Later (BNPL) offering, + available in Germany and Austria in EUR. + Requires a customer and a basket resource on the authorize call. + """ + + method = PaymentTypes.PAYLATER_DIRECT_DEBIT + method_name = PaymentMethodTypes.DIRECT_DEBIT_SECURED + + REQUIRED_ATTRIBUTES = ["iban", "holder"] + + def __init__( + self, + key: str = None, + iban: str = None, + holder: str = None, + country: str = None, + **kwargs, + ): + """Create a new Paylater Direct Debit paymentType resource. + + :param key: (optional) (original: id) ID for this payment type + :param iban: IBAN of the customer's bank account. + :param holder: Name of the bank account holder. + :param country: (optional) Country of the customer's bank account + in ISO 3166 ALPHA-2 format (e.g. ``DE``). + """ + super().__init__(key=key, **kwargs) + self.iban = iban + self.holder = holder + self.country = country + + def serialize(self) -> dict[str, JSONValue]: + data = { + "iban": self.iban, + "holder": self.holder, + "country": self.country, + } + # Only send what is set: a null value would violate the API's field constraints + return {key: value for key, value in data.items() if value is not None} + + @classmethod + def fromDict(cls, data: dict[str, JSONValue]) -> t.Self: + data = data.copy() + data["key"] = data["id"] + return cls(**data) diff --git a/src/unzer/model/payment_type/paylater_installment.py b/src/unzer/model/payment_type/paylater_installment.py new file mode 100644 index 0000000..9322bbb --- /dev/null +++ b/src/unzer/model/payment_type/paylater_installment.py @@ -0,0 +1,72 @@ +import typing as t + +from unzer.model.base import JSONValue +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class PaylaterInstallment(PaymentType): + """Paylater Installment + + Unzer Installment is a part of Unzer's Buy Now Pay Later (BNPL) offering, available in + Germany, Austria and Switzerland in EUR and CHF. + The customer pays in monthly rates of a plan selected during the checkout. + + Before this resource can be created, the available plans must be fetched with + :meth:`unzer.UnzerClient.getPaylaterInstallmentPlans` and presented to the customer. + The authorize call requires a customer and a basket resource + (the basket must use the v3 schema, see :class:`unzer.model.Basket`). + """ + + method = PaymentTypes.PAYLATER_INSTALLMENT + method_name = PaymentMethodTypes.UNZER_INSTALLMENT + + # The API reference marks only inquiryId and numberOfRates as required, + # while the documentation claims country to be required too + REQUIRED_ATTRIBUTES = ["inquiryId", "numberOfRates"] + + def __init__( + self, + key: str = None, + inquiryId: str = None, + numberOfRates: int = None, + iban: str = None, + country: str = None, + holder: str = None, + **kwargs, + ): + """Create a new Paylater Installment paymentType resource. + + :param key: (optional) (original: id) ID for this payment type + :param inquiryId: The id of the installment plans response + (:attr:`unzer.model.InstallmentPlans.inquiryId`, e.g. ``Tx-vyexxxzzy8p``). + :param numberOfRates: Duration in months of the plan the customer selected. + :param iban: (optional, but recommended) IBAN of the customer's bank account. + Without it the customer has to transfer the monthly rates manually. + :param country: (optional) Country of the customer's bank account + in ISO 3166 ALPHA-2 format (e.g. ``DE``). + :param holder: (optional, but recommended) Name of the bank account holder. + """ + super().__init__(key=key, **kwargs) + self.inquiryId = inquiryId + self.numberOfRates = numberOfRates + self.iban = iban + self.country = country + self.holder = holder + + def serialize(self) -> dict[str, JSONValue]: + data = { + "inquiryId": self.inquiryId, + "numberOfRates": self.numberOfRates, + "iban": self.iban, + "country": self.country, + "holder": self.holder, + } + # Only send what is set: a null value would violate the API's field constraints + return {key: value for key, value in data.items() if value is not None} + + @classmethod + def fromDict(cls, data: dict[str, JSONValue]) -> t.Self: + data = data.copy() + data["key"] = data["id"] + return cls(**data) diff --git a/src/unzer/model/payment_type/payu.py b/src/unzer/model/payment_type/payu.py new file mode 100644 index 0000000..a7dfb9d --- /dev/null +++ b/src/unzer/model/payment_type/payu.py @@ -0,0 +1,12 @@ +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class PayU(PaymentType): + """PayU + + Redirect payment for Poland and the Czech Republic (PLN and CZK). + """ + + method = PaymentTypes.PAYU + method_name = PaymentMethodTypes.PAYU diff --git a/src/unzer/model/payment_type/postfinance_card.py b/src/unzer/model/payment_type/postfinance_card.py new file mode 100644 index 0000000..76b2b2e --- /dev/null +++ b/src/unzer/model/payment_type/postfinance_card.py @@ -0,0 +1,12 @@ +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class PostFinanceCard(PaymentType): + """PostFinance Card + + Debit card of the swiss PostFinance. Redirect payment in CHF. + """ + + method = PaymentTypes.PF_CARD + method_name = PaymentMethodTypes.POST_FINANCE_CARD diff --git a/src/unzer/model/payment_type/postfinance_efinance.py b/src/unzer/model/payment_type/postfinance_efinance.py new file mode 100644 index 0000000..adc77f7 --- /dev/null +++ b/src/unzer/model/payment_type/postfinance_efinance.py @@ -0,0 +1,12 @@ +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class PostFinanceEfinance(PaymentType): + """PostFinance e-finance + + Online banking of the swiss PostFinance. Redirect payment in CHF. + """ + + method = PaymentTypes.PF_EFINANCE + method_name = PaymentMethodTypes.POST_FINANCE_EFINANCE diff --git a/src/unzer/model/payment_type/prepayment.py b/src/unzer/model/payment_type/prepayment.py new file mode 100644 index 0000000..7850293 --- /dev/null +++ b/src/unzer/model/payment_type/prepayment.py @@ -0,0 +1,14 @@ +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class Prepayment(PaymentType): + """Unzer Prepayment + + The customer receives the bank details with the charge response and transfers the + money before the order is shipped. The charge therefore stays *pending* until the + payment arrives. + """ + + method = PaymentTypes.PREPAYMENT + method_name = PaymentMethodTypes.UNZER_PREPAYMENT diff --git a/src/unzer/model/payment_type/przelewy24.py b/src/unzer/model/payment_type/przelewy24.py new file mode 100644 index 0000000..d88a445 --- /dev/null +++ b/src/unzer/model/payment_type/przelewy24.py @@ -0,0 +1,12 @@ +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class Przelewy24(PaymentType): + """Przelewy24 + + Polish online bank transfer. Redirect payment in PLN or EUR. + """ + + method = PaymentTypes.PRZELEWY24 + method_name = PaymentMethodTypes.PRZELEWY24 diff --git a/src/unzer/model/payment_type/sepa_direct_debit.py b/src/unzer/model/payment_type/sepa_direct_debit.py new file mode 100644 index 0000000..8d0eb94 --- /dev/null +++ b/src/unzer/model/payment_type/sepa_direct_debit.py @@ -0,0 +1,57 @@ +import typing as t + +from unzer.model.base import JSONValue +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class SepaDirectDebit(PaymentType): + """SEPA Direct Debit + + The amount is collected from the customer's bank account by direct debit. + Available in all SEPA countries in EUR. + """ + + method = PaymentTypes.SEPA_DIRECT_DEBIT + method_name = PaymentMethodTypes.UNZER_DIRECT_DEBIT + + # The API reference marks no field as required, but the PHP SDK + # takes the iban as the only mandatory constructor argument + REQUIRED_ATTRIBUTES = ["iban"] + + def __init__( + self, + key: str = None, + iban: str = None, + bic: str = None, + holder: str = None, + **kwargs, + ): + """Create a new SEPA Direct Debit paymentType resource. + + :param key: (optional) (original: id) ID for this payment type + :param iban: IBAN of the customer's bank account. + :param bic: (optional) BIC of the customer's bank. + :param holder: (optional) Name of the bank account holder. + The API documentation calls this field *accountHolder*, + but both the PHP and the Java SDK use *holder*. + """ + super().__init__(key=key, **kwargs) + self.iban = iban + self.bic = bic + self.holder = holder + + def serialize(self) -> dict[str, JSONValue]: + data = { + "iban": self.iban, + "bic": self.bic, + "holder": self.holder, + } + # Only send what is set: a null value would violate the API's field constraints + return {key: value for key, value in data.items() if value is not None} + + @classmethod + def fromDict(cls, data: dict[str, JSONValue]) -> t.Self: + data = data.copy() + data["key"] = data["id"] + return cls(**data) diff --git a/src/unzer/model/payment_type/twint.py b/src/unzer/model/payment_type/twint.py new file mode 100644 index 0000000..84faf2b --- /dev/null +++ b/src/unzer/model/payment_type/twint.py @@ -0,0 +1,12 @@ +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class Twint(PaymentType): + """TWINT + + Swiss smartphone payment. Redirect payment in CHF. + """ + + method = PaymentTypes.TWINT + method_name = PaymentMethodTypes.TWINT diff --git a/src/unzer/model/payment_type/wechatpay.py b/src/unzer/model/payment_type/wechatpay.py new file mode 100644 index 0000000..b94f2e9 --- /dev/null +++ b/src/unzer/model/payment_type/wechatpay.py @@ -0,0 +1,13 @@ +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class Wechatpay(PaymentType): + """WeChat Pay + + Wallet of the chinese provider WeChat, available in most European countries. + Requires a redirect to the wallet. + """ + + method = PaymentTypes.WECHATPAY + method_name = PaymentMethodTypes.WECHATPAY diff --git a/src/unzer/model/payment_type/wero.py b/src/unzer/model/payment_type/wero.py new file mode 100644 index 0000000..2022db5 --- /dev/null +++ b/src/unzer/model/payment_type/wero.py @@ -0,0 +1,39 @@ +import typing as t + +from unzer.model.base import JSONValue +from unzer.model.payment import PaymentMethodTypes, PaymentTypes +from .abstract_paymenttype import PaymentType + + +class Wero(PaymentType): + """Wero + + European wallet for instant payments via smartphone. Charge only, requires a redirect. + """ + + method = PaymentTypes.WERO + method_name = PaymentMethodTypes.WERO + + def __init__( + self, + key: str = None, + walletId: str = None, + **kwargs, + ): + """Create a new Wero paymentType resource. + + :param key: (optional) (original: id) ID for this payment type + :param walletId: (optional) Id of the customer's Wero wallet. + """ + super().__init__(key=key, **kwargs) + self.walletId = walletId + + def serialize(self) -> dict[str, JSONValue]: + # Only send what is set: an empty body is valid, a null value is not + return {key: value for key, value in (("walletId", self.walletId),) if value is not None} + + @classmethod + def fromDict(cls, data: dict[str, JSONValue]) -> t.Self: + data = data.copy() + data["key"] = data["id"] + return cls(**data) diff --git a/src/unzer/model/risk_check.py b/src/unzer/model/risk_check.py new file mode 100644 index 0000000..327f136 --- /dev/null +++ b/src/unzer/model/risk_check.py @@ -0,0 +1,61 @@ +import datetime +import typing as t + +from .base import BaseModel, JSONValue +from ..utils import parseBool, parseDateTime + + +class RiskCheckResponse(BaseModel): + """Response of a customer risk check. + + .. seealso:: :meth:`unzer.UnzerClient.riskCheckPaylaterInstallment` + """ + + def __init__( + self, + key: str = None, + url: str = None, + timestamp: datetime.datetime = None, + isSuccess: bool = None, + isPending: bool = None, + isResumed: bool = None, + isError: bool = None, + **kwargs, + ): + """Create a new RiskCheckResponse. + + :param key: (original: id) Id of this risk check (e.g. ``GHZC-PQVK-RLGP``). + :param url: (optional) URL of the checked resource. + :param timestamp: (optional) Time of the check. + :param isSuccess: (optional) The risk check was accepted. + :param isPending: (optional) + :param isResumed: (optional) (original: isResume) The API reference and the + accepted response spell this ``isResume``, the declined one ``isResumed``. + :param isError: (optional) The risk check was declined. + """ + super().__init__(**kwargs) + self.key = key + self.url = url + self.timestamp = timestamp + self.isSuccess = isSuccess + self.isPending = isPending + self.isResumed = isResumed + self.isError = isError + + def serialize(self) -> dict[str, JSONValue]: + raise NotImplementedError("No serialisation for response models.") + + @classmethod + def fromDict(cls, data: dict[str, JSONValue]) -> t.Self: + data = data.copy() + data["key"] = data["id"] + data["timestamp"] = parseDateTime(data.get("timestamp")) + for key in ("isSuccess", "isPending", "isError"): + data[key] = parseBool(data[key]) if key in data else None + # Unzer uses both spellings: isResume in the API reference and in the accepted + # response, isResumed in the declined response + if "isResumed" in data or "isResume" in data: + data["isResumed"] = parseBool(data.get("isResumed", data.get("isResume"))) + else: + data["isResumed"] = None + return cls(**data) diff --git a/src/unzer/utils.py b/src/unzer/utils.py index 74c86e2..a41d225 100644 --- a/src/unzer/utils.py +++ b/src/unzer/utils.py @@ -15,3 +15,21 @@ def parseDateTime(value): elif "." in value: # European Date return datetime.datetime.strptime(value, "%d.%m.%Y %H:%M:%S") raise TypeError("Invalid date format of %r" % value) + + +def parseDate(value): + """Parse a date without a time part (e.g. ``2023-08-20``).""" + if not value: + return None + if isinstance(value, datetime.datetime): + return value.date() + if isinstance(value, datetime.date): + return value + return datetime.datetime.strptime(value, "%Y-%m-%d").date() + + +def parseFloat(value): + """Parse an optional amount, which the API sends as string.""" + if value is None or value == "": + return None + return float(value)