11import hashlib
22import json
3+ import typing
34from decimal import Decimal
45from typing import List
56
67from actual import Actual
78from actual .queries import create_transaction
89from 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
1016from sqlalchemy .orm .exc import MultipleResultsFound
1117
1218from core .config import settings
1824
1925
2026class 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