diff --git a/README.md b/README.md index addd2d1..b4a3a5a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # 🎉 Tochka API -**Simple Tochka Bank API wrapper** +**Удобная обёртка над открытым API Банка Точка** -## 📥 Installation +## 📥 Установка -### 📦 From pip: +### 📦 Из pip: ```shell python -m pip install -u tochka_api ``` -### 🏗 From git: +### 🏗 Из репозитория: ```shell git clone https://github.com/WhiteApfel/tochka_api.git @@ -18,31 +18,42 @@ cd tochka_api python setup.py install ``` -### 🚧 Dev progress +### 🚧 Прогресс разработки -* [x] Auth -* [x] Balances -* [x] Accounts +* [x] Авторизация +* [x] Балансы +* [x] Счета * [ ] Webhooks -* [ ] Statements -* [ ] Cards -* [ ] Clients -* [ ] Payments -* [ ] Consents -* [ ] Special accounts -* [x] SBP +* [ ] Выписки +* [ ] Карты +* [ ] Клиенты +* [ ] Платежи +* [ ] Разрешения +* [ ] Специальные счета +* [x] СБП * [x] QR - * [x] Merchants - * [x] Legal - * [x] Refunds - * [x] Account + * [x] ТСП (Merchant) + * [x] Компании (Legal) + * [x] Возвраты -### 🧑‍🏫 How to use +### 🧑‍🏫 Как использовать + +**💰 Уточнения по типу данных для суммы** + +* ``Decimal`` and ``str`` - amount in rubles +* ``int`` - amount in kopecks + +**Различия user_code и customer_code** + +* ``user_code`` это код клиента, который имеет доступ к компании +* ``customer_code`` это код компании(ИП), на которую открыты счета ```python import asyncio -from tochka_api import TochkaAPI +from decimal import Decimal + +from tochka_api import TochkaAPI, context_user_code from tochka_api.models import PermissionsEnum client_id = "<>" @@ -52,12 +63,146 @@ redirect_uri = "https://tochka-api.pfel.cc/" tochka = TochkaAPI(client_id, client_secret, redirect_uri=redirect_uri) +async def add_user(): + consents_token, consents_expires_in = await tochka.get_consents_token() + consents_request = await tochka.create_consents( + consents_token, PermissionsEnum.all() + ) + auth_url = tochka.generate_auth_url(consent_id=consents_request.consent_id) + print("Auth:", auth_url) + + code = input("Code >>> ") + token_id = input("Token_id >>> ") + tokens = await tochka.get_access_token( + code=code, token_id=token_id + ) + + print(f"User {tokens.user_code=} are authorized.") + context_user_code.set(tokens.user_code) + + asyncio.create_task(get_accounts()) + merchant_id = await register_merchant(user_code=tokens.user_code) + await register_qr(merchant_id=merchant_id) + +async def get_accounts(): + # user_code будет унаследован из context_user_code + # Это thread-safe и loop-safe + for _ in range(25): + await tochka.get_accounts() + await asyncio.sleep(2) + +async def register_merchant(user_code) -> str: + # Будет использоваться указанный user_code + # Даже если в context_user_code было задано другое значение + accounts = await tochka.get_accounts(user_code=user_code) + + customer_info = await tochka.sbp_get_customer_info( + customer_code=accounts[0].customer_code + ) + legal_entity = await tochka.sbp_get_legal_entity(customer_info.legal_id) + + merchant = await tochka.sbp_register_merchant( + legal_id=legal_entity.legal_id, + name="TochkaExample", + mcc="7277", + address=" 1-й Вешняковский проезд, д. 1, стр. 8, этаж 1, помещ. 43", + city="Москва", + region_code="45", + zip_code="109456", + phone_number="+78002000024", + ) + + print(f"New merchant {merchant.merchant_id=}") + print( + *(await tochka.sbp_get_merchants(legal_id=legal_entity.legal_id)).merchants, + sep="\n", + ) + + return merchant.merchant_id + +async def register_qr(merchant_id): + # user_code будет унаследован из context_user_code + # Это thread-safe и loop-safe + accounts = await tochka.get_accounts() + + customer_info = await tochka.sbp_get_customer_info( + customer_code=accounts[0].customer_code + ) + legal_entity = await tochka.sbp_get_legal_entity(customer_info.legal_id) + sbp_accounts = await tochka.sbp_get_accounts(legal_entity.legal_id) + + qr = await tochka.sbp_register_qr( + merchant_id=merchant_id, + account=sbp_accounts[0].account, + is_static=True, + purpose="Перечисление по договору минета", + media_type="image/svg+xml", + ) + print("Статичный без суммы:\n",qr.image.content) + + qr = await tochka.sbp_register_qr( + merchant_id=merchant_id, + account=sbp_accounts[0].account, + is_static=True, + amount=Decimal("100.00"), # или Decimal(100), или 10000 + purpose="Оплата аренды борделя", + media_type="image/svg+xml", + ) + print("Статичный с суммой:\n",qr.image.content) + + qr = await tochka.sbp_register_qr( + merchant_id=merchant_id, + account=sbp_accounts[0].account, + is_static=False, + amount=10000, # или Decimal(100) + ttl=60, # 0 - максимально возможное ограничение, иначе в минутах + purpose="Оплата поставки презервативов", + media_type="image/svg+xml", + ) + print("Динамический с суммой:\n",qr.image.content) + +async def refund(): + accounts = await tochka.get_accounts() + + customer_info = await tochka.sbp_get_customer_info( + customer_code=accounts[0].customer_code + ) + sbp_accounts = await tochka.sbp_get_accounts(customer_info.legal_id) + + payments = await tochka.sbp_get_payments( + customer_info.customer_code, from_date=1, + ) + print(payments) + + last_payment = payments.payments[0] + refund_response = await tochka.sbp_start_refund( + account=sbp_accounts[0].account, + amount=Decimal("1.25"), # или 125 + qrc_id=last_payment.qrc_id, + trx_id=last_payment.trx_id, + ) + print("Refund: ", refund_response) + async def main(): - if tochka.tokens.access_token is None: - await tochka.get_consents_token() - consents_request = await tochka.create_consents(PermissionsEnum.all()) - print(tochka.generate_auth_url(consent_id=consents_request.consent_id)) - await tochka.get_access_token(code=input("Code >>> ")) + # Добавить два пользователя. + # Это могут быть бухгалтер и владелец одной компании + # или совершенно разные люди из разных компаний. + # Приложение может работать с несколькими пользователями + # либо в однопользовательском режиме, тогда надо указать + # tochka = TochkaAPI(client_id, client_secret, redirect_uri=redirect_uri, one_customer_mode=True) + # и тогда после добавления одного пользователя, система его запомнит. + # Указывать context_user_code не придётся + print("Введите что-либо, чтобы добавить пользователей") + print("Для пропуска нажмите Enter") + if input(">>> "): + await add_user() + await add_user() + else: + # можно не указывать, если one_customer_mode=True + context_user_code.set("212332030") + + await refund() + balances = await tochka.get_balances() print(balances[0].amount) diff --git a/setup.py b/setup.py index 70d81bd..0103028 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ def requirements(): description="Simple Tochka Bank Open API client", install_requires=requirements(), project_urls={ - "Source code": "https://github.com/WhiteApfel/pyQiwiP2P", + "Source code": "https://github.com/WhiteApfel/tochka-api", "Write me": "https://t.me/whiteapfel", }, long_description=read("README.md"), diff --git a/tests/sandbox/disable_test_sbp_legal.py b/tests/sandbox/disable_test_sbp_legal.py index deefca2..a439128 100644 --- a/tests/sandbox/disable_test_sbp_legal.py +++ b/tests/sandbox/disable_test_sbp_legal.py @@ -3,7 +3,7 @@ SbpAccountsResponse, SbpCustomerInfoResponse, SbpLegalEntityResponse, - SbpRegisterLegalEntity, + SbpRegisterLegalEntityResponse, TochkaBooleanResponse, ) @@ -31,30 +31,30 @@ async def test_balance_response(tochka_client): # async def sbp_set_account_status( -# self, legal_id: str, account_id: str, active: str | bool = True +# self, legal_id: str, account: str, is_active: str | bool = True # ) -> TochkaBooleanResponse: # data = { # "Data": { -# "status": ("Active" if active else "Suspended") -# if type(active) is bool -# else active, +# "status": ("Active" if is_active else "Suspended") +# if type(is_active) is bool +# else is_active, # } # } # return await self.request( # method="POST", -# url=f"/sbp/v1.0/account/{legal_id}/{account_id}", +# url=f"/sbp/v1.0/account/{legal_id}/{account}", # data=data, # ) # # # async def sbp_set_legal_entity_status( -# self, legal_id: str, active: str | bool = True +# self, legal_id: str, is_active: str | bool = True # ) -> TochkaBooleanResponse: # data = { # "Data": { -# "status": ("Active" if active else "Suspended") -# if type(active) is bool -# else active, +# "status": ("Active" if is_active else "Suspended") +# if type(is_active) is bool +# else is_active, # } # } # return await self.request( @@ -64,7 +64,7 @@ async def test_balance_response(tochka_client): # ) # # -# async def sbp_register_legal_entity(self, customer_code: str) -> SbpRegisterLegalEntity: +# async def sbp_register_legal_entity(self, customer_code: str) -> SbpRegisterLegalEntityResponse: # data = { # "Data": { # "customerCode": customer_code, @@ -78,10 +78,10 @@ async def test_balance_response(tochka_client): # ) # # -# async def sbp_get_account(self, legal_id: str, account_id: str) -> SbpAccountsResponse: +# async def sbp_get_account(self, legal_id: str, account: str) -> SbpAccountsResponse: # return await self.request( # method="GET", -# url=f"sbp/v1.0/account/{legal_id}/{account_id}" +# url=f"sbp/v1.0/account/{legal_id}/{account}" # ) # # diff --git a/tests/sandbox/test_accounts.py b/tests/sandbox/test_accounts.py index 5c52c4f..fa1a52e 100644 --- a/tests/sandbox/test_accounts.py +++ b/tests/sandbox/test_accounts.py @@ -12,5 +12,5 @@ async def test_accounts_response(tochka_client): async def test_account_response(tochka_client): accounts = await tochka_client.get_accounts() for account in accounts: - response = await tochka_client.get_account(account.account_id) + response = await tochka_client.get_account(account.account) assert isinstance(response, AccountsResponse) diff --git a/tests/sandbox/test_balances.py b/tests/sandbox/test_balances.py index 903be99..df16124 100644 --- a/tests/sandbox/test_balances.py +++ b/tests/sandbox/test_balances.py @@ -12,5 +12,5 @@ async def test_balances_response(tochka_client): async def test_balance_response(tochka_client): accounts = await tochka_client.get_accounts() for account in accounts: - response = await tochka_client.get_balance(account_id=account.account_id) + response = await tochka_client.get_balance(account=account.account) assert isinstance(response, BalanceResponse) diff --git a/tochka-api.pfel.cc/package.json b/tochka-api.pfel.cc/package.json index c4529a0..796162b 100644 --- a/tochka-api.pfel.cc/package.json +++ b/tochka-api.pfel.cc/package.json @@ -9,9 +9,11 @@ }, "devDependencies": { "nuxt": "3.0.0-rc.8", - "wrangler": "^2.0.28" + "wrangler": "^2.0.28", + "@rollup/plugin-inject": "^5.0.2npm a" }, "dependencies": { + "node-fetch": "^3.3.0", "yarn": "^1.22.19" } } diff --git a/tochka-api.pfel.cc/wrangler.toml b/tochka-api.pfel.cc/wrangler.toml index d8edda6..3c218e3 100644 --- a/tochka-api.pfel.cc/wrangler.toml +++ b/tochka-api.pfel.cc/wrangler.toml @@ -1,5 +1,5 @@ name = "tochka_api" -account_id = "05e1669d8f5321ed189bc14b2cc0d0c5" +account = "05e1669d8f5321ed189bc14b2cc0d0c5" workers_dev = true route = "" main = "./.output/server/index.mjs" diff --git a/tochka_api/__init__.py b/tochka_api/__init__.py index c2f0fd4..631f2b0 100644 --- a/tochka_api/__init__.py +++ b/tochka_api/__init__.py @@ -1 +1,2 @@ from modules import TochkaAPI +from modules.base import context_user_code diff --git a/tochka_api/models/responses/__init__.py b/tochka_api/models/responses/__init__.py index 634ecdf..1e05fcb 100644 --- a/tochka_api/models/responses/__init__.py +++ b/tochka_api/models/responses/__init__.py @@ -6,7 +6,7 @@ from .sbp_legal import ( SbpLegalEntityResponse, SbpCustomerInfoResponse, - SbpRegisterLegalEntity, + SbpRegisterLegalEntityResponse, SbpAccountsResponse, ) from .sbp_refunds import SbpPaymentsResponse, SbpRefundResponse diff --git a/tochka_api/models/responses/accounts.py b/tochka_api/models/responses/accounts.py index 65660cc..56a3626 100644 --- a/tochka_api/models/responses/accounts.py +++ b/tochka_api/models/responses/accounts.py @@ -13,7 +13,7 @@ class AccountDetails(BaseModel): class Account(BaseModel): customer_code: str = Field(..., alias="customerCode") - account_id: str = Field(..., alias="accountId") + account: str = Field(..., alias="accountId") transit_account: str | None = Field(None, alias="transitAccount") status: Literal["Enabled", "Disabled", "Deleted", "ProForma", "Pending"] status_updated_at: datetime = Field(..., alias="statusUpdateDateTime") diff --git a/tochka_api/models/responses/balances.py b/tochka_api/models/responses/balances.py index f556f89..f6dc21c 100644 --- a/tochka_api/models/responses/balances.py +++ b/tochka_api/models/responses/balances.py @@ -13,7 +13,7 @@ class Amount(BaseModel): class Balance(BaseModel): - account_id: str = Field(..., alias="accountId") + account: str = Field(..., alias="accountId") indicator: Literal["Credit", "Debit"] = Field(..., alias="creditDebitIndicator") created_at: datetime = Field(..., alias="dateTime") currency: str diff --git a/tochka_api/models/responses/sbp_accounts.py b/tochka_api/models/responses/sbp_accounts.py index d3f6613..a222099 100644 --- a/tochka_api/models/responses/sbp_accounts.py +++ b/tochka_api/models/responses/sbp_accounts.py @@ -1,7 +1,6 @@ from models.responses import TochkaBaseResponse -from pydantic import BaseModel, Field, root_validator - from models.responses.sbp_legal import SbpAccount +from pydantic import BaseModel, Field, root_validator class SbpAccountsResponse(TochkaBaseResponse): diff --git a/tochka_api/models/responses/sbp_legal.py b/tochka_api/models/responses/sbp_legal.py index e3af97c..b4eba70 100644 --- a/tochka_api/models/responses/sbp_legal.py +++ b/tochka_api/models/responses/sbp_legal.py @@ -28,19 +28,23 @@ class SbpMerchant(SbpLegalAddress): merchant_id: str = Field(..., alias="merchantId") brand: str = Field(..., alias="brandName") capabilities: Literal["001", "010", "011"] - phone: str = Field(..., alias="contactPhoneNumber") + phone: str | None = Field(None, alias="contactPhoneNumber") mcc: str additional_contacts: list[dict] | None = Field(None, alias="additionalContacts") class SbpAccount(BaseModel): - account_id: str = Field(..., alias="accountId") + account: str = Field(..., alias="accountId") status: Literal["Active", "Suspended"] created_at: datetime = Field(..., alias="createdAt") legal_id: str = Field(..., alias="legalId") class SbpCustomerInfoResponse(SbpLegalAddress, SbpLegalDetails, TochkaBaseResponse): + """ + Refers to CustomerInfoResponseV3 https://enter.tochka.com/doc/v2/redoc/tag/swagger.json + """ + status: Literal["Active", "Suspended"] created_at: datetime = Field(..., alias="createdAt") customer_code: str = Field(..., alias="customerCode") @@ -56,7 +60,7 @@ class SbpLegalEntityResponse(SbpLegalAddress, SbpLegalDetails, TochkaBaseRespons legal_id: str = Field(..., alias="legalId") -class SbpRegisterLegalEntity(TochkaBaseResponse): +class SbpRegisterLegalEntityResponse(TochkaBaseResponse): legal_id: str = Field(..., alias="legalId") diff --git a/tochka_api/models/responses/sbp_merchants.py b/tochka_api/models/responses/sbp_merchants.py index 8c83ea3..f1905e7 100644 --- a/tochka_api/models/responses/sbp_merchants.py +++ b/tochka_api/models/responses/sbp_merchants.py @@ -1,9 +1,8 @@ from datetime import datetime -from pydantic import Field, BaseModel, root_validator - from models.responses import TochkaBaseResponse from models.responses.sbp_legal import SbpMerchant +from pydantic import BaseModel, Field, root_validator class SbpMerchantsResponse(TochkaBaseResponse): diff --git a/tochka_api/models/responses/sbp_qr.py b/tochka_api/models/responses/sbp_qr.py index 2a2a188..ba4ba9a 100644 --- a/tochka_api/models/responses/sbp_qr.py +++ b/tochka_api/models/responses/sbp_qr.py @@ -1,9 +1,9 @@ +from decimal import Decimal from datetime import datetime from typing import Literal -from pydantic import BaseModel, Field, root_validator, validator - from models.responses import TochkaBaseResponse +from pydantic import BaseModel, Field, root_validator, validator class SbpQrCodeImage(BaseModel): @@ -14,7 +14,7 @@ class SbpQrCodeImage(BaseModel): class SbpQrCode(BaseModel): - account_id: str = Field(..., alias="accountId") + account: str = Field(..., alias="accountId") status: Literal["Active", "Suspended"] created_at: datetime = Field(..., alias="createdAt") qrc_id: str = Field(..., alias="qrcId") @@ -57,25 +57,56 @@ class SbpRegisterQrResponse(TochkaBaseResponse): class SbpQrPaymentDataResponse(TochkaBaseResponse): + """ + Refers to QrCodePaymentDataV3 + + Важно: атрибут ``amount`` вернёт сумму в рублях в формате ``Decimal``, + для получения суммы в копейках надо обращаться к ``amount_raw`` + + Важно: ``qrc_type`` имеет ENUM (01, 02) представление, + для получения понятного представления надо обращаться к ``qrc_type_pretty`` + + Пояснения к атрибуту ``scenario``: + + :param C2B_SUBSCRIPTION_WITH_PAYMENT: ссылка, зарегистрированная для Сценария «Оплата с привязкой счета» + :param C2B_SUBSCRIPTION: ссылка, зарегистрированная для Сценария «Привязка счета без оплаты» + :param C2B: одноразовая Платежная ссылка СБП или многоразовая Платежная ссылка СБП с фиксированной суммой + :param C2B_CASH_REGISTER: кассовая Платежная ссылка СБП + :param C2B_OPEN_SUM: многоразовая Платежная ссылка СБП с открытой суммой + + """ + address: str - amount: int or None + amount_raw: int or None = Field(..., alias="amount") currency: str or None brand_name: str = Field(..., alias="brandName") legal_name: str = Field(..., alias="legalName") payment_purpose: str | None = Field(..., alias="paymentPurpose") - qrc_type: str + subscription_purpose: str | None = Field(..., alias="subscriptionPurpose") + qrc_type: Literal["01", "02"] = Field(..., alias="qrcType") mcc: str - crc: str qrc_id: str = Field(..., alias="qrcId") - creditor_bank_id: str = Field(..., alias="creditorBankId") + member_id: str = Field(..., alias="memberId") + scenario: str - @validator("qrc_type", pre=True) - def normalize_qrc_type(cls, qrc_type: str): + ogrn: str | None + inn: str | None + + redirect_url: str | None = Field(None, alias="redirectUrl") + + @property + def amount(self) -> Decimal | None: + if self.amount_raw is not None: + return (Decimal(self.amount_raw) / Decimal(100)).quantize(Decimal("0.00")) + return None + + @property + def qrc_type_pretty(self) -> Literal["Static", "Dynamic"]: qrc_types = { "01": "Static", "02": "Dynamic", } - return qrc_types[qrc_type] + return qrc_types[self.qrc_type] class SbpQrPayment(BaseModel): diff --git a/tochka_api/models/responses/sbp_refunds.py b/tochka_api/models/responses/sbp_refunds.py index cfff3cf..9ed052f 100644 --- a/tochka_api/models/responses/sbp_refunds.py +++ b/tochka_api/models/responses/sbp_refunds.py @@ -18,7 +18,7 @@ class Payment(BaseModel): "Timeout", ] message: str - trx_id: str = Field(..., alias="trxId") + trx_id: str = Field(..., alias="refTransactionId") class SbpPaymentsResponse(TochkaBaseResponse): diff --git a/tochka_api/models/tokens.py b/tochka_api/models/tokens.py index e75d4d0..47b56ad 100644 --- a/tochka_api/models/tokens.py +++ b/tochka_api/models/tokens.py @@ -1,74 +1,100 @@ -from datetime import datetime, timedelta -from pathlib import Path +from datetime import datetime, timedelta, timezone +from typing import Callable + +import dateutil.parser -import ujson -from pydantic import BaseModel, Field from settings import HTTP_TIMEOUT -class Tokens(BaseModel): - consents_token: str = None - consents_expired_in: datetime = None - access_token: str = None - access_expired_in: datetime = None - refresh_token: str = None - path: Path = Field(None) - allow_save_tokens: bool = False - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.allow_save_tokens: - self.path.parent.mkdir(parents=True, exist_ok=True) - if self.path.exists(): - loaded = self.load_tokens(self.path) - self.consents_token = loaded.consents_token - self.consents_expired_in = loaded.consents_expired_in - self.access_token = loaded.access_token - self.refresh_token = loaded.refresh_token - self.access_expired_in = loaded.access_expired_in - else: - self.path.touch(exist_ok=True) - self.save_tokens() - - def set_consents_token(self, token: str, expires_in: int): - self.consents_token = token - self.consents_expired_in = datetime.utcnow() + timedelta( - seconds=expires_in - HTTP_TIMEOUT - ) - self.save_tokens() - - def set_access_token(self, access_token: str, refresh_token: str, expires_in: int): - self.access_token = access_token - self.refresh_token = refresh_token - self.access_expired_in = datetime.utcnow() + timedelta( - seconds=expires_in - HTTP_TIMEOUT - ) - self.save_tokens() - - def save_tokens(self): - if not self.allow_save_tokens: - return - self.path.write_text( - self.json( - include={ - "consents_token", - "consents_expired_in", - "access_token", - "refresh_token", - "access_expired_in", - "allow_save_tokens", - } +class TokenField(str): + def __new__(cls, *args, **kwargs): + return super().__new__(cls, args[0]) + + def __init__( + self, value, *, expires_in: int | None = None, expires: datetime | None = None + ): + if expires is not None: + self._expires = expires + elif expires_in is None: + self._expires = None + else: + self._expires = datetime.now(timezone.utc) + timedelta( + seconds=expires_in - HTTP_TIMEOUT ) - ) @property - def consents_is_alive(self) -> bool: - return self.consents_expired_in > datetime.utcnow() + def expires(self) -> datetime | None: + return self._expires @property - def access_is_alive(self) -> bool: - return self.access_expired_in > datetime.utcnow() + def is_alive(self) -> bool: + if self._expires is None: + return True + return datetime.now(timezone.utc) < self.expires + + +class TokenDescriptor: + def __init__(self): + self.value = None + + def __get__(self, instance, owner) -> TokenField | None: + return self.value + + def __set__( + self, instance, value: tuple[str, int | datetime | None, datetime | None] | str + ): + if type(value) is str: + value = (value, None, None) + elif len(value) == 1: + value = value + (None, None) + elif isinstance(value[1], datetime): + value = (value[0], None, value[1]) + elif len(value) == 2: + value = value + (None,) + self.value = TokenField(value[0], expires_in=value[1], expires=value[2]) + + instance.on_update(instance.user_code, instance) + + +class Tokens: + __slots__ = ("on_update", "user_code") + token_fields = ("client", "access", "refresh") + client: TokenField | None = TokenDescriptor() + access: TokenField | None = TokenDescriptor() + refresh: TokenField | None = TokenDescriptor() + + def __init__(self, user_code, on_update: Callable[[str, dict], None]): + self.on_update = on_update + self.user_code = user_code + # self.client: TokenField | None = TokenDescriptor() + # self.access: TokenField | None = TokenDescriptor() + # self.refresh: TokenField | None = TokenDescriptor() + + def dump(self) -> tuple[str, dict]: + data = {} + + for field_name in self.token_fields: + if field_name[0] == "_": + continue + + field = getattr(self, field_name, None) + + data[field_name] = { + "value": field, + "expires": field.expires if field else None, + } + + return self.user_code, data + + def load(self, user_code: str, data: dict[str, dict[str, str | datetime | None]]): + self.user_code = user_code + if not all(field in data for field in self.token_fields): + raise ValueError("data does not contain all fields") - @classmethod - def load_tokens(cls, path): - return cls(**(ujson.loads(path.read_text()) | {"allow_save_tokens": False})) + for field_name in self.token_fields: + field_data = data.get(field_name) + value = field_data.get("value") + expires = field_data.get("expires") + if isinstance(expires, str): + expires = dateutil.parser.parse(expires) + setattr(self, field_name, (value, None, expires)) diff --git a/tochka_api/modules/accounts.py b/tochka_api/modules/accounts.py index d18a489..db01066 100644 --- a/tochka_api/modules/accounts.py +++ b/tochka_api/modules/accounts.py @@ -3,14 +3,16 @@ class TochkaApiAccounts(TochkaApiBase): - async def get_accounts(self) -> AccountsResponse: + async def get_accounts(self, user_code: str | None = None) -> AccountsResponse: return await self.request( method="GET", url="/open-banking/v1.0/accounts", ) - async def get_account(self, account_id: str) -> AccountsResponse: + async def get_account( + self, account: str, user_code: str | None = None + ) -> AccountsResponse: return await self.request( method="GET", - url=f"/open-banking/v1.0/accounts/{account_id}", + url=f"/open-banking/v1.0/accounts/{account}", ) diff --git a/tochka_api/modules/balances.py b/tochka_api/modules/balances.py index be4d96f..228ffc6 100644 --- a/tochka_api/modules/balances.py +++ b/tochka_api/modules/balances.py @@ -3,14 +3,16 @@ class TochkaApiBalances(TochkaApiBase): - async def get_balances(self) -> BalanceResponse: + async def get_balances(self, user_code: str | None = None) -> BalanceResponse: return await self.request( method="GET", url="/open-banking/v1.0/balances", ) - async def get_balance(self, account_id: str) -> BalanceResponse: + async def get_balance( + self, account: str, user_code: str | None = None + ) -> BalanceResponse: return await self.request( method="GET", - url=f"/open-banking/v1.0/accounts/{account_id}/balances", + url=f"/open-banking/v1.0/accounts/{account}/balances", ) diff --git a/tochka_api/modules/base.py b/tochka_api/modules/base.py index d9dc322..cf9d608 100644 --- a/tochka_api/modules/base.py +++ b/tochka_api/modules/base.py @@ -1,17 +1,23 @@ import inspect import urllib.parse +from contextvars import ContextVar from datetime import datetime, timedelta -from pathlib import Path from types import GenericAlias -from typing import Literal +from typing import Literal, Type +import jwt import ujson as ujson -from appdirs import AppDirs from exceptions.base import TochkaError from httpx import AsyncClient, Response from models import PermissionsEnum, Tokens from models.responses import ConsentsResponse, TochkaBaseResponse from settings import HTTP_TIMEOUT, TOCHKA_BASE_API_URL +from token_manager import ( + AbstractTokenManager, + LocalStorageTokenManager, +) + +context_user_code = ContextVar("context_user_code") class TochkaAPIMeta: @@ -28,7 +34,18 @@ def __new__(cls, *args, **kwargs): def decorate(f): async def decorated(*f_args, **f_kwargs): + token = None + if f_kwargs.get("user_code") is not None: + token = context_user_code.set(f_kwargs.get("user_code")) + if ( + "user_code" not in inspect.getfullargspec(f).args + and "user_code" in f_kwargs + ): + del f_kwargs["user_code"] response: Response = await f(*f_args, **f_kwargs) + + if token is not None: + context_user_code.reset(token) if ( response.status_code == f.__annotations__["return"]._valid_status_code @@ -53,36 +70,29 @@ def __init__( client_secret: str, base_url: str | None = None, redirect_uri: str | None = None, - tokens_path: str | None = None, - allow_save_tokens: bool = True, + token_manager: Type[AbstractTokenManager] = LocalStorageTokenManager, + one_customer_mode: bool = True, *args, - **kwargs, + **token_manager_data, ): - self.__client_id = client_id - self.__client_secret = client_secret - self.redirect_uri = redirect_uri - self._allow_save_tokens = allow_save_tokens - self._base_url = base_url or TOCHKA_BASE_API_URL - self._tokens_path = Path(tokens_path) if tokens_path is not None else None - if self._tokens_path is None: - self._app_dirs = AppDirs("tochka_api", "whiteapfel") - self.tokens_path = Path( - f"{self._app_dirs.user_data_dir}/{self.__client_id}/tokens.json" - ) - self._tokens: Tokens | None = None - self._http_session: AsyncClient = None + self.__client_id: str = client_id + self.__client_secret: str = client_secret + self._base_url: str = base_url or TOCHKA_BASE_API_URL + self.redirect_uri: str = redirect_uri - @property - def tokens(self): - if self._tokens is None: - if self._allow_save_tokens: - self._tokens = Tokens(path=self.tokens_path, allow_save_tokens=True) - else: - self._tokens = Tokens() - return self._tokens + self.token_manager: AbstractTokenManager = token_manager( + self.__client_id, **token_manager_data + ) + self.token_manager.load_tokens() + self.one_customer_mode: bool = one_customer_mode + self._user_code: str | None = None + if self.one_customer_mode and len(self.token_manager.tokens_mapper) == 1: + self._customer_code = list(self.token_manager.tokens_mapper.keys())[0] + + self._http_session: AsyncClient = None @property - def http_session(self) -> AsyncClient: + def http_session(self, user_code: str | None = None) -> AsyncClient: if self._http_session is None: self._http_session = AsyncClient() return self._http_session @@ -98,18 +108,26 @@ async def request( cookies: dict = None, content: bytes = None, auth_required: bool = True, + **get_tokens_params, ) -> Response: if json is not None: content = ujson.encode(json) headers = (headers or {}) | {"Content-Type": "application/json"} if auth_required: - if self.tokens.access_token is not None and not self.tokens.access_is_alive: - await self.refresh_tokens() - elif self.tokens.access_token is None: + if self.one_customer_mode: + get_tokens_params = get_tokens_params | { + "user_code": self._customer_code + } + else: + get_tokens_params = get_tokens_params | { + "user_code": context_user_code.get() + } + tokens = self.token_manager.get_tokens(**get_tokens_params) + if tokens.access is not None and not tokens.access.is_alive: + await self.refresh_tokens(**get_tokens_params) + elif tokens.access is None: raise ValueError("access_token is needed for authorization") - headers = (headers or {}) | { - "Authorization": f"Bearer {self.tokens.access_token}" - } + headers = (headers or {}) | {"Authorization": f"Bearer {tokens.access}"} return await self.http_session.request( method=method, url=url if url.startswith("https://") else self._base_url + url, @@ -126,7 +144,9 @@ async def get_consents_token(self) -> tuple[str, datetime]: "client_id": self.__client_id, "client_secret": self.__client_secret, "grant_type": "client_credentials", - "scope": "accounts", + "scope": ( + "accounts balances customers statements cards sbp payments special" + ), "state": "qwe", } response = await self.request( @@ -138,16 +158,13 @@ async def get_consents_token(self) -> tuple[str, datetime]: if response.status_code == 200: response_data = ujson.loads(response.text) - self.tokens.set_consents_token( - token=response_data["access_token"], - expires_in=response_data["expires_in"], - ) - return self.tokens.consents_token, self.tokens.consents_expired_in + return response_data["access_token"], response_data["expires_in"] # TODO: Exception on error response async def create_consents( self, + consents_token: str, permissions: list[PermissionsEnum], expires_in: int | timedelta = None, expiration_time: datetime = None, @@ -158,7 +175,7 @@ async def create_consents( } } - headers = {"Authorization": f"Bearer {self.tokens.consents_token}"} + headers = {"Authorization": f"Bearer {consents_token}"} if expiration_time is not None or expires_in is not None: if expiration_time is None: @@ -194,16 +211,33 @@ def generate_auth_url( } if state is not None: params["state"] = urllib.parse.quote(state) - return f"https://enter.tochka.com/connect/authorize?{'&'.join([f'{a}={b}' for a, b in params.items()])}" + return ( + f"https://enter.tochka.com/connect/authorize?{'&'.join([f'{a}={b}' for a, b in params.items()])}" + ) async def get_access_token( - self, code: str, redirect_uri: str = None - ) -> tuple[str, str, datetime]: + self, + code: str, + token_id: str = None, + customer_code: str = None, + redirect_uri: str = None, + **get_tokens_params, + ) -> Tokens: + if customer_code is None and token_id is None and customer_code is not None: + raise ValueError( + "`one_customer_mode=False` requires `customer_code` or `token_id` to be" + " specified" + ) + if token_id is not None and customer_code is None: + token_data = jwt.decode(token_id, options={"verify_signature": False}) + customer_code = token_data["sub"] data = { "client_id": self.__client_id, "client_secret": self.__client_secret, "grant_type": "authorization_code", - "scope": "accounts", + "scope": ( + "accounts balances customers statements cards sbp payments special" + ), "code": code, "redirect_uri": redirect_uri or self.redirect_uri, } @@ -217,28 +251,39 @@ async def get_access_token( if response.status_code == 200: response_data = ujson.loads(response.text) - self.tokens.set_access_token( - access_token=response_data["access_token"], - refresh_token=response_data["refresh_token"], - expires_in=response_data["expires_in"], - ) - return ( - self.tokens.access_token, - self.tokens.refresh_token, - self.tokens.consents_expired_in, + if self.one_customer_mode: + self._customer_code = customer_code + tokens = self.token_manager.get_tokens( + customer_code, allow_create=True, **get_tokens_params ) + tokens.access = response_data["access_token"], response_data["expires_in"] + tokens.refresh = response_data["refresh_token"], timedelta(days=30).seconds + + return tokens # TODO: сделать обработку исключений async def refresh_tokens( - self, refresh_token: str | None = None + self, + refresh_token: str | None = None, + customer_code: str = None, + **get_tokens_param, ) -> tuple[str, str, datetime]: + tokens = None + if refresh_token is None: + if customer_code is None and not self.one_customer_mode: + raise ValueError("`refresh_token` or `customer_code` is required") + tokens = self.token_manager.get_tokens( + customer_code or self._customer_code, **get_tokens_param + ) + refresh_token = tokens.refresh + data = { "client_id": self.__client_id, "client_secret": self.__client_secret, "grant_type": "refresh_token", "scope": "accounts", - "refresh_token": refresh_token or self.tokens.refresh_token, + "refresh_token": refresh_token, } response = await self.request( @@ -250,22 +295,33 @@ async def refresh_tokens( if response.status_code == 200: response_data = ujson.loads(response.text) - self.tokens.set_access_token( - access_token=response_data["access_token"], - refresh_token=response_data["refresh_token"], - expires_in=response_data["expires_in"], - ) + access_token = response_data["access_token"] + refresh_token = response_data["refresh_token"] + expires_in = response_data["expires_in"] + + if tokens is not None: + tokens.access = access_token, expires_in + tokens.refresh = refresh_token, timedelta(days=30).seconds + return ( - self.tokens.access_token, - self.tokens.refresh_token, - self.tokens.consents_expired_in, + access_token, + refresh_token, + expires_in, ) # TODO: сделать обработку исключений - async def check_token(self, access_token: str | None = None) -> bool: + async def check_token( + self, + access_token: str | None = None, + customer_code: str = None, + **get_tokens_params, + ) -> bool: + if access_token is None: + tokens = self.token_manager.get_tokens(customer_code, **get_tokens_params) + access_token = tokens.access data = { - "access_token": access_token or self.tokens.access_token, + "access_token": access_token, } response = await self.request( diff --git a/tochka_api/modules/sbp_accounts.py b/tochka_api/modules/sbp_accounts.py deleted file mode 100644 index 1e9b2ab..0000000 --- a/tochka_api/modules/sbp_accounts.py +++ /dev/null @@ -1,38 +0,0 @@ -from models.responses import SbpAccountsResponse, TochkaBooleanResponse -from modules import TochkaApiBase - - -class TochkaAPISbpAccounts(TochkaApiBase): - async def sbp_get_accounts(self, legal_id: str) -> SbpAccountsResponse: - return await self.request( - method="GET", - url=f"/sbp/v1.0/account/{legal_id}", - ) - - async def sbp_get_account(self, legal_id: str, account_id: str): - return await self.request( - method="GET", - url=f"/sbp/v1.0/account/{legal_id}/{account_id}", - ) - - async def sbp_set_account_status( - self, legal_id: str, account_id: str, is_active: bool | str = True - ) -> TochkaBooleanResponse: - data = { - "Data": { - "status": ("Active" if is_active else "Suspended") - if type(is_active) is bool - else is_active - } - } - return await self.request( - method="PUT", - url=f"/sbp/v1.0/account/{legal_id}/{account_id}", - json=data, - ) - - async def sbp_register_account(self, legal_id: str, account_id: str) -> TochkaBooleanResponse: - return await self.request( - method="POST", - url=f"/sbp/v1.0/account/{legal_id}/{account_id}", - ) diff --git a/tochka_api/modules/sbp_legal.py b/tochka_api/modules/sbp_legal.py index 18a8d5d..6485302 100644 --- a/tochka_api/modules/sbp_legal.py +++ b/tochka_api/modules/sbp_legal.py @@ -1,7 +1,7 @@ from models.responses import ( SbpCustomerInfoResponse, SbpLegalEntityResponse, - SbpRegisterLegalEntity, + SbpRegisterLegalEntityResponse, TochkaBooleanResponse, ) from models.responses.sbp_legal import SbpAccountsResponse @@ -10,41 +10,71 @@ class TochkaApiSbpLegal(TochkaApiBase): async def sbp_get_customer_info( - self, customer_code: str + self, + customer_code: str, + bank_code: str = "044525104", + user_code: str | None = None, ) -> SbpCustomerInfoResponse: - return await self.request( - method="GET", url=f"/sbp/v1.0/customer/{customer_code}" - ) + """ + Возвращает информацию о клиенте СБП + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-YuL#get_accounts_list_sbp__apiVersion__account__legalId__get - async def sbp_get_legal_entity(self, legal_id: str) -> SbpLegalEntityResponse: + :param user_code: + :param customer_code: Код клиента в Точке + :type customer_code: ``str`` + :param bank_code: БИК банка счёта + :type bank_code: ``str``, default = ``044525104`` + :return: Схема CustomerInfoResponseV3 + ":rtype: SbpCustomerInfoResponse + """ return await self.request( - method="GET", url=f"/sbp/v1.0/legal-entity/{legal_id}" + method="GET", url=f"/sbp/v1.0/customer/{customer_code}/{bank_code}" ) - async def sbp_set_account_status( - self, legal_id: str, account_id: str, active: str | bool = True - ) -> TochkaBooleanResponse: - data = { - "Data": { - "status": ("Active" if active else "Suspended") - if type(active) is bool - else active, - } - } + async def sbp_get_legal_entity( + self, legal_id: str, user_code: str | None = None + ) -> SbpLegalEntityResponse: + """ + Метод для получения данных юрлица в Системе быстрых платежей + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-YuL#get_legal_entity_sbp__apiVersion__legal_entity__legalId__get + + :param user_code: + :param legal_id: Идентификатор юрлица в СБП + :type legal_id: ``str`` + :return: Схема LegalEntity + :rtype: SbpLegalEntityResponse + """ + return await self.request( - method="POST", - url=f"/sbp/v1.0/account/{legal_id}/{account_id}", - json=data, + method="GET", url=f"/sbp/v1.0/legal-entity/{legal_id}" ) async def sbp_set_legal_entity_status( - self, legal_id: str, active: str | bool = True + self, + legal_id: str, + is_active: str | bool = True, + user_code: str | None = None, ) -> TochkaBooleanResponse: + """ + Метод устанавливает статус юрлица в Системе быстрых платежей + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-YuL#set_legal_entity_status_sbp__apiVersion__legal_entity__legalId__post + + :param user_code: + :param legal_id: Идентификатор юрлица в СБП + :type legal_id: ``str`` + :param is_active: Устанавливаемый статус, True - Active, False - Suspended + :type is_active: ``bool``, default=``True`` + :return: Схема BooleanResponse + :rtype: TochkaBooleanResponse + """ data = { "Data": { - "status": ("Active" if active else "Suspended") - if type(active) is bool - else active, + "status": ("Active" if is_active else "Suspended") + if type(is_active) is bool + else is_active, } } return await self.request( @@ -54,26 +84,50 @@ async def sbp_set_legal_entity_status( ) async def sbp_register_legal_entity( - self, customer_code: str - ) -> SbpRegisterLegalEntity: + self, + customer_code: str, + bank_code: str = "044525104", + user_code: str | None = None, + ) -> SbpRegisterLegalEntityResponse: + """ + Выполняет регистрацию юрлица в СБП. + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-YuL#register_legal_entity_sbp__apiVersion__register_sbp_legal_entity_post + + :param user_code: + :param customer_code: Код клиента в Точке + :type customer_code: ``str`` + :param bank_code: БИК банка счёта + :type bank_code: ``str``, default = ``044525104`` + :return: Схема LegalId + ":rtype: SbpRegisterLegalEntityResponse + """ data = { "Data": { "customerCode": customer_code, + "bankCode": bank_code, } } return await self.request( method="POST", - url=f"/sbp/v1.0/register-legal-entity", + url=f"/sbp/v1.0/register-sbp-legal-entity", json=data, ) - async def sbp_get_account( - self, legal_id: str, account_id: str + async def sbp_get_accounts( + self, legal_id: str, user_code: str | None = None ) -> SbpAccountsResponse: - return await self.request( - method="GET", url=f"sbp/v1.0/account/{legal_id}/{account_id}" - ) + """ + Метод для получения списка счетов юрлица в Системе быстрых платежей + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-YuL#get_accounts_list_sbp__apiVersion__account__legalId__get + + :param user_code: + :param legal_id: Идентификатор юрлица в СБП + :type legal_id: ``str`` + :return: Схема AccountListResponse + :rtype: SbpAccountsResponse + """ - async def sbp_get_accounts(self, legal_id: str) -> SbpAccountsResponse: return await self.request(method="GET", url=f"sbp/v1.0/account/{legal_id}") diff --git a/tochka_api/modules/sbp_merchant.py b/tochka_api/modules/sbp_merchant.py index badeaf4..1c2bf19 100644 --- a/tochka_api/modules/sbp_merchant.py +++ b/tochka_api/modules/sbp_merchant.py @@ -7,13 +7,44 @@ class TochkaApiSbpMerchant(TochkaApiBase): - async def sbp_get_merchants(self, legal_id: str) -> SbpMerchantsResponse: + async def sbp_get_merchants( + self, legal_id: str, tochka_user_code: str | None = None + ) -> SbpMerchantsResponse: + """ + Метод для получения списка ТСП юрлица + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-TSP#get_merchants_list_sbp__apiVersion__merchant_legal_entity__legalId__get + + :param tochka_user_code: + :param legal_id: Идентификатор юрлица в СБП + :type legal_id: ``str`` + :return: Схема MerchantListResponse + :rtype: SbpMerchantsResponse + """ + return await self.request( method="GET", url=f"/sbp/v1.0/merchant/legal-entity/{legal_id}", ) - async def sbp_get_merchant(self, merchant_id: str) -> SbpMerchantsResponse: + async def sbp_get_merchant( + self, merchant_id: str, user_code: str | None = None + ) -> SbpMerchantsResponse: + """ + Метод для получения информации о ТСП. + + Важно: Ответ будет в такой же модели, что и sbp_get_merchants, + то есть содержать лист из одного конкретного ТСП. Не как в документации Точки. + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-TSP#get_merchant_sbp__apiVersion__merchant__merchantId__get + + :param user_code: + :param merchant_id: Идентификатор ТСП в СБП + :type merchant_id: ``str`` + :return: Схема Merchant + :rtype: SbpMerchantsResponse + """ + return await self.request( method="GET", url=f"/sbp/v1.0/merchant/{merchant_id}", @@ -28,10 +59,40 @@ async def sbp_register_merchant( city: str, region_code: str, zip_code: str, - phone_number: str, + phone_number: str | None = None, country_code: str = "RU", capabilities: str = "011", + user_code: str | None = None, ) -> SbpRegisterMerchantResponse: + """ + Регистрация мерчанта в СБП + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-TSP#register_merchant_sbp__apiVersion__merchant_legal_entity__legalId__post + + :param user_code: + :param legal_id: Идентификатор юрлица в СБП + :type legal_id: ``str`` + :param name: Название ТСП (по вывеске/бренду при наличии, либо краткое название юрлица) + :type name: ``str`` + :param mcc: MCC код ТСП + :type mcc: ``str`` + :param country_code: Код страны регистрации юрлица + :type country_code: ``str``, default=``RU`` + :param region_code: Код региона регистрации юрлица, первые две цифры ОКТМО + :type region_code: ``str`` + :param zip_code: Почтовый индекс юридического адреса юрлица + :type zip_code: ``str`` + :param city: Город юридического адреса юрлица + :type city: ``str`` + :param address: Улица, дом, корпус, офис юридического адреса юрлица + :type address: ``str`` + :param phone_number: Номер телефона ТСП до 13 знаков в любом формате + :type phone_number: ``str``, optional + :param capabilities: Возможности выпуска QR у ТСП: 001 - только статичный, 010 - только динамичный, 011 - оба + :type capabilities: ``str``, default=``011`` + :return: Схема MerchantId + :rtype: SbpRegisterMerchantResponse + """ data = { "Data": { @@ -42,18 +103,37 @@ async def sbp_register_merchant( "zipCode": zip_code, "brandName": name, "capabilities": capabilities, - "contactPhoneNumber": phone_number, "mcc": mcc, } } + if phone_number is not None: + data["Data"]["contactPhoneNumber"] = phone_number + return await self.request( method="POST", url=f"/sbp/v1.0/merchant/legal-entity/{legal_id}", json=data ) async def sbp_set_merchant_status( - self, merchant_id: str, is_active: bool | str = True + self, + merchant_id: str, + is_active: bool | str = True, + user_code: str | None = None, ) -> TochkaBooleanResponse: + """ + Метод устанавливает статус ТСП + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-TSP#set_merchant_status_sbp__apiVersion__merchant__merchantId__put + + :param user_code: + :param merchant_id: Идентификатор ТСП в СБП + :type merchant_id: ``str`` + :param is_active: Устанавливаемый статус, True - Active, False - Suspended + :type is_active: ``bool``, default=``True`` + :return: Схема BooleanResponse + :rtype: TochkaBooleanResponse + """ + data = { "Data": { "status": ("Active" if is_active else "Suspended") diff --git a/tochka_api/modules/sbp_qr.py b/tochka_api/modules/sbp_qr.py index abc9e91..454be3e 100644 --- a/tochka_api/modules/sbp_qr.py +++ b/tochka_api/modules/sbp_qr.py @@ -1,24 +1,57 @@ -from datetime import date +import re +from decimal import Decimal +from datetime import date, datetime, timedelta from typing import Literal from models.responses import ( + SbpQrPaymentDataResponse, + SbpQrPaymentStatusResponse, SbpQrsResponse, SbpRegisterQrResponse, TochkaBooleanResponse, - SbpQrPaymentDataResponse, - SbpQrPaymentStatusResponse, ) from modules import TochkaApiBase class TochkaApiSbpQr(TochkaApiBase): - async def sbp_get_qrs(self, legal_id: str) -> SbpQrsResponse: + async def sbp_get_qrs( + self, legal_id: str, user_code: str | None = None + ) -> SbpQrsResponse: + """ + Метод для получения списка всех QR кодов по всем ТСП + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-QR-kodami#get_qr_codes_list_sbp__apiVersion__qr_code_legal_entity__legalId__get + + :param user_code: + :param legal_id: Идентификатор юрлица в СБП + :type legal_id: ``str`` + :return: Схема QRCodeListResponse + :rtype: SbpQrsResponse + """ + return await self.request( method="GET", url=f"/sbp/v1.0/qr-code/legal-entity/{legal_id}", ) - async def sbp_get_qr(self, qrc_id: str) -> SbpQrsResponse: + async def sbp_get_qr( + self, qrc_id: str, user_code: str | None = None + ) -> SbpQrsResponse: + """ + Метод для получения информации о QR коде + + Важно: Ответ будет в такой же модели, что и sbp_get_qrs, + то есть содержать лист из одного конкретного QR кода. Не как в документации Точки. + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-QR-kodami#get_qr_code_sbp__apiVersion__qr_code__qrcId__get + + :param user_code: + :param qrc_id: идентификатор QR кода в СБП + :type qrc_id: ``str`` + :return: Схема QrCode + :rtype: SbpQrsResponse + """ + return await self.request( method="GET", url=f"/sbp/v1.0/qr-code/{qrc_id}", @@ -27,20 +60,52 @@ async def sbp_get_qr(self, qrc_id: str) -> SbpQrsResponse: async def sbp_register_qr( self, merchant_id: str, - account_id: str, + account: str, is_static: bool | str = True, - amount: int | None = None, + amount: int | Decimal | None = None, currency: int | None = None, ttl: int | None = None, purpose: str = "", width: int = 300, height: int = 300, media_type: Literal["image/png", "image/svg+xml"] = "image/png", + source_name: str = "https://github.com/whiteapfel/tochka_api", + user_code: str | None = None, ) -> SbpRegisterQrResponse: + """ + Метод для регистрации QR кода в СБП + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-QR-kodami#register_qr_code_sbp__apiVersion__qr_code_merchant__merchant_id___account__post + + :param user_code: + :param merchant_id: Идентификатор ТСП в СБП + :type merchant_id: ``str`` + :param account: идентификатор счёта юрлица (обычно в формате "{номер счёта}/{бик счёта}") + :type account: ``str`` + :param is_static: статичный (True) или динамический (False) QR код + :type is_static: ``bool``, default=``True`` + :param amount: Сумма, если нужна фиксированная. В копейках (``int``) или в рублях (``Decimal``). Для Decimal будет округление до копейки, то есть два знака после запятой. Обязательна для динамического (is_static=False). + :type amount: ``int`` | ``Decimal``, optional + :param currency: трёхсимвольный код валюты + :type currency: ``str``, default=``"RUB"`` + :param ttl: Период активности динамического (is_static=False) кода в минутах. 0 - максимальный, но не бесконечный + :type ttl: ``int``, default=``0`` + :param purpose: Комментарий к QR коду, до 140 символов + :type purpose: ``str``, default=``""`` + :param width: ширина изображения QR кода, не меньше 200 + :type width: ``int``, default=``300`` + :param height: высота изображения QR кода, не меньше 300 + :type height: ``int``, default=``300`` + :param media_type: Тип изображения: ``image/png`` или ``image/svg+xml`` + :type media_type: ``str``, default=``"image/png"`` + :param source_name: Название системы, выпустившей QR код + :type source_name: ``str``, optional + :return: Схема RegisteredQrCode + :rtype: SbpRegisterQrResponse + """ + data = { "Data": { - "amount": amount, - "currency": currency or "RUB", "paymentPurpose": purpose, "qrcType": ("01" if is_static else "02") if type(is_static) is bool @@ -50,20 +115,46 @@ async def sbp_register_qr( "height": height, "media_type": media_type, }, - "sourceName": "whiteapfel/tochka_api", - "ttl": ttl or 0, + "sourceName": source_name, } } + if not is_static: + data["Data"]["ttl"] = ttl or 0 + if amount is None: + raise ValueError( + "Dynamic QR code (is_static=False) requires a non-zero and non-None" + " amount value." + ) + if amount is not None: + data["Data"]["amount"] = amount + data["Data"]["currency"] = currency or "RUB" return await self.request( method="POST", - url=f"/sbp/v1.0/qr-code/merchant/{merchant_id}/{account_id}", + url=f"/sbp/v1.0/qr-code/merchant/{merchant_id}/{account}", json=data, ) async def sbp_set_qr_status( - self, qrc_id: str, is_active: bool | str = True + self, + qrc_id: str, + is_active: bool | str = True, + user_code: str | None = None, ) -> TochkaBooleanResponse: + """ + Метод устанавливает статус QR-кода + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-QR-kodami#set_qr_code_status_sbp__apiVersion__qr_code__qrcId__put + + :param user_code: + :param qrc_id: идентификатор QR кода в СБП + :type qrc_id: ``str`` + :param is_active: Устанавливаемый статус, True - Active, False - Suspended + :type is_active: ``bool``, default=``True`` + :return: Схема BooleanResponse + :rtype: TochkaBooleanResponse + """ + data = { "Data": { "status": ("Active" if is_active else "Suspended") @@ -77,21 +168,86 @@ async def sbp_set_qr_status( json=data, ) - async def sbp_get_qr_payment_data(self, qrc_id: str) -> SbpQrPaymentDataResponse: + async def sbp_get_qr_payment_data( + self, qrc_id: str, user_code: str | None = None + ) -> SbpQrPaymentDataResponse: + """ + Метод для получения данных о QR-коде и ТСП по идентификатору QR-кода + + Описание модели ответа расписано в документации к ней: SbpQrPaymentDataResponse + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-QR-kodami#get_qr_code_payment_data_sbp__apiVersion__qr_code__qrcId__payment_sbp_data_get + + :param user_code: + :param qrc_id: идентификатор QR кода в СБП + :type qrc_id: ``str`` + :return: Схема QrCodePaymentDataV3 + :rtype: SbpQrPaymentDataResponse + """ return await self.request( - method="GET", url=f"/sbp/v1.0/qr-code/{qrc_id}/payment-data" + method="GET", url=f"/sbp/v1.0/qr-code/{qrc_id}/payment-sbp-data" ) - async def sbp_get_qr_payment_status( - self, qrc_id: str, from_date: date | None = None, to_date: date | None = None + async def sbp_get_qrs_payment_status( + self, + qrc_ids: list[str] | str, + from_date: datetime | date | int | str | None = None, + to_date: datetime | date | int | str | None = None, + user_code: str | None = None, ) -> SbpQrPaymentStatusResponse: - data = {} + """ + Метод для получения статусов операций по динамическим QR-кодам + + Параметры ``from/to_date`` могут принимать date, datetime + или строку в формате ``YYYY-MM-DD``, + а также ``int``, который расценивается как количество дней назад. + Например, можно указать from_date=7, чтобы получить за прошедшие 7 дней + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-QR-kodami#get_qr_codes_payment_status_sbp__apiVersion__qr_codes__qrc_ids__payment_status_get + + :param user_code: + :param qrc_ids: идентификатор QR кода в СБП + :type qrc_ids: ``list[str]`` | ``str`` + :param from_date: начало периода для получения статусов + :type from_date: ``datetime`` | ``date`` | ``int`` | ``str``, optional + :param to_date: конец периодов для получения статусов + :type to_date: ``datetime`` | ``date`` | ``int`` | ``str``, optional + :return: Схема QRCodePaymentStatusListResponse + :rtype: SbpQrPaymentStatusResponse + """ + if isinstance(qrc_ids, str): + qrc_ids = [qrc_ids] + + params = {} + if from_date is not None: - data["fromDate"] = from_date.strftime("%Y-%m-%d") + if isinstance(from_date, int): + from_date: date = (datetime.now() - timedelta(days=1)).date() + if isinstance(from_date, datetime): + from_date: date = from_date.date() + if isinstance(from_date, date): + from_date: str = from_date.strftime("%Y-%m-%d") + + if not re.match(r"^\d{4}-\d{2}-\d{2}$", from_date): + raise ValueError("from_date must be in 'YYYY-MM-DD' format (%Y-%m-%d)") + + params["fromDate"] = from_date + if to_date is not None: - data["toDate"] = to_date.strftime("%Y-%m-%d") + if isinstance(to_date, int): + to_date: date = (datetime.now() - timedelta(days=1)).date() + if isinstance(to_date, datetime): + to_date: date = to_date.date() + if isinstance(to_date, date): + to_date: date = to_date.strftime("%Y-%m-%d") + + if not re.match(r"^\d{4}-\d{2}-\d{2}$", to_date): + raise ValueError("from_date must be in 'YYYY-MM-DD' format (%Y-%m-%d)") + + params["toDate"] = to_date + return await self.request( method="GET", - url=f"/sbp/v1.0/qr-code/{qrc_id}/payment-status", - params=data, + url=f"/sbp/v1.0/qr-code/{','.join(qrc_ids)}/payment-status", + params=params, ) diff --git a/tochka_api/modules/sbp_refunds.py b/tochka_api/modules/sbp_refunds.py index 9bdbc28..be430c1 100644 --- a/tochka_api/modules/sbp_refunds.py +++ b/tochka_api/modules/sbp_refunds.py @@ -1,40 +1,184 @@ +import re +from _decimal import Decimal +from datetime import datetime, timedelta, date + from models.responses import SbpPaymentsResponse, SbpRefundResponse from modules import TochkaApiBase +from settings import CHARS_FOR_PURPOSE class TochkaApiSbpRefunds(TochkaApiBase): async def sbp_get_payments( - self, customer_code: str, from_date: str, to_date: str + self, + customer_code: str, + qrc_id: str | None = None, + from_date: datetime | date | int | str | None = None, + to_date: datetime | date | int | str | None = None, + page: int = 1, + per_page: int = 1000, + user_code: str | None = None, ) -> SbpPaymentsResponse: + """ + Метод для получения списка платежей в Системе быстрых платежей + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-vozvratami#get_payments_sbp__apiVersion__get_sbp_payments_get + + :param user_code: + :param customer_code: Код клиента в Точке + :type customer_code: ``str`` + :param qrc_id: идентификатор QR кода в СБП + :type qrc_id: ``str`` + :param from_date: начало периода для получения статусов + :type from_date: ``datetime`` | ``date`` | ``int`` | ``str``, optional + :param to_date: конец периодов для получения статусов + :type to_date: ``datetime`` | ``date`` | ``int`` | ``str``, optional + :param page: страница выдачи + :type page: ``int``, default=``1`` + :param per_page: количество элементов на страницу + :type per_page: ``int``, default=``1000`` + :return: Схема SBPPaymentList + :rtype: SbpPaymentsResponse + """ + params = { + "customerCode": customer_code, + "page": page, + "perPage": per_page, + } + + if from_date is not None: + if isinstance(from_date, int): + from_date: date = (datetime.now() - timedelta(days=1)).date() + if isinstance(from_date, datetime): + from_date: date = from_date.date() + if isinstance(from_date, date): + from_date: str = from_date.strftime("%Y-%m-%d") + + if not re.match(r"^\d{4}-\d{2}-\d{2}$", from_date): + raise ValueError("from_date must be in 'YYYY-MM-DD' format (%Y-%m-%d)") + + params["fromDate"] = from_date + + if to_date is not None: + if isinstance(to_date, int): + to_date: date = (datetime.now() - timedelta(days=1)).date() + if isinstance(to_date, datetime): + to_date: date = to_date.date() + if isinstance(to_date, date): + to_date: date = to_date.strftime("%Y-%m-%d") + + if not re.match(r"^\d{4}-\d{2}-\d{2}$", to_date): + raise ValueError("from_date must be in 'YYYY-MM-DD' format (%Y-%m-%d)") + + params["toDate"] = to_date + + if qrc_id is not None: + params["qrcId"] = qrc_id + return await self.request( method="GET", - url=f"/sbp/v1.0/get-payments?customerCode={customer_code}&fromDate={from_date}&toDate={to_date}", + url=f"/sbp/v1.0/get-sbp-payments?customerCode={customer_code}&fromDate={from_date}&toDate={to_date}", + params=params, ) async def sbp_start_refund( self, account: str, - amount: str, - qrc: str, - trx: str, + amount: Decimal | str | int, + qrc_id: str, + trx_id: str, + purpose: str = None, currency: str = "RUB", - bank_code: str = "044525999", + bank_code: str = "044525104", + is_non_resident: bool = False, + user_code: str | None = None, ) -> SbpRefundResponse: + """ + Метод запрашивает возврат платежа через Систему быстрых платежей + + Для совместимости с другими методами, аргумент amount при получении значения ``int`` + будет воспринимать эту сумму в копейках. + + От разработчика: + Для возврата нерезиденту вы можете не прописывать назначение + из комментария от Точки, + а просто указать is_non_resident=True, метод самостоятельно добавит + нужный префикс к назначению или создаст его. + + Следует уточнить актуальные требования на странице метода API, и в случае изменений, + вернуть параметр на False и руками сгенерировать необходимое purpose. + + От Точки: + Если нужно вернуть деньги нерезиденту, назначение платежа должно + начинаться с «{VO99020} Возврат ошибочно полученной суммы transactionId», + где transactionId — это идентификатор оригинальной операции. + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-vozvratami#start_refund_sbp__apiVersion__refund_post + + :param user_code: + :param account: идентификатор счёта юрлица (обычно в формате "{номер счёта}/{бик счёта}") + :type account: ``str`` + :param amount: Сумма возврата в рублях (``Decimal`` | ``str``) или в копейках (``int``) + :type amount: ``Decimal`` | ``str`` | ``int`` + :param qrc_id: идентификатор QR кода в СБП + :type qrc_id: ``str`` + :param trx_id: идентификатор транзакции по QR коду в СБП + :type trx_id: ``str`` + :param purpose: назначение вовзрата + :type purpose: ``str``, optional + :param currency: трёхсимвольный код валюты + :type currency: ``str``, default=``"RUB"`` + :param bank_code: БИК банка счёта + :type bank_code: ``str``, default = ``044525104`` + :param is_non_resident: параметр для автоматического добавления префикса к назначению + :return: Схема SBPRefundRequestResponse + :rtype: SbpRefundResponse + """ + if isinstance(amount, str): + amount: Decimal = Decimal(amount) + if isinstance(amount, int): + amount: Decimal = Decimal(amount) / Decimal(100) + data = { "Data": { "bankCode": bank_code, "accountCode": account, - "amount": amount, + "amount": str(amount.quantize(Decimal("0.00"))), "currency": currency, - "qrcId": qrc, - "refTransactionId": trx, + "qrcId": qrc_id, + "refTransactionId": trx_id, } } + + if is_non_resident: + purpose = ( + f"{{VO99020}} Возврат ошибочно полученной суммы {trx_id}. " + purpose + if purpose is not None + else "" + ) + + if purpose is not None: + data["Data"]["purpose"] = "".join( + c for c in purpose if c in CHARS_FOR_PURPOSE + ).strip()[:140] + return await self.request( method="POST", url="/sbp/v1.0/refund", json=data, ) - async def sbp_get_refund_data(self, request_id: str) -> SbpRefundResponse: + async def sbp_get_refund_data( + self, request_id: str, user_code: str | None = None + ) -> SbpRefundResponse: + """ + Метод для получения информация о платеже-возврате по Системе быстрых платежей + + https://enter.tochka.com/doc/v2/redoc/tag/Servis-SBP:-Rabota-s-vozvratami#get_refund_data_sbp__apiVersion__refund__request_id__get + + :param user_code: + :param request_id: идентификатор запроса на возврат + :type request_id: ``str`` + :return: Странная схема + :rtype: SbpRefundResponse + """ return await self.request(method="GET", url=f"/sbp/v1.0/refund/{request_id}") diff --git a/tochka_api/settings.py b/tochka_api/settings.py index ae9f34b..7bd1ec2 100644 --- a/tochka_api/settings.py +++ b/tochka_api/settings.py @@ -3,3 +3,16 @@ TOCHKA_SANDBOX_API_URL: str = "https://enter.tochka.com/sandbox/v2" TOCHKA_SANDBOX_VALID_TOKEN: str = "working_token" TOCHKA_SANDBOX_INVALID_TOKEN: str = "invalid_token" +# fmt: off +CHARS_FOR_PURPOSE = { + '`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', '~', '!', '@', '#', '$', '%', '^', + '&', '*', '(', ')', '_', '+', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', 'Q', 'W', + 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', + "'", 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', + '.', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?', 'й', 'ц', 'у', 'к', 'е', 'н', 'г', 'ш', 'щ', + 'з', 'х', 'ъ', 'Й', 'Ц', 'У', 'К', 'Е', 'Н', 'Г', 'Ш', 'Щ', 'З', 'Х', 'Ъ', 'ф', 'ы', 'в', 'а', 'п', + 'р', 'о', 'л', 'д', 'ж', 'э', 'Ф', 'Ы', 'В', 'А', 'П', 'Р', 'О', 'Л', 'Д', 'Ж', 'Э', 'я', 'ч', 'с', + 'м', 'и', 'т', 'ь', 'б', 'ю', '.', 'Я', 'Ч', 'С', 'М', 'И', 'Т', 'Ь', 'Б', 'Ю', '\\', '/', '|', + ' ', 'ё', 'Ё', '№' +} +# fmt: on diff --git a/tochka_api/token_manager.py b/tochka_api/token_manager.py new file mode 100644 index 0000000..b0af00c --- /dev/null +++ b/tochka_api/token_manager.py @@ -0,0 +1,106 @@ +import hashlib +from abc import ABC, abstractmethod +from base64 import b64decode, b64encode +from hashlib import md5 +from pathlib import Path + +import orjson as orjson +from appdirs import AppDirs +from Cryptodome.Cipher import AES +from models.tokens import Tokens + + +class AbstractTokenManager(ABC): + def __init__(self, client_id: str, **kwargs): + self.client_id = client_id + self.tokens_mapper: dict[str, Tokens] = {} + + @abstractmethod + def on_update(self, user_code: str, tokens_data: Tokens) -> None: + ... + + @abstractmethod + def get_tokens( + self, user_code: str, allow_create: bool = False, **kwargs + ) -> Tokens: + ... + + @abstractmethod + def load_tokens(self, **kwargs): + ... + + +class InMemoryTokenManager(AbstractTokenManager): + def __init__(self, client_id: str): + super().__init__(client_id=client_id) + + def on_update(self, user_code: str, tokens_data: Tokens) -> None: + pass + + def get_tokens( + self, user_code: str, allow_create: bool = False, **kwargs + ) -> Tokens: + if allow_create: + return self.tokens_mapper.setdefault( + user_code, Tokens(user_code, self.on_update) + ) + return self.tokens_mapper[user_code] + + def load_tokens(self, **kwargs): + pass + + +class LocalStorageTokenManager(AbstractTokenManager): + def __init__(self, client_id: str, tokens_path: str = None): + super().__init__(client_id=client_id) + + self.json_dict = {} + + self.salt = md5(b"whiteapfel").hexdigest().encode() + self.key = hashlib.scrypt( + client_id.encode(), salt=self.salt, n=2, r=8, p=2, dklen=32 + ) + + self.tokens_path = Path(tokens_path) if tokens_path is not None else None + if self.tokens_path is None: + app_dirs = AppDirs("tochka_api", "whiteapfel") + self.tokens_path = Path( + f"{app_dirs.user_data_dir}/{md5(self.client_id.encode()).hexdigest()}/tokens.json" + ) + if not self.tokens_path.exists(): + self.tokens_path.parent.mkdir(exist_ok=True) + self.tokens_path.touch() + self.save_all() + + def get_cipher(self): + return AES.new(self.key, AES.MODE_EAX, b64decode(b"GAYGAY0WHITEAPFELGAYEw==")) + + def save_all(self): + json_string = orjson.dumps(self.json_dict) + ciphertext, tag = self.get_cipher().encrypt_and_digest(json_string) + encrypted_string = tag + ciphertext + encoded_b64_string = b64encode(encrypted_string).decode() + self.tokens_path.write_text(encoded_b64_string) + + def on_update(self, user_code: str, tokens_data: Tokens) -> None: + self.json_dict[user_code] = tokens_data.dump()[1] + self.save_all() + + def get_tokens( + self, user_code: str, allow_create: bool = False, **kwargs + ) -> Tokens: + if allow_create: + return self.tokens_mapper.setdefault( + user_code, Tokens(user_code, self.on_update) + ) + return self.tokens_mapper[user_code] + + def load_tokens(self, **kwargs): + encoded_b64_string = self.tokens_path.read_text() + encrypted_string = b64decode(encoded_b64_string) + tag, ciphertext = encrypted_string[:16], encrypted_string[16:] + json_string = self.get_cipher().decrypt_and_verify(ciphertext, tag) + self.json_dict = orjson.loads(json_string) + for user_code, tokens_data in self.json_dict.items(): + self.tokens_mapper[user_code] = Tokens(user_code, self.on_update) + self.tokens_mapper[user_code].load(user_code, tokens_data)