From 39807ef480eb9f888c435f57b47b844bd7e5472b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BE=9D=E7=91=AA=E8=B2=93?= Date: Sun, 5 Mar 2023 22:10:30 +0800 Subject: [PATCH] Added the income and expenses. --- src/accounting/report/period_choosers.py | 23 ++ src/accounting/report/report_chooser.py | 16 ++ src/accounting/report/report_rows.py | 52 +++++ src/accounting/report/report_type.py | 2 + src/accounting/report/reports.py | 215 +++++++++++++++++- src/accounting/report/views.py | 47 +++- .../include/income-expenses-mobile-row.html | 46 ++++ .../accounting/report/income-expenses.html | 211 +++++++++++++++++ 8 files changed, 609 insertions(+), 3 deletions(-) create mode 100644 src/accounting/templates/accounting/report/include/income-expenses-mobile-row.html create mode 100644 src/accounting/templates/accounting/report/income-expenses.html diff --git a/src/accounting/report/period_choosers.py b/src/accounting/report/period_choosers.py index 1999d98..4d47d3b 100644 --- a/src/accounting/report/period_choosers.py +++ b/src/accounting/report/period_choosers.py @@ -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) diff --git a/src/accounting/report/report_chooser.py b/src/accounting/report/report_chooser.py index 830f852..6bc9ab2 100644 --- a/src/accounting/report/report_chooser.py +++ b/src/accounting/report/report_chooser.py @@ -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. diff --git a/src/accounting/report/report_rows.py b/src/accounting/report/report_rows.py index 6da00cc..de382c4 100644 --- a/src/accounting/report/report_rows.py +++ b/src/accounting/report/report_rows.py @@ -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} diff --git a/src/accounting/report/report_type.py b/src/accounting/report/report_type.py index 1cf81c0..e10c68d 100644 --- a/src/accounting/report/report_type.py +++ b/src/accounting/report/report_type.py @@ -26,3 +26,5 @@ class ReportType(Enum): """The journal.""" LEDGER: str = "ledger" """The ledger.""" + INCOME_EXPENSES: str = "income-expenses" + """The income and expenses.""" diff --git a/src/accounting/report/reports.py b/src/accounting/report/reports.py index 51465da..9805aae 100644 --- a/src/accounting/report/reports.py +++ b/src/accounting/report/reports.py @@ -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()] diff --git a/src/accounting/report/views.py b/src/accounting/report/views.py index f29dcde..1a31c27 100644 --- a/src/accounting/report/views.py +++ b/src/accounting/report/views.py @@ -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//", + 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///", + 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() diff --git a/src/accounting/templates/accounting/report/include/income-expenses-mobile-row.html b/src/accounting/templates/accounting/report/include/income-expenses-mobile-row.html new file mode 100644 index 0000000..3aa3576 --- /dev/null +++ b/src/accounting/templates/accounting/report/include/income-expenses-mobile-row.html @@ -0,0 +1,46 @@ +{# +The Mia! Accounting Flask Project +income-expenses-mobile-row.html: The row in the income and expenses for the mobile devices + + Copyright (c) 2023 imacat. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Author: imacat@mail.imacat.idv.tw (imacat) +First written: 2023/3/5 +#} +
+ {% if item.date is not none or item.account is not none %} +
+ {% if item.date is not none %} + {{ item.date|accounting_format_date }} + {% endif %} + {% if item.account is not none %} + {{ item.account.title|title }} + {% endif %} +
+ {% endif %} + {% if item.summary is not none %} +
{{ item.summary }}
+ {% endif %} +
+ +
+ {% if item.income is not none %} + +{{ item.income|accounting_format_amount }} + {% endif %} + {% if item.expense is not none %} + -{{ item.expense|accounting_format_amount }} + {% endif %} + {{ item.balance|accounting_format_amount }} +
diff --git a/src/accounting/templates/accounting/report/income-expenses.html b/src/accounting/templates/accounting/report/income-expenses.html new file mode 100644 index 0000000..2291778 --- /dev/null +++ b/src/accounting/templates/accounting/report/income-expenses.html @@ -0,0 +1,211 @@ +{# +The Mia! Accounting Flask Project +income-expenses.html: The income and expenses + + Copyright (c) 2023 imacat. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Author: imacat@mail.imacat.idv.tw (imacat) +First written: 2023/3/5 +#} +{% extends "accounting/base.html" %} + +{% block accounting_scripts %} + + + +{% endblock %} + +{% block header %}{% block title %}{{ _("Income and Expenses of %(account)s in %(currency)s %(period)s", currency=report.currency, account=report.account|title, period=report.period.desc|title) }}{% endblock %}{% endblock %} + +{% block content %} + +
+ {% if accounting_can_edit() %} + + {% endif %} + {% with report_chooser = report.report_chooser %} + {% include "accounting/report/include/report-chooser.html" %} + {% endwith %} +
+ + +
+
+ + +
+ + + + {{ A_("Download") }} + +
+ +{% with txn_types = report.txn_types %} + {% include "accounting/include/add-txn-material-fab.html" %} +{% endwith %} + +
+ {% with report_chooser = report.report_chooser %} + {% include "accounting/report/include/report-chooser.html" %} + {% endwith %} +
+ + +
+
+ + +
+ +
+ +{% with period = report.period, period_chooser = report.period_chooser %} + {% include "accounting/report/include/period-chooser.html" %} +{% endwith %} + +{% if list %} + {% include "accounting/include/pagination.html" %} + + + + + + + + + + + + + + {% for item in list %} + + + + + + + + + {% endfor %} + + {% if report.total_row is not none %} + + + + + + + + + {% endif %} +
{{ A_("Date") }}{{ A_("Account") }}{{ A_("Summary") }}{{ A_("Income") }}{{ A_("Expense") }}{{ A_("Balance") }}
{{ A_("Total") }}{{ report.total_row.income|accounting_format_amount }}{{ report.total_row.expense|accounting_format_amount }}{{ report.total_row.balance|accounting_format_amount }}
+ +
+ {% for item in list %} + {% if item.transaction is not none %} + + {% include "accounting/report/include/income-expenses-mobile-row.html" %} + + {% else %} +
+ {% include "accounting/report/include/income-expenses-mobile-row.html" %} +
+ {% endif %} + {% endfor %} + {% if report.total_row is not none %} + {% with item = report.total_row %} +
+ {% include "accounting/report/include/income-expenses-mobile-row.html" %} +
+ {% endwith %} + {% endif %} +
+{% else %} +

{{ A_("There is no data.") }}

+{% endif %} + +{% endblock %}