Replaced the report generators with a separated module for each report, to work with the diversity of the report formats without messing-up one another.

This commit is contained in:
依瑪貓 2023-03-07 22:26:51 +08:00
parent 436a4c367f
commit edb893ecd3
21 changed files with 2228 additions and 1702 deletions

View File

@ -0,0 +1,68 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
# 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 page parameters of a report.
"""
from abc import ABC, abstractmethod
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
urlunparse
from flask import request
import typing as t
from accounting.report.report_chooser import ReportChooser
from accounting.utils.txn_types import TransactionType
class PageParams(ABC):
"""The page parameters of a report."""
@property
@abstractmethod
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
@property
@abstractmethod
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
@property
def txn_types(self) -> t.Type[TransactionType]:
"""Returns the transaction types.
:return: The transaction types.
"""
return TransactionType
@property
def csv_uri(self) -> str:
uri: str = request.full_path if request.query_string \
else request.path
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] != "as"]
params.append(("as", "csv"))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)

View File

@ -333,7 +333,7 @@ class Period:
""" """
if self.start is None: if self.start is None:
return None return None
return self.__class__(None, self.start - datetime.timedelta(days=1)) return Period(None, self.start - datetime.timedelta(days=1))
class ThisMonth(Period): class ThisMonth(Period):

View File

@ -1,387 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
# 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 page parameters of a report.
"""
import typing as t
from abc import ABC
import sqlalchemy as sa
from flask import url_for
from accounting import db
from accounting.models import Currency, Account, JournalEntry
from accounting.report.option_link import OptionLink
from accounting.report.period import Period
from accounting.report.period_choosers import PeriodChooser, \
JournalPeriodChooser, LedgerPeriodChooser, IncomeExpensesPeriodChooser, \
TrialBalancePeriodChooser, IncomeStatementPeriodChooser
from accounting.report.report_chooser import ReportChooser
from accounting.report.report_rows import JournalRow, LedgerRow, \
IncomeExpensesRow, TrialBalanceRow, IncomeStatementRow
from accounting.report.report_type import ReportType
from accounting.utils.pagination import Pagination
from accounting.utils.txn_types import TransactionType
T = t.TypeVar("T")
class ReportParams(t.Generic[T], ABC):
"""The parameters of a report page."""
def __init__(self,
period_chooser: PeriodChooser,
report_chooser: ReportChooser,
data_rows: list[T],
is_paged: bool,
filler: t.Callable[[list[T]], None] | None = None,
brought_forward: T | None = None,
total: T | None = None):
"""Constructs the parameters of a report page.
:param period_chooser: The period chooser.
:param report_chooser: The report chooser.
:param filler: The callback to fill in the related data to the rows.
:param data_rows: The data rows.
:param is_paged: True to use pagination, or False otherwise.
:param brought_forward: The brought-forward row, if any.
:param total: The total row, if any.
"""
self.txn_types: t.Type[TransactionType] = TransactionType
"""The transaction types."""
self.period_chooser: PeriodChooser = period_chooser
"""The period chooser."""
self.report_chooser: ReportChooser = report_chooser
"""The report chooser."""
self.data_rows: list[T] = data_rows
"""The data rows"""
self.brought_forward: T | None = brought_forward
"""The brought-forward row."""
self.total: T | None = total
"""The total row."""
self.pagination: Pagination[T] | None = None
"""The pagination."""
self.has_data: bool = len(self.data_rows) > 0
"""True if there is any data in the page, or False otherwise."""
if is_paged:
all_rows: list[T] = []
if brought_forward is not None:
all_rows.append(brought_forward)
all_rows.extend(data_rows)
if self.total is not None:
all_rows.append(total)
self.pagination = Pagination[T](all_rows)
rows = self.pagination.list
self.has_data = len(rows) > 0
if len(rows) > 0 and rows[0] == brought_forward:
rows = rows[1:]
else:
self.brought_forward = None
if len(rows) > 0 and rows[-1] == total:
rows = rows[:-1]
else:
self.total = None
self.data_rows = rows
if filler is not None:
filler(self.data_rows)
class JournalParams(ReportParams[JournalRow]):
"""The parameters of a journal page."""
def __init__(self,
period: Period,
data_rows: list[JournalRow],
filler: t.Callable[[list[JournalRow]], None]):
"""Constructs the parameters for the journal page.
:param period: The period.
:param data_rows: The data rows.
:param filler: The callback to fill in the related data to the rows.
"""
super().__init__(
period_chooser=JournalPeriodChooser(),
report_chooser=ReportChooser(ReportType.JOURNAL,
period=period),
data_rows=data_rows,
is_paged=True,
filler=filler)
self.period: Period | None = period
"""The period."""
class LedgerParams(ReportParams[LedgerRow]):
"""The parameters of a ledger page."""
def __init__(self,
currency: Currency,
account: Account,
period: Period,
data_rows: list[LedgerRow],
filler: t.Callable[[list[LedgerRow]], None],
brought_forward: LedgerRow | None,
total: LedgerRow):
"""Constructs the parameters for the ledger page.
:param currency: The currency.
:param account: The account.
:param period: The period.
:param data_rows: The data rows.
:param filler: The callback to fill in the related data to the rows.
:param brought_forward: The brought-forward row, if any.
:param total: The total row, if any.
"""
super().__init__(
period_chooser=IncomeExpensesPeriodChooser(currency, account),
report_chooser=ReportChooser(ReportType.LEDGER,
currency=currency,
account=account,
period=period),
data_rows=data_rows,
is_paged=True,
filler=filler,
brought_forward=brought_forward,
total=total)
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
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.ledger-default",
currency=currency, account=self.account)
return url_for("accounting.report.ledger",
currency=currency, account=self.account,
period=self.period)
in_use: sa.Select = sa.Select(JournalEntry.currency_code)\
.group_by(JournalEntry.currency_code)
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.ledger-default",
currency=self.currency, account=account)
return url_for("accounting.report.ledger",
currency=self.currency, account=account,
period=self.period)
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
.filter(JournalEntry.currency_code == self.currency.code)\
.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()]
class IncomeExpensesParams(ReportParams[IncomeExpensesRow]):
"""The parameters of an income and expenses page."""
def __init__(self,
currency: Currency,
account: Account,
period: Period,
data_rows: list[IncomeExpensesRow],
filler: t.Callable[[list[IncomeExpensesRow]], None],
brought_forward: IncomeExpensesRow | None,
total: IncomeExpensesRow):
"""Constructs the parameters for the income and expenses page.
:param currency: The currency.
:param account: The account.
:param period: The period.
:param data_rows: The data rows.
:param filler: The callback to fill in the related data to the rows.
:param brought_forward: The brought-forward row, if any.
:param total: The total row, if any.
"""
super().__init__(
period_chooser=LedgerPeriodChooser(currency, account),
report_chooser=ReportChooser(ReportType.INCOME_EXPENSES,
currency=currency,
account=account,
period=period),
data_rows=data_rows,
is_paged=True,
filler=filler,
brought_forward=brought_forward,
total=total)
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.period: Period = 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-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: sa.Select = sa.Select(JournalEntry.account_id)\
.join(Account)\
.filter(JournalEntry.currency_code == self.currency.code,
sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"),
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()]
class TrialBalanceParams(ReportParams[TrialBalanceRow]):
"""The parameters of a trial balance page."""
def __init__(self,
currency: Currency,
period: Period,
data_rows: list[TrialBalanceRow],
total: TrialBalanceRow):
"""Constructs the parameters for the trial balance 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=TrialBalancePeriodChooser(currency),
report_chooser=ReportChooser(ReportType.TRIAL_BALANCE,
currency=currency,
period=period),
data_rows=data_rows,
is_paged=False,
total=total)
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.trial-balance-default",
currency=currency)
return url_for("accounting.report.trial-balance",
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()]
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.
"""
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

@ -1,261 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# 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 rows of the reports.
"""
import typing as t
from abc import ABC, abstractmethod
from datetime import date
from decimal import Decimal
from accounting.locale import gettext
from accounting.models import JournalEntry, Transaction, Account, Currency
class ReportRow(ABC):
"""A row in the report."""
@abstractmethod
def as_dict(self) -> dict[str, t.Any]:
"""Returns the row as a dictionary.
:return: None.
"""
class JournalRow(ReportRow):
"""A row in the journal."""
def __init__(self, entry: JournalEntry):
"""Constructs the row in the journal.
:param entry: The journal entry.
"""
self.entry: JournalEntry = entry
"""The journal entry."""
self.transaction: Transaction | None = None
"""The transaction."""
self.currency: Currency | None = None
"""The currency."""
self.account: Account | None = None
"""The account."""
self.summary: str | None = entry.summary
"""The summary."""
self.debit: Decimal | None = entry.amount if entry.is_debit else None
"""The debit amount."""
self.credit: Decimal | None = None if entry.is_debit else entry.amount
"""The credit amount."""
self.amount: Decimal = entry.amount
"""The amount."""
def as_dict(self) -> dict[str, t.Any]:
return {"Date": self.transaction.date,
"Currency": self.currency.code,
"Account": str(self.account).title(),
"Summary": self.summary,
"Debit": self.debit,
"Credit": self.credit,
"Note": self.transaction.note}
class LedgerRow(ReportRow):
"""A row in the ledger."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the row in the ledger.
: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.summary: str | None = None
"""The summary."""
self.debit: Decimal | None = None
"""The debit amount."""
self.credit: Decimal | None = None
"""The credit 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.debit = entry.amount if entry.is_debit else None
self.credit = None if entry.is_debit else entry.amount
def as_dict(self) -> dict[str, t.Any]:
if self.is_total:
return {"Date": "Total",
"Summary": None,
"Debit": self.debit,
"Credit": self.credit,
"Balance": self.balance,
"Note": None}
return {"Date": self.date,
"Summary": self.summary,
"Debit": self.debit,
"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).title(),
"Summary": self.summary,
"Income": self.income,
"Expense": self.expense,
"Balance": self.balance,
"Note": self.note}
class TrialBalanceRow(ReportRow):
"""A row in the trial balance."""
def __init__(self, account: Account | None = None,
balance: Decimal | None = None):
"""Constructs the row in the trial balance.
:param account: The account.
:param balance: The balance.
"""
self.is_total: bool = False
"""Whether this is the total row."""
self.account: Account | None = account
"""The account."""
self.debit: Decimal | None = None
"""The debit amount."""
self.credit: Decimal | None = None
"""The credit amount."""
self.url: str | None = None
"""The URL."""
if balance is not None:
if balance > 0:
self.debit = balance
if balance < 0:
self.credit = -balance
def as_dict(self) -> dict[str, t.Any]:
if self.is_total:
return {"Account": gettext("Total"),
"Debit": self.debit,
"Credit": self.credit}
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."""
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

