From cc1909707b61d3bcc92036c2b4ad88d69db73819 Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Wed, 26 Aug 2026 09:28:34 +0200 Subject: [PATCH 1/3] feat(quotes): add quote and quote_version clients --- lago_python_client/client.py | 10 ++ lago_python_client/models/__init__.py | 27 ++++ lago_python_client/models/quote.py | 83 ++++++++++++ lago_python_client/quote_versions/__init__.py | 0 lago_python_client/quote_versions/clients.py | 90 +++++++++++++ lago_python_client/quotes/__init__.py | 0 lago_python_client/quotes/clients.py | 56 ++++++++ tests/fixtures/quote.json | 32 +++++ tests/fixtures/quote_index.json | 46 +++++++ tests/fixtures/quote_version.json | 60 +++++++++ tests/fixtures/quote_version_index.json | 39 ++++++ tests/test_quote_client.py | 118 ++++++++++++++++ tests/test_quote_version_client.py | 126 ++++++++++++++++++ 13 files changed, 687 insertions(+) create mode 100644 lago_python_client/models/quote.py create mode 100644 lago_python_client/quote_versions/__init__.py create mode 100644 lago_python_client/quote_versions/clients.py create mode 100644 lago_python_client/quotes/__init__.py create mode 100644 lago_python_client/quotes/clients.py create mode 100644 tests/fixtures/quote.json create mode 100644 tests/fixtures/quote_index.json create mode 100644 tests/fixtures/quote_version.json create mode 100644 tests/fixtures/quote_version_index.json create mode 100644 tests/test_quote_client.py create mode 100644 tests/test_quote_version_client.py diff --git a/lago_python_client/client.py b/lago_python_client/client.py index bf804191..2a1924c3 100644 --- a/lago_python_client/client.py +++ b/lago_python_client/client.py @@ -33,6 +33,8 @@ from .plans.charges_client import PlanChargesClient from .plans.clients import PlanClient from .plans.fixed_charges_client import PlanFixedChargesClient +from .quote_versions.clients import QuoteVersionClient +from .quotes.clients import QuoteClient from .services.rate_limit import RateLimitCallback, RateLimitRetryConfig from .subscriptions.charges_client import SubscriptionChargesClient from .subscriptions.clients import SubscriptionClient @@ -216,6 +218,14 @@ def plan_charges(self) -> PlanChargesClient: def plan_fixed_charges(self) -> PlanFixedChargesClient: return self._create_client(PlanFixedChargesClient, self.base_api_url, self.api_key) + @callable_cached_property + def quotes(self) -> QuoteClient: + return self._create_client(QuoteClient, self.base_api_url, self.api_key) + + @callable_cached_property + def quote_versions(self) -> QuoteVersionClient: + return self._create_client(QuoteVersionClient, self.base_api_url, self.api_key) + @callable_cached_property def subscriptions(self) -> SubscriptionClient: return self._create_client(SubscriptionClient, self.base_api_url, self.api_key) diff --git a/lago_python_client/models/__init__.py b/lago_python_client/models/__init__.py index 7fcf802b..f71d17c4 100644 --- a/lago_python_client/models/__init__.py +++ b/lago_python_client/models/__init__.py @@ -234,6 +234,33 @@ from .payment_request import PaymentRequest as PaymentRequest from .payment_method import PaymentMethod as PaymentMethod, PaymentMethodResponse as PaymentMethodResponse from .plan import Plan as Plan +from .quote import ( + QuoteBillingItemAddOnResponse as QuoteBillingItemAddOnResponse, +) +from .quote import ( + QuoteBillingItemCouponResponse as QuoteBillingItemCouponResponse, +) +from .quote import ( + QuoteBillingItemPlanResponse as QuoteBillingItemPlanResponse, +) +from .quote import ( + QuoteBillingItemsResponse as QuoteBillingItemsResponse, +) +from .quote import ( + QuoteBillingItemWalletCreditResponse as QuoteBillingItemWalletCreditResponse, +) +from .quote import ( + QuoteOwnerResponse as QuoteOwnerResponse, +) +from .quote import ( + QuoteResponse as QuoteResponse, +) +from .quote import ( + QuoteVersionApprove as QuoteVersionApprove, +) +from .quote import ( + QuoteVersionResponse as QuoteVersionResponse, +) from .subscription import ActivationRuleInput as ActivationRuleInput, Subscription as Subscription from .tax import ( Tax as Tax, diff --git a/lago_python_client/models/quote.py b/lago_python_client/models/quote.py new file mode 100644 index 00000000..3fed2e69 --- /dev/null +++ b/lago_python_client/models/quote.py @@ -0,0 +1,83 @@ +from typing import Any, Dict, List, Optional + +from lago_python_client.base_model import BaseModel + +from ..base_model import BaseResponseModel + + +class QuoteVersionApprove(BaseModel): + expires_at: Optional[str] + + +class QuoteBillingItemAddOnResponse(BaseResponseModel): + id: Optional[str] + localId: Optional[str] + type: Optional[str] + payload: Optional[Dict[str, Any]] + overrides: Optional[Dict[str, Any]] + + +class QuoteBillingItemPlanResponse(BaseResponseModel): + id: Optional[str] + localId: Optional[str] + type: Optional[str] + payload: Optional[Dict[str, Any]] + overrides: Optional[Dict[str, Any]] + + +class QuoteBillingItemCouponResponse(BaseResponseModel): + id: Optional[str] + localId: Optional[str] + type: Optional[str] + payload: Optional[Dict[str, Any]] + overrides: Optional[Dict[str, Any]] + + +class QuoteBillingItemWalletCreditResponse(BaseResponseModel): + localId: Optional[str] + type: Optional[str] + payload: Optional[Dict[str, Any]] + + +class QuoteBillingItemsResponse(BaseResponseModel): + addOns: Optional[List[QuoteBillingItemAddOnResponse]] + plans: Optional[List[QuoteBillingItemPlanResponse]] + coupons: Optional[List[QuoteBillingItemCouponResponse]] + walletCredits: Optional[List[QuoteBillingItemWalletCreditResponse]] + + +class QuoteVersionResponse(BaseResponseModel): + lago_id: str + lago_quote_id: str + lago_organization_id: str + version: int + status: str + currency: Optional[str] + billing_entity_code: Optional[str] + void_reason: Optional[str] + approved_at: Optional[str] + voided_at: Optional[str] + created_at: str + updated_at: str + # Only returned when a single version is retrieved. + content: Optional[str] + billing_items: Optional[QuoteBillingItemsResponse] + + +class QuoteOwnerResponse(BaseResponseModel): + lago_id: str + email: Optional[str] + + +class QuoteResponse(BaseResponseModel): + lago_id: str + number: str + order_type: str + lago_customer_id: str + lago_subscription_id: Optional[str] + lago_organization_id: str + created_at: str + updated_at: str + current_version: Optional[QuoteVersionResponse] + # Only returned when a single quote is retrieved. + owners: Optional[List[QuoteOwnerResponse]] diff --git a/lago_python_client/quote_versions/__init__.py b/lago_python_client/quote_versions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lago_python_client/quote_versions/clients.py b/lago_python_client/quote_versions/clients.py new file mode 100644 index 00000000..6cd950ac --- /dev/null +++ b/lago_python_client/quote_versions/clients.py @@ -0,0 +1,90 @@ +from typing import ClassVar, Optional, Type + +import httpx + +from ..base_client import BaseClient +from ..mixins import DEFAULT_TIMEOUT, FindCommandMixin +from ..models.quote import QuoteVersionApprove, QuoteVersionResponse +from ..services.json import to_json +from ..services.request import ( + make_headers, + make_url, + send_post_request, +) +from ..services.response import Response, get_response_data, prepare_object_response + + +class QuoteVersionClient( + FindCommandMixin[QuoteVersionResponse], + BaseClient, +): + API_RESOURCE: ClassVar[str] = "quote_versions" + RESPONSE_MODEL: ClassVar[Type[QuoteVersionResponse]] = QuoteVersionResponse + ROOT_NAME: ClassVar[str] = "quote_version" + + def approve( + self, + resource_id: str, + input_object: Optional[QuoteVersionApprove] = None, + timeout: Optional[httpx.Timeout] = DEFAULT_TIMEOUT, + ) -> QuoteVersionResponse: + """Approve a draft version and generate the order form to sign.""" + payload = input_object.dict(exclude_none=True) if input_object else {} + + api_response: Response = send_post_request( + url=make_url( + origin=self.base_url, + path_parts=(self.API_RESOURCE, resource_id, "approve"), + ), + content=to_json(payload) if payload else None, + headers=make_headers(api_key=self.api_key), + timeout=timeout, + rate_limit_retry_config=self.rate_limit_retry_config, + ) + + return prepare_object_response( + response_model=self.RESPONSE_MODEL, + data=get_response_data(response=api_response, key=self.ROOT_NAME), + ) + + def void( + self, + resource_id: str, + timeout: Optional[httpx.Timeout] = DEFAULT_TIMEOUT, + ) -> QuoteVersionResponse: + """Void a draft version, which makes it definitive.""" + api_response: Response = send_post_request( + url=make_url( + origin=self.base_url, + path_parts=(self.API_RESOURCE, resource_id, "void"), + ), + headers=make_headers(api_key=self.api_key), + timeout=timeout, + rate_limit_retry_config=self.rate_limit_retry_config, + ) + + return prepare_object_response( + response_model=self.RESPONSE_MODEL, + data=get_response_data(response=api_response, key=self.ROOT_NAME), + ) + + def clone( + self, + resource_id: str, + timeout: Optional[httpx.Timeout] = DEFAULT_TIMEOUT, + ) -> QuoteVersionResponse: + """Copy a version into a new draft version of the same quote.""" + api_response: Response = send_post_request( + url=make_url( + origin=self.base_url, + path_parts=(self.API_RESOURCE, resource_id, "clone"), + ), + headers=make_headers(api_key=self.api_key), + timeout=timeout, + rate_limit_retry_config=self.rate_limit_retry_config, + ) + + return prepare_object_response( + response_model=self.RESPONSE_MODEL, + data=get_response_data(response=api_response, key=self.ROOT_NAME), + ) diff --git a/lago_python_client/quotes/__init__.py b/lago_python_client/quotes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lago_python_client/quotes/clients.py b/lago_python_client/quotes/clients.py new file mode 100644 index 00000000..15587f7d --- /dev/null +++ b/lago_python_client/quotes/clients.py @@ -0,0 +1,56 @@ +from typing import Any, ClassVar, Mapping, Optional, Type + +import httpx + +from ..base_client import BaseClient +from ..mixins import ( + DEFAULT_TIMEOUT, + FindAllCommandMixin, + FindCommandMixin, +) +from ..models.quote import QuoteResponse, QuoteVersionResponse +from ..services.request import ( + QueryPairs, + make_headers, + make_url, + send_get_request, +) +from ..services.response import Response, get_response_data, prepare_index_response + + +class QuoteClient( + FindCommandMixin[QuoteResponse], + FindAllCommandMixin[QuoteResponse], + BaseClient, +): + API_RESOURCE: ClassVar[str] = "quotes" + RESPONSE_MODEL: ClassVar[Type[QuoteResponse]] = QuoteResponse + ROOT_NAME: ClassVar[str] = "quote" + + VERSIONS_API_RESOURCE: ClassVar[str] = "quote_versions" + + def versions( + self, + resource_id: str, + options: QueryPairs = None, + timeout: Optional[httpx.Timeout] = DEFAULT_TIMEOUT, + ) -> Mapping[str, Any]: + """List the versions of a quote, from the most recent to the oldest.""" + if options is None: + options = {} + api_response: Response = send_get_request( + url=make_url( + origin=self.base_url, + path_parts=(self.API_RESOURCE, resource_id, "versions"), + query_pairs=options, + ), + headers=make_headers(api_key=self.api_key), + timeout=timeout, + rate_limit_retry_config=self.rate_limit_retry_config, + ) + + return prepare_index_response( + api_resource=self.VERSIONS_API_RESOURCE, + response_model=QuoteVersionResponse, + data=get_response_data(response=api_response), + ) diff --git a/tests/fixtures/quote.json b/tests/fixtures/quote.json new file mode 100644 index 00000000..7a463026 --- /dev/null +++ b/tests/fixtures/quote.json @@ -0,0 +1,32 @@ +{ + "quote": { + "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "number": "QT-2026-0001", + "order_type": "subscription_creation", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_subscription_id": null, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "created_at": "2026-04-29T08:59:51Z", + "updated_at": "2026-04-29T08:59:51Z", + "current_version": { + "lago_id": "4d234d23-4d23-4d23-4d23-4d234d234d23", + "lago_quote_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "version": 1, + "status": "draft", + "currency": "EUR", + "billing_entity_code": "acme_corp", + "void_reason": null, + "approved_at": null, + "voided_at": null, + "created_at": "2026-04-29T08:59:51Z", + "updated_at": "2026-04-29T08:59:51Z" + }, + "owners": [ + { + "lago_id": "5e345e34-5e34-5e34-5e34-5e345e345e34", + "email": "sales@getlago.com" + } + ] + } +} diff --git a/tests/fixtures/quote_index.json b/tests/fixtures/quote_index.json new file mode 100644 index 00000000..6068987b --- /dev/null +++ b/tests/fixtures/quote_index.json @@ -0,0 +1,46 @@ +{ + "quotes": [ + { + "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "number": "QT-2026-0001", + "order_type": "subscription_creation", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_subscription_id": null, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "created_at": "2026-04-29T08:59:51Z", + "updated_at": "2026-04-29T08:59:51Z", + "current_version": { + "lago_id": "4d234d23-4d23-4d23-4d23-4d234d234d23", + "lago_quote_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "version": 1, + "status": "draft", + "currency": "EUR", + "billing_entity_code": "acme_corp", + "void_reason": null, + "approved_at": null, + "voided_at": null, + "created_at": "2026-04-29T08:59:51Z", + "updated_at": "2026-04-29T08:59:51Z" + } + }, + { + "lago_id": "6f456f45-6f45-6f45-6f45-6f456f456f45", + "number": "QT-2026-0002", + "order_type": "one_off", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_subscription_id": null, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "created_at": "2026-04-30T08:59:51Z", + "updated_at": "2026-04-30T08:59:51Z", + "current_version": null + } + ], + "meta": { + "current_page": 1, + "next_page": null, + "prev_page": null, + "total_pages": 1, + "total_count": 2 + } +} diff --git a/tests/fixtures/quote_version.json b/tests/fixtures/quote_version.json new file mode 100644 index 00000000..cbd91a9a --- /dev/null +++ b/tests/fixtures/quote_version.json @@ -0,0 +1,60 @@ +{ + "quote_version": { + "lago_id": "4d234d23-4d23-4d23-4d23-4d234d234d23", + "lago_quote_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "version": 1, + "status": "draft", + "currency": "EUR", + "billing_entity_code": "acme_corp", + "void_reason": null, + "approved_at": null, + "voided_at": null, + "created_at": "2026-04-29T08:59:51Z", + "updated_at": "2026-04-29T08:59:51Z", + "content": "

Quote QT-2026-0001

Prepared for Acme Corp.

", + "billing_items": { + "plans": [ + { + "id": "7a567a56-7a56-7a56-7a56-7a567a567a56", + "localId": "b5c1e2a4-4e1e-4a7f-9f0e-9c1a0c7e1f2b", + "type": "plan", + "payload": { + "code": "premium_plan", + "subscriptionExternalId": "sub_1234567890", + "endDate": "2027-01-01T00:00:00Z" + }, + "overrides": { + "amountCents": 50000, + "amountCurrency": "EUR" + } + } + ], + "coupons": [ + { + "id": "8b678b67-8b67-8b67-8b67-8b678b678b67", + "localId": "c6d2f3b5-5f2f-5b8a-af1f-ad2b1d8f2a3c", + "type": "coupon", + "payload": { + "code": "welcome_offer", + "type": "fixed_amount", + "amountCents": 10000, + "currency": "EUR" + } + } + ], + "walletCredits": [ + { + "localId": "d7e3a4c6-6a3a-6c9b-ba2a-be3c2e9a3b4d", + "type": "wallet_credit", + "payload": { + "paidCredits": "100.0", + "grantedCredits": "20.0", + "rateAmount": "1.0", + "currency": "EUR" + } + } + ] + } + } +} diff --git a/tests/fixtures/quote_version_index.json b/tests/fixtures/quote_version_index.json new file mode 100644 index 00000000..d2392c3a --- /dev/null +++ b/tests/fixtures/quote_version_index.json @@ -0,0 +1,39 @@ +{ + "quote_versions": [ + { + "lago_id": "4d234d23-4d23-4d23-4d23-4d234d234d23", + "lago_quote_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "version": 2, + "status": "draft", + "currency": "EUR", + "billing_entity_code": "acme_corp", + "void_reason": null, + "approved_at": null, + "voided_at": null, + "created_at": "2026-04-30T08:59:51Z", + "updated_at": "2026-04-30T08:59:51Z" + }, + { + "lago_id": "9c789c78-9c78-9c78-9c78-9c789c789c78", + "lago_quote_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "version": 1, + "status": "voided", + "currency": "EUR", + "billing_entity_code": "acme_corp", + "void_reason": "superseded", + "approved_at": null, + "voided_at": "2026-04-30T08:59:51Z", + "created_at": "2026-04-29T08:59:51Z", + "updated_at": "2026-04-30T08:59:51Z" + } + ], + "meta": { + "current_page": 1, + "next_page": null, + "prev_page": null, + "total_pages": 1, + "total_count": 2 + } +} diff --git a/tests/test_quote_client.py b/tests/test_quote_client.py new file mode 100644 index 00000000..023750c3 --- /dev/null +++ b/tests/test_quote_client.py @@ -0,0 +1,118 @@ +import os +from urllib.parse import urlencode + +import pytest +from pytest_httpx import HTTPXMock + +from lago_python_client.client import Client +from lago_python_client.exceptions import LagoApiError + +ENDPOINT = "https://api.getlago.com/api/v1/quotes" + + +def mock_response(fixture_path): + this_dir = os.path.dirname(os.path.abspath(__file__)) + data_path = os.path.join(this_dir, fixture_path) + + with open(data_path, "rb") as response: + return response.read() + + +def test_valid_find_quote_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + quote_id = "1a901a90-1a90-1a90-1a90-1a901a901a90" + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + f"/{quote_id}", + content=mock_response("fixtures/quote.json"), + ) + response = client.quotes.find(quote_id) + + assert response.lago_id == quote_id + assert response.number == "QT-2026-0001" + assert response.order_type == "subscription_creation" + assert response.lago_subscription_id is None + assert response.current_version.version == 1 + assert response.current_version.status == "draft" + assert response.owners[0].email == "sales@getlago.com" + + +def test_invalid_find_quote_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + quote_id = "invalid" + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + f"/{quote_id}", + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.quotes.find(quote_id) + + +def test_valid_find_all_quote_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url=ENDPOINT, + content=mock_response("fixtures/quote_index.json"), + ) + response = client.quotes.find_all() + + assert response["quotes"][0].lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + assert response["quotes"][1].order_type == "one_off" + assert response["quotes"][1].current_version is None + assert response["meta"]["current_page"] == 1 + + +def test_valid_find_all_quote_request_with_options(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + options = {"per_page": 2, "page": 1, "status[]": ["draft", "approved"]} + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + "?" + urlencode(options, doseq=True), + content=mock_response("fixtures/quote_index.json"), + ) + response = client.quotes.find_all(options) + + assert response["quotes"][0].number == "QT-2026-0001" + assert response["meta"]["current_page"] == 1 + + +def test_valid_quote_versions_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + quote_id = "1a901a90-1a90-1a90-1a90-1a901a901a90" + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + f"/{quote_id}/versions", + content=mock_response("fixtures/quote_version_index.json"), + ) + response = client.quotes.versions(quote_id) + + assert response["quote_versions"][0].version == 2 + assert response["quote_versions"][0].status == "draft" + assert response["quote_versions"][1].status == "voided" + assert response["quote_versions"][1].void_reason == "superseded" + assert response["meta"]["current_page"] == 1 + + +def test_valid_quote_versions_request_with_options(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + quote_id = "1a901a90-1a90-1a90-1a90-1a901a901a90" + options = {"per_page": 2, "page": 1} + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + f"/{quote_id}/versions?" + urlencode(options), + content=mock_response("fixtures/quote_version_index.json"), + ) + response = client.quotes.versions(quote_id, options) + + assert response["quote_versions"][0].version == 2 + assert response["meta"]["current_page"] == 1 diff --git a/tests/test_quote_version_client.py b/tests/test_quote_version_client.py new file mode 100644 index 00000000..ff773290 --- /dev/null +++ b/tests/test_quote_version_client.py @@ -0,0 +1,126 @@ +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.quote import QuoteVersionApprove + +ENDPOINT = "https://api.getlago.com/api/v1/quote_versions" +QUOTE_VERSION_ID = "4d234d23-4d23-4d23-4d23-4d234d234d23" + + +def mock_response(fixture_path): + this_dir = os.path.dirname(os.path.abspath(__file__)) + data_path = os.path.join(this_dir, fixture_path) + + with open(data_path, "rb") as response: + return response.read() + + +def test_valid_find_quote_version_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + f"/{QUOTE_VERSION_ID}", + content=mock_response("fixtures/quote_version.json"), + ) + response = client.quote_versions.find(QUOTE_VERSION_ID) + + assert response.lago_id == QUOTE_VERSION_ID + assert response.version == 1 + assert response.status == "draft" + assert response.void_reason is None + assert response.billing_entity_code == "acme_corp" + assert "QT-2026-0001" in response.content + assert response.billing_items.plans[0].payload["code"] == "premium_plan" + assert response.billing_items.plans[0].overrides["amountCents"] == 50000 + assert response.billing_items.coupons[0].type == "coupon" + assert response.billing_items.walletCredits[0].payload["paidCredits"] == "100.0" + assert response.billing_items.addOns is None + + +def test_invalid_find_quote_version_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + "/invalid", + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.quote_versions.find("invalid") + + +def test_valid_approve_quote_version_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{QUOTE_VERSION_ID}/approve", + content=mock_response("fixtures/quote_version.json"), + ) + response = client.quote_versions.approve(QUOTE_VERSION_ID) + + assert response.lago_id == QUOTE_VERSION_ID + + +def test_valid_approve_quote_version_request_with_expires_at(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{QUOTE_VERSION_ID}/approve", + match_content=b'{"expires_at":"2026-06-30T23:59:59Z"}', + content=mock_response("fixtures/quote_version.json"), + ) + response = client.quote_versions.approve( + QUOTE_VERSION_ID, + QuoteVersionApprove(expires_at="2026-06-30T23:59:59Z"), + ) + + assert response.lago_id == QUOTE_VERSION_ID + + +def test_invalid_approve_quote_version_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{QUOTE_VERSION_ID}/approve", + status_code=422, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.quote_versions.approve(QUOTE_VERSION_ID) + + +def test_valid_void_quote_version_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{QUOTE_VERSION_ID}/void", + content=mock_response("fixtures/quote_version.json"), + ) + response = client.quote_versions.void(QUOTE_VERSION_ID) + + assert response.lago_id == QUOTE_VERSION_ID + + +def test_valid_clone_quote_version_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{QUOTE_VERSION_ID}/clone", + content=mock_response("fixtures/quote_version.json"), + ) + response = client.quote_versions.clone(QUOTE_VERSION_ID) + + assert response.lago_id == QUOTE_VERSION_ID From feba69213c0131f2e62369474180c80fdbe2f91c Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Wed, 26 Aug 2026 09:49:05 +0200 Subject: [PATCH 2/3] feat(order-forms): add order_form client --- lago_python_client/client.py | 5 + lago_python_client/models/__init__.py | 6 + lago_python_client/models/order_form.py | 28 ++++ lago_python_client/order_forms/__init__.py | 0 lago_python_client/order_forms/clients.py | 74 ++++++++++ tests/fixtures/order_form.json | 18 +++ tests/fixtures/order_form_index.json | 43 ++++++ tests/fixtures/signed_order_form.json | 18 +++ tests/test_order_form_client.py | 161 +++++++++++++++++++++ 9 files changed, 353 insertions(+) create mode 100644 lago_python_client/models/order_form.py create mode 100644 lago_python_client/order_forms/__init__.py create mode 100644 lago_python_client/order_forms/clients.py create mode 100644 tests/fixtures/order_form.json create mode 100644 tests/fixtures/order_form_index.json create mode 100644 tests/fixtures/signed_order_form.json create mode 100644 tests/test_order_form_client.py diff --git a/lago_python_client/client.py b/lago_python_client/client.py index 2a1924c3..9f1157ff 100644 --- a/lago_python_client/client.py +++ b/lago_python_client/client.py @@ -25,6 +25,7 @@ from .invoiced_usages.clients import InvoicedUsageClient from .invoices.clients import InvoiceClient from .mrrs.clients import MrrClient +from .order_forms.clients import OrderFormClient from .organizations.clients import OrganizationClient from .overdue_balances.clients import OverdueBalanceClient from .payment_receipts.clients import PaymentReceiptClient @@ -182,6 +183,10 @@ def invoiced_usages(self) -> InvoicedUsageClient: def mrrs(self) -> MrrClient: return self._create_client(MrrClient, self.base_api_url, self.api_key) + @callable_cached_property + def order_forms(self) -> OrderFormClient: + return self._create_client(OrderFormClient, self.base_api_url, self.api_key) + @callable_cached_property def organizations(self) -> OrganizationClient: return self._create_client(OrganizationClient, self.base_api_url, self.api_key) diff --git a/lago_python_client/models/__init__.py b/lago_python_client/models/__init__.py index f71d17c4..e1fa4846 100644 --- a/lago_python_client/models/__init__.py +++ b/lago_python_client/models/__init__.py @@ -218,6 +218,12 @@ from .minimum_commitment import ( MinimumCommitmentResponse as MinimumCommitmentResponse, ) +from .order_form import ( + OrderFormMarkAsSigned as OrderFormMarkAsSigned, +) +from .order_form import ( + OrderFormResponse as OrderFormResponse, +) from .organization import ( Organization as Organization, ) diff --git a/lago_python_client/models/order_form.py b/lago_python_client/models/order_form.py new file mode 100644 index 00000000..2ae212f8 --- /dev/null +++ b/lago_python_client/models/order_form.py @@ -0,0 +1,28 @@ +from typing import Optional + +from lago_python_client.base_model import BaseModel + +from ..base_model import BaseResponseModel + + +class OrderFormMarkAsSigned(BaseModel): + signed_document: Optional[str] + execution_mode: Optional[str] + execute_at: Optional[str] + + +class OrderFormResponse(BaseResponseModel): + lago_id: str + number: str + status: str + void_reason: Optional[str] + expires_at: Optional[str] + signed_at: Optional[str] + voided_at: Optional[str] + signed_document_url: Optional[str] + lago_organization_id: str + lago_customer_id: str + lago_quote_id: str + lago_quote_version_id: str + created_at: str + updated_at: str diff --git a/lago_python_client/order_forms/__init__.py b/lago_python_client/order_forms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lago_python_client/order_forms/clients.py b/lago_python_client/order_forms/clients.py new file mode 100644 index 00000000..aa66c4af --- /dev/null +++ b/lago_python_client/order_forms/clients.py @@ -0,0 +1,74 @@ +from typing import ClassVar, Optional, Type + +import httpx + +from ..base_client import BaseClient +from ..mixins import ( + DEFAULT_TIMEOUT, + FindAllCommandMixin, + FindCommandMixin, +) +from ..models.order_form import OrderFormMarkAsSigned, OrderFormResponse +from ..services.json import to_json +from ..services.request import ( + make_headers, + make_url, + send_post_request, +) +from ..services.response import Response, get_response_data, prepare_object_response + + +class OrderFormClient( + FindCommandMixin[OrderFormResponse], + FindAllCommandMixin[OrderFormResponse], + BaseClient, +): + API_RESOURCE: ClassVar[str] = "order_forms" + RESPONSE_MODEL: ClassVar[Type[OrderFormResponse]] = OrderFormResponse + ROOT_NAME: ClassVar[str] = "order_form" + + def mark_as_signed( + self, + resource_id: str, + input_object: Optional[OrderFormMarkAsSigned] = None, + timeout: Optional[httpx.Timeout] = DEFAULT_TIMEOUT, + ) -> OrderFormResponse: + """Record the customer's signature and create the order carrying the deal out.""" + payload = input_object.dict(exclude_none=True) if input_object else {} + + api_response: Response = send_post_request( + url=make_url( + origin=self.base_url, + path_parts=(self.API_RESOURCE, resource_id, "mark_as_signed"), + ), + content=to_json({self.ROOT_NAME: payload}) if payload else None, + headers=make_headers(api_key=self.api_key), + timeout=timeout, + rate_limit_retry_config=self.rate_limit_retry_config, + ) + + return prepare_object_response( + response_model=self.RESPONSE_MODEL, + data=get_response_data(response=api_response, key=self.ROOT_NAME), + ) + + def void( + self, + resource_id: str, + timeout: Optional[httpx.Timeout] = DEFAULT_TIMEOUT, + ) -> OrderFormResponse: + """Void a generated order form, cascading to the quote version it came from.""" + api_response: Response = send_post_request( + url=make_url( + origin=self.base_url, + path_parts=(self.API_RESOURCE, resource_id, "void"), + ), + headers=make_headers(api_key=self.api_key), + timeout=timeout, + rate_limit_retry_config=self.rate_limit_retry_config, + ) + + return prepare_object_response( + response_model=self.RESPONSE_MODEL, + data=get_response_data(response=api_response, key=self.ROOT_NAME), + ) diff --git a/tests/fixtures/order_form.json b/tests/fixtures/order_form.json new file mode 100644 index 00000000..85b12b48 --- /dev/null +++ b/tests/fixtures/order_form.json @@ -0,0 +1,18 @@ +{ + "order_form": { + "lago_id": "aa11aa11-aa11-aa11-aa11-aa11aa11aa11", + "number": "OF-2026-0001", + "status": "generated", + "void_reason": null, + "expires_at": "2026-06-30T23:59:59Z", + "signed_at": null, + "voided_at": null, + "signed_document_url": null, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_quote_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_quote_version_id": "4d234d23-4d23-4d23-4d23-4d234d234d23", + "created_at": "2026-04-29T08:59:51Z", + "updated_at": "2026-04-29T08:59:51Z" + } +} diff --git a/tests/fixtures/order_form_index.json b/tests/fixtures/order_form_index.json new file mode 100644 index 00000000..77f34f9f --- /dev/null +++ b/tests/fixtures/order_form_index.json @@ -0,0 +1,43 @@ +{ + "order_forms": [ + { + "lago_id": "aa11aa11-aa11-aa11-aa11-aa11aa11aa11", + "number": "OF-2026-0001", + "status": "generated", + "void_reason": null, + "expires_at": "2026-06-30T23:59:59Z", + "signed_at": null, + "voided_at": null, + "signed_document_url": null, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_quote_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_quote_version_id": "4d234d23-4d23-4d23-4d23-4d234d234d23", + "created_at": "2026-04-29T08:59:51Z", + "updated_at": "2026-04-29T08:59:51Z" + }, + { + "lago_id": "bb22bb22-bb22-bb22-bb22-bb22bb22bb22", + "number": "OF-2026-0002", + "status": "voided", + "void_reason": "manual", + "expires_at": null, + "signed_at": null, + "voided_at": "2026-05-01T09:00:00Z", + "signed_document_url": null, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_quote_id": "6f456f45-6f45-6f45-6f45-6f456f456f45", + "lago_quote_version_id": "9c789c78-9c78-9c78-9c78-9c789c789c78", + "created_at": "2026-04-30T08:59:51Z", + "updated_at": "2026-05-01T09:00:00Z" + } + ], + "meta": { + "current_page": 1, + "next_page": null, + "prev_page": null, + "total_pages": 1, + "total_count": 2 + } +} diff --git a/tests/fixtures/signed_order_form.json b/tests/fixtures/signed_order_form.json new file mode 100644 index 00000000..6aec7d9d --- /dev/null +++ b/tests/fixtures/signed_order_form.json @@ -0,0 +1,18 @@ +{ + "order_form": { + "lago_id": "aa11aa11-aa11-aa11-aa11-aa11aa11aa11", + "number": "OF-2026-0001", + "status": "signed", + "void_reason": null, + "expires_at": "2026-06-30T23:59:59Z", + "signed_at": "2026-05-02T10:15:00Z", + "voided_at": null, + "signed_document_url": "https://api.getlago.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMi/OF-2026-0001", + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_quote_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_quote_version_id": "4d234d23-4d23-4d23-4d23-4d234d234d23", + "created_at": "2026-04-29T08:59:51Z", + "updated_at": "2026-05-02T10:15:00Z" + } +} diff --git a/tests/test_order_form_client.py b/tests/test_order_form_client.py new file mode 100644 index 00000000..1f2096db --- /dev/null +++ b/tests/test_order_form_client.py @@ -0,0 +1,161 @@ +import os +from urllib.parse import urlencode + +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.order_form import OrderFormMarkAsSigned + +ENDPOINT = "https://api.getlago.com/api/v1/order_forms" +ORDER_FORM_ID = "aa11aa11-aa11-aa11-aa11-aa11aa11aa11" + + +def mock_response(fixture_path): + this_dir = os.path.dirname(os.path.abspath(__file__)) + data_path = os.path.join(this_dir, fixture_path) + + with open(data_path, "rb") as response: + return response.read() + + +def test_valid_find_order_form_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + f"/{ORDER_FORM_ID}", + content=mock_response("fixtures/order_form.json"), + ) + response = client.order_forms.find(ORDER_FORM_ID) + + assert response.lago_id == ORDER_FORM_ID + assert response.number == "OF-2026-0001" + assert response.status == "generated" + assert response.void_reason is None + assert response.signed_document_url is None + assert response.lago_quote_version_id == "4d234d23-4d23-4d23-4d23-4d234d234d23" + + +def test_invalid_find_order_form_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + "/invalid", + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.order_forms.find("invalid") + + +def test_valid_find_all_order_form_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url=ENDPOINT, + content=mock_response("fixtures/order_form_index.json"), + ) + response = client.order_forms.find_all() + + assert response["order_forms"][0].number == "OF-2026-0001" + assert response["order_forms"][1].status == "voided" + assert response["order_forms"][1].void_reason == "manual" + assert response["order_forms"][1].expires_at is None + assert response["meta"]["current_page"] == 1 + + +def test_valid_find_all_order_form_request_with_options(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + options = {"per_page": 2, "page": 1, "status[]": ["generated"], "search_term": "OF-2026"} + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + "?" + urlencode(options, doseq=True), + content=mock_response("fixtures/order_form_index.json"), + ) + response = client.order_forms.find_all(options) + + assert response["order_forms"][0].number == "OF-2026-0001" + assert response["meta"]["current_page"] == 1 + + +def test_valid_mark_as_signed_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_FORM_ID}/mark_as_signed", + content=mock_response("fixtures/signed_order_form.json"), + ) + response = client.order_forms.mark_as_signed(ORDER_FORM_ID) + + assert response.lago_id == ORDER_FORM_ID + assert response.status == "signed" + + +def test_valid_mark_as_signed_request_with_params(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_FORM_ID}/mark_as_signed", + match_content=(b'{"order_form":{"execution_mode":"execute_in_lago","execute_at":"2026-07-01T00:00:00Z"}}'), + content=mock_response("fixtures/signed_order_form.json"), + ) + response = client.order_forms.mark_as_signed( + ORDER_FORM_ID, + OrderFormMarkAsSigned( + execution_mode="execute_in_lago", + execute_at="2026-07-01T00:00:00Z", + ), + ) + + assert response.status == "signed" + assert response.signed_at == "2026-05-02T10:15:00Z" + assert "OF-2026-0001" in response.signed_document_url + + +def test_invalid_mark_as_signed_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_FORM_ID}/mark_as_signed", + status_code=422, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.order_forms.mark_as_signed(ORDER_FORM_ID) + + +def test_valid_void_order_form_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_FORM_ID}/void", + content=mock_response("fixtures/order_form.json"), + ) + response = client.order_forms.void(ORDER_FORM_ID) + + assert response.lago_id == ORDER_FORM_ID + + +def test_invalid_void_order_form_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_FORM_ID}/void", + status_code=422, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.order_forms.void(ORDER_FORM_ID) From f0043fbfad10ae0458f4ee46a55c9bc6bf16d380 Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Wed, 26 Aug 2026 10:01:38 +0200 Subject: [PATCH 3/3] feat(orders): add order client --- lago_python_client/client.py | 5 + lago_python_client/models/__init__.py | 9 ++ lago_python_client/models/order.py | 39 +++++++ lago_python_client/orders/__init__.py | 0 lago_python_client/orders/clients.py | 53 ++++++++++ tests/fixtures/executed_order.json | 36 +++++++ tests/fixtures/order.json | 36 +++++++ tests/fixtures/order_index.json | 61 +++++++++++ tests/test_order_client.py | 146 ++++++++++++++++++++++++++ 9 files changed, 385 insertions(+) create mode 100644 lago_python_client/models/order.py create mode 100644 lago_python_client/orders/__init__.py create mode 100644 lago_python_client/orders/clients.py create mode 100644 tests/fixtures/executed_order.json create mode 100644 tests/fixtures/order.json create mode 100644 tests/fixtures/order_index.json create mode 100644 tests/test_order_client.py diff --git a/lago_python_client/client.py b/lago_python_client/client.py index 9f1157ff..8a6308f5 100644 --- a/lago_python_client/client.py +++ b/lago_python_client/client.py @@ -26,6 +26,7 @@ from .invoices.clients import InvoiceClient from .mrrs.clients import MrrClient from .order_forms.clients import OrderFormClient +from .orders.clients import OrderClient from .organizations.clients import OrganizationClient from .overdue_balances.clients import OverdueBalanceClient from .payment_receipts.clients import PaymentReceiptClient @@ -187,6 +188,10 @@ def mrrs(self) -> MrrClient: def order_forms(self) -> OrderFormClient: return self._create_client(OrderFormClient, self.base_api_url, self.api_key) + @callable_cached_property + def orders(self) -> OrderClient: + return self._create_client(OrderClient, self.base_api_url, self.api_key) + @callable_cached_property def organizations(self) -> OrganizationClient: return self._create_client(OrganizationClient, self.base_api_url, self.api_key) diff --git a/lago_python_client/models/__init__.py b/lago_python_client/models/__init__.py index e1fa4846..b19fd87f 100644 --- a/lago_python_client/models/__init__.py +++ b/lago_python_client/models/__init__.py @@ -218,6 +218,15 @@ from .minimum_commitment import ( MinimumCommitmentResponse as MinimumCommitmentResponse, ) +from .order import ( + OrderExecute as OrderExecute, +) +from .order import ( + OrderExecutionRecordResponse as OrderExecutionRecordResponse, +) +from .order import ( + OrderResponse as OrderResponse, +) from .order_form import ( OrderFormMarkAsSigned as OrderFormMarkAsSigned, ) diff --git a/lago_python_client/models/order.py b/lago_python_client/models/order.py new file mode 100644 index 00000000..f610c6be --- /dev/null +++ b/lago_python_client/models/order.py @@ -0,0 +1,39 @@ +from typing import List, Optional + +from lago_python_client.base_model import BaseModel + +from ..base_model import BaseResponseModel +from .quote import QuoteBillingItemsResponse + + +class OrderExecute(BaseModel): + execution_mode: Optional[str] + + +class OrderExecutionRecordResponse(BaseResponseModel): + executed_at: Optional[str] + execution_mode: Optional[str] + invoice_id: Optional[str] + subscription_ids: Optional[List[str]] + terminated_subscription_ids: Optional[List[str]] + applied_coupon_ids: Optional[List[str]] + wallet_ids: Optional[List[str]] + errors: Optional[List[str]] + + +class OrderResponse(BaseResponseModel): + lago_id: str + number: str + status: str + order_type: str + execution_mode: Optional[str] + currency: Optional[str] + executed_at: Optional[str] + execution_record: Optional[OrderExecutionRecordResponse] + lago_organization_id: str + lago_customer_id: str + lago_order_form_id: str + created_at: str + updated_at: str + # Omitted from the webhook payloads, being a heavy blob. + billing_snapshot: Optional[QuoteBillingItemsResponse] diff --git a/lago_python_client/orders/__init__.py b/lago_python_client/orders/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lago_python_client/orders/clients.py b/lago_python_client/orders/clients.py new file mode 100644 index 00000000..52d4e97c --- /dev/null +++ b/lago_python_client/orders/clients.py @@ -0,0 +1,53 @@ +from typing import ClassVar, Optional, Type + +import httpx + +from ..base_client import BaseClient +from ..mixins import ( + DEFAULT_TIMEOUT, + FindAllCommandMixin, + FindCommandMixin, +) +from ..models.order import OrderExecute, OrderResponse +from ..services.json import to_json +from ..services.request import ( + make_headers, + make_url, + send_post_request, +) +from ..services.response import Response, get_response_data, prepare_object_response + + +class OrderClient( + FindCommandMixin[OrderResponse], + FindAllCommandMixin[OrderResponse], + BaseClient, +): + API_RESOURCE: ClassVar[str] = "orders" + RESPONSE_MODEL: ClassVar[Type[OrderResponse]] = OrderResponse + ROOT_NAME: ClassVar[str] = "order" + + def execute( + self, + resource_id: str, + input_object: Optional[OrderExecute] = None, + timeout: Optional[httpx.Timeout] = DEFAULT_TIMEOUT, + ) -> OrderResponse: + """Carry out an order on demand, without waiting for its schedule.""" + payload = input_object.dict(exclude_none=True) if input_object else {} + + api_response: Response = send_post_request( + url=make_url( + origin=self.base_url, + path_parts=(self.API_RESOURCE, resource_id, "execute"), + ), + content=to_json({self.ROOT_NAME: payload}) if payload else None, + headers=make_headers(api_key=self.api_key), + timeout=timeout, + rate_limit_retry_config=self.rate_limit_retry_config, + ) + + return prepare_object_response( + response_model=self.RESPONSE_MODEL, + data=get_response_data(response=api_response, key=self.ROOT_NAME), + ) diff --git a/tests/fixtures/executed_order.json b/tests/fixtures/executed_order.json new file mode 100644 index 00000000..5fd1051c --- /dev/null +++ b/tests/fixtures/executed_order.json @@ -0,0 +1,36 @@ +{ + "order": { + "lago_id": "cc33cc33-cc33-cc33-cc33-cc33cc33cc33", + "number": "OR-2026-0001", + "status": "executed", + "order_type": "subscription_creation", + "execution_mode": "execute_in_lago", + "currency": "EUR", + "executed_at": "2026-07-01T00:00:00Z", + "execution_record": { + "executed_at": "2026-07-01T00:00:00Z", + "execution_mode": "execute_in_lago", + "invoice_id": null, + "subscription_ids": ["dd44dd44-dd44-dd44-dd44-dd44dd44dd44"], + "terminated_subscription_ids": [], + "applied_coupon_ids": ["ee55ee55-ee55-ee55-ee55-ee55ee55ee55"], + "wallet_ids": [], + "errors": [] + }, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_order_form_id": "aa11aa11-aa11-aa11-aa11-aa11aa11aa11", + "created_at": "2026-05-02T10:15:00Z", + "updated_at": "2026-07-01T00:00:00Z", + "billing_snapshot": { + "plans": [ + { + "id": "7a567a56-7a56-7a56-7a56-7a567a567a56", + "localId": "b5c1e2a4-4e1e-4a7f-9f0e-9c1a0c7e1f2b", + "type": "plan", + "payload": { "code": "premium_plan" } + } + ] + } + } +} diff --git a/tests/fixtures/order.json b/tests/fixtures/order.json new file mode 100644 index 00000000..97043a1d --- /dev/null +++ b/tests/fixtures/order.json @@ -0,0 +1,36 @@ +{ + "order": { + "lago_id": "cc33cc33-cc33-cc33-cc33-cc33cc33cc33", + "number": "OR-2026-0001", + "status": "created", + "order_type": "subscription_creation", + "execution_mode": "execute_in_lago", + "currency": "EUR", + "executed_at": null, + "execution_record": { + "executed_at": null, + "execution_mode": "execute_in_lago", + "invoice_id": null, + "subscription_ids": [], + "terminated_subscription_ids": [], + "applied_coupon_ids": [], + "wallet_ids": [], + "errors": [] + }, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_order_form_id": "aa11aa11-aa11-aa11-aa11-aa11aa11aa11", + "created_at": "2026-05-02T10:15:00Z", + "updated_at": "2026-05-02T10:15:00Z", + "billing_snapshot": { + "plans": [ + { + "id": "7a567a56-7a56-7a56-7a56-7a567a567a56", + "localId": "b5c1e2a4-4e1e-4a7f-9f0e-9c1a0c7e1f2b", + "type": "plan", + "payload": { "code": "premium_plan" } + } + ] + } + } +} diff --git a/tests/fixtures/order_index.json b/tests/fixtures/order_index.json new file mode 100644 index 00000000..87f8570d --- /dev/null +++ b/tests/fixtures/order_index.json @@ -0,0 +1,61 @@ +{ + "orders": [ + { + "lago_id": "cc33cc33-cc33-cc33-cc33-cc33cc33cc33", + "number": "OR-2026-0001", + "status": "created", + "order_type": "subscription_creation", + "execution_mode": "execute_in_lago", + "currency": "EUR", + "executed_at": null, + "execution_record": { + "executed_at": null, + "execution_mode": "execute_in_lago", + "invoice_id": null, + "subscription_ids": [], + "terminated_subscription_ids": [], + "applied_coupon_ids": [], + "wallet_ids": [], + "errors": [] + }, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_order_form_id": "aa11aa11-aa11-aa11-aa11-aa11aa11aa11", + "created_at": "2026-05-02T10:15:00Z", + "updated_at": "2026-05-02T10:15:00Z", + "billing_snapshot": {} + }, + { + "lago_id": "ff66ff66-ff66-ff66-ff66-ff66ff66ff66", + "number": "OR-2026-0002", + "status": "failed", + "order_type": "one_off", + "execution_mode": null, + "currency": "EUR", + "executed_at": null, + "execution_record": { + "executed_at": null, + "execution_mode": null, + "invoice_id": null, + "subscription_ids": [], + "terminated_subscription_ids": [], + "applied_coupon_ids": [], + "wallet_ids": [], + "errors": ["plan_not_found"] + }, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_order_form_id": "bb22bb22-bb22-bb22-bb22-bb22bb22bb22", + "created_at": "2026-05-03T10:15:00Z", + "updated_at": "2026-05-03T10:15:00Z", + "billing_snapshot": {} + } + ], + "meta": { + "current_page": 1, + "next_page": null, + "prev_page": null, + "total_pages": 1, + "total_count": 2 + } +} diff --git a/tests/test_order_client.py b/tests/test_order_client.py new file mode 100644 index 00000000..4c755d1c --- /dev/null +++ b/tests/test_order_client.py @@ -0,0 +1,146 @@ +import os +from urllib.parse import urlencode + +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.order import OrderExecute + +ENDPOINT = "https://api.getlago.com/api/v1/orders" +ORDER_ID = "cc33cc33-cc33-cc33-cc33-cc33cc33cc33" + + +def mock_response(fixture_path): + this_dir = os.path.dirname(os.path.abspath(__file__)) + data_path = os.path.join(this_dir, fixture_path) + + with open(data_path, "rb") as response: + return response.read() + + +def test_valid_find_order_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + f"/{ORDER_ID}", + content=mock_response("fixtures/order.json"), + ) + response = client.orders.find(ORDER_ID) + + assert response.lago_id == ORDER_ID + assert response.number == "OR-2026-0001" + assert response.status == "created" + assert response.order_type == "subscription_creation" + assert response.execution_mode == "execute_in_lago" + assert response.executed_at is None + assert response.execution_record.errors == [] + assert response.lago_order_form_id == "aa11aa11-aa11-aa11-aa11-aa11aa11aa11" + assert response.billing_snapshot.plans[0].payload["code"] == "premium_plan" + + +def test_invalid_find_order_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + "/invalid", + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.orders.find("invalid") + + +def test_valid_find_all_order_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url=ENDPOINT, + content=mock_response("fixtures/order_index.json"), + ) + response = client.orders.find_all() + + assert response["orders"][0].number == "OR-2026-0001" + assert response["orders"][1].status == "failed" + assert response["orders"][1].execution_mode is None + assert response["orders"][1].execution_record.errors == ["plan_not_found"] + assert response["meta"]["current_page"] == 1 + + +def test_valid_find_all_order_request_with_options(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + options = {"per_page": 2, "page": 1, "status[]": ["created"], "execution_mode[]": ["execute_in_lago"]} + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + "?" + urlencode(options, doseq=True), + content=mock_response("fixtures/order_index.json"), + ) + response = client.orders.find_all(options) + + assert response["orders"][0].number == "OR-2026-0001" + assert response["meta"]["current_page"] == 1 + + +def test_valid_execute_order_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_ID}/execute", + content=mock_response("fixtures/executed_order.json"), + ) + response = client.orders.execute(ORDER_ID) + + assert response.lago_id == ORDER_ID + assert response.status == "executed" + assert response.executed_at == "2026-07-01T00:00:00Z" + assert response.execution_record.subscription_ids == ["dd44dd44-dd44-dd44-dd44-dd44dd44dd44"] + assert response.execution_record.applied_coupon_ids == ["ee55ee55-ee55-ee55-ee55-ee55ee55ee55"] + + +def test_valid_execute_order_request_with_execution_mode(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_ID}/execute", + match_content=b'{"order":{"execution_mode":"execute_in_lago"}}', + content=mock_response("fixtures/executed_order.json"), + ) + response = client.orders.execute(ORDER_ID, OrderExecute(execution_mode="execute_in_lago")) + + assert response.status == "executed" + + +def test_invalid_execute_order_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_ID}/execute", + status_code=422, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.orders.execute(ORDER_ID) + + +def test_execute_order_request_with_missing_catalog_record(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_ID}/execute", + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.orders.execute(ORDER_ID)