Skip to content

Commit a57d1a1

Browse files
authored
Merge branch 'develop' into dependabot/pip/main/pytest-asyncio-1.4.0
2 parents 87e4dde + 12c5418 commit a57d1a1

4 files changed

Lines changed: 87 additions & 7 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.9
1+
0.1.10

__init__.py

Whitespace-only changes.

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
actualpy==0.22.1
1+
actualpy==0.22.2
22
fastapi[standard]==0.136.3
33
pydantic
44
pydantic-settings
55
pyyaml
6-
uvicorn==0.47.0
6+
uvicorn==0.49.0

services/actual_service.py

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import hashlib
22
import json
3+
import typing
34
from decimal import Decimal
45
from typing import List
56

67
from actual import Actual
78
from actual.queries import create_transaction
89
from actual.queries import get_payees
9-
from actual.queries import get_ruleset
10+
from actual.queries import get_rules
11+
from actual.rules import Action
12+
from actual.rules import Condition
13+
from actual.rules import Rule
14+
from actual.rules import RuleSet
15+
from pydantic import TypeAdapter
1016
from sqlalchemy.orm.exc import MultipleResultsFound
1117

1218
from core.config import settings
@@ -18,11 +24,19 @@
1824

1925

2026
class ActualService:
27+
"""Service layer for interacting with the Actual Budget API."""
28+
2129
def __init__(self):
2230
self.client = None
2331

2432
@staticmethod
2533
def _build_import_id(account_id: str, amount: Decimal, date, payee: str, notes: str, cleared: bool) -> str:
34+
"""Build a deterministic SHA-256-based import ID for a transaction.
35+
36+
The ID is derived from the account ID, amount, date, payee, notes, and
37+
cleared flag so that identical transactions always produce the same ID,
38+
allowing Actual Budget to detect and skip duplicates on re-import.
39+
"""
2640
normalized_amount = format(amount.normalize(), "f")
2741
normalized_payee = (payee or "").strip().lower()
2842
normalized_notes = (notes or "").strip().lower()
@@ -44,16 +58,83 @@ def _build_import_id(account_id: str, amount: Decimal, date, payee: str, notes:
4458

4559
@staticmethod
4660
def _is_duplicate_payee_error(error: Exception) -> bool:
61+
"""Return True if the exception indicates a duplicate payee lookup result."""
4762
return isinstance(error, MultipleResultsFound) or "Multiple rows were found when one or none was required" in str(error)
4863

4964
@staticmethod
5065
def _get_first_matching_payee(session, payee_name: str):
66+
"""Return the first payee matching the given name, or None if not found."""
5167
matching_payees = get_payees(session, name=payee_name)
5268
if not matching_payees:
5369
return None
5470
return matching_payees[0]
5571

72+
@staticmethod
73+
def _build_ruleset(session) -> RuleSet:
74+
"""Build a RuleSet from the database, skipping any rules that fail validation.
75+
76+
Valid action fields are derived dynamically from the ``Action`` model's
77+
type annotation so the check stays in sync with the installed version of
78+
``actualpy``. Any rule whose actions reference an unrecognised field is
79+
logged as a warning and excluded from the returned RuleSet rather than
80+
aborting the entire import.
81+
"""
82+
_field_annotation = Action.model_fields["field"].annotation
83+
valid_action_fields = {
84+
v
85+
for arg in typing.get_args(_field_annotation)
86+
for v in (typing.get_args(arg) if typing.get_origin(arg) is typing.Literal else ())
87+
}
88+
condition_adapter = TypeAdapter(list[Condition])
89+
action_adapter = TypeAdapter(list[Action])
90+
valid_rules = []
91+
for raw_rule in get_rules(session):
92+
if not raw_rule.conditions or not raw_rule.actions:
93+
continue
94+
try:
95+
conditions = condition_adapter.validate_json(raw_rule.conditions)
96+
actions = action_adapter.validate_json(raw_rule.actions)
97+
valid_rules.append(
98+
Rule(
99+
conditions=conditions,
100+
operation=raw_rule.conditions_op,
101+
actions=actions,
102+
stage=raw_rule.stage,
103+
)
104+
)
105+
except Exception as rule_error:
106+
try:
107+
raw_actions = json.loads(raw_rule.actions)
108+
bad_fields = [
109+
a.get("field")
110+
for a in raw_actions
111+
if isinstance(a, dict) and a.get("field") not in valid_action_fields and a.get("field") is not None
112+
]
113+
raw_conditions = json.loads(raw_rule.conditions or "[]")
114+
condition_summary = ", ".join(
115+
f"{c.get('field')} {c.get('op')} '{c.get('value')}'" for c in raw_conditions if isinstance(c, dict)
116+
)
117+
except json.JSONDecodeError:
118+
bad_fields = []
119+
condition_summary = "(unreadable)"
120+
logger.warning(
121+
f"Skipping rule ID '{raw_rule.id}' (stage={raw_rule.stage!r}, "
122+
f"conditions: [{condition_summary}])"
123+
+ (f" — unsupported action field(s): {bad_fields}" if bad_fields else f": {rule_error}")
124+
)
125+
return RuleSet(rules=valid_rules)
126+
56127
def add_transactions(self, transactions: List[Transaction]):
128+
"""Add a list of transactions to Actual Budget.
129+
130+
For each transaction, the account name is resolved to an Actual account
131+
ID using the configured mappings. A deterministic import ID is generated
132+
so duplicate submissions are ignored by Actual Budget. After all
133+
transactions are created, the full ruleset is applied (with invalid rules
134+
skipped), and the session is committed.
135+
136+
Returns a list of dicts containing the logged details for each transaction.
137+
"""
57138
transaction_info_list = []
58139
submitted_transactions = []
59140

@@ -133,9 +214,8 @@ def add_transactions(self, transactions: List[Transaction]):
133214
)
134215
submitted_transactions.append(actual_transaction)
135216

136-
# Run ruleset on submitted transactions
137-
rs = get_ruleset(actual.session)
138-
rs.run(submitted_transactions)
217+
# Run ruleset on submitted transactions, skipping any rules that fail validation
218+
self._build_ruleset(actual.session).run(submitted_transactions)
139219

140220
# Log transaction info
141221
logger.info("\n" + json.dumps(transaction_info_list, indent=2))

0 commit comments

Comments
 (0)