From e818f600f5088844c6ee810f79f4770af8b95287 Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:56:25 +0100 Subject: [PATCH 01/16] feat(alerts): Add alert webhook models ## Context The `alert.triggered` and `alert.resolved` webhooks have no typed model in the client, so consumers parse the payloads by hand. ## Description `TriggeredAlert` and `ResolvedAlert` were added to `models/alert.py` and exported from `lago_python_client.models`. Crossed and recovered thresholds use a new `AlertCrossedThreshold` model, since `AlertThresholdResponse` also carries `notify_on` and a required `recurring`. Decimal values stay strings. The deprecated `subscription_external_id` and `customer_external_id` fields are kept as optional because the API still sends them. --- lago_python_client/models/__init__.py | 12 +++ lago_python_client/models/alert.py | 53 ++++++++++++ tests/fixtures/alert_resolved_webhook.json | 33 ++++++++ tests/fixtures/alert_triggered_webhook.json | 35 ++++++++ tests/test_alert_webhook_models.py | 94 +++++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 tests/fixtures/alert_resolved_webhook.json create mode 100644 tests/fixtures/alert_triggered_webhook.json create mode 100644 tests/test_alert_webhook_models.py diff --git a/lago_python_client/models/__init__.py b/lago_python_client/models/__init__.py index b19fd87..e02722f 100644 --- a/lago_python_client/models/__init__.py +++ b/lago_python_client/models/__init__.py @@ -25,6 +25,18 @@ from .alert import ( AlertThresholdResponseList as AlertThresholdResponseList, ) +from .alert import ( + AlertCrossedThreshold as AlertCrossedThreshold, +) +from .alert import ( + AlertCrossedThresholdList as AlertCrossedThresholdList, +) +from .alert import ( + TriggeredAlert as TriggeredAlert, +) +from .alert import ( + ResolvedAlert as ResolvedAlert, +) from .api_log import ApiLog as ApiLog from .api_log import ApiLogResponse as ApiLogResponse from .applied_coupon import AppliedCoupon as AppliedCoupon diff --git a/lago_python_client/models/alert.py b/lago_python_client/models/alert.py index f7b8fb9..da9e0fa 100644 --- a/lago_python_client/models/alert.py +++ b/lago_python_client/models/alert.py @@ -57,3 +57,56 @@ class AlertResponse(BaseResponseModel): class AlertsResponseList(BaseResponseModel): alerts: List[AlertResponse] + + +class AlertCrossedThreshold(BaseResponseModel): + code: Optional[str] + value: str + recurring: Optional[bool] + + +class AlertCrossedThresholdList(BaseResponseModel): + __root__: List[AlertCrossedThreshold] + + +class TriggeredAlert(BaseResponseModel): + lago_id: str + lago_organization_id: str + lago_alert_id: str + lago_subscription_id: Optional[str] + external_subscription_id: Optional[str] + lago_wallet_id: Optional[str] + external_customer_id: Optional[str] + billable_metric_code: Optional[str] + alert_name: Optional[str] + alert_code: str + alert_type: str + current_value: Optional[str] + previous_value: Optional[str] + crossed_thresholds: AlertCrossedThresholdList + triggered_at: str + # Deprecated + subscription_external_id: Optional[str] + # Deprecated + customer_external_id: Optional[str] + + +class ResolvedAlert(BaseResponseModel): + lago_id: str + lago_organization_id: str + lago_alert_id: str + lago_subscription_id: Optional[str] + external_subscription_id: Optional[str] + lago_wallet_id: Optional[str] + external_customer_id: Optional[str] + billable_metric_code: Optional[str] + alert_name: Optional[str] + alert_code: str + alert_type: str + current_value: Optional[str] + previous_value: Optional[str] + recovered_thresholds: AlertCrossedThresholdList + in_alarm_thresholds: List[str] + fully_resolved: bool + reason: Optional[str] + resolved_at: str diff --git a/tests/fixtures/alert_resolved_webhook.json b/tests/fixtures/alert_resolved_webhook.json new file mode 100644 index 0000000..e743ead --- /dev/null +++ b/tests/fixtures/alert_resolved_webhook.json @@ -0,0 +1,33 @@ +{ + "webhook_type": "alert.resolved", + "object_type": "resolved_alert", + "organization_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "resolved_alert": { + "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a94", + "lago_organization_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_alert_id": "1a901a90-1a90-1a90-1a90-1a901a901a92", + "lago_subscription_id": "1a901a90-1a90-1a90-1a90-1a901a901a93", + "external_subscription_id": "sub_1234", + "lago_wallet_id": null, + "external_customer_id": "cust_1234", + "billable_metric_code": "storage", + "alert_name": "Storage usage", + "alert_code": "storage_alert", + "alert_type": "billable_metric_current_usage_amount", + "current_value": "15000.0", + "previous_value": "25000.0", + "recovered_thresholds": [ + { + "code": null, + "value": "10000.0", + "recurring": false + } + ], + "in_alarm_thresholds": ["20000.0"], + "fully_resolved": false, + "reason": "recovered", + "resolved_at": "2026-10-28T13:00:00Z", + "subscription_external_id": "sub_1234", + "customer_external_id": "cust_1234" + } +} diff --git a/tests/fixtures/alert_triggered_webhook.json b/tests/fixtures/alert_triggered_webhook.json new file mode 100644 index 0000000..6a2738f --- /dev/null +++ b/tests/fixtures/alert_triggered_webhook.json @@ -0,0 +1,35 @@ +{ + "webhook_type": "alert.triggered", + "object_type": "triggered_alert", + "organization_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "triggered_alert": { + "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a91", + "lago_organization_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_alert_id": "1a901a90-1a90-1a90-1a90-1a901a901a92", + "lago_subscription_id": "1a901a90-1a90-1a90-1a90-1a901a901a93", + "external_subscription_id": "sub_1234", + "lago_wallet_id": null, + "external_customer_id": "cust_1234", + "billable_metric_code": "storage", + "alert_name": "Storage usage", + "alert_code": "storage_alert", + "alert_type": "billable_metric_current_usage_amount", + "current_value": "25000.0", + "previous_value": "5000.0", + "crossed_thresholds": [ + { + "code": null, + "value": "10000.0", + "recurring": false + }, + { + "code": "warn", + "value": "20000.0", + "recurring": true + } + ], + "triggered_at": "2026-10-28T12:00:00Z", + "subscription_external_id": "sub_1234", + "customer_external_id": "cust_1234" + } +} diff --git a/tests/test_alert_webhook_models.py b/tests/test_alert_webhook_models.py new file mode 100644 index 0000000..d9767a3 --- /dev/null +++ b/tests/test_alert_webhook_models.py @@ -0,0 +1,94 @@ +import json + +from lago_python_client.models import ResolvedAlert, TriggeredAlert + +from .utils.mixin import mock_response + + +def triggered_alert_payload(): + return json.loads(mock_response("alert_triggered_webhook"))["triggered_alert"] + + +def resolved_alert_payload(): + return json.loads(mock_response("alert_resolved_webhook"))["resolved_alert"] + + +def test_triggered_alert_parsing(): + alert = TriggeredAlert.parse_obj(triggered_alert_payload()) + + assert alert.lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a91" + assert alert.lago_organization_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + assert alert.lago_alert_id == "1a901a90-1a90-1a90-1a90-1a901a901a92" + assert alert.lago_subscription_id == "1a901a90-1a90-1a90-1a90-1a901a901a93" + assert alert.external_subscription_id == "sub_1234" + assert alert.lago_wallet_id is None + assert alert.external_customer_id == "cust_1234" + assert alert.billable_metric_code == "storage" + assert alert.alert_name == "Storage usage" + assert alert.alert_code == "storage_alert" + assert alert.alert_type == "billable_metric_current_usage_amount" + assert alert.current_value == "25000.0" + assert alert.previous_value == "5000.0" + assert alert.triggered_at == "2026-10-28T12:00:00Z" + assert alert.subscription_external_id == "sub_1234" + assert alert.customer_external_id == "cust_1234" + + thresholds = alert.crossed_thresholds.__root__ + assert len(thresholds) == 2 + assert thresholds[0].code is None + assert thresholds[0].value == "10000.0" + assert type(thresholds[0].value) is str + assert thresholds[0].recurring is False + assert thresholds[1].code == "warn" + assert thresholds[1].value == "20000.0" + assert type(thresholds[1].value) is str + assert thresholds[1].recurring is True + + +def test_triggered_alert_parsing_without_previous_value(): + payload = triggered_alert_payload() + payload["previous_value"] = None + + alert = TriggeredAlert.parse_obj(payload) + + assert alert.previous_value is None + assert alert.current_value == "25000.0" + + +def test_resolved_alert_parsing(): + alert = ResolvedAlert.parse_obj(resolved_alert_payload()) + + assert alert.lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a94" + assert alert.lago_organization_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + assert alert.lago_alert_id == "1a901a90-1a90-1a90-1a90-1a901a901a92" + assert alert.lago_subscription_id == "1a901a90-1a90-1a90-1a90-1a901a901a93" + assert alert.external_subscription_id == "sub_1234" + assert alert.lago_wallet_id is None + assert alert.external_customer_id == "cust_1234" + assert alert.billable_metric_code == "storage" + assert alert.alert_name == "Storage usage" + assert alert.alert_code == "storage_alert" + assert alert.alert_type == "billable_metric_current_usage_amount" + assert alert.current_value == "15000.0" + assert alert.previous_value == "25000.0" + assert alert.in_alarm_thresholds == ["20000.0"] + assert alert.fully_resolved is False + assert alert.reason == "recovered" + assert alert.resolved_at == "2026-10-28T13:00:00Z" + + thresholds = alert.recovered_thresholds.__root__ + assert len(thresholds) == 1 + assert thresholds[0].code is None + assert thresholds[0].value == "10000.0" + assert type(thresholds[0].value) is str + assert thresholds[0].recurring is False + + +def test_resolved_alert_parsing_without_previous_value(): + payload = resolved_alert_payload() + payload["previous_value"] = None + + alert = ResolvedAlert.parse_obj(payload) + + assert alert.previous_value is None + assert alert.fully_resolved is False From bd31ecdd88759cb1cc76f466d3d32c96d1048cf6 Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:56:25 +0100 Subject: [PATCH 02/16] feat(customer): Add account type and integration fields ## Context The Lago API accepts and returns customer fields that the Python client does not declare yet, so SDK users cannot send or read them. ## Description `Customer` now accepts `account_type` and `external_salesforce_id`, and `IntegrationCustomer` accepts `code`. Fields left unset are not sent. `CustomerResponse` exposes `account_type`, `external_salesforce_id`, `sequential_id` and `slug`. `IntegrationCustomerResponse` exposes `code`, `is_default` and `category`. --- lago_python_client/models/customer.py | 10 +++++ tests/fixtures/customer.json | 7 ++++ tests/test_customer_client.py | 53 +++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/lago_python_client/models/customer.py b/lago_python_client/models/customer.py index 8919b98..77cf91c 100644 --- a/lago_python_client/models/customer.py +++ b/lago_python_client/models/customer.py @@ -35,6 +35,7 @@ class IntegrationCustomer(BaseModel): external_customer_id: Optional[str] integration_type: Optional[str] integration_code: Optional[str] + code: Optional[str] subsidiary_id: Optional[str] sync_with_provider: Optional[bool] @@ -44,6 +45,9 @@ class IntegrationCustomerResponse(BaseModel): external_customer_id: Optional[str] type: Optional[str] integration_code: Optional[str] + code: Optional[str] + is_default: Optional[bool] + category: Optional[str] subsidiary_id: Optional[str] sync_with_provider: Optional[bool] @@ -80,6 +84,7 @@ class MetadataResponseList(BaseModel): class Customer(BaseModel): external_id: str + account_type: Optional[str] address_line1: Optional[str] address_line2: Optional[str] city: Optional[str] @@ -89,6 +94,7 @@ class Customer(BaseModel): legal_name: Optional[str] legal_number: Optional[str] net_payment_term: Optional[int] + external_salesforce_id: Optional[str] tax_identification_number: Optional[str] logo_url: Optional[str] name: Optional[str] @@ -114,6 +120,9 @@ class Customer(BaseModel): class CustomerResponse(BaseResponseModel): lago_id: str external_id: str + account_type: Optional[str] + sequential_id: Optional[int] + slug: Optional[str] address_line1: Optional[str] address_line2: Optional[str] city: Optional[str] @@ -125,6 +134,7 @@ class CustomerResponse(BaseResponseModel): legal_name: Optional[str] legal_number: Optional[str] net_payment_term: Optional[int] + external_salesforce_id: Optional[str] tax_identification_number: Optional[str] logo_url: Optional[str] name: Optional[str] diff --git a/tests/fixtures/customer.json b/tests/fixtures/customer.json index 8790825..7261552 100644 --- a/tests/fixtures/customer.json +++ b/tests/fixtures/customer.json @@ -2,6 +2,7 @@ "customer": { "lago_id": "99a6094e-199b-4101-896a-54e927ce7bd7", "external_id": "5eb02857-a71e-4ea2-bcf9-57d3a41bc6ba", + "account_type": "customer", "billing_entity_code": "test-company", "address_line1": "5230 Penfield Ave", "address_line2": null, @@ -18,6 +19,8 @@ "firstname": "Gavin", "lastname": "Belson", "customer_type": "individual", + "sequential_id": 42, + "slug": "LAG-1234-042", "phone": "1-171-883-3711 x245", "state": "CA", "url": "http://hooli.com", @@ -26,6 +29,7 @@ "timezone": "Europe/Paris", "applicable_timezone": "Europe/Paris", "net_payment_term": null, + "external_salesforce_id": "sf_001", "finalize_zero_amount_invoice": "inherit", "billing_configuration": { "invoice_grace_period": 3, @@ -52,6 +56,9 @@ "external_customer_id": "test-12345", "type": "netsuite", "integration_code": "test", + "code": "netsuite-main", + "is_default": true, + "category": "accounting", "subsidiary_id": "2", "sync_with_provider": true } diff --git a/tests/test_customer_client.py b/tests/test_customer_client.py index 34011eb..c1e5c88 100644 --- a/tests/test_customer_client.py +++ b/tests/test_customer_client.py @@ -1,3 +1,4 @@ +import json import os import pytest @@ -125,12 +126,64 @@ def test_valid_create_customers_request(httpx_mock: HTTPXMock): assert response.shipping_address.state == "XZ" assert response.integration_customers.__root__[0].external_customer_id == "test-12345" assert response.integration_customers.__root__[0].type == "netsuite" + assert response.integration_customers.__root__[0].code == "netsuite-main" + assert response.integration_customers.__root__[0].is_default is True + assert response.integration_customers.__root__[0].category == "accounting" + assert response.account_type == "customer" + assert response.external_salesforce_id == "sf_001" + assert response.sequential_id == 42 + assert response.slug == "LAG-1234-042" assert response.applicable_invoice_custom_sections.__root__[0].code == "custom_section_code" assert response.metadata.__root__[0].lago_id == "12345" assert response.metadata.__root__[0].key == "key" assert response.metadata.__root__[0].value == "value" +def test_valid_create_customers_request_with_account_type_and_salesforce_id(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/customers", + content=mock_response(), + ) + customer = Customer( + external_id="5eb02857-a71e-4ea2-bcf9-57d3a41bc6ba", + account_type="partner", + external_salesforce_id="sf_123", + integration_customers=IntegrationCustomersList( + __root__=[ + IntegrationCustomer( + integration_type="anrok", + integration_code="anrok-1", + code="anrok", + ) + ] + ), + ) + client.customers.create(customer) + + body = json.loads(httpx_mock.get_request().content) + assert body["customer"]["account_type"] == "partner" + assert body["customer"]["external_salesforce_id"] == "sf_123" + assert body["customer"]["integration_customers"][0]["code"] == "anrok" + + +def test_create_customers_request_without_account_type_and_salesforce_id(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/customers", + content=mock_response(), + ) + client.customers.create(Customer(external_id="5eb02857-a71e-4ea2-bcf9-57d3a41bc6ba")) + + body = json.loads(httpx_mock.get_request().content) + assert "account_type" not in body["customer"] + assert "external_salesforce_id" not in body["customer"] + + def test_invalid_create_customers_request(httpx_mock: HTTPXMock): client = Client(api_key="invalid") From be6e1f988f92ea472e2ec5cc6fff3ca2dee31673 Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:56:35 +0100 Subject: [PATCH 03/16] feat(payments): Add missing payment response fields ## Context The Lago API returns several payment fields that `PaymentResponse` did not declare, so they were not available on the parsed object. ## Description `PaymentResponse` now exposes `lago_customer_id`, `external_customer_id`, `lago_payable_id`, `payable_type`, `status`, `payment_provider_code`, `payment_provider_type`, `provider_payment_id`, `provider_customer_id` and `next_action` (a dict). All of them are optional, since payment receipts embed a payment and their fixtures do not carry these keys. The fixtures now include a provider payment and a manual payment, whose provider fields are null and whose `next_action` is empty. A test for `payments.find` was added. --- lago_python_client/models/payment.py | 12 +++++++- tests/fixtures/payment.json | 27 ++++++++++++++--- tests/fixtures/payment_index.json | 43 +++++++++++++++++++++++++++- tests/test_payment_client.py | 40 ++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/lago_python_client/models/payment.py b/lago_python_client/models/payment.py index 869b209..76f82e2 100644 --- a/lago_python_client/models/payment.py +++ b/lago_python_client/models/payment.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Any, Dict, List, Optional from ..base_model import BaseModel, BaseResponseModel @@ -12,14 +12,24 @@ class Payment(BaseModel): class PaymentResponse(BaseResponseModel): lago_id: str + lago_customer_id: Optional[str] + external_customer_id: Optional[str] invoice_ids: List[str] invoice_numbers: List[str] + lago_payable_id: Optional[str] + payable_type: Optional[str] amount_cents: int amount_currency: str + status: Optional[str] payment_status: str type: str reference: str + payment_provider_code: Optional[str] + payment_provider_type: Optional[str] external_payment_id: Optional[str] + provider_payment_id: Optional[str] + provider_customer_id: Optional[str] + next_action: Optional[Dict[str, Any]] created_at: str diff --git a/tests/fixtures/payment.json b/tests/fixtures/payment.json index 9ab94a4..0d5bdf5 100644 --- a/tests/fixtures/payment.json +++ b/tests/fixtures/payment.json @@ -1,14 +1,33 @@ { "payment": { "lago_id": "8e5d5ec2-bdc7-4c43-a944-5ababae775d4", - "invoice_ids": ["f8e194df-5d90-4382-b146-c881d2c67f28"], - "invoice_numbers": ["LAG-1234-567-890"], + "lago_customer_id": "99a6094e-199b-4101-896a-54e927ce7bd7", + "external_customer_id": "customer_external_id", + "invoice_ids": [ + "f8e194df-5d90-4382-b146-c881d2c67f28" + ], + "invoice_numbers": [ + "LAG-1234-567-890" + ], + "lago_payable_id": "f8e194df-5d90-4382-b146-c881d2c67f28", + "payable_type": "Invoice", "amount_cents": 100, "amount_currency": "USD", + "status": "succeeded", "payment_status": "succeeded", - "type": "manual", + "type": "provider", "reference": "123", - "external_payment_id": null, + "payment_provider_code": "stripe_prod", + "payment_provider_type": "stripe", + "external_payment_id": "pi_123", + "provider_payment_id": "pi_123", + "provider_customer_id": "cus_123", + "next_action": { + "type": "redirect_to_url", + "redirect_to_url": { + "url": "https://example.com" + } + }, "created_at": "2025-02-18T18:31:13Z" } } diff --git a/tests/fixtures/payment_index.json b/tests/fixtures/payment_index.json index 71ba597..0cf34e5 100644 --- a/tests/fixtures/payment_index.json +++ b/tests/fixtures/payment_index.json @@ -2,18 +2,59 @@ "payments": [ { "lago_id": "8e5d5ec2-bdc7-4c43-a944-5ababae775d4", + "lago_customer_id": "99a6094e-199b-4101-896a-54e927ce7bd7", + "external_customer_id": "customer_external_id", "invoice_ids": [ "ed267c66-8170-4d23-83e8-6d6e4fc735ef" ], "invoice_numbers": [ "LAG-1234-567-891" ], + "lago_payable_id": "ed267c66-8170-4d23-83e8-6d6e4fc735ef", + "payable_type": "Invoice", "amount_cents": 100, "amount_currency": "USD", + "status": "succeeded", "payment_status": "succeeded", - "type": "manual", + "type": "provider", "reference": "123", + "payment_provider_code": "stripe_prod", + "payment_provider_type": "stripe", + "external_payment_id": "pi_123", + "provider_payment_id": "pi_123", + "provider_customer_id": "cus_123", + "next_action": { + "type": "redirect_to_url", + "redirect_to_url": { + "url": "https://example.com" + } + }, + "created_at": "2025-02-18T18:31:13Z" + }, + { + "lago_id": "5b3a2c1d-7e8f-4a9b-8c7d-6e5f4a3b2c1d", + "lago_customer_id": "99a6094e-199b-4101-896a-54e927ce7bd7", + "external_customer_id": "customer_external_id", + "invoice_ids": [ + "ed267c66-8170-4d23-83e8-6d6e4fc735ef" + ], + "invoice_numbers": [ + "LAG-1234-567-891" + ], + "lago_payable_id": "ed267c66-8170-4d23-83e8-6d6e4fc735ef", + "payable_type": "Invoice", + "amount_cents": 100, + "amount_currency": "USD", + "status": "succeeded", + "payment_status": "succeeded", + "type": "manual", + "reference": "456", + "payment_provider_code": null, + "payment_provider_type": null, "external_payment_id": null, + "provider_payment_id": null, + "provider_customer_id": null, + "next_action": {}, "created_at": "2025-02-18T18:31:13Z" } ], diff --git a/tests/test_payment_client.py b/tests/test_payment_client.py index 6daf6f7..d6193d2 100644 --- a/tests/test_payment_client.py +++ b/tests/test_payment_client.py @@ -33,6 +33,36 @@ def mock_collection_response(): return payments_response.read() +def assert_new_payment_fields(response): + assert response.status == "succeeded" + assert response.lago_customer_id == "99a6094e-199b-4101-896a-54e927ce7bd7" + assert response.external_customer_id == "customer_external_id" + assert response.lago_payable_id == "f8e194df-5d90-4382-b146-c881d2c67f28" + assert response.payable_type == "Invoice" + assert response.payment_provider_code == "stripe_prod" + assert response.payment_provider_type == "stripe" + assert response.provider_payment_id == "pi_123" + assert response.provider_customer_id == "cus_123" + assert isinstance(response.next_action, dict) + assert response.next_action["type"] == "redirect_to_url" + assert response.next_action["redirect_to_url"]["url"] == "https://example.com" + + +def test_valid_find_payment_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + identifier = "8e5d5ec2-bdc7-4c43-a944-5ababae775d4" + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/payments/" + identifier, + content=mock_response(), + ) + response = client.payments.find(identifier) + + assert response.lago_id == identifier + assert_new_payment_fields(response) + + def test_valid_find_all_payments_request(httpx_mock: HTTPXMock): client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") @@ -44,6 +74,15 @@ def test_valid_find_all_payments_request(httpx_mock: HTTPXMock): response = client.payments.find_all() assert response["payments"][0].lago_id == "8e5d5ec2-bdc7-4c43-a944-5ababae775d4" + assert response["payments"][0].payment_provider_type == "stripe" + assert response["payments"][0].next_action["type"] == "redirect_to_url" + assert response["payments"][1].status == "succeeded" + assert response["payments"][1].payable_type == "Invoice" + assert response["payments"][1].payment_provider_code is None + assert response["payments"][1].payment_provider_type is None + assert response["payments"][1].provider_payment_id is None + assert response["payments"][1].provider_customer_id is None + assert response["payments"][1].next_action == {} assert response["meta"]["current_page"] == 1 @@ -94,6 +133,7 @@ def test_valid_create_payment_request(httpx_mock: HTTPXMock): assert response.invoice_ids[0] == "f8e194df-5d90-4382-b146-c881d2c67f28" assert len(response.invoice_numbers) == 1 assert response.invoice_numbers[0] == "LAG-1234-567-890" + assert_new_payment_fields(response) def test_invalid_create_payment_request(httpx_mock: HTTPXMock): From 986cf72cfc125673f313de48e0d73c632d072f85 Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:56:41 +0100 Subject: [PATCH 04/16] feat(models): Add einvoicing and organization slug ## Context The Lago API documents an `einvoicing` flag on billing entities and a `slug` (plus `lago_id`) on the organization. The Python client could neither send nor read these fields. ## Description `BillingEntity`, `BillingEntityUpdate` and `BillingEntityResponse` now have an optional `einvoicing` boolean. The `Organization` input accepts an optional `slug`, and `OrganizationResponse` exposes `lago_id` and `slug`. Fields left unset are still dropped from the request body, and an explicit `einvoicing=False` is sent. --- lago_python_client/models/billing_entity.py | 3 ++ lago_python_client/models/organization.py | 3 ++ tests/fixtures/billing_entity.json | 1 + tests/fixtures/billing_entity_index.json | 2 ++ tests/fixtures/organization.json | 2 ++ tests/test_billing_entity_client.py | 39 +++++++++++++++++++++ tests/test_organization_client.py | 21 +++++++++++ 7 files changed, 71 insertions(+) diff --git a/lago_python_client/models/billing_entity.py b/lago_python_client/models/billing_entity.py index 16c501b..e92b2a6 100644 --- a/lago_python_client/models/billing_entity.py +++ b/lago_python_client/models/billing_entity.py @@ -38,6 +38,7 @@ class BillingEntity(BaseModel): tax_identification_number: Optional[str] email_settings: Optional[List[str]] billing_configuration: Optional[BillingEntityBillingConfiguration] + einvoicing: Optional[bool] class BillingEntityUpdate(BaseModel): @@ -65,6 +66,7 @@ class BillingEntityUpdate(BaseModel): email_settings: Optional[List[str]] billing_configuration: Optional[BillingEntityBillingConfiguration] invoice_custom_section_codes: Optional[List[str]] + einvoicing: Optional[bool] class BillingEntityResponse(BaseResponseModel): @@ -99,5 +101,6 @@ class BillingEntityResponse(BaseResponseModel): email_settings: Optional[List[str]] taxes: Optional[TaxesResponse] selected_invoice_custom_sections: Optional[InvoiceCustomSectionsResponseList] + einvoicing: Optional[bool] created_at: datetime updated_at: datetime diff --git a/lago_python_client/models/organization.py b/lago_python_client/models/organization.py index 258b537..6f17a04 100644 --- a/lago_python_client/models/organization.py +++ b/lago_python_client/models/organization.py @@ -32,9 +32,11 @@ class Organization(BaseModel): email_settings: Optional[List[str]] finalize_zero_amount_invoice: Optional[bool] billing_configuration: Optional[OrganizationBillingConfiguration] + slug: Optional[str] class OrganizationResponse(BaseResponseModel): + lago_id: Optional[str] name: str created_at: str webhook_url: Optional[str] @@ -57,3 +59,4 @@ class OrganizationResponse(BaseResponseModel): finalize_zero_amount_invoice: Optional[bool] billing_configuration: Optional[OrganizationBillingConfiguration] events_store: Optional[str] + slug: Optional[str] diff --git a/tests/fixtures/billing_entity.json b/tests/fixtures/billing_entity.json index eecea36..73e3432 100644 --- a/tests/fixtures/billing_entity.json +++ b/tests/fixtures/billing_entity.json @@ -52,6 +52,7 @@ "payment_receipt.created" ], "is_default": true, + "einvoicing": false, "created_at": "2024-03-20T10:00:00Z", "updated_at": "2024-03-20T10:00:00Z" } diff --git a/tests/fixtures/billing_entity_index.json b/tests/fixtures/billing_entity_index.json index 2862048..906329c 100644 --- a/tests/fixtures/billing_entity_index.json +++ b/tests/fixtures/billing_entity_index.json @@ -33,6 +33,7 @@ "payment_receipt.created" ], "is_default": true, + "einvoicing": false, "created_at": "2024-03-20T10:00:00Z", "updated_at": "2024-03-20T10:00:00Z" }, @@ -66,6 +67,7 @@ "invoice.finalized" ], "is_default": false, + "einvoicing": false, "created_at": "2024-03-20T10:00:00Z", "updated_at": "2024-03-20T10:00:00Z" } diff --git a/tests/fixtures/organization.json b/tests/fixtures/organization.json index 9b8857c..f0126b3 100644 --- a/tests/fixtures/organization.json +++ b/tests/fixtures/organization.json @@ -1,6 +1,8 @@ { "organization": { + "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", "name": "Hooli", + "slug": "hooli", "created_at": "2022-05-02T13:04:09Z", "webhook_url": "https://test-example.example", "webhook_urls": ["https://test-example.example", "https://test-example2.example"], diff --git a/tests/test_billing_entity_client.py b/tests/test_billing_entity_client.py index 745a031..0d2656b 100644 --- a/tests/test_billing_entity_client.py +++ b/tests/test_billing_entity_client.py @@ -1,3 +1,4 @@ +import json import os import pytest @@ -98,6 +99,24 @@ def test_valid_create_billing_entity_request(httpx_mock: HTTPXMock): assert response is not None assert response.lago_id == "123e4567-e89b-12d3-a456-426614174000" assert response.code == "test-company" + assert response.einvoicing is False + assert "einvoicing" not in json.loads(httpx_mock.get_request().content)["billing_entity"] + + +def test_valid_create_billing_entity_request_with_einvoicing(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + billing_entity_input = create_billing_entity_input() + billing_entity_input.einvoicing = True + + httpx_mock.add_response( + method="POST", + url="https://api.getlago.com/api/v1/billing_entities", + content=mock_response(), + ) + client.billing_entities.create(billing_entity_input) + + body = json.loads(httpx_mock.get_request().content) + assert body["billing_entity"]["einvoicing"] is True def test_invalid_create_billing_entity_request(httpx_mock: HTTPXMock): @@ -160,6 +179,7 @@ def test_valid_find_all_billing_entities_request(httpx_mock: HTTPXMock): assert response["billing_entities"][0].code == "test-company-1" assert response["billing_entities"][1].lago_id == "123e4567-e89b-12d3-a456-426614174001" assert response["billing_entities"][1].code == "test-company-2" + assert response["billing_entities"][1].einvoicing is False def test_invalid_find_all_billing_entities_request(httpx_mock: HTTPXMock): @@ -189,6 +209,25 @@ def test_valid_update_billing_entity_request(httpx_mock: HTTPXMock): assert response.lago_id == "123e4567-e89b-12d3-a456-426614174000" assert response.code == "test-company" + assert response.einvoicing is False + assert "einvoicing" not in json.loads(httpx_mock.get_request().content)["billing_entity"] + + +def test_valid_update_billing_entity_request_with_einvoicing(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + billing_entity_code = "test-company" + billing_entity_input = update_billing_entity_input() + billing_entity_input.einvoicing = False + + httpx_mock.add_response( + method="PUT", + url="https://api.getlago.com/api/v1/billing_entities/" + billing_entity_code, + content=mock_response(), + ) + client.billing_entities.update(billing_entity_input, billing_entity_code) + + body = json.loads(httpx_mock.get_request().content) + assert body["billing_entity"]["einvoicing"] is False def test_invalid_update_billing_entity_request(httpx_mock: HTTPXMock): diff --git a/tests/test_organization_client.py b/tests/test_organization_client.py index b51df79..9bb598c 100644 --- a/tests/test_organization_client.py +++ b/tests/test_organization_client.py @@ -1,3 +1,4 @@ +import json import os import pytest @@ -44,6 +45,26 @@ def test_valid_update_organization_request(httpx_mock: HTTPXMock): assert response.tax_identification_number == "EU123456789" assert response.document_number_prefix == "ORG-1234" assert response.net_payment_term == 0 + assert "slug" not in json.loads(httpx_mock.get_request().content)["organization"] + + +def test_valid_update_organization_request_with_slug(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + organization = organization_object() + organization.slug = "acme-corp" + + httpx_mock.add_response( + method="PUT", + url="https://api.getlago.com/api/v1/organizations", + content=mock_response(), + ) + response = client.organizations.update(organization) + + body = json.loads(httpx_mock.get_request().content) + assert body["organization"]["slug"] == "acme-corp" + assert body["organization"]["default_currency"] == "EUR" + assert response.lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + assert response.slug == "hooli" def test_invalid_update_organization_request(httpx_mock: HTTPXMock): From 73a4d0ec5c8eb191295f06efb4ae9055c6111ba6 Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:56:37 +0100 Subject: [PATCH 05/16] feat(subscription): Add progressive billing fields ## Context The Lago API accepts and returns `progressive_billing_disabled` on subscriptions, and accepts `usage_thresholds` on subscription create and update. The Python client did not declare them, so they were dropped from requests and responses. `SubscriptionResponse` also declared `subscription_date`, which the API no longer returns. ## Description The `Subscription` input now has `progressive_billing_disabled` and `usage_thresholds` (reusing `UsageThresholdsOverrides`), and `SubscriptionResponse` exposes `progressive_billing_disabled`. `SubscriptionResponse.subscription_date` was removed. This is a breaking change for code that reads it; `subscription_at` holds the same date. --- lago_python_client/models/subscription.py | 5 +- tests/fixtures/subscription.json | 1 + tests/test_subscription_client.py | 89 +++++++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/lago_python_client/models/subscription.py b/lago_python_client/models/subscription.py index 2c3ea75..ef2938c 100644 --- a/lago_python_client/models/subscription.py +++ b/lago_python_client/models/subscription.py @@ -6,6 +6,7 @@ from .invoice_custom_section import AppliedInvoiceCustomSections, InvoiceCustomSectionInput from .payment_method import PaymentMethod from .plan import PlanOverrides +from .usage_threshold import UsageThresholdsOverrides class ActivationRuleInput(BaseModel): @@ -28,6 +29,8 @@ class Subscription(BaseModel): billing_entity_code: Optional[str] purchase_order_number: Optional[str] activation_rules: Optional[List[ActivationRuleInput]] + progressive_billing_disabled: Optional[bool] + usage_thresholds: Optional[UsageThresholdsOverrides] class Subscriptions(BaseModel): @@ -63,7 +66,6 @@ class SubscriptionResponse(BaseResponseModel): terminated_at: Optional[str] ending_at: Optional[str] trial_ended_at: Optional[str] - subscription_date: Optional[str] subscription_at: Optional[str] previous_plan_code: Optional[str] next_plan_code: Optional[str] @@ -80,6 +82,7 @@ class SubscriptionResponse(BaseResponseModel): cancellation_reason: Optional[str] activated_at: Optional[str] activation_rules: Optional[List[ActivationRuleResponse]] + progressive_billing_disabled: Optional[bool] class SubscriptionsResponse(BaseResponseModel): diff --git a/tests/fixtures/subscription.json b/tests/fixtures/subscription.json index 53cf87a..6776cb7 100644 --- a/tests/fixtures/subscription.json +++ b/tests/fixtures/subscription.json @@ -17,6 +17,7 @@ "on_termination_invoice": "skip", "consolidate_invoice": false, "purchase_order_number": "PO-123", + "progressive_billing_disabled": false, "subscription_at": "2022-04-29T08:59:51Z", "previous_plan_code": null, "next_plan_code": null, diff --git a/tests/test_subscription_client.py b/tests/test_subscription_client.py index 5755174..9c4fea7 100644 --- a/tests/test_subscription_client.py +++ b/tests/test_subscription_client.py @@ -1,3 +1,4 @@ +import json import os import httpx @@ -17,6 +18,8 @@ Subscription, ) from lago_python_client.models.alert import Alert, AlertsList, AlertThreshold +from lago_python_client.models.subscription import SubscriptionResponse +from lago_python_client.models.usage_threshold import UsageThresholdOverrides, UsageThresholdsOverrides def create_subscription(): @@ -130,6 +133,54 @@ def test_valid_create_subscriptions_request_with_activation_rules(httpx_mock: HT assert response.activation_rules[0].status == "pending" +def test_valid_create_subscriptions_request_with_progressive_billing(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/subscriptions", + match_json={ + "subscription": { + "external_customer_id": "5eb02857-a71e-4ea2-bcf9-57d3a41bc6ba", + "plan_code": "eartha lynch", + "external_id": "code", + "billing_time": "anniversary", + "subscription_at": "2022-04-29T08:59:51Z", + "ending_at": "2022-08-29T08:59:51Z", + "consolidate_invoice": False, + "progressive_billing_disabled": True, + "usage_thresholds": [ + {"amount_cents": 10000, "threshold_display_name": "Alert", "recurring": False}, + ], + } + }, + content=mock_response(), + ) + subscription = create_subscription() + subscription.progressive_billing_disabled = True + subscription.usage_thresholds = UsageThresholdsOverrides( + __root__=[UsageThresholdOverrides(amount_cents=10000, threshold_display_name="Alert", recurring=False)] + ) + response = client.subscriptions.create(subscription) + + assert response.progressive_billing_disabled is False + + +def test_valid_create_subscriptions_request_without_progressive_billing(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/subscriptions", + content=mock_response(), + ) + client.subscriptions.create(create_subscription()) + + body = json.loads(httpx_mock.get_request().content)["subscription"] + assert "progressive_billing_disabled" not in body + assert "usage_thresholds" not in body + + def test_invalid_create_subscriptions_request(httpx_mock: HTTPXMock): client = Client(api_key="invalid") @@ -184,6 +235,34 @@ def test_valid_update_subscription_request(httpx_mock: HTTPXMock): assert response.subscription_at == "2022-04-29T08:59:51Z" +def test_valid_update_subscription_request_with_progressive_billing(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + identifier = "sub_id" + + httpx_mock.add_response( + method="PUT", + url="https://api.getlago.com/api/v1/subscriptions/" + identifier, + match_json={ + "subscription": { + "progressive_billing_disabled": False, + "usage_thresholds": [ + {"amount_cents": 5000, "threshold_display_name": "Recurring", "recurring": True}, + ], + } + }, + content=mock_response(), + ) + subscription = Subscription( + progressive_billing_disabled=False, + usage_thresholds=UsageThresholdsOverrides( + __root__=[UsageThresholdOverrides(amount_cents=5000, threshold_display_name="Recurring", recurring=True)] + ), + ) + response = client.subscriptions.update(subscription, identifier) + + assert response.progressive_billing_disabled is False + + def test_valid_update_subscription_request_with_payment_method(httpx_mock: HTTPXMock): client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") identifier = "sub_id" @@ -538,6 +617,16 @@ def mock_subscription_alerts_response(): return alerts_response.read() +def test_subscription_response_has_no_subscription_date(): + assert "subscription_date" not in SubscriptionResponse.__fields__ + + response = SubscriptionResponse.parse_obj(json.loads(mock_response())["subscription"]) + + assert not hasattr(response, "subscription_date") + assert response.subscription_at == "2022-04-29T08:59:51Z" + assert response.progressive_billing_disabled is False + + def test_valid_create_alerts_request(httpx_mock: HTTPXMock): client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") external_id = "sub_1234" From 3c3626d79163ef34a82c3fba7adc1dfb9f2392e6 Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:56:42 +0100 Subject: [PATCH 06/16] feat(plan): Add entitlements and pending_deletion ## Context The plan object returned by the Lago API documents `pending_deletion`, `entitlements` and `applicable_usage_thresholds`, but `PlanResponse` did not declare them, so they were dropped when parsing. ## Description `PlanResponse` now exposes the three fields. Entitlements are parsed into new models in `models/entitlement.py`. A privilege `value` is typed `Any` so it keeps its JSON type (an integer, a boolean or a string like `"10"`); a `Union` would make pydantic v1 coerce `true` to `1` and `"10"` to `10`. Applicable usage thresholds use a new `ApplicableUsageThresholdResponse`, since these items have no `lago_id` and `UsageThresholdResponse` requires one. --- lago_python_client/models/entitlement.py | 26 ++++++++++++ lago_python_client/models/plan.py | 5 +++ lago_python_client/models/usage_threshold.py | 10 +++++ tests/fixtures/plan.json | 42 +++++++++++++++++++- tests/test_plan_client.py | 36 +++++++++++++++++ 5 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 lago_python_client/models/entitlement.py diff --git a/lago_python_client/models/entitlement.py b/lago_python_client/models/entitlement.py new file mode 100644 index 0000000..8f14e36 --- /dev/null +++ b/lago_python_client/models/entitlement.py @@ -0,0 +1,26 @@ +from typing import Any, Dict, List, Optional + +from ..base_model import BaseResponseModel + + +class PlanEntitlementPrivilegeResponse(BaseResponseModel): + code: Optional[str] + name: Optional[str] + value_type: Optional[str] + config: Optional[Dict[str, Any]] + value: Any + + +class PlanEntitlementPrivilegesResponse(BaseResponseModel): + __root__: List[PlanEntitlementPrivilegeResponse] + + +class PlanEntitlementResponse(BaseResponseModel): + code: Optional[str] + name: Optional[str] + description: Optional[str] + privileges: Optional[PlanEntitlementPrivilegesResponse] + + +class PlanEntitlementsResponse(BaseResponseModel): + __root__: List[PlanEntitlementResponse] diff --git a/lago_python_client/models/plan.py b/lago_python_client/models/plan.py index e429d59..d161301 100644 --- a/lago_python_client/models/plan.py +++ b/lago_python_client/models/plan.py @@ -4,6 +4,7 @@ from ..base_model import BaseResponseModel from .charge import Charges, ChargesOverrides, ChargesResponse +from .entitlement import PlanEntitlementsResponse from .fixed_charge import ( FixedCharges, FixedChargesOverrides, @@ -16,6 +17,7 @@ ) from .tax import TaxesResponse from .usage_threshold import ( + ApplicableUsageThresholdsResponse, UsageThresholds, UsageThresholdsOverrides, UsageThresholdsResponse, @@ -63,6 +65,9 @@ class PlanResponse(BaseResponseModel): usage_thresholds: Optional[UsageThresholdsResponse] taxes: Optional[TaxesResponse] metadata: Optional[Dict[str, Optional[str]]] + pending_deletion: Optional[bool] + entitlements: Optional[PlanEntitlementsResponse] + applicable_usage_thresholds: Optional[ApplicableUsageThresholdsResponse] class PlanOverrides(BaseModel): diff --git a/lago_python_client/models/usage_threshold.py b/lago_python_client/models/usage_threshold.py index b50bd61..61b86fa 100644 --- a/lago_python_client/models/usage_threshold.py +++ b/lago_python_client/models/usage_threshold.py @@ -29,6 +29,16 @@ class UsageThresholdsResponse(BaseResponseModel): __root__: List[UsageThresholdResponse] +class ApplicableUsageThresholdResponse(BaseResponseModel): + threshold_display_name: Optional[str] + amount_cents: Optional[int] + recurring: Optional[bool] + + +class ApplicableUsageThresholdsResponse(BaseResponseModel): + __root__: List[ApplicableUsageThresholdResponse] + + class UsageThresholdOverrides(BaseModel): amount_cents: Optional[int] threshold_display_name: Optional[str] diff --git a/tests/fixtures/plan.json b/tests/fixtures/plan.json index 1423c64..2d0ea07 100644 --- a/tests/fixtures/plan.json +++ b/tests/fixtures/plan.json @@ -90,6 +90,46 @@ "metadata": { "key1": "value1", "key2": null - } + }, + "pending_deletion": false, + "entitlements": [ + { + "code": "seats", + "name": "Seats", + "description": "Number of seats", + "privileges": [ + { + "code": "max", + "name": "Maximum", + "value_type": "integer", + "config": {}, + "value": 10 + }, + { + "code": "sso", + "name": "SSO", + "value_type": "boolean", + "config": {}, + "value": true + }, + { + "code": "tier", + "name": "Tier", + "value_type": "select", + "config": { + "select_options": ["5", "10"] + }, + "value": "10" + } + ] + } + ], + "applicable_usage_thresholds": [ + { + "amount_cents": 20, + "threshold_display_name": "Applicable threshold", + "recurring": false + } + ] } } diff --git a/tests/test_plan_client.py b/tests/test_plan_client.py index 3425dad..ea32111 100644 --- a/tests/test_plan_client.py +++ b/tests/test_plan_client.py @@ -18,6 +18,7 @@ UsageThreshold, UsageThresholds, ) +from lago_python_client.models.entitlement import PlanEntitlementPrivilegeResponse def plan_object(): @@ -206,6 +207,9 @@ def test_valid_create_plan_request(httpx_mock: HTTPXMock): assert response.minimum_commitment.invoice_display_name == "Minimum commitment (C1)" assert response.usage_thresholds.__root__[0].threshold_display_name == "Threshold 1" assert response.metadata == {"key1": "value1", "key2": None} + assert response.pending_deletion is False + assert response.entitlements.__root__[0].code == "seats" + assert response.applicable_usage_thresholds.__root__[0].amount_cents == 20 def test_valid_create_graduated_plan_request(httpx_mock: HTTPXMock): @@ -288,6 +292,38 @@ def test_valid_find_plan_request(httpx_mock: HTTPXMock): charge = response.charges.__root__[0] assert charge.properties["presentation_group_keys"][0]["value"] == "region" assert response.minimum_commitment.amount_cents == 1000 + assert response.pending_deletion is False + + entitlement = response.entitlements.__root__[0] + assert entitlement.code == "seats" + assert entitlement.name == "Seats" + assert entitlement.description == "Number of seats" + + integer_privilege, boolean_privilege, select_privilege = entitlement.privileges.__root__ + assert integer_privilege.code == "max" + assert integer_privilege.name == "Maximum" + assert integer_privilege.value_type == "integer" + assert type(integer_privilege.value) is int + assert integer_privilege.value == 10 + assert boolean_privilege.value_type == "boolean" + assert boolean_privilege.value is True + assert select_privilege.value_type == "select" + assert type(select_privilege.value) is str + assert select_privilege.value == "10" + assert isinstance(select_privilege.config, dict) + assert select_privilege.config == {"select_options": ["5", "10"]} + + applicable_threshold = response.applicable_usage_thresholds.__root__[0] + assert applicable_threshold.threshold_display_name == "Applicable threshold" + assert applicable_threshold.amount_cents == 20 + assert applicable_threshold.recurring is False + + +def test_plan_entitlement_privilege_keeps_string_value(): + privilege = PlanEntitlementPrivilegeResponse.parse_obj({"code": "x", "value": "10"}) + + assert type(privilege.value) is str + assert privilege.value == "10" def test_invalid_find_plan_request(httpx_mock: HTTPXMock): From ab11ab9083e94d16e556fdfe8babdf61eab5f94d Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:57:16 +0100 Subject: [PATCH 07/16] feat(wallets): Add wallet transaction input fields ## Context The Lago API accepts `transaction_priority` when a wallet is created, and `priority` and `voided_transaction_id` when a wallet transaction is created. The input models of the client did not declare these fields, so they could not be sent. ## Description `Wallet` now declares `transaction_priority`, and `WalletTransaction` declares `priority` and `voided_transaction_id`. The response models are unchanged. --- lago_python_client/models/wallet.py | 1 + .../models/wallet_transaction.py | 2 + tests/test_wallet_client.py | 47 +++++++++++++++++++ tests/test_wallet_transaction_client.py | 21 +++++++++ 4 files changed, 71 insertions(+) diff --git a/lago_python_client/models/wallet.py b/lago_python_client/models/wallet.py index fa7c028..ee3e2eb 100644 --- a/lago_python_client/models/wallet.py +++ b/lago_python_client/models/wallet.py @@ -68,6 +68,7 @@ class Wallet(BaseModel): name: Optional[str] code: Optional[str] priority: Optional[int] + transaction_priority: Optional[int] paid_credits: Optional[str] granted_credits: Optional[str] expiration_at: Optional[str] diff --git a/lago_python_client/models/wallet_transaction.py b/lago_python_client/models/wallet_transaction.py index 620c218..5d69c57 100644 --- a/lago_python_client/models/wallet_transaction.py +++ b/lago_python_client/models/wallet_transaction.py @@ -19,6 +19,8 @@ class WalletTransaction(BaseModel): purchase_order_number: Optional[str] payment_method: Optional[PaymentMethod] invoice_custom_section: Optional[InvoiceCustomSectionInput] + priority: Optional[int] + voided_transaction_id: Optional[str] class WalletTransactionResponse(BaseResponseModel): diff --git a/tests/test_wallet_client.py b/tests/test_wallet_client.py index 9457c96..9db70ab 100644 --- a/tests/test_wallet_client.py +++ b/tests/test_wallet_client.py @@ -122,6 +122,53 @@ def test_valid_create_wallet_request(httpx_mock: HTTPXMock): assert response.recurring_transaction_rules.__root__[0].purchase_order_number == "PO-RULE-123" +def test_valid_create_wallet_request_with_transaction_priority(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/wallets", + content=mock_response(), + match_json={ + "wallet": { + "external_customer_id": "12345", + "rate_amount": "1", + "name": "name", + "code": "wallet_code", + "priority": 30, + "transaction_priority": 10, + "paid_credits": "10", + "granted_credits": "10", + "paid_top_up_max_amount_cents": 10000, + "paid_top_up_min_amount_cents": 500, + "recurring_transaction_rules": [ + { + "interval": "monthly", + "trigger": "interval", + "method": "target", + "paid_credits": "105.0", + "granted_credits": "105.0", + "target_ongoing_balance": "105.0", + "transaction_name": "Recurring Transaction Rule", + "purchase_order_number": "PO-RULE-123", + "ignore_paid_top_up_limits": True, + "grants_target_top_up": True, + } + ], + "invoice_requires_successful_payment": False, + "transaction_name": "Transaction Name", + "purchase_order_number": "PO-123", + "applies_to": {"fee_types": ["charge"], "billable_metric_codes": ["usage"]}, + } + }, + ) + wallet = wallet_object() + wallet.transaction_priority = 10 + response = client.wallets.create(wallet) + + assert response.lago_id == "b7ab2926-1de8-4428-9bcd-779314ac129b" + + def test_valid_create_wallet_request_with_payment_method_on_wallet(httpx_mock: HTTPXMock): client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") payment_method = PaymentMethod(payment_method_type="card", payment_method_id="pm_123") diff --git a/tests/test_wallet_transaction_client.py b/tests/test_wallet_transaction_client.py index 76c0712..7f8bccf 100644 --- a/tests/test_wallet_transaction_client.py +++ b/tests/test_wallet_transaction_client.py @@ -1,3 +1,4 @@ +import json import os import pytest @@ -80,6 +81,26 @@ def test_valid_create_wallet_transaction_request(httpx_mock: HTTPXMock): assert response["wallet_transactions"][2].priority == 50 +def test_valid_create_wallet_transaction_request_with_priority_and_voided_transaction_id(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/wallet_transactions", + content=mock_response(), + ) + transaction = wallet_transaction_object() + transaction.priority = 25 + transaction.voided_transaction_id = "b7ab2926-1de8-4428-9bcd-779314ac1111" + response = client.wallet_transactions.create(transaction) + + body = json.loads(httpx_mock.get_request().content) + assert body["wallet_transaction"]["priority"] == 25 + assert body["wallet_transaction"]["voided_transaction_id"] == "b7ab2926-1de8-4428-9bcd-779314ac1111" + assert response["wallet_transactions"][0].lago_id == "b7ab2926-1de8-4428-9bcd-779314ac1111" + assert response["wallet_transactions"][0].priority == 50 + + def test_valid_create_wallet_transaction_request_with_payment_method(httpx_mock: HTTPXMock): client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") payment_method = PaymentMethod(payment_method_type="card", payment_method_id="pm_123") From 2aa433c7161a296ee792ab8ed1e22598779d6d81 Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:34:37 +0100 Subject: [PATCH 08/16] test(wallets): Cover unset wallet transaction fields ## Context `client.wallet_transactions.create` serializes the input model without excluding unset fields, so every unset field is sent as `null`. The Lago API treats a `null` `priority` or `voided_transaction_id` the same as an absent one. ## Description A test now shows that `priority` and `voided_transaction_id` are sent as `null` when they are not set. --- tests/test_wallet_transaction_client.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_wallet_transaction_client.py b/tests/test_wallet_transaction_client.py index 7f8bccf..a6b0590 100644 --- a/tests/test_wallet_transaction_client.py +++ b/tests/test_wallet_transaction_client.py @@ -101,6 +101,21 @@ def test_valid_create_wallet_transaction_request_with_priority_and_voided_transa assert response["wallet_transactions"][0].priority == 50 +def test_valid_create_wallet_transaction_request_without_priority_and_voided_transaction_id(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/wallet_transactions", + content=mock_response(), + ) + client.wallet_transactions.create(wallet_transaction_object()) + + body = json.loads(httpx_mock.get_request().content) + assert body["wallet_transaction"]["priority"] is None + assert body["wallet_transaction"]["voided_transaction_id"] is None + + def test_valid_create_wallet_transaction_request_with_payment_method(httpx_mock: HTTPXMock): client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") payment_method = PaymentMethod(payment_method_type="card", payment_method_id="pm_123") From 08c17d2955a5f35422ed349cbcfaef0e2fec808b Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:35:47 +0100 Subject: [PATCH 09/16] test(wallets): Match full wallet transaction bodies ## Context The wallet transaction specs for priority and voided_transaction_id read the sent request body and checked a few keys, so other fields of the body were not checked. ## Description Both specs now pass the full expected body to match_json, like the other client specs. Unset fields are expected as null, which is how WalletTransactionClient serializes them. --- tests/test_wallet_transaction_client.py | 44 ++++++++++++++++++++----- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/tests/test_wallet_transaction_client.py b/tests/test_wallet_transaction_client.py index a6b0590..ac96540 100644 --- a/tests/test_wallet_transaction_client.py +++ b/tests/test_wallet_transaction_client.py @@ -1,4 +1,3 @@ -import json import os import pytest @@ -87,6 +86,23 @@ def test_valid_create_wallet_transaction_request_with_priority_and_voided_transa httpx_mock.add_response( method="POST", url="https://api.getlago.com/api/v1/wallet_transactions", + match_json={ + "wallet_transaction": { + "wallet_id": "123", + "paid_credits": "10", + "granted_credits": "10", + "voided_credits": "0", + "invoice_requires_successful_payment": False, + "metadata": None, + "name": "Transaction Name", + "ignore_paid_top_up_limits": None, + "purchase_order_number": "PO-123", + "payment_method": None, + "invoice_custom_section": None, + "priority": 25, + "voided_transaction_id": "b7ab2926-1de8-4428-9bcd-779314ac1111", + } + }, content=mock_response(), ) transaction = wallet_transaction_object() @@ -94,9 +110,6 @@ def test_valid_create_wallet_transaction_request_with_priority_and_voided_transa transaction.voided_transaction_id = "b7ab2926-1de8-4428-9bcd-779314ac1111" response = client.wallet_transactions.create(transaction) - body = json.loads(httpx_mock.get_request().content) - assert body["wallet_transaction"]["priority"] == 25 - assert body["wallet_transaction"]["voided_transaction_id"] == "b7ab2926-1de8-4428-9bcd-779314ac1111" assert response["wallet_transactions"][0].lago_id == "b7ab2926-1de8-4428-9bcd-779314ac1111" assert response["wallet_transactions"][0].priority == 50 @@ -107,13 +120,28 @@ def test_valid_create_wallet_transaction_request_without_priority_and_voided_tra httpx_mock.add_response( method="POST", url="https://api.getlago.com/api/v1/wallet_transactions", + match_json={ + "wallet_transaction": { + "wallet_id": "123", + "paid_credits": "10", + "granted_credits": "10", + "voided_credits": "0", + "invoice_requires_successful_payment": False, + "metadata": None, + "name": "Transaction Name", + "ignore_paid_top_up_limits": None, + "purchase_order_number": "PO-123", + "payment_method": None, + "invoice_custom_section": None, + "priority": None, + "voided_transaction_id": None, + } + }, content=mock_response(), ) - client.wallet_transactions.create(wallet_transaction_object()) + response = client.wallet_transactions.create(wallet_transaction_object()) - body = json.loads(httpx_mock.get_request().content) - assert body["wallet_transaction"]["priority"] is None - assert body["wallet_transaction"]["voided_transaction_id"] is None + assert response["wallet_transactions"][0].lago_id == "b7ab2926-1de8-4428-9bcd-779314ac1111" def test_valid_create_wallet_transaction_request_with_payment_method(httpx_mock: HTTPXMock): From 4b353066904e02e996f233131e9f412953bf063a Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:40:15 +0100 Subject: [PATCH 10/16] test(alerts): Cover wallet alert webhook payloads ## Context The alert webhook tests only parsed subscription alerts, so the Optional subscription fields and the deprecated keys were never absent or null in a test. ## Description New tests parse `alert.triggered` and `alert.resolved` payloads for a wallet alert, with the subscription fields set to null and the deprecated keys removed. --- tests/test_alert_webhook_models.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_alert_webhook_models.py b/tests/test_alert_webhook_models.py index d9767a3..8ff8c76 100644 --- a/tests/test_alert_webhook_models.py +++ b/tests/test_alert_webhook_models.py @@ -92,3 +92,33 @@ def test_resolved_alert_parsing_without_previous_value(): assert alert.previous_value is None assert alert.fully_resolved is False + + +def wallet_alert_payload(payload): + payload["lago_subscription_id"] = None + payload["external_subscription_id"] = None + payload["billable_metric_code"] = None + payload["lago_wallet_id"] = "1a901a90-1a90-1a90-1a90-1a901a901a95" + payload.pop("subscription_external_id", None) + payload.pop("customer_external_id", None) + return payload + + +def test_triggered_alert_parsing_for_wallet_alert(): + alert = TriggeredAlert.parse_obj(wallet_alert_payload(triggered_alert_payload())) + + assert alert.lago_wallet_id == "1a901a90-1a90-1a90-1a90-1a901a901a95" + assert alert.lago_subscription_id is None + assert alert.external_subscription_id is None + assert alert.billable_metric_code is None + assert alert.subscription_external_id is None + assert alert.customer_external_id is None + + +def test_resolved_alert_parsing_for_wallet_alert(): + alert = ResolvedAlert.parse_obj(wallet_alert_payload(resolved_alert_payload())) + + assert alert.lago_wallet_id == "1a901a90-1a90-1a90-1a90-1a901a901a95" + assert alert.lago_subscription_id is None + assert alert.external_subscription_id is None + assert alert.billable_metric_code is None From 33a127d5ba04bc8578c1b95119f2dbe68ea11a77 Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:13:19 +0100 Subject: [PATCH 11/16] fix(wallets): Omit unset priority on transactions ## Context lago-api's void path passes the raw `priority` param to the wallet transaction create service, which requires a priority. Sending `priority: null` makes void requests without a priority fail. ## Description `WalletTransactionClient.create` now leaves out `priority` and `voided_transaction_id` when they are None. Other fields keep their current serialization. --- lago_python_client/wallets/clients.py | 7 +++++- tests/test_wallet_transaction_client.py | 30 +++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/lago_python_client/wallets/clients.py b/lago_python_client/wallets/clients.py index 85c6f7a..6ba648c 100644 --- a/lago_python_client/wallets/clients.py +++ b/lago_python_client/wallets/clients.py @@ -104,12 +104,17 @@ class WalletTransactionClient(BaseClient): ROOT_NAME: ClassVar[str] = "wallet_transactions" def create(self, input_object: BaseModel) -> Mapping[str, Any]: + payload = input_object.dict() + for key in ("priority", "voided_transaction_id"): + if payload.get(key) is None: + payload.pop(key, None) + api_response: Response = send_post_request( url=make_url( origin=self.base_url, path_parts=(self.API_RESOURCE,), ), - content=to_json({"wallet_transaction": input_object.dict()}), + content=to_json({"wallet_transaction": payload}), headers=make_headers(api_key=self.api_key), ) diff --git a/tests/test_wallet_transaction_client.py b/tests/test_wallet_transaction_client.py index ac96540..12aa97d 100644 --- a/tests/test_wallet_transaction_client.py +++ b/tests/test_wallet_transaction_client.py @@ -133,8 +133,6 @@ def test_valid_create_wallet_transaction_request_without_priority_and_voided_tra "purchase_order_number": "PO-123", "payment_method": None, "invoice_custom_section": None, - "priority": None, - "voided_transaction_id": None, } }, content=mock_response(), @@ -144,6 +142,34 @@ def test_valid_create_wallet_transaction_request_without_priority_and_voided_tra assert response["wallet_transactions"][0].lago_id == "b7ab2926-1de8-4428-9bcd-779314ac1111" +def test_valid_create_wallet_transaction_request_voiding_credits_without_priority(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/wallet_transactions", + match_json={ + "wallet_transaction": { + "wallet_id": "123", + "paid_credits": None, + "granted_credits": None, + "voided_credits": "5", + "invoice_requires_successful_payment": None, + "metadata": None, + "name": None, + "ignore_paid_top_up_limits": None, + "purchase_order_number": None, + "payment_method": None, + "invoice_custom_section": None, + } + }, + content=mock_response(), + ) + response = client.wallet_transactions.create(WalletTransaction(wallet_id="123", voided_credits="5")) + + assert response["wallet_transactions"][0].lago_id == "b7ab2926-1de8-4428-9bcd-779314ac1111" + + def test_valid_create_wallet_transaction_request_with_payment_method(httpx_mock: HTTPXMock): client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") payment_method = PaymentMethod(payment_method_type="card", payment_method_id="pm_123") From ba4c5cfc6ce384d3eb463465cc7e501aaefd0abd Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:15:26 +0100 Subject: [PATCH 12/16] feat(models): Export plan entitlement and threshold models The plan entitlement and applicable usage threshold response models are now re-exported from lago_python_client.models, like the other response models. --- lago_python_client/models/__init__.py | 18 ++++++++++++++++++ tests/test_plan_client.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lago_python_client/models/__init__.py b/lago_python_client/models/__init__.py index e02722f..71fe475 100644 --- a/lago_python_client/models/__init__.py +++ b/lago_python_client/models/__init__.py @@ -156,6 +156,18 @@ from .customer_usage import ( PresentationBreakdowns as PresentationBreakdowns, ) +from .entitlement import ( + PlanEntitlementPrivilegeResponse as PlanEntitlementPrivilegeResponse, +) +from .entitlement import ( + PlanEntitlementPrivilegesResponse as PlanEntitlementPrivilegesResponse, +) +from .entitlement import ( + PlanEntitlementResponse as PlanEntitlementResponse, +) +from .entitlement import ( + PlanEntitlementsResponse as PlanEntitlementsResponse, +) from .event import BatchEvent as BatchEvent from .event import Event as Event from .fee import Fee as Fee @@ -301,6 +313,12 @@ from .tax import ( TaxResponse as TaxResponse, ) +from .usage_threshold import ( + ApplicableUsageThresholdResponse as ApplicableUsageThresholdResponse, +) +from .usage_threshold import ( + ApplicableUsageThresholdsResponse as ApplicableUsageThresholdsResponse, +) from .usage_threshold import ( UsageThreshold as UsageThreshold, ) diff --git a/tests/test_plan_client.py b/tests/test_plan_client.py index ea32111..f2aa9f4 100644 --- a/tests/test_plan_client.py +++ b/tests/test_plan_client.py @@ -15,10 +15,10 @@ FixedCharges, MinimumCommitment, Plan, + PlanEntitlementPrivilegeResponse, UsageThreshold, UsageThresholds, ) -from lago_python_client.models.entitlement import PlanEntitlementPrivilegeResponse def plan_object(): From c0a133e99160f86f3063e91bc449892bff59c1ad Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:15:26 +0100 Subject: [PATCH 13/16] test(wallets): Send a zero priority on transactions A priority of 0 is valid and must be sent. This test guards the None check that leaves out an unset priority. --- tests/test_wallet_transaction_client.py | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_wallet_transaction_client.py b/tests/test_wallet_transaction_client.py index 12aa97d..3035f19 100644 --- a/tests/test_wallet_transaction_client.py +++ b/tests/test_wallet_transaction_client.py @@ -114,6 +114,38 @@ def test_valid_create_wallet_transaction_request_with_priority_and_voided_transa assert response["wallet_transactions"][0].priority == 50 +def test_valid_create_wallet_transaction_request_with_zero_priority(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/wallet_transactions", + match_json={ + "wallet_transaction": { + "wallet_id": "123", + "paid_credits": "10", + "granted_credits": "10", + "voided_credits": "0", + "invoice_requires_successful_payment": False, + "metadata": None, + "name": "Transaction Name", + "ignore_paid_top_up_limits": None, + "purchase_order_number": "PO-123", + "payment_method": None, + "invoice_custom_section": None, + "priority": 0, + } + }, + content=mock_response(), + ) + transaction = wallet_transaction_object() + transaction.priority = 0 + response = client.wallet_transactions.create(transaction) + + assert response["wallet_transactions"][0].lago_id == "b7ab2926-1de8-4428-9bcd-779314ac1111" + assert response["wallet_transactions"][0].priority == 50 + + def test_valid_create_wallet_transaction_request_without_priority_and_voided_transaction_id(httpx_mock: HTTPXMock): client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") From c24b77b7cfacd9e428a52690bd872bde374fac9e Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:36:18 +0100 Subject: [PATCH 14/16] refactor(alerts): Share the alert webhook fields and threshold model TriggeredAlert and ResolvedAlert now inherit their common fields from AlertWebhookBase. Crossed and recovered thresholds have the same shape as alert thresholds, so they reuse AlertThresholdResponseList instead of a separate model. The resolved alert fixture no longer holds the deprecated subscription_external_id and customer_external_id keys, which the API only sends on triggered alerts. --- lago_python_client/models/__init__.py | 6 ---- lago_python_client/models/alert.py | 34 +++++----------------- tests/fixtures/alert_resolved_webhook.json | 4 +-- 3 files changed, 8 insertions(+), 36 deletions(-) diff --git a/lago_python_client/models/__init__.py b/lago_python_client/models/__init__.py index 71fe475..9b0b42e 100644 --- a/lago_python_client/models/__init__.py +++ b/lago_python_client/models/__init__.py @@ -25,12 +25,6 @@ from .alert import ( AlertThresholdResponseList as AlertThresholdResponseList, ) -from .alert import ( - AlertCrossedThreshold as AlertCrossedThreshold, -) -from .alert import ( - AlertCrossedThresholdList as AlertCrossedThresholdList, -) from .alert import ( TriggeredAlert as TriggeredAlert, ) diff --git a/lago_python_client/models/alert.py b/lago_python_client/models/alert.py index da9e0fa..301d7bb 100644 --- a/lago_python_client/models/alert.py +++ b/lago_python_client/models/alert.py @@ -59,17 +59,7 @@ class AlertsResponseList(BaseResponseModel): alerts: List[AlertResponse] -class AlertCrossedThreshold(BaseResponseModel): - code: Optional[str] - value: str - recurring: Optional[bool] - - -class AlertCrossedThresholdList(BaseResponseModel): - __root__: List[AlertCrossedThreshold] - - -class TriggeredAlert(BaseResponseModel): +class AlertWebhookBase(BaseResponseModel): lago_id: str lago_organization_id: str lago_alert_id: str @@ -83,7 +73,10 @@ class TriggeredAlert(BaseResponseModel): alert_type: str current_value: Optional[str] previous_value: Optional[str] - crossed_thresholds: AlertCrossedThresholdList + + +class TriggeredAlert(AlertWebhookBase): + crossed_thresholds: AlertThresholdResponseList triggered_at: str # Deprecated subscription_external_id: Optional[str] @@ -91,21 +84,8 @@ class TriggeredAlert(BaseResponseModel): customer_external_id: Optional[str] -class ResolvedAlert(BaseResponseModel): - lago_id: str - lago_organization_id: str - lago_alert_id: str - lago_subscription_id: Optional[str] - external_subscription_id: Optional[str] - lago_wallet_id: Optional[str] - external_customer_id: Optional[str] - billable_metric_code: Optional[str] - alert_name: Optional[str] - alert_code: str - alert_type: str - current_value: Optional[str] - previous_value: Optional[str] - recovered_thresholds: AlertCrossedThresholdList +class ResolvedAlert(AlertWebhookBase): + recovered_thresholds: AlertThresholdResponseList in_alarm_thresholds: List[str] fully_resolved: bool reason: Optional[str] diff --git a/tests/fixtures/alert_resolved_webhook.json b/tests/fixtures/alert_resolved_webhook.json index e743ead..fc23ed7 100644 --- a/tests/fixtures/alert_resolved_webhook.json +++ b/tests/fixtures/alert_resolved_webhook.json @@ -26,8 +26,6 @@ "in_alarm_thresholds": ["20000.0"], "fully_resolved": false, "reason": "recovered", - "resolved_at": "2026-10-28T13:00:00Z", - "subscription_external_id": "sub_1234", - "customer_external_id": "cust_1234" + "resolved_at": "2026-10-28T13:00:00Z" } } From 63dd77782fce9a33b55290cb8d283e24ee92205f Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:36:42 +0100 Subject: [PATCH 15/16] refactor(wallets): Drop unset fields from wallet transaction create The wallet transaction create now sends input_object.dict(exclude_none=True), like the other create calls, instead of a hand-written list of keys to drop. On create, an unset field and a null field have the same effect on the API. A priority of 0 is still sent. --- lago_python_client/wallets/clients.py | 7 +------ tests/test_wallet_transaction_client.py | 21 --------------------- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/lago_python_client/wallets/clients.py b/lago_python_client/wallets/clients.py index 6ba648c..3a02515 100644 --- a/lago_python_client/wallets/clients.py +++ b/lago_python_client/wallets/clients.py @@ -104,17 +104,12 @@ class WalletTransactionClient(BaseClient): ROOT_NAME: ClassVar[str] = "wallet_transactions" def create(self, input_object: BaseModel) -> Mapping[str, Any]: - payload = input_object.dict() - for key in ("priority", "voided_transaction_id"): - if payload.get(key) is None: - payload.pop(key, None) - api_response: Response = send_post_request( url=make_url( origin=self.base_url, path_parts=(self.API_RESOURCE,), ), - content=to_json({"wallet_transaction": payload}), + content=to_json({"wallet_transaction": input_object.dict(exclude_none=True)}), headers=make_headers(api_key=self.api_key), ) diff --git a/tests/test_wallet_transaction_client.py b/tests/test_wallet_transaction_client.py index 3035f19..9fc91b8 100644 --- a/tests/test_wallet_transaction_client.py +++ b/tests/test_wallet_transaction_client.py @@ -93,12 +93,8 @@ def test_valid_create_wallet_transaction_request_with_priority_and_voided_transa "granted_credits": "10", "voided_credits": "0", "invoice_requires_successful_payment": False, - "metadata": None, "name": "Transaction Name", - "ignore_paid_top_up_limits": None, "purchase_order_number": "PO-123", - "payment_method": None, - "invoice_custom_section": None, "priority": 25, "voided_transaction_id": "b7ab2926-1de8-4428-9bcd-779314ac1111", } @@ -127,12 +123,8 @@ def test_valid_create_wallet_transaction_request_with_zero_priority(httpx_mock: "granted_credits": "10", "voided_credits": "0", "invoice_requires_successful_payment": False, - "metadata": None, "name": "Transaction Name", - "ignore_paid_top_up_limits": None, "purchase_order_number": "PO-123", - "payment_method": None, - "invoice_custom_section": None, "priority": 0, } }, @@ -159,12 +151,8 @@ def test_valid_create_wallet_transaction_request_without_priority_and_voided_tra "granted_credits": "10", "voided_credits": "0", "invoice_requires_successful_payment": False, - "metadata": None, "name": "Transaction Name", - "ignore_paid_top_up_limits": None, "purchase_order_number": "PO-123", - "payment_method": None, - "invoice_custom_section": None, } }, content=mock_response(), @@ -183,16 +171,7 @@ def test_valid_create_wallet_transaction_request_voiding_credits_without_priorit match_json={ "wallet_transaction": { "wallet_id": "123", - "paid_credits": None, - "granted_credits": None, "voided_credits": "5", - "invoice_requires_successful_payment": None, - "metadata": None, - "name": None, - "ignore_paid_top_up_limits": None, - "purchase_order_number": None, - "payment_method": None, - "invoice_custom_section": None, } }, content=mock_response(), From 237394ac12ed99c3bb50f5b9c5ff563da9d44a2b Mon Sep 17 00:00:00 2001 From: "Yohan R." <7434196+groyoh@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:37:16 +0100 Subject: [PATCH 16/16] feat(subscription): Read entitlements and applicable usage thresholds The API returns applicable_usage_thresholds on subscription create, update and show, and entitlements on create and show. SubscriptionResponse now parses both, so callers can read back the thresholds that apply after sending usage_thresholds. Subscription entitlements get their own models: each privilege carries the plan_value and override_value next to the value, and the entitlement holds the overrides map. --- lago_python_client/models/__init__.py | 12 ++++++++++ lago_python_client/models/entitlement.py | 26 +++++++++++++++++++++ lago_python_client/models/subscription.py | 5 +++- tests/fixtures/subscription.json | 28 +++++++++++++++++++++++ tests/test_subscription_client.py | 13 +++++++++++ 5 files changed, 83 insertions(+), 1 deletion(-) diff --git a/lago_python_client/models/__init__.py b/lago_python_client/models/__init__.py index 9b0b42e..bf1c43a 100644 --- a/lago_python_client/models/__init__.py +++ b/lago_python_client/models/__init__.py @@ -162,6 +162,18 @@ from .entitlement import ( PlanEntitlementsResponse as PlanEntitlementsResponse, ) +from .entitlement import ( + SubscriptionEntitlementPrivilegeResponse as SubscriptionEntitlementPrivilegeResponse, +) +from .entitlement import ( + SubscriptionEntitlementPrivilegesResponse as SubscriptionEntitlementPrivilegesResponse, +) +from .entitlement import ( + SubscriptionEntitlementResponse as SubscriptionEntitlementResponse, +) +from .entitlement import ( + SubscriptionEntitlementsResponse as SubscriptionEntitlementsResponse, +) from .event import BatchEvent as BatchEvent from .event import Event as Event from .fee import Fee as Fee diff --git a/lago_python_client/models/entitlement.py b/lago_python_client/models/entitlement.py index 8f14e36..ea35960 100644 --- a/lago_python_client/models/entitlement.py +++ b/lago_python_client/models/entitlement.py @@ -24,3 +24,29 @@ class PlanEntitlementResponse(BaseResponseModel): class PlanEntitlementsResponse(BaseResponseModel): __root__: List[PlanEntitlementResponse] + + +class SubscriptionEntitlementPrivilegeResponse(BaseResponseModel): + code: Optional[str] + name: Optional[str] + value_type: Optional[str] + config: Optional[Dict[str, Any]] + value: Any + plan_value: Any + override_value: Any + + +class SubscriptionEntitlementPrivilegesResponse(BaseResponseModel): + __root__: List[SubscriptionEntitlementPrivilegeResponse] + + +class SubscriptionEntitlementResponse(BaseResponseModel): + code: Optional[str] + name: Optional[str] + description: Optional[str] + privileges: Optional[SubscriptionEntitlementPrivilegesResponse] + overrides: Optional[Dict[str, Any]] + + +class SubscriptionEntitlementsResponse(BaseResponseModel): + __root__: List[SubscriptionEntitlementResponse] diff --git a/lago_python_client/models/subscription.py b/lago_python_client/models/subscription.py index ef2938c..96b7314 100644 --- a/lago_python_client/models/subscription.py +++ b/lago_python_client/models/subscription.py @@ -3,10 +3,11 @@ from lago_python_client.base_model import BaseModel from ..base_model import BaseResponseModel +from .entitlement import SubscriptionEntitlementsResponse from .invoice_custom_section import AppliedInvoiceCustomSections, InvoiceCustomSectionInput from .payment_method import PaymentMethod from .plan import PlanOverrides -from .usage_threshold import UsageThresholdsOverrides +from .usage_threshold import ApplicableUsageThresholdsResponse, UsageThresholdsOverrides class ActivationRuleInput(BaseModel): @@ -83,6 +84,8 @@ class SubscriptionResponse(BaseResponseModel): activated_at: Optional[str] activation_rules: Optional[List[ActivationRuleResponse]] progressive_billing_disabled: Optional[bool] + entitlements: Optional[SubscriptionEntitlementsResponse] + applicable_usage_thresholds: Optional[ApplicableUsageThresholdsResponse] class SubscriptionsResponse(BaseResponseModel): diff --git a/tests/fixtures/subscription.json b/tests/fixtures/subscription.json index 6776cb7..a698ed3 100644 --- a/tests/fixtures/subscription.json +++ b/tests/fixtures/subscription.json @@ -52,6 +52,34 @@ "created_at": "2022-04-29T08:59:51Z", "updated_at": "2022-04-29T08:59:51Z" } + ], + "entitlements": [ + { + "code": "seats", + "name": "Seats", + "description": "Number of seats", + "privileges": [ + { + "code": "max", + "name": "Maximum", + "value_type": "integer", + "value": 20, + "plan_value": 10, + "override_value": 20, + "config": {} + } + ], + "overrides": { + "max": 20 + } + } + ], + "applicable_usage_thresholds": [ + { + "threshold_display_name": "Applicable threshold", + "amount_cents": 20, + "recurring": false + } ] } } diff --git a/tests/test_subscription_client.py b/tests/test_subscription_client.py index 9c4fea7..5b44a7b 100644 --- a/tests/test_subscription_client.py +++ b/tests/test_subscription_client.py @@ -93,6 +93,19 @@ def test_valid_create_subscriptions_request(httpx_mock: HTTPXMock): assert response.consolidate_invoice is False assert response.purchase_order_number == "PO-123" + entitlement = response.entitlements.__root__[0] + assert entitlement.code == "seats" + assert entitlement.overrides == {"max": 20} + privilege = entitlement.privileges.__root__[0] + assert privilege.value == 20 + assert privilege.plan_value == 10 + assert privilege.override_value == 20 + + applicable_threshold = response.applicable_usage_thresholds.__root__[0] + assert applicable_threshold.threshold_display_name == "Applicable threshold" + assert applicable_threshold.amount_cents == 20 + assert applicable_threshold.recurring is False + def test_valid_create_subscriptions_request_with_payment_method(httpx_mock: HTTPXMock): client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d")