Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion ntropy_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "5.5.0"
__version__ = "5.6.0"

from typing import TYPE_CHECKING, Optional

Expand Down
17 changes: 14 additions & 3 deletions ntropy_sdk/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 1 addition & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 5.5.0
current_version = 5.6.0
commit = True
tag = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(rc(?P<pre>\d+))?
Expand All @@ -22,4 +22,3 @@ universal = 1
exclude = docs

[aliases]

2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
106 changes: 106 additions & 0 deletions tests/v3/test_recurrence_models.py
Original file line number Diff line number Diff line change
@@ -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
Loading