diff --git a/actual/database.py b/actual/database.py index f0f0d84..32b740d 100644 --- a/actual/database.py +++ b/actual/database.py @@ -311,7 +311,7 @@ class Rules(BaseModel, table=True): class Schedules(SQLModel, table=True): id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True)) - rule: Optional[str] = Field(default=None, sa_column=Column("rule", Text, ForeignKey("rules.id"))) + rule_id: Optional[str] = Field(default=None, sa_column=Column("rule", Text, ForeignKey("rules.id"))) active: Optional[int] = Field(default=None, sa_column=Column("active", Integer, server_default=text("0"))) completed: Optional[int] = Field(default=None, sa_column=Column("completed", Integer, server_default=text("0"))) posts_transaction: Optional[int] = Field( @@ -321,7 +321,7 @@ class Schedules(SQLModel, table=True): tombstone: Optional[int] = Field(default=None, sa_column=Column("tombstone", Integer, server_default=text("0"))) name: Optional[str] = Field(default=None, sa_column=Column("name", Text, server_default=text("NULL"))) - config: "Rules" = Relationship(sa_relationship_kwargs={"uselist": False}) + rule: "Rules" = Relationship(sa_relationship_kwargs={"uselist": False}) transactions: List["Transactions"] = Relationship(back_populates="schedule") diff --git a/actual/queries.py b/actual/queries.py index f4f0930..5143e84 100644 --- a/actual/queries.py +++ b/actual/queries.py @@ -134,7 +134,9 @@ def create_transaction( return create_transaction_from_ids(s, date, acct.id, payee.id, notes, category_id, amount) -def base_query(s: Session, instance: typing.Type[T], name: str = None, include_deleted: bool = False) -> typing.List[T]: +def base_query( + s: Session, instance: typing.Type[T], name: str = None, include_deleted: bool = False +) -> sqlalchemy.orm.Query: """Internal method to reduce querying complexity on sub-functions.""" query = s.query(instance) if not include_deleted: @@ -369,7 +371,7 @@ def get_rules(s: Session, include_deleted: bool = False) -> list[Rules]: :param include_deleted: includes all payees which were deleted via frontend. They would not show normally. :return: list of rules. """ - return base_query(s, Rules, None, include_deleted).all() # noqa + return base_query(s, Rules, None, include_deleted).all() def get_ruleset(s: Session) -> RuleSet: @@ -417,5 +419,13 @@ def create_rule( return database_rule -def get_schedules(s: Session) -> typing.List[Schedules]: - return s.query(Schedules).all() +def get_schedules(s: Session, name: str = None, include_deleted: bool = False) -> typing.List[Schedules]: + """ + Returns a list of all available schedules. + + :param s: session from Actual local database. + :param name: pattern name of the payee, case-insensitive. + :param include_deleted: includes all payees which were deleted via frontend. They would not show normally. + :return: list of schedules. + """ + return base_query(s, Schedules, name, include_deleted).all() diff --git a/actual/rules.py b/actual/rules.py index 1e4c7ae..47e9b2d 100644 --- a/actual/rules.py +++ b/actual/rules.py @@ -73,7 +73,7 @@ def validate(self, value: typing.Union[int, list[str], str, None], as_list: bool res = False return res elif self == ValueType.NUMBER: - return (isinstance(value, int) or isinstance(value, float)) and value >= 0 + return isinstance(value, int) else: # must be BOOLEAN return isinstance(value, bool) @@ -114,12 +114,23 @@ def condition_evaluation( op: ConditionType, true_value: typing.Union[int, list[str], str, datetime.date, None], self_value: typing.Union[int, list[str], str, datetime.date, None], + options: dict = None, ) -> bool: """Helper function to evaluate the condition based on the true_value, value found on the transaction, and the self_value, value defined on rule condition.""" if true_value is None: # short circuit as comparisons with NoneType are useless return False + if isinstance(options, dict): + # short circuit if the transaction should be and in/outflow but it isn't + if options.get("outflow") is True and true_value > 0: + return False + if options.get("inflow") is True and true_value < 0: + return False + if isinstance(self_value, int) and isinstance(options, dict) and options.get("outflow") is True: + # if it's an outflow we use the negative value of self_value, that is positive + self_value = -self_value + # do comparison if op == ConditionType.IS: return self_value == true_value elif op == ConditionType.IS_NOT: @@ -158,6 +169,10 @@ class Condition(pydantic.BaseModel): set to IS or CONTAINS, and the operation applied to a 'field' with certain 'value'. If the transaction value matches the condition, the `run` method returns `True`, otherwise it returns `False`. + **Important**: Actual shows the amount on frontend as decimal but handles it internally as cents. Make sure that, if + you provide the 'amount' rule manually, you either provide number of cents or a float that get automatically + converted to cents. + The 'field' can be one of the following ('type' will be set automatically): - imported_description: 'type' must be 'string' and 'value' any string @@ -166,19 +181,30 @@ class Condition(pydantic.BaseModel): - date: 'type' must be 'date' and 'value' a string in the date format '2024-04-11' - description: 'type' must be 'id' and 'value' a valid uuid (means payee_id) - notes: 'type' must be 'string' and 'value' any string - - amount: 'type' must be 'number' and format in cents (additional "options":{"inflow":true} or - "options":{"outflow":true} for inflow/outflow distinction, set automatically based on the amount sign) + - amount: 'type' must be 'number' and format in cents + - amount_inflow: 'type' must be 'number' and format in cents, will set "options":{"inflow":true} + - amount_outflow: 'type' must be 'number' and format in cents, will set "options":{"outflow":true} """ - field: typing.Literal["imported_description", "acct", "category", "date", "description", "notes", "amount"] + field: typing.Literal[ + "imported_description", + "acct", + "category", + "date", + "description", + "notes", + "amount", + "amount_inflow", + "amount_outflow", + ] op: ConditionType value: typing.Union[int, float, str, list[str], Schedule, list[BaseModel], BaseModel, datetime.date, None] type: typing.Optional[ValueType] = None options: typing.Optional[dict] = None def __str__(self) -> str: - value = f"'{self.value}'" if isinstance(self.value, str) else str(self.value) - return f"'{self.field}' {self.op.value} {value}" + v = f"'{self.value}'" if isinstance(self.value, str) or isinstance(self.value, Schedule) else str(self.value) + return f"'{self.field}' {self.op.value} {v}" def as_dict(self): """Returns valid dict for database insertion.""" @@ -192,9 +218,13 @@ def get_value(self) -> typing.Union[int, datetime.date, list[str], str, None]: @pydantic.model_validator(mode="after") def convert_value(self): - if (isinstance(self.value, int) or isinstance(self.value, float)) and self.options is None: - self.options = {"inflow": True} if self.value > 0 else {"outflow": True} - self.value = int(abs(self.value) * 100) + if self.field in ("amount_inflow", "amount_outflow") and self.options is None: + self.options = {self.field.split("_")[1]: True} + self.value = abs(self.value) + self.field = "amount" + if isinstance(self.value, float): + # convert silently in the background to a valid number + self.value = int(self.value * 100) return self @pydantic.model_validator(mode="after") @@ -206,10 +236,7 @@ def check_operation_type(self): raise ValueError(f"Operation {self.op} not supported for type {self.type}") # if a pydantic object is provided and id is expected, extract the id if isinstance(self.value, BaseModel): - if hasattr(self.value, "id"): - self.value = str(self.value.id) - else: - raise ValueError(f"Value {self.value} is not valid pydantic model") + self.value = str(self.value.id) elif isinstance(self.value, list) and len(self.value) and isinstance(self.value[0], pydantic.BaseModel): self.value = [v.id if hasattr(v, "id") else v for v in self.value] # make sure the data matches the value type @@ -222,7 +249,7 @@ def run(self, transaction: Transactions) -> bool: attr = get_attribute_by_table_name(Transactions.__tablename__, self.field) true_value = get_value(getattr(transaction, attr), self.type) self_value = self.get_value() - return condition_evaluation(self.op, true_value, self_value) + return condition_evaluation(self.op, true_value, self_value, self.options) class Action(pydantic.BaseModel): @@ -238,16 +265,20 @@ class Action(pydantic.BaseModel): - cleared: 'type' must be 'boolean' and value is a literal True/False (additional "options":{"splitIndex":0}) - acct: 'type' must be 'id' and 'value' an uuid - date: 'type' must be 'date' and 'value' a string in the date format '2024-04-11' + - amount: 'type' must be 'number' and format in cents """ - field: typing.Optional[typing.Literal["category", "description", "notes", "cleared", "acct", "date"]] = None + field: typing.Optional[typing.Literal["category", "description", "notes", "cleared", "acct", "date", "amount"]] = ( + None + ) op: ActionType = pydantic.Field(ActionType.SET, description="Action type to apply (default changes a column).") - value: typing.Union[str, bool, pydantic.BaseModel, None] + value: typing.Union[str, bool, int, float, pydantic.BaseModel, None] type: typing.Optional[ValueType] = None options: dict = None def __str__(self) -> str: - return f"{self.op.value} '{self.field}' to '{self.value}'" + field_str = f" '{self.field}'" if self.field else "" + return f"{self.op.value}{field_str} to '{self.value}'" def as_dict(self): """Returns valid dict for database insertion.""" @@ -256,6 +287,13 @@ def as_dict(self): ret.pop("options", None) return ret + @pydantic.model_validator(mode="after") + def convert_value(self): + if isinstance(self.value, float): + # convert silently in the background to a valid number + self.value = int(self.value * 100) + return self + @pydantic.model_validator(mode="after") def check_operation_type(self): if not self.type: diff --git a/tests/test_rules.py b/tests/test_rules.py index fd7e3b1..7a4201f 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -76,12 +76,12 @@ def test_datetime_rule(): def test_numeric_condition(): t = create_transaction(MagicMock(), datetime.date(2024, 1, 1), "Bank", "", amount=5) - c1 = Condition(field="amount", op="gt", value=10) + c1 = Condition(field="amount_inflow", op="gt", value=10.0) assert "inflow" in c1.options assert c1.run(t) is False - c2 = Condition(field="amount", op="lt", value=-10) + c2 = Condition(field="amount_outflow", op="lt", value=-10.0) assert "outflow" in c2.options - assert c2.run(t) is True # outflow, so the comparison should be with the positive value + assert c2.run(t) is False # outflow, so the comparison should be with the positive value # isapprox condition c2 = Condition(field="amount", op="isapprox", value=5.1) assert c2.run(t) is True @@ -159,7 +159,7 @@ def test_value_type_value_validation(): assert ValueType.STRING.validate("") is True assert ValueType.STRING.validate(123) is False assert ValueType.NUMBER.validate(123) is True - assert ValueType.NUMBER.validate(1.23) is True # noqa: test just in case + assert ValueType.NUMBER.validate(1.23) is False # noqa: test just in case assert ValueType.NUMBER.validate("123") is False assert ValueType.ID.validate("1c1a1707-15ea-4051-b98a-e400ee2900c7") is True assert ValueType.ID.validate("foo") is False diff --git a/tests/test_schedules.py b/tests/test_schedules.py index 47cac18..f0fb2bc 100644 --- a/tests/test_schedules.py +++ b/tests/test_schedules.py @@ -1,7 +1,10 @@ from datetime import date +from unittest.mock import MagicMock import pytest +from actual.queries import create_account, create_transaction +from actual.rules import Rule from actual.schedules import Schedule, date_to_datetime @@ -135,3 +138,42 @@ def test_strings(): assert str(Schedule(start="2024-05-12", frequency="yearly")) == "Every year on May 12" assert str(Schedule(start="2024-05-12", frequency="weekly")) == "Every week on Sunday" assert str(Schedule(start="2024-05-12", frequency="daily")) == "Every day" + + +def test_scheduled_rule(): + mock = MagicMock() + acct = create_account(mock, "Bank") + rule = Rule( + id="d84d1400-4245-4bb9-95d0-be4524edafe9", + conditions=[ + { + "op": "isapprox", + "field": "date", + "value": { + "start": "2024-05-01", + "frequency": "monthly", + "patterns": [], + "skipWeekend": False, + "weekendSolveMode": "after", + "endMode": "never", + "endOccurrences": 1, + "endDate": "2024-05-14", + "interval": 1, + }, + }, + {"op": "isapprox", "field": "amount", "value": -2000}, + {"op": "is", "field": "acct", "value": acct.id}, + ], + stage=None, + actions=[{"op": "link-schedule", "value": "df1e464f-13ae-4a97-a07e-990faeb48b2f"}], + conditions_op="and", + ) + assert "'date' isapprox 'Every month on the 1st'" in str(rule) + + transaction_matching = create_transaction(mock, date(2024, 5, 2), acct, None, amount=-19) + transaction_not_matching = create_transaction(mock, date(2024, 5, 2), acct, None, amount=-15) + rule.run(transaction_matching) + rule.run(transaction_not_matching) + + assert transaction_matching.schedule_id == "df1e464f-13ae-4a97-a07e-990faeb48b2f" + assert transaction_not_matching.schedule_id is None