@ -1,726 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# 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 reports.
"""
import csv
import typing as t
from abc import ABC, abstractmethod
from decimal import Decimal
from io import StringIO
import sqlalchemy as sa
from flask import Response, render_template, url_for
from accounting import db
from accounting.locale import gettext
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, IncomeStatementParams
from accounting.report.report_rows import JournalRow, LedgerRow, \
IncomeExpensesRow, TrialBalanceRow, IncomeStatementRow
T = t.TypeVar("T")
class Report(t.Generic[T], ABC):
"""A report."""
def __init__(self):
"""Constructs a report."""
self.data_rows: list[T]
"""The data rows."""
self.brought_forward: T | None
"""The brought-forward row."""
self.total: T | None
"""The total row."""
self.data_rows, self.brought_forward, self.total = self.get_rows()
@abstractmethod
def get_rows(self) -> tuple[list[T], T | None, T | None]:
"""Returns the data rows, the brought-forward row, and the total row.
:return: The data rows, the brought-forward row, and the total row.
"""
@staticmethod
@abstractmethod
def populate_rows(rows: list[JournalRow]) -> None:
"""Fills in the related data to the data rows.
:param rows: The data rows.
:return: None.
"""
@property
@abstractmethod
def csv_field_names(self) -> list[str]:
"""Returns the CSV field names.
:return: The CSV field names.
"""
@property
@abstractmethod
def csv_filename(self) -> str:
"""Returns the CSV download file name.
:return: The CSV download file name.
"""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
rows: list[T] = []
if self.brought_forward is not None:
rows.append(self.brought_forward)
rows.extend(self.data_rows)
if self.total is not None:
rows.append(self.total)
self.populate_rows(rows)
with StringIO() as fp:
writer: csv.DictWriter = csv.DictWriter(
fp, fieldnames=self.csv_field_names)
writer.writeheader()
writer.writerows([x.as_dict() for x in rows])
fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \
= f"attachment; filename={self.csv_filename}"
return response
@abstractmethod
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
class Journal(Report[JournalRow]):
"""The journal."""
def __init__(self, period: Period):
"""Constructs a journal.
:param period: The period.
"""
self.period: Period = period
"""The period."""
super().__init__()
def get_rows(self) -> tuple[list[T], T | None, T | None]:
conditions: list[sa.BinaryExpression] = []
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)
rows: list[JournalRow] = [JournalRow(x) for x in db.session
.query(JournalEntry)
.join(Transaction)
.filter(*conditions)
.order_by(Transaction.date,
JournalEntry.is_debit.desc(),
JournalEntry.no).all()]
return rows, None, None
@staticmethod
def populate_rows(rows: list[JournalRow]) -> 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}))}
accounts: dict[int, Account] \
= {x.id: x for x in Account.query.filter(
Account.id.in_({x.entry.account_id for x in rows}))}
currencies: dict[int, Currency] \
= {x.code: x for x in Currency.query.filter(
Currency.code.in_({x.entry.currency_code for x in rows}))}
for row in rows:
row.transaction = transactions[row.entry.transaction_id]
row.account = accounts[row.entry.account_id]
row.currency = currencies[row.entry.currency_code]
@property
def csv_field_names(self) -> list[str]:
return ["Date", "Currency", "Account", "Summary", "Debit", "Credit",
"Note"]
@property
def csv_filename(self) -> str:
return f"journal-{self.period.spec}.csv"
def html(self) -> str:
params: JournalParams = JournalParams(
period=self.period,
data_rows=self.data_rows,
filler=self.populate_rows)
return render_template("accounting/report/journal.html",
report=params)
class Ledger(Report[LedgerRow]):
"""The ledger."""
def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs a ledger.
:param currency: The currency.
:param account: The account.
:param period: The period.
"""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.period: Period = period
"""The period."""
super().__init__()
def get_rows(self) -> tuple[list[T], T | None, T | None]:
brought_forward: LedgerRow | None = self.__get_brought_forward_row()
rows: list[LedgerRow] = [LedgerRow(x) for x in self.__query_entries()]
total: LedgerRow = self.__get_total_row(brought_forward, rows)
self.__populate_balance(brought_forward, rows)
return rows, brought_forward, total
def __get_brought_forward_row(self) -> LedgerRow | None:
"""Queries, composes and returns the brought-forward row.
:return: The brought-forward row, or None if the ledger 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: LedgerRow = LedgerRow()
row.date = self.period.start
row.summary = gettext("Brought forward")
if balance > 0:
row.debit = balance
elif balance < 0:
row.credit = -balance
row.balance = balance
return row
def __query_entries(self) -> list[JournalEntry]:
"""Queries and returns the journal entries.
:return: The journal entries.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.currency.code,
JournalEntry.account_id == self.account.id]
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)
return db.session.query(JournalEntry).join(Transaction)\
.filter(*conditions)\
.order_by(Transaction.date,
JournalEntry.is_debit.desc(),
JournalEntry.no).all()
@staticmethod
def __get_total_row(brought_forward: LedgerRow | None,
rows: list[LedgerRow]) -> LedgerRow:
"""Composes the total row.
:param brought_forward: The brought-forward row.
:param rows: The rows.
:return: None.
"""
row: LedgerRow = LedgerRow()
row.is_total = True
row.summary = gettext("Total")
row.debit = sum([x.debit for x in rows if x.debit is not None])
row.credit = sum([x.credit for x in rows if x.credit is not None])
row.balance = row.debit - row.credit
if brought_forward is not None:
row.balance = brought_forward.balance + row.balance
return row
@staticmethod
def __populate_balance(brought_forward: LedgerRow | None,
rows: list[LedgerRow]) -> 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.debit is not None:
balance = balance + row.debit
if row.credit is not None:
balance = balance - row.credit
row.balance = balance
@staticmethod
def populate_rows(rows: list[LedgerRow]) -> 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}))}
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
@property
def csv_field_names(self) -> list[str]:
return ["Date", "Summary", "Debit", "Credit", "Balance", "Note"]
@property
def csv_filename(self) -> str:
return "ledger-{currency}-{account}-{period}.csv".format(
currency=self.currency.code, account=self.account.code,
period=self.period.spec)
def html(self) -> str:
params: LedgerParams = LedgerParams(
currency=self.currency,
account=self.account,
period=self.period,
data_rows=self.data_rows,
filler=self.populate_rows,
brought_forward=self.brought_forward,
total=self.total)
return render_template("accounting/report/ledger.html",
report=params)
class IncomeExpenses(Report[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.
"""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.period: Period = period
"""The period."""
super().__init__()
def get_rows(self) -> tuple[list[T], T | None, T | None]:
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)
return rows, brought_forward, total
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.account = Account.find_by_code("3351-001")
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.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.currency.code,
JournalEntry.account_id == self.account.id]
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)
return JournalEntry.query.join(Transaction)\
.filter(JournalEntry.transaction_id.in_(txn_with_account),
JournalEntry.currency_code == self.currency.code,
JournalEntry.account_id != self.account.id)\
.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
@staticmethod
def populate_rows(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)
def html(self) -> str:
params: IncomeExpensesParams = IncomeExpensesParams(
currency=self.currency,
account=self.account,
period=self.period,
data_rows=self.data_rows,
filler=self.populate_rows,
brought_forward=self.brought_forward,
total=self.total)
return render_template("accounting/report/income-expenses.html",
report=params)
class TrialBalance(Report[TrialBalanceRow]):
"""The trial balance."""
def __init__(self, currency: Currency, period: Period):
"""Constructs a trial balance.
: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[TrialBalanceRow] = self.__query_balances()
self.__populate_url(rows)
total_row: TrialBalanceRow = self.__get_total_row(rows)
return rows, None, total_row
def __query_balances(self) -> list[TrialBalanceRow]:
"""Queries and returns the balances.
:return: The balances.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.currency.code]
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_balances: 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_balances).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()}
return [TrialBalanceRow(accounts[x.account_id], x.balance)
for x in balances]
def __populate_url(self, rows: list[TrialBalanceRow]) -> None:
"""Populates the URL of the trial balance rows.
:param rows: The trial balance rows.
:return: None.
"""
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)
for row in rows:
row.url = get_url(row.account)
@staticmethod
def __get_total_row(rows: list[TrialBalanceRow]) -> TrialBalanceRow:
"""Composes the total row.
:param rows: The rows.
:return: None.
"""
row: TrialBalanceRow = TrialBalanceRow()
row.is_total = True
row.debit = sum([x.debit for x in rows if x.debit is not None])
row.credit = sum([x.credit for x in rows if x.credit is not None])
return row
@staticmethod
def populate_rows(rows: list[JournalRow]) -> None:
pass
@property
def csv_field_names(self) -> list[str]:
return ["Account", "Debit", "Credit"]
@property
def csv_filename(self) -> str:
return f"trial-balance-{self.period.spec}.csv"
def html(self) -> str:
params: TrialBalanceParams = TrialBalanceParams(
currency=self.currency,
period=self.period,
data_rows=self.data_rows,
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
@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

@ -0,0 +1,25 @@
# 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 reports.
"""
from .balance_sheet import BalanceSheet
from .income_expenses import IncomeExpenses
from .income_statement import IncomeStatement
from .journal import Journal
from .ledger import Ledger
from .trial_balance import TrialBalance

View File

@ -1,5 +1,5 @@
# The Mia! Accounting Flask Project. # The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023 imacat.
# #
@ -18,24 +18,22 @@
""" """
import csv import csv
import typing as t
from decimal import Decimal from decimal import Decimal
from io import StringIO from io import StringIO
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
urlunparse
import sqlalchemy as sa import sqlalchemy as sa
from flask import url_for, render_template, Response, request from flask import url_for, render_template, Response
from accounting import db from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \ from accounting.models import Currency, BaseAccount, Account, Transaction, \
JournalEntry JournalEntry
from accounting.utils.txn_types import TransactionType from accounting.report.option_link import OptionLink
from .option_link import OptionLink from accounting.report.page_params import PageParams
from .period import Period from accounting.report.period import Period
from .period_choosers import BalanceSheetPeriodChooser from accounting.report.period_choosers import BalanceSheetPeriodChooser
from .report_chooser import ReportChooser from accounting.report.report_chooser import ReportChooser
from .report_type import ReportType from accounting.report.report_type import ReportType
class BalanceSheetAccount: class BalanceSheetAccount:
@ -100,6 +98,168 @@ class BalanceSheetSection:
return sum([x.total for x in self.subsections]) return sum([x.total for x in self.subsections])
class AccountCollector:
"""The balance sheet account collector."""
def __init__(self, currency: Currency, period: Period):
"""Constructs the balance sheet account collector.
:param currency: The currency.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__period: Period = period
"""The period."""
self.accounts: list[BalanceSheetAccount] = self.__query_balances()
"""The balance sheet accounts."""
def __query_balances(self) -> list[BalanceSheetAccount]:
"""Queries and returns the balances.
:return: The balances.
"""
sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
sa.or_(*sub_conditions)]
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(Account.id, Account.base_code, Account.no,
balance_func)\
.join(Transaction).join(Account)\
.filter(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\
.order_by(Account.base_code, Account.no)
account_balances: list[sa.Row] \
= db.session.execute(select_balance).all()
self.__all_accounts: list[Account] = Account.query\
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
Account.base_code == "3351",
Account.base_code == "3353")).all()
account_by_id: dict[int, Account] \
= {x.id: x for x in self.__all_accounts}
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)
self.accounts: list[BalanceSheetAccount] \
= [BalanceSheetAccount(account=account_by_id[x.id],
amount=x.balance,
url=get_url(account_by_id[x.id]))
for x in account_balances]
self.__add_accumulated()
self.__add_current_period()
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
for balance in self.accounts:
if not balance.account.base_code.startswith("1"):
balance.amount = -balance.amount
return self.accounts
def __add_accumulated(self) -> None:
"""Adds the accumulated profit or loss to the balances.
:return: None.
"""
code: str = "3351-001"
amount: Decimal | None = self.__query_accumulated()
url: str = url_for("accounting.report.income-statement",
currency=self.__currency,
period=self.__period.before)
self.__add_owner_s_equity(code, amount, url)
def __query_accumulated(self) -> Decimal | None:
"""Queries and returns the accumulated profit or loss.
:return: The accumulated profit or loss.
"""
if self.__period.start is None:
return None
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
Transaction.date < self.__period.start]
conditions.extend([sa.not_(Account.base_code.startswith(x))
for x in {"1", "2"}])
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(balance_func)\
.join(Transaction).join(Account).filter(*conditions)
return db.session.scalar(select_balance)
def __add_current_period(self) -> None:
"""Adds the accumulated profit or loss to the balances.
:return: None.
"""
code: str = "3353-001"
amount: Decimal | None = self.__query_currency_period()
url: str = url_for("accounting.report.income-statement",
currency=self.__currency, period=self.__period)
self.__add_owner_s_equity(code, amount, url)
def __query_currency_period(self) -> Decimal | None:
"""Queries and returns the net income or loss for current period.
:return: The net income or loss for current period.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code]
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)
conditions.extend([sa.not_(Account.base_code.startswith(x))
for x in {"1", "2"}])
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(balance_func)\
.join(Transaction).join(Account).filter(*conditions)
return db.session.scalar(select_balance)
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
url: str) -> None:
"""Adds an owner's equity balance.
:param code: The code of the account to add.
:param amount: The amount.
:return: None.
"""
# There is an existing balance.
account_balance_by_code: dict[str, BalanceSheetAccount] \
= {x.account.code: x for x in self.accounts}
if code in account_balance_by_code:
balance: BalanceSheetAccount = account_balance_by_code[code]
balance.url = url
if amount is not None:
balance.amount = balance.amount + amount
return
# Add a new balance
if amount is None:
return
account_by_code: dict[str, Account] \
= {x.code: x for x in self.__all_accounts}
self.accounts.append(BalanceSheetAccount(account=account_by_code[code],
amount=amount,
url=url))
class CSVHalfRow: class CSVHalfRow:
"""A half row in the CSV balance sheet.""" """A half row in the CSV balance sheet."""
@ -139,7 +299,7 @@ class CSVRow:
self.liability_title, self.liability_amount] self.liability_title, self.liability_amount]
class BalanceSheetPageParams: class BalanceSheetPageParams(PageParams):
"""The HTML parameters of the balance sheet.""" """The HTML parameters of the balance sheet."""
def __init__(self, currency: Currency, def __init__(self, currency: Currency,
@ -161,7 +321,7 @@ class BalanceSheetPageParams:
"""The currency.""" """The currency."""
self.period: Period = period self.period: Period = period
"""The period.""" """The period."""
self.has_data: bool = has_data self.__has_data: bool = has_data
"""True if there is any data, or False otherwise.""" """True if there is any data, or False otherwise."""
self.assets: BalanceSheetSection = assets self.assets: BalanceSheetSection = assets
"""The assets.""" """The assets."""
@ -172,25 +332,24 @@ class BalanceSheetPageParams:
self.period_chooser: BalanceSheetPeriodChooser \ self.period_chooser: BalanceSheetPeriodChooser \
= BalanceSheetPeriodChooser(currency) = BalanceSheetPeriodChooser(currency)
"""The period chooser.""" """The period chooser."""
self.report_chooser: ReportChooser \
= ReportChooser(ReportType.BALANCE_SHEET,
currency=currency,
period=period)
"""The report chooser."""
self.txn_types: t.Type[TransactionType] = TransactionType
"""The transaction types."""
@property @property
def csv_uri(self) -> str: def has_data(self) -> bool:
uri: str = request.full_path if request.query_string \ """Returns whether there is any data on the page.
else request.path
uri_p: ParseResult = urlparse(uri) :return: True if there is any data, or False otherwise.
params: list[tuple[str, str]] = parse_qsl(uri_p.query) """
params = [x for x in params if x[0] != "as"] return self.__has_data
params.append(("as", "csv"))
parts: list[str] = list(uri_p) @property
parts[4] = urlencode(params) def report_chooser(self) -> ReportChooser:
return urlunparse(parts) """Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.BALANCE_SHEET,
currency=self.currency,
period=self.period)
@property @property
def currency_options(self) -> list[OptionLink]: def currency_options(self) -> list[OptionLink]:
@ -222,19 +381,28 @@ class BalanceSheet:
:param currency: The currency. :param currency: The currency.
:param period: The period. :param period: The period.
""" """
self.currency: Currency = currency self.__currency: Currency = currency
"""The currency.""" """The currency."""
self.period: Period = period self.__period: Period = period
"""The period.""" """The period."""
self.__has_data: bool
"""True if there is any data, or False otherwise."""
self.__assets: BalanceSheetSection
"""The assets."""
self.__liabilities: BalanceSheetSection
"""The liabilities."""
self.__owner_s_equity: BalanceSheetSection
"""The owner's equity."""
self.__set_data() self.__set_data()
def __set_data(self) -> None: def __set_data(self) -> None:
"""Queries and sets assets, the liabilities, and the owner's equity """Queries and sets assets, the liabilities, and the owner's equity
sections in the balance sheet. sections in the balance sheet.
:return: The assets, the liabilities, and the owner's equity sections. :return: None.
""" """
balances: list[BalanceSheetAccount] = self.__query_balances() balances: list[BalanceSheetAccount] = AccountCollector(
self.__currency, self.__period).accounts
titles: list[BaseAccount] = BaseAccount.query\ titles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({"1", "2", "3"})).all() .filter(BaseAccount.code.in_({"1", "2", "3"})).all()
@ -251,164 +419,10 @@ class BalanceSheet:
for balance in balances: for balance in balances:
subsections[balance.account.base_code[:2]].accounts.append(balance) subsections[balance.account.base_code[:2]].accounts.append(balance)
self.__has_data: bool = len(balances) > 0 self.__has_data = len(balances) > 0
self.__assets: BalanceSheetSection = sections["1"] self.__assets = sections["1"]
self.__liabilities: BalanceSheetSection = sections["2"] self.__liabilities = sections["2"]
self.__owner_s_equity: BalanceSheetSection = sections["3"] self.__owner_s_equity = sections["3"]
def __query_balances(self) -> list[BalanceSheetAccount]:
"""Queries and returns the balances.
:return: The balances.
"""
sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.currency.code,
sa.or_(*sub_conditions)]
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(Account.id, Account.base_code, Account.no,
balance_func)\
.join(Transaction).join(Account)\
.filter(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\
.order_by(Account.base_code, Account.no)
account_balances: list[sa.Row] \
= db.session.execute(select_balance).all()
account_by_id: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
Account.base_code == "3351",
Account.base_code == "3353")).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)
balances: list[BalanceSheetAccount] \
= [BalanceSheetAccount(account=account_by_id[x.id],
amount=x.balance,
url=get_url(account_by_id[x.id]))
for x in account_balances]
self.__add_accumulated(balances, list(account_by_id.values()))
self.__add_current_period(balances, list(account_by_id.values()))
for balance in balances:
if not balance.account.base_code.startswith("1"):
balance.amount = -balance.amount
return balances
def __add_accumulated(self, balances: list[BalanceSheetAccount],
accounts: list[Account]) -> None:
"""Adds the accumulated profit or loss to the balances.
:param balances: The accounts on the balance sheet.
:param accounts: The accounts.
:return: None.
"""
code: str = "3351-001"
amount: Decimal | None = self.__query_accumulated()
url: str = url_for("accounting.report.income-statement",
currency=self.currency, period=self.period.before)
self.__add_owner_s_equity(balances, accounts, code, amount, url)
def __query_accumulated(self) -> Decimal | None:
"""Queries and returns the accumulated profit or loss.
:return: The accumulated profit or loss.
"""
if self.period.start is None:
return None
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.currency.code,
Transaction.date < self.period.start]
conditions.extend([sa.not_(Account.base_code.startswith(x))
for x in {"1", "2"}])
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(balance_func)\
.join(Transaction).join(Account).filter(*conditions)
return db.session.scalar(select_balance)
def __add_current_period(self, balances: list[BalanceSheetAccount],
accounts: list[Account]) -> None:
"""Adds the accumulated profit or loss to the balances.
:param balances: The accounts on the balance sheet.
:param accounts: The accounts.
:return: None.
"""
code: str = "3353-001"
amount: Decimal | None = self.__query_currency_period()
url: str = url_for("accounting.report.income-statement",
currency=self.currency, period=self.period)
self.__add_owner_s_equity(balances, accounts, code, amount, url)
def __query_currency_period(self) -> Decimal | None:
"""Queries and returns the net income or loss for current period.
:return: The net income or loss for current period.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.currency.code]
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)
conditions.extend([sa.not_(Account.base_code.startswith(x))
for x in {"1", "2"}])
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(balance_func)\
.join(Transaction).join(Account).filter(*conditions)
return db.session.scalar(select_balance)
@staticmethod
def __add_owner_s_equity(balances: list[BalanceSheetAccount],
accounts: list[Account],
code: str,
amount: Decimal | None,
url: str):
"""Adds an owner's equity balance.
:param balances: The accounts on the balance sheet.
:param accounts: The accounts.
:param code: The code of the account to add.
:param amount: The amount.
:return: None.
"""
# There is an existing balance.
balance_by_code: dict[str, BalanceSheetAccount] \
= {x.account.code: x for x in balances}
if code in balance_by_code:
balance: BalanceSheetAccount = balance_by_code[code]
balance.url = url
if amount is not None:
balance.amount = balance.amount + amount
return
# Add a new balance
if amount is None:
return
account_by_code: dict[str, Account] = {x.code: x for x in accounts}
balances.append(BalanceSheetAccount(
account=account_by_code[code],
amount=amount,
url=url))
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
@ -416,7 +430,7 @@ class BalanceSheet:
:return: The response of the report for download. :return: The response of the report for download.
""" """
filename: str = "balance-sheet-{currency}-{period}.csv"\ filename: str = "balance-sheet-{currency}-{period}.csv"\
.format(currency=self.currency.code, period=self.period.spec) .format(currency=self.__currency.code, period=self.__period.spec)
rows: list[CSVRow] = self.__get_csv_rows() rows: list[CSVRow] = self.__get_csv_rows()
with StringIO() as fp: with StringIO() as fp:
writer = csv.writer(fp) writer = csv.writer(fp)
@ -435,10 +449,12 @@ class BalanceSheet:
asset_rows: list[CSVHalfRow] = self.__section_csv_rows(self.__assets) asset_rows: list[CSVHalfRow] = self.__section_csv_rows(self.__assets)
liability_rows: list[CSVHalfRow] = [] liability_rows: list[CSVHalfRow] = []
liability_rows.extend(self.__section_csv_rows(self.__liabilities)) liability_rows.extend(self.__section_csv_rows(self.__liabilities))
liability_rows.append(CSVHalfRow("Total", self.__liabilities.total)) liability_rows.append(CSVHalfRow(gettext("Total"),
self.__liabilities.total))
liability_rows.append(CSVHalfRow(None, None)) liability_rows.append(CSVHalfRow(None, None))
liability_rows.extend(self.__section_csv_rows(self.__owner_s_equity)) liability_rows.extend(self.__section_csv_rows(self.__owner_s_equity))
liability_rows.append(CSVHalfRow("Total", self.__owner_s_equity.total)) liability_rows.append(CSVHalfRow(gettext("Total"),
self.__owner_s_equity.total))
rows: list[CSVRow] = [CSVRow() for _ in rows: list[CSVRow] = [CSVRow() for _ in
range(max(len(asset_rows), len(liability_rows)))] range(max(len(asset_rows), len(liability_rows)))]
for i in range(len(rows)): for i in range(len(rows)):
@ -449,9 +465,9 @@ class BalanceSheet:
rows[i].liability_title = liability_rows[i].title rows[i].liability_title = liability_rows[i].title
rows[i].liability_amount = liability_rows[i].amount rows[i].liability_amount = liability_rows[i].amount
total: CSVRow = CSVRow() total: CSVRow = CSVRow()
total.asset_title = "Total" total.asset_title = gettext("Total")
total.asset_amount = self.__assets.total total.asset_amount = self.__assets.total
total.liability_title = "Total" total.liability_title = gettext("Total")
total.liability_amount \ total.liability_amount \
= self.__liabilities.total + self.__owner_s_equity.total = self.__liabilities.total + self.__owner_s_equity.total
rows.append(total) rows.append(total)
@ -479,8 +495,8 @@ class BalanceSheet:
:return: The report as HTML. :return: The report as HTML.
""" """
params: BalanceSheetPageParams = BalanceSheetPageParams( params: BalanceSheetPageParams = BalanceSheetPageParams(
currency=self.currency, currency=self.__currency,
period=self.period, period=self.__period,
has_data=self.__has_data, has_data=self.__has_data,
assets=self.__assets, assets=self.__assets,
liabilities=self.__liabilities, liabilities=self.__liabilities,

View File

@ -0,0 +1,466 @@
# 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 income and expenses log.
"""
import csv
from datetime import date
from decimal import Decimal
from io import StringIO
import sqlalchemy as sa
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.option_link import OptionLink
from accounting.report.page_params import PageParams
from accounting.report.period import Period
from accounting.report.period_choosers import IncomeExpensesPeriodChooser
from accounting.report.report_chooser import ReportChooser
from accounting.report.report_type import ReportType
from accounting.utils.pagination import Pagination
class Entry:
"""An entry in the income and expenses log."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the income and expenses log.
:param entry: The journal entry.
"""
self.entry: JournalEntry | None = None
"""The journal entry."""
self.transaction: Transaction | None = None
"""The transaction."""
self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry."""
self.is_total: bool = False
"""Whether this is the total entry."""
self.date: date | None = None
"""The date."""
self.account: Account | None = None
"""The account."""
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
class EntryCollector:
"""The income and expenses log entry collector."""
def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs the income and expenses log entry collector.
:param currency: The currency.
:param account: The account.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
self.__period: Period = period
"""The period"""
self.brought_forward: Entry | None
"""The brought-forward entry."""
self.entries: list[Entry]
"""The log entries."""
self.total: Entry
"""The total entry."""
self.brought_forward = self.__get_brought_forward_entry()
self.entries = self.__query_entries()
self.total = self.__get_total_entry()
self.__populate_balance()
def __get_brought_forward_entry(self) -> Entry | None:
"""Queries, composes and returns the brought-forward entry.
:return: The brought-forward entry, or None if the period 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
entry: Entry = Entry()
entry.is_brought_forward = True
entry.date = self.__period.start
entry.account = Account.find_by_code("3351-001")
entry.summary = gettext("Brought forward")
if balance > 0:
entry.income = balance
elif balance < 0:
entry.expense = -balance
entry.balance = balance
return entry
def __query_entries(self) -> list[Entry]:
"""Queries and returns the log entries.
:return: The log entries.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
JournalEntry.account_id == self.__account.id]
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)
return [Entry(x)
for x in JournalEntry.query.join(Transaction)
.filter(JournalEntry.transaction_id.in_(txn_with_account),
JournalEntry.currency_code == self.__currency.code,
JournalEntry.account_id != self.__account.id)
.order_by(Transaction.date,
JournalEntry.is_debit,
JournalEntry.no)]
def __get_total_entry(self) -> Entry:
"""Composes the total entry.
:return: None.
"""
entry: Entry = Entry()
entry.is_total = True
entry.summary = gettext("Total")
entry.income = sum([x.income for x in self.entries
if x.income is not None])
entry.expense = sum([x.expense for x in self.entries
if x.expense is not None])
entry.balance = entry.income - entry.expense
if self.brought_forward is not None:
entry.balance = self.brought_forward.balance + entry.balance
return entry
def __populate_balance(self) -> None:
"""Populates the balance of the entries.
:return: None.
"""
balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.balance
for entry in self.entries:
if entry.income is not None:
balance = balance + entry.income
if entry.expense is not None:
balance = balance - entry.expense
entry.balance = balance
class CSVRow:
"""A row in the CSV income and expenses log."""
def __init__(self, txn_date: date | str | None,
account: str | None,
summary: str | None,
income: str | Decimal | None,
expense: str | Decimal | None,
balance: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV income and expenses log.
:param txn_date: The transaction date.
:param account: The account.
:param summary: The summary.
:param income: The income.
:param expense: The expense.
:param balance: The balance.
:param note: The note.
"""
self.date: date | str | None = txn_date
"""The date."""
self.account: str | None = account
"""The account."""
self.summary: str | None = summary
"""The summary."""
self.income: str | Decimal | None = income
"""The income."""
self.expense: str | Decimal | None = expense
"""The expense."""
self.balance: str | Decimal | None = balance
"""The balance."""
self.note: str | None = note
"""The note."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.account, self.summary,
self.income, self.expense, self.balance, self.note]
class IncomeExpensesPageParams(PageParams):
"""The HTML parameters of the income and expenses log."""
def __init__(self, currency: Currency,
account: Account,
period: Period,
has_data: bool,
pagination: Pagination[Entry],
brought_forward: Entry | None,
entries: list[Entry],
total: Entry | None):
"""Constructs the HTML parameters of the income and expenses log.
:param currency: The currency.
:param account: The account.
:param period: The period.
:param has_data: True if there is any data, or False otherwise.
:param brought_forward: The brought-forward entry.
:param entries: The log entries.
:param total: The total entry.
"""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.period: Period = period
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.pagination: Pagination[Entry] = pagination
"""The pagination."""
self.brought_forward: Entry | None = brought_forward
"""The brought-forward entry."""
self.entries: list[Entry] = entries
"""The entries."""
self.total: Entry | None = total
"""The total entry."""
self.period_chooser: IncomeExpensesPeriodChooser \
= IncomeExpensesPeriodChooser(currency, account)
"""The period chooser."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return self.__has_data
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.INCOME_EXPENSES,
currency=self.currency,
account=self.account,
period=self.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-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: sa.Select = sa.Select(JournalEntry.account_id)\
.join(Account)\
.filter(JournalEntry.currency_code == self.currency.code,
sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"),
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()]
def _populate_entries(entries: list[Entry]) -> None:
"""Populates the income and expenses entries with relative data.
:param entries: The income and expenses entries.
:return: None.
"""
transactions: dict[int, Transaction] \
= {x.id: x for x in Transaction.query.filter(
Transaction.id.in_({x.entry.transaction_id for x in entries
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 entries
if x.entry is not None}))}
for entry in entries:
if entry.entry is not None:
entry.transaction = transactions[entry.entry.transaction_id]
entry.date = entry.transaction.date
entry.note = entry.transaction.note
entry.account = accounts[entry.entry.account_id]
class IncomeExpenses:
"""The income and expenses log."""
def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs an income and expenses log.
:param currency: The currency.
:param account: The account.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
self.__period: Period = period
"""The period."""
collector: EntryCollector = EntryCollector(
self.__currency, self.__account, self.__period)
self.__brought_forward: Entry | None = collector.brought_forward
"""The brought-forward entry."""
self.__entries: list[Entry] = collector.entries
"""The log entries."""
self.__total: Entry = collector.total
"""The total entry."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "income-expenses-{currency}-{account}-{period}.csv"\
.format(currency=self.__currency.code, account=self.__account.code,
period=self.__period.spec)
rows: list[CSVRow] = self.__get_csv_rows()
with StringIO() as fp:
writer = csv.writer(fp)
writer.writerows([x.values for x in rows])
fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \
= f"attachment; filename={filename}"
return response
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
_populate_entries(self.__entries)
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
gettext("Summary"), gettext("Income"),
gettext("Expense"), gettext("Balance"),
gettext("Note"))]
if self.__brought_forward is not None:
rows.append(CSVRow(self.__brought_forward.date,
str(self.__brought_forward.account).title(),
self.__brought_forward.summary,
self.__brought_forward.income,
self.__brought_forward.expense,
self.__brought_forward.balance,
None))
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
x.income, x.expense, x.balance, x.note)
for x in self.__entries])
rows.append(CSVRow(gettext("Total"), None, None,
self.__total.income, self.__total.expense,
self.__total.balance, None))
return rows
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
all_entries: list[Entry] = []
if self.__brought_forward is not None:
all_entries.append(self.__brought_forward)
all_entries.extend(self.__entries)
all_entries.append(self.__total)
pagination: Pagination[Entry] = Pagination[Entry](all_entries)
page_entries: list[Entry] = pagination.list
has_data: bool = len(page_entries) > 0
_populate_entries(page_entries)
brought_forward: Entry | None = None
if page_entries[0].is_brought_forward:
brought_forward = page_entries[0]
page_entries = page_entries[1:]
total: Entry | None = None
if page_entries[-1].is_total:
total = page_entries[-1]
page_entries = page_entries[:-1]
params: IncomeExpensesPageParams = IncomeExpensesPageParams(
currency=self.__currency,
account=self.__account,
period=self.__period,
has_data=has_data,
pagination=pagination,
brought_forward=brought_forward,
entries=page_entries,
total=total)
return render_template("accounting/report/income-expenses.html",
report=params)

View File

@ -0,0 +1,356 @@
# 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 income statement.
"""
import csv
from decimal import Decimal
from io import StringIO
import sqlalchemy as sa
from flask import url_for, render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \
JournalEntry
from accounting.report.option_link import OptionLink
from accounting.report.page_params import PageParams
from accounting.report.period import Period
from accounting.report.period_choosers import IncomeStatementPeriodChooser
from accounting.report.report_chooser import ReportChooser
from accounting.report.report_type import ReportType
class IncomeStatementAccount:
"""An account in the income statement."""
def __init__(self, account: Account, amount: Decimal, url: str):
"""Constructs an account in the income statement.
:param account: The account.
:param amount: The amount.
:param url: The URL to the ledger of the account.
"""
self.account: Account = account
"""The account."""
self.amount: Decimal = amount
"""The amount of the account."""
self.url: str = url
"""The URL to the ledger of the account."""
class IncomeStatementAccumulatedTotal:
"""An accumulated total in the income statement."""
def __init__(self, title: str):
"""Constructs an accumulated total in the income statement.
:param title: The title.
"""
self.title: str = title
"""The account."""
self.amount: Decimal = Decimal("0")
"""The amount of the account."""
class IncomeStatementSubsection:
"""A subsection in the income statement."""
def __init__(self, title: BaseAccount):
"""Constructs a subsection in the income statement.
:param title: The title account.
"""
self.title: BaseAccount = title
"""The title account."""
self.accounts: list[IncomeStatementAccount] = []
"""The accounts in the subsection."""
@property
def total(self) -> Decimal:
"""Returns the total of the subsection.
:return: The total of the subsection.
"""
return sum([x.amount for x in self.accounts])
class IncomeStatementSection:
"""A section in the income statement."""
def __init__(self, title: BaseAccount, accumulated_title: str):
"""Constructs a section in the income statement.
:param title: The title account.
:param accumulated_title: The title for the accumulated total.
"""
self.title: BaseAccount = title
"""The title account."""
self.subsections: list[IncomeStatementSubsection] = []
"""The subsections in the section."""
self.accumulated: IncomeStatementAccumulatedTotal \
= IncomeStatementAccumulatedTotal(accumulated_title)
@property
def total(self) -> Decimal:
"""Returns the total of the section.
:return: The total of the section.
"""
return sum([x.total for x in self.subsections])
class CSVRow:
"""A row in the CSV income statement."""
def __init__(self, text: str | None, amount: str | Decimal | None):
"""Constructs a row in the CSV income statement.
:param text: The text.
:param amount: The amount.
"""
self.text: str | None = text
"""The text."""
self.amount: str | Decimal | None = amount
"""The amount."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.text, self.amount]
class IncomeStatementPageParams(PageParams):
"""The HTML parameters of the income statement."""
def __init__(self, currency: Currency,
period: Period,
has_data: bool,
sections: list[IncomeStatementSection],):
"""Constructs the HTML parameters of the income statement.
:param currency: The currency.
:param period: The period.
:param has_data: True if there is any data, or False otherwise.
"""
self.currency: Currency = currency
"""The currency."""
self.period: Period = period
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.sections: list[IncomeStatementSection] = sections
self.period_chooser: IncomeStatementPeriodChooser \
= IncomeStatementPeriodChooser(currency)
"""The period chooser."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return self.__has_data
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.INCOME_STATEMENT,
currency=self.currency,
period=self.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()]
class IncomeStatement:
"""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."""
self.__has_data: bool
"""True if there is any data, or False otherwise."""
self.__sections: list[IncomeStatementSection]
"""The sections."""
self.__set_data()
def __set_data(self) -> None:
"""Queries and sets data sections in the income statement.
:return: None.
"""
balances: list[IncomeStatementAccount] = self.__query_balances()
titles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all()
subtitles: list[BaseAccount] = BaseAccount.query\
.filter(BaseAccount.code.in_({x.account.base_code[:2]
for x in balances})).all()
total_titles: dict[str, str] \
= {"4": gettext("total revenue"),
"5": gettext("gross income"),
"6": gettext("operating income"),
"7": gettext("before tax income"),
"8": gettext("after tax income"),
"9": gettext("net income or loss for current period")}
sections: dict[str, IncomeStatementSection] \
= {x.code: IncomeStatementSection(x, total_titles[x.code])
for x in titles}
subsections: dict[str, IncomeStatementSubsection] \
= {x.code: IncomeStatementSubsection(x) for x in subtitles}
for subsection in subsections.values():
sections[subsection.title.code[0]].subsections.append(subsection)
for balance in balances:
subsections[balance.account.base_code[:2]].accounts.append(balance)
self.__has_data = len(balances) > 0
self.__sections = sorted(sections.values(), key=lambda x: x.title.code)
total: Decimal = Decimal("0")
for section in self.__sections:
total = total + section.total
section.accumulated.amount = total
def __query_balances(self) -> list[IncomeStatementAccount]:
"""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 [IncomeStatementAccount(account=accounts[x.account_id],
amount=x.balance,
url=get_url(accounts[x.account_id]))
for x in balances]
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "income-statement-{currency}-{period}.csv"\
.format(currency=self.__currency.code, period=self.__period.spec)
rows: list[CSVRow] = self.__get_csv_rows()
with StringIO() as fp:
writer = csv.writer(fp)
writer.writerows([x.values for x in rows])
fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \
= f"attachment; filename={filename}"
return response
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
total_str: str = gettext("Total")
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
for section in self.__sections:
rows.append(CSVRow(str(section.title).title(), None))
for subsection in section.subsections:
rows.append(CSVRow(f" {str(subsection.title).title()}", None))
for account in subsection.accounts:
rows.append(CSVRow(f" {str(account.account).title()}",
account.amount))
rows.append(CSVRow(f" {total_str}", subsection.total))
rows.append(CSVRow(section.accumulated.title.title(),
section.accumulated.amount))
rows.append(CSVRow(None, None))
rows = rows[:-1]
return rows
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
params: IncomeStatementPageParams = IncomeStatementPageParams(
currency=self.__currency,
period=self.__period,
has_data=self.__has_data,
sections=self.__sections)
return render_template("accounting/report/income-statement.html",
report=params)

View File

@ -0,0 +1,251 @@
# 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 journal.
"""
import csv
from datetime import date
from decimal import Decimal
from io import StringIO
import sqlalchemy as sa
from flask import render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.page_params import PageParams
from accounting.report.period import Period
from accounting.report.period_choosers import JournalPeriodChooser
from accounting.report.report_chooser import ReportChooser
from accounting.report.report_type import ReportType
from accounting.utils.pagination import Pagination
class Entry:
"""An entry in the journal."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the journal.
: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 entry."""
self.currency: Currency | None = None
"""The account."""
self.account: Account | None = None
"""The account."""
self.summary: str | None = None
"""The summary."""
self.debit: Decimal | None = None
"""The debit amount."""
self.credit: Decimal | None = None
"""The credit amount."""
self.amount: Decimal | None = None
"""The amount."""
if entry is not None:
self.entry = entry
self.summary = entry.summary
self.debit = entry.amount if entry.is_debit else None
self.credit = None if entry.is_debit else entry.amount
self.amount = entry.amount
class CSVRow:
"""A row in the CSV journal."""
def __init__(self, txn_date: str | date,
currency: str,
account: str,
summary: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV journal.
:param txn_date: The transaction date.
:param summary: The summary.
:param debit: The debit amount.
:param credit: The credit amount.
:param note: The note.
"""
self.date: str | date = txn_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.account: str = account
"""The account."""
self.summary: str | None = summary
"""The summary."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
"""The credit amount."""
self.note: str | None = note
"""The note."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.currency, self.account, self.summary,
self.debit, self.credit, self.note]
class JournalPageParams(PageParams):
"""The HTML parameters of the journal."""
def __init__(self, period: Period,
pagination: Pagination[Entry],
entries: list[Entry]):
"""Constructs the HTML parameters of the journal.
:param period: The period.
:param entries: The journal entries.
"""
self.period: Period = period
"""The period."""
self.pagination: Pagination[Entry] = pagination
"""The pagination."""
self.entries: list[Entry] = entries
"""The entries."""
self.period_chooser: JournalPeriodChooser \
= JournalPeriodChooser()
"""The period chooser."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.entries) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.JOURNAL,
period=self.period)
def _populate_entries(entries: list[Entry]) -> None:
"""Populates the journal entries with relative data.
:param entries: The journal entries.
:return: None.
"""
transactions: dict[int, Transaction] \
= {x.id: x for x in Transaction.query.filter(
Transaction.id.in_({x.entry.transaction_id for x in entries}))}
accounts: dict[int, Account] \
= {x.id: x for x in Account.query.filter(
Account.id.in_({x.entry.account_id for x in entries}))}
currencies: dict[int, Currency] \
= {x.code: x for x in Currency.query.filter(
Currency.code.in_({x.entry.currency_code for x in entries}))}
for entry in entries:
entry.transaction = transactions[entry.entry.transaction_id]
entry.account = accounts[entry.entry.account_id]
entry.currency = currencies[entry.entry.currency_code]
class Journal:
"""The journal."""
def __init__(self, period: Period):
"""Constructs a journal.
:param period: The period.
"""
"""The account."""
self.__period: Period = period
"""The period."""
self.__entries: list[Entry] = self.__query_entries()
"""The journal entries."""
def __query_entries(self) -> list[Entry]:
"""Queries and returns the journal entries.
:return: The journal entries.
"""
conditions: list[sa.BinaryExpression] = []
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)
return [Entry(x) for x in db.session
.query(JournalEntry).join(Transaction).filter(*conditions)
.order_by(Transaction.date,
JournalEntry.is_debit.desc(),
JournalEntry.no).all()]
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = f"journal-{self.__period.spec}.csv"
rows: list[CSVRow] = self.__get_csv_rows()
with StringIO() as fp:
writer = csv.writer(fp)
writer.writerows([x.values for x in rows])
fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \
= f"attachment; filename={filename}"
return response
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
_populate_entries(self.__entries)
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Account"), gettext("Summary"),
gettext("Debit"), gettext("Credit"),
gettext("Note"))]
rows.extend([CSVRow(x.transaction.date, x.currency.code,
str(x.account).title(), x.summary,
x.debit, x.credit, x.transaction.note)
for x in self.__entries])
return rows
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[Entry] = Pagination[Entry](self.__entries)
page_entries: list[Entry] = pagination.list
_populate_entries(page_entries)
params: JournalPageParams = JournalPageParams(
period=self.__period,
pagination=pagination,
entries=page_entries)
return render_template("accounting/report/journal.html",
report=params)

