From 69b59fecc898c0bf5ed078924328e92e8c87b90a Mon Sep 17 00:00:00 2001 From: Flummy1 Date: Fri, 10 Jul 2026 00:05:31 +0300 Subject: [PATCH 1/6] fix: preserve requested transaction filter across pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TransactionPreviewsBatch.next_batch()` re-derived the filter from `self.filter`, which is scraped from the response HTML. FunPay omits the hidden `filter` input in some responses, so `self.filter` became `None` and `self.filter or ''` silently widened the filter to `TransactionFilter.ALL` — every page after the first returned the entire transaction feed instead of the requested category. `TransactionFilter.ALL` is `''` and therefore falsy, so no `or`-based fallback on this enum can distinguish "no filter" from "unknown filter". Thread the requested filter through the method context, the same way `GetSales` threads `order_preview_type`, and let `next_batch()` fail loudly on an unknown filter instead of defaulting to the widest one — mirroring `OrderPreviewsBatch.next_batch()`. - `GetTransactions` now passes `context={'transaction_filter': ...}`. - `TransactionPreviewsBatch` prefers the requested filter over the scraped one. - `next_batch()` raises `ValueError` when the filter is unknown. - `GetTransactionsPage` marks its batch as `ALL` (the balance page is unfiltered). - `GetTransactions()` no longer fails validation when called without arguments (`from_transaction_id` defaulted to `None` against an `int` field). Adds a regression suite covering the end-to-end path with a response that has no hidden `filter` input. --- funpaybotengine/methods/get_transactions.py | 7 +- .../methods/get_transactions_page.py | 4 +- funpaybotengine/types/finances.py | 22 ++- pyproject.toml | 6 + tests/test_transactions_pagination.py | 161 ++++++++++++++++++ 5 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 tests/test_transactions_pagination.py diff --git a/funpaybotengine/methods/get_transactions.py b/funpaybotengine/methods/get_transactions.py index 9a6c92d..732945f 100644 --- a/funpaybotengine/methods/get_transactions.py +++ b/funpaybotengine/methods/get_transactions.py @@ -33,7 +33,7 @@ class GetTransactions(FunPayMethod[TransactionPreviewsBatch], BaseModel): def __init__( self, filter: TransactionFilter | str = TransactionFilter.ALL, - from_transaction_id: int | None = None, + from_transaction_id: int = 0, locale: Language | None = None, ): super().__init__( @@ -44,6 +44,7 @@ def __init__( allow_anonymous=False, allow_uninitialized=False, data=make_data, + context=make_context, filter=filter, from_transaction_id=from_transaction_id, ) @@ -55,3 +56,7 @@ async def make_data(method: GetTransactions, bot: Bot) -> dict[str, Any]: 'continue': str(method.from_transaction_id) if method.from_transaction_id > 0 else '', 'user_id': str(bot.userid), } + + +async def make_context(method: GetTransactions, bot: Bot) -> dict[str, Any]: + return {'transaction_filter': method.filter} diff --git a/funpaybotengine/methods/get_transactions_page.py b/funpaybotengine/methods/get_transactions_page.py index 4e680ec..a45a181 100644 --- a/funpaybotengine/methods/get_transactions_page.py +++ b/funpaybotengine/methods/get_transactions_page.py @@ -7,7 +7,7 @@ from pydantic import BaseModel from funpayparsers.parsers.page_parsers import TransactionsPageParser -from funpaybotengine.types.enums import Language +from funpaybotengine.types.enums import Language, TransactionFilter from funpaybotengine.types.pages import TransactionsPage from funpaybotengine.methods.base import FunPayMethod from funpaybotengine.client.session import HTTPMethod @@ -26,4 +26,6 @@ def __init__(self, locale: Language | None = None): parser_cls=TransactionsPageParser, allow_anonymous=False, allow_uninitialized=True, + # The balance page always renders an unfiltered transaction list. + context={'transaction_filter': TransactionFilter.ALL}, ) diff --git a/funpaybotengine/types/finances.py b/funpaybotengine/types/finances.py index 16d6827..bda0886 100644 --- a/funpaybotengine/types/finances.py +++ b/funpaybotengine/types/finances.py @@ -4,6 +4,7 @@ __all__ = ('TransactionPreview', 'TransactionInfo', 'TransactionPreviewsBatch') +from typing import Any from types import MappingProxyType from collections.abc import Mapping @@ -84,7 +85,22 @@ class TransactionPreviewsBatch(FunPayObject, BaseModel): """ID of the user to whom all transactions in this batch belong.""" filter: TransactionFilter | None - """Transactions filter applied to the current batch.""" + """ + Transactions filter applied to the current batch. + + The filter passed to the request takes precedence; the value scraped from the + response HTML is only a fallback, since FunPay omits the hidden ``filter`` input + in some responses. + + ``None`` means the filter is unknown, which is not the same as + ``TransactionFilter.ALL``. + """ + + def model_post_init(self, context: dict[Any, Any]) -> None: + super().model_post_init(context) + requested_filter = context.get('transaction_filter') if context else None + if isinstance(requested_filter, TransactionFilter): + self.filter = requested_filter @field_validator('filter', mode='before') @classmethod @@ -112,8 +128,10 @@ def _coerce_transaction_filter( async def next_batch(self) -> TransactionPreviewsBatch: if not self.next_transaction_id: raise ValueError('Last batch.') + if self.filter is None: + raise ValueError('Unknown transaction filter.') return await self.get_bound_bot().get_transactions( from_transaction_id=self.next_transaction_id, - filter=self.filter or '', + filter=self.filter, ) diff --git a/pyproject.toml b/pyproject.toml index 67f90a9..987b28d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,12 @@ redis = [ requires = ["hatchling"] build-backend = "hatchling.build" +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +# Test the working tree, never a funpaybotengine release installed in the environment. +pythonpath = ["."] + [tool.mypy] plugins = ["pydantic.mypy"] python_version = "3.10" diff --git a/tests/test_transactions_pagination.py b/tests/test_transactions_pagination.py new file mode 100644 index 0000000..106c4b1 --- /dev/null +++ b/tests/test_transactions_pagination.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from typing import Any +from http import HTTPStatus + +import pytest +from funpaybotengine.types.enums import TransactionFilter +from funpaybotengine.types.finances import TransactionPreviewsBatch +from funpaybotengine.client.session.base import RawResponse +from funpaybotengine.methods.get_transactions import GetTransactions +from funpaybotengine.methods.get_transactions_page import GetTransactionsPage + + +# A real ``users/transactions`` response: it carries a ``continue`` cursor but no +# hidden ``filter`` input, which is exactly the shape that used to widen the filter. +RESPONSE_WITHOUT_FILTER_INPUT = """ +
+ 1 июля, 12:00 + Вывод средств +
-100 ₽
+
+ + +""" + + +class BotStub: + """Records the arguments ``next_batch()`` forwards to ``Bot.get_transactions``.""" + + userid = 777 + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + async def get_transactions( + self, + from_transaction_id: int = 0, + filter: TransactionFilter | str = TransactionFilter.ALL, + ) -> None: + self.calls.append({'from_transaction_id': from_transaction_id, 'filter': filter}) + + +def make_batch( + *, + scraped_filter: str | None, + context: dict[str, Any] | None = None, + next_transaction_id: int | None = 42, +) -> TransactionPreviewsBatch: + return TransactionPreviewsBatch.model_validate( + { + 'raw_source': '', + 'transactions': (), + 'user_id': 1, + 'filter': scraped_filter, + 'next_transaction_id': next_transaction_id, + }, + context=context, + ) + + +async def test_get_transactions_exposes_requested_filter_via_context() -> None: + method = GetTransactions(filter=TransactionFilter.WITHDRAW) + + context = await method.get_context(None) # type: ignore[arg-type] + + assert context['transaction_filter'] is TransactionFilter.WITHDRAW + + +async def test_get_transactions_accepts_no_arguments() -> None: + method = GetTransactions() + + assert method.from_transaction_id == 0 + assert method.filter is TransactionFilter.ALL + + +async def test_requested_filter_wins_over_missing_hidden_input() -> None: + batch = make_batch( + scraped_filter=None, + context={'transaction_filter': TransactionFilter.WITHDRAW}, + ) + + assert batch.filter is TransactionFilter.WITHDRAW + + +async def test_next_batch_keeps_requested_filter() -> None: + bot = BotStub() + batch = make_batch( + scraped_filter=None, + context={'transaction_filter': TransactionFilter.WITHDRAW}, + ) + batch.bind_to(bot) # type: ignore[arg-type] + + await batch.next_batch() + + assert bot.calls == [{'from_transaction_id': 42, 'filter': TransactionFilter.WITHDRAW}] + + +async def test_next_batch_preserves_all_filter() -> None: + bot = BotStub() + batch = make_batch( + scraped_filter='', + context={'transaction_filter': TransactionFilter.ALL}, + ) + batch.bind_to(bot) # type: ignore[arg-type] + + await batch.next_batch() + + assert bot.calls == [{'from_transaction_id': 42, 'filter': TransactionFilter.ALL}] + + +async def test_next_batch_falls_back_to_scraped_filter_without_context() -> None: + bot = BotStub() + batch = make_batch(scraped_filter='withdraw') + batch.bind_to(bot) # type: ignore[arg-type] + + await batch.next_batch() + + assert bot.calls == [{'from_transaction_id': 42, 'filter': TransactionFilter.WITHDRAW}] + + +async def test_next_batch_raises_when_filter_is_unknown() -> None: + batch = make_batch(scraped_filter=None) + batch.bind_to(BotStub()) # type: ignore[arg-type] + + with pytest.raises(ValueError, match='Unknown transaction filter.'): + await batch.next_batch() + + +async def test_next_batch_raises_on_last_batch() -> None: + batch = make_batch(scraped_filter='withdraw', next_transaction_id=None) + batch.bind_to(BotStub()) # type: ignore[arg-type] + + with pytest.raises(ValueError, match='Last batch.'): + await batch.next_batch() + + +async def test_transactions_page_batch_is_marked_as_unfiltered() -> None: + context = await GetTransactionsPage().get_context(None) # type: ignore[arg-type] + + assert context['transaction_filter'] is TransactionFilter.ALL + + +async def test_filtered_pagination_survives_a_response_without_filter_input() -> None: + bot = BotStub() + method = GetTransactions(filter=TransactionFilter.WITHDRAW) + response: RawResponse[TransactionPreviewsBatch] = RawResponse( + url='https://funpay.com/users/transactions', + status_code=HTTPStatus.OK, + raw_response=RESPONSE_WITHOUT_FILTER_INPUT, + headers={}, + cookies={}, + method_obj=method, + context={}, + executed_as=bot, # type: ignore[arg-type] + ) + + batch = await method.to_obj(response) + await batch.next_batch() + + assert batch.filter is TransactionFilter.WITHDRAW + assert bot.calls == [{'from_transaction_id': 456, 'filter': TransactionFilter.WITHDRAW}] From f706f9e5eac9436d5351647b6fa869440dbf05a4 Mon Sep 17 00:00:00 2001 From: Flummy1 Date: Tue, 14 Jul 2026 13:47:51 +0300 Subject: [PATCH 2/6] chore: remove pagination filter tests Requested by maintainer. Co-Authored-By: Claude Opus 4.8 --- tests/test_transactions_pagination.py | 161 -------------------------- 1 file changed, 161 deletions(-) delete mode 100644 tests/test_transactions_pagination.py diff --git a/tests/test_transactions_pagination.py b/tests/test_transactions_pagination.py deleted file mode 100644 index 106c4b1..0000000 --- a/tests/test_transactions_pagination.py +++ /dev/null @@ -1,161 +0,0 @@ -from __future__ import annotations - -from typing import Any -from http import HTTPStatus - -import pytest -from funpaybotengine.types.enums import TransactionFilter -from funpaybotengine.types.finances import TransactionPreviewsBatch -from funpaybotengine.client.session.base import RawResponse -from funpaybotengine.methods.get_transactions import GetTransactions -from funpaybotengine.methods.get_transactions_page import GetTransactionsPage - - -# A real ``users/transactions`` response: it carries a ``continue`` cursor but no -# hidden ``filter`` input, which is exactly the shape that used to widen the filter. -RESPONSE_WITHOUT_FILTER_INPUT = """ -
- 1 июля, 12:00 - Вывод средств -
-100 ₽
-
- - -""" - - -class BotStub: - """Records the arguments ``next_batch()`` forwards to ``Bot.get_transactions``.""" - - userid = 777 - - def __init__(self) -> None: - self.calls: list[dict[str, Any]] = [] - - async def get_transactions( - self, - from_transaction_id: int = 0, - filter: TransactionFilter | str = TransactionFilter.ALL, - ) -> None: - self.calls.append({'from_transaction_id': from_transaction_id, 'filter': filter}) - - -def make_batch( - *, - scraped_filter: str | None, - context: dict[str, Any] | None = None, - next_transaction_id: int | None = 42, -) -> TransactionPreviewsBatch: - return TransactionPreviewsBatch.model_validate( - { - 'raw_source': '', - 'transactions': (), - 'user_id': 1, - 'filter': scraped_filter, - 'next_transaction_id': next_transaction_id, - }, - context=context, - ) - - -async def test_get_transactions_exposes_requested_filter_via_context() -> None: - method = GetTransactions(filter=TransactionFilter.WITHDRAW) - - context = await method.get_context(None) # type: ignore[arg-type] - - assert context['transaction_filter'] is TransactionFilter.WITHDRAW - - -async def test_get_transactions_accepts_no_arguments() -> None: - method = GetTransactions() - - assert method.from_transaction_id == 0 - assert method.filter is TransactionFilter.ALL - - -async def test_requested_filter_wins_over_missing_hidden_input() -> None: - batch = make_batch( - scraped_filter=None, - context={'transaction_filter': TransactionFilter.WITHDRAW}, - ) - - assert batch.filter is TransactionFilter.WITHDRAW - - -async def test_next_batch_keeps_requested_filter() -> None: - bot = BotStub() - batch = make_batch( - scraped_filter=None, - context={'transaction_filter': TransactionFilter.WITHDRAW}, - ) - batch.bind_to(bot) # type: ignore[arg-type] - - await batch.next_batch() - - assert bot.calls == [{'from_transaction_id': 42, 'filter': TransactionFilter.WITHDRAW}] - - -async def test_next_batch_preserves_all_filter() -> None: - bot = BotStub() - batch = make_batch( - scraped_filter='', - context={'transaction_filter': TransactionFilter.ALL}, - ) - batch.bind_to(bot) # type: ignore[arg-type] - - await batch.next_batch() - - assert bot.calls == [{'from_transaction_id': 42, 'filter': TransactionFilter.ALL}] - - -async def test_next_batch_falls_back_to_scraped_filter_without_context() -> None: - bot = BotStub() - batch = make_batch(scraped_filter='withdraw') - batch.bind_to(bot) # type: ignore[arg-type] - - await batch.next_batch() - - assert bot.calls == [{'from_transaction_id': 42, 'filter': TransactionFilter.WITHDRAW}] - - -async def test_next_batch_raises_when_filter_is_unknown() -> None: - batch = make_batch(scraped_filter=None) - batch.bind_to(BotStub()) # type: ignore[arg-type] - - with pytest.raises(ValueError, match='Unknown transaction filter.'): - await batch.next_batch() - - -async def test_next_batch_raises_on_last_batch() -> None: - batch = make_batch(scraped_filter='withdraw', next_transaction_id=None) - batch.bind_to(BotStub()) # type: ignore[arg-type] - - with pytest.raises(ValueError, match='Last batch.'): - await batch.next_batch() - - -async def test_transactions_page_batch_is_marked_as_unfiltered() -> None: - context = await GetTransactionsPage().get_context(None) # type: ignore[arg-type] - - assert context['transaction_filter'] is TransactionFilter.ALL - - -async def test_filtered_pagination_survives_a_response_without_filter_input() -> None: - bot = BotStub() - method = GetTransactions(filter=TransactionFilter.WITHDRAW) - response: RawResponse[TransactionPreviewsBatch] = RawResponse( - url='https://funpay.com/users/transactions', - status_code=HTTPStatus.OK, - raw_response=RESPONSE_WITHOUT_FILTER_INPUT, - headers={}, - cookies={}, - method_obj=method, - context={}, - executed_as=bot, # type: ignore[arg-type] - ) - - batch = await method.to_obj(response) - await batch.next_batch() - - assert batch.filter is TransactionFilter.WITHDRAW - assert bot.calls == [{'from_transaction_id': 456, 'filter': TransactionFilter.WITHDRAW}] From 9f68260a3e135e8a8a95a3980e2f66a2747a3490 Mon Sep 17 00:00:00 2001 From: Flummy1 Date: Tue, 14 Jul 2026 13:47:58 +0300 Subject: [PATCH 3/6] chore: drop pytest config from pyproject Requested by maintainer. Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 987b28d..67f90a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,12 +65,6 @@ redis = [ requires = ["hatchling"] build-backend = "hatchling.build" -[tool.pytest.ini_options] -testpaths = ["tests"] -asyncio_mode = "auto" -# Test the working tree, never a funpaybotengine release installed in the environment. -pythonpath = ["."] - [tool.mypy] plugins = ["pydantic.mypy"] python_version = "3.10" From 15449b92fc0c1bcfd60bcd86edbb032f0af73e62 Mon Sep 17 00:00:00 2001 From: Flummy1 Date: Thu, 16 Jul 2026 11:13:31 +0300 Subject: [PATCH 4/6] refactor: stamp transaction filter on the batch instead of the validation context Passing the requested filter through the pydantic validation context leaked it to every nested model: pydantic hands the same context object to each nested `model_validate`, so `header`, `app_data`, every `MoneyValue` and every `TransactionPreview` saw `transaction_filter`, not just the batch it belongs to. Stamp the filter directly on the batch in the building method instead: - `GetTransactions.transform_result` sets `batch.filter` to the requested filter. - `GetTransactionsPage.transform_result` marks its nested batch as `ALL`. - Drop the context-reading `model_post_init` from `TransactionPreviewsBatch`; the HTML-scraped value stays as a fallback for hand-built objects. The validation context now carries only `bot`. Filter propagation is verified end-to-end against a response with no hidden `filter` input. --- funpaybotengine/methods/get_transactions.py | 18 +++++++++++----- .../methods/get_transactions_page.py | 21 +++++++++++++++++-- funpaybotengine/types/finances.py | 14 ++++--------- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/funpaybotengine/methods/get_transactions.py b/funpaybotengine/methods/get_transactions.py index 732945f..a210cf9 100644 --- a/funpaybotengine/methods/get_transactions.py +++ b/funpaybotengine/methods/get_transactions.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from funpaybotengine.client import Bot + from funpaybotengine.client.session.base import RawResponse class GetTransactions(FunPayMethod[TransactionPreviewsBatch], BaseModel): @@ -44,11 +45,22 @@ def __init__( allow_anonymous=False, allow_uninitialized=False, data=make_data, - context=make_context, filter=filter, from_transaction_id=from_transaction_id, ) + async def transform_result( + self, + parsing_result: Any, + response: RawResponse[Any], + ) -> TransactionPreviewsBatch: + # Stamp the batch with the filter that was actually requested, rather than + # leaking it through the validation context (which every nested model sees). + # The scraped value stays only as a fallback for hand-built objects. + batch = await super().transform_result(parsing_result, response) + batch.filter = self.filter + return batch + async def make_data(method: GetTransactions, bot: Bot) -> dict[str, Any]: return { @@ -56,7 +68,3 @@ async def make_data(method: GetTransactions, bot: Bot) -> dict[str, Any]: 'continue': str(method.from_transaction_id) if method.from_transaction_id > 0 else '', 'user_id': str(bot.userid), } - - -async def make_context(method: GetTransactions, bot: Bot) -> dict[str, Any]: - return {'transaction_filter': method.filter} diff --git a/funpaybotengine/methods/get_transactions_page.py b/funpaybotengine/methods/get_transactions_page.py index a45a181..bc7e78f 100644 --- a/funpaybotengine/methods/get_transactions_page.py +++ b/funpaybotengine/methods/get_transactions_page.py @@ -4,6 +4,8 @@ __all__ = ('GetTransactionsPage',) +from typing import TYPE_CHECKING, Any + from pydantic import BaseModel from funpayparsers.parsers.page_parsers import TransactionsPageParser @@ -13,6 +15,10 @@ from funpaybotengine.client.session import HTTPMethod +if TYPE_CHECKING: + from funpaybotengine.client.session.base import RawResponse + + class GetTransactionsPage(FunPayMethod[TransactionsPage], BaseModel): """Get the transactions page (``https://funpay.com/account/balance``).""" @@ -26,6 +32,17 @@ def __init__(self, locale: Language | None = None): parser_cls=TransactionsPageParser, allow_anonymous=False, allow_uninitialized=True, - # The balance page always renders an unfiltered transaction list. - context={'transaction_filter': TransactionFilter.ALL}, ) + + async def transform_result( + self, + parsing_result: Any, + response: RawResponse[Any], + ) -> TransactionsPage: + # The balance page always renders an unfiltered transaction list, so its + # batch paginates as ALL. Stamp it directly instead of leaking the filter + # through the validation context shared by every nested model. + page = await super().transform_result(parsing_result, response) + if page.transactions is not None: + page.transactions.filter = TransactionFilter.ALL + return page diff --git a/funpaybotengine/types/finances.py b/funpaybotengine/types/finances.py index bda0886..9b337e0 100644 --- a/funpaybotengine/types/finances.py +++ b/funpaybotengine/types/finances.py @@ -4,7 +4,6 @@ __all__ = ('TransactionPreview', 'TransactionInfo', 'TransactionPreviewsBatch') -from typing import Any from types import MappingProxyType from collections.abc import Mapping @@ -88,20 +87,15 @@ class TransactionPreviewsBatch(FunPayObject, BaseModel): """ Transactions filter applied to the current batch. - The filter passed to the request takes precedence; the value scraped from the - response HTML is only a fallback, since FunPay omits the hidden ``filter`` input - in some responses. + The building method stamps this with the filter that was actually requested + (see ``GetTransactions.transform_result``). The value scraped from the response + HTML is only a fallback for hand-built objects, since FunPay omits the hidden + ``filter`` input in some responses. ``None`` means the filter is unknown, which is not the same as ``TransactionFilter.ALL``. """ - def model_post_init(self, context: dict[Any, Any]) -> None: - super().model_post_init(context) - requested_filter = context.get('transaction_filter') if context else None - if isinstance(requested_filter, TransactionFilter): - self.filter = requested_filter - @field_validator('filter', mode='before') @classmethod def _coerce_transaction_filter( From 1b339f9aaeec68dfeac6b1f36839f5d5c1e4fe9c Mon Sep 17 00:00:00 2001 From: qvvonk Date: Thu, 16 Jul 2026 11:48:53 +0300 Subject: [PATCH 5/6] small improvements --- funpaybotengine/methods/get_transactions.py | 3 --- .../methods/get_transactions_page.py | 3 --- funpaybotengine/types/enums.py | 2 ++ funpaybotengine/types/finances.py | 26 ++++++++----------- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/funpaybotengine/methods/get_transactions.py b/funpaybotengine/methods/get_transactions.py index a210cf9..40663d2 100644 --- a/funpaybotengine/methods/get_transactions.py +++ b/funpaybotengine/methods/get_transactions.py @@ -54,9 +54,6 @@ async def transform_result( parsing_result: Any, response: RawResponse[Any], ) -> TransactionPreviewsBatch: - # Stamp the batch with the filter that was actually requested, rather than - # leaking it through the validation context (which every nested model sees). - # The scraped value stays only as a fallback for hand-built objects. batch = await super().transform_result(parsing_result, response) batch.filter = self.filter return batch diff --git a/funpaybotengine/methods/get_transactions_page.py b/funpaybotengine/methods/get_transactions_page.py index bc7e78f..72d7b1e 100644 --- a/funpaybotengine/methods/get_transactions_page.py +++ b/funpaybotengine/methods/get_transactions_page.py @@ -39,9 +39,6 @@ async def transform_result( parsing_result: Any, response: RawResponse[Any], ) -> TransactionsPage: - # The balance page always renders an unfiltered transaction list, so its - # batch paginates as ALL. Stamp it directly instead of leaking the filter - # through the validation context shared by every nested model. page = await super().transform_result(parsing_result, response) if page.transactions is not None: page.transactions.filter = TransactionFilter.ALL diff --git a/funpaybotengine/types/enums.py b/funpaybotengine/types/enums.py index 502ea59..daa62a7 100644 --- a/funpaybotengine/types/enums.py +++ b/funpaybotengine/types/enums.py @@ -31,6 +31,8 @@ class TransactionFilter(str, Enum): OTHER = 'other' """Other transactions.""" + UNKNOWN = 'unknown' + class OrderPreviewType(Enum): SALE = auto() diff --git a/funpaybotengine/types/finances.py b/funpaybotengine/types/finances.py index 9b337e0..94c1df9 100644 --- a/funpaybotengine/types/finances.py +++ b/funpaybotengine/types/finances.py @@ -6,6 +6,7 @@ from types import MappingProxyType from collections.abc import Mapping +from typing import Any from pydantic import BaseModel, field_validator from funpayparsers.parsers.utils import parse_date_string @@ -83,7 +84,7 @@ class TransactionPreviewsBatch(FunPayObject, BaseModel): user_id: int | None """ID of the user to whom all transactions in this batch belong.""" - filter: TransactionFilter | None + filter: TransactionFilter """ Transactions filter applied to the current batch. @@ -96,19 +97,6 @@ class TransactionPreviewsBatch(FunPayObject, BaseModel): ``TransactionFilter.ALL``. """ - @field_validator('filter', mode='before') - @classmethod - def _coerce_transaction_filter( - cls, - value: str | TransactionFilter | None, - ) -> TransactionFilter | None: - if value is None or isinstance(value, TransactionFilter): - return value - try: - return TransactionFilter(value) - except ValueError: - return None - next_transaction_id: int | None """ ID of the next transaction to use as a cursor for pagination. @@ -119,10 +107,18 @@ def _coerce_transaction_filter( If ``None``, there are no more transactions to load. """ + @field_validator('filter', mode='before') + @classmethod + def _coerce_transaction_filter(cls, value: Any) -> TransactionFilter | None: + try: + return TransactionFilter(value) + except ValueError: + return TransactionFilter.UNKNOWN + async def next_batch(self) -> TransactionPreviewsBatch: if not self.next_transaction_id: raise ValueError('Last batch.') - if self.filter is None: + if self.filter is TransactionFilter.UNKNOWN: raise ValueError('Unknown transaction filter.') return await self.get_bound_bot().get_transactions( From ff071172744f55513228e3cf8d6e8cdd73ae6524 Mon Sep 17 00:00:00 2001 From: qvvonk Date: Thu, 16 Jul 2026 12:01:28 +0300 Subject: [PATCH 6/6] moved filter logic to method init --- funpaybotengine/methods/get_transactions.py | 3 +++ funpaybotengine/types/finances.py | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/funpaybotengine/methods/get_transactions.py b/funpaybotengine/methods/get_transactions.py index 40663d2..f016ca8 100644 --- a/funpaybotengine/methods/get_transactions.py +++ b/funpaybotengine/methods/get_transactions.py @@ -49,6 +49,9 @@ def __init__( from_transaction_id=from_transaction_id, ) + if self.filter is TransactionFilter.UNKNOWN: + raise ValueError(f'Unknown filter.') + async def transform_result( self, parsing_result: Any, diff --git a/funpaybotengine/types/finances.py b/funpaybotengine/types/finances.py index 94c1df9..85de3ef 100644 --- a/funpaybotengine/types/finances.py +++ b/funpaybotengine/types/finances.py @@ -118,8 +118,6 @@ def _coerce_transaction_filter(cls, value: Any) -> TransactionFilter | None: async def next_batch(self) -> TransactionPreviewsBatch: if not self.next_transaction_id: raise ValueError('Last batch.') - if self.filter is TransactionFilter.UNKNOWN: - raise ValueError('Unknown transaction filter.') return await self.get_bound_bot().get_transactions( from_transaction_id=self.next_transaction_id,