Added ledger.

This commit is contained in:
依瑪貓 2023-03-05 14:25:00 +08:00
parent fe77f87110
commit eabe80b790
12 changed files with 736 additions and 86 deletions

View File

@ -0,0 +1,34 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5
# 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 option link.
"""
class OptionLink:
"""An option link."""
def __init__(self, title: str, url: str, is_active: bool):
"""Constructs an option link.
:param title: The title.
:param url: The URI.
:param is_active: True if active, or False otherwise
"""
self.title: str = title
self.url: str = url
self.is_active: bool = is_active

View File

@ -26,7 +26,7 @@ from datetime import date
from flask import url_for from flask import url_for
from accounting.models import Transaction from accounting.models import Currency, Account, Transaction
from .period import YearPeriod, Period, ThisMonth, LastMonth, SinceLastMonth, \ from .period import YearPeriod, Period, ThisMonth, LastMonth, SinceLastMonth, \
ThisYear, LastYear, Today, Yesterday, TemplatePeriod ThisYear, LastYear, Today, Yesterday, TemplatePeriod
@ -116,3 +116,26 @@ class JournalPeriodChooser(PeriodChooser):
if period.is_default: if period.is_default:
return url_for("accounting.report.journal-default") return url_for("accounting.report.journal-default")
return url_for("accounting.report.journal", period=period) return url_for("accounting.report.journal", period=period)
class LedgerPeriodChooser(PeriodChooser):
"""The ledger period chooser."""
def __init__(self, currency: Currency, account: Account):
"""Constructs the ledger period chooser."""
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
super(LedgerPeriodChooser, self).__init__(
None if first is None else first.date)
def _url_for(self, period: Period) -> str:
if period.is_default:
return url_for("accounting.report.ledger-default",
currency=self.currency, account=self.account)
return url_for("accounting.report.ledger",
currency=self.currency, account=self.account,
period=period)

View File

@ -27,40 +27,26 @@ from flask_babel import LazyString
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency from accounting.models import Currency, Account
from accounting.template_globals import default_currency_code from accounting.template_globals import default_currency_code
from .option_link import OptionLink
from .period import Period from .period import Period
from .report_type import ReportType from .report_type import ReportType
class ReportLink:
"""A link of a report."""
def __init__(self, name: str | LazyString, url: str):
"""Constructs a report.
:param name: The report name.
:param url: The URL.
"""
self.name: str | LazyString = name
"""The report name."""
self.url: str = url
"""The URL."""
self.is_active: bool = False
"""Whether the report is the current report."""
class ReportChooser: class ReportChooser:
"""The report chooser.""" """The report chooser."""
def __init__(self, active_report: ReportType, def __init__(self, active_report: ReportType,
period: Period | None = None, period: Period | None = None,
currency: Currency | None = None): currency: Currency | None = None,
account: Account | None = None):
"""Constructs the report chooser. """Constructs the report chooser.
:param active_report: The active report. :param active_report: The active report.
:param period: The period. :param period: The period.
:param currency: The currency. :param currency: The currency.
:param account: The account.
""" """
self.__active_report: ReportType = active_report self.__active_report: ReportType = active_report
"""The currently active report.""" """The currently active report."""
@ -71,17 +57,21 @@ class ReportChooser:
Currency, default_currency_code()) \ Currency, default_currency_code()) \
if currency is None else currency if currency is None else currency
"""The currency.""" """The currency."""
self.__reports: list[ReportLink] = [] self.__account: Account = Account.find_by_code("1111-001") \
if account is None else account
"""The currency."""
self.__reports: list[OptionLink] = []
"""The links to the reports.""" """The links to the reports."""
self.__reports.append(self.__journal)
self.current_report: str | LazyString = "" self.current_report: str | LazyString = ""
"""The name of the current report.""" """The title of the current report."""
self.__reports.append(self.__journal)
self.__reports.append(self.__ledger)
for report in self.__reports: for report in self.__reports:
if report.is_active: if report.is_active:
self.current_report = report.name self.current_report = report.title
@property @property
def __journal(self) -> ReportLink: def __journal(self) -> OptionLink:
"""Returns the journal. """Returns the journal.
:return: The journal. :return: The journal.
@ -89,12 +79,25 @@ class ReportChooser:
url: str = url_for("accounting.report.journal-default") \ url: str = url_for("accounting.report.journal-default") \
if self.__period.is_default \ if self.__period.is_default \
else url_for("accounting.report.journal", period=self.__period) else url_for("accounting.report.journal", period=self.__period)
report = ReportLink(gettext("Journal"), url) return OptionLink(gettext("Journal"), url,
if self.__active_report == ReportType.JOURNAL: self.__active_report == ReportType.JOURNAL)
report.is_active = True
return report
def __iter__(self) -> t.Iterator[ReportLink]: @property
def __ledger(self) -> OptionLink:
"""Returns the ledger.
:return: The ledger.
"""
url: str = url_for("accounting.report.ledger-default",
currency=self.__currency, account=self.__account) \
if self.__period.is_default \
else url_for("accounting.report.ledger",
currency=self.__currency, account=self.__account,
period=self.__period)
return OptionLink(gettext("Ledger"), url,
self.__active_report == ReportType.LEDGER)
def __iter__(self) -> t.Iterator[OptionLink]:
"""Returns the iteration of the reports. """Returns the iteration of the reports.
:return: The iteration of the reports. :return: The iteration of the reports.

View File

@ -18,8 +18,9 @@
""" """
import typing as t import typing as t
from decimal import Decimal
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import date
from decimal import Decimal
from accounting.models import JournalEntry, Transaction, Account, Currency from accounting.models import JournalEntry, Transaction, Account, Currency
@ -38,27 +39,77 @@ class ReportRow(ABC):
class JournalRow(ReportRow): class JournalRow(ReportRow):
"""A row in the journal report.""" """A row in the journal report."""
def __init__(self, entry: JournalEntry, transaction: Transaction, def __init__(self, entry: JournalEntry):
account: Account, currency: Currency):
"""Constructs the row in the journal report. """Constructs the row in the journal report.
:param entry: The journal entry. :param entry: The journal entry.
:param transaction: The transaction.
:param account: The account.
:param currency: The currency.
""" """
self.is_debit: bool = entry.is_debit self.entry: JournalEntry = entry
"""The journal entry."""
self.summary: str | None = entry.summary self.summary: str | None = entry.summary
"""The summary."""
self.currency_code: str = entry.currency_code
"""The currency code."""
self.is_debit: bool = entry.is_debit
"""True for a debit journal entry, or False for a credit entry."""
self.amount: Decimal = entry.amount self.amount: Decimal = entry.amount
self.transaction: Account = transaction """The amount."""
self.account: Account = account self.transaction: Transaction | None = None
self.currency: Currency = currency """The transaction."""
self.currency: Currency | None = None
"""The currency."""
self.account: Account | None = None
"""The account."""
def as_dict(self) -> dict[str, t.Any]: def as_dict(self) -> dict[str, t.Any]:
return {"date": self.transaction.date.isoformat(), return {"date": self.transaction.date,
"currency": self.currency.name, "currency": str(self.currency),
"account": str(self.account), "account": str(self.account),
"summary": self.summary, "summary": self.summary,
"debit": self.amount if self.is_debit else None, "debit": self.amount if self.is_debit else None,
"credit": None if self.is_debit else self.amount, "credit": None if self.is_debit else self.amount,
"note": self.transaction.note} "note": self.transaction.note}
class LedgerRow(ReportRow):
"""A row in the ledger report."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the row in the journal report.
: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."""
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}
return {"date": self.date,
"summary": self.summary,
"debit": self.debit,
"credit": self.credit,
"balance": self.balance}

View File

@ -24,3 +24,5 @@ class ReportType(Enum):
"""The report types.""" """The report types."""
JOURNAL: str = "journal" JOURNAL: str = "journal"
"""The journal.""" """The journal."""
LEDGER: str = "ledger"
"""The ledger."""

View File

@ -20,24 +20,32 @@
import csv import csv
import typing as t import typing as t
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from decimal import Decimal
from io import StringIO from io import StringIO
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
urlunparse
import sqlalchemy as sa import sqlalchemy as sa
from flask import Response, render_template from flask import Response, render_template, request, url_for
from flask_sqlalchemy.query import Query
from accounting import db from accounting import db
from accounting.models import JournalEntry, Transaction, Account, Currency from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
from accounting.utils.txn_types import TransactionType from accounting.utils.txn_types import TransactionType
from .option_link import OptionLink
from .period import Period from .period import Period
from .period_choosers import PeriodChooser, JournalPeriodChooser from .period_choosers import PeriodChooser, JournalPeriodChooser, \
LedgerPeriodChooser
from .report_chooser import ReportChooser from .report_chooser import ReportChooser
from .report_rows import ReportRow, JournalRow from .report_rows import ReportRow, JournalRow, LedgerRow
from .report_type import ReportType from .report_type import ReportType
T = t.TypeVar("T", bound=ReportRow)
"""The row class in the report."""
class JournalEntryReport(ABC):
class JournalEntryReport(t.Generic[T], ABC):
"""A report based on a journal entry.""" """A report based on a journal entry."""
def __init__(self, period: Period): def __init__(self, period: Period):
@ -47,22 +55,23 @@ class JournalEntryReport(ABC):
""" """
self.period: Period = period self.period: Period = period
"""The period.""" """The period."""
self._entries: list[JournalEntry] = self.get_entries() self.__rows: list[T] | None = None
"""The journal entries.""" """The rows in the report."""
@abstractmethod @abstractmethod
def get_entries(self) -> list[JournalEntry]: def get_rows(self) -> list[T]:
"""Returns the journal entries. """Returns the rows, without pagination.
:return: The journal entries. :return: The rows.
""" """
@abstractmethod @abstractmethod
def entries_to_rows(self, entries: list[JournalEntry]) -> list[ReportRow]: def populate_rows(self, rows: list[T]) -> None:
"""Converts the journal entries into report rows. """Populates the transaction, currency, account, and other data to the
given rows.
:param entries: The journal entries. :param rows: The rows.
:return: The report rows. :return: None.
""" """
@property @property
@ -104,6 +113,16 @@ class JournalEntryReport(ABC):
:return: The report as an HTML page. :return: The report as an HTML page.
""" """
@property
def rows(self) -> list[T]:
"""Returns the journal entries.
:return: The journal entries.
"""
if self.__rows is None:
self.__rows = self.get_rows()
return self.__rows
@property @property
def txn_types(self) -> t.Type[TransactionType]: def txn_types(self) -> t.Type[TransactionType]:
"""Returns the transaction types. """Returns the transaction types.
@ -112,17 +131,32 @@ class JournalEntryReport(ABC):
""" """
return TransactionType return TransactionType
@property
def csv_uri(self) -> str:
"""Returns the URI to download the report as CSV.
:return: The URI to download the report as CSV.
"""
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 as_csv_download(self) -> Response: def as_csv_download(self) -> Response:
"""Returns the journal entries as CSV download. """Returns the report as CSV download.
:return: The CSV download response. :return: The CSV download response.
""" """
self.populate_rows(self.rows)
with StringIO() as fp: with StringIO() as fp:
writer: csv.DictWriter = csv.DictWriter( writer: csv.DictWriter = csv.DictWriter(
fp, fieldnames=self.csv_field_names) fp, fieldnames=self.csv_field_names)
writer.writeheader() writer.writeheader()
writer.writerows([x.as_dict() writer.writerows([x.as_dict() for x in self.rows])
for x in self.entries_to_rows(self._entries)])
fp.seek(0) fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv") response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \ response.headers["Content-Disposition"] \
@ -130,35 +164,37 @@ class JournalEntryReport(ABC):
return response return response
class Journal(JournalEntryReport): class Journal(JournalEntryReport[JournalRow]):
"""The journal.""" """The journal."""
def get_entries(self) -> list[JournalEntry]: def get_rows(self) -> list[JournalRow]:
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.BinaryExpression] = []
if self.period.start is not None: if self.period.start is not None:
conditions.append(Transaction.date >= self.period.start) conditions.append(Transaction.date >= self.period.start)
if self.period.end is not None: if self.period.end is not None:
conditions.append(Transaction.date <= self.period.end) conditions.append(Transaction.date <= self.period.end)
query: Query = db.session.query(JournalEntry).join(Transaction) return [JournalRow(x) for x in db.session
if len(conditions) > 0: .query(JournalEntry)
query = query.filter(*conditions) .join(Transaction)
return query.order_by(Transaction.date, .filter(*conditions)
.order_by(Transaction.date,
JournalEntry.is_debit.desc(), JournalEntry.is_debit.desc(),
JournalEntry.no).all() JournalEntry.no).all()]
def entries_to_rows(self, entries: list[JournalEntry]) -> list[ReportRow]: def populate_rows(self, rows: list[JournalRow]) -> None:
transactions: dict[int, Transaction] \ transactions: dict[int, Transaction] \
= {x.id: x for x in Transaction.query.filter( = {x.id: x for x in Transaction.query.filter(
Transaction.id.in_({x.transaction_id for x in entries}))} Transaction.id.in_({x.entry.transaction_id for x in rows}))}
accounts: dict[int, Account] \ accounts: dict[int, Account] \
= {x.id: x for x in Account.query.filter( = {x.id: x for x in Account.query.filter(
Account.id.in_({x.account_id for x in entries}))} Account.id.in_({x.entry.account_id for x in rows}))}
currencies: dict[int, Currency] \ currencies: dict[int, Currency] \
= {x.code: x for x in Currency.query.filter( = {x.code: x for x in Currency.query.filter(
Currency.code.in_({x.currency_code for x in entries}))} Currency.code.in_({x.entry.currency_code for x in rows}))}
return [JournalRow(x, transactions[x.transaction_id], for row in rows:
accounts[x.account_id], currencies[x.currency_code]) row.transaction = transactions[row.entry.transaction_id]
for x in entries] row.account = accounts[row.entry.account_id]
row.currency = currencies[row.entry.currency_code]
@property @property
def csv_field_names(self) -> list[str]: def csv_field_names(self) -> list[str]:
@ -175,10 +211,208 @@ class Journal(JournalEntryReport):
@property @property
def report_chooser(self) -> ReportChooser: def report_chooser(self) -> ReportChooser:
return ReportChooser(ReportType.JOURNAL, self.period) return ReportChooser(ReportType.JOURNAL,
period=self.period)
def as_html_page(self) -> str: def as_html_page(self) -> str:
pagination: Pagination = Pagination[JournalEntry](self._entries) pagination: Pagination = Pagination[JournalRow](self.rows)
rows: list[JournalRow] = pagination.list
self.populate_rows(rows)
return render_template("accounting/report/journal.html", return render_template("accounting/report/journal.html",
list=self.entries_to_rows(pagination.list), list=rows, pagination=pagination, report=self)
pagination=pagination, report=self)
class Ledger(JournalEntryReport[LedgerRow]):
"""The ledger."""
def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs a journal.
:param currency: The currency.
:param account: The account.
:param period: The period.
"""
super().__init__(period)
self.currency: Currency = currency
"""The currency."""
self.account: Account = account
"""The account."""
self.total_row: LedgerRow | None = None
"""The total row to show on the template."""
def get_rows(self) -> list[LedgerRow]:
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)
if brought_forward is not None:
rows.insert(0, brought_forward)
rows.append(total)
return rows
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
def populate_rows(self, 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
@property
def csv_field_names(self) -> list[str]:
return ["date", "summary", "debit", "credit", "balance"]
@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)
@property
def period_chooser(self) -> PeriodChooser:
return LedgerPeriodChooser(self.currency, self.account)
@property
def report_chooser(self) -> ReportChooser:
return ReportChooser(ReportType.LEDGER,
currency=self.currency,
account=self.account,
period=self.period)
def as_html_page(self) -> str:
pagination: Pagination = Pagination[LedgerRow](self.rows)
rows: list[LedgerRow] = pagination.list
self.populate_rows(rows)
if len(rows) > 0 and rows[-1].is_total:
self.total_row = rows[-1]
rows = rows[:-1]
return render_template("accounting/report/ledger.html",
list=rows, pagination=pagination, report=self)
@property
def currency_options(self) -> list[OptionLink]:
"""Returns the currency options.
:return: The currency options.
"""
def get_url(currency: Currency):
if self.period.is_default:
return url_for("accounting.report.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: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(JournalEntry.currency_code == self.currency.code)
.group_by(JournalEntry.account_id)).all())
return [OptionLink(str(x), get_url(x), x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no).all()]

