Added the income and expenses.

This commit is contained in:
2023-03-05 22:10:30 +08:00
parent 39723b1299
commit 39807ef480
8 changed files with 609 additions and 3 deletions

@ -139,3 +139,26 @@ class LedgerPeriodChooser(PeriodChooser):
return url_for("accounting.report.ledger",
currency=self.currency, account=self.account,
period=period)
class IncomeExpensesPeriodChooser(PeriodChooser):
"""The income-expenses period chooser."""
def __init__(self, currency: Currency, account: Account):
"""Constructs the income-expenses period chooser."""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
super(IncomeExpensesPeriodChooser, self).__init__(
None if first is None else first.date)
def _url_for(self, period: Period) -> str:
if period.is_default:
return url_for("accounting.report.income-expenses-default",
currency=self.currency, account=self.account)
return url_for("accounting.report.income-expenses",
currency=self.currency, account=self.account,
period=period)

@ -66,6 +66,7 @@ class ReportChooser:
"""The title of the current report."""
self.__reports.append(self.__journal)
self.__reports.append(self.__ledger)
self.__reports.append(self.__income_expenses)
for report in self.__reports:
if report.is_active:
self.current_report = report.title
@ -97,6 +98,21 @@ class ReportChooser:
return OptionLink(gettext("Ledger"), url,
self.__active_report == ReportType.LEDGER)
@property
def __income_expenses(self) -> OptionLink:
"""Returns the income and expenses.
:return: The income and expenses.
"""
url: str = url_for("accounting.report.income-expenses-default",
currency=self.__currency, account=self.__account) \
if self.__period.is_default \
else url_for("accounting.report.income-expenses",
currency=self.__currency, account=self.__account,
period=self.__period)
return OptionLink(gettext("Income and Expenses"), url,
self.__active_report == ReportType.INCOME_EXPENSES)
def __iter__(self) -> t.Iterator[OptionLink]:
"""Returns the iteration of the reports.

@ -117,3 +117,55 @@ class LedgerRow(ReportRow):
"Credit": self.credit,
"Balance": self.balance,
"Note": self.note}
class IncomeExpensesRow(ReportRow):
"""A row in the income and expenses."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the row in the income and expenses.
:param entry: The journal entry.
"""
self.entry: JournalEntry | None = None
"""The journal entry."""
self.transaction: Transaction | None = None
"""The transaction."""
self.is_total: bool = False
"""Whether this is the total row."""
self.date: date | None = None
"""The date."""
self.account: Account | None = None
"""The date."""
self.summary: str | None = None
"""The summary."""
self.income: Decimal | None = None
"""The income amount."""
self.expense: Decimal | None = None
"""The expense amount."""
self.balance: Decimal | None = None
"""The balance."""
self.note: str | None = None
"""The note."""
if entry is not None:
self.entry = entry
self.summary = entry.summary
self.income = None if entry.is_debit else entry.amount
self.expense = entry.amount if entry.is_debit else None
def as_dict(self) -> dict[str, t.Any]:
if self.is_total:
return {"Date": "Total",
"Account": None,
"Summary": None,
"Income": self.income,
"Expense": self.expense,
"Balance": self.balance,
"Note": None}
return {"Date": self.date,
"Account": str(self.account),
"Summary": self.summary,
"Income": self.income,
"Expense": self.expense,
"Balance": self.balance,
"Note": self.note}

@ -26,3 +26,5 @@ class ReportType(Enum):
"""The journal."""
LEDGER: str = "ledger"
"""The ledger."""
INCOME_EXPENSES: str = "income-expenses"
"""The income and expenses."""

@ -36,9 +36,9 @@ from accounting.utils.txn_types import TransactionType
from .option_link import OptionLink
from .period import Period
from .period_choosers import PeriodChooser, JournalPeriodChooser, \
LedgerPeriodChooser
LedgerPeriodChooser, IncomeExpensesPeriodChooser
from .report_chooser import ReportChooser
from .report_rows import ReportRow, JournalRow, LedgerRow
from .report_rows import ReportRow, JournalRow, LedgerRow, IncomeExpensesRow
from .report_type import ReportType
T = t.TypeVar("T", bound=ReportRow)
@ -417,3 +417,214 @@ class Ledger(JournalEntryReport[LedgerRow]):
return [OptionLink(str(x), get_url(x), x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no).all()]
class IncomeExpenses(JournalEntryReport[IncomeExpensesRow]):
"""The income and expenses."""
def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs an income and expenses.
:param currency: The currency.
:param account: The account.
:param period: The period.
"""
super().__init__(period)
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.total_row: IncomeExpensesRow | None = None
"""The total row to show on the template."""
def get_rows(self) -> list[IncomeExpensesRow]:
brought_forward: IncomeExpensesRow | None \
= self.__get_brought_forward_row()
rows: list[IncomeExpensesRow] \
= [IncomeExpensesRow(x) for x in self.__query_entries()]
total: IncomeExpensesRow = self.__get_total_row(brought_forward, rows)
self.__populate_balance(brought_forward, rows)
if brought_forward is not None:
rows.insert(0, brought_forward)
rows.append(total)
return rows
def __get_brought_forward_row(self) -> IncomeExpensesRow | None:
"""Queries, composes and returns the brought-forward row.
:return: The brought-forward row, or None if the income-expenses starts
from the beginning.
"""
if self.period.start is None:
return None
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount))
select: sa.Select = sa.Select(balance_func).join(Transaction)\
.filter(JournalEntry.currency_code == self.currency.code,
JournalEntry.account_id == self.account.id,
Transaction.date < self.period.start)
balance: int | None = db.session.scalar(select)
if balance is None:
return None
row: IncomeExpensesRow = IncomeExpensesRow()
row.date = self.period.start
row.summary = gettext("Brought forward")
if balance > 0:
row.income = balance
elif balance < 0:
row.expense = -balance
row.balance = balance
return row
def __query_entries(self) -> list[JournalEntry]:
"""Queries and returns the journal entries.
:return: The journal entries.
"""
conditions1: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.currency.code,
JournalEntry.account_id == self.account.id]
if self.period.start is not None:
conditions1.append(Transaction.date >= self.period.start)
if self.period.end is not None:
conditions1.append(Transaction.date <= self.period.end)
select1: sa.Select = sa.Select(Transaction.id).join(JournalEntry)\
.filter(*conditions1)
conditions: list[sa.BinaryExpression] \
= [JournalEntry.transaction_id.in_(select1),
JournalEntry.currency_code == self.currency.code,
JournalEntry.account_id != self.account.id]
return JournalEntry.query.join(Transaction).filter(*conditions)\
.order_by(Transaction.date,
sa.desc(JournalEntry.is_debit),
JournalEntry.no)
@staticmethod
def __get_total_row(brought_forward: IncomeExpensesRow | None,
rows: list[IncomeExpensesRow]) -> IncomeExpensesRow:
"""Composes the total row.
:param brought_forward: The brought-forward row.
:param rows: The rows.
:return: None.
"""
row: IncomeExpensesRow = IncomeExpensesRow()
row.is_total = True
row.summary = gettext("Total")
row.income = sum([x.income for x in rows if x.income is not None])
row.expense = sum([x.expense for x in rows if x.expense is not None])
row.balance = row.income - row.expense
if brought_forward is not None:
row.balance = brought_forward.balance + row.balance
return row
@staticmethod
def __populate_balance(brought_forward: IncomeExpensesRow | None,
rows: list[IncomeExpensesRow]) -> None:
"""Populates the balance of the rows.
:param brought_forward: The brought-forward row.
:param rows: The rows.
:return: None.
"""
balance: Decimal = 0 if brought_forward is None \
else brought_forward.balance
for row in rows:
if row.income is not None:
balance = balance + row.income
if row.expense is not None:
balance = balance - row.expense
row.balance = balance
def populate_rows(self, rows: list[IncomeExpensesRow]) -> None:
transactions: dict[int, Transaction] \
= {x.id: x for x in Transaction.query.filter(
Transaction.id.in_({x.entry.transaction_id for x in rows
if x.entry is not None}))}
accounts: dict[int, Account] \
= {x.id: x for x in Account.query.filter(
Account.id.in_({x.entry.account_id for x in rows
if x.entry is not None}))}
for row in rows:
if row.entry is not None:
row.transaction = transactions[row.entry.transaction_id]
row.date = row.transaction.date
row.note = row.transaction.note
row.account = accounts[row.entry.account_id]
@property
def csv_field_names(self) -> list[str]:
return ["Date", "Account", "Summary", "Income", "Expense", "Balance",
"Note"]
@property
def csv_filename(self) -> str:
return "income-expenses-{currency}-{account}-{period}.csv".format(
currency=self.currency.code, account=self.account.code,
period=self.period.spec)
@property
def period_chooser(self) -> PeriodChooser:
return IncomeExpensesPeriodChooser(self.currency, self.account)
@property
def report_chooser(self) -> ReportChooser:
return ReportChooser(ReportType.INCOME_EXPENSES,
currency=self.currency,
account=self.account,
period=self.period)
def as_html_page(self) -> str:
pagination: Pagination = Pagination[IncomeExpensesRow](self.rows)
rows: list[IncomeExpensesRow] = pagination.list
self.populate_rows(rows)
if len(rows) > 0 and rows[-1].is_total:
self.total_row = rows[-1]
rows = rows[:-1]
return render_template("accounting/report/income-expenses.html",
list=rows, pagination=pagination, report=self)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
def get_url(currency: Currency):
if self.period.is_default:
return url_for("accounting.report.income-expenses-default",
currency=currency, account=self.account)
return url_for("accounting.report.income-expenses",
currency=currency, account=self.account,
period=self.period)
in_use: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.group_by(JournalEntry.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == self.currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()]
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
def get_url(account: Account):
if self.period.is_default:
return url_for("accounting.report.income-expenses-default",
currency=self.currency, account=account)
return url_for("accounting.report.income-expenses",
currency=self.currency, account=account,
period=self.period)
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(JournalEntry.currency_code == self.currency.code)
.group_by(JournalEntry.account_id)).all())
return [OptionLink(str(x), get_url(x), x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no).all()]

