From 7355a1fd7d688e80b74afebc138212a29cb80c17 Mon Sep 17 00:00:00 2001 From: Lovro Colic Date: Thu, 22 Oct 2026 12:19:14 +0200 Subject: [PATCH 1/2] add support for attributed usage --- lago_python_client/client.py | 5 + lago_python_client/customers/clients.py | 42 ++++ lago_python_client/models/__init__.py | 21 ++ lago_python_client/models/attributed_usage.py | 41 ++++ .../models/usage_attribution_type.py | 30 +++ .../usage_attribution_types/__init__.py | 0 .../usage_attribution_types/clients.py | 24 +++ tests/fixtures/customer_attributed_usage.json | 79 +++++++ tests/fixtures/usage_attribution_type.json | 15 ++ .../usage_attribution_type_index.json | 37 ++++ tests/test_customer_client.py | 63 ++++++ tests/test_usage_attribution_type_client.py | 200 ++++++++++++++++++ 12 files changed, 557 insertions(+) create mode 100644 lago_python_client/models/attributed_usage.py create mode 100644 lago_python_client/models/usage_attribution_type.py create mode 100644 lago_python_client/usage_attribution_types/__init__.py create mode 100644 lago_python_client/usage_attribution_types/clients.py create mode 100644 tests/fixtures/customer_attributed_usage.json create mode 100644 tests/fixtures/usage_attribution_type.json create mode 100644 tests/fixtures/usage_attribution_type_index.json create mode 100644 tests/test_usage_attribution_type_client.py diff --git a/lago_python_client/client.py b/lago_python_client/client.py index f115f14e..f9abcbff 100644 --- a/lago_python_client/client.py +++ b/lago_python_client/client.py @@ -42,6 +42,7 @@ from .subscriptions.clients import SubscriptionClient from .subscriptions.fixed_charges_client import SubscriptionFixedChargesClient from .taxes.clients import TaxClient +from .usage_attribution_types.clients import UsageAttributionTypeClient from .usages.clients import UsageClient from .wallets.clients import WalletClient, WalletTransactionClient from .webhook_endpoints.clients import WebhookEndpointClient @@ -252,6 +253,10 @@ def subscription_fixed_charges(self) -> SubscriptionFixedChargesClient: def taxes(self) -> TaxClient: return self._create_client(TaxClient, self.base_api_url, self.api_key) + @callable_cached_property + def usage_attribution_types(self) -> UsageAttributionTypeClient: + return self._create_client(UsageAttributionTypeClient, self.base_api_url, self.api_key) + @callable_cached_property def usages(self) -> UsageClient: return self._create_client(UsageClient, self.base_api_url, self.api_key) diff --git a/lago_python_client/customers/clients.py b/lago_python_client/customers/clients.py index 41573f03..95e89b88 100644 --- a/lago_python_client/customers/clients.py +++ b/lago_python_client/customers/clients.py @@ -11,6 +11,7 @@ FindAllCommandMixin, FindCommandMixin, ) +from ..models.attributed_usage import AttributedUsageResponse from ..models.customer import CustomerResponse from ..models.customer_projected_usage import CustomerProjectedUsageResponse from ..models.customer_usage import CustomerUsageResponse @@ -147,6 +148,47 @@ def past_usage( data=get_response_data(response=api_response), ) + def attributed_usage( + self, + resource_id: str, + external_subscription_id: str, + group_by: Optional[str] = None, + filters: Optional[Mapping[str, Union[str, list[str]]]] = None, + options: QueryPairs = None, + ) -> Mapping[str, Any]: + query_params: dict[str, Union[int, str, list[str]]] = {"external_subscription_id": external_subscription_id} + if group_by is not None: + query_params["group_by"] = group_by + if filters is not None: + for k, v in filters.items(): + if isinstance(v, str): + query_params[f"filters[{k}]"] = v + else: + query_params[f"filters[{k}][]"] = list(v) + if options is not None: + query_params.update(options) + + api_response: Response = send_get_request( + url=make_url( + origin=self.base_url, + path_parts=(self.API_RESOURCE, resource_id, "attributed_usage"), + query_pairs=query_params, + ), + headers=make_headers(api_key=self.api_key), + ) + + response_data = get_response_data(response=api_response) + if not isinstance(response_data, Mapping): + response_data = {} + + return { + "attributed_usage": prepare_object_response( + response_model=AttributedUsageResponse, + data=response_data.get("attributed_usage"), + ), + "meta": response_data.get("meta"), + } + def portal_url(self, resource_id: str) -> str: api_response: Response = send_get_request( url=make_url( diff --git a/lago_python_client/models/__init__.py b/lago_python_client/models/__init__.py index b19fd87f..e78db03d 100644 --- a/lago_python_client/models/__init__.py +++ b/lago_python_client/models/__init__.py @@ -28,6 +28,18 @@ from .api_log import ApiLog as ApiLog from .api_log import ApiLogResponse as ApiLogResponse from .applied_coupon import AppliedCoupon as AppliedCoupon +from .attributed_usage import ( + AttributedChargeUsage as AttributedChargeUsage, +) +from .attributed_usage import ( + AttributedUsageAggregate as AttributedUsageAggregate, +) +from .attributed_usage import ( + AttributedUsageResponse as AttributedUsageResponse, +) +from .attributed_usage import ( + AttributedUsageRow as AttributedUsageRow, +) from .billable_metric import ( BillableMetric as BillableMetric, ) @@ -289,6 +301,15 @@ from .tax import ( TaxResponse as TaxResponse, ) +from .usage_attribution_type import ( + UsageAttributionType as UsageAttributionType, +) +from .usage_attribution_type import ( + UsageAttributionTypeResponse as UsageAttributionTypeResponse, +) +from .usage_attribution_type import ( + UsageAttributionTypesResponse as UsageAttributionTypesResponse, +) from .usage_threshold import ( UsageThreshold as UsageThreshold, ) diff --git a/lago_python_client/models/attributed_usage.py b/lago_python_client/models/attributed_usage.py new file mode 100644 index 00000000..c08c2878 --- /dev/null +++ b/lago_python_client/models/attributed_usage.py @@ -0,0 +1,41 @@ +from typing import Dict, List, Optional + +from ..base_model import BaseModel, BaseResponseModel + + +class AttributedChargeUsage(BaseModel): + lago_charge_id: str + charge_code: str + billable_metric_code: str + lago_charge_filter_id: Optional[str] + charge_filter_values: Optional[Dict[str, List[str]]] + charge_filter_invoice_display_name: Optional[str] + units: str + amount_cents: Optional[int] + precise_amount_cents: Optional[str] + events_count: int + + +class AttributedUsageAggregate(BaseModel): + amount_cents: Optional[int] + precise_amount_cents: Optional[str] + events_count: int + charges_usage: List[AttributedChargeUsage] + + +class AttributedUsageRow(AttributedUsageAggregate): + value: Optional[str] + rank: int + + +class AttributedUsageResponse(BaseResponseModel): + lago_subscription_id: str + external_subscription_id: str + group_by: Optional[str] + basis: str + from_datetime: str + to_datetime: str + currency: str + rows: List[AttributedUsageRow] + unattributed: AttributedUsageAggregate + totals: AttributedUsageAggregate diff --git a/lago_python_client/models/usage_attribution_type.py b/lago_python_client/models/usage_attribution_type.py new file mode 100644 index 00000000..f5cfb830 --- /dev/null +++ b/lago_python_client/models/usage_attribution_type.py @@ -0,0 +1,30 @@ +from typing import List, Optional + +from ..base_model import BaseModel, BaseResponseModel + + +class UsageAttributionType(BaseModel): + code: Optional[str] + name: Optional[str] + description: Optional[str] + role: Optional[str] + parent_code: Optional[str] + attribution_keys: Optional[List[str]] + + +class UsageAttributionTypeResponse(BaseResponseModel): + lago_id: str + lago_organization_id: str + code: str + name: Optional[str] + description: Optional[str] + attribution_keys: List[str] + role: str + lago_parent_id: Optional[str] + parent_code: Optional[str] + created_at: str + updated_at: str + + +class UsageAttributionTypesResponse(BaseResponseModel): + __root__: List[UsageAttributionTypeResponse] diff --git a/lago_python_client/usage_attribution_types/__init__.py b/lago_python_client/usage_attribution_types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lago_python_client/usage_attribution_types/clients.py b/lago_python_client/usage_attribution_types/clients.py new file mode 100644 index 00000000..413c2ca3 --- /dev/null +++ b/lago_python_client/usage_attribution_types/clients.py @@ -0,0 +1,24 @@ +from typing import ClassVar, Type + +from ..base_client import BaseClient +from ..mixins import ( + CreateCommandMixin, + DestroyCommandMixin, + FindAllCommandMixin, + FindCommandMixin, + UpdateCommandMixin, +) +from ..models.usage_attribution_type import UsageAttributionTypeResponse + + +class UsageAttributionTypeClient( + CreateCommandMixin[UsageAttributionTypeResponse], + DestroyCommandMixin[UsageAttributionTypeResponse], + FindAllCommandMixin[UsageAttributionTypeResponse], + FindCommandMixin[UsageAttributionTypeResponse], + UpdateCommandMixin[UsageAttributionTypeResponse], + BaseClient, +): + API_RESOURCE: ClassVar[str] = "usage_attribution_types" + RESPONSE_MODEL: ClassVar[Type[UsageAttributionTypeResponse]] = UsageAttributionTypeResponse + ROOT_NAME: ClassVar[str] = "usage_attribution_type" diff --git a/tests/fixtures/customer_attributed_usage.json b/tests/fixtures/customer_attributed_usage.json new file mode 100644 index 00000000..1f7e73dc --- /dev/null +++ b/tests/fixtures/customer_attributed_usage.json @@ -0,0 +1,79 @@ +{ + "attributed_usage": { + "lago_subscription_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "external_subscription_id": "sub_123", + "group_by": "team", + "basis": "units", + "from_datetime": "2026-10-01T00:00:00Z", + "to_datetime": "2026-10-31T23:59:59Z", + "currency": "EUR", + "rows": [ + { + "value": "eng", + "rank": 1, + "amount_cents": 1200, + "precise_amount_cents": "1200.4", + "events_count": 12, + "charges_usage": [ + { + "lago_charge_id": "1a901a90-1a90-1a90-1a90-1a901a901a94", + "charge_code": "tokens", + "billable_metric_code": "tokens", + "lago_charge_filter_id": "1a901a90-1a90-1a90-1a90-1a901a901a95", + "charge_filter_values": {"model": ["opus"]}, + "charge_filter_invoice_display_name": "Opus tokens", + "units": "120.0", + "amount_cents": 1200, + "precise_amount_cents": "1200.4", + "events_count": 12 + } + ] + } + ], + "unattributed": { + "amount_cents": 100, + "precise_amount_cents": "100.0", + "events_count": 1, + "charges_usage": [ + { + "lago_charge_id": "1a901a90-1a90-1a90-1a90-1a901a901a94", + "charge_code": "tokens", + "billable_metric_code": "tokens", + "lago_charge_filter_id": null, + "charge_filter_values": null, + "charge_filter_invoice_display_name": null, + "units": "10.0", + "amount_cents": 100, + "precise_amount_cents": "100.0", + "events_count": 1 + } + ] + }, + "totals": { + "amount_cents": 1300, + "precise_amount_cents": "1300.4", + "events_count": 13, + "charges_usage": [ + { + "lago_charge_id": "1a901a90-1a90-1a90-1a90-1a901a901a94", + "charge_code": "tokens", + "billable_metric_code": "tokens", + "lago_charge_filter_id": null, + "charge_filter_values": null, + "charge_filter_invoice_display_name": null, + "units": "130.0", + "amount_cents": 1300, + "precise_amount_cents": "1300.4", + "events_count": 13 + } + ] + } + }, + "meta": { + "current_page": 1, + "next_page": null, + "prev_page": null, + "total_pages": 1, + "total_count": 1 + } +} diff --git a/tests/fixtures/usage_attribution_type.json b/tests/fixtures/usage_attribution_type.json new file mode 100644 index 00000000..96b7e5f6 --- /dev/null +++ b/tests/fixtures/usage_attribution_type.json @@ -0,0 +1,15 @@ +{ + "usage_attribution_type": { + "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_organization_id": "1a901a90-1a90-1a90-1a90-1a901a901a91", + "code": "team", + "name": "Team", + "description": "Teams of the customer", + "attribution_keys": ["team_id"], + "role": "hierarchical", + "lago_parent_id": "1a901a90-1a90-1a90-1a90-1a901a901a92", + "parent_code": "department", + "created_at": "2026-10-01T08:59:51Z", + "updated_at": "2026-10-01T08:59:51Z" + } +} diff --git a/tests/fixtures/usage_attribution_type_index.json b/tests/fixtures/usage_attribution_type_index.json new file mode 100644 index 00000000..03e31ffe --- /dev/null +++ b/tests/fixtures/usage_attribution_type_index.json @@ -0,0 +1,37 @@ +{ + "usage_attribution_types": [ + { + "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a92", + "lago_organization_id": "1a901a90-1a90-1a90-1a90-1a901a901a91", + "code": "department", + "name": "Department", + "description": null, + "attribution_keys": ["department_id"], + "role": "hierarchical", + "lago_parent_id": null, + "parent_code": null, + "created_at": "2026-10-01T08:59:51Z", + "updated_at": "2026-10-01T08:59:51Z" + }, + { + "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a93", + "lago_organization_id": "1a901a90-1a90-1a90-1a90-1a901a901a91", + "code": "model", + "name": "Model", + "description": "LLM model", + "attribution_keys": ["model", "model_name"], + "role": "flat", + "lago_parent_id": null, + "parent_code": null, + "created_at": "2026-10-01T08:59:51Z", + "updated_at": "2026-10-01T08:59:51Z" + } + ], + "meta": { + "current_page": 1, + "next_page": null, + "prev_page": null, + "total_pages": 1, + "total_count": 2 + } +} diff --git a/tests/test_customer_client.py b/tests/test_customer_client.py index 9c7ed58a..f5a45d1b 100644 --- a/tests/test_customer_client.py +++ b/tests/test_customer_client.py @@ -295,6 +295,69 @@ def test_invalid_past_usage(httpx_mock: HTTPXMock): client.customers.past_usage("invalid_customer", "123") +def test_valid_attributed_usage(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/customers/external_customer_id/attributed_usage?external_subscription_id=sub_123", + content=mock_response("customer_attributed_usage"), + ) + response = client.customers.attributed_usage("external_customer_id", "sub_123") + + attributed_usage = response["attributed_usage"] + assert attributed_usage.external_subscription_id == "sub_123" + assert attributed_usage.group_by == "team" + assert attributed_usage.basis == "units" + assert len(attributed_usage.rows) == 1 + assert attributed_usage.rows[0].value == "eng" + assert attributed_usage.rows[0].rank == 1 + assert attributed_usage.rows[0].precise_amount_cents == "1200.4" + assert attributed_usage.rows[0].charges_usage[0].charge_filter_values == {"model": ["opus"]} + assert attributed_usage.unattributed.amount_cents == 100 + assert attributed_usage.unattributed.charges_usage[0].lago_charge_filter_id is None + assert attributed_usage.totals.events_count == 13 + assert response["meta"]["total_count"] == 1 + + +def test_valid_attributed_usage_with_filters_and_options(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url=( + "https://api.getlago.com/api/v1/customers/external_customer_id/attributed_usage" + "?external_subscription_id=sub_123&group_by=team" + "&filters%5Bdepartment%5D=rnd&filters%5Bmodel%5D%5B%5D=opus&filters%5Bmodel%5D%5B%5D=haiku" + "&basis=amount&charge_codes%5B%5D=tokens&page=2&per_page=10" + ), + content=mock_response("customer_attributed_usage"), + ) + response = client.customers.attributed_usage( + "external_customer_id", + "sub_123", + group_by="team", + filters={"department": "rnd", "model": ["opus", "haiku"]}, + options={"basis": "amount", "charge_codes[]": ["tokens"], "page": 2, "per_page": 10}, + ) + + assert response["attributed_usage"].rows[0].value == "eng" + + +def test_invalid_attributed_usage(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/customers/invalid_customer/attributed_usage?external_subscription_id=sub_123", + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.customers.attributed_usage("invalid_customer", "sub_123") + + def test_valid_portal_url(httpx_mock: HTTPXMock): client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") diff --git a/tests/test_usage_attribution_type_client.py b/tests/test_usage_attribution_type_client.py new file mode 100644 index 00000000..be4fdc95 --- /dev/null +++ b/tests/test_usage_attribution_type_client.py @@ -0,0 +1,200 @@ +import os + +import pytest +from pytest_httpx import HTTPXMock + +from lago_python_client.client import Client +from lago_python_client.exceptions import LagoApiError +from lago_python_client.models.usage_attribution_type import UsageAttributionType + + +def usage_attribution_type_object(): + return UsageAttributionType( + code="team", + name="Team", + description="Teams of the customer", + role="hierarchical", + parent_code="department", + attribution_keys=["team_id"], + ) + + +def mock_response(): + this_dir = os.path.dirname(os.path.abspath(__file__)) + data_path = os.path.join(this_dir, "fixtures/usage_attribution_type.json") + + with open(data_path, "rb") as usage_attribution_type_response: + return usage_attribution_type_response.read() + + +def mock_collection_response(): + this_dir = os.path.dirname(os.path.abspath(__file__)) + data_path = os.path.join(this_dir, "fixtures/usage_attribution_type_index.json") + + with open(data_path, "rb") as usage_attribution_type_response: + return usage_attribution_type_response.read() + + +def test_valid_create_usage_attribution_type_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url="https://api.getlago.com/api/v1/usage_attribution_types", + content=mock_response(), + ) + response = client.usage_attribution_types.create(usage_attribution_type_object()) + + assert response.lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + assert response.code == "team" + assert response.attribution_keys == ["team_id"] + assert response.role == "hierarchical" + assert response.lago_parent_id == "1a901a90-1a90-1a90-1a90-1a901a901a92" + assert response.parent_code == "department" + + +def test_invalid_create_usage_attribution_type_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + + httpx_mock.add_response( + method="POST", + url="https://api.getlago.com/api/v1/usage_attribution_types", + status_code=401, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.usage_attribution_types.create(usage_attribution_type_object()) + + +def test_valid_update_usage_attribution_type_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + code = "team" + + httpx_mock.add_response( + method="PUT", + url="https://api.getlago.com/api/v1/usage_attribution_types/" + code, + content=mock_response(), + ) + response = client.usage_attribution_types.update(usage_attribution_type_object(), code) + + assert response.lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + assert response.code == code + + +def test_invalid_update_usage_attribution_type_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + code = "invalid" + + httpx_mock.add_response( + method="PUT", + url="https://api.getlago.com/api/v1/usage_attribution_types/" + code, + status_code=401, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.usage_attribution_types.update(usage_attribution_type_object(), code) + + +def test_valid_find_usage_attribution_type_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + code = "team" + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/usage_attribution_types/" + code, + content=mock_response(), + ) + response = client.usage_attribution_types.find(code) + + assert response.lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + assert response.code == code + + +def test_invalid_find_usage_attribution_type_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + code = "invalid" + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/usage_attribution_types/" + code, + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.usage_attribution_types.find(code) + + +def test_valid_destroy_usage_attribution_type_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + code = "team" + + httpx_mock.add_response( + method="DELETE", + url="https://api.getlago.com/api/v1/usage_attribution_types/" + code, + content=mock_response(), + ) + response = client.usage_attribution_types.destroy(code) + + assert response.lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + assert response.code == code + + +def test_invalid_destroy_usage_attribution_type_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + code = "invalid" + + httpx_mock.add_response( + method="DELETE", + url="https://api.getlago.com/api/v1/usage_attribution_types/" + code, + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.usage_attribution_types.destroy(code) + + +def test_valid_find_all_usage_attribution_types_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/usage_attribution_types", + content=mock_collection_response(), + ) + response = client.usage_attribution_types.find_all() + + assert response["usage_attribution_types"][0].code == "department" + assert response["usage_attribution_types"][1].role == "flat" + assert response["meta"]["current_page"] == 1 + + +def test_valid_find_all_usage_attribution_types_request_with_options(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/usage_attribution_types?per_page=2&page=1&role=flat&search_term=mod", + content=mock_collection_response(), + ) + response = client.usage_attribution_types.find_all({"per_page": 2, "page": 1, "role": "flat", "search_term": "mod"}) + + assert response["usage_attribution_types"][1].code == "model" + assert response["meta"]["total_count"] == 2 + + +def test_invalid_find_all_usage_attribution_types_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/usage_attribution_types", + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.usage_attribution_types.find_all() From b3aeb6f2e9a0a6ea2828879c734a26e747bf9444 Mon Sep 17 00:00:00 2001 From: Lovro Colic Date: Fri, 23 Oct 2026 12:19:15 +0200 Subject: [PATCH 2/2] fix(attributed_usage): require group_by and add explicit charge_codes param Co-Authored-By: Claude Opus 5.5 --- lago_python_client/customers/clients.py | 18 ++++++++++++---- tests/test_customer_client.py | 28 +++++++++++++++++++------ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/lago_python_client/customers/clients.py b/lago_python_client/customers/clients.py index 95e89b88..8dcc3d06 100644 --- a/lago_python_client/customers/clients.py +++ b/lago_python_client/customers/clients.py @@ -152,19 +152,29 @@ def attributed_usage( self, resource_id: str, external_subscription_id: str, - group_by: Optional[str] = None, + group_by: str, filters: Optional[Mapping[str, Union[str, list[str]]]] = None, + charge_codes: Optional[list[str]] = None, options: QueryPairs = None, ) -> Mapping[str, Any]: - query_params: dict[str, Union[int, str, list[str]]] = {"external_subscription_id": external_subscription_id} - if group_by is not None: - query_params["group_by"] = group_by + query_params: dict[str, Union[int, str, list[str]]] = { + "external_subscription_id": external_subscription_id, + "group_by": group_by, + } if filters is not None: for k, v in filters.items(): if isinstance(v, str): query_params[f"filters[{k}]"] = v + elif not v: + # An empty list would send no param and silently drop the filter + raise ValueError(f"filters[{k}] must not be empty") else: query_params[f"filters[{k}][]"] = list(v) + if charge_codes is not None: + if not charge_codes: + # An empty list would send no param, which the API reads as all charges + raise ValueError("charge_codes must not be empty") + query_params["charge_codes[]"] = list(charge_codes) if options is not None: query_params.update(options) diff --git a/tests/test_customer_client.py b/tests/test_customer_client.py index f5a45d1b..578fc553 100644 --- a/tests/test_customer_client.py +++ b/tests/test_customer_client.py @@ -300,10 +300,10 @@ def test_valid_attributed_usage(httpx_mock: HTTPXMock): httpx_mock.add_response( method="GET", - url="https://api.getlago.com/api/v1/customers/external_customer_id/attributed_usage?external_subscription_id=sub_123", + url="https://api.getlago.com/api/v1/customers/external_customer_id/attributed_usage?external_subscription_id=sub_123&group_by=team", content=mock_response("customer_attributed_usage"), ) - response = client.customers.attributed_usage("external_customer_id", "sub_123") + response = client.customers.attributed_usage("external_customer_id", "sub_123", "team") attributed_usage = response["attributed_usage"] assert attributed_usage.external_subscription_id == "sub_123" @@ -329,7 +329,8 @@ def test_valid_attributed_usage_with_filters_and_options(httpx_mock: HTTPXMock): "https://api.getlago.com/api/v1/customers/external_customer_id/attributed_usage" "?external_subscription_id=sub_123&group_by=team" "&filters%5Bdepartment%5D=rnd&filters%5Bmodel%5D%5B%5D=opus&filters%5Bmodel%5D%5B%5D=haiku" - "&basis=amount&charge_codes%5B%5D=tokens&page=2&per_page=10" + "&charge_codes%5B%5D=tokens&charge_codes%5B%5D=requests" + "&basis=amount&page=2&per_page=10" ), content=mock_response("customer_attributed_usage"), ) @@ -338,24 +339,39 @@ def test_valid_attributed_usage_with_filters_and_options(httpx_mock: HTTPXMock): "sub_123", group_by="team", filters={"department": "rnd", "model": ["opus", "haiku"]}, - options={"basis": "amount", "charge_codes[]": ["tokens"], "page": 2, "per_page": 10}, + charge_codes=["tokens", "requests"], + options={"basis": "amount", "page": 2, "per_page": 10}, ) assert response["attributed_usage"].rows[0].value == "eng" +def test_attributed_usage_with_empty_filter_values(): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + with pytest.raises(ValueError): + client.customers.attributed_usage("external_customer_id", "sub_123", "team", filters={"department": []}) + + +def test_attributed_usage_with_empty_charge_codes(): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + with pytest.raises(ValueError): + client.customers.attributed_usage("external_customer_id", "sub_123", "team", charge_codes=[]) + + def test_invalid_attributed_usage(httpx_mock: HTTPXMock): client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") httpx_mock.add_response( method="GET", - url="https://api.getlago.com/api/v1/customers/invalid_customer/attributed_usage?external_subscription_id=sub_123", + url="https://api.getlago.com/api/v1/customers/invalid_customer/attributed_usage?external_subscription_id=sub_123&group_by=team", status_code=404, content=b"", ) with pytest.raises(LagoApiError): - client.customers.attributed_usage("invalid_customer", "sub_123") + client.customers.attributed_usage("invalid_customer", "sub_123", "team") def test_valid_portal_url(httpx_mock: HTTPXMock):