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

View File

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

View File

@ -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 %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% 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 %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
{{ report.currency.name|title }}
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
<i class="fa-solid fa-calendar-day"></i>
{{ report.period.desc|title }}
</button>
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
{{ A_("Download") }}
</a>
</div>
{% with txn_types = report.txn_types %}
{% include "accounting/include/add-txn-material-fab.html" %}
{% endwith %}
<div class="btn-group btn-actions mb-3 d-md-none">
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
<i class="fa-solid fa-calendar-day"></i>
{{ A_("Period") }}
</button>
</div>
{% with period = report.period, period_chooser = report.period_chooser %}
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% if report.has_data %}
<div class="accounting-sheet">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ _("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
</div>
<div class="accounting-report-table accounting-income-statement-table">
<div class="accounting-report-table-header">
<div class="accounting-report-table-row">
<div class="accounting-amount">{{ A_("Amount") }}</div>
</div>
</div>
<div class="accounting-report-table-body">
{% for item in report.data_rows %}
{% if item.is_category %}
<div class="accounting-report-table-row accounting-income-statement-category">
<div>
<span class="d-none d-md-inline">{{ item.code }}</span>
{{ item.title|title }}
</div>
</div>
{% elif item.is_total %}
<div class="accounting-report-table-row accounting-income-statement-total">
<div>{{ item.title|title }}</div>
<div class="accounting-amount">{{ item.amount|accounting_format_amount }}</div>
</div>
{% elif item.is_subcategory %}
<div class="accounting-report-table-row accounting-income-statement-subcategory">
<div>
<span class="d-none d-md-inline">{{ item.code }}</span>
{{ item.title|title }}
</div>
</div>
{% elif item.is_subtotal %}
<div class="accounting-report-table-row accounting-income-statement-subtotal">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ item.amount|accounting_format_amount }}</div>
</div>
{% else %}
<a class="accounting-report-table-row accounting-income-statement-account" href="{{ item.url }}">
<div>
<span class="d-none d-md-inline">{{ item.code }}</span>
{{ item.title|title }}
</div>
<div class="accounting-amount">{{ item.amount|accounting_format_amount }}</div>
</a>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}