Skip to content

Commit

Permalink
docs: Improve documentation for database objects (#109)
Browse files Browse the repository at this point in the history
Improves documentation on many fields, including:

- Explain that bold transactions cannot be achieved via this API
- Add a docstring to most useful database tables
- Document table methods so that they appear on the documentation
- Add one basic example using relationships and properties, using the budget as an example

Closes #105
Closes #107
  • Loading branch information
bvanelli authored Jan 26, 2025
1 parent b3df262 commit 8ded923
Show file tree
Hide file tree
Showing 3 changed files with 138 additions and 6 deletions.
88 changes: 84 additions & 4 deletions actual/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
and patch the necessary models by merging the results. The [BaseModel][actual.database.BaseModel] defines all models
that can be updated from the user, and must contain a unique `id`. Those models can then be converted automatically
into a protobuf change message using [BaseModel.convert][actual.database.BaseModel.convert].
It is preferred to create database entries using the [queries][actual.queries], rather than using the raw database
model.
"""

import datetime
Expand Down Expand Up @@ -217,6 +220,7 @@ class Accounts(BaseModel, table=True):
id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True))
account_id: Optional[str] = Field(default=None, sa_column=Column("account_id", Text))
name: Optional[str] = Field(default=None, sa_column=Column("name", Text))
# Careful when using those balance fields, are they might be empty. Use account.balance property instead
balance_current: Optional[int] = Field(default=None, sa_column=Column("balance_current", Integer))
balance_available: Optional[int] = Field(default=None, sa_column=Column("balance_available", Integer))
balance_limit: Optional[int] = Field(default=None, sa_column=Column("balance_limit", Integer))
Expand Down Expand Up @@ -276,6 +280,12 @@ class Banks(BaseModel, table=True):


class Categories(BaseModel, table=True):
"""
Stores the category list, which is the classification applied on top of the transaction.
Each category will belong to its own category group.
"""

hidden: bool = Field(sa_column=Column("hidden", Boolean, nullable=False, server_default=text("0")))
id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True))
name: Optional[str] = Field(default=None, sa_column=Column("name", Text))
Expand Down Expand Up @@ -329,6 +339,10 @@ def balance(self) -> decimal.Decimal:


class CategoryGroups(BaseModel, table=True):
"""
Stores the groups that the categories can belong to.
"""

__tablename__ = "category_groups"

hidden: bool = Field(sa_column=Column("hidden", Boolean, nullable=False, server_default=text("0")))
Expand Down Expand Up @@ -461,6 +475,8 @@ class MessagesCrdt(SQLModel, table=True):


class Notes(BaseModel, table=True):
"""Stores the description of each account."""

id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True))
note: Optional[str] = Field(default=None, sa_column=Column("note", Text))

Expand All @@ -473,6 +489,14 @@ class PayeeMapping(BaseModel, table=True):


class Payees(BaseModel, table=True):
"""
Stores the individual payees.
Each payee is a unique identifier that can be assigned to a transaction. Certain payees have empty names and are
associated to the accounts themselves, representing the transfer between one account and another. These would
have the field `account` not set to `None`.
"""

id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True))
name: Optional[str] = Field(default=None, sa_column=Column("name", Text))
category: Optional[str] = Field(default=None, sa_column=Column("category", Text))
Expand Down Expand Up @@ -502,11 +526,20 @@ def balance(self) -> decimal.Decimal:


class Preferences(BaseModel, table=True):
"""Stores the preferences for the user, using key/value pairs."""

id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True))
value: Optional[str] = Field(default=None, sa_column=Column("value", Text))


class Rules(BaseModel, table=True):
"""
Stores all rules on the budget. The conditions and actions are stored separately using the JSON format.
The conditions are stored as a text field, but can be retrieved as a model using
[get_ruleset][actual.queries.get_ruleset].
"""

id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True))
stage: Optional[str] = Field(default=None, sa_column=Column("stage", Text))
conditions: Optional[str] = Field(default=None, sa_column=Column("conditions", Text))
Expand All @@ -519,6 +552,8 @@ class Rules(BaseModel, table=True):


class Schedules(SQLModel, table=True):
"""Stores the schedules defined by the user. Is also linked to a rule that executes it."""

id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True))
rule_id: Optional[str] = Field(default=None, sa_column=Column("rule", Text, ForeignKey("rules.id")))
active: Optional[int] = Field(default=None, sa_column=Column("active", Integer, server_default=text("0")))
Expand Down Expand Up @@ -570,6 +605,10 @@ class TransactionFilters(BaseModel, table=True):


class Transactions(BaseModel, table=True):
"""
Contains all transactions inserted into Actual.
"""

__table_args__ = (
Index("trans_category", "category"),
Index("trans_category_date", "category", "date"),
Expand Down Expand Up @@ -634,15 +673,19 @@ class Transactions(BaseModel, table=True):
)

def get_date(self) -> datetime.date:
"""Returns the transaction date as a datetime.date object, instead of as a string."""
return datetime.datetime.strptime(str(self.date), "%Y%m%d").date()

def set_date(self, date: datetime.date):
"""Sets the transaction date as a datetime.date object, instead of as a string."""
self.date = int(datetime.date.strftime(date, "%Y%m%d"))

def set_amount(self, amount: Union[decimal.Decimal, int, float]):
"""Sets the amount as a decimal.Decimal object, instead of as an integer representing the number of cents."""
self.amount = int(round(amount * 100))

def get_amount(self) -> decimal.Decimal:
"""Returns the amount as a decimal.Decimal, instead of as an integer representing the number of cents."""
return decimal.Decimal(self.amount) / decimal.Decimal(100)


Expand All @@ -654,36 +697,59 @@ class ZeroBudgetMonths(SQLModel, table=True):


class BaseBudgets(BaseModel):
"""
Hosts the shared code between both [ZeroBudgets][actual.database.ZeroBudgets] and
[ReflectBudgets][actual.database.ReflectBudgets].
The budget will represent a certain month in a certain category.
"""

id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True))
month: Optional[int] = Field(default=None, sa_column=Column("month", Integer))
category_id: Optional[str] = Field(default=None, sa_column=Column("category", Text))
amount: Optional[int] = Field(default=None, sa_column=Column("amount", Integer, server_default=text("0")))

def get_date(self) -> datetime.date:
"""Returns the transaction date as a datetime.date object, instead of as a string."""
return datetime.datetime.strptime(str(self.month), "%Y%m").date()

def set_date(self, date: datetime.date):
"""
Sets the transaction date as a datetime.date object, instead of as a string.
If the date value contains a day, it will be truncated and only the month and year will be inserted, as the
budget applies to a month.
"""
self.month = int(datetime.date.strftime(date, "%Y%m"))

def set_amount(self, amount: Union[decimal.Decimal, int, float]):
"""Sets the amount as a decimal.Decimal object, instead of as an integer representing the number of cents."""
self.amount = int(round(amount * 100))

def get_amount(self) -> decimal.Decimal:
"""Returns the amount as a decimal.Decimal, instead of as an integer representing the number of cents."""
return decimal.Decimal(self.amount) / decimal.Decimal(100)

@property
def range(self) -> Tuple[datetime.date, datetime.date]:
"""Range of the budget as a tuple [start, end). The end date is not inclusive, as it represents the start of the
next month."""
"""
Range of the budget as a tuple [start, end).
The end date is not inclusive, as it represents the start of the next month.
"""
budget_start = self.get_date().replace(day=1)
# conversion taken from https://stackoverflow.com/a/59199379/12681470
budget_end = (budget_start + datetime.timedelta(days=32)).replace(day=1)
return budget_start, budget_end

@property
def balance(self) -> decimal.Decimal:
"""Returns the current balance of the budget. The evaluation will take into account the budget month and
only selected transactions for the combination month and category. Deleted transactions are ignored."""
"""
Returns the current balance of the budget.
The evaluation will take into account the budget month and only selected transactions for the combination month
and category. Deleted transactions are ignored.
"""
budget_start, budget_end = (int(datetime.date.strftime(d, "%Y%m%d")) for d in self.range)
value = object_session(self).scalar(
select(func.coalesce(func.sum(Transactions.amount), 0)).where(
Expand All @@ -698,6 +764,13 @@ def balance(self) -> decimal.Decimal:


class ReflectBudgets(BaseBudgets, table=True):
"""
Stores the budgets, when using tracking budget.
This table will only contain data for the entries which are created. If a combination of category and budget month
is not existing, it is assumed that the budget is 0.
"""

__tablename__ = "reflect_budgets"

id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True))
Expand All @@ -718,6 +791,13 @@ class ReflectBudgets(BaseBudgets, table=True):


class ZeroBudgets(BaseBudgets, table=True):
"""
Stores the budgets, when using envelope budget (default).
This table will only contain data for the entries which are created. If a combination of category and budget month
is not existing, it is assumed that the budget is 0.
"""

__tablename__ = "zero_budgets"

id: Optional[str] = Field(default=None, sa_column=Column("id", Text, primary_key=True))
Expand Down
11 changes: 11 additions & 0 deletions docs/FAQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# FAQ

## Can the added transactions have the bold effect of new transactions similar to the frontend?

No, unfortunately this effect, that appears when you import transactions via CSV, is only stored in memory and applied
via frontend. This means that the

If you want to import transactions to later confirm them by hand, you can do it using the cleared flag instead.
This would show the green box on the right side of the screen on/off.

Read more about the cleared flag [here](https://actualbudget.org/docs/accounts/reconciliation/#work-flow).
45 changes: 43 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,46 @@
# Quickstart

## Using relationships and properties

The SQLAlchemy model already contains relationships to the referenced foreign keys and some properties. For example,
it's pretty simple to get the current balances for both accounts, payees and budgets:

```python
from actual import Actual
from actual.queries import get_accounts, get_payees, get_budgets

with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
# Print each account balance, for the entire dataset
for account in get_accounts(actual.session):
print(f"Balance for account {account.name} is {account.balance}")
# Print each payee balance, for the entire dataset
for payee in get_payees(actual.session):
print(f"Balance for payee {payee.name} is {payee.balance}")
# Print the leftover budget balance, for each category and the current month
for budget in get_budgets(actual.session):
print(f"Balance for budget {budget.category.name} is {budget.balance}")
```

You can quickly iterate over the transactions of one specific account:

```python
from actual import Actual
from actual.queries import get_account

with Actual(base_url="http://localhost:5006", password="mypass", file="My budget") as actual:
account = get_account(actual.session, "Bank name")
for transaction in account.transactions:
# Get the payee, notes and amount of each transaction
print(f"Transaction ({transaction.payee.name}, {transaction.notes}) has a value of {transaction.get_amount()}")
```

## 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).
using the [`create_transaction`][actual.queries.create_transaction] method, and commit it using
[`actual.commit`][actual.Actual.commit]. 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:
Expand Down Expand Up @@ -52,6 +88,11 @@ with Actual(base_url="http://localhost:5006", password="mypass", file="My budget

```

When working with transactions, is importing to keep in mind that the value amounts are set with floating number,
but the value stored on the database will be an integer (number of cents) instead. So instead of updating a
transaction with [Transactions.amount][actual.database.Transactions], use the
[Transactions.set_amount][actual.database.Transactions.set_amount] instead.

!!! warning
You can also modify the relationships, for example the `transaction.payee.name`, but you to be aware that
this payee might be used for more than one transaction. Whenever the relationship is anything but 1:1, you have to
Expand Down

0 comments on commit 8ded923

Please sign in to comment.