diff --git a/src/accounting/report/period_choosers.py b/src/accounting/report/period_choosers.py index 3158b10..1d190c7 100644 --- a/src/accounting/report/period_choosers.py +++ b/src/accounting/report/period_choosers.py @@ -178,3 +178,22 @@ class TrialBalancePeriodChooser(PeriodChooser): currency=self.currency) return url_for("accounting.report.trial-balance", currency=self.currency, period=period) + + +class IncomeStatementPeriodChooser(PeriodChooser): + """The income statement period chooser.""" + + def __init__(self, currency: Currency): + """Constructs the income statement period chooser.""" + self.currency: Currency = currency + """The currency.""" + first: Transaction | None \ + = Transaction.query.order_by(Transaction.date).first() + super().__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-statement-default", + currency=self.currency) + return url_for("accounting.report.income-statement", + currency=self.currency, period=period) diff --git a/src/accounting/report/report_chooser.py b/src/accounting/report/report_chooser.py index 42dca0c..f0dc75f 100644 --- a/src/accounting/report/report_chooser.py +++ b/src/accounting/report/report_chooser.py @@ -69,6 +69,7 @@ class ReportChooser: self.__reports.append(self.__ledger) self.__reports.append(self.__income_expenses) self.__reports.append(self.__trial_balance) + self.__reports.append(self.__income_statement) for report in self.__reports: if report.is_active: self.current_report = report.title @@ -132,6 +133,20 @@ class ReportChooser: return OptionLink(gettext("Trial Balance"), url, self.__active_report == ReportType.TRIAL_BALANCE) + @property + def __income_statement(self) -> OptionLink: + """Returns the income statement. + + :return: The income statement. + """ + url: str = url_for("accounting.report.income-statement-default", + currency=self.__currency) \ + if self.__period.is_default \ + else url_for("accounting.report.income-statement", + currency=self.__currency, period=self.__period) + return OptionLink(gettext("Income Statement"), url, + self.__active_report == ReportType.INCOME_STATEMENT) + def __iter__(self) -> t.Iterator[OptionLink]: """Returns the iteration of the reports. diff --git a/src/accounting/report/report_params.py b/src/accounting/report/report_params.py index 12f6756..a2b1374 100644 --- a/src/accounting/report/report_params.py +++ b/src/accounting/report/report_params.py @@ -29,10 +29,10 @@ from accounting.report.option_link import OptionLink from accounting.report.period import Period from accounting.report.period_choosers import PeriodChooser, \ JournalPeriodChooser, LedgerPeriodChooser, IncomeExpensesPeriodChooser, \ - TrialBalancePeriodChooser + TrialBalancePeriodChooser, IncomeStatementPeriodChooser from accounting.report.report_chooser import ReportChooser from accounting.report.report_rows import JournalRow, LedgerRow, \ - IncomeExpensesRow, TrialBalanceRow + IncomeExpensesRow, TrialBalanceRow, IncomeStatementRow from accounting.report.report_type import ReportType from accounting.utils.pagination import Pagination from accounting.utils.txn_types import TransactionType @@ -339,3 +339,50 @@ class TrialBalanceParams(ReportParams[TrialBalanceRow]): 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()] + + +class IncomeStatementParams(ReportParams[IncomeStatementRow]): + """The parameters of an income statement page.""" + + def __init__(self, + currency: Currency, + period: Period, + data_rows: list[IncomeStatementRow]): + """Constructs the parameters for the income statement page. + + :param currency: The currency. + :param period: The period. + :param data_rows: The data rows. + :param total: The total row, if any. + """ + super().__init__( + period_chooser=IncomeStatementPeriodChooser(currency), + report_chooser=ReportChooser(ReportType.INCOME_STATEMENT, + currency=currency, + period=period), + data_rows=data_rows, + is_paged=False) + self.currency: Currency = currency + """The currency.""" + self.period: Period | None = period + """The period.""" + + @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-statement-default", + currency=currency) + return url_for("accounting.report.income-statement", + currency=currency, 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()] diff --git a/src/accounting/report/report_rows.py b/src/accounting/report/report_rows.py index e536f5d..fae777c 100644 --- a/src/accounting/report/report_rows.py +++ b/src/accounting/report/report_rows.py @@ -206,3 +206,65 @@ class TrialBalanceRow(ReportRow): return {"Account": str(self.account).title(), "Debit": self.debit, "Credit": self.credit} + + +class IncomeStatementRow(ReportRow): + """A row in the income statement.""" + + def __init__(self, + code: str | None = None, + title: str | None = None, + amount: Decimal | None = None, + is_category: bool = False, + is_total: bool = False, + is_subcategory: bool = False, + is_subtotal: bool = False, + url: str | None = None): + """Constructs the row in the income statement. + + :param code: The account code. + :param title: The account title. + :param amount: The amount. + :param is_category: True for a category, or False otherwise. + :param is_total: True for a total, or False otherwise. + :param is_subcategory: True for a subcategory, or False otherwise. + :param is_subtotal: True for a subtotal, or False otherwise. + :param url: The URL for the account. + """ + self.is_total: bool = False + """Whether this is the total row.""" + self.code: str | None = code + """The account code.""" + self.title: str | None = title + """The account code.""" + self.amount: Decimal | None = amount + """The amount.""" + self.is_category: bool = is_category + """True if this row is a category, or False otherwise.""" + self.is_total: bool = is_total + """True if this row is a total, or False otherwise.""" + self.is_subcategory: bool = is_subcategory + """True if this row is a subcategory, or False otherwise.""" + self.is_subtotal: bool = is_subtotal + """True if this row is a subtotal, or False otherwise.""" + self.url: str | None = url + """The URL.""" + + @property + def is_account(self) -> bool: + """Returns whether the row represents an account. + + :return: True if the row represents an account, or False otherwise. + """ + return not self.is_category and not self.is_total \ + and not self.is_subcategory and not self.is_subtotal + + def as_dict(self) -> dict[str, t.Any]: + if self.is_subtotal: + return {"": "Total", + "Amount": self.amount} + if self.is_total: + return {"": self.title, + "Amount": self.amount} + return {"": f"{self.code} {self.title}", + "Amount": self.amount} diff --git a/src/accounting/report/report_type.py b/src/accounting/report/report_type.py index 873af81..4749227 100644 --- a/src/accounting/report/report_type.py +++ b/src/accounting/report/report_type.py @@ -30,3 +30,5 @@ class ReportType(Enum): """The income and expenses.""" TRIAL_BALANCE: str = "trial-balance" """The trial balance.""" + INCOME_STATEMENT: str = "income-statement" + """The income statement.""" diff --git a/src/accounting/report/reports.py b/src/accounting/report/reports.py index ac91ae8..a994fbc 100644 --- a/src/accounting/report/reports.py +++ b/src/accounting/report/reports.py @@ -28,12 +28,13 @@ from flask import Response, render_template, url_for from accounting import db from accounting.locale import gettext -from accounting.models import Currency, Account, Transaction, JournalEntry +from accounting.models import Currency, BaseAccount, Account, Transaction, \ + JournalEntry from accounting.report.period import Period from accounting.report.report_params import JournalParams, LedgerParams, \ - IncomeExpensesParams, TrialBalanceParams + IncomeExpensesParams, TrialBalanceParams, IncomeStatementParams from accounting.report.report_rows import JournalRow, LedgerRow, \ - IncomeExpensesRow, TrialBalanceRow + IncomeExpensesRow, TrialBalanceRow, IncomeStatementRow T = t.TypeVar("T") @@ -578,3 +579,160 @@ class TrialBalance(Report[TrialBalanceRow]): total=self.total) return render_template("accounting/report/trial-balance.html", report=params) + + +class IncomeStatement(Report[IncomeStatementRow]): + """The income statement.""" + + def __init__(self, currency: Currency, period: Period): + """Constructs an income statement. + + :param currency: The currency. + :param period: The period. + """ + self.currency: Currency = currency + """The currency.""" + self.period: Period = period + """The period.""" + super().__init__() + + def get_rows(self) -> tuple[list[T], T | None, T | None]: + rows: list[IncomeStatementRow] = self.__query_balances() + rows = self.__get_income_statement_rows(rows) + return rows, None, None + + def __query_balances(self) -> list[IncomeStatementRow]: + """Queries and returns the balances. + + :return: The balances. + """ + sub_conditions: list[sa.BinaryExpression] \ + = [Account.base_code.startswith(str(x)) for x in range(4, 10)] + conditions: list[sa.BinaryExpression] \ + = [JournalEntry.currency_code == self.currency.code, + sa.or_(*sub_conditions)] + 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) + balance_func: sa.Function = sa.func.sum(sa.case( + (JournalEntry.is_debit, JournalEntry.amount), + else_=-JournalEntry.amount)).label("balance") + select_balance: sa.Select \ + = sa.select(JournalEntry.account_id, balance_func)\ + .join(Transaction).join(Account)\ + .filter(*conditions)\ + .group_by(JournalEntry.account_id)\ + .order_by(Account.base_code, Account.no) + balances: list[sa.Row] = db.session.execute(select_balance).all() + accounts: dict[int, Account] \ + = {x.id: x for x in Account.query + .filter(Account.id.in_([x.account_id for x in balances])).all()} + + def get_url(account: Account) -> str: + """Returns the ledger URL of an account. + + :param account: The account. + :return: The ledger URL of the account. + """ + if self.period.is_default: + return url_for("accounting.report.ledger-default", + currency=self.currency, account=account) + return url_for("accounting.report.ledger", + currency=self.currency, account=account, + period=self.period) + + return [IncomeStatementRow(code=accounts[x.account_id].code, + title=accounts[x.account_id].title, + amount=x.balance, + url=get_url(accounts[x.account_id])) + for x in balances] + + @staticmethod + def __get_income_statement_rows(balances: list[IncomeStatementRow]) \ + -> list[IncomeStatementRow]: + """Composes the categories and totals from the balance rows. + + :param balances: The balance rows. + :return: None. + """ + categories: list[BaseAccount] \ + = BaseAccount.query\ + .filter(BaseAccount.code.in_([str(x) for x in range(4, 10)]))\ + .order_by(BaseAccount.code).all() + subcategory_codes: set[str] = {x.code[:2] for x in balances} + subcategory_dict: dict[str, BaseAccount] \ + = {x.code: x for x in BaseAccount.query + .filter(BaseAccount.code.in_({x.code[:2] for x in balances}))} + + balances_by_subcategory: dict[str, list[IncomeStatementRow]] \ + = {x: [] for x in subcategory_codes} + for balance in balances: + balances_by_subcategory[balance.code[:2]].append(balance) + + subcategories_by_category: dict[str, list[BaseAccount]] \ + = {x.code: [] for x in categories} + for subcategory in subcategory_dict.values(): + subcategories_by_category[subcategory.code[0]].append(subcategory) + + total_titles: dict[str, str] \ + = {"4": "total revenue", + "5": "gross income", + "6": "operating income", + "7": "before tax income", + "8": "after tax income", + "9": "net income or loss for current period"} + + rows: list[IncomeStatementRow] = [] + total: Decimal = Decimal(0) + for category in categories: + rows.append(IncomeStatementRow(code=category.code, + title=category.title, + is_category=True)) + for subcategory in subcategories_by_category[category.code]: + rows.append(IncomeStatementRow(code=subcategory.code, + title=subcategory.title, + is_subcategory=True)) + subtotal: Decimal = Decimal(0) + for balance in balances_by_subcategory[subcategory.code]: + rows.append(balance) + subtotal = subtotal + balance.amount + rows.append(IncomeStatementRow(amount=subtotal, + is_subtotal=True)) + total = total + subtotal + rows.append(IncomeStatementRow(title=total_titles[category.code], + amount=total, + is_total=True)) + return rows + + def __get_category(self, category: BaseAccount, + subcategory_dict: dict[str, BaseAccount], + balances: list[IncomeStatementRow]) \ + -> list[IncomeStatementRow]: + """Returns the rows in the category. + + :param category: The category. + :param subcategory_dict: The subcategories + :param balances: The balances. + :return: The rows in the category. + """ + + @staticmethod + def populate_rows(rows: list[JournalRow]) -> None: + pass + + @property + def csv_field_names(self) -> list[str]: + return ["", "Amount"] + + @property + def csv_filename(self) -> str: + return f"income-statement-{self.period.spec}.csv" + + def html(self) -> str: + params: IncomeStatementParams = IncomeStatementParams( + currency=self.currency, + period=self.period, + data_rows=self.data_rows) + return render_template("accounting/report/income-statement.html", + report=params) diff --git a/src/accounting/report/views.py b/src/accounting/report/views.py index f54a5af..9b82052 100644 --- a/src/accounting/report/views.py +++ b/src/accounting/report/views.py @@ -22,7 +22,8 @@ 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, IncomeExpenses, TrialBalance +from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ + IncomeStatement bp: Blueprint = Blueprint("report", __name__) """The view blueprint for the reports.""" @@ -188,3 +189,43 @@ def __get_trial_balance_list(currency: Currency, period: Period) \ if "as" in request.args and request.args["as"] == "csv": return report.csv() return report.html() + + +@bp.get("income-statement/", + endpoint="income-statement-default") +@has_permission(can_view) +def get_default_income_statement_list(currency: Currency) -> str | Response: + """Returns the income statement in the default period. + + :param currency: The currency. + :return: The income statement in the default period. + """ + return __get_income_statement_list(currency, Period.get_instance()) + + +@bp.get("income-statement//", + endpoint="income-statement") +@has_permission(can_view) +def get_income_statement_list(currency: Currency, period: Period) \ + -> str | Response: + """Returns the income statement. + + :param currency: The currency. + :param period: The period. + :return: The income statement in the period. + """ + return __get_income_statement_list(currency, period) + + +def __get_income_statement_list(currency: Currency, period: Period) \ + -> str | Response: + """Returns the income statement. + + :param currency: The currency. + :param period: The period. + :return: The income statement in the period. + """ + report: IncomeStatement = IncomeStatement(currency, period) + if "as" in request.args and request.args["as"] == "csv": + return report.csv() + return report.html() diff --git a/src/accounting/static/css/style.css b/src/accounting/static/css/style.css index dd284fe..22ca83a 100644 --- a/src/accounting/static/css/style.css +++ b/src/accounting/static/css/style.css @@ -115,11 +115,11 @@ font-weight: bolder; } .accounting-report-table-header { - border-bottom: thick double slategray; + border-bottom: thin solid slategray; } .accounting-report-table-footer { font-style: italic; - border-top: thick double slategray; + border-top: thin solid slategray; } .accounting-report-table-row { display: grid; @@ -158,9 +158,56 @@ a.accounting-report-table-row { .accounting-income-expenses-table .accounting-report-table-footer .accounting-report-table-row { grid-template-columns: 7fr 1fr 1fr 1fr; } +.accounting-trial-balance-table .accounting-report-table-header { + border-bottom: thick double slategray; +} +.accounting-trial-balance-table .accounting-report-table-footer { + border-top: thick double slategray; +} .accounting-trial-balance-table .accounting-report-table-row { grid-template-columns: 3fr 1fr 1fr; } +.accounting-income-statement-table .accounting-report-table-body { + border-top: thick double slategray; + border-bottom: thick double slategray; +} +.accounting-income-statement-table .accounting-report-table-row { + grid-template-columns: 3fr 1fr; +} +.accounting-income-statement-table .accounting-report-table-header .accounting-report-table-row, .accounting-income-statement-table .accounting-report-table-row.accounting-income-statement-category, .accounting-income-statement-table .accounting-report-table-row.accounting-income-statement-subcategory { + grid-template-columns: 1fr; +} +.accounting-income-statement-category, .accounting-income-statement-total { + font-size: 1.2rem; + font-weight: bolder; +} +.accounting-income-statement-subcategory, .accounting-income-statement-subtotal { + font-size: 1.1rem; +} +.accounting-income-statement-subcategory > :first-child { + padding-left: 1rem; +} +.accounting-income-statement-subtotal { + border-top: thin solid darkslategray; +} +.accounting-income-statement-account > :first-child, .accounting-income-statement-subtotal > :first-child { + padding-left: 2rem; +} +.accounting-income-statement-account > .accounting-amount, .accounting-income-statement-subtotal .accounting-amount { + padding-right: 2rem; +} +.accounting-income-statement-category { + margin-top: 2rem; +} +.accounting-income-statement-category:first-child { + margin-top: 0; +} +.accounting-income-statement-total { + margin-bottom: 2rem; +} +.accounting-income-statement-total:last-child { + margin-bottom: 0; +} /* The accounting report */ .accounting-mobile-journal-credit { diff --git a/src/accounting/templates/accounting/report/income-statement.html b/src/accounting/templates/accounting/report/income-statement.html new file mode 100644 index 0000000..36b5a77 --- /dev/null +++ b/src/accounting/templates/accounting/report/income-statement.html @@ -0,0 +1,174 @@ +{# +The Mia! Accounting Flask Project +income-statement.html: The income statement + + 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/7 +#} +{% extends "accounting/base.html" %} + +{% block accounting_scripts %} + + +{% endblock %} + +{% block header %}{% block title %}{{ _("Income Statement of %(currency)s %(period)s", currency=report.currency.name|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 report.has_data %} +
+
+

{{ _("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}

+
+ +
+
+
+
{{ A_("Amount") }}
+
+
+
+ {% for item in report.data_rows %} + {% if item.is_category %} +
+
+ {{ item.code }} + {{ item.title|title }} +
+
+ {% elif item.is_total %} +
+
{{ item.title|title }}
+
{{ item.amount|accounting_format_amount }}
+
+ {% elif item.is_subcategory %} +
+
+ {{ item.code }} + {{ item.title|title }} +
+
+ {% elif item.is_subtotal %} +
+
{{ A_("Total") }}
+
{{ item.amount|accounting_format_amount }}
+
+ {% else %} + + {% endif %} + {% endfor %} +
+
+
+{% else %} +

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

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