View File

@ -19,9 +19,10 @@
""" """
from flask import Blueprint, request, Response from flask import Blueprint, request, Response
from accounting.models import Currency, Account
from accounting.utils.permission import has_permission, can_view from accounting.utils.permission import has_permission, can_view
from .period import Period from .period import Period
from .reports import Journal from .reports import Journal, Ledger
bp: Blueprint = Blueprint("report", __name__) bp: Blueprint = Blueprint("report", __name__)
"""The view blueprint for the reports.""" """The view blueprint for the reports."""
@ -58,3 +59,47 @@ def __get_journal_list(period: Period) -> str | Response:
if "as" in request.args and request.args["as"] == "csv": if "as" in request.args and request.args["as"] == "csv":
return report.as_csv_download() return report.as_csv_download()
return report.as_html_page() return report.as_html_page()
@bp.get("ledger/<currency:currency>/<account:account>",
endpoint="ledger-default")
@has_permission(can_view)
def get_default_ledger_list(currency: Currency, account: Account) \
-> str | Response:
"""Returns the ledger in the default period.
:param currency: The currency.
:param account: The account.
:return: The ledger in the default period.
"""
return __get_ledger_list(currency, account, Period.get_instance())
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
endpoint="ledger")
@has_permission(can_view)
def get_ledger_list(currency: Currency, account: Account, period: Period) \
-> str | Response:
"""Returns the ledger.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The ledger in the period.
"""
return __get_ledger_list(currency, account, period)
def __get_ledger_list(currency: Currency, account: Account, period: Period) \
-> str | Response:
"""Returns the ledger.
:param currency: The currency.
:param account: The account.
:param period: The period.
:return: The ledger in the period.
"""
report: Ledger = Ledger(currency, account, period)
if "as" in request.args and request.args["as"] == "csv":
return report.as_csv_download()
return report.as_html_page()

