Repository: bvanelli/actualpy
Pull request #34 (2024-07-01 19:26:13)

feat: Multiple changes to core files.
- Introduce issue templates for Github (closes https://github.com/bvanelli/actualpy/issues/28)
- Fix command to install directly from git (closes https://github.com/bvanelli/actualpy/issues/33)
- Add documentation on how to install new transactions and run rules
- Add example on how to import a CSV file
- Small fixes to the Gnucash import

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/bvanelli/actualpy/pull/34?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) Report
Attention: Patch coverage is `64.28571%` with `5 lines` in your changes missing coverage. Please review.
> Project coverage is 96.29%. Comparing base [(`d79c930`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/d79c930bd9926f244ea3f3cbb214163a85df59a9?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) to head [(`be27c9f`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/be27c9f5717a561baf1b99dd03ce10be47db9c38?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).

| [Files](https://app.codecov.io/gh/bvanelli/actualpy/pull/34?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) | Patch % | Lines |
|---|---|---|
| [actual/\_\_init\_\_.py](https://app.codecov.io/gh/bvanelli/actualpy/pull/34?src=pr&el=tree&filepath=actual%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli#diff-YWN0dWFsL19faW5pdF9fLnB5) | 72.72% | [3 Missing :warning: ](https://app.codecov.io/gh/bvanelli/actualpy/pull/34?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) |
| [actual/queries.py](https://app.codecov.io/gh/bvanelli/actualpy/pull/34?src=pr&el=tree&filepath=actual%2Fqueries.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli#diff-YWN0dWFsL3F1ZXJpZXMucHk=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/bvanelli/actualpy/pull/34?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) |
| [actual/rules.py](https://app.codecov.io/gh/bvanelli/actualpy/pull/34?src=pr&el=tree&filepath=actual%2Frules.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli#diff-YWN0dWFsL3J1bGVzLnB5) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/bvanelli/actualpy/pull/34?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #34      +/-   ##
==========================================
- Coverage   96.45%   96.29%   -0.16%     
==========================================
  Files          12       12              
  Lines        1691     1701      +10     
==========================================
+ Hits         1631     1638       +7     
- Misses         60       63       +3     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/bvanelli/actualpy/pull/34?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli).


Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..4475bb6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,69 @@
+name: '🐛 Bug report'
+description: Report an issue with Actual.
+labels: [bug]
+
+body:
+  - type: checkboxes
+    id: checks
+    attributes:
+      label: Checks
+      options:
+        - label: I have checked that this issue has not already been reported.
+          required: true
+        - label: I have confirmed this bug exists on the latest version of actualpy.
+          required: true
+
+  - type: textarea
+    id: example
+    attributes:
+      label: Reproducible example
+      description: >
+        Please follow [this guide](https://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports) on how to
+        provide a minimal, copy-pastable example. Include the (wrong) output if applicable.
+      value: |
+        ```python
+
+        ```
+    validations:
+      required: true
+
+  - type: textarea
+    id: logs
+    attributes:
+      label: Log output
+      description: >
+        Include the stack trace, if available, of the problem being reported.
+      render: shell
+
+  - type: textarea
+    id: problem
+    attributes:
+      label: Issue description
+      description: >
+        Provide any additional information you think might be relevant. Things like which features are being used
+        (bank syncs, use case, etc).
+    validations:
+      required: true
+
+  - type: textarea
+    id: expected-behavior
+    attributes:
+      label: Expected behavior
+      description: >
+        Describe or show a code example of the expected behavior. This might be the relevant UI or code snippet where
+        Actual will handle things correctly, but the library does not.
+    validations:
+      required: true
+
+  - type: textarea
+    id: version
+    attributes:
+      label: Installed versions
+      description: >
+        Describe which version (or if running to git version, which commit) of the Python library and Actual Server
+        are being ran.
+      value: >
+        - actualpy version:
+        - Actual Server version:
+    validations:
+      required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..250c550
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,14 @@
+name: '✨ Feature request'
+description: Suggest a new feature or enhancement for actualpy.
+labels: [enhancement]
+
+body:
+  - type: textarea
+    id: description
+    attributes:
+      label: Description
+      description: >
+        Describe the feature or enhancement and explain why it should be implemented.
+        Include a code example if applicable.
+    validations:
+      required: true
diff --git a/README.md b/README.md
index f2814fd..ef120bc 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ Python API implementation for Actual server.
 Install it via Pip using the repository url:
 
 ```bash
-pip install https://github.com/bvanelli/actualpy
+pip install git+https://github.com/bvanelli/actualpy.git
 ```
 
 # Basic usage
@@ -42,6 +42,38 @@ with Actual(
         print(t.date, account_name, t.notes, t.amount, category)
 ```
 
+## Adding new transactions
+
+After you created your first budget (or when updating an existing budget), you can add new transactions by adding them
+using the `actual.session.add()` method. You cannot use the SQLAlchemy session directly because that adds the entries to your
+local database, but will not sync the results back to the server (that is only possible when re-uploading the file).
+
+The method will make sure the local database is updated, but will also send a SYNC request with the added data so that
+it will be immediately available on the frontend:
+
+```python
+import decimal
+import datetime
+from actual import Actual
+from actual.queries import create_transaction, create_account
+
+with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
+    act = create_account(actual.session, "My account")
+    t = create_transaction(
+        actual.session,
+        datetime.date.today(),
+        act,
+        "My payee",
+        notes="My first transaction",
+        amount=decimal.Decimal(-10.5),
+    )
+    actual.commit()  # use the actual.commit() instead of session.commit()!
+```
+
+Will produce:
+
+![added-transaction](./docs/static/added-transaction.png)
+
 # Experimental features
 
 > **WARNING:** Experimental features do not have all the testing necessary to ensure correctness in comparison to the
@@ -67,36 +99,51 @@ You will then have a freshly created new budget to use:
 
 If the `encryption_password` is set, the budget will additionally also be encrypted on the upload step to the server.
 
-# Adding new transactions
+## Updating transactions using Bank Sync
 
-After you created your first budget (or when updating an existing budget), you can add new transactions by adding them
-using the `actual.session.add()` method. You cannot use the SQLAlchemy session directly because that adds the entries to your
-local database, but will not sync the results back to the server (that is only possible when re-uploading the file).
+If you have either [goCardless](https://actualbudget.org/docs/advanced/bank-sync/#gocardless-setup) or
+[simplefin](https://actualbudget.org/docs/experimental/simplefin-sync/) integration configured, it is possible to
+update the transactions using just the Python API alone. This is because the actual queries to the third-party service
+are handled on the server, so the client does not have to do any custom API queries.
 
-The method will make sure the local database is updated, but will also send a SYNC request with the added data so that
-it will be immediately available on the frontend:
+To sync your account, simply call the `run_bank_sync` method:
+
+```python
+from actual import Actual
+
+with Actual(base_url="http://localhost:5006", password="mypass") as actual:
+    synchronized_transactions = actual.run_bank_sync()
+    for transaction in synchronized_transactions:
+        print(f"Added of modified {transaction}")
+    # sync changes back to the server
+    actual.commit()
+```
+
+## Running rules
+
+You can also automatically run rules using the library:
 
 ```python
-import decimal
-import datetime
 from actual import Actual
-from actual.queries import create_transaction, create_account
 
 with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
-    act = create_account(actual.session, "My account")
-    t = create_transaction(
-        actual.session,
-        datetime.date.today(),
-        act,
-        "My payee",
-        notes="My first transaction",
-        amount=decimal.Decimal(-10.5),
-    )
-    actual.session.add(t)
-    actual.commit()  # use the actual.commit() instead of session.commit()!
+    actual.run_rules()
 ```
 
-![added-transaction](./docs/static/added-transaction.png)
+You can also manipulate the rules individually:
+
+```python
+from actual import Actual
+from actual.queries import get_ruleset, get_transactions
+
+with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
+    rs = get_ruleset(actual.session)
+    transactions = get_transactions(actual.session)
+    for rule in rs:
+        for t in transactions:
+            if rule.evaluate(t):
+                print(f"Rule {rule} matches for {t}")
+```
 
 # Understanding how Actual handles changes
 
diff --git a/actual/__init__.py b/actual/__init__.py
index cab6794..edf41fd 100644
--- a/actual/__init__.py
+++ b/actual/__init__.py
@@ -31,6 +31,7 @@ from actual.protobuf_models import HULC_Client, Message, SyncRequest
 from actual.queries import (
     get_account,
     get_accounts,
+    get_ruleset,
     get_transactions,
     reconcile_transaction,
 )
@@ -46,6 +47,7 @@ class Actual(ActualServer):
         encryption_password: str = None,
         data_dir: Union[str, pathlib.Path] = None,
         bootstrap: bool = False,
+        sa_kwargs: dict = None,
     ):
         """
         Implements the Python API for the Actual Server in order to be able to read and modify information on Actual
@@ -60,8 +62,11 @@ class Actual(ActualServer):
         :param file: the name or id of the file to be set
         :param encryption_password: password used to configure encryption, if existing
         :param data_dir: where to store the downloaded files from the server. If not specified, a temporary folder will
-            be created instead.
+        be created instead.
         :param bootstrap: if the server is not bootstrapped, bootstrap it with the password.
+        :param sa_kwargs: additional kwargs passed to the SQLAlchemy session maker. Examples are `autoflush` (enabled
+        by default), `autocommit` (disabled by default). For a list of all parameters, check the SQLAlchemy
+        documentation: https://docs.sqlalchemy.org/en/20/orm/session_api.html#sqlalchemy.orm.Session.__init__
         """
         super().__init__(base_url, token, password, bootstrap)
         self._file: RemoteFileListDTO | None = None
@@ -75,12 +80,14 @@ class Actual(ActualServer):
         self._encryption_password = encryption_password
         self._master_key = None
         self._in_context = False
+        self._sa_kwargs = sa_kwargs or {}
+        if "autoflush" not in self._sa_kwargs:
+            self._sa_kwargs["autoflush"] = True
 
     def __enter__(self) -> Actual:
+        self._in_context = True
         if self._file:
             self.download_budget(self._encryption_password)
-            self._session = strong_reference_session(Session(self.engine))
-        self._in_context = True
         return self
 
     def __exit__(self, exc_type, exc_val, exc_tb):
@@ -162,7 +169,7 @@ class Actual(ActualServer):
         # generate a session
         self.engine = create_engine(f"sqlite:///{self._data_dir}/db.sqlite")
         if self._in_context:
-            self._session = strong_reference_session(Session(self.engine, autoflush=False))
+            self._session = strong_reference_session(Session(self.engine, **self._sa_kwargs))
         # create a clock
         self.load_clock()
 
@@ -292,6 +299,9 @@ class Actual(ActualServer):
         # actual js always calls validation
         self.validate()
         self.sync()
+        # create session if not existing
+        if self._in_context and not self._session:
+            self._session = strong_reference_session(Session(self.engine, **self._sa_kwargs))
 
     def import_zip(self, file_bytes: str | PathLike[str] | IO[bytes]):
         try:
@@ -362,6 +372,11 @@ class Actual(ActualServer):
         # sync all changes to the server
         self.sync_sync(req)
 
+    def run_rules(self):
+        ruleset = get_ruleset(self.session)
+        transactions = get_transactions(self.session)
+        ruleset.run(transactions)
+
     def _run_bank_sync_account(self, acct: Accounts, start_date: datetime.date) -> list[Transactions]:
         sync_method = acct.account_sync_source
         account_id = acct.account_id
diff --git a/actual/queries.py b/actual/queries.py
index 8c8b3cf..e66b849 100644
--- a/actual/queries.py
+++ b/actual/queries.py
@@ -300,7 +300,7 @@ def reconcile_transaction(
         if update_existing:
             match.notes = notes
             if category:
-                match.category = get_or_create_category(s, category).id
+                match.category_id = get_or_create_category(s, category).id
             match.set_date(date)
         return match
     return create_transaction(s, date, account, payee, notes, category, amount, imported_id, cleared, imported_payee)
diff --git a/actual/rules.py b/actual/rules.py
index 9740b51..83f72a9 100644
--- a/actual/rules.py
+++ b/actual/rules.py
@@ -400,6 +400,9 @@ class RuleSet(pydantic.BaseModel):
     def __str__(self):
         return "\n".join([str(r) for r in self.rules])
 
+    def __iter__(self) -> typing.Iterator[Rule]:
+        return self.rules.__iter__()
+
     def _run(
         self, transaction: typing.Union[Transactions, list[Transactions]], stage: typing.Literal["pre", "post", None]
     ):
@@ -412,7 +415,7 @@ class RuleSet(pydantic.BaseModel):
 
     def run(
         self,
-        transaction: typing.Union[Transactions, list[Transactions]],
+        transaction: typing.Union[Transactions, typing.Sequence[Transactions]],
         stage: typing.Literal["all", "pre", "post", None] = "all",
     ):
         """Runs the rules for each and every transaction on the list. If stage is 'all' (default), all rules are run in
diff --git a/examples/csv/README.md b/examples/csv/README.md
new file mode 100644
index 0000000..eb795ba
--- /dev/null
+++ b/examples/csv/README.md
@@ -0,0 +1,13 @@
+This example shows how to import a CSV file directly into Actual without having to use the UI.
+
+It also reconciles the transactions, to make sure that a transaction cannot be inserted twice into the database.
+
+The file under `files/transactions.csv` is a direct export from Actual and contains the following fields:
+
+- Account
+- Date
+- Payee
+- Notes
+- Category
+- Amount
+- Cleared
diff --git a/examples/csv/files/transactions.csv b/examples/csv/files/transactions.csv
new file mode 100644
index 0000000..9a02074
--- /dev/null
+++ b/examples/csv/files/transactions.csv
@@ -0,0 +1,19 @@
+Account,Date,Payee,Notes,Category,Amount,Cleared
+Current Checking Account,2024-04-01,,Paying rent,Rent,-250,Not cleared
+Current Savings Account,2024-01-31,Current Checking Account,Saving money,,200,Cleared
+Current Checking Account,2024-01-31,Current Savings Account,Saving money,,-200,Not cleared
+Current Checking Account,2024-01-31,,Streaming services,Online Services,-15,Not cleared
+Current Checking Account,2024-01-30,,Groceries,Groceries,-15,Not cleared
+Current Checking Account,2024-01-26,,New pants,Clothes,-40,Not cleared
+Current Checking Account,2024-01-26,,Groceries,Groceries,-25,Not cleared
+Current Checking Account,2024-01-19,,Groceries,Groceries,-25,Not cleared
+Current Checking Account,2024-01-18,,University book,Books,-30,Not cleared
+Current Checking Account,2024-01-16,,Phone contract,Phone,-15,Not cleared
+Current Checking Account,2024-01-13,,Cinema tickets,Entertainment:Music/Movies,-10,Not cleared
+Current Checking Account,2024-01-12,,Groceries,Groceries,-25,Not cleared
+Current Cash in Wallet,2024-01-06,,Couple of beers at a bar,Entertainment:Recreation,-25,Not cleared
+Current Cash in Wallet,2024-01-06,Current Checking Account,Cash withdraw,,50,Not cleared
+Current Checking Account,2024-01-06,Current Cash in Wallet,Cash withdraw,,-50,Not cleared
+Current Checking Account,2024-01-05,,Groceries,Groceries,-25,Not cleared
+Current Checking Account,2024-01-01,,Mobility Bonus,Other Income,100,Not cleared
+Current Checking Account,2024-01-01,,Salary Payment,Salary,700,Not cleared
diff --git a/examples/csv/import.py b/examples/csv/import.py
new file mode 100644
index 0000000..43fe8ec
--- /dev/null
+++ b/examples/csv/import.py
@@ -0,0 +1,68 @@
+import csv
+import datetime
+import decimal
+import pathlib
+
+from actual import Actual
+from actual.exceptions import UnknownFileId
+from actual.queries import get_or_create_account, reconcile_transaction
+
+
+def load_csv_data(file: pathlib.Path) -> list[dict]:
+    # load data from the csv
+    data = []
+    with open(file) as csvfile:
+        for entry in csv.DictReader(csvfile):
+            entry: dict
+            data.append(entry)
+    return data
+
+
+def main():
+    file = pathlib.Path(__file__).parent / "files/transactions.csv"
+    with Actual(password="mypass") as actual:
+        try:
+            actual.set_file("CSV Import")
+            actual.download_budget()
+        except UnknownFileId:
+            actual.create_budget("CSV Import")
+            actual.upload_budget()
+        # now try to do all the changes
+        added_transactions = []
+        for row in load_csv_data(file):
+            # here, we define the basic information from the file
+            account_name, payee, notes, category, cleared, date, amount = (
+                row["Account"],
+                row["Payee"],
+                row["Notes"],
+                row["Category"],
+                row["Cleared"] == "Cleared",  # transform to boolean
+                datetime.datetime.strptime(row["Date"], "%Y-%m-%d").date(),  # transform to date
+                decimal.Decimal(row["Amount"]),  # transform to decimal (float is also possible)
+            )
+            # we then create the required account, with empty starting balances, if it does not exist
+            # this is required because the transaction methods will refuse to auto-create accounts
+            account = get_or_create_account(actual.session, account_name)
+            # reconcile transaction. Here, it is important to pass the transactions added so far because the importer
+            # might overwrite transactions that look very similar (same value, same date) due to them being flagged as
+            # duplicates
+            t = reconcile_transaction(
+                actual.session,
+                date,
+                account,
+                payee,
+                notes,
+                category,
+                amount,
+                cleared=cleared,
+                already_matched=added_transactions,
+            )
+            added_transactions.append(t)
+            if t.changed():
+                print(f"Added or modified {t}")
+        # finally, the commit will push the changes to the server
+        actual.commit()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/examples/gnucash/README.md b/examples/gnucash/README.md
index cc1b246..1e98137 100644
--- a/examples/gnucash/README.md
+++ b/examples/gnucash/README.md
@@ -1,7 +1,8 @@
 This example shows how to import a Gnucash file using entirely the Python API. It will create the budget from scratch
 using the remote migrations, import all transactions and the upload the new budget to the server.
 
-The API, however, only uploads the budget in full, so the partial syncs are not done.
+The API, however, only uploads the budget in full, so the partial syncs are not done. If you want to do a partial
+update, check the [CSV import example](../csv).
 
 This script requires a custom version of [Piecash](https://github.com/sdementen/piecash) to run that support SQLAlchemy
 1.4 and beyond, since it's a requirement for SQLModel. Getting the dependencies to work together can be a little bit
diff --git a/examples/gnucash/import.py b/examples/gnucash/import.py
index 41162ed..00aab98 100644
--- a/examples/gnucash/import.py
+++ b/examples/gnucash/import.py
@@ -6,7 +6,7 @@ import piecash
 
 from actual import Actual
 from actual.queries import (
-    create_transaction_from_ids,
+    create_transaction,
     create_transfer,
     get_or_create_account,
     get_or_create_category,
@@ -23,12 +23,12 @@ def insert_transaction(
         account = get_or_create_account(session, account_source.replace("Assets:", ""))
         group_name, _, category_name = expense_source.partition(":")
         category = get_or_create_category(session, category_name, group_name)
-        create_transaction_from_ids(session, date, account.id, payee.id, notes, category.id, -value)
+        create_transaction(session, date, account, payee, notes, category, -value)
     elif account_source.startswith("Income:") and expense_source.startswith("Assets:"):
         expense = get_or_create_account(session, expense_source.replace("Assets:", ""))
         group_name, _, category_name = account_source.partition(":")
         category = get_or_create_category(session, category_name, group_name)
-        create_transaction_from_ids(session, date, expense.id, payee.id, notes, category.id, value)
+        create_transaction(session, date, expense, payee, notes, category, value)
     elif account_source.startswith("Assets:") and expense_source.startswith("Assets:"):
         account = get_or_create_account(session, account_source.replace("Assets:", ""))
         expense = get_or_create_account(session, expense_source.replace("Assets:", ""))


Test patch:
diff --git a/tests/test_database.py b/tests/test_database.py
index 75a7043..41a8c40 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -66,15 +66,20 @@ def test_reconcile_transaction(session):
     today = date.today()
     create_account(session, "Bank")
     rent_payment = create_transaction(
-        session, today, "Bank", "Landlord", "Paying rent", "Rent", -1200, imported_id="unique"
+        session, today, "Bank", "Landlord", "Paying rent", "Expenses", -1200, imported_id="unique"
     )
     unrelated = create_transaction(
         session, today - timedelta(days=5), "Bank", "Carshop", "Car maintenance", "Car", -1200
     )
     session.commit()
-    assert reconcile_transaction(session, today + timedelta(days=1), "Bank", amount=-1200).id == rent_payment.id
+    assert (
+        reconcile_transaction(session, today + timedelta(days=1), "Bank", category="Rent", amount=-1200).id
+        == rent_payment.id
+    )
+    session.commit()
     # check if the property was updated
     assert rent_payment.get_date() == today + timedelta(days=1)
+    assert rent_payment.category.name == "Rent"
     # should still be able to match if the payee is defined, as the match is stronger
     assert (
         reconcile_transaction(
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 9511653..7286bac 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -47,6 +47,10 @@ def test_create_user_file(actual_server):
         assert len(new_user_files) == 1
         assert new_user_files[-1].name == "My Budget"
         assert actual.info().build is not None
+        # run rules
+        actual.run_rules()
+        # run bank sync
+        actual.run_bank_sync()
 
     # make sure a new instance can now retrieve the budget info
     with Actual(f"http://localhost:{port}", password="mypass", file="My Budget"):
diff --git a/tests/test_rules.py b/tests/test_rules.py
index 8f480ec..ab3d684 100644
--- a/tests/test_rules.py
+++ b/tests/test_rules.py
@@ -33,6 +33,7 @@ def test_category_rule():
     action = Action(field="description", value=payee)
     rule = Rule(conditions=[condition], actions=[action], operation="all")
     rs = RuleSet(rules=[])
+    assert list(rs) == []
     rs.add(rule)
     # run for one transaction
     t = create_transaction(mock, datetime.date(2024, 1, 1), acct, "", category=cat)