diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f73be..bfb4984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Check our main [developer changelog](https://developer.paddle.com/?utm_source=dx&utm_medium=paddle-python-sdk) for information about changes to the Paddle Billing platform, the Paddle API, and other developer tools. +## 1.14.0 - 2026-03-30 + +### Added + +- Added support for metrics endpoints. See [related changelog](https://developer.paddle.com/changelog/2026/metrics-api?utm_source=dx&utm_medium=paddle-python-sdk) + - `Client.metrics.get_monthly_recurring_revenue` + - `Client.metrics.get_monthly_recurring_revenue_change` + - `Client.metrics.get_active_subscribers` + - `Client.metrics.get_revenue` + - `Client.metrics.get_refunds` + - `Client.metrics.get_chargebacks` + - `Client.metrics.get_checkout_conversion` + ## 1.13.1 - 2026-03-25 ### Added diff --git a/paddle_billing/Client.py b/paddle_billing/Client.py index 6bafc67..249c632 100644 --- a/paddle_billing/Client.py +++ b/paddle_billing/Client.py @@ -39,6 +39,7 @@ from paddle_billing.Resources.SimulationRunEvents.SimulationRunEventsClient import SimulationRunEventsClient from paddle_billing.Resources.SimulationTypes.SimulationTypesClient import SimulationTypesClient from paddle_billing.Resources.Subscriptions.SubscriptionsClient import SubscriptionsClient +from paddle_billing.Resources.Metrics.MetricsClient import MetricsClient from paddle_billing.Resources.Transactions.TransactionsClient import TransactionsClient @@ -94,6 +95,7 @@ def __init__( self.subscriptions = SubscriptionsClient(self) self.transactions = TransactionsClient(self) self.ip_addresses = IPAddressesClient(self) + self.metrics = MetricsClient(self) @staticmethod def null_logger() -> Logger: @@ -200,7 +202,7 @@ def build_request_session(self) -> Session: "Authorization": f"Bearer {self.__api_key}", "Content-Type": "application/json", "Paddle-Version": str(self.use_api_version), - "User-Agent": "PaddleSDK/python 1.13.1", + "User-Agent": "PaddleSDK/python 1.14.0", } ) diff --git a/paddle_billing/Entities/Metrics/MetricsActiveSubscribers.py b/paddle_billing/Entities/Metrics/MetricsActiveSubscribers.py new file mode 100644 index 0000000..d91af8c --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsActiveSubscribers.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from paddle_billing.Entities.Entity import Entity +from paddle_billing.Entities.Metrics.MetricsCountDatapoint import MetricsCountDatapoint +from paddle_billing.Entities.Metrics.MetricsInterval import MetricsInterval + + +@dataclass +class MetricsActiveSubscribers(Entity): + timeseries: list[MetricsCountDatapoint] + starts_at: datetime + ends_at: datetime + interval: MetricsInterval + updated_at: datetime + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsActiveSubscribers: + return MetricsActiveSubscribers( + timeseries=[MetricsCountDatapoint.from_dict(dp) for dp in data.get("timeseries", [])], + starts_at=datetime.fromisoformat(data["starts_at"]), + ends_at=datetime.fromisoformat(data["ends_at"]), + interval=MetricsInterval(data["interval"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + ) diff --git a/paddle_billing/Entities/Metrics/MetricsAmountDatapoint.py b/paddle_billing/Entities/Metrics/MetricsAmountDatapoint.py new file mode 100644 index 0000000..28f62d5 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsAmountDatapoint.py @@ -0,0 +1,17 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass +class MetricsAmountDatapoint: + timestamp: datetime + amount: str + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsAmountDatapoint: + return MetricsAmountDatapoint( + timestamp=datetime.fromisoformat(data["timestamp"]), + amount=data["amount"], + ) diff --git a/paddle_billing/Entities/Metrics/MetricsChargebacks.py b/paddle_billing/Entities/Metrics/MetricsChargebacks.py new file mode 100644 index 0000000..5144493 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsChargebacks.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from paddle_billing.Entities.Entity import Entity +from paddle_billing.Entities.Metrics.MetricsCountDatapoint import MetricsCountDatapoint +from paddle_billing.Entities.Metrics.MetricsInterval import MetricsInterval + + +@dataclass +class MetricsChargebacks(Entity): + timeseries: list[MetricsCountDatapoint] + starts_at: datetime + ends_at: datetime + interval: MetricsInterval + updated_at: datetime + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsChargebacks: + return MetricsChargebacks( + timeseries=[MetricsCountDatapoint.from_dict(dp) for dp in data.get("timeseries", [])], + starts_at=datetime.fromisoformat(data["starts_at"]), + ends_at=datetime.fromisoformat(data["ends_at"]), + interval=MetricsInterval(data["interval"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + ) diff --git a/paddle_billing/Entities/Metrics/MetricsCheckoutConversion.py b/paddle_billing/Entities/Metrics/MetricsCheckoutConversion.py new file mode 100644 index 0000000..d2a0de0 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsCheckoutConversion.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from paddle_billing.Entities.Entity import Entity +from paddle_billing.Entities.Metrics.MetricsCheckoutConversionDatapoint import MetricsCheckoutConversionDatapoint +from paddle_billing.Entities.Metrics.MetricsInterval import MetricsInterval + + +@dataclass +class MetricsCheckoutConversion(Entity): + timeseries: list[MetricsCheckoutConversionDatapoint] + starts_at: datetime + ends_at: datetime + interval: MetricsInterval + updated_at: datetime + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsCheckoutConversion: + return MetricsCheckoutConversion( + timeseries=[MetricsCheckoutConversionDatapoint.from_dict(dp) for dp in data.get("timeseries", [])], + starts_at=datetime.fromisoformat(data["starts_at"]), + ends_at=datetime.fromisoformat(data["ends_at"]), + interval=MetricsInterval(data["interval"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + ) diff --git a/paddle_billing/Entities/Metrics/MetricsCheckoutConversionDatapoint.py b/paddle_billing/Entities/Metrics/MetricsCheckoutConversionDatapoint.py new file mode 100644 index 0000000..10cfdd0 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsCheckoutConversionDatapoint.py @@ -0,0 +1,21 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass +class MetricsCheckoutConversionDatapoint: + timestamp: datetime + count: int + completed_count: int + rate: str + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsCheckoutConversionDatapoint: + return MetricsCheckoutConversionDatapoint( + timestamp=datetime.fromisoformat(data["timestamp"]), + count=data["count"], + completed_count=data["completed_count"], + rate=data["rate"], + ) diff --git a/paddle_billing/Entities/Metrics/MetricsCountDatapoint.py b/paddle_billing/Entities/Metrics/MetricsCountDatapoint.py new file mode 100644 index 0000000..edfe830 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsCountDatapoint.py @@ -0,0 +1,17 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass +class MetricsCountDatapoint: + timestamp: datetime + count: int + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsCountDatapoint: + return MetricsCountDatapoint( + timestamp=datetime.fromisoformat(data["timestamp"]), + count=data["count"], + ) diff --git a/paddle_billing/Entities/Metrics/MetricsInterval.py b/paddle_billing/Entities/Metrics/MetricsInterval.py new file mode 100644 index 0000000..0e980f6 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsInterval.py @@ -0,0 +1,5 @@ +from paddle_billing.PaddleStrEnum import PaddleStrEnum, PaddleStrEnumMeta + + +class MetricsInterval(PaddleStrEnum, metaclass=PaddleStrEnumMeta): + Day: "MetricsInterval" = "day" diff --git a/paddle_billing/Entities/Metrics/MetricsMonthlyRecurringRevenue.py b/paddle_billing/Entities/Metrics/MetricsMonthlyRecurringRevenue.py new file mode 100644 index 0000000..0101916 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsMonthlyRecurringRevenue.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from paddle_billing.Entities.Entity import Entity +from paddle_billing.Entities.Metrics.MetricsAmountDatapoint import MetricsAmountDatapoint +from paddle_billing.Entities.Metrics.MetricsInterval import MetricsInterval +from paddle_billing.Entities.Shared import CurrencyCode + + +@dataclass +class MetricsMonthlyRecurringRevenue(Entity): + currency_code: CurrencyCode + timeseries: list[MetricsAmountDatapoint] + starts_at: datetime + ends_at: datetime + interval: MetricsInterval + updated_at: datetime + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsMonthlyRecurringRevenue: + return MetricsMonthlyRecurringRevenue( + currency_code=CurrencyCode(data["currency_code"]), + timeseries=[MetricsAmountDatapoint.from_dict(dp) for dp in data.get("timeseries", [])], + starts_at=datetime.fromisoformat(data["starts_at"]), + ends_at=datetime.fromisoformat(data["ends_at"]), + interval=MetricsInterval(data["interval"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + ) diff --git a/paddle_billing/Entities/Metrics/MetricsMonthlyRecurringRevenueChange.py b/paddle_billing/Entities/Metrics/MetricsMonthlyRecurringRevenueChange.py new file mode 100644 index 0000000..ed308ca --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsMonthlyRecurringRevenueChange.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from paddle_billing.Entities.Entity import Entity +from paddle_billing.Entities.Metrics.MetricsAmountDatapoint import MetricsAmountDatapoint +from paddle_billing.Entities.Metrics.MetricsInterval import MetricsInterval +from paddle_billing.Entities.Shared import CurrencyCode + + +@dataclass +class MetricsMonthlyRecurringRevenueChange(Entity): + currency_code: CurrencyCode + timeseries: list[MetricsAmountDatapoint] + starts_at: datetime + ends_at: datetime + interval: MetricsInterval + updated_at: datetime + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsMonthlyRecurringRevenueChange: + return MetricsMonthlyRecurringRevenueChange( + currency_code=CurrencyCode(data["currency_code"]), + timeseries=[MetricsAmountDatapoint.from_dict(dp) for dp in data.get("timeseries", [])], + starts_at=datetime.fromisoformat(data["starts_at"]), + ends_at=datetime.fromisoformat(data["ends_at"]), + interval=MetricsInterval(data["interval"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + ) diff --git a/paddle_billing/Entities/Metrics/MetricsRefunds.py b/paddle_billing/Entities/Metrics/MetricsRefunds.py new file mode 100644 index 0000000..deb544b --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsRefunds.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from paddle_billing.Entities.Entity import Entity +from paddle_billing.Entities.Metrics.MetricsAmountDatapoint import MetricsAmountDatapoint +from paddle_billing.Entities.Metrics.MetricsInterval import MetricsInterval +from paddle_billing.Entities.Shared import CurrencyCode + + +@dataclass +class MetricsRefunds(Entity): + currency_code: CurrencyCode + timeseries: list[MetricsAmountDatapoint] + starts_at: datetime + ends_at: datetime + interval: MetricsInterval + updated_at: datetime + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsRefunds: + return MetricsRefunds( + currency_code=CurrencyCode(data["currency_code"]), + timeseries=[MetricsAmountDatapoint.from_dict(dp) for dp in data.get("timeseries", [])], + starts_at=datetime.fromisoformat(data["starts_at"]), + ends_at=datetime.fromisoformat(data["ends_at"]), + interval=MetricsInterval(data["interval"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + ) diff --git a/paddle_billing/Entities/Metrics/MetricsRevenue.py b/paddle_billing/Entities/Metrics/MetricsRevenue.py new file mode 100644 index 0000000..311e4e8 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsRevenue.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from paddle_billing.Entities.Entity import Entity +from paddle_billing.Entities.Metrics.MetricsInterval import MetricsInterval +from paddle_billing.Entities.Metrics.MetricsRevenueDatapoint import MetricsRevenueDatapoint +from paddle_billing.Entities.Shared import CurrencyCode + + +@dataclass +class MetricsRevenue(Entity): + currency_code: CurrencyCode + timeseries: list[MetricsRevenueDatapoint] + starts_at: datetime + ends_at: datetime + interval: MetricsInterval + updated_at: datetime + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsRevenue: + return MetricsRevenue( + currency_code=CurrencyCode(data["currency_code"]), + timeseries=[MetricsRevenueDatapoint.from_dict(dp) for dp in data.get("timeseries", [])], + starts_at=datetime.fromisoformat(data["starts_at"]), + ends_at=datetime.fromisoformat(data["ends_at"]), + interval=MetricsInterval(data["interval"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + ) diff --git a/paddle_billing/Entities/Metrics/MetricsRevenueDatapoint.py b/paddle_billing/Entities/Metrics/MetricsRevenueDatapoint.py new file mode 100644 index 0000000..317d4ca --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsRevenueDatapoint.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass +class MetricsRevenueDatapoint: + timestamp: datetime + amount: str + count: int + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsRevenueDatapoint: + return MetricsRevenueDatapoint( + timestamp=datetime.fromisoformat(data["timestamp"]), + amount=data["amount"], + count=data["count"], + ) diff --git a/paddle_billing/Entities/Metrics/__init__.py b/paddle_billing/Entities/Metrics/__init__.py new file mode 100644 index 0000000..51c17a6 --- /dev/null +++ b/paddle_billing/Entities/Metrics/__init__.py @@ -0,0 +1,12 @@ +from paddle_billing.Entities.Metrics.MetricsInterval import MetricsInterval +from paddle_billing.Entities.Metrics.MetricsAmountDatapoint import MetricsAmountDatapoint +from paddle_billing.Entities.Metrics.MetricsCountDatapoint import MetricsCountDatapoint +from paddle_billing.Entities.Metrics.MetricsRevenueDatapoint import MetricsRevenueDatapoint +from paddle_billing.Entities.Metrics.MetricsCheckoutConversionDatapoint import MetricsCheckoutConversionDatapoint +from paddle_billing.Entities.Metrics.MetricsMonthlyRecurringRevenue import MetricsMonthlyRecurringRevenue +from paddle_billing.Entities.Metrics.MetricsMonthlyRecurringRevenueChange import MetricsMonthlyRecurringRevenueChange +from paddle_billing.Entities.Metrics.MetricsActiveSubscribers import MetricsActiveSubscribers +from paddle_billing.Entities.Metrics.MetricsRevenue import MetricsRevenue +from paddle_billing.Entities.Metrics.MetricsRefunds import MetricsRefunds +from paddle_billing.Entities.Metrics.MetricsChargebacks import MetricsChargebacks +from paddle_billing.Entities.Metrics.MetricsCheckoutConversion import MetricsCheckoutConversion diff --git a/paddle_billing/Resources/Metrics/MetricsClient.py b/paddle_billing/Resources/Metrics/MetricsClient.py new file mode 100644 index 0000000..982a540 --- /dev/null +++ b/paddle_billing/Resources/Metrics/MetricsClient.py @@ -0,0 +1,64 @@ +from paddle_billing.ResponseParser import ResponseParser + +from paddle_billing.Entities.Metrics.MetricsActiveSubscribers import MetricsActiveSubscribers +from paddle_billing.Entities.Metrics.MetricsChargebacks import MetricsChargebacks +from paddle_billing.Entities.Metrics.MetricsCheckoutConversion import MetricsCheckoutConversion +from paddle_billing.Entities.Metrics.MetricsMonthlyRecurringRevenue import MetricsMonthlyRecurringRevenue +from paddle_billing.Entities.Metrics.MetricsMonthlyRecurringRevenueChange import MetricsMonthlyRecurringRevenueChange +from paddle_billing.Entities.Metrics.MetricsRefunds import MetricsRefunds +from paddle_billing.Entities.Metrics.MetricsRevenue import MetricsRevenue + +from paddle_billing.Resources.Metrics.Operations import GetMetrics + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from paddle_billing.Client import Client + + +class MetricsClient: + def __init__(self, client: "Client"): + self.client = client + self.response = None + + def get_monthly_recurring_revenue(self, operation: GetMetrics) -> MetricsMonthlyRecurringRevenue: + self.response = self.client.get_raw("/metrics/monthly-recurring-revenue", operation.get_parameters()) + parser = ResponseParser(self.response) + + return MetricsMonthlyRecurringRevenue.from_dict(parser.get_dict()) + + def get_monthly_recurring_revenue_change(self, operation: GetMetrics) -> MetricsMonthlyRecurringRevenueChange: + self.response = self.client.get_raw("/metrics/monthly-recurring-revenue-change", operation.get_parameters()) + parser = ResponseParser(self.response) + + return MetricsMonthlyRecurringRevenueChange.from_dict(parser.get_dict()) + + def get_active_subscribers(self, operation: GetMetrics) -> MetricsActiveSubscribers: + self.response = self.client.get_raw("/metrics/active-subscribers", operation.get_parameters()) + parser = ResponseParser(self.response) + + return MetricsActiveSubscribers.from_dict(parser.get_dict()) + + def get_revenue(self, operation: GetMetrics) -> MetricsRevenue: + self.response = self.client.get_raw("/metrics/revenue", operation.get_parameters()) + parser = ResponseParser(self.response) + + return MetricsRevenue.from_dict(parser.get_dict()) + + def get_refunds(self, operation: GetMetrics) -> MetricsRefunds: + self.response = self.client.get_raw("/metrics/refunds", operation.get_parameters()) + parser = ResponseParser(self.response) + + return MetricsRefunds.from_dict(parser.get_dict()) + + def get_chargebacks(self, operation: GetMetrics) -> MetricsChargebacks: + self.response = self.client.get_raw("/metrics/chargebacks", operation.get_parameters()) + parser = ResponseParser(self.response) + + return MetricsChargebacks.from_dict(parser.get_dict()) + + def get_checkout_conversion(self, operation: GetMetrics) -> MetricsCheckoutConversion: + self.response = self.client.get_raw("/metrics/checkout-conversion", operation.get_parameters()) + parser = ResponseParser(self.response) + + return MetricsCheckoutConversion.from_dict(parser.get_dict()) diff --git a/paddle_billing/Resources/Metrics/Operations/GetMetrics.py b/paddle_billing/Resources/Metrics/Operations/GetMetrics.py new file mode 100644 index 0000000..d87d838 --- /dev/null +++ b/paddle_billing/Resources/Metrics/Operations/GetMetrics.py @@ -0,0 +1,33 @@ +from datetime import date + +from paddle_billing.HasParameters import HasParameters + + +class GetMetrics(HasParameters): + def __init__( + self, + date_from: str, + date_to: str, + ): + self.date_from = date_from + self.date_to = date_to + + for field_name, field_value in [ + ("date_from", self.date_from), + ("date_to", self.date_to), + ]: + try: + date.fromisoformat(field_value) + except (ValueError, TypeError): + raise ValueError(f"'{field_name}' must be a date string in YYYY-MM-DD format, got '{field_value}'") + + if "T" in field_value: + raise ValueError( + f"'{field_name}' must be a date string in YYYY-MM-DD format without time, got '{field_value}'" + ) + + def get_parameters(self) -> dict[str, str]: + return { + "from": self.date_from, + "to": self.date_to, + } diff --git a/paddle_billing/Resources/Metrics/Operations/__init__.py b/paddle_billing/Resources/Metrics/Operations/__init__.py new file mode 100644 index 0000000..4aa9524 --- /dev/null +++ b/paddle_billing/Resources/Metrics/Operations/__init__.py @@ -0,0 +1 @@ +from paddle_billing.Resources.Metrics.Operations.GetMetrics import GetMetrics diff --git a/paddle_billing/Resources/Metrics/__init__.py b/paddle_billing/Resources/Metrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/setup.py b/setup.py index 92dbb2a..f4e7f57 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import setup, find_packages setup( - version="1.13.1", + version="1.14.0", author="Paddle and contributors", author_email="team-dx@paddle.com", description="Paddle's Python SDK for Paddle Billing", diff --git a/tests/Functional/Resources/Metrics/__init__.py b/tests/Functional/Resources/Metrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/Functional/Resources/Metrics/_fixtures/response/active_subscribers.json b/tests/Functional/Resources/Metrics/_fixtures/response/active_subscribers.json new file mode 100644 index 0000000..4d81b01 --- /dev/null +++ b/tests/Functional/Resources/Metrics/_fixtures/response/active_subscribers.json @@ -0,0 +1,17 @@ +{ + "data": { + "timeseries": [ + { "timestamp": "2025-09-01T00:00:00.000000Z", "count": 1250 }, + { "timestamp": "2025-09-02T00:00:00.000000Z", "count": 1267 }, + { "timestamp": "2025-09-03T00:00:00.000000Z", "count": 1284 }, + { "timestamp": "2025-09-04T00:00:00.000000Z", "count": 1291 } + ], + "starts_at": "2025-09-01T00:00:00.000000Z", + "ends_at": "2025-09-05T00:00:00.000000Z", + "interval": "day", + "updated_at": "2025-09-04T20:30:00.000000Z" + }, + "meta": { + "request_id": "b93d9c94-c28f-4e5d-af2e-044854d7afe8" + } +} diff --git a/tests/Functional/Resources/Metrics/_fixtures/response/chargebacks.json b/tests/Functional/Resources/Metrics/_fixtures/response/chargebacks.json new file mode 100644 index 0000000..21ef906 --- /dev/null +++ b/tests/Functional/Resources/Metrics/_fixtures/response/chargebacks.json @@ -0,0 +1,17 @@ +{ + "data": { + "timeseries": [ + { "timestamp": "2025-09-01T00:00:00.000000Z", "count": 1 }, + { "timestamp": "2025-09-02T00:00:00.000000Z", "count": 2 }, + { "timestamp": "2025-09-03T00:00:00.000000Z", "count": 0 }, + { "timestamp": "2025-09-04T00:00:00.000000Z", "count": 1 } + ], + "starts_at": "2025-09-01T00:00:00.000000Z", + "ends_at": "2025-09-05T00:00:00.000000Z", + "interval": "day", + "updated_at": "2026-03-03T06:30:32.811000Z" + }, + "meta": { + "request_id": "b93d9c94-c28f-4e5d-af2e-044854d7afe8" + } +} diff --git a/tests/Functional/Resources/Metrics/_fixtures/response/checkout_conversion.json b/tests/Functional/Resources/Metrics/_fixtures/response/checkout_conversion.json new file mode 100644 index 0000000..4fcbe6c --- /dev/null +++ b/tests/Functional/Resources/Metrics/_fixtures/response/checkout_conversion.json @@ -0,0 +1,17 @@ +{ + "data": { + "timeseries": [ + { "timestamp": "2025-09-01T00:00:00.000000Z", "count": 151, "completed_count": 5, "rate": "0.033113" }, + { "timestamp": "2025-09-02T00:00:00.000000Z", "count": 66, "completed_count": 11, "rate": "0.166667" }, + { "timestamp": "2025-09-03T00:00:00.000000Z", "count": 139, "completed_count": 12, "rate": "0.086331" }, + { "timestamp": "2025-09-04T00:00:00.000000Z", "count": 210, "completed_count": 28, "rate": "0.133333" } + ], + "starts_at": "2025-09-01T00:00:00.000000Z", + "ends_at": "2025-09-05T00:00:00.000000Z", + "interval": "day", + "updated_at": "2025-09-04T20:30:00.000000Z" + }, + "meta": { + "request_id": "b93d9c94-c28f-4e5d-af2e-044854d7afe8" + } +} diff --git a/tests/Functional/Resources/Metrics/_fixtures/response/monthly_recurring_revenue.json b/tests/Functional/Resources/Metrics/_fixtures/response/monthly_recurring_revenue.json new file mode 100644 index 0000000..f18adef --- /dev/null +++ b/tests/Functional/Resources/Metrics/_fixtures/response/monthly_recurring_revenue.json @@ -0,0 +1,18 @@ +{ + "data": { + "currency_code": "USD", + "timeseries": [ + { "timestamp": "2025-09-01T00:00:00.000000Z", "amount": "1286023068" }, + { "timestamp": "2025-09-02T00:00:00.000000Z", "amount": "1345678901" }, + { "timestamp": "2025-09-03T00:00:00.000000Z", "amount": "1398765432" }, + { "timestamp": "2025-09-04T00:00:00.000000Z", "amount": "1420987654" } + ], + "starts_at": "2025-09-01T00:00:00.000000Z", + "ends_at": "2025-09-05T00:00:00.000000Z", + "interval": "day", + "updated_at": "2025-09-04T20:30:00.000000Z" + }, + "meta": { + "request_id": "b93d9c94-c28f-4e5d-af2e-044854d7afe8" + } +} diff --git a/tests/Functional/Resources/Metrics/_fixtures/response/monthly_recurring_revenue_change.json b/tests/Functional/Resources/Metrics/_fixtures/response/monthly_recurring_revenue_change.json new file mode 100644 index 0000000..c2605a3 --- /dev/null +++ b/tests/Functional/Resources/Metrics/_fixtures/response/monthly_recurring_revenue_change.json @@ -0,0 +1,18 @@ +{ + "data": { + "currency_code": "USD", + "timeseries": [ + { "timestamp": "2025-09-01T00:00:00.000000Z", "amount": "125000" }, + { "timestamp": "2025-09-02T00:00:00.000000Z", "amount": "-75000" }, + { "timestamp": "2025-09-03T00:00:00.000000Z", "amount": "200000" }, + { "timestamp": "2025-09-04T00:00:00.000000Z", "amount": "50000" } + ], + "starts_at": "2025-09-01T00:00:00.000000Z", + "ends_at": "2025-09-05T00:00:00.000000Z", + "interval": "day", + "updated_at": "2025-09-04T20:30:00.000000Z" + }, + "meta": { + "request_id": "b93d9c94-c28f-4e5d-af2e-044854d7afe8" + } +} diff --git a/tests/Functional/Resources/Metrics/_fixtures/response/refunds.json b/tests/Functional/Resources/Metrics/_fixtures/response/refunds.json new file mode 100644 index 0000000..cf35e37 --- /dev/null +++ b/tests/Functional/Resources/Metrics/_fixtures/response/refunds.json @@ -0,0 +1,18 @@ +{ + "data": { + "currency_code": "USD", + "timeseries": [ + { "timestamp": "2025-09-01T00:00:00.000000Z", "amount": "10000" }, + { "timestamp": "2025-09-02T00:00:00.000000Z", "amount": "0" }, + { "timestamp": "2025-09-03T00:00:00.000000Z", "amount": "0" }, + { "timestamp": "2025-09-04T00:00:00.000000Z", "amount": "0" } + ], + "starts_at": "2025-09-01T00:00:00.000000Z", + "ends_at": "2025-09-05T00:00:00.000000Z", + "interval": "day", + "updated_at": "2025-09-04T20:30:00.000000Z" + }, + "meta": { + "request_id": "b93d9c94-c28f-4e5d-af2e-044854d7afe8" + } +} diff --git a/tests/Functional/Resources/Metrics/_fixtures/response/revenue.json b/tests/Functional/Resources/Metrics/_fixtures/response/revenue.json new file mode 100644 index 0000000..31836a6 --- /dev/null +++ b/tests/Functional/Resources/Metrics/_fixtures/response/revenue.json @@ -0,0 +1,18 @@ +{ + "data": { + "currency_code": "USD", + "timeseries": [ + { "timestamp": "2025-09-01T00:00:00.000000Z", "amount": "1286023068", "count": 100 }, + { "timestamp": "2025-09-02T00:00:00.000000Z", "amount": "1345678901", "count": 100 }, + { "timestamp": "2025-09-03T00:00:00.000000Z", "amount": "1398765432", "count": 100 }, + { "timestamp": "2025-09-04T00:00:00.000000Z", "amount": "1420987654", "count": 100 } + ], + "starts_at": "2025-09-01T00:00:00.000000Z", + "ends_at": "2025-09-05T00:00:00.000000Z", + "interval": "day", + "updated_at": "2025-09-04T20:30:00.000000Z" + }, + "meta": { + "request_id": "b93d9c94-c28f-4e5d-af2e-044854d7afe8" + } +} diff --git a/tests/Functional/Resources/Metrics/test_MetricsClient.py b/tests/Functional/Resources/Metrics/test_MetricsClient.py new file mode 100644 index 0000000..0081933 --- /dev/null +++ b/tests/Functional/Resources/Metrics/test_MetricsClient.py @@ -0,0 +1,112 @@ +from json import dumps, loads +from paddle_billing.Json import PayloadEncoder +from pytest import mark +from urllib.parse import unquote + +from paddle_billing.Entities.Metrics import ( + MetricsActiveSubscribers, + MetricsChargebacks, + MetricsCheckoutConversion, + MetricsMonthlyRecurringRevenue, + MetricsMonthlyRecurringRevenueChange, + MetricsRefunds, + MetricsRevenue, +) + +from paddle_billing.Resources.Metrics.Operations import GetMetrics + +from tests.Utils.ReadsFixture import ReadsFixtures + + +class TestMetricsClient: + @mark.parametrize( + "method, operation, expected_response_body, expected_url, expected_entity_type", + [ + ( + "get_monthly_recurring_revenue", + GetMetrics(date_from="2025-09-01", date_to="2025-09-05"), + ReadsFixtures.read_raw_json_fixture("response/monthly_recurring_revenue"), + "/metrics/monthly-recurring-revenue?from=2025-09-01&to=2025-09-05", + MetricsMonthlyRecurringRevenue, + ), + ( + "get_monthly_recurring_revenue_change", + GetMetrics(date_from="2025-09-01", date_to="2025-09-05"), + ReadsFixtures.read_raw_json_fixture("response/monthly_recurring_revenue_change"), + "/metrics/monthly-recurring-revenue-change?from=2025-09-01&to=2025-09-05", + MetricsMonthlyRecurringRevenueChange, + ), + ( + "get_active_subscribers", + GetMetrics(date_from="2025-09-01", date_to="2025-09-05"), + ReadsFixtures.read_raw_json_fixture("response/active_subscribers"), + "/metrics/active-subscribers?from=2025-09-01&to=2025-09-05", + MetricsActiveSubscribers, + ), + ( + "get_revenue", + GetMetrics(date_from="2025-09-01", date_to="2025-09-05"), + ReadsFixtures.read_raw_json_fixture("response/revenue"), + "/metrics/revenue?from=2025-09-01&to=2025-09-05", + MetricsRevenue, + ), + ( + "get_refunds", + GetMetrics(date_from="2025-09-01", date_to="2025-09-05"), + ReadsFixtures.read_raw_json_fixture("response/refunds"), + "/metrics/refunds?from=2025-09-01&to=2025-09-05", + MetricsRefunds, + ), + ( + "get_chargebacks", + GetMetrics(date_from="2025-09-01", date_to="2025-09-05"), + ReadsFixtures.read_raw_json_fixture("response/chargebacks"), + "/metrics/chargebacks?from=2025-09-01&to=2025-09-05", + MetricsChargebacks, + ), + ( + "get_checkout_conversion", + GetMetrics(date_from="2025-09-01", date_to="2025-09-05"), + ReadsFixtures.read_raw_json_fixture("response/checkout_conversion"), + "/metrics/checkout-conversion?from=2025-09-01&to=2025-09-05", + MetricsCheckoutConversion, + ), + ], + ids=[ + "Get monthly recurring revenue", + "Get monthly recurring revenue change", + "Get active subscribers", + "Get revenue", + "Get refunds", + "Get chargebacks", + "Get checkout conversion", + ], + ) + def test_metrics_returns_expected_response( + self, + test_client, + mock_requests, + method, + operation, + expected_response_body, + expected_url, + expected_entity_type, + ): + expected_url = f"{test_client.base_url}{expected_url}" + mock_requests.get(expected_url, status_code=200, text=expected_response_body) + + response = getattr(test_client.client.metrics, method)(operation) + response_json = test_client.client.metrics.response.json() + last_request = mock_requests.last_request + + assert isinstance(response, expected_entity_type) + assert last_request is not None + assert last_request.method == "GET" + assert test_client.client.status_code == 200 + assert unquote(last_request.url) == expected_url, "The URL does not match the expected URL" + assert ( + loads(dumps(response, cls=PayloadEncoder)) == loads(str(expected_response_body))["data"] + ), "The metrics response object doesn't match the expected fixture JSON" + assert response_json == loads( + str(expected_response_body) + ), "The response JSON doesn't match the expected fixture JSON" diff --git a/tests/Unit/Resources/Metrics/Operations/__init__.py b/tests/Unit/Resources/Metrics/Operations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/Unit/Resources/Metrics/Operations/test_GetMetrics.py b/tests/Unit/Resources/Metrics/Operations/test_GetMetrics.py new file mode 100644 index 0000000..728ce1f --- /dev/null +++ b/tests/Unit/Resources/Metrics/Operations/test_GetMetrics.py @@ -0,0 +1,48 @@ +from pytest import mark, raises + +from paddle_billing.Resources.Metrics.Operations import GetMetrics + + +class TestGetMetrics: + def test_accepts_valid_date_strings(self): + operation = GetMetrics(date_from="2025-09-01", date_to="2025-09-05") + + assert operation.get_parameters() == {"from": "2025-09-01", "to": "2025-09-05"} + + @mark.parametrize( + "date_from, date_to", + [ + ("2025-09-01T00:00:00Z", "2025-09-05"), + ("2025-09-01", "2025-09-05T23:59:59Z"), + ("2025-09-01T00:00:00.000000Z", "2025-09-05"), + ], + ids=[ + "date_from with datetime", + "date_to with datetime", + "date_from with microseconds", + ], + ) + def test_rejects_datetime_strings(self, date_from, date_to): + with raises(ValueError, match="YYYY-MM-DD format"): + GetMetrics(date_from=date_from, date_to=date_to) + + @mark.parametrize( + "date_from, date_to", + [ + ("not-a-date", "2025-09-05"), + ("2025-09-01", "invalid"), + ("", "2025-09-05"), + ("2025/09/01", "2025-09-05"), + ("01-09-2025", "2025-09-05"), + ], + ids=[ + "date_from not a date", + "date_to not a date", + "date_from empty string", + "date_from wrong separator", + "date_from wrong order", + ], + ) + def test_rejects_invalid_date_strings(self, date_from, date_to): + with raises(ValueError, match="YYYY-MM-DD format"): + GetMetrics(date_from=date_from, date_to=date_to) diff --git a/tests/Unit/Resources/Metrics/__init__.py b/tests/Unit/Resources/Metrics/__init__.py new file mode 100644 index 0000000..e69de29