Repository: bvanelli/actualpy
Pull request #27 (2024-06-13 20:34:49)

feat: Add bank sync and implement it for the simplefin demo data.
- Add reconciliation of transactions using fuzzy matching
- Add pydantic models and endpoint call for the bank sync methods (tested with simplefin)

@latetedemelon @JaxOfDiamonds let me know what you think, I did not have time to test it since it took me way longer than I expected to write it.

I did not have any time to test it so it might be buggy.

I also could not validate the models with the responses from the API from gocardless, that I assume is similar. I'm not sure if they have a dev token just like simplefin has, if you guys know (or can provide an anonimized response) let me know.

Here is one example:

```python
from actual import Actual

with Actual(base_url="http://localhost:5006", password="<your password>", file="<your file>") as actual:
    added = actual.run_bank_sync()
    print(f"Imported or updated {len(added)} transactions!")
    actual.commit()
```

This will only work once the first sync was already done, and I tested it by deleting the latest x transactions and importing again.

Closes https://github.com/bvanelli/actualpy/issues/25

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/bvanelli/actualpy/pull/27?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 `75.22124%` with `56 lines` in your changes missing coverage. Please review.
> Project coverage is 93.41%. Comparing base [(`5de4026`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/5de4026234962526ac4e9680ad9a9014b571e0ab?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) to head [(`29a374b`)](https://app.codecov.io/gh/bvanelli/actualpy/commit/29a374bc89671344bdd99d4d188f30ad376b64b3?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/27?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/queries.py](https://app.codecov.io/gh/bvanelli/actualpy/pull/27?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=) | 25.00% | [24 Missing :warning: ](https://app.codecov.io/gh/bvanelli/actualpy/pull/27?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) |
| [actual/\_\_init\_\_.py](https://app.codecov.io/gh/bvanelli/actualpy/pull/27?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) | 15.38% | [22 Missing :warning: ](https://app.codecov.io/gh/bvanelli/actualpy/pull/27?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) |
| [actual/api/\_\_init\_\_.py](https://app.codecov.io/gh/bvanelli/actualpy/pull/27?src=pr&el=tree&filepath=actual%2Fapi%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli#diff-YWN0dWFsL2FwaS9fX2luaXRfXy5weQ==) | 40.00% | [9 Missing :warning: ](https://app.codecov.io/gh/bvanelli/actualpy/pull/27?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli) |
| [actual/api/models.py](https://app.codecov.io/gh/bvanelli/actualpy/pull/27?src=pr&el=tree&filepath=actual%2Fapi%2Fmodels.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Brunno+Vanelli#diff-YWN0dWFsL2FwaS9tb2RlbHMucHk=) | 98.92% | [1 Missing :warning: ](https://app.codecov.io/gh/bvanelli/actualpy/pull/27?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      #27      +/-   ##
==========================================
- Coverage   96.54%   93.41%   -3.14%     
==========================================
  Files           9       11       +2     
  Lines        1447     1594     +147     
==========================================
+ Hits         1397     1489      +92     
- Misses         50      105      +55     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/bvanelli/actualpy/pull/27?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/actual/__init__.py b/actual/__init__.py
index 76c99bf..cab6794 100644
--- a/actual/__init__.py
+++ b/actual/__init__.py
@@ -15,16 +15,25 @@ from typing import IO, Union
 
 from sqlmodel import Session, create_engine, select
 
-from actual.api import ActualServer, RemoteFileListDTO
+from actual.api import ActualServer
+from actual.api.models import RemoteFileListDTO
 from actual.crypto import create_key_buffer, decrypt_from_meta, encrypt, make_salt
 from actual.database import (
+    Accounts,
     MessagesClock,
+    Transactions,
     get_attribute_by_table_name,
     get_class_by_table_name,
     strong_reference_session,
 )
 from actual.exceptions import ActualError, InvalidZipFile, UnknownFileId
 from actual.protobuf_models import HULC_Client, Message, SyncRequest
+from actual.queries import (
+    get_account,
+    get_accounts,
+    get_transactions,
+    reconcile_transaction,
+)
 
 
 class Actual(ActualServer):
@@ -352,3 +361,65 @@ class Actual(ActualServer):
         self._session.commit()
         # sync all changes to the server
         self.sync_sync(req)
+
+    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
+        requisition_id = acct.bank.bank_id if sync_method == "goCardless" else None
+        new_transactions_data = self.bank_sync_transactions(
+            sync_method.lower(), account_id, start_date, requisition_id=requisition_id
+        )
+        new_transactions = new_transactions_data.data.transactions.all
+        imported_transactions = []
+        for transaction in new_transactions:
+            payee = transaction.imported_payee or "" if sync_method == "goCardless" else transaction.notes
+            reconciled = reconcile_transaction(
+                self.session,
+                transaction.date,
+                acct,
+                payee,
+                transaction.notes,
+                amount=transaction.transaction_amount.amount,
+                imported_id=transaction.transaction_id,
+                cleared=transaction.booked,
+                imported_payee=payee,
+                already_matched=imported_transactions,
+            )
+            if reconciled.changed():
+                imported_transactions.append(reconciled)
+        return imported_transactions
+
+    def run_bank_sync(
+        self, account: str | Accounts | None = None, start_date: datetime.date | None = None
+    ) -> list[Transactions]:
+        """
+        Runs the bank synchronization for the selected account. If missing, all accounts are synchronized. If a
+        start_date is provided, is used as a reference, otherwise, the last timestamp of each account will be used. If
+        the account does not have any transaction, the last 90 days are considered instead.
+        """
+        # if no account is provided, sync all of them, otherwise just the account provided
+        if account is None:
+            accounts = get_accounts(self.session)
+        else:
+            account = get_account(self.session, account)
+            accounts = [account]
+        imported_transactions = []
+
+        default_start_date = start_date
+        for acct in accounts:
+            sync_method = acct.account_sync_source
+            account_id = acct.account_id
+            if not (account_id and sync_method):
+                continue
+            status = self.bank_sync_status(sync_method.lower())
+            if not status.data.configured:
+                continue
+            if start_date is None:
+                all_transactions = get_transactions(self.session, account=acct)
+                if all_transactions:
+                    default_start_date = all_transactions[0].get_date()
+                else:
+                    default_start_date = datetime.date.today() - datetime.timedelta(days=90)
+            transactions = self._run_bank_sync_account(acct, default_start_date)
+            imported_transactions.extend(transactions)
+        return imported_transactions
diff --git a/actual/api.py b/actual/api/__init__.py
similarity index 78%
rename from actual/api.py
rename to actual/api/__init__.py
index aa1a3e7..b98a8cb 100644
--- a/actual/api.py
+++ b/actual/api/__init__.py
@@ -1,136 +1,35 @@
 from __future__ import annotations
 
-import enum
+import datetime
 import json
-from typing import List, Optional
+from typing import List, Literal
 
 import requests
-from pydantic import BaseModel, Field
 
+from actual.api.models import (
+    BankSyncAccountResponseDTO,
+    BankSyncStatusDTO,
+    BankSyncTransactionResponseDTO,
+    BootstrapInfoDTO,
+    Endpoints,
+    GetUserFileInfoDTO,
+    InfoDTO,
+    ListUserFilesDTO,
+    LoginDTO,
+    StatusDTO,
+    UploadUserFileDTO,
+    UserGetKeyDTO,
+    ValidateDTO,
+)
 from actual.crypto import create_key_buffer, make_test_message
-from actual.exceptions import AuthorizationError, UnknownFileId
+from actual.exceptions import (
+    ActualInvalidOperationError,
+    AuthorizationError,
+    UnknownFileId,
+)
 from actual.protobuf_models import SyncRequest, SyncResponse
 
 
-class Endpoints(enum.Enum):
-    LOGIN = "account/login"
-    INFO = "info"
-    ACCOUNT_VALIDATE = "account/validate"
-    NEEDS_BOOTSTRAP = "account/needs-bootstrap"
-    BOOTSTRAP = "account/bootstrap"
-    SYNC = "sync/sync"
-    LIST_USER_FILES = "sync/list-user-files"
-    GET_USER_FILE_INFO = "sync/get-user-file-info"
-    UPDATE_USER_FILE_NAME = "sync/update-user-filename"
-    DOWNLOAD_USER_FILE = "sync/download-user-file"
-    UPLOAD_USER_FILE = "sync/upload-user-file"
-    RESET_USER_FILE = "sync/reset-user-file"
-    # encryption related
-    USER_GET_KEY = "sync/user-get-key"
-    USER_CREATE_KEY = "sync/user-create-key"
-    # data related
-    DATA_FILE_INDEX = "data-file-index.txt"
-    DEFAULT_DB = "data/default-db.sqlite"
-    MIGRATIONS = "data/migrations"
-
-    def __str__(self):
-        return self.value
-
-
-class StatusCode(enum.Enum):
-    OK = "ok"
-
-
-class StatusDTO(BaseModel):
-    status: StatusCode
-
-
-class TokenDTO(BaseModel):
-    token: Optional[str]
-
-
-class LoginDTO(StatusDTO):
-    data: TokenDTO
-
-
-class UploadUserFileDTO(StatusDTO):
-    group_id: str = Field(..., alias="groupId")
-
-
-class IsValidatedDTO(BaseModel):
-    validated: Optional[bool]
-
-
-class ValidateDTO(StatusDTO):
-    data: IsValidatedDTO
-
-
-class EncryptMetaDTO(BaseModel):
-    key_id: Optional[str] = Field(..., alias="keyId")
-    algorithm: Optional[str]
-    iv: Optional[str]
-    auth_tag: Optional[str] = Field(..., alias="authTag")
-
-
-class EncryptionTestDTO(BaseModel):
-    value: str
-    meta: EncryptMetaDTO
-
-
-class EncryptionDTO(BaseModel):
-    id: Optional[str]
-    salt: Optional[str]
-    test: Optional[str]
-
-    def meta(self) -> EncryptionTestDTO:
-        return EncryptionTestDTO.parse_raw(self.test)
-
-
-class FileDTO(BaseModel):
-    deleted: Optional[int]
-    file_id: Optional[str] = Field(..., alias="fileId")
-    group_id: Optional[str] = Field(..., alias="groupId")
-    name: Optional[str]
-
-
-class RemoteFileListDTO(FileDTO):
-    encrypt_key_id: Optional[str] = Field(..., alias="encryptKeyId")
-
-
-class RemoteFileDTO(FileDTO):
-    encrypt_meta: Optional[EncryptMetaDTO] = Field(..., alias="encryptMeta")
-
-
-class GetUserFileInfoDTO(StatusDTO):
-    data: RemoteFileDTO
-
-
-class ListUserFilesDTO(StatusDTO):
-    data: List[RemoteFileListDTO]
-
-
-class UserGetKeyDTO(StatusDTO):
-    data: EncryptionDTO
-
-
-class BuildDTO(BaseModel):
-    name: str
-    description: Optional[str]
-    version: Optional[str]
-
-
-class InfoDTO(BaseModel):
-    build: BuildDTO
-
-
-class IsBootstrapedDTO(BaseModel):
-    bootstrapped: bool
-
-
-class BootstrapInfoDTO(StatusDTO):
-    data: IsBootstrapedDTO
-
-
 class ActualServer:
     def __init__(
         self,
@@ -323,3 +222,29 @@ class ActualServer:
         response.raise_for_status()
         parsed_response = SyncResponse.deserialize(response.content)
         return parsed_response  # noqa
+
+    def bank_sync_status(self, bank_sync: Literal["gocardless", "simplefin"] | str) -> BankSyncStatusDTO:
+        endpoint = Endpoints.BANK_SYNC_STATUS.value.format(bank_sync=bank_sync)
+        response = requests.post(f"{self.api_url}/{endpoint}", headers=self.headers(), json={})
+        return BankSyncStatusDTO.model_validate(response.json())
+
+    def bank_sync_accounts(self, bank_sync: Literal["gocardless", "simplefin"]) -> BankSyncAccountResponseDTO:
+        endpoint = Endpoints.BANK_SYNC_ACCOUNTS.value.format(bank_sync=bank_sync)
+        response = requests.post(f"{self.api_url}/{endpoint}", headers=self.headers(), json={})
+        return BankSyncAccountResponseDTO.model_validate(response.json())
+
+    def bank_sync_transactions(
+        self,
+        bank_sync: Literal["gocardless", "simplefin"] | str,
+        account_id: str,
+        start_date: datetime.date,
+        requisition_id: str = None,
+    ) -> BankSyncTransactionResponseDTO:
+        if bank_sync == "gocardless" and requisition_id is None:
+            raise ActualInvalidOperationError("Retrieving transactions with goCardless requires `requisition_id`")
+        endpoint = Endpoints.BANK_SYNC_TRANSACTIONS.value.format(bank_sync=bank_sync)
+        payload = {"accountId": account_id, "startDate": start_date.strftime("%Y-%m-%d")}
+        if requisition_id:
+            payload["requisitionId"] = requisition_id
+        response = requests.post(f"{self.api_url}/{endpoint}", headers=self.headers(), json=payload)
+        return BankSyncTransactionResponseDTO.model_validate(response.json())
diff --git a/actual/api/bank_sync.py b/actual/api/bank_sync.py
new file mode 100644
index 0000000..c8dd600
--- /dev/null
+++ b/actual/api/bank_sync.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+import datetime
+import decimal
+import enum
+from typing import List, Optional
+
+from pydantic import AliasChoices, BaseModel, Field
+
+from actual.utils.title import title
+
+
+class BankSyncTransactionDTO(BaseModel):
+    id: str
+    posted: int
+    amount: str
+    description: str
+    payee: str
+    memo: str
+
+
+class BankSyncOrgDTO(BaseModel):
+    domain: str
+    sfin_url: str = Field(..., alias="sfin-url")
+
+
+class BankSyncAccountDTO(BaseModel):
+    org: BankSyncOrgDTO
+    id: str
+    name: str
+    currency: str
+    balance: str
+    available_balance: str = Field(..., alias="available-balance")
+    balance_date: int = Field(..., alias="balance-date")
+    transactions: List[BankSyncTransactionDTO]
+    holdings: List[dict]
+
+
+class BankSyncAmount(BaseModel):
+    amount: decimal.Decimal
+    currency: str
+
+
+class DebtorAccount(BaseModel):
+    iban: str
+
+
+class BalanceType(enum.Enum):
+    CLOSING_BOOKED = "closingBooked"
+    EXPECTED = "expected"
+    FORWARD_AVAILABLE = "forwardAvailable"
+    INTERIM_AVAILABLE = "interimAvailable"
+    INTERIM_BOOKED = "interimBooked"
+    NON_INVOICED = "nonInvoiced"
+    OPENING_BOOKED = "openingBooked"
+
+
+class Balance(BaseModel):
+    """An object containing the balance amount and currency."""
+
+    balance_amount: BankSyncAmount = Field(..., alias="balanceAmount")
+    balance_type: BalanceType = Field(..., alias="balanceType")
+    reference_date: Optional[str] = Field(..., alias="referenceDate", description="The date of the balance")
+
+
+class TransactionItem(BaseModel):
+    transaction_id: str = Field(..., alias="transactionId")
+    booking_date: str = Field(..., alias="bookingDate")
+    booked: bool = True
+    value_date: str = Field(..., alias="valueDate")
+    transaction_amount: BankSyncAmount = Field(..., alias="transactionAmount")
+    # this field will come as either debtorName or creditorName, depending on if it's a debt or credit
+    payee: str = Field(None, validation_alias=AliasChoices("debtorName", "creditorName"))
+    payee_account: Optional[DebtorAccount] = Field(
+        None, validation_alias=AliasChoices("debtorAccount", "creditorAccount")
+    )
+    date: datetime.date
+    remittance_information_unstructured: str = Field(None, alias="remittanceInformationUnstructured")
+    remittance_information_unstructured_array: list[str] = Field(
+        default_factory=list, alias="remittanceInformationUnstructuredArray"
+    )
+    additional_information: Optional[str] = Field(None, alias="additionalInformation")
+
+    @property
+    def imported_payee(self):
+        name_parts = []
+        name = self.payee or self.notes or self.additional_information
+        if name:
+            name_parts.append(title(name))
+        if self.payee_account and self.payee_account.iban:
+            iban = self.payee_account.iban
+            name_parts.append(f"({iban[:4]} XXX {iban[-4:]})")
+        return " ".join(name_parts).strip()
+
+    @property
+    def notes(self):
+        notes = self.remittance_information_unstructured or ", ".join(
+            self.remittance_information_unstructured_array or []
+        )
+        return notes.strip()
+
+
+class Transactions(BaseModel):
+    all: List[TransactionItem]
+    booked: List[TransactionItem]
+    pending: List[TransactionItem]
+
+
+class BankSyncAccountData(BaseModel):
+    accounts: List[BankSyncAccountDTO]
+
+
+class BankSyncTransactionData(BaseModel):
+    balances: List[Balance]
+    starting_balance: int = Field(..., alias="startingBalance")
+    transactions: Transactions
+    # goCardless specific
+    iban: Optional[str] = None
+    institution_id: Optional[str] = Field(None, alias="institutionId")
diff --git a/actual/api/models.py b/actual/api/models.py
new file mode 100644
index 0000000..1871e09
--- /dev/null
+++ b/actual/api/models.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+import enum
+from typing import List, Optional
+
+from pydantic import BaseModel, Field
+
+from actual.api.bank_sync import BankSyncAccountData, BankSyncTransactionData
+
+
+class Endpoints(enum.Enum):
+    LOGIN = "account/login"
+    INFO = "info"
+    ACCOUNT_VALIDATE = "account/validate"
+    NEEDS_BOOTSTRAP = "account/needs-bootstrap"
+    BOOTSTRAP = "account/bootstrap"
+    SYNC = "sync/sync"
+    LIST_USER_FILES = "sync/list-user-files"
+    GET_USER_FILE_INFO = "sync/get-user-file-info"
+    UPDATE_USER_FILE_NAME = "sync/update-user-filename"
+    DOWNLOAD_USER_FILE = "sync/download-user-file"
+    UPLOAD_USER_FILE = "sync/upload-user-file"
+    RESET_USER_FILE = "sync/reset-user-file"
+    # encryption related
+    USER_GET_KEY = "sync/user-get-key"
+    USER_CREATE_KEY = "sync/user-create-key"
+    # data related
+    DATA_FILE_INDEX = "data-file-index.txt"
+    DEFAULT_DB = "data/default-db.sqlite"
+    MIGRATIONS = "data/migrations"
+    # bank sync related
+    SECRET = "secret"
+    BANK_SYNC_STATUS = "{bank_sync}/status"
+    BANK_SYNC_ACCOUNTS = "{bank_sync}/accounts"
+    BANK_SYNC_TRANSACTIONS = "{bank_sync}/transactions"
+
+    def __str__(self):
+        return self.value
+
+
+class BankSyncs(enum.Enum):
+    GOCARDLESS = "gocardless"
+    SIMPLEFIN = "simplefin"
+
+
+class StatusCode(enum.Enum):
+    OK = "ok"
+
+
+class StatusDTO(BaseModel):
+    status: StatusCode
+
+
+class TokenDTO(BaseModel):
+    token: Optional[str]
+
+
+class LoginDTO(StatusDTO):
+    data: TokenDTO
+
+
+class UploadUserFileDTO(StatusDTO):
+    group_id: str = Field(..., alias="groupId")
+
+
+class IsValidatedDTO(BaseModel):
+    validated: Optional[bool]
+
+
+class ValidateDTO(StatusDTO):
+    data: IsValidatedDTO
+
+
+class EncryptMetaDTO(BaseModel):
+    key_id: Optional[str] = Field(..., alias="keyId")
+    algorithm: Optional[str]
+    iv: Optional[str]
+    auth_tag: Optional[str] = Field(..., alias="authTag")
+
+
+class EncryptionTestDTO(BaseModel):
+    value: str
+    meta: EncryptMetaDTO
+
+
+class EncryptionDTO(BaseModel):
+    id: Optional[str]
+    salt: Optional[str]
+    test: Optional[str]
+
+    def meta(self) -> EncryptionTestDTO:
+        return EncryptionTestDTO.parse_raw(self.test)
+
+
+class FileDTO(BaseModel):
+    deleted: Optional[int]
+    file_id: Optional[str] = Field(..., alias="fileId")
+    group_id: Optional[str] = Field(..., alias="groupId")
+    name: Optional[str]
+
+
+class RemoteFileListDTO(FileDTO):
+    encrypt_key_id: Optional[str] = Field(..., alias="encryptKeyId")
+
+
+class RemoteFileDTO(FileDTO):
+    encrypt_meta: Optional[EncryptMetaDTO] = Field(..., alias="encryptMeta")
+
+
+class GetUserFileInfoDTO(StatusDTO):
+    data: RemoteFileDTO
+
+
+class ListUserFilesDTO(StatusDTO):
+    data: List[RemoteFileListDTO]
+
+
+class UserGetKeyDTO(StatusDTO):
+    data: EncryptionDTO
+
+
+class BuildDTO(BaseModel):
+    name: str
+    description: Optional[str]
+    version: Optional[str]
+
+
+class InfoDTO(BaseModel):
+    build: BuildDTO
+
+
+class IsBootstrapedDTO(BaseModel):
+    bootstrapped: bool
+
+
+class BootstrapInfoDTO(StatusDTO):
+    data: IsBootstrapedDTO
+
+
+class IsConfiguredDTO(BaseModel):
+    configured: bool
+
+
+class BankSyncStatusDTO(StatusDTO):
+    data: IsConfiguredDTO
+
+
+class BankSyncAccountResponseDTO(StatusDTO):
+    data: BankSyncAccountData
+
+
+class BankSyncTransactionResponseDTO(StatusDTO):
+    data: BankSyncTransactionData
diff --git a/actual/database.py b/actual/database.py
index bdafd73..6447923 100644
--- a/actual/database.py
+++ b/actual/database.py
@@ -120,6 +120,19 @@ class BaseModel(SQLModel):
             )
         # compute changes from a sqlalchemy instance, see https://stackoverflow.com/a/28353846/12681470
         changes = []
+        for column in self.changed():
+            converted_attr_name = get_attribute_by_table_name(self.__tablename__, column, reverse=True)
+            m = Message(dict(dataset=self.__tablename__, row=row, column=converted_attr_name))
+            value = self.__getattribute__(column)
+            # if the entry is new, we can ignore null columns, otherwise consider it an update to None
+            if value is not None or not is_new:
+                m.set_value(value)
+                changes.append(m)
+        return changes
+
+    def changed(self) -> list[str]:
+        """Returns list of model changed attributes."""
+        changed_attributes = []
         inspr = inspect(self)
         attrs = class_mapper(self.__class__).column_attrs  # exclude relationships
         for attr in attrs:  # noqa: you can iterate over attrs
@@ -128,16 +141,12 @@ class BaseModel(SQLModel):
                 continue
             hist = getattr(inspr.attrs, column).history
             if hist.has_changes():
-                converted_attr_name = get_attribute_by_table_name(self.__tablename__, column, reverse=True)
-                m = Message(dict(dataset=self.__tablename__, row=row, column=converted_attr_name))
-                value = self.__getattribute__(column)
-                # if the entry is new, we can ignore null columns, otherwise consider it an update to None
-                if value is not None or not is_new:
-                    m.set_value(value)
-                    changes.append(m)
-        return changes
+                changed_attributes.append(column)
+        return changed_attributes
 
     def delete(self):
+        """Deletes the model, by setting the `tombstone` attribute to 1. It is only possible to hard delete
+        transactions by updating and re-uploading the downloaded budget."""
         if not hasattr(self, "tombstone"):
             raise AttributeError(f"Model {self.__class__.__name__} has no tombstone field and cannot be deleted.")
         setattr(self, "tombstone", 1)
@@ -166,7 +175,7 @@ class Accounts(BaseModel, table=True):
     mask: Optional[str] = Field(default=None, sa_column=Column("mask", Text))
     official_name: Optional[str] = Field(default=None, sa_column=Column("official_name", Text))
     subtype: Optional[str] = Field(default=None, sa_column=Column("subtype", Text))
-    bank: Optional[str] = Field(default=None, sa_column=Column("bank", Text))
+    bank_id: Optional[str] = Field(default=None, sa_column=Column("bank", Text, ForeignKey("banks.id")))
     offbudget: Optional[int] = Field(default=None, sa_column=Column("offbudget", Integer, server_default=text("0")))
     closed: Optional[int] = Field(default=None, sa_column=Column("closed", Integer, server_default=text("0")))
     tombstone: Optional[int] = Field(default=None, sa_column=Column("tombstone", Integer, server_default=text("0")))
@@ -183,6 +192,13 @@ class Accounts(BaseModel, table=True):
             )
         },
     )
+    bank: "Banks" = Relationship(
+        back_populates="account",
+        sa_relationship_kwargs={
+            "uselist": False,
+            "primaryjoin": "and_(Accounts.bank_id == Banks.id,Banks.tombstone == 0)",
+        },
+    )
 
     @property
     def balance(self) -> decimal.Decimal:
@@ -203,6 +219,8 @@ class Banks(BaseModel, table=True):
     name: Optional[str] = Field(default=None, sa_column=Column("name", Text))
     tombstone: Optional[int] = Field(default=None, sa_column=Column("tombstone", Integer, server_default=text("0")))
 
+    account: "Accounts" = Relationship(back_populates="bank")
+
 
 class Categories(BaseModel, table=True):
     hidden: bool = Field(sa_column=Column("hidden", Boolean, nullable=False, server_default=text("0")))
diff --git a/actual/queries.py b/actual/queries.py
index b83eb76..8c8b3cf 100644
--- a/actual/queries.py
+++ b/actual/queries.py
@@ -26,34 +26,18 @@ from actual.database import (
 )
 from actual.exceptions import ActualError
 from actual.rules import Action, Condition, Rule, RuleSet
+from actual.utils.title import title
 
 T = typing.TypeVar("T")
 
 
-def get_transactions(
+def _transactions_base_query(
     s: Session,
     start_date: datetime.date = None,
     end_date: datetime.date = None,
-    notes: str = None,
     account: Accounts | str | None = None,
-    is_parent: bool = False,
     include_deleted: bool = False,
-) -> typing.Sequence[Transactions]:
-    """
-    Returns a list of all available transactions.
-
-    :param s: session from Actual local database.
-    :param start_date: optional start date for the transaction period (inclusive)
-    :param end_date: optional end date for the transaction period (exclusive)
-    :param notes: optional notes filter for the transactions. This looks for a case-insensitive pattern rather than for
-    the exact match, i.e. 'foo' would match 'Foo Bar'.
-    :param account: optional account (either Account object or Account name) filter for the transactions.
-    :param is_parent: optional boolean flag to indicate if a transaction is a parent. Parent transactions are either
-    single transactions or the main transaction with `Transactions.splits` property. Default is to return all individual
-    splits, and the parent can be retrieved by `Transactions.parent`.
-    :param include_deleted: includes deleted transactions from the search.
-    :return: list of transactions with `account`, `category` and `payee` pre-loaded.
-    """
+) -> Select:
     query = (
         select(Transactions)
         .options(
@@ -64,7 +48,6 @@ def get_transactions(
         .filter(
             Transactions.date.isnot(None),
             Transactions.acct.isnot(None),
-            Transactions.is_parent == int(is_parent),
         )
         .order_by(
             Transactions.date.desc(),
@@ -83,11 +66,97 @@ def get_transactions(
         account = get_account(s, account)
         if account:
             query = query.filter(Transactions.acct == account.id)
+    return query
+
+
+def get_transactions(
+    s: Session,
+    start_date: datetime.date = None,
+    end_date: datetime.date = None,
+    notes: str = None,
+    account: Accounts | str | None = None,
+    is_parent: bool = False,
+    include_deleted: bool = False,
+) -> typing.Sequence[Transactions]:
+    """
+    Returns a list of all available transactions, sorted by date in descending order.
+
+    :param s: session from Actual local database.
+    :param start_date: optional start date for the transaction period (inclusive)
+    :param end_date: optional end date for the transaction period (exclusive)
+    :param notes: optional notes filter for the transactions. This looks for a case-insensitive pattern rather than for
+    the exact match, i.e. 'foo' would match 'Foo Bar'.
+    :param account: optional account (either Account object or Account name) filter for the transactions.
+    :param is_parent: optional boolean flag to indicate if a transaction is a parent. Parent transactions are either
+    single transactions or the main transaction with `Transactions.splits` property. Default is to return all individual
+    splits, and the parent can be retrieved by `Transactions.parent`.
+    :param include_deleted: includes deleted transactions from the search.
+    :return: list of transactions with `account`, `category` and `payee` preloaded.
+    """
+    query = _transactions_base_query(s, start_date, end_date, account, include_deleted)
+    query = query.filter(Transactions.is_parent == int(is_parent))
     if notes:
         query = query.filter(Transactions.notes.ilike(f"%{sqlalchemy.text(notes).compile()}%"))
     return s.exec(query).all()
 
 
+def match_transaction(
+    s: Session,
+    date: datetime.date,
+    account: str | Accounts,
+    payee: str | Payees = "",
+    amount: decimal.Decimal | float | int = 0,
+    imported_id: str | None = None,
+    already_matched: list[Transactions] = None,
+) -> typing.Optional[Transactions]:
+    """Matches a transaction with another transaction based on the fuzzy matching described at `reconcileTransactions`:
+
+    https://github.com/actualbudget/actual/blob/b192ad955ed222d9aa388fe36557b39868029db4/packages/loot-core/src/server/accounts/sync.ts#L347
+
+    The matches, from strongest to the weakest are defined as follows:
+
+    - The strongest match will be the imported_id (or financial_id)
+    - The transaction with the same exact amount and around the same date (7 days), with the same payee (closest first)
+    - The transaction with the same exact amount and around the same date (7 days, closest first)
+
+    """
+    # First, match with an existing transaction's imported_id
+    if imported_id:
+        query = _transactions_base_query(s, account=account)
+        imported_transaction = s.exec(query.filter(Transactions.financial_id == imported_id)).first()
+        if imported_transaction:
+            return imported_transaction  # noqa
+    # if not matched, look 7 days ahead and 7 days back when fuzzy matching
+    query = _transactions_base_query(
+        s, date - datetime.timedelta(days=7), date + datetime.timedelta(days=8), account=account
+    ).filter(Transactions.amount == amount * 100)
+    results: list[Transactions] = s.exec(query).all()  # noqa
+    # filter out the ones that were already matched
+    if already_matched:
+        matched = {t.id for t in already_matched}
+        results = [r for r in results if r.id not in matched]
+    if not results:
+        # nothing to be matched
+        return None
+    # sort the results by their distance to the original date
+    results.sort(key=lambda t: abs((t.get_date() - date).total_seconds()))
+    # Next, do the fuzzy matching. This first pass matches based on the
+    # payee id. We do this in multiple passes so that higher fidelity
+    # matching always happens first, i.e. a transaction should
+    # match with low fidelity if a later transaction is going to match
+    # the same one with high fidelity.
+    payee = get_payee(s, payee)
+    if payee:
+        matching_payee = [r for r in results if r.payee_id == payee.id]
+        if matching_payee:
+            return matching_payee[0]
+    # The final fuzzy matching pass. This is the lowest fidelity
+    # matching: it just find the first transaction that hasn't been
+    # matched yet. Remember the dataset only contains transactions
+    # around the same date with the same amount.
+    return results[0]
+
+
 def create_transaction_from_ids(
     s: Session,
     date: datetime.date,
@@ -96,6 +165,9 @@ def create_transaction_from_ids(
     notes: str,
     category_id: str = None,
     amount: decimal.Decimal = 0,
+    imported_id: str = None,
+    cleared: bool = False,
+    imported_payee: str = None,
 ) -> Transactions:
     """Internal method to generate a transaction from ids instead of objects."""
     date_int = int(datetime.date.strftime(date, "%Y%m%d"))
@@ -108,8 +180,10 @@ def create_transaction_from_ids(
         payee_id=payee_id,
         notes=notes,
         reconciled=0,
-        cleared=0,
-        sort_order=datetime.datetime.utcnow().timestamp(),
+        cleared=int(cleared),
+        sort_order=int(datetime.datetime.utcnow().timestamp()),
+        financial_id=imported_id,
+        imported_description=imported_payee,
     )
     s.add(t)
     return t
@@ -123,6 +197,9 @@ def create_transaction(
     notes: str = "",
     category: str | Categories | None = None,
     amount: decimal.Decimal | float | int = 0,
+    imported_id: str | None = None,
+    cleared: bool = False,
+    imported_payee: str = None,
 ) -> Transactions:
     """
     Creates a transaction from the provided input.
@@ -136,6 +213,12 @@ def create_transaction(
     :param category: optional category for the transaction. Will be created if not existing.
     :param amount: amount of the transaction. Positive indicates that the account balance will go up (deposit), and
     negative that the account balance will go down (payment)
+    :param imported_id: unique id of the imported transaction. This is often provided if the transaction comes from
+    a third-party system that contains unique ids (i.e. via bank sync).
+    :param cleared: visual indication that the transaction is in both your budget and in your account statement,
+    and they match.
+    :param imported_payee: known internally as imported_description, this is the original name of the payee, when
+    importing data and before running rules.
     :return: the generated transaction object.
     """
     acct = get_account(s, account)
@@ -146,7 +229,81 @@ def create_transaction(
         category_id = get_or_create_category(s, category).id
     else:
         category_id = None
-    return create_transaction_from_ids(s, date, acct.id, payee.id, notes, category_id, amount)
+    return create_transaction_from_ids(
+        s, date, acct.id, payee.id, notes, category_id, amount, imported_id, cleared, imported_payee
+    )
+
+
+def normalize_payee(payee_name: str | None, raw_payee_name: bool = False) -> str:
+    """
+    Normalizes the payees according to the source code found at:
+
+    https://github.com/actualbudget/actual/blob/f02ca4e3d26f5b91f4234317e024022fcae2c13c/packages/loot-core/src/server/accounts/sync.ts#L206-L214
+
+    This make sures that the payees are consistent across the imports, i.e. 'MY PAYEE ' turns into 'My Payee', but so
+    does 'My PaYeE'.
+
+    :param payee_name: the original payee name to be normalized.
+    :param raw_payee_name: if the original payee name should be used instead. If the payee provided consists of spaces
+    or an empty string, it will still be assigned to `None`.
+    :return:
+    """
+    if payee_name:
+        trimmed = payee_name.strip()
+        if raw_payee_name:
+            return trimmed
+        else:
+            return title(trimmed)
+    return ""
+
+
+def reconcile_transaction(
+    s: Session,
+    date: datetime.date,
+    account: str | Accounts,
+    payee: str | Payees = "",
+    notes: str = "",
+    category: str | Categories | None = None,
+    amount: decimal.Decimal | float | int = 0,
+    imported_id: str | None = None,
+    cleared: bool = False,
+    imported_payee: str = None,
+    update_existing: bool = True,
+    already_matched: list[Transactions] = None,
+) -> Transactions:
+    """Matches the transaction to an existing transaction using fuzzy matching.
+
+    :param s: session from Actual local database.
+    :param date: date of the transaction.
+    :param account: either account name or account object (via `get_account` or `get_accounts`). Will not be
+    auto-created if missing.
+    :param payee: name of the payee from the transaction. Will be created if missing.
+    :param notes: optional description for the transaction.
+    :param category: optional category for the transaction. Will be created if not existing.
+    :param amount: amount of the transaction. Positive indicates that the account balance will go up (deposit), and
+    negative that the account balance will go down (payment)
+    :param imported_id: unique id of the imported transaction. This is often provided if the transaction comes from
+    a third-party system that contains unique ids (i.e. via bank sync).
+    :param cleared: This is a visual indication that the transaction is in both your budget and in your account
+    statement, and they match.
+    :param imported_payee: known internally as imported_description, this is the original name of the payee, when
+    importing data and before running rules.
+    :param update_existing: if the transaction should be updated to the provided properties, if a match is found.
+    :param already_matched: list of the transactions that were already matched. When importing a list of transactions,
+    this would prevent transactions with the exact same (date, amount) to be assigned as duplicates.
+    :return: the generated or matched transaction object.
+    """
+    account = get_account(s, account)
+    match = match_transaction(s, date, account, payee, amount, imported_id, already_matched)
+    if match:
+        # try to update fields
+        if update_existing:
+            match.notes = notes
+            if category:
+                match.category = 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)
 
 
 def create_splits(
diff --git a/actual/utils/title.py b/actual/utils/title.py
new file mode 100644
index 0000000..c41f90c
--- /dev/null
+++ b/actual/utils/title.py
@@ -0,0 +1,199 @@
+import re
+
+conjunctions = [
+    "for",
+    "and",
+    "nor",
+    "but",
+    "or",
+    "yet",
+    "so",
+]
+
+articles = [
+    "a",
+    "an",
+    "the",
+]
+
+prepositions = [
+    "aboard",
+    "about",
+    "above",
+    "across",
+    "after",
+    "against",
+    "along",
+    "amid",
+    "among",
+    "anti",
+    "around",
+    "as",
+    "at",
+    "before",
+    "behind",
+    "below",
+    "beneath",
+    "beside",
+    "besides",
+    "between",
+    "beyond",
+    "but",
+    "by",
+    "concerning",
+    "considering",
+    "despite",
+    "down",
+    "during",
+    "except",
+    "excepting",
+    "excluding",
+    "following",
+    "for",
+    "from",
+    "in",
+    "inside",
+    "into",
+    "like",
+    "minus",
+    "near",
+    "of",
+    "off",
+    "on",
+    "onto",
+    "opposite",
+    "over",
+    "past",
+    "per",
+    "plus",
+    "regarding",
+    "round",
+    "save",
+    "since",
+    "than",
+    "through",
+    "to",
+    "toward",
+    "towards",
+    "under",
+    "underneath",
+    "unlike",
+    "until",
+    "up",
+    "upon",
+    "versus",
+    "via",
+    "with",
+    "within",
+    "without",
+]
+
+specials = [
+    "CLI",
+    "API",
+    "HTTP",
+    "HTTPS",
+    "JSX",
+    "DNS",
+    "URL",
+    "CI",
+    "CDN",
+    "GitHub",
+    "CSS",
+    "JS",
+    "JavaScript",
+    "TypeScript",
+    "HTML",
+    "WordPress",
+    "JavaScript",
+    "Next.js",
+    "Node.js",
+]
+
+lower_case_set = set(conjunctions + articles + prepositions)
+
+# I have no idea how/why someone came up with this, and at this point I'm too afraid to ask.
+# https://github.com/actualbudget/actual/blob/f02ca4e3d26f5b91f4234317e024022fcae2c13c/packages/loot-core/src/server/accounts/title/index.ts#L7
+character = (
+    "[0-9\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0"
+    "-\u02E4\u02EC\u02EE\u0370-\u0374\u0376-\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3"
+    "-\u03F5\u03F7-\u0481\u048A-\u0523\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0621-\u064A\u066E"
+    "-\u066F\u0671-\u06D3\u06D5\u06E5-\u06E6\u06EE-\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D"
+    "-\u07A5\u07B1\u07CA-\u07EA\u07F4-\u07F5\u07FA\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0972\u097B"
+    "-\u097F\u0985-\u098C\u098F-\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC-\u09DD\u09DF"
+    "-\u09E1\u09F0-\u09F1\u0A05-\u0A0A\u0A0F-\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32-\u0A33\u0A35-\u0A36\u0A38"
+    "-\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2-\u0AB3\u0AB5"
+    "-\u0AB9\u0ABD\u0AD0\u0AE0-\u0AE1\u0B05-\u0B0C\u0B0F-\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32-\u0B33\u0B35"
+    "-\u0B39\u0B3D\u0B5C-\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99"
+    "-\u0B9A\u0B9C\u0B9E-\u0B9F\u0BA3-\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12"
+    "-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58-\u0C59\u0C60-\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA"
+    "-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0-\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D3D\u0D60"
+    "-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32-\u0E33\u0E40"
+    "-\u0E46\u0E81-\u0E82\u0E84\u0E87-\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA"
+    "-\u0EAB\u0EAD-\u0EB0\u0EB2-\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDD\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88"
+    "-\u0F8B\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065-\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0"
+    "-\u10C5\u10D0-\u10FA\u10FC\u1100-\u1159\u115F-\u11A2\u11A8-\u11F9\u1200-\u1248\u124A-\u124D\u1250"
+    "-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2"
+    "-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F"
+    "-\u1676\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F0\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760"
+    "-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u1900-\u191C\u1950-\u196D\u1970"
+    "-\u1974\u1980-\u19A9\u19C1-\u19C7\u1A00-\u1A16\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE-\u1BAF\u1C00"
+    "-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50"
+    "-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0"
+    "-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u2094\u2102\u2107\u210A"
+    "-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2160"
+    "-\u2188\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2C6F\u2C71-\u2C7D\u2C80-\u2CE4\u2D00-\u2D25\u2D30-\u2D65\u2D6F\u2D80"
+    "-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8"
+    "-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC"
+    "-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31B7\u31F0-\u31FF\u3400\u4DB5\u4E00\u9FC3\uA000-\uA48C\uA500"
+    "-\uA60C\uA610-\uA61F\uA62A-\uA62B\uA640-\uA65F\uA662-\uA66E\uA67F-\uA697\uA717-\uA71F\uA722-\uA788\uA78B"
+    "-\uA78C\uA7FB-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA90A-\uA925\uA930"
+    "-\uA946\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAC00\uD7A3\uF900-\uFA2D\uFA30-\uFA6A\uFA70-\uFAD9\uFB00"
+    "-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40-\uFB41\uFB43-\uFB44\uFB46"
+    "-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41"
+    "-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]"
+)
+
+regex = re.compile(
+    rf'(?:(?:(\s?(?:^|[.\(\)!?;:"-])\s*)({character}))|({character}))({character}*[’\']*{character}*)', re.UNICODE
+)
+
+
+def convert_to_regexp(special_characters: list[str]):
+    return [(re.compile(rf"\b{s}\b", re.IGNORECASE), s) for s in special_characters]
+
+
+def parse_match(match: str):
+    first_character = match[0]
+    if re.match(r"\s", first_character):
+        return match[1:]
+    if re.match(r"[()]", first_character):
+        return None
+    return match
+
+
+def replace_func(m: re.Match):
+    lead, forced, lower, rest = m.groups()
+    parsed_match = parse_match(m.group(0))
+    if not parsed_match:
+        return m.group(0)
+    if not forced:
+        full_lower = (lower or "") + (rest or "")
+        if full_lower in lower_case_set:
+            return parsed_match
+    return (lead or "") + (lower or forced or "").upper() + (rest or "")
+
+
+def title(title_str: str, custom_specials: list[str] = None):
+    title_str = title_str.lower()
+    title_str = regex.sub(replace_func, title_str)
+
+    if not custom_specials:
+        custom_specials = []
+    replace = specials + custom_specials
+    replace_regexp = convert_to_regexp(replace)
+
+    for pattern, s in replace_regexp:
+        title_str = pattern.sub(s, title_str)
+
+    return title_str
diff --git a/codecov.yaml b/codecov.yaml
new file mode 100644
index 0000000..5077db3
--- /dev/null
+++ b/codecov.yaml
@@ -0,0 +1,5 @@
+coverage:
+  status:
+    project:
+      default:
+        threshold: 5%


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..81d627c
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+import json
+import tempfile
+
+import pytest
+from sqlmodel import Session, create_engine
+
+from actual.database import SQLModel
+
+
+class RequestsMock:
+    def __init__(self, json_data: dict | list, status_code: int = 200):
+        self.json_data = json_data
+        self.status_code = status_code
+        self.text = json.dumps(json_data)
+        self.content = json.dumps(json_data).encode("utf-8")
+
+    def json(self):
+        if isinstance(self.json_data, str):
+            return json.loads(self.json_data)
+        return self.json_data
+
+    def raise_for_status(self):
+        if self.status_code != 200:
+            raise ValueError
+
+
+@pytest.fixture
+def session():
+    with tempfile.NamedTemporaryFile() as f:
+        sqlite_url = f"sqlite:///{f.name}"
+        engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
+        SQLModel.metadata.create_all(engine)
+        with Session(engine) as session:
+            yield session
diff --git a/tests/test_bank_sync.py b/tests/test_bank_sync.py
new file mode 100644
index 0000000..f7dd24f
--- /dev/null
+++ b/tests/test_bank_sync.py
@@ -0,0 +1,142 @@
+import copy
+import datetime
+import decimal
+
+import pytest
+
+from actual import Actual
+from actual.database import Banks
+from actual.queries import create_account
+from tests.conftest import RequestsMock
+
+response = {
+    "iban": "DE123",
+    "balances": [
+        {
+            "balanceType": "expected",
+            "lastChangeDateTime": "2024-06-13T14:56:06.092039915Z",
+            "referenceDate": "2024-06-13",
+            "balanceAmount": {"amount": "0.00", "currency": "EUR"},
+        }
+    ],
+    "institutionId": "My Bank",
+    "startingBalance": 0,
+    "transactions": {
+        "all": [
+            {
+                "transactionId": "208584e9-343f-4831-8095-7b9f4a34a77e",
+                "bookingDate": "2024-06-13",
+                "valueDate": "2024-06-13",
+                "date": "2024-06-13",
+                "transactionAmount": {"amount": "9.26", "currency": "EUR"},
+                "debtorName": "John Doe",
+                "remittanceInformationUnstructured": "Transferring Money",
+            },
+            # some have creditor name
+            {
+                "transactionId": "a2c2fafe-334a-46a6-8d05-200c2e41397b",
+                "mandateId": "FOOBAR",
+                "creditorId": "FOOBAR",
+                "bookingDate": "2024-06-13",
+                "valueDate": "2024-06-13",
+                "date": "2024-06-13",
+                "transactionAmount": {"amount": "-7.77", "currency": "EUR"},
+                "creditorName": "Institution GmbH",
+                "creditorAccount": {"iban": "DE123456789"},
+                "remittanceInformationUnstructured": "Payment",
+                "remittanceInformationUnstructuredArray": ["Payment"],
+                "bankTransactionCode": "FOO-BAR",
+                "internalTransactionId": "6118268af4dc45039a7ca21b0fdcbe96",
+            },
+        ],
+        "booked": [],
+        "pending": [],
+    },
+}
+
+
+def create_accounts(session, protocol: str):
+    bank = create_account(session, "Bank")
+    create_account(session, "Not related")
+    bank.account_sync_source = protocol
+    bank.bank_id = bank.account_id = "foobar"
+    session.add(Banks(id="foobar", bank_id="foobar", name="test"))
+    session.commit()
+    return bank
+
+
+@pytest.fixture
+def set_mocks(mocker):
+    # call for validate
+    response_empty = copy.deepcopy(response)
+    response_empty["transactions"]["all"] = []
+    mocker.patch("requests.get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}})
+    main_mock = mocker.patch("requests.post")
+    main_mock.side_effect = [
+        RequestsMock({"status": "ok", "data": {"configured": True}}),
+        RequestsMock({"status": "ok", "data": response}),
+        RequestsMock({"status": "ok", "data": {"configured": True}}),  # in case it gets called again
+        RequestsMock({"status": "ok", "data": response_empty}),
+    ]
+    return main_mock
+
+
+def test_full_bank_sync_go_cardless(session, set_mocks):
+    with Actual(token="foo") as actual:
+        actual._session = session
+        create_accounts(session, "goCardless")
+
+        # now try to run the bank sync
+        imported_transactions = actual.run_bank_sync()
+        session.commit()
+        assert len(imported_transactions) == 2
+        assert imported_transactions[0].financial_id == "208584e9-343f-4831-8095-7b9f4a34a77e"
+        assert imported_transactions[0].get_date() == datetime.date(2024, 6, 13)
+        assert imported_transactions[0].get_amount() == decimal.Decimal("9.26")
+        assert imported_transactions[0].payee.name == "John Doe"
+        assert imported_transactions[0].notes == "Transferring Money"
+
+        assert imported_transactions[1].financial_id == "a2c2fafe-334a-46a6-8d05-200c2e41397b"
+        assert imported_transactions[1].get_date() == datetime.date(2024, 6, 13)
+        assert imported_transactions[1].get_amount() == decimal.Decimal("-7.77")
+        # the name of the payee was normalized (from GmbH to Gmbh) and the masked iban is included
+        assert imported_transactions[1].payee.name == "Institution Gmbh (DE12 XXX 6789)"
+        assert imported_transactions[1].notes == "Payment"
+
+        # the next call should do nothing
+        new_imported_transactions = actual.run_bank_sync()
+        assert new_imported_transactions == []
+        # assert that the call date is correctly set
+        assert set_mocks.call_args_list[3][1]["json"]["startDate"] == "2024-06-13"
+
+
+def test_full_bank_sync_go_simplefin(session, set_mocks):
+    with Actual(token="foo") as actual:
+        actual._session = session
+        create_accounts(session, "simplefin")
+
+        # now try to run the bank sync
+        imported_transactions = actual.run_bank_sync("Bank")
+        assert len(imported_transactions) == 2
+        assert imported_transactions[0].financial_id == "208584e9-343f-4831-8095-7b9f4a34a77e"
+        assert imported_transactions[0].get_date() == datetime.date(2024, 6, 13)
+        assert imported_transactions[0].get_amount() == decimal.Decimal("9.26")
+        assert imported_transactions[0].payee.name == "Transferring Money"  # simplefin uses the wrong field
+        assert imported_transactions[0].notes == "Transferring Money"
+
+        assert imported_transactions[1].financial_id == "a2c2fafe-334a-46a6-8d05-200c2e41397b"
+        assert imported_transactions[1].get_date() == datetime.date(2024, 6, 13)
+        assert imported_transactions[1].get_amount() == decimal.Decimal("-7.77")
+        assert imported_transactions[1].payee.name == "Payment"  # simplefin uses the wrong field
+        assert imported_transactions[1].notes == "Payment"
+
+
+def test_bank_sync_unconfigured(mocker, session):
+    mocker.patch("requests.get").return_value = RequestsMock({"status": "ok", "data": {"validated": True}})
+    main_mock = mocker.patch("requests.post")
+    main_mock.return_value = RequestsMock({"status": "ok", "data": {"configured": False}})
+
+    with Actual(token="foo") as actual:
+        actual._session = session
+        create_accounts(session, "simplefin")
+        assert actual.run_bank_sync() == []
diff --git a/tests/test_crypto.py b/tests/test_crypto.py
index bd0310b..47e71db 100644
--- a/tests/test_crypto.py
+++ b/tests/test_crypto.py
@@ -2,7 +2,7 @@ import base64
 
 import pytest
 
-from actual.api import EncryptMetaDTO
+from actual.api.models import EncryptMetaDTO
 from actual.crypto import (
     create_key_buffer,
     decrypt,
diff --git a/tests/test_database.py b/tests/test_database.py
index bad4131..75a7043 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -1,13 +1,10 @@
 import decimal
 import json
-import tempfile
 from datetime import date, timedelta
 
 import pytest
-from sqlmodel import Session, create_engine
 
 from actual import ActualError
-from actual.database import SQLModel
 from actual.queries import (
     create_account,
     create_rule,
@@ -19,20 +16,12 @@ from actual.queries import (
     get_or_create_payee,
     get_ruleset,
     get_transactions,
+    normalize_payee,
+    reconcile_transaction,
 )
 from actual.rules import Action, Condition, ConditionType, Rule
 
 
-@pytest.fixture
-def session():
-    with tempfile.NamedTemporaryFile() as f:
-        sqlite_url = f"sqlite:///{f.name}"
-        engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
-        SQLModel.metadata.create_all(engine)
-        with Session(engine) as session:
-            yield session
-
-
 def test_account_relationships(session):
     today = date.today()
     bank = create_account(session, "Bank", 5000)
@@ -73,6 +62,47 @@ def test_account_relationships(session):
     assert get_accounts(session, "Bank") == [bank]
 
 
+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"
+    )
+    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
+    # check if the property was updated
+    assert rent_payment.get_date() == today + timedelta(days=1)
+    # should still be able to match if the payee is defined, as the match is stronger
+    assert (
+        reconcile_transaction(
+            session, today - timedelta(days=5), payee="Landlord", account="Bank", amount=-1200, update_existing=False
+        ).id
+        == rent_payment.id
+    )
+    # should not be able to match without payee
+    assert reconcile_transaction(session, today - timedelta(days=5), account="Bank", amount=-1200).id == unrelated.id
+    # regardless of date, the match by unique id should work
+    assert (
+        reconcile_transaction(
+            session,
+            today - timedelta(days=30),
+            account="Bank",
+            amount=-1200,
+            imported_id="unique",
+            update_existing=False,
+        ).id
+        == rent_payment.id
+    )
+    # but if it's too far, it will be a new transaction
+    assert reconcile_transaction(session, today - timedelta(days=30), account="Bank", amount=-1200).id not in (
+        rent_payment.id,
+        unrelated.id,
+    )
+
+
 def test_create_splits(session):
     bank = create_account(session, "Bank")
     t = create_transaction(session, date.today(), bank, category="Dining", amount=-10.0)
@@ -131,3 +161,9 @@ def test_rule_insertion_method(session):
     rs = get_ruleset(session)
     assert len(rs.rules) == 1
     assert str(rs) == "If all of these conditions match 'date' isapprox '2024-01-02' then set 'cleared' to 'True'"
+
+
+def test_normalize_payee():
+    assert normalize_payee("   mY paYeE ") == "My Payee"
+    assert normalize_payee("  ", raw_payee_name=True) == ""
+    assert normalize_payee(" My PayeE ", raw_payee_name=True) == "My PayeE"