View File

@ -122,6 +122,11 @@ td.accounting-amount {
.accounting-mobile-journal-credit { .accounting-mobile-journal-credit {
padding-left: 1rem; padding-left: 1rem;
} }
.accounting-ledger-table tfoot {
border-top: 1px double black;
font-weight: bolder;
font-style: italic;
}
/* The Material Design text field (floating form control in Bootstrap) */ /* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field { .accounting-material-text-field {

View File

@ -0,0 +1,41 @@
{#
The Mia! Accounting Flask Project
ledger-mobile-row.html: The row in the ledger for the mobile devices
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/5
#}
<div>
{% if item.date is not none %}
<div class="text-muted small">
{{ item.date|accounting_format_date }}
</div>
{% endif %}
{% if item.summary is not none %}
<div>{{ item.summary }}</div>
{% endif %}
</div>
<div>
{% if item.debit is not none %}
<span class="badge rounded-pill bg-success">+{{ item.debit|accounting_format_amount }}</span>
{% endif %}
{% if item.credit is not none %}
<span class="badge rounded-pill bg-warning">-{{ item.credit|accounting_format_amount }}</span>
{% endif %}
<span class="badge rounded-pill bg-primary">{{ item.balance|accounting_format_amount }}</span>
</div>

View File

@ -27,7 +27,7 @@ First written: 2023/3/4
</button> </button>
<ul class="dropdown-menu" aria-labelledby="accounting-report-chooser"> <ul class="dropdown-menu" aria-labelledby="accounting-report-chooser">
{% for report in report_chooser %} {% for report in report_chooser %}
<li><a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">{{ report.name }}</a></li> <li><a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">{{ report.title }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>

View File

@ -41,7 +41,8 @@ First written: 2023/3/4
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li> <li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}"> <a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}</a> {{ A_("Cash Expense") }}
</a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}"> <a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
@ -61,9 +62,9 @@ First written: 2023/3/4
{% endwith %} {% endwith %}
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal"> <button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
<i class="fa-solid fa-calendar-day"></i> <i class="fa-solid fa-calendar-day"></i>
{{ report.period.desc }} {{ report.period.desc|title }}
</button> </button>
<a class="btn btn-primary" role="button" href="?as=csv"> <a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i> <i class="fa-solid fa-download"></i>
{{ A_("Download") }} {{ A_("Download") }}
</a> </a>
@ -106,7 +107,7 @@ First written: 2023/3/4
<tr class="accounting-clickable accounting-table-row-link" data-href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}"> <tr class="accounting-clickable accounting-table-row-link" data-href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
<td>{{ item.transaction.date|accounting_format_date }}</td> <td>{{ item.transaction.date|accounting_format_date }}</td>
<td>{{ item.currency.name }}</td> <td>{{ item.currency.name }}</td>
<td>{{ item.account.title }}</td> <td>{{ item.account }}</td>
<td>{{ "" if item.summary is none else item.summary }}</td> <td>{{ "" if item.summary is none else item.summary }}</td>
<td class="accounting-amount">{{ "" if not item.is_debit else item.amount|accounting_format_amount }}</td> <td class="accounting-amount">{{ "" if not item.is_debit else item.amount|accounting_format_amount }}</td>
<td class="accounting-amount">{{ "" if item.is_debit else item.amount|accounting_format_amount }}</td> <td class="accounting-amount">{{ "" if item.is_debit else item.amount|accounting_format_amount }}</td>
@ -124,7 +125,7 @@ First written: 2023/3/4
{{ item.transaction.date|accounting_format_date }} {{ item.transaction.date|accounting_format_date }}
{{ item.account.title }} {{ item.account.title }}
{% if item.currency_code != accounting_default_currency_code() %} {% if item.currency_code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ item.currency.code }}</span> <span class="badge rounded-pill bg-info">{{ item.currency_code }}</span>
{% endif %} {% endif %}
</div> </div>
{% if item.summary is not none %} {% if item.summary is not none %}

View File

@ -0,0 +1,211 @@
{#
The Mia! Accounting Flask Project
ledger.html: The ledger
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/5
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/table-row-link.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ _("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency, account=report.account|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
{{ report.currency.name|title }}
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-book"></i>
{{ report.account.title|title }}
</button>
<ul class="dropdown-menu">
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
{{ account.title|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
<i class="fa-solid fa-calendar-day"></i>
{{ report.period.desc|title }}
</button>
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
{{ A_("Download") }}
</a>
</div>
{% with txn_types = report.txn_types %}
{% include "accounting/include/add-txn-material-fab.html" %}
{% endwith %}
<div class="btn-group btn-actions mb-3 d-md-none">
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
{{ A_("Currency") }}
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-book"></i>
{{ A_("Account") }}
</button>
<ul class="dropdown-menu">
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
{{ account.title|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
<i class="fa-solid fa-calendar-day"></i>
{{ A_("Period") }}
</button>
</div>
{% with period = report.period, period_chooser = report.period_chooser %}
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% if list %}
{% include "accounting/include/pagination.html" %}
<table class="table table-striped table-hover d-none d-md-table accounting-ledger-table">
<thead>
<tr>
<th scope="col">{{ A_("Date") }}</th>
<th scope="col">{{ A_("Summary") }}</th>
<th class="accounting-amount" scope="col">{{ A_("Debit") }}</th>
<th class="accounting-amount" scope="col">{{ A_("Credit") }}</th>
<th class="accounting-amount" scope="col">{{ A_("Balance") }}</th>
</tr>
</thead>
<tbody>
{% for item in list %}
<tr {% if item.transaction is not none %} class="accounting-clickable accounting-table-row-link" data-href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}" {% endif %}>
<td>{{ item.date|accounting_format_date }}</td>
<td>{{ "" if item.summary is none else item.summary }}</td>
<td class="accounting-amount">{{ "" if item.debit is none else item.debit|accounting_format_amount }}</td>
<td class="accounting-amount">{{ "" if item.credit is none else item.credit|accounting_format_amount }}</td>
<td class="accounting-amount">{{ item.balance|accounting_format_amount }}</td>
</tr>
{% endfor %}
</tbody>
{% if report.total_row is not none %}
<tfoot>
<tr>
<td colspan="2">{{ A_("Total") }}</td>
<td class="accounting-amount">{{ report.total_row.debit|accounting_format_amount }}</td>
<td class="accounting-amount">{{ report.total_row.credit|accounting_format_amount }}</td>
<td class="accounting-amount">{{ report.total_row.balance|accounting_format_amount }}</td>
</tr>
</tfoot>
{% endif %}
</table>
<div class="list-group d-md-none">
{% for item in list %}
{% if item.transaction is not none %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
{% include "accounting/report/include/ledger-mobile-row.html" %}
</a>
{% else %}
<div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/ledger-mobile-row.html" %}
</div>
{% endif %}
{% endfor %}
{% if report.total_row is not none %}
{% with item = report.total_row %}
<div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/ledger-mobile-row.html" %}
</div>
{% endwith %}
{% endif %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}