Added the income and expenses.

This commit is contained in:
依瑪貓 2023-03-05 22:10:30 +08:00
parent 39723b1299
commit 39807ef480
8 changed files with 609 additions and 3 deletions

View File

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

View File

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

View File

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

View File

@ -26,3 +26,5 @@ class ReportType(Enum):
"""The journal."""
LEDGER: str = "ledger"
"""The ledger."""
INCOME_EXPENSES: str = "income-expenses"
"""The income and expenses."""

View File

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

View File

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

View File

@ -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
#}
<div>
{% if item.date is not none or item.account is not none %}
<div class="text-muted small">
{% if item.date is not none %}
{{ item.date|accounting_format_date }}
{% endif %}
{% if item.account is not none %}
{{ item.account.title|title }}
{% endif %}
</div>
{% endif %}
{% if item.summary is not none %}
<div>{{ item.summary }}</div>
{% endif %}
</div>
<div>
{% if item.income is not none %}
<span class="badge rounded-pill bg-success">+{{ item.income|accounting_format_amount }}</span>
{% endif %}
{% if item.expense is not none %}
<span class="badge rounded-pill bg-warning">-{{ item.expense|accounting_format_amount }}</span>
{% endif %}
<span class="badge rounded-pill bg-primary">{{ item.balance|accounting_format_amount }}</span>
</div>

View File

@ -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 %}
<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>
<script src="{{ url_for("accounting.static", filename="js/table-row-link.js") }}"></script>
{% 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 %}
<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|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<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-clipboard"></i>
{{ report.account.title|title }}
</button>
<ul class="dropdown-menu">
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
{{ account.title|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|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<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-clipboard"></i>
</button>
<ul class="dropdown-menu">
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
{{ account.title|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 list %}
{% include "accounting/include/pagination.html" %}
<table class="table table-striped table-hover d-none d-md-table accounting-ledger-table">
<thead>
<tr>
<th scope="col">{{ A_("Date") }}</th>
<th scope="col">{{ A_("Account") }}</th>
<th scope="col">{{ A_("Summary") }}</th>
<th class="accounting-amount" scope="col">{{ A_("Income") }}</th>
<th class="accounting-amount" scope="col">{{ A_("Expense") }}</th>
<th class="accounting-amount" scope="col">{{ A_("Balance") }}</th>
</tr>
</thead>
<tbody>
{% for item in list %}
<tr {% if item.transaction is not none %} class="accounting-clickable accounting-table-row-link" data-href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}" {% endif %}>
<td>{{ item.date|accounting_format_date }}</td>
<td>{{ item.account.title|title }}</td>
<td>{{ "" if item.summary is none else item.summary }}</td>
<td class="accounting-amount">{{ "" if item.income is none else item.income|accounting_format_amount }}</td>
<td class="accounting-amount">{{ "" if item.expense is none else item.expense|accounting_format_amount }}</td>
<td class="accounting-amount">{{ item.balance|accounting_format_amount }}</td>
</tr>
{% endfor %}
</tbody>
{% if report.total_row is not none %}
<tfoot>
<tr>
<td colspan="3">{{ A_("Total") }}</td>
<td class="accounting-amount">{{ report.total_row.income|accounting_format_amount }}</td>
<td class="accounting-amount">{{ report.total_row.expense|accounting_format_amount }}</td>
<td class="accounting-amount">{{ report.total_row.balance|accounting_format_amount }}</td>
</tr>
</tfoot>
{% endif %}
</table>
<div class="list-group d-md-none">
{% for item in list %}
{% if item.transaction is not none %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
{% include "accounting/report/include/income-expenses-mobile-row.html" %}
</a>
{% else %}
<div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/income-expenses-mobile-row.html" %}
</div>
{% endif %}
{% endfor %}
{% if report.total_row is not none %}
{% with item = report.total_row %}
<div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/income-expenses-mobile-row.html" %}
</div>
{% endwith %}
{% endif %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}