diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d9b28e..863abb52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Check our main [developer changelog](https://developer.paddle.com/?utm_source=dx ### Added +- Added support for metrics explore. See [related changelog](https://developer.paddle.com/changelog/2026/explore-metrics-api?utm_source=dx&utm_medium=paddle-python-sdk) + - `Client.metrics.list_explore_entities` + - `Client.metrics.run_explore_query` - Added support for `subscriptions` report type. See [related changelog](https://developer.paddle.com/changelog/2026/subscriptions-report?utm_source=dx&utm_medium=paddle-python-sdk) and [scheduled change filter changelog](https://developer.paddle.com/changelog/2026/subscriptions-report-scheduled-change-filter?utm_source=dx&utm_medium=paddle-python-sdk) - Added `filter`, `start` (sent as `from`), and `end` (sent as `to`) parameters to list events operation. See [related changelog](https://developer.paddle.com/changelog/2026/event-notification-log-filtering?utm_source=dx&utm_medium=paddle-python-sdk) - Added `paypal` to transaction payment `method_details`, showing the PayPal account used for a payment attempt. See [related changelog](https://developer.paddle.com/changelog/2026/paypal-transaction-payments?utm_source=dx&utm_medium=paddle-python-sdk) diff --git a/paddle_billing/Entities/Metrics/MetricEntity.py b/paddle_billing/Entities/Metrics/MetricEntity.py new file mode 100644 index 00000000..3c68460b --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricEntity.py @@ -0,0 +1,9 @@ +from paddle_billing.PaddleStrEnum import PaddleStrEnum, PaddleStrEnumMeta + + +class MetricEntity(PaddleStrEnum, metaclass=PaddleStrEnumMeta): + TransactionsCompleted: "MetricEntity" = "transactions.completed" + AdjustmentsRefunds: "MetricEntity" = "adjustments.refunds" + AdjustmentsChargebacks: "MetricEntity" = "adjustments.chargebacks" + Checkouts: "MetricEntity" = "checkouts" + Subscriptions: "MetricEntity" = "subscriptions" diff --git a/paddle_billing/Entities/Metrics/MetricEntityDefinition.py b/paddle_billing/Entities/Metrics/MetricEntityDefinition.py new file mode 100644 index 00000000..44027b7b --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricEntityDefinition.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any + +from paddle_billing.Entities.Entity import Entity +from paddle_billing.Entities.Metrics.MetricEntity import MetricEntity +from paddle_billing.Entities.Metrics.MetricEntityDimension import MetricEntityDimension +from paddle_billing.Entities.Metrics.MetricEntityMeasure import MetricEntityMeasure +from paddle_billing.Entities.Metrics.MetricEntityTimeDimension import MetricEntityTimeDimension + + +@dataclass +class MetricEntityDefinition(Entity): + entity: MetricEntity + time_dimension: MetricEntityTimeDimension + dimensions: list[MetricEntityDimension] + measures: list[MetricEntityMeasure] + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricEntityDefinition: + return MetricEntityDefinition( + entity=MetricEntity(data["entity"]), + time_dimension=MetricEntityTimeDimension.from_dict(data["time_dimension"]), + dimensions=[MetricEntityDimension.from_dict(dimension) for dimension in data["dimensions"]], + measures=[MetricEntityMeasure.from_dict(measure) for measure in data["measures"]], + ) diff --git a/paddle_billing/Entities/Metrics/MetricEntityDimension.py b/paddle_billing/Entities/Metrics/MetricEntityDimension.py new file mode 100644 index 00000000..888a8736 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricEntityDimension.py @@ -0,0 +1,23 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any + +from paddle_billing.Entities.Metrics.MetricsFieldType import MetricsFieldType +from paddle_billing.Entities.Metrics.MetricsFilterOperator import MetricsFilterOperator + + +@dataclass +class MetricEntityDimension: + name: str + type: MetricsFieldType + filter_ops: list[MetricsFilterOperator] + multi_valued: bool | None = None + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricEntityDimension: + return MetricEntityDimension( + name=data["name"], + type=MetricsFieldType(data["type"]), + filter_ops=[MetricsFilterOperator(op) for op in data["filter_ops"]], + multi_valued=data.get("multi_valued"), + ) diff --git a/paddle_billing/Entities/Metrics/MetricEntityMeasure.py b/paddle_billing/Entities/Metrics/MetricEntityMeasure.py new file mode 100644 index 00000000..6488027d --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricEntityMeasure.py @@ -0,0 +1,23 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any + +from paddle_billing.Entities.Metrics.MetricsAggregation import MetricsAggregation +from paddle_billing.Entities.Metrics.MetricsFieldType import MetricsFieldType + + +@dataclass +class MetricEntityMeasure: + name: str + type: MetricsFieldType + aggs: list[MetricsAggregation] | None + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricEntityMeasure: + aggs = data.get("aggs") + + return MetricEntityMeasure( + name=data["name"], + type=MetricsFieldType(data["type"]), + aggs=[MetricsAggregation(agg) for agg in aggs] if aggs is not None else None, + ) diff --git a/paddle_billing/Entities/Metrics/MetricEntityTimeDimension.py b/paddle_billing/Entities/Metrics/MetricEntityTimeDimension.py new file mode 100644 index 00000000..032be426 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricEntityTimeDimension.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any + +from paddle_billing.Entities.Metrics.MetricsBucketInterval import MetricsBucketInterval + + +@dataclass +class MetricEntityTimeDimension: + intervals: list[MetricsBucketInterval] + max_lookback_days: int + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricEntityTimeDimension: + return MetricEntityTimeDimension( + intervals=[MetricsBucketInterval(interval) for interval in data["intervals"]], + max_lookback_days=data["max_lookback_days"], + ) diff --git a/paddle_billing/Entities/Metrics/MetricsAggregation.py b/paddle_billing/Entities/Metrics/MetricsAggregation.py new file mode 100644 index 00000000..4f15bfd0 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsAggregation.py @@ -0,0 +1,7 @@ +from paddle_billing.PaddleStrEnum import PaddleStrEnum, PaddleStrEnumMeta + + +class MetricsAggregation(PaddleStrEnum, metaclass=PaddleStrEnumMeta): + Count: "MetricsAggregation" = "count" + CountDistinct: "MetricsAggregation" = "count_distinct" + Sum: "MetricsAggregation" = "sum" diff --git a/paddle_billing/Entities/Metrics/MetricsBucketInterval.py b/paddle_billing/Entities/Metrics/MetricsBucketInterval.py new file mode 100644 index 00000000..31572884 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsBucketInterval.py @@ -0,0 +1,7 @@ +from paddle_billing.PaddleStrEnum import PaddleStrEnum, PaddleStrEnumMeta + + +class MetricsBucketInterval(PaddleStrEnum, metaclass=PaddleStrEnumMeta): + Day: "MetricsBucketInterval" = "day" + Week: "MetricsBucketInterval" = "week" + Month: "MetricsBucketInterval" = "month" diff --git a/paddle_billing/Entities/Metrics/MetricsExploreData.py b/paddle_billing/Entities/Metrics/MetricsExploreData.py new file mode 100644 index 00000000..c6615969 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsExploreData.py @@ -0,0 +1,36 @@ +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.MetricEntity import MetricEntity +from paddle_billing.Entities.Metrics.MetricsBucketInterval import MetricsBucketInterval +from paddle_billing.Entities.Metrics.MetricsExploreFields import MetricsExploreFields +from paddle_billing.Entities.Metrics.MetricsExploreSeries import MetricsExploreSeries +from paddle_billing.Entities.Shared import CurrencyCode + + +@dataclass +class MetricsExploreData(Entity): + entity: MetricEntity + interval: MetricsBucketInterval + starts_at: datetime + ends_at: datetime + updated_at: datetime + fields: MetricsExploreFields + series: list[MetricsExploreSeries] + currency_code: CurrencyCode | None = None + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsExploreData: + return MetricsExploreData( + entity=MetricEntity(data["entity"]), + interval=MetricsBucketInterval(data["interval"]), + starts_at=datetime.fromisoformat(data["starts_at"]), + ends_at=datetime.fromisoformat(data["ends_at"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + fields=MetricsExploreFields.from_dict(data["fields"]), + series=[MetricsExploreSeries.from_dict(series) for series in data["series"]], + currency_code=CurrencyCode(data["currency_code"]) if data.get("currency_code") else None, + ) diff --git a/paddle_billing/Entities/Metrics/MetricsExploreDataPoint.py b/paddle_billing/Entities/Metrics/MetricsExploreDataPoint.py new file mode 100644 index 00000000..d97de26b --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsExploreDataPoint.py @@ -0,0 +1,13 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any + + +@dataclass +class MetricsExploreDataPoint: + timestamp: str + measures: dict[str, str] + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsExploreDataPoint: + return MetricsExploreDataPoint(timestamp=data["timestamp"], measures=dict(data["measures"])) diff --git a/paddle_billing/Entities/Metrics/MetricsExploreFieldDimension.py b/paddle_billing/Entities/Metrics/MetricsExploreFieldDimension.py new file mode 100644 index 00000000..d19b31c6 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsExploreFieldDimension.py @@ -0,0 +1,15 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any + +from paddle_billing.Entities.Metrics.MetricsFieldType import MetricsFieldType + + +@dataclass +class MetricsExploreFieldDimension: + name: str + type: MetricsFieldType + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsExploreFieldDimension: + return MetricsExploreFieldDimension(name=data["name"], type=MetricsFieldType(data["type"])) diff --git a/paddle_billing/Entities/Metrics/MetricsExploreFieldMeasure.py b/paddle_billing/Entities/Metrics/MetricsExploreFieldMeasure.py new file mode 100644 index 00000000..8a52e6bb --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsExploreFieldMeasure.py @@ -0,0 +1,23 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any + +from paddle_billing.Entities.Metrics.MetricsAggregation import MetricsAggregation +from paddle_billing.Entities.Metrics.MetricsFieldType import MetricsFieldType + + +@dataclass +class MetricsExploreFieldMeasure: + name: str + type: MetricsFieldType + field: str | None = None + agg: MetricsAggregation | None = None + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsExploreFieldMeasure: + return MetricsExploreFieldMeasure( + name=data["name"], + type=MetricsFieldType(data["type"]), + field=data.get("field"), + agg=MetricsAggregation(data["agg"]) if data.get("agg") else None, + ) diff --git a/paddle_billing/Entities/Metrics/MetricsExploreFields.py b/paddle_billing/Entities/Metrics/MetricsExploreFields.py new file mode 100644 index 00000000..f7b66eb6 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsExploreFields.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any + +from paddle_billing.Entities.Metrics.MetricsExploreFieldDimension import MetricsExploreFieldDimension +from paddle_billing.Entities.Metrics.MetricsExploreFieldMeasure import MetricsExploreFieldMeasure + + +@dataclass +class MetricsExploreFields: + dimensions: list[MetricsExploreFieldDimension] + measures: list[MetricsExploreFieldMeasure] + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsExploreFields: + return MetricsExploreFields( + dimensions=[MetricsExploreFieldDimension.from_dict(dimension) for dimension in data["dimensions"]], + measures=[MetricsExploreFieldMeasure.from_dict(measure) for measure in data["measures"]], + ) diff --git a/paddle_billing/Entities/Metrics/MetricsExploreResponse.py b/paddle_billing/Entities/Metrics/MetricsExploreResponse.py new file mode 100644 index 00000000..e28fcda6 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsExploreResponse.py @@ -0,0 +1,34 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any +from urllib.parse import parse_qs, urlparse + +from paddle_billing.Entities.Metrics.MetricsExploreData import MetricsExploreData +from paddle_billing.Entities.Shared import MetaPaginated + + +@dataclass +class MetricsExploreResponse: + data: MetricsExploreData + meta: MetaPaginated + + @property + def has_more(self) -> bool: + return self.meta.pagination.has_more + + @property + def next_cursor(self) -> str | None: + """The `after` value to pass to the next run_explore_query call, or None on the last page.""" + if not self.has_more or not self.meta.pagination.next: + return None + + after = parse_qs(urlparse(self.meta.pagination.next).query).get("after") + + return after[0] if after else None + + @staticmethod + def from_dict(body: dict[str, Any]) -> MetricsExploreResponse: + return MetricsExploreResponse( + data=MetricsExploreData.from_dict(body["data"]), + meta=MetaPaginated.from_dict(body["meta"]), + ) diff --git a/paddle_billing/Entities/Metrics/MetricsExploreSeries.py b/paddle_billing/Entities/Metrics/MetricsExploreSeries.py new file mode 100644 index 00000000..8eac27ad --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsExploreSeries.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any + +from paddle_billing.Entities.Metrics.MetricsExploreDataPoint import MetricsExploreDataPoint + + +@dataclass +class MetricsExploreSeries: + dimensions: dict[str, str] + timeseries: list[MetricsExploreDataPoint] + + @staticmethod + def from_dict(data: dict[str, Any]) -> MetricsExploreSeries: + return MetricsExploreSeries( + dimensions=dict(data["dimensions"]), + timeseries=[MetricsExploreDataPoint.from_dict(point) for point in data["timeseries"]], + ) diff --git a/paddle_billing/Entities/Metrics/MetricsFieldType.py b/paddle_billing/Entities/Metrics/MetricsFieldType.py new file mode 100644 index 00000000..ce4a20c8 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsFieldType.py @@ -0,0 +1,8 @@ +from paddle_billing.PaddleStrEnum import PaddleStrEnum, PaddleStrEnumMeta + + +class MetricsFieldType(PaddleStrEnum, metaclass=PaddleStrEnumMeta): + String: "MetricsFieldType" = "string" + Integer: "MetricsFieldType" = "integer" + Decimal: "MetricsFieldType" = "decimal" + Currency: "MetricsFieldType" = "currency" diff --git a/paddle_billing/Entities/Metrics/MetricsFilterOperator.py b/paddle_billing/Entities/Metrics/MetricsFilterOperator.py new file mode 100644 index 00000000..47bbc809 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsFilterOperator.py @@ -0,0 +1,5 @@ +from paddle_billing.PaddleStrEnum import PaddleStrEnum, PaddleStrEnumMeta + + +class MetricsFilterOperator(PaddleStrEnum, metaclass=PaddleStrEnumMeta): + In: "MetricsFilterOperator" = "in" diff --git a/paddle_billing/Entities/Metrics/MetricsSortDirection.py b/paddle_billing/Entities/Metrics/MetricsSortDirection.py new file mode 100644 index 00000000..329fd8c4 --- /dev/null +++ b/paddle_billing/Entities/Metrics/MetricsSortDirection.py @@ -0,0 +1,6 @@ +from paddle_billing.PaddleStrEnum import PaddleStrEnum, PaddleStrEnumMeta + + +class MetricsSortDirection(PaddleStrEnum, metaclass=PaddleStrEnumMeta): + Asc: "MetricsSortDirection" = "asc" + Desc: "MetricsSortDirection" = "desc" diff --git a/paddle_billing/Entities/Metrics/__init__.py b/paddle_billing/Entities/Metrics/__init__.py index 51c17a6a..30ea3076 100644 --- a/paddle_billing/Entities/Metrics/__init__.py +++ b/paddle_billing/Entities/Metrics/__init__.py @@ -10,3 +10,20 @@ from paddle_billing.Entities.Metrics.MetricsRefunds import MetricsRefunds from paddle_billing.Entities.Metrics.MetricsChargebacks import MetricsChargebacks from paddle_billing.Entities.Metrics.MetricsCheckoutConversion import MetricsCheckoutConversion +from paddle_billing.Entities.Metrics.MetricEntity import MetricEntity +from paddle_billing.Entities.Metrics.MetricsBucketInterval import MetricsBucketInterval +from paddle_billing.Entities.Metrics.MetricsAggregation import MetricsAggregation +from paddle_billing.Entities.Metrics.MetricsFieldType import MetricsFieldType +from paddle_billing.Entities.Metrics.MetricsFilterOperator import MetricsFilterOperator +from paddle_billing.Entities.Metrics.MetricsSortDirection import MetricsSortDirection +from paddle_billing.Entities.Metrics.MetricEntityTimeDimension import MetricEntityTimeDimension +from paddle_billing.Entities.Metrics.MetricEntityDimension import MetricEntityDimension +from paddle_billing.Entities.Metrics.MetricEntityMeasure import MetricEntityMeasure +from paddle_billing.Entities.Metrics.MetricEntityDefinition import MetricEntityDefinition +from paddle_billing.Entities.Metrics.MetricsExploreFieldDimension import MetricsExploreFieldDimension +from paddle_billing.Entities.Metrics.MetricsExploreFieldMeasure import MetricsExploreFieldMeasure +from paddle_billing.Entities.Metrics.MetricsExploreFields import MetricsExploreFields +from paddle_billing.Entities.Metrics.MetricsExploreDataPoint import MetricsExploreDataPoint +from paddle_billing.Entities.Metrics.MetricsExploreSeries import MetricsExploreSeries +from paddle_billing.Entities.Metrics.MetricsExploreData import MetricsExploreData +from paddle_billing.Entities.Metrics.MetricsExploreResponse import MetricsExploreResponse diff --git a/paddle_billing/Resources/Metrics/MetricsClient.py b/paddle_billing/Resources/Metrics/MetricsClient.py index 982a5403..a21bde6a 100644 --- a/paddle_billing/Resources/Metrics/MetricsClient.py +++ b/paddle_billing/Resources/Metrics/MetricsClient.py @@ -1,14 +1,20 @@ from paddle_billing.ResponseParser import ResponseParser +from paddle_billing.Entities.Metrics.MetricEntityDefinition import MetricEntityDefinition 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.MetricsExploreResponse import MetricsExploreResponse 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 paddle_billing.Resources.Metrics.Operations import ( + GetMetrics, + ListExploreMetricEntities, + RunExploreMetricsQuery, +) from typing import TYPE_CHECKING @@ -62,3 +68,18 @@ def get_checkout_conversion(self, operation: GetMetrics) -> MetricsCheckoutConve parser = ResponseParser(self.response) return MetricsCheckoutConversion.from_dict(parser.get_dict()) + + def list_explore_entities(self, operation: ListExploreMetricEntities | None = None) -> list[MetricEntityDefinition]: + if operation is None: + operation = ListExploreMetricEntities() + + self.response = self.client.get_raw("/metrics/explore/entities", operation.get_parameters()) + parser = ResponseParser(self.response) + + return [MetricEntityDefinition.from_dict(entity) for entity in parser.get_list()] + + def run_explore_query(self, operation: RunExploreMetricsQuery, after: str | None = None) -> MetricsExploreResponse: + parameters = {"after": after} if after else None + self.response = self.client.post_raw("/metrics/explore", operation, parameters) + + return MetricsExploreResponse.from_dict(self.response.json()) diff --git a/paddle_billing/Resources/Metrics/Operations/ListExploreMetricEntities.py b/paddle_billing/Resources/Metrics/Operations/ListExploreMetricEntities.py new file mode 100644 index 00000000..3a8ea1d4 --- /dev/null +++ b/paddle_billing/Resources/Metrics/Operations/ListExploreMetricEntities.py @@ -0,0 +1,13 @@ +from paddle_billing.Entities.Metrics import MetricEntity +from paddle_billing.HasParameters import HasParameters + + +class ListExploreMetricEntities(HasParameters): + def __init__(self, entity: MetricEntity | None = None): + self.entity = entity + + def get_parameters(self) -> dict[str, str]: + if self.entity is None: + return {} + + return {"entity": self.entity.value} diff --git a/paddle_billing/Resources/Metrics/Operations/RunExploreMetricsQuery.py b/paddle_billing/Resources/Metrics/Operations/RunExploreMetricsQuery.py new file mode 100644 index 00000000..a15f5f30 --- /dev/null +++ b/paddle_billing/Resources/Metrics/Operations/RunExploreMetricsQuery.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass +from datetime import date, datetime +from typing import Any + +from paddle_billing.Entities.DateTime import DateTime +from paddle_billing.Entities.Metrics import ( + MetricEntity, + MetricsAggregation, + MetricsBucketInterval, + MetricsFilterOperator, + MetricsSortDirection, +) +from paddle_billing.Operation import Operation +from paddle_billing.Undefined import Undefined + + +@dataclass +class ExploreMeasure: + field: str + agg: MetricsAggregation | None | Undefined = Undefined() + + +@dataclass +class ExploreFilter: + field: str + op: MetricsFilterOperator + value: list[str] + + +@dataclass +class ExploreOrderBy: + field: str + dir: MetricsSortDirection + + +@dataclass +class RunExploreMetricsQuery(Operation): + entity: MetricEntity + date_from: DateTime | datetime | date | str + date_to: DateTime | datetime | date | str + measures: list[ExploreMeasure] + interval: MetricsBucketInterval | Undefined = Undefined() + dimensions: list[str] | Undefined = Undefined() + filters: list[ExploreFilter] | Undefined = Undefined() + order_by: list[ExploreOrderBy] | Undefined = Undefined() + per_page: int | Undefined = Undefined() + + def to_json(self) -> dict[str, Any]: + payload = { + "entity": self.entity, + "from": self._format_boundary(self.date_from), + "to": self._format_boundary(self.date_to), + "measures": self.measures, + "interval": self.interval, + "dimensions": self.dimensions, + "filters": self.filters, + "order_by": self.order_by, + "per_page": self.per_page, + } + + return {key: value for key, value in payload.items() if not isinstance(value, Undefined)} + + @staticmethod + def _format_boundary(value: DateTime | datetime | date | str) -> str: + if isinstance(value, datetime): + return str(DateTime.from_datetime(value)) + + if isinstance(value, date): + return value.isoformat() + + return str(value) diff --git a/paddle_billing/Resources/Metrics/Operations/__init__.py b/paddle_billing/Resources/Metrics/Operations/__init__.py index 4aa9524a..0078b151 100644 --- a/paddle_billing/Resources/Metrics/Operations/__init__.py +++ b/paddle_billing/Resources/Metrics/Operations/__init__.py @@ -1 +1,8 @@ from paddle_billing.Resources.Metrics.Operations.GetMetrics import GetMetrics +from paddle_billing.Resources.Metrics.Operations.ListExploreMetricEntities import ListExploreMetricEntities +from paddle_billing.Resources.Metrics.Operations.RunExploreMetricsQuery import ( + ExploreFilter, + ExploreMeasure, + ExploreOrderBy, + RunExploreMetricsQuery, +) diff --git a/tests/Functional/Resources/Metrics/_fixtures/response/explore.json b/tests/Functional/Resources/Metrics/_fixtures/response/explore.json new file mode 100644 index 00000000..29697beb --- /dev/null +++ b/tests/Functional/Resources/Metrics/_fixtures/response/explore.json @@ -0,0 +1,62 @@ +{ + "data": { + "entity": "transactions.completed", + "interval": "month", + "currency_code": "USD", + "starts_at": "2026-05-01T00:00:00Z", + "ends_at": "2026-08-01T00:00:00Z", + "updated_at": "2026-08-01T03:00:00Z", + "fields": { + "dimensions": [ + { + "name": "product", + "type": "string" + } + ], + "measures": [ + { + "name": "sum_gross_revenue", + "type": "currency", + "field": "gross_revenue", + "agg": "sum" + } + ] + }, + "series": [ + { + "dimensions": { + "product": "pro_01h1vjfevh5etwq3rb416a23h2" + }, + "timeseries": [ + { + "timestamp": "2026-05-01", + "measures": { + "sum_gross_revenue": "120000" + } + }, + { + "timestamp": "2026-06-01", + "measures": { + "sum_gross_revenue": "135000" + } + }, + { + "timestamp": "2026-07-01", + "measures": { + "sum_gross_revenue": "142000" + } + } + ] + } + ] + }, + "meta": { + "request_id": "b93d9c94-c28f-4e5d-af2e-044854d7afe8", + "pagination": { + "per_page": 5, + "next": "https://api.paddle.com/metrics/explore?after=eyJ2IjoxLCJxaWQiOiIwMWpuOHg0azJwN3E5ciIsIm9mZiI6NSwicWgiOiJzaGEyNTY6OWYyYzRkMWUiLCJleHAiOjE3ODU1Njc2MDB9", + "has_more": true, + "estimated_total": 42 + } + } +} diff --git a/tests/Functional/Resources/Metrics/_fixtures/response/explore_entities.json b/tests/Functional/Resources/Metrics/_fixtures/response/explore_entities.json new file mode 100644 index 00000000..2f703d9d --- /dev/null +++ b/tests/Functional/Resources/Metrics/_fixtures/response/explore_entities.json @@ -0,0 +1,52 @@ +{ + "data": [ + { + "entity": "subscriptions", + "time_dimension": { + "intervals": ["month"], + "max_lookback_days": 1096 + }, + "dimensions": [ + { + "name": "product", + "type": "string", + "filter_ops": ["in"], + "multi_valued": false + }, + { + "name": "country", + "type": "string", + "filter_ops": ["in"], + "multi_valued": false + }, + { + "name": "billing_cycle", + "type": "string", + "filter_ops": ["in"], + "multi_valued": false + }, + { + "name": "subscription_activity", + "type": "string", + "filter_ops": ["in"], + "multi_valued": false + } + ], + "measures": [ + { + "name": "mrr", + "type": "currency", + "aggs": null + }, + { + "name": "mrr_growth", + "type": "currency", + "aggs": null + } + ] + } + ], + "meta": { + "request_id": "ecd52aa1-a73c-43df-af3b-e87edec3b435" + } +} diff --git a/tests/Functional/Resources/Metrics/_fixtures/response/explore_last_page.json b/tests/Functional/Resources/Metrics/_fixtures/response/explore_last_page.json new file mode 100644 index 00000000..e8dfcce2 --- /dev/null +++ b/tests/Functional/Resources/Metrics/_fixtures/response/explore_last_page.json @@ -0,0 +1,50 @@ +{ + "data": { + "entity": "transactions.completed", + "interval": "month", + "currency_code": "USD", + "starts_at": "2026-05-01T00:00:00Z", + "ends_at": "2026-08-01T00:00:00Z", + "updated_at": "2026-08-01T03:00:00Z", + "fields": { + "dimensions": [ + { + "name": "product", + "type": "string" + } + ], + "measures": [ + { + "name": "sum_gross_revenue", + "type": "currency", + "field": "gross_revenue", + "agg": "sum" + } + ] + }, + "series": [ + { + "dimensions": { + "product": "pro_01h1vjfevh5etwq3rb416a23h3" + }, + "timeseries": [ + { + "timestamp": "2026-05-01", + "measures": { + "sum_gross_revenue": "5000" + } + } + ] + } + ] + }, + "meta": { + "request_id": "c93d9c94-c28f-4e5d-af2e-044854d7afe9", + "pagination": { + "per_page": 5, + "next": "https://api.paddle.com/metrics/explore?after=ignored", + "has_more": false, + "estimated_total": 42 + } + } +} \ No newline at end of file diff --git a/tests/Functional/Resources/Metrics/test_MetricsExplore.py b/tests/Functional/Resources/Metrics/test_MetricsExplore.py new file mode 100644 index 00000000..02f52be3 --- /dev/null +++ b/tests/Functional/Resources/Metrics/test_MetricsExplore.py @@ -0,0 +1,189 @@ +from datetime import date, datetime, timezone +from json import loads + +from paddle_billing.Entities.Metrics import ( + MetricEntity, + MetricEntityDefinition, + MetricsAggregation, + MetricsBucketInterval, + MetricsExploreResponse, + MetricsFieldType, + MetricsFilterOperator, + MetricsSortDirection, +) +from paddle_billing.Entities.Shared import CurrencyCode +from paddle_billing.Resources.Metrics.Operations import ( + ExploreFilter, + ExploreMeasure, + ExploreOrderBy, + ListExploreMetricEntities, + RunExploreMetricsQuery, +) + +from tests.Utils.ReadsFixture import ReadsFixtures + +FIRST_CURSOR = ( + "eyJ2IjoxLCJxaWQiOiIwMWpuOHg0azJwN3E5ciIsIm9mZiI6NSwicWgiOiJzaGEyNTY6OWYyYzRkMWUiLCJleHAiOjE3ODU1Njc2MDB9" +) + + +class TestMetricsExploreEntities: + def test_list_explore_entities_returns_all_entities(self, test_client, mock_requests): + body = ReadsFixtures.read_raw_json_fixture("response/explore_entities") + url = f"{test_client.base_url}/metrics/explore/entities" + mock_requests.get(url, status_code=200, text=body) + + entities = test_client.client.metrics.list_explore_entities() + + assert mock_requests.last_request.method == "GET" + assert mock_requests.last_request.url == url + assert len(entities) == 1 + entity = entities[0] + assert isinstance(entity, MetricEntityDefinition) + assert entity.entity == MetricEntity.Subscriptions + assert entity.time_dimension.intervals == [MetricsBucketInterval.Month] + assert entity.time_dimension.max_lookback_days == 1096 + assert [dimension.name for dimension in entity.dimensions] == [ + "product", + "country", + "billing_cycle", + "subscription_activity", + ] + assert entity.dimensions[0].type == MetricsFieldType.String + assert entity.dimensions[0].filter_ops == [MetricsFilterOperator.In] + assert entity.dimensions[0].multi_valued is False + assert entity.measures[0].name == "mrr" + assert entity.measures[0].type == MetricsFieldType.Currency + assert entity.measures[0].aggs is None + + def test_list_explore_entities_filters_by_entity(self, test_client, mock_requests): + body = ReadsFixtures.read_raw_json_fixture("response/explore_entities") + url = f"{test_client.base_url}/metrics/explore/entities?entity=subscriptions" + mock_requests.get(url, status_code=200, text=body) + + entities = test_client.client.metrics.list_explore_entities( + ListExploreMetricEntities(entity=MetricEntity.Subscriptions) + ) + + assert mock_requests.last_request.url == url + assert len(entities) == 1 + + def test_measure_aggs_are_parsed(self, test_client, mock_requests): + body = '{"data":[{"entity":"checkouts","time_dimension":{"intervals":["day","week"],"max_lookback_days":90},' + body += '"dimensions":[],"measures":[{"name":"gross","type":"currency","aggs":["sum","count"]}]}],"meta":{}}' + mock_requests.get(f"{test_client.base_url}/metrics/explore/entities", status_code=200, text=body) + + entity = test_client.client.metrics.list_explore_entities()[0] + + assert entity.measures[0].aggs == [MetricsAggregation.Sum, MetricsAggregation.Count] + assert entity.time_dimension.intervals == [MetricsBucketInterval.Day, MetricsBucketInterval.Week] + + +class TestMetricsExploreQuery: + def test_run_explore_query_sends_full_body_and_parses_response(self, test_client, mock_requests): + body = ReadsFixtures.read_raw_json_fixture("response/explore") + url = f"{test_client.base_url}/metrics/explore" + mock_requests.post(url, status_code=200, text=body) + + operation = RunExploreMetricsQuery( + entity=MetricEntity.TransactionsCompleted, + date_from=datetime(2026, 5, 1, tzinfo=timezone.utc), + date_to=date(2026, 8, 1), + measures=[ExploreMeasure(field="gross_revenue", agg=MetricsAggregation.Sum), ExploreMeasure(field="count")], + interval=MetricsBucketInterval.Month, + dimensions=["product"], + filters=[ExploreFilter(field="country", op=MetricsFilterOperator.In, value=["US", "GB"])], + order_by=[ExploreOrderBy(field="gross_revenue", dir=MetricsSortDirection.Desc)], + per_page=5, + ) + + response = test_client.client.metrics.run_explore_query(operation) + + request = mock_requests.last_request + assert request.method == "POST" + assert request.url == url + assert loads(request.text) == { + "entity": "transactions.completed", + "from": "2026-05-01T00:00:00.000000Z", + "to": "2026-08-01", + "measures": [{"field": "gross_revenue", "agg": "sum"}, {"field": "count"}], + "interval": "month", + "dimensions": ["product"], + "filters": [{"field": "country", "op": "in", "value": ["US", "GB"]}], + "order_by": [{"field": "gross_revenue", "dir": "desc"}], + "per_page": 5, + } + + assert isinstance(response, MetricsExploreResponse) + data = response.data + assert data.entity == MetricEntity.TransactionsCompleted + assert data.interval == MetricsBucketInterval.Month + assert data.currency_code == CurrencyCode.USD + assert data.starts_at == datetime(2026, 5, 1, tzinfo=timezone.utc) + assert data.ends_at == datetime(2026, 8, 1, tzinfo=timezone.utc) + assert data.updated_at == datetime(2026, 8, 1, 3, tzinfo=timezone.utc) + assert data.fields.dimensions[0].name == "product" + assert data.fields.dimensions[0].type == MetricsFieldType.String + assert data.fields.measures[0].name == "sum_gross_revenue" + assert data.fields.measures[0].type == MetricsFieldType.Currency + assert data.fields.measures[0].field == "gross_revenue" + assert data.fields.measures[0].agg == MetricsAggregation.Sum + assert len(data.series) == 1 + assert data.series[0].dimensions == {"product": "pro_01h1vjfevh5etwq3rb416a23h2"} + assert [point.timestamp for point in data.series[0].timeseries] == ["2026-05-01", "2026-06-01", "2026-07-01"] + assert data.series[0].timeseries[2].measures == {"sum_gross_revenue": "142000"} + + pagination = response.meta.pagination + assert response.meta.request_id == "b93d9c94-c28f-4e5d-af2e-044854d7afe8" + assert pagination.per_page == 5 + assert pagination.has_more is True + assert pagination.estimated_total == 42 + assert pagination.next == f"https://api.paddle.com/metrics/explore?after={FIRST_CURSOR}" + assert response.has_more is True + assert response.next_cursor == FIRST_CURSOR + + def test_run_explore_query_minimal_body_omits_optional_fields(self, test_client, mock_requests): + body = ReadsFixtures.read_raw_json_fixture("response/explore") + mock_requests.post(f"{test_client.base_url}/metrics/explore", status_code=200, text=body) + + test_client.client.metrics.run_explore_query( + RunExploreMetricsQuery( + entity=MetricEntity.Checkouts, + date_from="2026-05-01", + date_to="2026-08-01", + measures=[ExploreMeasure(field="count")], + ) + ) + + assert loads(mock_requests.last_request.text) == { + "entity": "checkouts", + "from": "2026-05-01", + "to": "2026-08-01", + "measures": [{"field": "count"}], + } + + def test_run_explore_query_follows_next_cursor(self, test_client, mock_requests): + first_page = ReadsFixtures.read_raw_json_fixture("response/explore") + last_page = ReadsFixtures.read_raw_json_fixture("response/explore_last_page") + url = f"{test_client.base_url}/metrics/explore" + mock_requests.post(url, status_code=200, text=first_page) + mock_requests.post(f"{url}?after={FIRST_CURSOR}", status_code=200, text=last_page) + + operation = RunExploreMetricsQuery( + entity=MetricEntity.TransactionsCompleted, + date_from="2026-05-01", + date_to="2026-08-01", + measures=[ExploreMeasure(field="gross_revenue", agg=MetricsAggregation.Sum)], + dimensions=["product"], + ) + + first = test_client.client.metrics.run_explore_query(operation) + assert mock_requests.last_request.url == url + + second = test_client.client.metrics.run_explore_query(operation, after=first.next_cursor) + + assert mock_requests.last_request.url == f"{url}?after={FIRST_CURSOR}" + assert loads(mock_requests.last_request.text)["dimensions"] == ["product"] + assert second.data.series[0].dimensions == {"product": "pro_01h1vjfevh5etwq3rb416a23h3"} + assert second.has_more is False + assert second.next_cursor is None