Added the income statement.

This commit is contained in:
2023-03-07 09:56:10 +08:00
parent fcefc64117
commit 84d239e4b1
9 changed files with 573 additions and 8 deletions

View File

@ -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)

View File

@ -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.

View File

@ -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()]

View File

@ -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}

View File

@ -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."""

View File

@ -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)

View File

@ -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/<currency:currency>",
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/<currency:currency>/<period:period>",
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()