View File

@ -0,0 +1,443 @@
# 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 ledger.
"""
import csv
from datetime import date
from decimal import Decimal
from io import StringIO
import sqlalchemy as sa
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.option_link import OptionLink
from accounting.report.page_params import PageParams
from accounting.report.period import Period
from accounting.report.period_choosers import LedgerPeriodChooser
from accounting.report.report_chooser import ReportChooser
from accounting.report.report_type import ReportType
from accounting.utils.pagination import Pagination
class Entry:
"""An entry in the ledger."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the ledger.
:param entry: The journal entry.
"""
self.entry: JournalEntry | None = None
"""The journal entry."""
self.transaction: Transaction | None = None
"""The transaction."""
self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry."""
self.is_total: bool = False
"""Whether this is the total entry."""
self.date: date | None = None
"""The date."""
self.account: Account | None = None
"""The account."""
self.summary: str | None = None
"""The summary."""
self.debit: Decimal | None = None
"""The debit amount."""
self.credit: Decimal | None = None
"""The credit 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.debit = entry.amount if entry.is_debit else None
self.credit = None if entry.is_debit else entry.amount
class EntryCollector:
"""The ledger entry collector."""
def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs the ledger entry collector.
:param currency: The currency.
:param account: The account.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
self.__period: Period = period
"""The period"""
self.brought_forward: Entry | None
"""The brought-forward entry."""
self.entries: list[Entry]
"""The ledger entries."""
self.total: Entry
"""The total entry."""
self.brought_forward = self.__get_brought_forward_entry()
self.entries = self.__query_entries()
self.total = self.__get_total_entry()
self.__populate_balance()
def __get_brought_forward_entry(self) -> Entry | None:
"""Queries, composes and returns the brought-forward entry.
:return: The brought-forward entry, or None if the ledger 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
entry: Entry = Entry()
entry.is_brought_forward = True
entry.date = self.__period.start
entry.summary = gettext("Brought forward")
if balance > 0:
entry.debit = balance
elif balance < 0:
entry.credit = -balance
entry.balance = balance
return entry
def __query_entries(self) -> list[Entry]:
"""Queries and returns the ledger entries.
:return: The ledger entries.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code,
JournalEntry.account_id == self.__account.id]
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)
return [Entry(x) for x in JournalEntry.query.join(Transaction)
.filter(*conditions)
.order_by(Transaction.date,
JournalEntry.is_debit.desc(),
JournalEntry.no).all()]
def __get_total_entry(self) -> Entry:
"""Composes the total entry.
:return: None.
"""
entry: Entry = Entry()
entry.is_total = True
entry.summary = gettext("Total")
entry.debit = sum([x.debit for x in self.entries
if x.debit is not None])
entry.credit = sum([x.credit for x in self.entries
if x.credit is not None])
entry.balance = entry.debit - entry.credit
if self.brought_forward is not None:
entry.balance = self.brought_forward.balance + entry.balance
return entry
def __populate_balance(self) -> None:
"""Populates the balance of the entries.
:return: None.
"""
balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.balance
for entry in self.entries:
if entry.debit is not None:
balance = balance + entry.debit
if entry.credit is not None:
balance = balance - entry.credit
entry.balance = balance
class CSVRow:
"""A row in the CSV ledger."""
def __init__(self, txn_date: date | str | None,
summary: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None,
balance: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV ledger.
:param txn_date: The transaction date.
:param summary: The summary.
:param debit: The debit amount.
:param credit: The credit amount.
:param balance: The balance.
:param note: The note.
"""
self.date: date | str | None = txn_date
"""The date."""
self.summary: str | None = summary
"""The summary."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
"""The credit amount."""
self.balance: str | Decimal | None = balance
"""The balance."""
self.note: str | None = note
"""The note."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.summary,
self.debit, self.credit, self.balance, self.note]
class LedgerPageParams(PageParams):
"""The HTML parameters of the ledger."""
def __init__(self, currency: Currency,
account: Account,
period: Period,
has_data: bool,
pagination: Pagination[Entry],
brought_forward: Entry | None,
entries: list[Entry],
total: Entry | None):
"""Constructs the HTML parameters of the ledger.
:param currency: The currency.
:param account: The account.
:param period: The period.
:param has_data: True if there is any data, or False otherwise.
:param brought_forward: The brought-forward entry.
:param entries: The ledger entries.
:param total: The total entry.
"""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.period: Period = period
"""The period."""
self.__has_data: bool = has_data
"""True if there is any data, or False otherwise."""
self.pagination: Pagination[Entry] = pagination
"""The pagination."""
self.brought_forward: Entry | None = brought_forward
"""The brought-forward entry."""
self.entries: list[Entry] = entries
"""The entries."""
self.total: Entry | None = total
"""The total entry."""
self.period_chooser: LedgerPeriodChooser \
= LedgerPeriodChooser(currency, account)
"""The period chooser."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return self.__has_data
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.LEDGER,
currency=self.currency,
account=self.account,
period=self.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.ledger-default",
currency=currency, account=self.account)
return url_for("accounting.report.ledger",
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.ledger-default",
currency=self.currency, account=account)
return url_for("accounting.report.ledger",
currency=self.currency, account=account,
period=self.period)
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
.filter(JournalEntry.currency_code == self.currency.code)\
.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()]
def _populate_entries(entries: list[Entry]) -> None:
"""Populates the ledger entries with relative data.
:param entries: The ledger entries.
:return: None.
"""
transactions: dict[int, Transaction] \
= {x.id: x for x in Transaction.query.filter(
Transaction.id.in_({x.entry.transaction_id for x in entries
if x.entry is not None}))}
for entry in entries:
if entry.entry is not None:
entry.transaction = transactions[entry.entry.transaction_id]
entry.date = entry.transaction.date
entry.note = entry.transaction.note
class Ledger:
"""The ledger."""
def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs a ledger.
:param currency: The currency.
:param account: The account.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: Account = account
"""The account."""
self.__period: Period = period
"""The period."""
collector: EntryCollector = EntryCollector(
self.__currency, self.__account, self.__period)
self.__brought_forward: Entry | None = collector.brought_forward
"""The brought-forward entry."""
self.__entries: list[Entry] = collector.entries
"""The ledger entries."""
self.__total: Entry = collector.total
"""The total entry."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "ledger-{currency}-{account}-{period}.csv"\
.format(currency=self.__currency.code, account=self.__account.code,
period=self.__period.spec)
rows: list[CSVRow] = self.__get_csv_rows()
with StringIO() as fp:
writer = csv.writer(fp)
writer.writerows([x.values for x in rows])
fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \
= f"attachment; filename={filename}"
return response
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
_populate_entries(self.__entries)
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
gettext("Debit"), gettext("Credit"),
gettext("Balance"), gettext("Note"))]
if self.__brought_forward is not None:
rows.append(CSVRow(self.__brought_forward.date,
self.__brought_forward.summary,
self.__brought_forward.debit,
self.__brought_forward.credit,
self.__brought_forward.balance,
None))
rows.extend([CSVRow(x.date, x.summary,
x.debit, x.credit, x.balance, x.note)
for x in self.__entries])
rows.append(CSVRow(gettext("Total"), None,
self.__total.debit, self.__total.credit,
self.__total.balance, None))
return rows
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
all_entries: list[Entry] = []
if self.__brought_forward is not None:
all_entries.append(self.__brought_forward)
all_entries.extend(self.__entries)
all_entries.append(self.__total)
pagination: Pagination[Entry] = Pagination[Entry](all_entries)
page_entries: list[Entry] = pagination.list
has_data: bool = len(page_entries) > 0
_populate_entries(page_entries)
brought_forward: Entry | None = None
if page_entries[0].is_brought_forward:
brought_forward = page_entries[0]
page_entries = page_entries[1:]
total: Entry | None = None
if page_entries[-1].is_total:
total = page_entries[-1]
page_entries = page_entries[:-1]
params: LedgerPageParams = LedgerPageParams(
currency=self.__currency,
account=self.__account,
period=self.__period,
has_data=has_data,
pagination=pagination,
brought_forward=brought_forward,
entries=page_entries,
total=total)
return render_template("accounting/report/ledger.html",
report=params)

