diff --git a/actual/__init__.py b/actual/__init__.py index a188967..7f0592a 100644 --- a/actual/__init__.py +++ b/actual/__init__.py @@ -362,8 +362,38 @@ def commit(self): # sync all changes to the server self.sync_sync(req) - def run_bank_sync(self, account: str | Accounts | None = None) -> list[Transactions]: - """Runs the bank synchronization for the selected account. If missing, all accounts are synchronized.""" + 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: + note = transaction.remittance_information_unstructured + payee = transaction.payee or "" if sync_method == "goCardless" else note + reconciled = reconcile_transaction( + self.session, + transaction.date, + acct, + payee, + note, + amount=transaction.transaction_amount.amount, + imported_id=transaction.transaction_id, + ) + 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) @@ -374,26 +404,17 @@ def run_bank_sync(self, account: str | Accounts | None = None) -> list[Transacti for acct in accounts: sync_method = acct.account_sync_source account_id = acct.account_id - if account_id and sync_method: - status = self.bank_sync_status(sync_method.lower()) - if status.data.configured: - all_transactions = get_transactions(self.session, account=acct) - if all_transactions: - start_date = all_transactions[0].get_date() - else: - start_date = datetime.date.today() - datetime.timedelta(days=90) - new_transactions_data = self.bank_sync_transactions(sync_method.lower(), account_id, start_date) - new_transactions = new_transactions_data.data.transactions.all - for transaction in new_transactions: - note = transaction.remittance_information_unstructured - reconciled = reconcile_transaction( - self.session, - transaction.date, - acct, - note, - note, - amount=transaction.transaction_amount.amount, - imported_id=transaction.transaction_id, - ) - imported_transactions.append(reconciled) + 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() + elif start_date is None: + default_start_date = datetime.date.today() - datetime.timedelta(days=90) + transactions = self._run_bank_sync_account(acct, start_date or default_start_date) + imported_transactions.extend(transactions) return imported_transactions diff --git a/actual/api/__init__.py b/actual/api/__init__.py index edb37c6..b98a8cb 100644 --- a/actual/api/__init__.py +++ b/actual/api/__init__.py @@ -22,7 +22,11 @@ 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 @@ -230,12 +234,17 @@ def bank_sync_accounts(self, bank_sync: Literal["gocardless", "simplefin"]) -> B return BankSyncAccountResponseDTO.model_validate(response.json()) def bank_sync_transactions( - self, bank_sync: Literal["gocardless", "simplefin"] | str, account_id: str, start_date: datetime.date + 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) - response = requests.post( - f"{self.api_url}/{endpoint}", - headers=self.headers(), - json={"accountId": account_id, "startDate": start_date.strftime("%Y-%m-%d")}, - ) + 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 index 243c565..818bfef 100644 --- a/actual/api/bank_sync.py +++ b/actual/api/bank_sync.py @@ -5,7 +5,7 @@ import enum from typing import List, Optional -from pydantic import BaseModel, Field +from pydantic import AliasChoices, BaseModel, Field class BankSyncTransactionDTO(BaseModel): @@ -58,20 +58,20 @@ class Balance(BaseModel): class TransactionItem(BaseModel): - booked: bool - booking_date: str = Field(..., alias="bookingDate") - date: datetime.date - debtor_name: str = Field(..., alias="debtorName") - remittance_information_unstructured: str = Field(..., alias="remittanceInformationUnstructured") - transaction_amount: BankSyncAmount = Field(..., alias="transactionAmount") transaction_id: str = Field(..., alias="transactionId") + booking_date: str = Field(..., alias="bookingDate") 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")) + date: datetime.date + remittance_information_unstructured: str = Field(None, alias="remittanceInformationUnstructured") class Transactions(BaseModel): all: List[TransactionItem] booked: List[TransactionItem] - pending: List + pending: List[TransactionItem] class BankSyncAccountData(BaseModel): @@ -82,3 +82,6 @@ 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/database.py b/actual/database.py index bdafd73..c23e22d 100644 --- a/actual/database.py +++ b/actual/database.py @@ -166,7 +166,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 +183,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 +210,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 a9f24ab..2bb729c 100644 --- a/actual/queries.py +++ b/actual/queries.py @@ -249,9 +249,10 @@ def reconcile_transaction( # try to update fields match.acct = account.id match.notes = notes - match.category = get_or_create_category(s, category).id - match.date = date - return match[0] + 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)