diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd2514..081af93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ # Changelog All notable changes to this project will be documented in this file. +## [5.6.0] - 2026-08-29 +- Add `semi-monthly` recurrence periodicity support. +- Align compound recurrence periodicity values with the API's hyphenated wire format while accepting legacy underscore spellings. + ## [5.1.3] - 2025-02-25 - Fix `extra_kwargs` handling for bank statements `wait_for_results` diff --git a/ntropy_sdk/__init__.py b/ntropy_sdk/__init__.py index 0a60803..fbb19bf 100644 --- a/ntropy_sdk/__init__.py +++ b/ntropy_sdk/__init__.py @@ -1,4 +1,4 @@ -__version__ = "5.5.0" +__version__ = "5.6.0" from typing import TYPE_CHECKING, Optional diff --git a/ntropy_sdk/transactions.py b/ntropy_sdk/transactions.py index 905f500..15e392d 100644 --- a/ntropy_sdk/transactions.py +++ b/ntropy_sdk/transactions.py @@ -203,14 +203,25 @@ class RecurrenceType(str, enum.Enum): class RecurrencePeriodicity(str, enum.Enum): daily = "daily" weekly = "weekly" - bi_weekly = "bi_weekly" + bi_weekly = "bi-weekly" + semi_monthly = "semi-monthly" monthly = "monthly" - bi_monthly = "bi_monthly" + bi_monthly = "bi-monthly" quarterly = "quarterly" - semi_yearly = "semi_yearly" + semi_yearly = "semi-yearly" yearly = "yearly" other = "other" + @classmethod + def _missing_(cls, value): + # Accept values emitted by older SDK models while serializing to the + # hyphenated spellings used by the API. + if isinstance(value, str): + wire_value = value.replace("_", "-") + if wire_value != value: + return cls(wire_value) + return None + class RecurrenceGroup(BaseModel): id: str = Field(description="A unique UUID identifier for the group") diff --git a/setup.cfg b/setup.cfg index be2559d..3cab0a6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 5.5.0 +current_version = 5.6.0 commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)(rc(?P
\d+))?
@@ -22,4 +22,3 @@ universal = 1
 exclude = docs
 
 [aliases]
-
diff --git a/setup.py b/setup.py
index ca59a48..0de875f 100644
--- a/setup.py
+++ b/setup.py
@@ -55,6 +55,6 @@
     test_suite="tests",
     tests_require=test_requirements,
     url="https://github.com/ntropy-network/ntropy-sdk",
-    version="5.5.0",
+    version="5.6.0",
     zip_safe=False,
 )
diff --git a/tests/v3/test_recurrence_models.py b/tests/v3/test_recurrence_models.py
new file mode 100644
index 0000000..8d0c64e
--- /dev/null
+++ b/tests/v3/test_recurrence_models.py
@@ -0,0 +1,106 @@
+import json
+
+import pytest
+
+from ntropy_sdk.account_holders import (
+    AccountHoldersResource,
+    AccountHoldersResourceAsync,
+)
+from ntropy_sdk.transactions import RecurrenceGroup, RecurrencePeriodicity
+from ntropy_sdk.utils import pydantic_json
+
+
+def _semi_monthly_group():
+    return {
+        "id": "recurrence-group-id",
+        "start_date": "2026-06-01",
+        "end_date": "2026-08-15",
+        "total_amount": 6000,
+        "average_amount": 1000,
+        "periodicity_in_days": 15,
+        "periodicity": "semi-monthly",
+        "counterparty": {"type": "organization"},
+        "categories": {},
+        "transaction_ids": ["transaction-id"],
+    }
+
+
+class _Response:
+    headers = {}
+
+    def json(self):
+        return [_semi_monthly_group()]
+
+
+class _SyncSDK:
+    def retry_ratelimited_request(self, **kwargs):
+        return _Response()
+
+
+class _AsyncResponse(_Response):
+    async def json(self):
+        return [_semi_monthly_group()]
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc_value, traceback):
+        pass
+
+
+class _AsyncSDK:
+    async def retry_ratelimited_request(self, **kwargs):
+        return _AsyncResponse()
+
+
+@pytest.mark.parametrize(
+    "wire_value, member",
+    [
+        ("bi-weekly", RecurrencePeriodicity.bi_weekly),
+        ("semi-monthly", RecurrencePeriodicity.semi_monthly),
+        ("bi-monthly", RecurrencePeriodicity.bi_monthly),
+        ("semi-yearly", RecurrencePeriodicity.semi_yearly),
+    ],
+)
+def test_recurrence_periodicity_uses_api_wire_values(wire_value, member):
+    assert RecurrencePeriodicity(wire_value) is member
+    assert member.value == wire_value
+
+
+@pytest.mark.parametrize(
+    "legacy_value, member",
+    [
+        ("bi_weekly", RecurrencePeriodicity.bi_weekly),
+        ("semi_monthly", RecurrencePeriodicity.semi_monthly),
+        ("bi_monthly", RecurrencePeriodicity.bi_monthly),
+        ("semi_yearly", RecurrencePeriodicity.semi_yearly),
+    ],
+)
+def test_recurrence_periodicity_accepts_legacy_sdk_values(
+    legacy_value, member
+):
+    assert RecurrencePeriodicity(legacy_value) is member
+
+
+def test_recurrence_group_parses_and_serializes_semi_monthly():
+    group = RecurrenceGroup(**_semi_monthly_group())
+
+    assert group.periodicity is RecurrencePeriodicity.semi_monthly
+    assert json.loads(pydantic_json(group))["periodicity"] == "semi-monthly"
+
+
+def test_sync_recurring_groups_parses_semi_monthly():
+    groups = AccountHoldersResource(_SyncSDK()).recurring_groups(
+        "account-holder-id"
+    )
+
+    assert groups.groups[0].periodicity is RecurrencePeriodicity.semi_monthly
+
+
+@pytest.mark.asyncio
+async def test_async_recurring_groups_parses_semi_monthly():
+    groups = await AccountHoldersResourceAsync(_AsyncSDK()).recurring_groups(
+        "account-holder-id"
+    )
+
+    assert groups.groups[0].periodicity is RecurrencePeriodicity.semi_monthly