View File

@ -0,0 +1,272 @@
# 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 trial balance.
"""
import csv
from decimal import Decimal
from io import StringIO
import sqlalchemy as sa
from flask import url_for, Response, render_template
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.report.option_link import OptionLink
from accounting.report.page_params import PageParams
from accounting.report.period import Period
from accounting.report.period_choosers import TrialBalancePeriodChooser
from accounting.report.report_chooser import ReportChooser
from accounting.report.report_type import ReportType
class TrialBalanceAccount:
"""An account in the trial balance."""
def __init__(self, account: Account, amount: Decimal, url: str):
"""Constructs an account in the trial balance.
:param account: The account.
:param amount: The amount.
:param url: The URL to the ledger of the account.
"""
self.account: Account = account
"""The account."""
self.debit: Decimal | None = amount if amount > 0 else None
"""The debit amount."""
self.credit: Decimal | None = -amount if amount < 0 else None
"""The credit amount."""
self.url: str = url
"""The URL to the ledger of the account."""
class TrialBalanceTotal:
"""The total in the trial balance."""
def __init__(self, debit: Decimal, credit: Decimal):
"""Constructs the total in the trial balance.
:param debit: The debit amount.
:param credit: The credit amount.
"""
self.debit: Decimal | None = debit
"""The debit amount."""
self.credit: Decimal | None = credit
"""The credit amount."""
class CSVRow:
"""A row in the CSV trial balance."""
def __init__(self, text: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None):
"""Constructs a row in the CSV trial balance.
:param text: The text.
:param debit: The debit amount.
:param credit: The credit amount.
"""
self.text: str | None = text
"""The text."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
"""The credit amount."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.text, self.debit, self.credit]
class TrialBalancePageParams(PageParams):
"""The HTML parameters of the trial balance."""
def __init__(self, currency: Currency,
period: Period,
accounts: list[TrialBalanceAccount],
total: TrialBalanceTotal):
"""Constructs the HTML parameters of the trial balance.
:param currency: The currency.
:param period: The period.
:param accounts: The accounts in the trial balance.
:param total: The total of the trial balance.
"""
self.currency: Currency = currency
"""The currency."""
self.period: Period = period
"""The period."""
self.accounts: list[TrialBalanceAccount] = accounts
"""The accounts in the trial balance."""
self.total: TrialBalanceTotal = total
"""The total of the trial balance."""
self.period_chooser: TrialBalancePeriodChooser \
= TrialBalancePeriodChooser(currency)
"""The period chooser."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.accounts) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.TRIAL_BALANCE,
currency=self.currency,
period=self.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.trial-balance-default",
currency=currency)
return url_for("accounting.report.trial-balance",
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()]
class TrialBalance:
"""The trial balance."""
def __init__(self, currency: Currency, period: Period):
"""Constructs a trial balance.
:param currency: The currency.
:param period: The period.
"""
self.__currency: Currency = currency
"""The currency."""
self.__period: Period = period
"""The period."""
self.__accounts: list[TrialBalanceAccount]
"""The accounts in the trial balance."""
self.__total: TrialBalanceTotal
"""The total of the trial balance."""
self.__set_data()
def __set_data(self) -> None:
"""Queries and sets data sections in the trial balance.
:return: None.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code]
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_balances: sa.Select \
= sa.select(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_balances).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_([x.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)
self.__accounts = [TrialBalanceAccount(account=accounts[x.id],
amount=x.balance,
url=get_url(accounts[x.id]))
for x in balances]
self.__total = TrialBalanceTotal(
sum([x.debit for x in self.__accounts if x.debit is not None]),
sum([x.credit for x in self.__accounts if x.credit is not None]))
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "trial-balance-{currency}-{period}.csv"\
.format(currency=self.__currency.code, period=self.__period.spec)
rows: list[CSVRow] = self.__get_csv_rows()
with StringIO() as fp:
writer = csv.writer(fp)
writer.writerows([x.values for x in rows])
fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \
= f"attachment; filename={filename}"
return response
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
gettext("Credit"))]
rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit)
for x in self.__accounts])
rows.append(CSVRow(gettext("Total"), self.__total.debit,
self.__total.credit))
return rows
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
params: TrialBalancePageParams = TrialBalancePageParams(
currency=self.__currency,
period=self.__period,
accounts=self.__accounts,
total=self.__total)
return render_template("accounting/report/trial-balance.html",
report=params)

View File

@ -21,10 +21,9 @@ from flask import Blueprint, request, Response
from accounting.models import Currency, Account from accounting.models import Currency, Account
from accounting.utils.permission import has_permission, can_view from accounting.utils.permission import has_permission, can_view
from .balance_sheet import BalanceSheet
from .period import Period from .period import Period
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement IncomeStatement, BalanceSheet
bp: Blueprint = Blueprint("report", __name__) bp: Blueprint = Blueprint("report", __name__)
"""The view blueprint for the reports.""" """The view blueprint for the reports."""

View File

@ -175,18 +175,21 @@ a.accounting-report-table-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
.accounting-income-statement-category, .accounting-income-statement-total { .accounting-income-statement-table .accounting-report-table-header .accounting-report-table-row {
display: block;
}
.accounting-income-statement-section, .accounting-income-statement-total {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: bolder; font-weight: bolder;
} }
.accounting-income-statement-subcategory, .accounting-income-statement-subtotal { .accounting-income-statement-subsection, .accounting-income-statement-subtotal {
font-size: 1.1rem; font-size: 1.1rem;
} }
.accounting-income-statement-subtotal { .accounting-income-statement-subtotal {
border-top: thin solid darkslategray; border-top: thin solid darkslategray;
} }
/* Indents */ /* Indents */
.accounting-income-statement-subcategory { .accounting-income-statement-subsection {
margin-left: 0.5rem; margin-left: 0.5rem;
margin-right: 0.5rem; margin-right: 0.5rem;
} }
@ -195,10 +198,10 @@ a.accounting-report-table-row {
margin-right: 1rem; margin-right: 1rem;
} }
/* A visual blank line between categories */ /* A visual blank line between categories */
.accounting-income-statement-category { .accounting-income-statement-section {
margin-top: 2rem; margin-top: 2rem;
} }
.accounting-income-statement-category:first-child { .accounting-income-statement-section:first-child {
margin-top: 0; margin-top: 0;
} }
.accounting-income-statement-total { .accounting-income-statement-total {

View File

@ -20,31 +20,31 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/5 First written: 2023/3/5
#} #}
<div> <div>
{% if item.date is not none or item.account is not none %} {% if entry.date or entry.account %}
<div class="text-muted small"> <div class="text-muted small">
{% if item.date is not none %} {% if entry.date %}
{{ item.date|accounting_format_date }} {{ entry.date|accounting_format_date }}
{% endif %} {% endif %}
{% if item.account is not none %} {% if entry.account %}
{{ item.account.title|title }} {{ entry.account.title|title }}
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% if item.summary is not none %} {% if entry.summary %}
<div>{{ item.summary }}</div> <div>{{ entry.summary }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="text-nowrap"> <div class="text-nowrap">
{% if item.income is not none %} {% if entry.income %}
<span class="badge rounded-pill bg-success">+{{ item.income|accounting_format_amount }}</span> <span class="badge rounded-pill bg-success">+{{ entry.income|accounting_format_amount }}</span>
{% endif %} {% endif %}
{% if item.expense is not none %} {% if entry.expense %}
<span class="badge rounded-pill bg-warning">-{{ item.expense|accounting_format_amount }}</span> <span class="badge rounded-pill bg-warning">-{{ entry.expense|accounting_format_amount }}</span>
{% endif %} {% endif %}
{% if item.balance < 0 %} {% if entry.balance < 0 %}
<span class="badge rounded-pill bg-danger">{{ item.balance|accounting_format_amount }}</span> <span class="badge rounded-pill bg-danger">{{ entry.balance|accounting_format_amount }}</span>
{% else %} {% else %}
<span class="badge rounded-pill bg-primary">{{ item.balance|accounting_format_amount }}</span> <span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
{% endif %} {% endif %}
</div> </div>

View File

@ -20,22 +20,22 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/5 First written: 2023/3/5
#} #}
<div> <div>
{% if item.date is not none %} {% if entry.date %}
<div class="text-muted small"> <div class="text-muted small">
{{ item.date|accounting_format_date }} {{ entry.date|accounting_format_date }}
</div> </div>
{% endif %} {% endif %}
{% if item.summary is not none %} {% if entry.summary %}
<div>{{ item.summary }}</div> <div>{{ entry.summary }}</div>
{% endif %} {% endif %}
</div> </div>
<div> <div>
{% if item.debit is not none %} {% if entry.debit %}
<span class="badge rounded-pill bg-success">+{{ item.debit|accounting_format_amount }}</span> <span class="badge rounded-pill bg-success">+{{ entry.debit|accounting_format_amount }}</span>
{% endif %} {% endif %}
{% if item.credit is not none %} {% if entry.credit %}
<span class="badge rounded-pill bg-warning">-{{ item.credit|accounting_format_amount }}</span> <span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span>
{% endif %} {% endif %}
<span class="badge rounded-pill bg-primary">{{ item.balance|accounting_format_amount }}</span> <span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
</div> </div>

View File

@ -164,36 +164,36 @@ First written: 2023/3/5
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% if report.brought_forward %} {% if report.brought_forward %}
{% with item = report.brought_forward %} {% with entry = report.brought_forward %}
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
<div>{{ item.date|accounting_format_date }}</div> <div>{{ entry.date|accounting_format_date }}</div>
<div>{{ item.account.title|title }}</div> <div>{{ entry.account.title|title }}</div>
<div>{{ item.summary|accounting_default }}</div> <div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ item.income|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ item.expense|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ item.balance|accounting_format_amount }}</div> <div class="accounting-amount">{{ entry.balance|accounting_format_amount }}</div>
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for item in report.data_rows %} {% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div>{{ item.date|accounting_format_date }}</div> <div>{{ entry.date|accounting_format_date }}</div>
<div>{{ item.account.title|title }}</div> <div>{{ entry.account.title|title }}</div>
<div>{{ item.summary|accounting_default }}</div> <div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ item.income|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ item.expense|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ item.balance|accounting_format_amount }}</div> <div class="accounting-amount">{{ entry.balance|accounting_format_amount }}</div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% if report.total %} {% if report.total %}
{% with item = report.total %} {% with entry = report.total %}
<div class="accounting-report-table-footer"> <div class="accounting-report-table-footer">
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ item.income|accounting_format_amount }}</div> <div class="accounting-amount">{{ entry.income|accounting_format_amount }}</div>
<div class="accounting-amount">{{ item.expense|accounting_format_amount }}</div> <div class="accounting-amount">{{ entry.expense|accounting_format_amount }}</div>
<div class="accounting-amount">{{ item.balance|accounting_format_amount }}</div> <div class="accounting-amount">{{ entry.balance|accounting_format_amount }}</div>
</div> </div>
</div> </div>
{% endwith %} {% endwith %}
@ -202,19 +202,19 @@ First written: 2023/3/5
<div class="list-group d-md-none"> <div class="list-group d-md-none">
{% if report.brought_forward %} {% if report.brought_forward %}
{% with item = report.brought_forward %} {% with entry = report.brought_forward %}
<div class="list-group-item list-group-item-action d-flex justify-content-between"> <div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/income-expenses-mobile-row.html" %} {% include "accounting/report/include/income-expenses-mobile-row.html" %}
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for item in report.data_rows %} {% for entry in report.entries %}
<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 }}"> <a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
{% include "accounting/report/include/income-expenses-mobile-row.html" %} {% include "accounting/report/include/income-expenses-mobile-row.html" %}
</a> </a>
{% endfor %} {% endfor %}
{% if report.total is not none %} {% if report.total %}
{% with item = report.total %} {% with entry = report.total %}
<div class="list-group-item list-group-item-action d-flex justify-content-between"> <div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/income-expenses-mobile-row.html" %} {% include "accounting/report/include/income-expenses-mobile-row.html" %}
</div> </div>

View File

@ -129,40 +129,38 @@ First written: 2023/3/7
</div> </div>
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% for item in report.data_rows %} {% for section in report.sections %}
{% if item.is_category %} <div class="accounting-report-table-row accounting-income-statement-section">
<div class="accounting-report-table-row accounting-income-statement-category">
<div> <div>
<span class="d-none d-md-inline">{{ item.code }}</span> <span class="d-none d-md-inline">{{ section.title.code }}</span>
{{ item.title|title }} {{ section.title.title|title }}
</div> </div>
</div> </div>
{% elif item.is_total %} {% for subsection in section.subsections %}
<div class="accounting-report-table-row accounting-income-statement-total"> <div class="accounting-report-table-row accounting-income-statement-subsection">
<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> <div>
<span class="d-none d-md-inline">{{ item.code }}</span> <span class="d-none d-md-inline">{{ subsection.title.code }}</span>
{{ item.title|title }} {{ subsection.title.title|title }}
</div> </div>
</div> </div>
{% elif item.is_subtotal %} {% for account in subsection.accounts %}
<a class="accounting-report-table-row accounting-income-statement-account" href="{{ account.url }}">
<div>
<span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }}
</div>
<div class="accounting-amount">{{ account.amount|accounting_format_amount }}</div>
</a>
{% endfor %}
<div class="accounting-report-table-row accounting-income-statement-subtotal"> <div class="accounting-report-table-row accounting-income-statement-subtotal">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ item.amount|accounting_format_amount }}</div> <div class="accounting-amount">{{ subsection.total|accounting_format_amount }}</div>
</div> </div>
{% else %} {% endfor %}
<a class="accounting-report-table-row accounting-income-statement-account" href="{{ item.url }}"> <div class="accounting-report-table-row accounting-income-statement-total">
<div> <div>{{ section.accumulated.title|title }}</div>
<span class="d-none d-md-inline">{{ item.code }}</span> <div class="accounting-amount">{{ section.accumulated.amount|accounting_format_amount }}</div>
{{ item.title|title }}
</div> </div>
<div class="accounting-amount">{{ item.amount|accounting_format_amount }}</div>
</a>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

@ -105,38 +105,41 @@ First written: 2023/3/4
</div> </div>
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% for item in report.data_rows %} {% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div>{{ item.transaction.date|accounting_format_date }}</div> <div>{{ entry.transaction.date|accounting_format_date }}</div>
<div>{{ item.currency.name }}</div> <div>{{ entry.currency.name }}</div>
<div>{{ item.account.title|title }}</div> <div>
<div>{{ item.summary|accounting_default }}</div> <span class="d-none d-md-inline">{{ entry.account.code }}</span>
<div class="accounting-amount">{{ item.debit|accounting_format_amount|accounting_default }}</div> {{ entry.account.title|title }}
<div class="accounting-amount">{{ item.credit|accounting_format_amount|accounting_default }}</div> </div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="list-group d-md-none"> <div class="list-group d-md-none">
{% for item in report.data_rows %} {% for entry in report.entries %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}"> <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div {% if not item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}> <div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small"> <div class="text-muted small">
{{ item.transaction.date|accounting_format_date }} {{ entry.transaction.date|accounting_format_date }}
{{ item.account.title|title }} {{ entry.account.title|title }}
{% if item.currency_code != accounting_default_currency_code() %} {% if entry.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ item.currency_code }}</span> <span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>
{% endif %} {% endif %}
</div> </div>
{% if item.summary is not none %} {% if entry.summary is not none %}
<div>{{ item.summary }}</div> <div>{{ entry.summary }}</div>
{% endif %} {% endif %}
</div> </div>
<div> <div>
<span class="badge rounded-pill bg-info">{{ item.amount|accounting_format_amount }}</span> <span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span>
</div> </div>
</div> </div>
</a> </a>

View File

@ -163,34 +163,34 @@ First written: 2023/3/5
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% if report.brought_forward %} {% if report.brought_forward %}
{% with item = report.brought_forward %} {% with entry = report.brought_forward %}
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
<div>{{ item.date|accounting_format_date }}</div> <div>{{ entry.date|accounting_format_date }}</div>
<div>{{ item.summary|accounting_default }}</div> <div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ item.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ item.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ item.balance|accounting_format_amount }}</div> <div class="accounting-amount">{{ entry.balance|accounting_format_amount }}</div>
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for item in report.data_rows %} {% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div>{{ item.date|accounting_format_date }}</div> <div>{{ entry.date|accounting_format_date }}</div>
<div>{{ item.summary|accounting_default }}</div> <div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ item.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ item.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ item.balance|accounting_format_amount }}</div> <div class="accounting-amount">{{ entry.balance|accounting_format_amount }}</div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% if report.total %} {% if report.total %}
{% with item = report.total %} {% with entry = report.total %}
<div class="accounting-report-table-footer"> <div class="accounting-report-table-footer">
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ item.debit|accounting_format_amount }}</div> <div class="accounting-amount">{{ entry.debit|accounting_format_amount }}</div>
<div class="accounting-amount">{{ item.credit|accounting_format_amount }}</div> <div class="accounting-amount">{{ entry.credit|accounting_format_amount }}</div>
<div class="accounting-amount">{{ item.balance|accounting_format_amount }}</div> <div class="accounting-amount">{{ entry.balance|accounting_format_amount }}</div>
</div> </div>
</div> </div>
{% endwith %} {% endwith %}
@ -199,19 +199,19 @@ First written: 2023/3/5
<div class="list-group d-md-none"> <div class="list-group d-md-none">
{% if report.brought_forward %} {% if report.brought_forward %}
{% with item = report.brought_forward %} {% with entry = report.brought_forward %}
<div class="list-group-item list-group-item-action d-flex justify-content-between"> <div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/ledger-mobile-row.html" %} {% include "accounting/report/include/ledger-mobile-row.html" %}
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for item in report.data_rows %} {% for entry in report.entries %}
<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 }}"> <a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
{% include "accounting/report/include/ledger-mobile-row.html" %} {% include "accounting/report/include/ledger-mobile-row.html" %}
</a> </a>
{% endfor %} {% endfor %}
{% if report.total is not none %} {% if report.total %}
{% with item = report.total %} {% with entry = report.total %}
<div class="list-group-item list-group-item-action d-flex justify-content-between"> <div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/ledger-mobile-row.html" %} {% include "accounting/report/include/ledger-mobile-row.html" %}
</div> </div>

View File

@ -131,14 +131,14 @@ First written: 2023/3/5
</div> </div>
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% for item in report.data_rows %} {% for account in report.accounts %}
<a class="accounting-report-table-row" href="{{ item.url }}"> <a class="accounting-report-table-row" href="{{ account.url }}">
<div> <div>
<span class="d-none d-md-inline">{{ item.account.code }}</span> <span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ item.account.title|title }} {{ account.account.title|title }}
</div> </div>
<div class="accounting-amount">{{ item.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ account.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ item.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ account.credit|accounting_format_amount|accounting_default }}</div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>