diff --git a/src/accounting/report/page_params.py b/src/accounting/report/page_params.py new file mode 100644 index 0000000..733c7b8 --- /dev/null +++ b/src/accounting/report/page_params.py @@ -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) diff --git a/src/accounting/report/period.py b/src/accounting/report/period.py index 5b121c8..4c0413d 100644 --- a/src/accounting/report/period.py +++ b/src/accounting/report/period.py @@ -333,7 +333,7 @@ class Period: """ if self.start is 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): diff --git a/src/accounting/report/report_params.py b/src/accounting/report/report_params.py deleted file mode 100644 index 82d70a6..0000000 --- a/src/accounting/report/report_params.py +++ /dev/null @@ -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()] diff --git a/src/accounting/report/report_rows.py b/src/accounting/report/report_rows.py deleted file mode 100644 index bf77dc5..0000000 --- a/src/accounting/report/report_rows.py +++ /dev/null @@ -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} diff --git a/src/accounting/report/reports.py b/src/accounting/report/reports.py deleted file mode 100644 index 522da5c..0000000 --- a/src/accounting/report/reports.py +++ /dev/null @@ -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) diff --git a/src/accounting/report/reports/__init__.py b/src/accounting/report/reports/__init__.py new file mode 100644 index 0000000..fdb9e57 --- /dev/null +++ b/src/accounting/report/reports/__init__.py @@ -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 diff --git a/src/accounting/report/balance_sheet.py b/src/accounting/report/reports/balance_sheet.py similarity index 72% rename from src/accounting/report/balance_sheet.py rename to src/accounting/report/reports/balance_sheet.py index 30c43ec..8286292 100644 --- a/src/accounting/report/balance_sheet.py +++ b/src/accounting/report/reports/balance_sheet.py @@ -1,5 +1,5 @@ # 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. # @@ -18,24 +18,22 @@ """ import csv -import typing as t from decimal import Decimal from io import StringIO -from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \ - urlunparse 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.locale import gettext from accounting.models import Currency, BaseAccount, Account, Transaction, \ JournalEntry -from accounting.utils.txn_types import TransactionType -from .option_link import OptionLink -from .period import Period -from .period_choosers import BalanceSheetPeriodChooser -from .report_chooser import ReportChooser -from .report_type import ReportType +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 BalanceSheetPeriodChooser +from accounting.report.report_chooser import ReportChooser +from accounting.report.report_type import ReportType class BalanceSheetAccount: @@ -100,6 +98,168 @@ class BalanceSheetSection: 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: """A half row in the CSV balance sheet.""" @@ -139,7 +299,7 @@ class CSVRow: self.liability_title, self.liability_amount] -class BalanceSheetPageParams: +class BalanceSheetPageParams(PageParams): """The HTML parameters of the balance sheet.""" def __init__(self, currency: Currency, @@ -161,7 +321,7 @@ class BalanceSheetPageParams: """The currency.""" self.period: Period = period """The period.""" - self.has_data: bool = has_data + self.__has_data: bool = has_data """True if there is any data, or False otherwise.""" self.assets: BalanceSheetSection = assets """The assets.""" @@ -172,25 +332,24 @@ class BalanceSheetPageParams: self.period_chooser: BalanceSheetPeriodChooser \ = BalanceSheetPeriodChooser(currency) """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 - 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) + 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.BALANCE_SHEET, + currency=self.currency, + period=self.period) @property def currency_options(self) -> list[OptionLink]: @@ -222,19 +381,28 @@ class BalanceSheet: :param currency: The currency. :param period: The period. """ - self.currency: Currency = currency + self.__currency: Currency = currency """The currency.""" - self.period: Period = period + self.__period: Period = 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() def __set_data(self) -> None: """Queries and sets assets, the liabilities, and the owner's equity 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\ .filter(BaseAccount.code.in_({"1", "2", "3"})).all() @@ -251,164 +419,10 @@ class BalanceSheet: for balance in balances: subsections[balance.account.base_code[:2]].accounts.append(balance) - self.__has_data: bool = len(balances) > 0 - self.__assets: BalanceSheetSection = sections["1"] - self.__liabilities: BalanceSheetSection = sections["2"] - self.__owner_s_equity: BalanceSheetSection = 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)) + self.__has_data = len(balances) > 0 + self.__assets = sections["1"] + self.__liabilities = sections["2"] + self.__owner_s_equity = sections["3"] def csv(self) -> Response: """Returns the report as CSV for download. @@ -416,7 +430,7 @@ class BalanceSheet: :return: The response of the report for download. """ 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() with StringIO() as fp: writer = csv.writer(fp) @@ -435,10 +449,12 @@ class BalanceSheet: asset_rows: list[CSVHalfRow] = self.__section_csv_rows(self.__assets) liability_rows: list[CSVHalfRow] = [] 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.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 range(max(len(asset_rows), len(liability_rows)))] for i in range(len(rows)): @@ -449,9 +465,9 @@ class BalanceSheet: rows[i].liability_title = liability_rows[i].title rows[i].liability_amount = liability_rows[i].amount total: CSVRow = CSVRow() - total.asset_title = "Total" + total.asset_title = gettext("Total") total.asset_amount = self.__assets.total - total.liability_title = "Total" + total.liability_title = gettext("Total") total.liability_amount \ = self.__liabilities.total + self.__owner_s_equity.total rows.append(total) @@ -479,8 +495,8 @@ class BalanceSheet: :return: The report as HTML. """ params: BalanceSheetPageParams = BalanceSheetPageParams( - currency=self.currency, - period=self.period, + currency=self.__currency, + period=self.__period, has_data=self.__has_data, assets=self.__assets, liabilities=self.__liabilities, diff --git a/src/accounting/report/reports/income_expenses.py b/src/accounting/report/reports/income_expenses.py new file mode 100644 index 0000000..2763e5e --- /dev/null +++ b/src/accounting/report/reports/income_expenses.py @@ -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) diff --git a/src/accounting/report/reports/income_statement.py b/src/accounting/report/reports/income_statement.py new file mode 100644 index 0000000..4ce61af --- /dev/null +++ b/src/accounting/report/reports/income_statement.py @@ -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) diff --git a/src/accounting/report/reports/journal.py b/src/accounting/report/reports/journal.py new file mode 100644 index 0000000..b6ac9bf --- /dev/null +++ b/src/accounting/report/reports/journal.py @@ -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) diff --git a/src/accounting/report/reports/ledger.py b/src/accounting/report/reports/ledger.py new file mode 100644 index 0000000..aa522fe --- /dev/null +++ b/src/accounting/report/reports/ledger.py @@ -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) diff --git a/src/accounting/report/reports/trial_balance.py b/src/accounting/report/reports/trial_balance.py new file mode 100644 index 0000000..e6b4b4c --- /dev/null +++ b/src/accounting/report/reports/trial_balance.py @@ -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) diff --git a/src/accounting/report/views.py b/src/accounting/report/views.py index 446dafd..54e2709 100644 --- a/src/accounting/report/views.py +++ b/src/accounting/report/views.py @@ -21,10 +21,9 @@ from flask import Blueprint, request, Response from accounting.models import Currency, Account from accounting.utils.permission import has_permission, can_view -from .balance_sheet import BalanceSheet from .period import Period from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ - IncomeStatement + IncomeStatement, BalanceSheet bp: Blueprint = Blueprint("report", __name__) """The view blueprint for the reports.""" diff --git a/src/accounting/static/css/style.css b/src/accounting/static/css/style.css index 0c43b49..64c9683 100644 --- a/src/accounting/static/css/style.css +++ b/src/accounting/static/css/style.css @@ -175,18 +175,21 @@ a.accounting-report-table-row { display: flex; 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-weight: bolder; } -.accounting-income-statement-subcategory, .accounting-income-statement-subtotal { +.accounting-income-statement-subsection, .accounting-income-statement-subtotal { font-size: 1.1rem; } .accounting-income-statement-subtotal { border-top: thin solid darkslategray; } /* Indents */ -.accounting-income-statement-subcategory { +.accounting-income-statement-subsection { margin-left: 0.5rem; margin-right: 0.5rem; } @@ -195,10 +198,10 @@ a.accounting-report-table-row { margin-right: 1rem; } /* A visual blank line between categories */ -.accounting-income-statement-category { +.accounting-income-statement-section { margin-top: 2rem; } -.accounting-income-statement-category:first-child { +.accounting-income-statement-section:first-child { margin-top: 0; } .accounting-income-statement-total { diff --git a/src/accounting/templates/accounting/report/include/income-expenses-mobile-row.html b/src/accounting/templates/accounting/report/include/income-expenses-mobile-row.html index 9b24a0c..851c1e5 100644 --- a/src/accounting/templates/accounting/report/include/income-expenses-mobile-row.html +++ b/src/accounting/templates/accounting/report/include/income-expenses-mobile-row.html @@ -20,31 +20,31 @@ Author: imacat@mail.imacat.idv.tw (imacat) First written: 2023/3/5 #}