Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add a way to allow to get budgets information #61

Merged
merged 3 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions actual/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ def before_flush(sess, flush_context, instances):
@event.listens_for(session, "after_commit")
@event.listens_for(session, "after_soft_rollback")
def after_commit_or_rollback(
sess, previous_transaction=None # noqa: previous_transaction needed for soft rollback
sess,
previous_transaction=None, # noqa: previous_transaction needed for soft rollback
):
if sess.info.get("messages"):
del sess.info["messages"]
Expand Down Expand Up @@ -272,6 +273,12 @@ class Categories(BaseModel, table=True):
tombstone: Optional[int] = Field(default=None, sa_column=Column("tombstone", Integer, server_default=text("0")))
goal_def: Optional[str] = Field(default=None, sa_column=Column("goal_def", Text, server_default=text("null")))

zero_budgets: "ZeroBudgets" = Relationship(
back_populates="category",
sa_relationship_kwargs={
"primaryjoin": "and_(ZeroBudgets.category_id == Categories.id)",
},
)
transactions: List["Transactions"] = Relationship(
back_populates="category",
sa_relationship_kwargs={
Expand Down Expand Up @@ -618,17 +625,37 @@ class ZeroBudgetMonths(SQLModel, table=True):
buffered: Optional[int] = Field(default=None, sa_column=Column("buffered", Integer, server_default=text("0")))


class ZeroBudgets(SQLModel, table=True):
class ZeroBudgets(BaseModel, table=True):
__tablename__ = "zero_budgets"

bvanelli marked this conversation as resolved.
Show resolved Hide resolved
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: Optional[str] = Field(default=None, sa_column=Column("category", Text))
category_id: Optional[str] = Field(default=None, sa_column=Column("category", ForeignKey("categories.id")))
amount: Optional[int] = Field(default=None, sa_column=Column("amount", Integer, server_default=text("0")))
carryover: Optional[int] = Field(default=None, sa_column=Column("carryover", Integer, server_default=text("0")))
goal: Optional[int] = Field(default=None, sa_column=Column("goal", Integer, server_default=text("null")))
long_goal: Optional[int] = Field(default=None, sa_column=Column("long_goal", Integer, server_default=text("null")))

category: "Categories" = Relationship(
back_populates="zero_budgets",
sa_relationship_kwargs={
"uselist": False,
"primaryjoin": "and_(ZeroBudgets.category_id == Categories.id, Categories.tombstone == 0)",
},
)

def get_date(self) -> datetime.date:
return datetime.datetime.strptime(str(self.month), "%Y%m").date()

def set_date(self, date: datetime.date):
self.month = int(datetime.date.strftime(date, "%Y%m"))

def set_amount(self, amount: Union[decimal.Decimal, int, float]):
self.amount = int(round(amount * 100))

def get_amount(self) -> decimal.Decimal:
return decimal.Decimal(self.amount) / decimal.Decimal(100)


class PendingTransactions(SQLModel, table=True):
__tablename__ = "pending_transactions"
Expand Down
72 changes: 69 additions & 3 deletions actual/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
Rules,
Schedules,
Transactions,
ZeroBudgets,
)
from actual.exceptions import ActualError
from actual.rules import Action, Condition, Rule, RuleSet
Expand Down Expand Up @@ -109,9 +110,9 @@ def match_transaction(
imported_id: str | None = None,
already_matched: typing.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
"""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:

Expand Down Expand Up @@ -550,6 +551,71 @@ def get_or_create_account(s: Session, name: str | Accounts) -> Accounts:
return account


def get_budgets(
s: Session, month: datetime.date = None, category: str | Categories = None
) -> typing.Sequence[ZeroBudgets]:
"""
Returns a list of all available budgets.

:param s: session from Actual local database.
:param month: month to get budgets for, as a date for that month. Use `datetime.date.today()` if you want the budget
for current month
:param category: category to filter for the budget. By default, the query looks for all budgets.
:return: list of budgets
"""
query = select(ZeroBudgets).options(joinedload(ZeroBudgets.category))
if month:
month_filter = int(datetime.date.strftime(month, "%Y%m"))
query = query.filter(ZeroBudgets.month == month_filter)
if category:
category = get_category(s, category)
if not category:
raise ActualError("Category is provided but does not exist.")
query = query.filter(ZeroBudgets.category_id == category.id)
return s.exec(query).unique().all()


def get_budget(s: Session, month: datetime.date, category: str | Categories) -> typing.Optional[ZeroBudgets]:
"""
Gets an existing budget by category name, returns `None` if not found.

:param s: session from Actual local database.
:param month: month to get budgets for, as a date for that month. Use `datetime.date.today()` if you want the budget
for current month.
:param category: category to filter for the budget.
:return: return budget matching the month and category. If not found, returns `None`.
"""
budgets = get_budgets(s, month, category)
return budgets[0] if budgets else None


def create_budget(
s: Session, month: datetime.date, category: str | Categories, amount: decimal.Decimal | float | int = 0.0
) -> ZeroBudgets:
"""
Gets an existing budget based on the month and category. If it already exists, the amount will be replaced by
the new amount.

:param s: session from Actual local database.
:param month: month to get budgets for, as a date for that month. Use `datetime.date.today()` if you want the budget
for current month.
:param category: category to filter for the budget.
:param amount: amount for the budget.
:return: return budget matching the month and category, and assigns the amount to the budget. If not found, creates
a new budget.
"""
budget = get_budget(s, month, category)
if budget:
budget.set_amount(amount)
return budget
category = get_category(s, category)
budget = ZeroBudgets(id=str(uuid.uuid4()), category_id=category.id)
budget.set_date(month)
budget.set_amount(amount)
s.add(budget)
return budget


def create_transfer(
s: Session,
date: datetime.date,
Expand Down
22 changes: 22 additions & 0 deletions tests/test_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
from actual.database import Notes
from actual.queries import (
create_account,
create_budget,
create_rule,
create_splits,
create_transaction,
create_transfer,
get_accounts,
get_budgets,
get_or_create_category,
get_or_create_payee,
get_ruleset,
Expand Down Expand Up @@ -181,6 +183,26 @@ def test_rule_insertion_method(session):
assert str(rs) == "If all of these conditions match 'date' isapprox '2024-01-02' then set 'cleared' to 'True'"


def test_budgets(session):
# insert a budget
category = get_or_create_category(session, "Expenses")
session.commit()
create_budget(session, date(2024, 10, 7), category, 10.0)
assert len(get_budgets(session)) == 1
assert len(get_budgets(session, date(2024, 10, 1))) == 1
assert len(get_budgets(session, date(2024, 10, 1), category)) == 1
assert len(get_budgets(session, date(2024, 9, 1))) == 0
budget = get_budgets(session)[0]
assert budget.get_amount() == 10.0
assert budget.get_date() == date(2024, 10, 1)
# get a budget that already exists, but re-set it
create_budget(session, date(2024, 10, 7), category, 20.0)
assert budget.get_amount() == 20.0
# test if it fails if category does not exist
with pytest.raises(ActualError, match="Category is provided but does not exist"):
get_budgets(session, category="foo")


def test_normalize_payee():
assert normalize_payee(" mY paYeE ") == "My Payee"
assert normalize_payee(" ", raw_payee_name=True) == ""
Expand Down
Loading