diff --git a/src/accounting/report/__init__.py b/src/accounting/report/__init__.py index cd11237..7f1b673 100644 --- a/src/accounting/report/__init__.py +++ b/src/accounting/report/__init__.py @@ -27,8 +27,9 @@ def init_app(app: Flask, bp: Blueprint) -> None: :param bp: The blueprint of the accounting application. :return: None. """ - from .converters import PeriodConverter + from .converters import PeriodConverter, IncomeExpensesAccountConverter app.url_map.converters["period"] = PeriodConverter + app.url_map.converters["ioAccount"] = IncomeExpensesAccountConverter from .views import bp as report_bp bp.register_blueprint(report_bp, url_prefix="/reports") diff --git a/src/accounting/report/converters.py b/src/accounting/report/converters.py index 72b0789..f1ecac2 100644 --- a/src/accounting/report/converters.py +++ b/src/accounting/report/converters.py @@ -17,9 +17,13 @@ """The path converters for the report management. """ +import re + from flask import abort from werkzeug.routing import BaseConverter +from accounting.models import Account +from .income_expense_account import IncomeExpensesAccount from .period import Period @@ -45,3 +49,31 @@ class PeriodConverter(BaseConverter): :return: Its specification. """ return value.spec + + +class IncomeExpensesAccountConverter(BaseConverter): + """The supplier converter to convert the income and expenses pseudo account + code from and to the corresponding pseudo account in the routes.""" + + def to_python(self, value: str) -> IncomeExpensesAccount: + """Converts an account code to an account. + + :param value: The account code. + :return: The corresponding account. + """ + if value == IncomeExpensesAccount.CURRENT_AL_CODE: + return IncomeExpensesAccount.current_assets_and_liabilities() + if not re.match("^[12][12]", value): + abort(404) + account: Account | None = Account.find_by_code(value) + if account is None: + abort(404) + return IncomeExpensesAccount(account) + + def to_url(self, value: IncomeExpensesAccount) -> str: + """Converts an account to account code. + + :param value: The account. + :return: Its code. + """ + return value.code diff --git a/src/accounting/report/income_expense_account.py b/src/accounting/report/income_expense_account.py new file mode 100644 index 0000000..f9b5a90 --- /dev/null +++ b/src/accounting/report/income_expense_account.py @@ -0,0 +1,70 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 + +# 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. +"""The pseudo account for the income and expenses log. + +""" +import typing as t + +from accounting.locale import gettext +from accounting.models import Account + + +class IncomeExpensesAccount: + """The pseudo account for the income and expenses log.""" + CURRENT_AL_CODE: str = "0000-000" + """The account code for the current assets and liabilities.""" + + def __init__(self, account: Account | None = None): + """Constructs the pseudo account for the income and expenses log. + + :param account: The actual account. + """ + self.account: Account | None = None + self.id: int | None = None + """The ID.""" + self.code: str | None = None + """The code.""" + self.title: str | None = None + """The title.""" + self.str: str = "" + """The string representation of the account.""" + if account is not None: + self.account = account + self.id = account.id + self.code = account.code + self.title = account.title + self.str = str(account) + + def __str__(self) -> str: + """Returns the string representation of the account. + + :return: The string representation of the account. + """ + return self.str + + @classmethod + def current_assets_and_liabilities(cls) -> t.Self: + """Returns the pseudo account for current assets and liabilities. + + :return: The pseudo account for current assets and liabilities. + """ + account: cls = cls() + account.id = 0 + account.code = cls.CURRENT_AL_CODE + account.title = gettext("current assets and liabilities") + account.str = account.title + return account diff --git a/src/accounting/report/reports/income_expenses.py b/src/accounting/report/reports/income_expenses.py index c9849d5..23420f8 100644 --- a/src/accounting/report/reports/income_expenses.py +++ b/src/accounting/report/reports/income_expenses.py @@ -26,6 +26,7 @@ from flask import url_for, render_template, Response from accounting import db from accounting.locale import gettext from accounting.models import Currency, Account, Transaction, JournalEntry +from accounting.report.income_expense_account import IncomeExpensesAccount from accounting.report.period import Period from accounting.utils.pagination import Pagination from .utils.csv_export import BaseCSVRow, csv_download @@ -76,7 +77,8 @@ class Entry: class EntryCollector: """The income and expenses log entry collector.""" - def __init__(self, currency: Currency, account: Account, period: Period): + def __init__(self, currency: Currency, account: IncomeExpensesAccount, + period: Period): """Constructs the income and expenses log entry collector. :param currency: The currency. @@ -85,7 +87,7 @@ class EntryCollector: """ self.__currency: Currency = currency """The currency.""" - self.__account: Account = account + self.__account: IncomeExpensesAccount = account """The account.""" self.__period: Period = period """The period""" @@ -111,9 +113,10 @@ class EntryCollector: 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)\ + select: sa.Select = sa.Select(balance_func)\ + .join(Transaction).join(Account)\ .filter(JournalEntry.currency_code == self.__currency.code, - JournalEntry.account_id == self.__account.id, + self.__account_condition, Transaction.date < self.__period.start) balance: int | None = db.session.scalar(select) if balance is None: @@ -137,23 +140,32 @@ class EntryCollector: """ conditions: list[sa.BinaryExpression] \ = [JournalEntry.currency_code == self.__currency.code, - JournalEntry.account_id == self.__account.id] + self.__account_condition] if self.__period.start is not None: conditions.append(Transaction.date >= self.__period.start) if self.__period.end is not None: conditions.append(Transaction.date <= self.__period.end) txn_with_account: sa.Select = sa.Select(Transaction.id).\ - join(JournalEntry).filter(*conditions) + join(JournalEntry).join(Account).filter(*conditions) return [Entry(x) - for x in JournalEntry.query.join(Transaction) + for x in JournalEntry.query.join(Transaction).join(Account) .filter(JournalEntry.transaction_id.in_(txn_with_account), JournalEntry.currency_code == self.__currency.code, - JournalEntry.account_id != self.__account.id) + sa.not_(self.__account_condition)) .order_by(Transaction.date, JournalEntry.is_debit, JournalEntry.no)] + @property + def __account_condition(self) -> sa.BinaryExpression: + if self.__account.code == IncomeExpensesAccount.CURRENT_AL_CODE: + return sa.or_(Account.base_code.startswith("11"), + Account.base_code.startswith("12"), + Account.base_code.startswith("21"), + Account.base_code.startswith("22")) + return Account.id == self.__account.id + def __get_total_entry(self) -> Entry | None: """Composes the total entry. @@ -237,7 +249,7 @@ class IncomeExpensesPageParams(PageParams): """The HTML parameters of the income and expenses log.""" def __init__(self, currency: Currency, - account: Account, + account: IncomeExpensesAccount, period: Period, has_data: bool, pagination: Pagination[Entry], @@ -256,7 +268,7 @@ class IncomeExpensesPageParams(PageParams): """ self.currency: Currency = currency """The currency.""" - self.account: Account = account + self.account: IncomeExpensesAccount = account """The account.""" self.period: Period = period """The period.""" @@ -288,9 +300,14 @@ class IncomeExpensesPageParams(PageParams): :return: The report chooser. """ + if self.account.account is None: + return ReportChooser(ReportType.INCOME_EXPENSES, + currency=self.currency, + account=Account.cash(), + period=self.period) return ReportChooser(ReportType.INCOME_EXPENSES, currency=self.currency, - account=self.account, + account=self.account.account, period=self.period) @property @@ -320,7 +337,7 @@ class IncomeExpensesPageParams(PageParams): :return: The account options. """ - def get_url(account: Account): + def get_url(account: IncomeExpensesAccount): if self.period.is_default: return url_for("accounting.report.income-expenses-default", currency=self.currency, account=account) @@ -328,6 +345,11 @@ class IncomeExpensesPageParams(PageParams): currency=self.currency, account=account, period=self.period) + current_al: IncomeExpensesAccount \ + = IncomeExpensesAccount.current_assets_and_liabilities() + options: list[OptionLink] \ + = [OptionLink(str(current_al), get_url(current_al), + self.account.id == 0)] in_use: sa.Select = sa.Select(JournalEntry.account_id)\ .join(Account)\ .filter(JournalEntry.currency_code == self.currency.code, @@ -336,9 +358,11 @@ class IncomeExpensesPageParams(PageParams): Account.base_code.startswith("21"), Account.base_code.startswith("22")))\ .group_by(JournalEntry.account_id) - 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()] + options.extend([OptionLink(str(x), get_url(IncomeExpensesAccount(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()]) + return options def _populate_entries(entries: list[Entry]) -> None: @@ -366,7 +390,8 @@ def _populate_entries(entries: list[Entry]) -> None: class IncomeExpenses: """The income and expenses log.""" - def __init__(self, currency: Currency, account: Account, period: Period): + def __init__(self, currency: Currency, account: IncomeExpensesAccount, + period: Period): """Constructs an income and expenses log. :param currency: The currency. @@ -375,7 +400,7 @@ class IncomeExpenses: """ self.__currency: Currency = currency """The currency.""" - self.__account: Account = account + self.__account: IncomeExpensesAccount = account """The account.""" self.__period: Period = period """The period.""" diff --git a/src/accounting/report/reports/utils/period_choosers.py b/src/accounting/report/reports/utils/period_choosers.py index 1d0b34f..759274e 100644 --- a/src/accounting/report/reports/utils/period_choosers.py +++ b/src/accounting/report/reports/utils/period_choosers.py @@ -27,6 +27,7 @@ from datetime import date from flask import url_for from accounting.models import Currency, Account, Transaction +from accounting.report.income_expense_account import IncomeExpensesAccount from accounting.report.period import YearPeriod, Period, ThisMonth, \ LastMonth, SinceLastMonth, ThisYear, LastYear, Today, Yesterday, \ TemplatePeriod @@ -143,11 +144,11 @@ class LedgerPeriodChooser(PeriodChooser): class IncomeExpensesPeriodChooser(PeriodChooser): """The income and expenses period chooser.""" - def __init__(self, currency: Currency, account: Account): + def __init__(self, currency: Currency, account: IncomeExpensesAccount): """Constructs the income and expenses period chooser.""" self.currency: Currency = currency """The currency.""" - self.account: Account = account + self.account: IncomeExpensesAccount = account """The account.""" first: Transaction | None \ = Transaction.query.order_by(Transaction.date).first() diff --git a/src/accounting/report/views.py b/src/accounting/report/views.py index 395797c..4f49162 100644 --- a/src/accounting/report/views.py +++ b/src/accounting/report/views.py @@ -21,6 +21,7 @@ from flask import Blueprint, request, Response from accounting.models import Currency, Account from accounting.utils.permission import has_permission, can_view +from .income_expense_account import IncomeExpensesAccount from .period import Period from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ IncomeStatement, BalanceSheet @@ -108,10 +109,11 @@ def __get_ledger_list(currency: Currency, account: Account, period: Period) \ return report.html() -@bp.get("income-expenses//", +@bp.get("income-expenses//", endpoint="income-expenses-default") @has_permission(can_view) -def get_default_income_expenses_list(currency: Currency, account: Account) \ +def get_default_income_expenses_list(currency: Currency, + account: IncomeExpensesAccount) \ -> str | Response: """Returns the income and expenses in the default period. @@ -123,10 +125,11 @@ def get_default_income_expenses_list(currency: Currency, account: Account) \ @bp.get( - "income-expenses///", + "income-expenses///", endpoint="income-expenses") @has_permission(can_view) -def get_income_expenses_list(currency: Currency, account: Account, +def get_income_expenses_list(currency: Currency, + account: IncomeExpensesAccount, period: Period) -> str | Response: """Returns the income and expenses. @@ -138,7 +141,8 @@ def get_income_expenses_list(currency: Currency, account: Account, return __get_income_expenses_list(currency, account, period) -def __get_income_expenses_list(currency: Currency, account: Account, +def __get_income_expenses_list(currency: Currency, + account: IncomeExpensesAccount, period: Period) -> str | Response: """Returns the income and expenses.