From 06876fe1886fb24c00374e0cf585c01878deab7e Mon Sep 17 00:00:00 2001 From: Flummy1 Date: Thu, 20 Aug 2026 20:36:14 +0300 Subject: [PATCH] feat: add WithdrawCalc method Add support for the withdrawal amount calculation endpoint (https://funpay.com/withdraw/calc), which FunPay uses to convert between the amount debited from the balance and the amount credited to the wallet. Exactly one of `amount_int` / `amount_ext` must be passed: the one that is omitted is the one FunPay calculates and returns. New files: - types/withdraw.py: WithdrawCalcResult pydantic model - methods/withdraw_calc.py: WithdrawCalc method Also adds the `Bot.calc_withdraw()` shortcut. Co-Authored-By: Claude Opus 5 --- funpaybotengine/client/bot.py | 34 +++++++- funpaybotengine/methods/__init__.py | 1 + funpaybotengine/methods/withdraw_calc.py | 100 +++++++++++++++++++++++ funpaybotengine/types/__init__.py | 1 + funpaybotengine/types/withdraw.py | 39 +++++++++ 5 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 funpaybotengine/methods/withdraw_calc.py create mode 100644 funpaybotengine/types/withdraw.py diff --git a/funpaybotengine/client/bot.py b/funpaybotengine/client/bot.py index c8d902a..d9d1cf2 100644 --- a/funpaybotengine/client/bot.py +++ b/funpaybotengine/client/bot.py @@ -27,7 +27,8 @@ PrivateChatPreview, TransactionPreviewsBatch, CurrentlyViewingOfferInfo, - RaiseOffersResponse + RaiseOffersResponse, + WithdrawCalcResult, ) from funpaybotengine.utils import ( random_runner_tag, @@ -44,6 +45,7 @@ MuteChat, CalcChips, CheckBanned, + WithdrawCalc, GetChatPage, GetMainPage, RaiseOffers, @@ -463,6 +465,36 @@ async def calc_lots(self, subcategory_id: int, price: float) -> CalcResult: await CalcLots(subcategory_id=subcategory_id, price=price).execute(self) ).response_obj + async def calc_withdraw( + self, + currency_id: str, + ext_currency_id: str, + wallet: str, + amount_int: float | None = None, + amount_ext: float | None = None, + ) -> WithdrawCalcResult: + """ + Calculate a withdrawal amount. + + Exactly one of ``amount_int`` / ``amount_ext`` must be specified: + FunPay calculates the other one and returns it. + + :param currency_id: balance currency ID (e.g. ``'rub'``). + :param ext_currency_id: withdrawal method ID (e.g. ``'card_rub'``, ``'fps'``). + :param wallet: card / phone / wallet number to withdraw to. + :param amount_int: amount to debit from the FunPay balance. + :param amount_ext: amount to credit to the wallet. + """ + return ( + await WithdrawCalc( + currency_id=currency_id, + ext_currency_id=ext_currency_id, + wallet=wallet, + amount_int=amount_int, + amount_ext=amount_ext, + ).execute(self) + ).response_obj + async def logout(self) -> bool: if self._logout_token is None: await self.update() diff --git a/funpaybotengine/methods/__init__.py b/funpaybotengine/methods/__init__.py index 2c8e77b..7459310 100644 --- a/funpaybotengine/methods/__init__.py +++ b/funpaybotengine/methods/__init__.py @@ -16,6 +16,7 @@ from .get_main_page import * from .get_purchases import * from .upload_avatar import * +from .withdraw_calc import * from .get_2fa_status import * from .get_offer_page import * from .get_order_page import * diff --git a/funpaybotengine/methods/withdraw_calc.py b/funpaybotengine/methods/withdraw_calc.py new file mode 100644 index 0000000..5b1ada2 --- /dev/null +++ b/funpaybotengine/methods/withdraw_calc.py @@ -0,0 +1,100 @@ +from __future__ import annotations + + +__all__ = ('WithdrawCalc',) + + +import json +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, model_validator +from typing_extensions import Self + +from funpaybotengine.types.enums import Language +from funpaybotengine.methods.base import FunPayMethod +from funpaybotengine.client.session import HTTPMethod +from funpaybotengine.types.withdraw import WithdrawCalcResult + + +if TYPE_CHECKING: + from funpaybotengine.client import Bot, RawResponse + + +class WithdrawCalc(FunPayMethod[WithdrawCalcResult], BaseModel): + """ + Calculate a withdrawal amount (``https://funpay.com/withdraw/calc``). + + Exactly one of ``amount_int`` / ``amount_ext`` must be specified: + FunPay calculates the other one and returns it. + + Returns ``funpaybotengine.types.withdraw.WithdrawCalcResult`` obj. + """ + + currency_id: str + """Balance currency ID (e.g. ``'rub'``).""" + + ext_currency_id: str + """Withdrawal method ID (e.g. ``'card_rub'``, ``'fps'``).""" + + wallet: str + """Card / phone / wallet number to withdraw to.""" + + amount_int: float | None = None + """Amount to debit from the FunPay balance. Mutually exclusive with ``amount_ext``.""" + + amount_ext: float | None = None + """Amount to credit to the wallet. Mutually exclusive with ``amount_int``.""" + + __model_to_build__ = WithdrawCalcResult + + def __init__( + self, + currency_id: str, + ext_currency_id: str, + wallet: str, + amount_int: float | None = None, + amount_ext: float | None = None, + locale: Language | None = None, + ) -> None: + super().__init__( + url='withdraw/calc', + method=HTTPMethod.POST, + locale=locale, + expected_status_codes=[200], + headers={'X-Requested-With': 'XMLHttpRequest'}, + data=make_data, + allow_anonymous=False, + allow_uninitialized=False, + currency_id=currency_id, + ext_currency_id=ext_currency_id, + wallet=wallet, + amount_int=amount_int, + amount_ext=amount_ext, + ) + + @model_validator(mode='after') + def _check_amounts(self) -> Self: + if (self.amount_int is None) == (self.amount_ext is None): + raise ValueError( + 'Exactly one of `amount_int` / `amount_ext` must be specified: ' + 'FunPay calculates the one that is not passed.', + ) + return self + + async def parse_result(self, response: RawResponse[WithdrawCalcResult]) -> dict[str, Any]: + return json.loads(response.raw_response) # type: ignore # always dict + + +async def make_data(method: WithdrawCalc, bot: Bot) -> dict[str, Any]: + # The amount that must be calculated is not passed at all: + # FunPay returns it in the response. + amount_field = 'amount_int' if method.amount_int is not None else 'amount_ext' + amount_value = method.amount_int if method.amount_int is not None else method.amount_ext + + return { + 'preview': '1', + 'currency_id': method.currency_id, + 'ext_currency_id': method.ext_currency_id, + 'wallet': method.wallet, + amount_field: amount_value, + } diff --git a/funpaybotengine/types/__init__.py b/funpaybotengine/types/__init__.py index 0f13264..5341ee0 100644 --- a/funpaybotengine/types/__init__.py +++ b/funpaybotengine/types/__init__.py @@ -13,5 +13,6 @@ from .messages import * from .sras import * from .settings import * +from .withdraw import * from .categories import * from .common_page_elements import * diff --git a/funpaybotengine/types/withdraw.py b/funpaybotengine/types/withdraw.py new file mode 100644 index 0000000..228e1fa --- /dev/null +++ b/funpaybotengine/types/withdraw.py @@ -0,0 +1,39 @@ +from __future__ import annotations + + +__all__ = ('WithdrawCalcResult',) + + +from typing import Any + +from pydantic import BaseModel, field_validator + +from funpaybotengine.types.base import FunPayObject + + +class WithdrawCalcResult(FunPayObject, BaseModel): + """ + Represents a result of a withdrawal amount calculation method (``withdraw/calc``). + + FunPay calculates only the amount that was **not** passed in the request: + if ``amount_int`` is passed, ``amount_ext`` is calculated, and vice versa. + + The field that was passed in the request is not present in the response, + and therefore is always ``None``. + """ + + amount_int: float | None = None + """Amount debited from the FunPay balance, if calculated.""" + + amount_ext: float | None = None + """Amount credited to the wallet (i.e. fee already deducted), if calculated.""" + + @field_validator('amount_int', 'amount_ext', mode='before') + @classmethod + def _validate_amount(cls, value: Any) -> float | None: + if value is None or value == '': + return None + if isinstance(value, str): + normalized = value.replace('\xa0', '').replace(' ', '').replace(',', '.') + return float(normalized) + return float(value)