@ -22,7 +22,7 @@ from flask import Blueprint, request, Response
from accounting.models import Currency, Account
from accounting.utils.permission import has_permission, can_view
from .period import Period
from .reports import Journal, Ledger
from .reports import Journal, Ledger, IncomeExpenses
bp: Blueprint = Blueprint("report", __name__)
"""The view blueprint for the reports."""
@ -103,3 +103,48 @@ def __get_ledger_list(currency: Currency, account: Account, period: Period) \
if "as" in request.args and request.args["as"] == "csv":
return report.as_csv_download()
return report.as_html_page()
@bp.get("income-expenses/<currency:currency>/<account:account>",
endpoint="income-expenses-default")
@has_permission(can_view)
def get_default_income_expenses_list(currency: Currency, account: Account) \
-> str | Response:
"""Returns the income and expenses in the default period.
:param currency: The currency.
:param account: The account.
:return: The income and expenses in the default period.
"""
return __get_income_expenses_list(currency, account, Period.get_instance())
@bp.get(
"income-expenses/<currency:currency>/<account:account>/<period:period>",
endpoint="income-expenses")
@has_permission(can_view)
def get_income_expenses_list(currency: Currency, account: Account,
period: Period) -> str | Response:
"""Returns the income and expenses.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The income and expenses in the period.
"""
return __get_income_expenses_list(currency, account, period)
def __get_income_expenses_list(currency: Currency, account: Account,
period: Period) -> str | Response:
"""Returns the income and expenses.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The income and expenses in the period.
"""
report: IncomeExpenses = IncomeExpenses(currency, account, period)
if "as" in request.args and request.args["as"] == "csv":
return report.as_csv_download()
return report.as_html_page()