Renamed "transaction" to "voucher", "cash expense transaction" to "cash disbursement voucher", and "cash income transaction" to "cash receipt voucher".

This commit is contained in:
依瑪貓 2023-03-19 13:44:51 +08:00
parent 1e286fbeba
commit 5db13393cc
75 changed files with 1812 additions and 1792 deletions

View File

@ -80,8 +80,8 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
from . import currency from . import currency
currency.init_app(app, bp) currency.init_app(app, bp)
from . import transaction from . import voucher
transaction.init_app(app, bp) voucher.init_app(app, bp)
from . import report from . import report
report.init_app(app, bp) report.init_app(app, bp)

View File

@ -447,12 +447,12 @@ class CurrencyL10n(db.Model):
"""The localized name.""" """The localized name."""
class TransactionCurrency: class VoucherCurrency:
"""A currency in a transaction.""" """A currency in a voucher."""
def __init__(self, code: str, debit: list[JournalEntry], def __init__(self, code: str, debit: list[JournalEntry],
credit: list[JournalEntry]): credit: list[JournalEntry]):
"""Constructs the currency in the transaction. """Constructs the currency in the voucher.
:param code: The currency code. :param code: The currency code.
:param debit: The debit entries. :param debit: The debit entries.
@ -490,13 +490,13 @@ class TransactionCurrency:
return sum([x.amount for x in self.credit]) return sum([x.amount for x in self.credit])
class Transaction(db.Model): class Voucher(db.Model):
"""A transaction.""" """A voucher."""
__tablename__ = "accounting_transactions" __tablename__ = "accounting_vouchers"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False) autoincrement=False)
"""The transaction ID.""" """The voucher ID."""
date = db.Column(db.Date, nullable=False) date = db.Column(db.Date, nullable=False)
"""The date.""" """The date."""
no = db.Column(db.Integer, nullable=False, default=text("1")) no = db.Column(db.Integer, nullable=False, default=text("1"))
@ -523,22 +523,22 @@ class Transaction(db.Model):
"""The ID of the updator.""" """The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator.""" """The updator."""
entries = db.relationship("JournalEntry", back_populates="transaction") entries = db.relationship("JournalEntry", back_populates="voucher")
"""The journal entries.""" """The journal entries."""
def __str__(self) -> str: def __str__(self) -> str:
"""Returns the string representation of this transaction. """Returns the string representation of this voucher.
:return: The string representation of this transaction. :return: The string representation of this voucher.
""" """
if self.is_cash_expense: if self.is_cash_disbursement:
return gettext("Cash Expense Transaction#%(id)s", id=self.id) return gettext("Cash Disbursement Voucher#%(id)s", id=self.id)
if self.is_cash_income: if self.is_cash_receipt:
return gettext("Cash Income Transaction#%(id)s", id=self.id) return gettext("Cash Receipt Voucher#%(id)s", id=self.id)
return gettext("Transfer Transaction#%(id)s", id=self.id) return gettext("Transfer Voucher#%(id)s", id=self.id)
@property @property
def currencies(self) -> list[TransactionCurrency]: def currencies(self) -> list[VoucherCurrency]:
"""Returns the journal entries categorized by their currencies. """Returns the journal entries categorized by their currencies.
:return: The currency categories. :return: The currency categories.
@ -551,7 +551,7 @@ class Transaction(db.Model):
codes.append(entry.currency_code) codes.append(entry.currency_code)
by_currency[entry.currency_code] = [] by_currency[entry.currency_code] = []
by_currency[entry.currency_code].append(entry) by_currency[entry.currency_code].append(entry)
return [TransactionCurrency(code=x, return [VoucherCurrency(code=x,
debit=[y for y in by_currency[x] debit=[y for y in by_currency[x]
if y.is_debit], if y.is_debit],
credit=[y for y in by_currency[x] credit=[y for y in by_currency[x]
@ -559,10 +559,10 @@ class Transaction(db.Model):
for x in codes] for x in codes]
@property @property
def is_cash_income(self) -> bool: def is_cash_receipt(self) -> bool:
"""Returns whether this is a cash income transaction. """Returns whether this is a cash receipt voucher.
:return: True if this is a cash income transaction, or False otherwise. :return: True if this is a cash receipt voucher, or False otherwise.
""" """
for currency in self.currencies: for currency in self.currencies:
if len(currency.debit) > 1: if len(currency.debit) > 1:
@ -572,10 +572,10 @@ class Transaction(db.Model):
return True return True
@property @property
def is_cash_expense(self) -> bool: def is_cash_disbursement(self) -> bool:
"""Returns whether this is a cash expense transaction. """Returns whether this is a cash disbursement voucher.
:return: True if this is a cash expense transaction, or False :return: True if this is a cash disbursement voucher, or False
otherwise. otherwise.
""" """
for currency in self.currencies: for currency in self.currencies:
@ -587,9 +587,9 @@ class Transaction(db.Model):
@property @property
def can_delete(self) -> bool: def can_delete(self) -> bool:
"""Returns whether the transaction can be deleted. """Returns whether the voucher can be deleted.
:return: True if the transaction can be deleted, or False otherwise. :return: True if the voucher can be deleted, or False otherwise.
""" """
if not hasattr(self, "__can_delete"): if not hasattr(self, "__can_delete"):
def has_offset() -> bool: def has_offset() -> bool:
@ -601,12 +601,12 @@ class Transaction(db.Model):
return getattr(self, "__can_delete") return getattr(self, "__can_delete")
def delete(self) -> None: def delete(self) -> None:
"""Deletes the transaction. """Deletes the voucher.
:return: None. :return: None.
""" """
JournalEntry.query\ JournalEntry.query\
.filter(JournalEntry.transaction_id == self.id).delete() .filter(JournalEntry.voucher_id == self.id).delete()
db.session.delete(self) db.session.delete(self)
@ -617,18 +617,17 @@ class JournalEntry(db.Model):
id = db.Column(db.Integer, nullable=False, primary_key=True, id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False) autoincrement=False)
"""The entry ID.""" """The entry ID."""
transaction_id = db.Column(db.Integer, voucher_id = db.Column(db.Integer,
db.ForeignKey(Transaction.id, db.ForeignKey(Voucher.id, onupdate="CASCADE",
onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False) nullable=False)
"""The transaction ID.""" """The voucher ID."""
transaction = db.relationship(Transaction, back_populates="entries") voucher = db.relationship(Voucher, back_populates="entries")
"""The transaction.""" """The voucher."""
is_debit = db.Column(db.Boolean, nullable=False) is_debit = db.Column(db.Boolean, nullable=False)
"""True for a debit entry, or False for a credit entry.""" """True for a debit entry, or False for a credit entry."""
no = db.Column(db.Integer, nullable=False) no = db.Column(db.Integer, nullable=False)
"""The entry number under the transaction and debit or credit.""" """The entry number under the voucher and debit or credit."""
original_entry_id = db.Column(db.Integer, original_entry_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"), db.ForeignKey(id, onupdate="CASCADE"),
nullable=True) nullable=True)
@ -665,7 +664,7 @@ class JournalEntry(db.Model):
from accounting.template_filters import format_date, format_amount from accounting.template_filters import format_date, format_amount
setattr(self, "__str", setattr(self, "__str",
gettext("%(date)s %(summary)s %(amount)s", gettext("%(date)s %(summary)s %(amount)s",
date=format_date(self.transaction.date), date=format_date(self.voucher.date),
summary="" if self.summary is None summary="" if self.summary is None
else self.summary, else self.summary,
amount=format_amount(self.amount))) amount=format_amount(self.amount)))
@ -750,12 +749,13 @@ class JournalEntry(db.Model):
frac: Decimal = (value - whole).normalize() frac: Decimal = (value - whole).normalize()
return str(whole) + str(abs(frac))[1:] return str(whole) + str(abs(frac))[1:]
txn_day: date = self.transaction.date voucher_day: date = self.voucher.date
summary: str = "" if self.summary is None else self.summary summary: str = "" if self.summary is None else self.summary
return ([summary], return ([summary],
[str(txn_day.year), [str(voucher_day.year),
"{}/{}".format(txn_day.year, txn_day.month), "{}/{}".format(voucher_day.year, voucher_day.month),
"{}/{}".format(txn_day.month, txn_day.day), "{}/{}".format(voucher_day.month, voucher_day.day),
"{}/{}/{}".format(txn_day.year, txn_day.month, txn_day.day), "{}/{}/{}".format(voucher_day.year, voucher_day.month,
voucher_day.day),
format_amount(self.amount), format_amount(self.amount),
format_amount(self.net_balance)]) format_amount(self.net_balance)])

View File

@ -23,7 +23,7 @@ This file is largely taken from the NanoParma ERP project, first written in
import typing as t import typing as t
from datetime import date from datetime import date
from accounting.models import Transaction from accounting.models import Voucher
from .period import Period from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
@ -61,8 +61,8 @@ class PeriodChooser:
self.url_template: str = get_url(TemplatePeriod()) self.url_template: str = get_url(TemplatePeriod())
"""The URL template.""" """The URL template."""
first: Transaction | None \ first: Voucher | None \
= Transaction.query.order_by(Transaction.date).first() = Voucher.query.order_by(Voucher.date).first()
start: date | None = None if first is None else first.date start: date | None = None if first is None else first.date
# Attributes # Attributes

View File

@ -24,7 +24,7 @@ from flask import render_template, Response
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \ from accounting.models import Currency, BaseAccount, Account, Voucher, \
JournalEntry JournalEntry
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
@ -127,14 +127,14 @@ class AccountCollector:
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntry.currency_code == self.__currency.code,
sa.or_(*sub_conditions)] sa.or_(*sub_conditions)]
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(Voucher.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)).label("balance") else_=-JournalEntry.amount)).label("balance")
select_balance: sa.Select \ select_balance: sa.Select \
= sa.select(Account.id, Account.base_code, Account.no, = sa.select(Account.id, Account.base_code, Account.no,
balance_func)\ balance_func)\
.join(Transaction).join(Account)\ .join(Voucher).join(Account)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\ .group_by(Account.id, Account.base_code, Account.no)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)
@ -179,7 +179,7 @@ class AccountCollector:
return None return None
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntry.currency_code == self.__currency.code,
Transaction.date < self.__period.start] Voucher.date < self.__period.start]
return self.__query_balance(conditions) return self.__query_balance(conditions)
def __add_current_period(self) -> None: def __add_current_period(self) -> None:
@ -199,9 +199,9 @@ class AccountCollector:
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code] = [JournalEntry.currency_code == self.__currency.code]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(Voucher.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(Voucher.date <= self.__period.end)
return self.__query_balance(conditions) return self.__query_balance(conditions)
@staticmethod @staticmethod
@ -218,7 +218,7 @@ class AccountCollector:
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)) else_=-JournalEntry.amount))
select_balance: sa.Select = sa.select(balance_func)\ select_balance: sa.Select = sa.select(balance_func)\
.join(Transaction).join(Account).filter(*conditions) .join(Voucher).join(Account).filter(*conditions)
return db.session.scalar(select_balance) return db.session.scalar(select_balance)
def __add_owner_s_equity(self, code: str, amount: Decimal | None, def __add_owner_s_equity(self, code: str, amount: Decimal | None,

View File

@ -26,7 +26,7 @@ from sqlalchemy.orm import selectinload
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry from accounting.models import Currency, Account, Voucher, JournalEntry
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -70,14 +70,14 @@ class ReportEntry:
self.url: str | None = None self.url: str | None = None
"""The URL to the journal entry.""" """The URL to the journal entry."""
if entry is not None: if entry is not None:
self.date = entry.transaction.date self.date = entry.voucher.date
self.account = entry.account self.account = entry.account
self.summary = entry.summary self.summary = entry.summary
self.income = None if entry.is_debit else entry.amount self.income = None if entry.is_debit else entry.amount
self.expense = entry.amount if entry.is_debit else None self.expense = entry.amount if entry.is_debit else None
self.note = entry.transaction.note self.note = entry.voucher.note
self.url = url_for("accounting.transaction.detail", self.url = url_for("accounting.voucher.detail",
txn=entry.transaction) voucher=entry.voucher)
class EntryCollector: class EntryCollector:
@ -120,10 +120,10 @@ class EntryCollector:
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)) else_=-JournalEntry.amount))
select: sa.Select = sa.Select(balance_func)\ select: sa.Select = sa.Select(balance_func)\
.join(Transaction).join(Account)\ .join(Voucher).join(Account)\
.filter(be(JournalEntry.currency_code == self.__currency.code), .filter(be(JournalEntry.currency_code == self.__currency.code),
self.__account_condition, self.__account_condition,
Transaction.date < self.__period.start) Voucher.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: int | None = db.session.scalar(select)
if balance is None: if balance is None:
return None return None
@ -148,23 +148,23 @@ class EntryCollector:
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntry.currency_code == self.__currency.code,
self.__account_condition] self.__account_condition]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(Voucher.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(Voucher.date <= self.__period.end)
txn_with_account: sa.Select = sa.Select(Transaction.id).\ voucher_with_account: sa.Select = sa.Select(Voucher.id).\
join(JournalEntry).join(Account).filter(*conditions) join(JournalEntry).join(Account).filter(*conditions)
return [ReportEntry(x) return [ReportEntry(x)
for x in JournalEntry.query.join(Transaction).join(Account) for x in JournalEntry.query.join(Voucher).join(Account)
.filter(JournalEntry.transaction_id.in_(txn_with_account), .filter(JournalEntry.voucher_id.in_(voucher_with_account),
JournalEntry.currency_code == self.__currency.code, JournalEntry.currency_code == self.__currency.code,
sa.not_(self.__account_condition)) sa.not_(self.__account_condition))
.order_by(Transaction.date, .order_by(Voucher.date,
Transaction.no, Voucher.no,
JournalEntry.is_debit, JournalEntry.is_debit,
JournalEntry.no) JournalEntry.no)
.options(selectinload(JournalEntry.account), .options(selectinload(JournalEntry.account),
selectinload(JournalEntry.transaction))] selectinload(JournalEntry.voucher))]
@property @property
def __account_condition(self) -> sa.BinaryExpression: def __account_condition(self) -> sa.BinaryExpression:
@ -212,7 +212,7 @@ class EntryCollector:
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, txn_date: date | str | None, def __init__(self, voucher_date: date | str | None,
account: str | None, account: str | None,
summary: str | None, summary: str | None,
income: str | Decimal | None, income: str | Decimal | None,
@ -221,7 +221,7 @@ class CSVRow(BaseCSVRow):
note: str | None): note: str | None):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param txn_date: The transaction date. :param voucher_date: The voucher date.
:param account: The account. :param account: The account.
:param summary: The summary. :param summary: The summary.
:param income: The income. :param income: The income.
@ -229,7 +229,7 @@ class CSVRow(BaseCSVRow):
:param balance: The balance. :param balance: The balance.
:param note: The note. :param note: The note.
""" """
self.date: date | str | None = txn_date self.date: date | str | None = voucher_date
"""The date.""" """The date."""
self.account: str | None = account self.account: str | None = account
"""The account.""" """The account."""

View File

@ -24,7 +24,7 @@ from flask import render_template, Response
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \ from accounting.models import Currency, BaseAccount, Account, Voucher, \
JournalEntry JournalEntry
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
@ -259,14 +259,14 @@ class IncomeStatement(BaseReport):
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntry.currency_code == self.__currency.code,
sa.or_(*sub_conditions)] sa.or_(*sub_conditions)]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(Voucher.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(Voucher.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, -JournalEntry.amount), (JournalEntry.is_debit, -JournalEntry.amount),
else_=JournalEntry.amount)).label("balance") else_=JournalEntry.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\ select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\ .join(Voucher).join(Account)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(Account.id)\ .group_by(Account.id)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)

View File

@ -25,7 +25,7 @@ from flask import render_template, Response
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry from accounting.models import Currency, Account, Voucher, JournalEntry
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -47,8 +47,8 @@ class ReportEntry:
""" """
self.entry: JournalEntry = entry self.entry: JournalEntry = entry
"""The journal entry.""" """The journal entry."""
self.transaction: Transaction = entry.transaction self.voucher: Voucher = entry.voucher
"""The transaction.""" """The voucher."""
self.currency: Currency = entry.currency self.currency: Currency = entry.currency
"""The account.""" """The account."""
self.account: Account = entry.account self.account: Account = entry.account
@ -66,7 +66,7 @@ class ReportEntry:
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, txn_date: str | date, def __init__(self, voucher_date: str | date,
currency: str, currency: str,
account: str, account: str,
summary: str | None, summary: str | None,
@ -75,13 +75,13 @@ class CSVRow(BaseCSVRow):
note: str | None): note: str | None):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param txn_date: The transaction date. :param voucher_date: The voucher date.
:param summary: The summary. :param summary: The summary.
:param debit: The debit amount. :param debit: The debit amount.
:param credit: The credit amount. :param credit: The credit amount.
:param note: The note. :param note: The note.
""" """
self.date: str | date = txn_date self.date: str | date = voucher_date
"""The date.""" """The date."""
self.currency: str = currency self.currency: str = currency
"""The currency.""" """The currency."""
@ -155,9 +155,9 @@ def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
gettext("Account"), gettext("Summary"), gettext("Account"), gettext("Summary"),
gettext("Debit"), gettext("Credit"), gettext("Debit"), gettext("Credit"),
gettext("Note"))] gettext("Note"))]
rows.extend([CSVRow(x.transaction.date, x.currency.code, rows.extend([CSVRow(x.voucher.date, x.currency.code,
str(x.account).title(), x.summary, str(x.account).title(), x.summary,
x.debit, x.credit, x.transaction.note) x.debit, x.credit, x.voucher.note)
for x in entries]) for x in entries])
return rows return rows
@ -182,18 +182,18 @@ class Journal(BaseReport):
""" """
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(Voucher.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(Voucher.date <= self.__period.end)
return JournalEntry.query.join(Transaction)\ return JournalEntry.query.join(Voucher)\
.filter(*conditions)\ .filter(*conditions)\
.order_by(Transaction.date, .order_by(Voucher.date,
Transaction.no, Voucher.no,
JournalEntry.is_debit.desc(), JournalEntry.is_debit.desc(),
JournalEntry.no)\ JournalEntry.no)\
.options(selectinload(JournalEntry.account), .options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency), selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all() selectinload(JournalEntry.voucher)).all()
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.

View File

@ -26,7 +26,7 @@ from sqlalchemy.orm import selectinload
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry from accounting.models import Currency, Account, Voucher, JournalEntry
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -67,13 +67,13 @@ class ReportEntry:
self.url: str | None = None self.url: str | None = None
"""The URL to the journal entry.""" """The URL to the journal entry."""
if entry is not None: if entry is not None:
self.date = entry.transaction.date self.date = entry.voucher.date
self.summary = entry.summary self.summary = entry.summary
self.debit = entry.amount if entry.is_debit else None self.debit = entry.amount if entry.is_debit else None
self.credit = None if entry.is_debit else entry.amount self.credit = None if entry.is_debit else entry.amount
self.note = entry.transaction.note self.note = entry.voucher.note
self.url = url_for("accounting.transaction.detail", self.url = url_for("accounting.voucher.detail",
txn=entry.transaction) voucher=entry.voucher)
class EntryCollector: class EntryCollector:
@ -116,10 +116,10 @@ class EntryCollector:
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)) else_=-JournalEntry.amount))
select: sa.Select = sa.Select(balance_func).join(Transaction)\ select: sa.Select = sa.Select(balance_func).join(Voucher)\
.filter(be(JournalEntry.currency_code == self.__currency.code), .filter(be(JournalEntry.currency_code == self.__currency.code),
be(JournalEntry.account_id == self.__account.id), be(JournalEntry.account_id == self.__account.id),
Transaction.date < self.__period.start) Voucher.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: int | None = db.session.scalar(select)
if balance is None: if balance is None:
return None return None
@ -143,16 +143,16 @@ class EntryCollector:
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntry.currency_code == self.__currency.code,
JournalEntry.account_id == self.__account.id] JournalEntry.account_id == self.__account.id]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(Voucher.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(Voucher.date <= self.__period.end)
return [ReportEntry(x) for x in JournalEntry.query.join(Transaction) return [ReportEntry(x) for x in JournalEntry.query.join(Voucher)
.filter(*conditions) .filter(*conditions)
.order_by(Transaction.date, .order_by(Voucher.date,
Transaction.no, Voucher.no,
JournalEntry.is_debit.desc(), JournalEntry.is_debit.desc(),
JournalEntry.no) JournalEntry.no)
.options(selectinload(JournalEntry.transaction)).all()] .options(selectinload(JournalEntry.voucher)).all()]
def __get_total_entry(self) -> ReportEntry | None: def __get_total_entry(self) -> ReportEntry | None:
"""Composes the total entry. """Composes the total entry.
@ -193,7 +193,7 @@ class EntryCollector:
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, txn_date: date | str | None, def __init__(self, voucher_date: date | str | None,
summary: str | None, summary: str | None,
debit: str | Decimal | None, debit: str | Decimal | None,
credit: str | Decimal | None, credit: str | Decimal | None,
@ -201,14 +201,14 @@ class CSVRow(BaseCSVRow):
note: str | None): note: str | None):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param txn_date: The transaction date. :param voucher_date: The voucher date.
:param summary: The summary. :param summary: The summary.
:param debit: The debit amount. :param debit: The debit amount.
:param credit: The credit amount. :param credit: The credit amount.
:param balance: The balance. :param balance: The balance.
:param note: The note. :param note: The note.
""" """
self.date: date | str | None = txn_date self.date: date | str | None = voucher_date
"""The date.""" """The date."""
self.summary: str | None = summary self.summary: str | None = summary
"""The summary.""" """The summary."""

View File

@ -26,7 +26,7 @@ from sqlalchemy.orm import selectinload
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \ from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
Transaction, JournalEntry Voucher, JournalEntry
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import csv_download from accounting.report.utils.csv_export import csv_download
@ -62,21 +62,21 @@ class EntryCollector:
self.__get_account_condition(k)), self.__get_account_condition(k)),
JournalEntry.currency_code.in_( JournalEntry.currency_code.in_(
self.__get_currency_condition(k)), self.__get_currency_condition(k)),
JournalEntry.transaction_id.in_( JournalEntry.voucher_id.in_(
self.__get_transaction_condition(k))] self.__get_voucher_condition(k))]
try: try:
sub_conditions.append(JournalEntry.amount == Decimal(k)) sub_conditions.append(JournalEntry.amount == Decimal(k))
except ArithmeticError: except ArithmeticError:
pass pass
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
return JournalEntry.query.join(Transaction).filter(*conditions)\ return JournalEntry.query.join(Voucher).filter(*conditions)\
.order_by(Transaction.date, .order_by(Voucher.date,
Transaction.no, Voucher.no,
JournalEntry.is_debit, JournalEntry.is_debit,
JournalEntry.no)\ JournalEntry.no)\
.options(selectinload(JournalEntry.account), .options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency), selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all() selectinload(JournalEntry.voucher)).all()
@staticmethod @staticmethod
def __get_account_condition(k: str) -> sa.Select: def __get_account_condition(k: str) -> sa.Select:
@ -115,35 +115,35 @@ class EntryCollector:
Currency.code.in_(select_l10n))) Currency.code.in_(select_l10n)))
@staticmethod @staticmethod
def __get_transaction_condition(k: str) -> sa.Select: def __get_voucher_condition(k: str) -> sa.Select:
"""Composes and returns the condition to filter the transaction. """Composes and returns the condition to filter the voucher.
:param k: The keyword. :param k: The keyword.
:return: The condition to filter the transaction. :return: The condition to filter the voucher.
""" """
conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)] conditions: list[sa.BinaryExpression] = [Voucher.note.contains(k)]
txn_date: datetime voucher_date: datetime
try: try:
txn_date = datetime.strptime(k, "%Y") voucher_date = datetime.strptime(k, "%Y")
conditions.append( conditions.append(
be(sa.extract("year", Transaction.date) == txn_date.year)) be(sa.extract("year", Voucher.date) == voucher_date.year))
except ValueError: except ValueError:
pass pass
try: try:
txn_date = datetime.strptime(k, "%Y/%m") voucher_date = datetime.strptime(k, "%Y/%m")
conditions.append(sa.and_( conditions.append(sa.and_(
sa.extract("year", Transaction.date) == txn_date.year, sa.extract("year", Voucher.date) == voucher_date.year,
sa.extract("month", Transaction.date) == txn_date.month)) sa.extract("month", Voucher.date) == voucher_date.month))
except ValueError: except ValueError:
pass pass
try: try:
txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d") voucher_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
conditions.append(sa.and_( conditions.append(sa.and_(
sa.extract("month", Transaction.date) == txn_date.month, sa.extract("month", Voucher.date) == voucher_date.month,
sa.extract("day", Transaction.date) == txn_date.day)) sa.extract("day", Voucher.date) == voucher_date.day))
except ValueError: except ValueError:
pass pass
return sa.select(Transaction.id).filter(sa.or_(*conditions)) return sa.select(Voucher.id).filter(sa.or_(*conditions))
class PageParams(BasePageParams): class PageParams(BasePageParams):

View File

@ -24,7 +24,7 @@ from flask import Response, render_template
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry from accounting.models import Currency, Account, Voucher, JournalEntry
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -180,14 +180,14 @@ class TrialBalance(BaseReport):
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code] = [JournalEntry.currency_code == self.__currency.code]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(Voucher.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(Voucher.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)).label("balance") else_=-JournalEntry.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\ select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\ .join(Voucher).join(Account)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(Account.id)\ .group_by(Account.id)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)

View File

@ -27,7 +27,7 @@ from flask import request
from accounting import db from accounting import db
from accounting.models import Currency, JournalEntry from accounting.models import Currency, JournalEntry
from accounting.utils.txn_types import TransactionType from accounting.utils.voucher_types import VoucherType
from .option_link import OptionLink from .option_link import OptionLink
from .report_chooser import ReportChooser from .report_chooser import ReportChooser
@ -52,12 +52,12 @@ class BasePageParams(ABC):
""" """
@property @property
def txn_types(self) -> t.Type[TransactionType]: def voucher_types(self) -> t.Type[VoucherType]:
"""Returns the transaction types. """Returns the voucher types.
:return: The transaction types. :return: The voucher types.
""" """
return TransactionType return VoucherType
@property @property
def csv_uri(self) -> str: def csv_uri(self) -> str:

View File

@ -149,7 +149,7 @@
overflow-y: scroll; overflow-y: scroll;
} }
/** The transaction management */ /** The voucher management */
.accounting-currency-control { .accounting-currency-control {
background-color: transparent; background-color: transparent;
} }
@ -169,14 +169,14 @@
.accounting-list-group-hover .list-group-item:hover { .accounting-list-group-hover .list-group-item:hover {
background-color: #ececec; background-color: #ececec;
} }
.accounting-transaction-entry { .accounting-voucher-entry {
border: none; border: none;
} }
.accounting-transaction-entry-header { .accounting-voucher-entry-header {
font-weight: bolder; font-weight: bolder;
border-bottom: thick double slategray; border-bottom: thick double slategray;
} }
.list-group-item.accounting-transaction-entry-total { .list-group-item.accounting-voucher-entry-total {
font-weight: bolder; font-weight: bolder;
border-top: thick double slategray; border-top: thick double slategray;
} }

View File

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form * account-selector.js: The JavaScript for the account selector
*/ */
/* Copyright (c) 2023 imacat. /* Copyright (c) 2023 imacat.

View File

@ -29,8 +29,8 @@
class JournalEntryEditor { class JournalEntryEditor {
/** /**
* The transaction form * The voucher form
* @type {TransactionForm} * @type {VoucherForm}
*/ */
form; form;
@ -217,7 +217,7 @@ class JournalEntryEditor {
/** /**
* Constructs a new journal entry editor. * Constructs a new journal entry editor.
* *
* @param form {TransactionForm} the transaction form * @param form {VoucherForm} the voucher form
*/ */
constructor(form) { constructor(form) {
this.form = form; this.form = form;

View File

@ -105,7 +105,7 @@ class OriginalEntrySelector {
* Returns the net balance for an original entry. * Returns the net balance for an original entry.
* *
* @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing * @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing
* @param form {TransactionForm} the transaction form * @param form {VoucherForm} the voucher form
* @param originalEntryId {string} the ID of the original entry * @param originalEntryId {string} the ID of the original entry
* @return {Decimal} the net balance of the original entry * @return {Decimal} the net balance of the original entry
*/ */

View File

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Flask Project
* transaction-form.js: The JavaScript for the transaction form * voucher-form.js: The JavaScript for the voucher form
*/ */
/* Copyright (c) 2023 imacat. /* Copyright (c) 2023 imacat.
@ -23,14 +23,14 @@
"use strict"; "use strict";
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
TransactionForm.initialize(); VoucherForm.initialize();
}); });
/** /**
* The transaction form * The voucher form
* *
*/ */
class TransactionForm { class VoucherForm {
/** /**
* The form element * The form element
@ -105,7 +105,7 @@ class TransactionForm {
entryEditor; entryEditor;
/** /**
* Constructs the transaction form. * Constructs the voucher form.
* *
*/ */
constructor() { constructor() {
@ -325,17 +325,17 @@ class TransactionForm {
} }
/** /**
* The transaction form * The voucher form
* @type {TransactionForm} * @type {VoucherForm}
*/ */
static #form; static #form;
/** /**
* Initializes the transaction form. * Initializes the voucher form.
* *
*/ */
static initialize() { static initialize() {
this.#form = new TransactionForm() this.#form = new VoucherForm()
} }
} }
@ -352,8 +352,8 @@ class CurrencySubForm {
element; element;
/** /**
* The transaction form * The voucher form
* @type {TransactionForm} * @type {VoucherForm}
*/ */
form; form;
@ -420,7 +420,7 @@ class CurrencySubForm {
/** /**
* Constructs a currency sub-form * Constructs a currency sub-form
* *
* @param form {TransactionForm} the transaction form * @param form {VoucherForm} the voucher form
* @param element {HTMLDivElement} the currency sub-form element * @param element {HTMLDivElement} the currency sub-form element
*/ */
constructor(form, element) { constructor(form, element) {

View File

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Flask Project
* transaction-order.js: The JavaScript for the transaction order * voucher-order.js: The JavaScript for the voucher order
*/ */
/* Copyright (c) 2023 imacat. /* Copyright (c) 2023 imacat.

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The template globals for the transaction management. """The template globals.
""" """
from flask import current_app from flask import current_app

View File

@ -37,7 +37,7 @@ First written: 2023/3/7
{% endwith %} {% endwith %}
</div> </div>
{% include "accounting/report/include/add-txn-material-fab.html" %} {% include "accounting/report/include/add-voucher-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %} {% include "accounting/report/include/period-chooser.html" %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
add-txn-material-fab.html: The material floating action buttons to add a new transaction add-voucher-material-fab.html: The material floating action buttons to add a new voucher
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -22,13 +22,13 @@ First written: 2023/2/25
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab"> <div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
<div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group"> <div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}"> <a class="btn rounded-pill" href="{{ url_for("accounting.voucher.create", voucher_type=report.voucher_types.CASH_DISBURSEMENT)|accounting_append_next }}">
{{ A_("Cash expense") }} {{ A_("Cash expense") }}
</a> </a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}"> <a class="btn rounded-pill" href="{{ url_for("accounting.voucher.create", voucher_type=report.voucher_types.CASH_RECEIPT)|accounting_append_next }}">
{{ A_("Cash income") }} {{ A_("Cash income") }}
</a> </a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}"> <a class="btn rounded-pill" href="{{ url_for("accounting.voucher.create", voucher_type=report.voucher_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }} {{ A_("Transfer") }}
</a> </a>
</div> </div>

View File

@ -27,17 +27,17 @@ First written: 2023/3/8
</button> </button>
<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.voucher.create", voucher_type=report.voucher_types.CASH_DISBURSEMENT)|accounting_append_next }}">
{{ A_("Cash Expense") }} {{ A_("Cash Expense") }}
</a> </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.voucher.create", voucher_type=report.voucher_types.CASH_RECEIPT)|accounting_append_next }}">
{{ A_("Cash Income") }} {{ A_("Cash Income") }}
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}"> <a class="dropdown-item" href="{{ url_for("accounting.voucher.create", voucher_type=report.voucher_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }} {{ A_("Transfer") }}
</a> </a>
</li> </li>

View File

@ -38,7 +38,7 @@ First written: 2023/3/5
{% endwith %} {% endwith %}
</div> </div>
{% include "accounting/report/include/add-txn-material-fab.html" %} {% include "accounting/report/include/add-voucher-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %} {% include "accounting/report/include/period-chooser.html" %}

View File

@ -37,7 +37,7 @@ First written: 2023/3/7
{% endwith %} {% endwith %}
</div> </div>
{% include "accounting/report/include/add-txn-material-fab.html" %} {% include "accounting/report/include/add-voucher-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %} {% include "accounting/report/include/period-chooser.html" %}

View File

@ -36,7 +36,7 @@ First written: 2023/3/4
{% endwith %} {% endwith %}
</div> </div>
{% include "accounting/report/include/add-txn-material-fab.html" %} {% include "accounting/report/include/add-voucher-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %} {% include "accounting/report/include/period-chooser.html" %}
@ -60,8 +60,8 @@ First written: 2023/3/4
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% for entry in report.entries %} {% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ url_for("accounting.voucher.detail", voucher=entry.voucher)|accounting_append_next }}">
<div>{{ entry.transaction.date|accounting_format_date }}</div> <div>{{ entry.voucher.date|accounting_format_date }}</div>
<div>{{ entry.currency.name }}</div> <div>{{ entry.currency.name }}</div>
<div> <div>
<span class="d-none d-md-inline">{{ entry.account.code }}</span> <span class="d-none d-md-inline">{{ entry.account.code }}</span>
@ -77,11 +77,11 @@ First written: 2023/3/4
<div class="list-group d-md-none"> <div class="list-group d-md-none">
{% for entry in report.entries %} {% for entry in report.entries %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}"> <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.voucher.detail", voucher=entry.voucher)|accounting_append_next }}">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}> <div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small"> <div class="text-muted small">
{{ entry.transaction.date|accounting_format_date }} {{ entry.voucher.date|accounting_format_date }}
{{ entry.account.title|title }} {{ entry.account.title|title }}
{% if entry.currency.code != accounting_default_currency_code() %} {% if entry.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span> <span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>

View File

@ -38,7 +38,7 @@ First written: 2023/3/5
{% endwith %} {% endwith %}
</div> </div>
{% include "accounting/report/include/add-txn-material-fab.html" %} {% include "accounting/report/include/add-voucher-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %} {% include "accounting/report/include/period-chooser.html" %}

View File

@ -35,7 +35,7 @@ First written: 2023/3/8
{% endwith %} {% endwith %}
</div> </div>
{% include "accounting/report/include/add-txn-material-fab.html" %} {% include "accounting/report/include/add-voucher-material-fab.html" %}
{% include "accounting/report/include/search-modal.html" %} {% include "accounting/report/include/search-modal.html" %}
@ -57,8 +57,8 @@ First written: 2023/3/8
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% for entry in report.entries %} {% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ url_for("accounting.voucher.detail", voucher=entry.voucher)|accounting_append_next }}">
<div>{{ entry.transaction.date|accounting_format_date }}</div> <div>{{ entry.voucher.date|accounting_format_date }}</div>
<div>{{ entry.currency.name }}</div> <div>{{ entry.currency.name }}</div>
<div> <div>
<span class="d-none d-md-inline">{{ entry.account.code }}</span> <span class="d-none d-md-inline">{{ entry.account.code }}</span>
@ -74,11 +74,11 @@ First written: 2023/3/8
<div class="list-group d-md-none"> <div class="list-group d-md-none">
{% for entry in report.entries %} {% for entry in report.entries %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}"> <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.voucher.detail", voucher=entry.voucher)|accounting_append_next }}">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}> <div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small"> <div class="text-muted small">
{{ entry.transaction.date|accounting_format_date }} {{ entry.voucher.date|accounting_format_date }}
{{ entry.account.title|title }} {{ entry.account.title|title }}
{% if entry.currency.code != accounting_default_currency_code() %} {% if entry.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span> <span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>

View File

@ -37,7 +37,7 @@ First written: 2023/3/5
{% endwith %} {% endwith %}
</div> </div>
{% include "accounting/report/include/add-txn-material-fab.html" %} {% include "accounting/report/include/add-voucher-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %} {% include "accounting/report/include/period-chooser.html" %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
create.html: The cash expense transaction creation form create.html: The cash disbursement voucher creation form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ create.html: The cash expense transaction creation form
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
{% extends "accounting/transaction/expense/include/form.html" %} {% extends "accounting/voucher/disbursement/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Voucher") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %} {% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.voucher.store", voucher_type=voucher_type) }}{% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
detail.html: The account detail detail.html: The cash disbursement voucher detail
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,26 +19,26 @@ detail.html: The account detail
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26 First written: 2023/2/26
#} #}
{% extends "accounting/transaction/include/detail.html" %} {% extends "accounting/voucher/include/detail.html" %}
{% block to_transfer %} {% block to_transfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}"> <a class="btn btn-primary" href="{{ url_for("accounting.voucher.edit", voucher=obj)|accounting_voucher_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i> <i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }} {{ A_("To Transfer") }}
</a> </a>
{% endblock %} {% endblock %}
{% block transaction_currencies %} {% block voucher_currencies %}
{% for currency in obj.currencies %} {% for currency in obj.currencies %}
<div class="mb-3"> <div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div> <div class="mb-2 fw-bolder">{{ currency.name }}</div>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li> <li class="list-group-item accounting-voucher-entry accounting-voucher-entry-header">{{ A_("Content") }}</li>
{% with entries = currency.debit %} {% with entries = currency.debit %}
{% include "accounting/transaction/include/detail-entries.html" %} {% include "accounting/voucher/include/detail-entries.html" %}
{% endwith %} {% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> <li class="list-group-item accounting-voucher-entry accounting-voucher-entry-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div> <div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
edit.html: The cash income transaction edit form edit.html: The cash disbursement voucher edit form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ edit.html: The cash income transaction edit form
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
{% extends "accounting/transaction/income/include/form.html" %} {% extends "accounting/voucher/disbursement/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Editing %(voucher)s", voucher=voucher) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %} {% block back_url %}{{ url_for("accounting.voucher.detail", voucher=voucher)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %} {% block action_url %}{{ url_for("accounting.voucher.update", voucher=voucher)|accounting_voucher_with_type }}{% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the cash expense transaction form currency-sub-form.html: The currency sub-form in the cash disbursement voucher form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -64,11 +64,11 @@ First written: 2023/2/25
offset_total = entry_form.offset_total|accounting_default("0"), offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance, net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount, net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input, amount_data = entry_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = entry_form.amount.errors, amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount, amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %} entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %} {% include "accounting/voucher/include/form-entry-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
form.html: The cash expense transaction form form.html: The cash disbursement voucher form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,7 +19,7 @@ form.html: The cash expense transaction form
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
{% extends "accounting/transaction/include/form.html" %} {% extends "accounting/voucher/include/form.html" %}
{% block currency_sub_forms %} {% block currency_sub_forms %}
{% if form.currencies %} {% if form.currencies %}
@ -33,7 +33,7 @@ First written: 2023/2/25
debit_forms = currency_form.debit, debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors, debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_format_amount %} debit_total = currency_form.form.debit_total|accounting_format_amount %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %} {% include "accounting/voucher/disbursement/include/form-currency-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
{% else %} {% else %}
@ -41,17 +41,17 @@ First written: 2023/2/25
only_one_currency_form = True, only_one_currency_form = True,
currency_code_data = accounting_default_currency_code(), currency_code_data = accounting_default_currency_code(),
debit_total = "-" %} debit_total = "-" %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %} {% include "accounting/voucher/disbursement/include/form-currency-item.html" %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block form_modals %} {% block form_modals %}
{% with summary_editor = form.summary_editor.debit %} {% with summary_editor = form.summary_editor.debit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %} {% include "accounting/voucher/include/summary-editor-modal.html" %}
{% endwith %} {% endwith %}
{% with entry_type = "debit", {% with entry_type = "debit",
account_options = form.debit_account_options %} account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %} {% include "accounting/voucher/include/account-selector-modal.html" %}
{% endwith %} {% endwith %}
{% endblock %} {% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
detail-entries-item: The journal entries in the transaction detail detail-entries-item: The journal entries in the voucher detail
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -21,7 +21,7 @@ First written: 2023/3/14
#} #}
{# <ul> For SonarQube not to complain about incorrect HTML #} {# <ul> For SonarQube not to complain about incorrect HTML #}
{% for entry in entries %} {% for entry in entries %}
<li class="list-group-item accounting-transaction-entry"> <li class="list-group-item accounting-voucher-entry">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div> <div>
<div class="small">{{ entry.account }}</div> <div class="small">{{ entry.account }}</div>
@ -30,7 +30,7 @@ First written: 2023/3/14
{% endif %} {% endif %}
{% if entry.original_entry %} {% if entry.original_entry %}
<div class="fst-italic small accounting-original-entry"> <div class="fst-italic small accounting-original-entry">
<a href="{{ url_for("accounting.transaction.detail", txn=entry.original_entry.transaction)|accounting_append_next }}"> <a href="{{ url_for("accounting.voucher.detail", voucher=entry.original_entry.voucher)|accounting_append_next }}">
{{ A_("Offset %(entry)s", entry=entry.original_entry) }} {{ A_("Offset %(entry)s", entry=entry.original_entry) }}
</a> </a>
</div> </div>
@ -43,8 +43,8 @@ First written: 2023/3/14
<ul class="ms-2 ps-0"> <ul class="ms-2 ps-0">
{% for offset in entry.offsets %} {% for offset in entry.offsets %}
<li> <li>
<a href="{{ url_for("accounting.transaction.detail", txn=offset.transaction)|accounting_append_next }}"> <a href="{{ url_for("accounting.voucher.detail", voucher=offset.voucher)|accounting_append_next }}">
{{ offset.transaction.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }} {{ offset.voucher.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
</a> </a>
</li> </li>
{% endfor %} {% endfor %}

View File

@ -31,12 +31,12 @@ First written: 2023/2/26
{{ A_("Back") }} {{ A_("Back") }}
</a> </a>
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}"> <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.voucher.edit", voucher=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i> <i class="fa-solid fa-gear"></i>
{{ A_("Settings") }} {{ A_("Settings") }}
</a> </a>
{% endif %} {% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.order", txn_date=obj.date)|accounting_append_next }}"> <a class="btn btn-primary" href="{{ url_for("accounting.voucher.order", voucher_date=obj.date)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i> <i class="fa-solid fa-bars-staggered"></i>
{{ A_("Order") }} {{ A_("Order") }}
</a> </a>
@ -58,14 +58,14 @@ First written: 2023/2/26
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab"> <div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}"> <a class="btn btn-primary" href="{{ url_for("accounting.voucher.edit", voucher=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i> <i class="fa-solid fa-pen-to-square"></i>
</a> </a>
</div> </div>
{% endif %} {% endif %}
{% if accounting_can_edit() and obj.can_delete %} {% if accounting_can_edit() and obj.can_delete %}
<form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post"> <form action="{{ url_for("accounting.voucher.delete", voucher=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %} {% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}"> <input type="hidden" name="next" value="{{ request.args.next }}">
@ -74,11 +74,11 @@ First written: 2023/2/26
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Transaction Confirmation") }}</h1> <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Voucher Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{{ A_("Do you really want to delete this transaction?") }} {{ A_("Do you really want to delete this voucher?") }}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
@ -99,13 +99,13 @@ First written: 2023/2/26
{{ obj.date|accounting_format_date }} {{ obj.date|accounting_format_date }}
</div> </div>
{% block transaction_currencies %}{% endblock %} {% block voucher_currencies %}{% endblock %}
{% if obj.note %} {% if obj.note %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<i class="far fa-comment-dots"></i> <i class="far fa-comment-dots"></i>
{{ obj.note|accounting_txn_text2html|safe }} {{ obj.note|accounting_voucher_text2html|safe }}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
entry-sub-form.html: The journal entry sub-form in the transaction form entry-sub-form.html: The journal entry sub-form in the voucher form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -43,7 +43,7 @@ First written: 2023/2/25
<div>{{ A_("Offsets") }}</div> <div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0"> <ul class="ms-2 ps-0">
{% for offset in offset_entries %} {% for offset in offset_entries %}
<li>{{ offset.transaction.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li> <li>{{ offset.voucher.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
form.html: The transfer transaction form form.html: The base voucher form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -23,7 +23,7 @@ First written: 2023/2/26
{% block accounting_scripts %} {% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/voucher-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-editor.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/journal-entry-editor.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/original-entry-selector.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/original-entry-selector.js") }}"></script>
@ -88,8 +88,8 @@ First written: 2023/2/26
</div> </div>
</form> </form>
{% include "accounting/transaction/include/journal-entry-editor-modal.html" %} {% include "accounting/voucher/include/journal-entry-editor-modal.html" %}
{% block form_modals %}{% endblock %} {% block form_modals %}{% endblock %}
{% include "accounting/transaction/include/original-entry-selector-modal.html" %} {% include "accounting/voucher/include/original-entry-selector-modal.html" %}
{% endblock %} {% endblock %}

View File

@ -37,8 +37,8 @@ First written: 2023/2/25
<ul id="accounting-original-entry-selector-option-list" class="list-group accounting-selector-list"> <ul id="accounting-original-entry-selector-option-list" class="list-group accounting-selector-list">
{% for entry in form.original_entry_options %} {% for entry in form.original_entry_options %}
<li id="accounting-original-entry-selector-option-{{ entry.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-entry-selector-option" data-id="{{ entry.id }}" data-date="{{ entry.transaction.date }}" data-entry-type="{{ "debit" if entry.is_debit else "credit" }}" data-currency-code="{{ entry.currency.code }}" data-account-code="{{ entry.account_code }}" data-account-text="{{ entry.account }}" data-summary="{{ entry.summary|accounting_default }}" data-net-balance="{{ entry.net_balance|accounting_txn_format_amount_input }}" data-text="{{ entry }}" data-query-values="{{ entry.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal"> <li id="accounting-original-entry-selector-option-{{ entry.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-entry-selector-option" data-id="{{ entry.id }}" data-date="{{ entry.voucher.date }}" data-entry-type="{{ "debit" if entry.is_debit else "credit" }}" data-currency-code="{{ entry.currency.code }}" data-account-code="{{ entry.account_code }}" data-account-text="{{ entry.account }}" data-summary="{{ entry.summary|accounting_default }}" data-net-balance="{{ entry.net_balance|accounting_voucher_format_amount_input }}" data-text="{{ entry }}" data-query-values="{{ entry.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<div>{{ entry.transaction.date|accounting_format_date }} {{ entry.summary|accounting_default }}</div> <div>{{ entry.voucher.date|accounting_format_date }} {{ entry.summary|accounting_default }}</div>
<div> <div>
<span class="badge bg-primary rounded-pill"> <span class="badge bg-primary rounded-pill">
<span id="accounting-original-entry-selector-option-{{ entry.id }}-net-balance">{{ entry.net_balance|accounting_format_amount }}</span> <span id="accounting-original-entry-selector-option-{{ entry.id }}-net-balance">{{ entry.net_balance|accounting_format_amount }}</span>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
order.html: The order of the transactions in a same day order.html: The order of the vouchers in a same day
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -23,10 +23,10 @@ First written: 2023/2/26
{% block accounting_scripts %} {% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-order.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/voucher-order.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{{ A_("Transactions on %(date)s", date=date) }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Vouchers on %(date)s", date=date) }}{% endblock %}{% endblock %}
{% block content %} {% block content %}
@ -38,7 +38,7 @@ First written: 2023/2/26
</div> </div>
{% if list|length > 1 and accounting_can_edit() %} {% if list|length > 1 and accounting_can_edit() %}
<form action="{{ url_for("accounting.transaction.sort", txn_date=date) }}" method="post"> <form action="{{ url_for("accounting.voucher.sort", voucher_date=date) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %} {% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}"> <input type="hidden" name="next" value="{{ request.args.next }}">

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
create.html: The transfer transaction creation form create.html: The cash receipt voucher creation form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ create.html: The transfer transaction creation form
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
{% extends "accounting/transaction/transfer/include/form.html" %} {% extends "accounting/voucher/receipt/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Transfer Transaction") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Cash Receipt Voucher") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %} {% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.voucher.store", voucher_type=voucher_type) }}{% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
detail.html: The account detail detail.html: The cash receipt voucher detail
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,26 +19,26 @@ detail.html: The account detail
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26 First written: 2023/2/26
#} #}
{% extends "accounting/transaction/include/detail.html" %} {% extends "accounting/voucher/include/detail.html" %}
{% block to_transfer %} {% block to_transfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}"> <a class="btn btn-primary" href="{{ url_for("accounting.voucher.edit", voucher=obj)|accounting_voucher_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i> <i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }} {{ A_("To Transfer") }}
</a> </a>
{% endblock %} {% endblock %}
{% block transaction_currencies %} {% block voucher_currencies %}
{% for currency in obj.currencies %} {% for currency in obj.currencies %}
<div class="mb-3"> <div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div> <div class="mb-2 fw-bolder">{{ currency.name }}</div>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li> <li class="list-group-item accounting-voucher-entry accounting-voucher-entry-header">{{ A_("Content") }}</li>
{% with entries = currency.credit %} {% with entries = currency.credit %}
{% include "accounting/transaction/include/detail-entries.html" %} {% include "accounting/voucher/include/detail-entries.html" %}
{% endwith %} {% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> <li class="list-group-item accounting-voucher-entry accounting-voucher-entry-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div> <div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
edit.html: The cash expense transaction edit form edit.html: The cash receipt voucher edit form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ edit.html: The cash expense transaction edit form
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
{% extends "accounting/transaction/expense/include/form.html" %} {% extends "accounting/voucher/receipt/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Editing %(voucher)s", voucher=voucher) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %} {% block back_url %}{{ url_for("accounting.voucher.detail", voucher=voucher)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %} {% block action_url %}{{ url_for("accounting.voucher.update", voucher=voucher)|accounting_voucher_with_type }}{% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the cash income transaction form currency-sub-form.html: The currency sub-form in the cash receipt voucher form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -64,11 +64,11 @@ First written: 2023/2/25
offset_total = entry_form.offset_total|accounting_default("0"), offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance, net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount, net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input, amount_data = entry_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = entry_form.amount.errors, amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount, amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %} entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %} {% include "accounting/voucher/include/form-entry-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
form.html: The cash income transaction form form.html: The cash receipt voucher form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,7 +19,7 @@ form.html: The cash income transaction form
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
{% extends "accounting/transaction/include/form.html" %} {% extends "accounting/voucher/include/form.html" %}
{% block currency_sub_forms %} {% block currency_sub_forms %}
{% if form.currencies %} {% if form.currencies %}
@ -33,7 +33,7 @@ First written: 2023/2/25
credit_forms = currency_form.credit, credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors, credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_format_amount %} credit_total = currency_form.form.credit_total|accounting_format_amount %}
{% include "accounting/transaction/income/include/form-currency-item.html" %} {% include "accounting/voucher/receipt/include/form-currency-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
{% else %} {% else %}
@ -41,17 +41,17 @@ First written: 2023/2/25
only_one_currency_form = True, only_one_currency_form = True,
currency_code_data = accounting_default_currency_code(), currency_code_data = accounting_default_currency_code(),
credit_total = "-" %} credit_total = "-" %}
{% include "accounting/transaction/income/include/form-currency-item.html" %} {% include "accounting/voucher/receipt/include/form-currency-item.html" %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block form_modals %} {% block form_modals %}
{% with summary_editor = form.summary_editor.credit %} {% with summary_editor = form.summary_editor.credit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %} {% include "accounting/voucher/include/summary-editor-modal.html" %}
{% endwith %} {% endwith %}
{% with entry_type = "credit", {% with entry_type = "credit",
account_options = form.credit_account_options %} account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %} {% include "accounting/voucher/include/account-selector-modal.html" %}
{% endwith %} {% endwith %}
{% endblock %} {% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
create.html: The cash income transaction creation form create.html: The transfer voucher creation form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ create.html: The cash income transaction creation form
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
{% extends "accounting/transaction/income/include/form.html" %} {% extends "accounting/voucher/transfer/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Cash Income Transaction") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Transfer Voucher") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %} {% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.voucher.store", voucher_type=voucher_type) }}{% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
detail.html: The account detail detail.html: The transfer voucher detail
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,9 +19,9 @@ detail.html: The account detail
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26 First written: 2023/2/26
#} #}
{% extends "accounting/transaction/include/detail.html" %} {% extends "accounting/voucher/include/detail.html" %}
{% block transaction_currencies %} {% block voucher_currencies %}
{% for currency in obj.currencies %} {% for currency in obj.currencies %}
<div class="mb-3"> <div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div> <div class="mb-2 fw-bolder">{{ currency.name }}</div>
@ -30,11 +30,11 @@ First written: 2023/2/26
{# The debit entries #} {# The debit entries #}
<div class="col-sm-6 mb-2"> <div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Debit") }}</li> <li class="list-group-item accounting-voucher-entry accounting-voucher-entry-header">{{ A_("Debit") }}</li>
{% with entries = currency.debit %} {% with entries = currency.debit %}
{% include "accounting/transaction/include/detail-entries.html" %} {% include "accounting/voucher/include/detail-entries.html" %}
{% endwith %} {% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> <li class="list-group-item accounting-voucher-entry accounting-voucher-entry-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div> <div>{{ currency.debit_total|accounting_format_amount }}</div>
@ -46,11 +46,11 @@ First written: 2023/2/26
{# The credit entries #} {# The credit entries #}
<div class="col-sm-6 mb-2"> <div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Credit") }}</li> <li class="list-group-item accounting-voucher-entry accounting-voucher-entry-header">{{ A_("Credit") }}</li>
{% with entries = currency.credit %} {% with entries = currency.credit %}
{% include "accounting/transaction/include/detail-entries.html" %} {% include "accounting/voucher/include/detail-entries.html" %}
{% endwith %} {% endwith %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> <li class="list-group-item accounting-voucher-entry accounting-voucher-entry-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div> <div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
edit.html: The transfer transaction edit form edit.html: The transfer voucher edit form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ edit.html: The transfer transaction edit form
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
{% extends "accounting/transaction/transfer/include/form.html" %} {% extends "accounting/voucher/transfer/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Editing %(voucher)s", voucher=voucher) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %} {% block back_url %}{{ url_for("accounting.voucher.detail", voucher=voucher)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %} {% block action_url %}{{ url_for("accounting.voucher.update", voucher=voucher)|accounting_voucher_with_type }}{% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the transfer transaction form currency-sub-form.html: The currency sub-form in the transfer voucher form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -66,11 +66,11 @@ First written: 2023/2/25
offset_total = entry_form.offset_total|accounting_default, offset_total = entry_form.offset_total|accounting_default,
net_balance_data = entry_form.net_balance, net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount, net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input, amount_data = entry_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = entry_form.amount.errors, amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount, amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %} entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %} {% include "accounting/voucher/include/form-entry-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>
@ -114,11 +114,11 @@ First written: 2023/2/25
offset_total = entry_form.offset_total|accounting_default("0"), offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance, net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount, net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input, amount_data = entry_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = entry_form.amount.errors, amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount, amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %} entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %} {% include "accounting/voucher/include/form-entry-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
form.html: The transfer transaction form form.html: The transfer voucher form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,7 +19,7 @@ form.html: The transfer transaction form
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
{% extends "accounting/transaction/include/form.html" %} {% extends "accounting/voucher/include/form.html" %}
{% block currency_sub_forms %} {% block currency_sub_forms %}
{% if form.currencies %} {% if form.currencies %}
@ -36,7 +36,7 @@ First written: 2023/2/25
credit_forms = currency_form.credit, credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors, credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_format_amount %} credit_total = currency_form.form.credit_total|accounting_format_amount %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %} {% include "accounting/voucher/transfer/include/form-currency-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
{% else %} {% else %}
@ -45,24 +45,24 @@ First written: 2023/2/25
currency_code_data = accounting_default_currency_code(), currency_code_data = accounting_default_currency_code(),
debit_total = "-", debit_total = "-",
credit_total = "-" %} credit_total = "-" %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %} {% include "accounting/voucher/transfer/include/form-currency-item.html" %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block form_modals %} {% block form_modals %}
{% with summary_editor = form.summary_editor.debit %} {% with summary_editor = form.summary_editor.debit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %} {% include "accounting/voucher/include/summary-editor-modal.html" %}
{% endwith %} {% endwith %}
{% with summary_editor = form.summary_editor.credit %} {% with summary_editor = form.summary_editor.credit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %} {% include "accounting/voucher/include/summary-editor-modal.html" %}
{% endwith %} {% endwith %}
{% with entry_type = "debit", {% with entry_type = "debit",
account_options = form.debit_account_options %} account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %} {% include "accounting/voucher/include/account-selector-modal.html" %}
{% endwith %} {% endwith %}
{% with entry_type = "credit", {% with entry_type = "credit",
account_options = form.credit_account_options %} account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %} {% include "accounting/voucher/include/account-selector-modal.html" %}
{% endwith %} {% endwith %}
{% endblock %} {% endblock %}

View File

@ -1,92 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 reorder forms for the transaction management.
"""
from datetime import date
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import Transaction
def sort_transactions_in(txn_date: date, exclude: int | None = None) -> None:
"""Sorts the transactions under a date after changing the date or deleting
a transaction.
:param txn_date: The date of the transaction.
:param exclude: The transaction ID to exclude.
:return: None.
"""
conditions: list[sa.BinaryExpression] = [Transaction.date == txn_date]
if exclude is not None:
conditions.append(Transaction.id != exclude)
transactions: list[Transaction] = Transaction.query\
.filter(*conditions)\
.order_by(Transaction.no).all()
for i in range(len(transactions)):
if transactions[i].no != i + 1:
transactions[i].no = i + 1
class TransactionReorderForm:
"""The form to reorder the transactions."""
def __init__(self, txn_date: date):
"""Constructs the form to reorder the transactions in a day.
:param txn_date: The date.
"""
self.date: date = txn_date
self.is_modified: bool = False
def save_order(self) -> None:
"""Saves the order of the account.
:return:
"""
transactions: list[Transaction] = Transaction.query\
.filter(Transaction.date == self.date).all()
# Collects the specified order.
orders: dict[Transaction, int] = {}
for txn in transactions:
if f"{txn.id}-no" in request.form:
try:
orders[txn] = int(request.form[f"{txn.id}-no"])
except ValueError:
pass
# Missing and invalid orders are appended to the end.
missing: list[Transaction] \
= [x for x in transactions if x not in orders]
if len(missing) > 0:
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
for txn in missing:
orders[txn] = next_no
# Sort by the specified order first, and their original order.
transactions.sort(key=lambda x: (orders[x], x.no))
# Update the orders.
with db.session.no_autoflush:
for i in range(len(transactions)):
if transactions[i].no != i + 1:
transactions[i].no = i + 1
self.is_modified = True

View File

@ -1,326 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# 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 operators for different transaction types.
"""
import typing as t
from abc import ABC, abstractmethod
from flask import render_template, request, abort
from flask_wtf import FlaskForm
from accounting.models import Transaction
from accounting.template_globals import default_currency_code
from accounting.utils.txn_types import TransactionType
from accounting.transaction.forms import TransactionForm, \
IncomeTransactionForm, ExpenseTransactionForm, TransferTransactionForm
class TransactionOperator(ABC):
"""The base transaction operator."""
CHECK_ORDER: int = -1
"""The order when checking the transaction operator."""
@property
@abstractmethod
def form(self) -> t.Type[TransactionForm]:
"""Returns the form class.
:return: The form class.
"""
@abstractmethod
def render_create_template(self, form: FlaskForm) -> str:
"""Renders the template for the form to create a transaction.
:param form: The transaction form.
:return: the form to create a transaction.
"""
@abstractmethod
def render_detail_template(self, txn: Transaction) -> str:
"""Renders the template for the detail page.
:param txn: The transaction.
:return: the detail page.
"""
@abstractmethod
def render_edit_template(self, txn: Transaction, form: FlaskForm) -> str:
"""Renders the template for the form to edit a transaction.
:param txn: The transaction.
:param form: The form.
:return: the form to edit a transaction.
"""
@abstractmethod
def is_my_type(self, txn: Transaction) -> bool:
"""Checks and returns whether the transaction belongs to the type.
:param txn: The transaction.
:return: True if the transaction belongs to the type, or False
otherwise.
"""
@property
def _entry_template(self) -> str:
"""Renders and returns the template for the journal entry sub-form.
:return: The template for the journal entry sub-form.
"""
return render_template(
"accounting/transaction/include/form-entry-item.html",
currency_index="CURRENCY_INDEX",
entry_type="ENTRY_TYPE",
entry_index="ENTRY_INDEX")
class IncomeTransaction(TransactionOperator):
"""An income transaction."""
CHECK_ORDER: int = 2
"""The order when checking the transaction operator."""
@property
def form(self) -> t.Type[TransactionForm]:
"""Returns the form class.
:return: The form class.
"""
return IncomeTransactionForm
def render_create_template(self, form: IncomeTransactionForm) -> str:
"""Renders the template for the form to create a transaction.
:param form: The transaction form.
:return: the form to create a transaction.
"""
return render_template("accounting/transaction/income/create.html",
form=form,
txn_type=TransactionType.CASH_INCOME,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def render_detail_template(self, txn: Transaction) -> str:
"""Renders the template for the detail page.
:param txn: The transaction.
:return: the detail page.
"""
return render_template("accounting/transaction/income/detail.html",
obj=txn)
def render_edit_template(self, txn: Transaction,
form: IncomeTransactionForm) -> str:
"""Renders the template for the form to edit a transaction.
:param txn: The transaction.
:param form: The form.
:return: the form to edit a transaction.
"""
return render_template("accounting/transaction/income/edit.html",
txn=txn, form=form,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def is_my_type(self, txn: Transaction) -> bool:
"""Checks and returns whether the transaction belongs to the type.
:param txn: The transaction.
:return: True if the transaction belongs to the type, or False
otherwise.
"""
return txn.is_cash_income
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/transaction/income/include/form-currency-item.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
credit_total="-")
class ExpenseTransaction(TransactionOperator):
"""An expense transaction."""
CHECK_ORDER: int = 1
"""The order when checking the transaction operator."""
@property
def form(self) -> t.Type[TransactionForm]:
"""Returns the form class.
:return: The form class.
"""
return ExpenseTransactionForm
def render_create_template(self, form: ExpenseTransactionForm) -> str:
"""Renders the template for the form to create a transaction.
:param form: The transaction form.
:return: the form to create a transaction.
"""
return render_template("accounting/transaction/expense/create.html",
form=form,
txn_type=TransactionType.CASH_EXPENSE,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def render_detail_template(self, txn: Transaction) -> str:
"""Renders the template for the detail page.
:param txn: The transaction.
:return: the detail page.
"""
return render_template("accounting/transaction/expense/detail.html",
obj=txn)
def render_edit_template(self, txn: Transaction,
form: ExpenseTransactionForm) -> str:
"""Renders the template for the form to edit a transaction.
:param txn: The transaction.
:param form: The form.
:return: the form to edit a transaction.
"""
return render_template("accounting/transaction/expense/edit.html",
txn=txn, form=form,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def is_my_type(self, txn: Transaction) -> bool:
"""Checks and returns whether the transaction belongs to the type.
:param txn: The transaction.
:return: True if the transaction belongs to the type, or False
otherwise.
"""
return txn.is_cash_expense
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/transaction/expense/include/form-currency-item.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
debit_total="-")
class TransferTransaction(TransactionOperator):
"""A transfer transaction."""
CHECK_ORDER: int = 3
"""The order when checking the transaction operator."""
@property
def form(self) -> t.Type[TransactionForm]:
"""Returns the form class.
:return: The form class.
"""
return TransferTransactionForm
def render_create_template(self, form: TransferTransactionForm) -> str:
"""Renders the template for the form to create a transaction.
:param form: The transaction form.
:return: the form to create a transaction.
"""
return render_template("accounting/transaction/transfer/create.html",
form=form,
txn_type=TransactionType.TRANSFER,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def render_detail_template(self, txn: Transaction) -> str:
"""Renders the template for the detail page.
:param txn: The transaction.
:return: the detail page.
"""
return render_template("accounting/transaction/transfer/detail.html",
obj=txn)
def render_edit_template(self, txn: Transaction,
form: TransferTransactionForm) -> str:
"""Renders the template for the form to edit a transaction.
:param txn: The transaction.
:param form: The form.
:return: the form to edit a transaction.
"""
return render_template("accounting/transaction/transfer/edit.html",
txn=txn, form=form,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def is_my_type(self, txn: Transaction) -> bool:
"""Checks and returns whether the transaction belongs to the type.
:param txn: The transaction.
:return: True if the transaction belongs to the type, or False
otherwise.
"""
return True
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/transaction/transfer/include/form-currency-item.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
debit_total="-", credit_total="-")
TXN_TYPE_TO_OP: dict[TransactionType, TransactionOperator] \
= {TransactionType.CASH_INCOME: IncomeTransaction(),
TransactionType.CASH_EXPENSE: ExpenseTransaction(),
TransactionType.TRANSFER: TransferTransaction()}
"""The map from the transaction types to their operators."""
def get_txn_op(txn: Transaction, is_check_as: bool = False) \
-> TransactionOperator:
"""Returns the transaction operator that may be specified in the "as" query
parameter. If it is not specified, check the transaction type from the
transaction.
:param txn: The transaction.
:param is_check_as: True to check the "as" parameter, or False otherwise.
:return: None.
"""
if is_check_as and "as" in request.args:
type_dict: dict[str, TransactionType] \
= {x.value: x for x in TransactionType}
if request.args["as"] not in type_dict:
abort(404)
return TXN_TYPE_TO_OP[type_dict[request.args["as"]]]
for txn_type in sorted(TXN_TYPE_TO_OP.values(),
key=lambda x: x.CHECK_ORDER):
if txn_type.is_my_type(txn):
return txn_type

View File

@ -1,222 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# 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 views for the transaction management.
"""
from datetime import date
from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, request, \
flash, url_for
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Transaction
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.txn_types import TransactionType
from accounting.utils.user import get_current_user_pk
from .forms import sort_transactions_in, TransactionReorderForm
from .template_filters import with_type, to_transfer, format_amount_input, \
text2html
from .utils.operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op
bp: Blueprint = Blueprint("transaction", __name__)
"""The view blueprint for the transaction management."""
bp.add_app_template_filter(with_type, "accounting_txn_with_type")
bp.add_app_template_filter(to_transfer, "accounting_txn_to_transfer")
bp.add_app_template_filter(format_amount_input,
"accounting_txn_format_amount_input")
bp.add_app_template_filter(text2html, "accounting_txn_text2html")
@bp.get("/create/<transactionType:txn_type>", endpoint="create")
@has_permission(can_edit)
def show_add_transaction_form(txn_type: TransactionType) -> str:
"""Shows the form to add a transaction.
:param txn_type: The transaction type.
:return: The form to add a transaction.
"""
txn_op: TransactionOperator = TXN_TYPE_TO_OP[txn_type]
form: txn_op.form
if "form" in session:
form = txn_op.form(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = txn_op.form()
form.date.data = date.today()
return txn_op.render_create_template(form)
@bp.post("/store/<transactionType:txn_type>", endpoint="store")
@has_permission(can_edit)
def add_transaction(txn_type: TransactionType) -> redirect:
"""Adds a transaction.
:param txn_type: The transaction type.
:return: The redirection to the transaction detail on success, or the
transaction creation form on error.
"""
txn_op: TransactionOperator = TXN_TYPE_TO_OP[txn_type]
form: txn_op.form = txn_op.form(request.form)
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(with_type(
url_for("accounting.transaction.create", txn_type=txn_type))))
txn: Transaction = Transaction()
form.populate_obj(txn)
db.session.add(txn)
db.session.commit()
flash(s(lazy_gettext("The transaction is added successfully")), "success")
return redirect(inherit_next(__get_detail_uri(txn)))
@bp.get("/<transaction:txn>", endpoint="detail")
@has_permission(can_view)
def show_transaction_detail(txn: Transaction) -> str:
"""Shows the transaction detail.
:param txn: The transaction.
:return: The detail.
"""
txn_op: TransactionOperator = get_txn_op(txn)
return txn_op.render_detail_template(txn)
@bp.get("/<transaction:txn>/edit", endpoint="edit")
@has_permission(can_edit)
def show_transaction_edit_form(txn: Transaction) -> str:
"""Shows the form to edit a transaction.
:param txn: The transaction.
:return: The form to edit the transaction.
"""
txn_op: TransactionOperator = get_txn_op(txn, is_check_as=True)
form: txn_op.form
if "form" in session:
form = txn_op.form(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.obj = txn
form.validate()
else:
form = txn_op.form(obj=txn)
return txn_op.render_edit_template(txn, form)
@bp.post("/<transaction:txn>/update", endpoint="update")
@has_permission(can_edit)
def update_transaction(txn: Transaction) -> redirect:
"""Updates a transaction.
:param txn: The transaction.
:return: The redirection to the transaction detail on success, or the
transaction edit form on error.
"""
txn_op: TransactionOperator = get_txn_op(txn, is_check_as=True)
form: txn_op.form = txn_op.form(request.form)
form.obj = txn
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(with_type(
url_for("accounting.transaction.edit", txn=txn))))
with db.session.no_autoflush:
form.populate_obj(txn)
if not form.is_modified:
flash(s(lazy_gettext("The transaction was not modified.")), "success")
return redirect(inherit_next(__get_detail_uri(txn)))
txn.updated_by_id = get_current_user_pk()
txn.updated_at = sa.func.now()
db.session.commit()
flash(s(lazy_gettext("The transaction is updated successfully.")),
"success")
return redirect(inherit_next(__get_detail_uri(txn)))
@bp.post("/<transaction:txn>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_transaction(txn: Transaction) -> redirect:
"""Deletes a transaction.
:param txn: The transaction.
:return: The redirection to the transaction list on success, or the
transaction detail on error.
"""
txn.delete()
sort_transactions_in(txn.date, txn.id)
db.session.commit()
flash(s(lazy_gettext("The transaction is deleted successfully.")),
"success")
return redirect(or_next(__get_default_page_uri()))
@bp.get("/dates/<date:txn_date>", endpoint="order")
@has_permission(can_view)
def show_transaction_order(txn_date: date) -> str:
"""Shows the order of the transactions in a same date.
:param txn_date: The date.
:return: The order of the transactions in the date.
"""
transactions: list[Transaction] = Transaction.query \
.filter(Transaction.date == txn_date) \
.order_by(Transaction.no).all()
return render_template("accounting/transaction/order.html",
date=txn_date, list=transactions)
@bp.post("/dates/<date:txn_date>", endpoint="sort")
@has_permission(can_edit)
def sort_transactions(txn_date: date) -> redirect:
"""Reorders the transactions in a date.
:param txn_date: The date.
:return: The redirection to the incoming account or the account list. The
reordering operation does not fail.
"""
form: TransactionReorderForm = TransactionReorderForm(txn_date)
form.save_order()
if not form.is_modified:
flash(s(lazy_gettext("The order was not modified.")), "success")
return redirect(or_next(__get_default_page_uri()))
db.session.commit()
flash(s(lazy_gettext("The order is updated successfully.")), "success")
return redirect(or_next(__get_default_page_uri()))
def __get_detail_uri(txn: Transaction) -> str:
"""Returns the detail URI of a transaction.
:param txn: The transaction.
:return: The detail URI of the transaction.
"""
return url_for("accounting.transaction.detail", txn=txn)
def __get_default_page_uri() -> str:
"""Returns the URI for the default page.
:return: The URI for the default page.
"""
return url_for("accounting.report.default")

View File

@ -14,17 +14,17 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The transaction types. """The voucher types.
""" """
from enum import Enum from enum import Enum
class TransactionType(Enum): class VoucherType(Enum):
"""The transaction types.""" """The voucher types."""
CASH_INCOME: str = "income" CASH_RECEIPT: str = "receipt"
"""The cash income transaction.""" """The cash receipt voucher."""
CASH_EXPENSE: str = "expense" CASH_DISBURSEMENT: str = "disbursement"
"""The cash expense transaction.""" """The cash disbursement voucher."""
TRANSFER: str = "transfer" TRANSFER: str = "transfer"
"""The transfer transaction.""" """The transfer voucher."""

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The transaction management. """The voucher management.
""" """
from flask import Flask, Blueprint from flask import Flask, Blueprint
@ -27,11 +27,11 @@ def init_app(app: Flask, bp: Blueprint) -> None:
:param bp: The blueprint of the accounting application. :param bp: The blueprint of the accounting application.
:return: None. :return: None.
""" """
from .converters import TransactionConverter, TransactionTypeConverter, \ from .converters import VoucherConverter, VoucherTypeConverter, \
DateConverter DateConverter
app.url_map.converters["transaction"] = TransactionConverter app.url_map.converters["voucher"] = VoucherConverter
app.url_map.converters["transactionType"] = TransactionTypeConverter app.url_map.converters["voucherType"] = VoucherTypeConverter
app.url_map.converters["date"] = DateConverter app.url_map.converters["date"] = DateConverter
from .views import bp as transaction_bp from .views import bp as voucher_bp
bp.register_blueprint(transaction_bp, url_prefix="/transactions") bp.register_blueprint(voucher_bp, url_prefix="/vouchers")

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The path converters for the transaction management. """The path converters for the voucher management.
""" """
from datetime import date from datetime import date
@ -23,61 +23,59 @@ from flask import abort
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting.models import Transaction, JournalEntry from accounting.models import Voucher, JournalEntry
from accounting.utils.txn_types import TransactionType from accounting.utils.voucher_types import VoucherType
class TransactionConverter(BaseConverter): class VoucherConverter(BaseConverter):
"""The transaction converter to convert the transaction ID from and to the """The voucher converter to convert the voucher ID from and to the
corresponding transaction in the routes.""" corresponding voucher in the routes."""
def to_python(self, value: str) -> Transaction: def to_python(self, value: str) -> Voucher:
"""Converts a transaction ID to a transaction. """Converts a voucher ID to a voucher.
:param value: The transaction ID. :param value: The voucher ID.
:return: The corresponding transaction. :return: The corresponding voucher.
""" """
transaction: Transaction | None = Transaction.query\ voucher: Voucher | None = Voucher.query.join(JournalEntry)\
.join(JournalEntry)\ .filter(Voucher.id == value)\
.filter(Transaction.id == value)\ .options(selectinload(Voucher.entries)
.options(selectinload(Transaction.entries)
.selectinload(JournalEntry.offsets) .selectinload(JournalEntry.offsets)
.selectinload(JournalEntry.transaction))\ .selectinload(JournalEntry.voucher))\
.first() .first()
if transaction is None: if voucher is None:
abort(404) abort(404)
return transaction return voucher
def to_url(self, value: Transaction) -> str: def to_url(self, value: Voucher) -> str:
"""Converts a transaction to its ID. """Converts a voucher to its ID.
:param value: The transaction. :param value: The voucher.
:return: The ID. :return: The ID.
""" """
return str(value.id) return str(value.id)
class TransactionTypeConverter(BaseConverter): class VoucherTypeConverter(BaseConverter):
"""The transaction converter to convert the transaction type ID from and to """The voucher converter to convert the voucher type ID from and to the
the corresponding transaction type in the routes.""" corresponding voucher type in the routes."""
def to_python(self, value: str) -> TransactionType: def to_python(self, value: str) -> VoucherType:
"""Converts a transaction ID to a transaction. """Converts a voucher ID to a voucher.
:param value: The transaction ID. :param value: The voucher ID.
:return: The corresponding transaction. :return: The corresponding voucher type.
""" """
type_dict: dict[str, TransactionType] \ type_dict: dict[str, VoucherType] = {x.value: x for x in VoucherType}
= {x.value: x for x in TransactionType} voucher_type: VoucherType | None = type_dict.get(value)
txn_type: TransactionType | None = type_dict.get(value) if voucher_type is None:
if txn_type is None:
abort(404) abort(404)
return txn_type return voucher_type
def to_url(self, value: TransactionType) -> str: def to_url(self, value: VoucherType) -> str:
"""Converts a transaction type to its ID. """Converts a voucher type to its ID.
:param value: The transaction type. :param value: The voucher type.
:return: The ID. :return: The ID.
""" """
return str(value.value) return str(value.value)

View File

@ -14,9 +14,9 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The forms for the transaction management. """The forms for the voucher management.
""" """
from .reorder import sort_transactions_in, TransactionReorderForm from .reorder import sort_vouchers_in, VoucherReorderForm
from .transaction import TransactionForm, IncomeTransactionForm, \ from .voucher import VoucherForm, CashReceiptVoucherForm, \
ExpenseTransactionForm, TransferTransactionForm CashDisbursementVoucherForm, TransferVoucherForm

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The currency sub-forms for the transaction management. """The currency sub-forms for the voucher management.
""" """
from decimal import Decimal from decimal import Decimal
@ -29,7 +29,7 @@ from wtforms.validators import DataRequired
from accounting import db from accounting import db
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Currency, JournalEntry from accounting.models import Currency, JournalEntry
from accounting.transaction.utils.offset_alias import offset_alias from accounting.voucher.utils.offset_alias import offset_alias
from accounting.utils.cast import be from accounting.utils.cast import be
from accounting.utils.strip_text import strip_text from accounting.utils.strip_text import strip_text
from .journal_entry import JournalEntryForm, CreditEntryForm, DebitEntryForm from .journal_entry import JournalEntryForm, CreditEntryForm, DebitEntryForm
@ -117,9 +117,9 @@ class IsBalanced:
class CurrencyForm(FlaskForm): class CurrencyForm(FlaskForm):
"""The form to create or edit a currency in a transaction.""" """The form to create or edit a currency in a voucher."""
no = IntegerField() no = IntegerField()
"""The order in the transaction.""" """The order in the voucher."""
code = StringField() code = StringField()
"""The currency code.""" """The currency code."""
whole_form = BooleanField() whole_form = BooleanField()
@ -132,9 +132,9 @@ class CurrencyForm(FlaskForm):
:return: The journal entry sub-forms. :return: The journal entry sub-forms.
""" """
entry_forms: list[JournalEntryForm] = [] entry_forms: list[JournalEntryForm] = []
if isinstance(self, IncomeCurrencyForm): if isinstance(self, CashReceiptCurrencyForm):
entry_forms.extend([x.form for x in self.credit]) entry_forms.extend([x.form for x in self.credit])
elif isinstance(self, ExpenseCurrencyForm): elif isinstance(self, CashDisbursementCurrencyForm):
entry_forms.extend([x.form for x in self.debit]) entry_forms.extend([x.form for x in self.debit])
elif isinstance(self, TransferCurrencyForm): elif isinstance(self, TransferCurrencyForm):
entry_forms.extend([x.form for x in self.debit]) entry_forms.extend([x.form for x in self.debit])
@ -161,10 +161,10 @@ class CurrencyForm(FlaskForm):
return db.session.scalar(select) > 0 return db.session.scalar(select) > 0
class IncomeCurrencyForm(CurrencyForm): class CashReceiptCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash income transaction.""" """The form to create or edit a currency in a cash receipt voucher."""
no = IntegerField() no = IntegerField()
"""The order in the transaction.""" """The order in the voucher."""
code = StringField( code = StringField(
filters=[strip_text], filters=[strip_text],
validators=[CURRENCY_REQUIRED, validators=[CURRENCY_REQUIRED,
@ -198,10 +198,10 @@ class IncomeCurrencyForm(CurrencyForm):
if isinstance(x, str) or isinstance(x, LazyString)] if isinstance(x, str) or isinstance(x, LazyString)]
class ExpenseCurrencyForm(CurrencyForm): class CashDisbursementCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash expense transaction.""" """The form to create or edit a currency in a cash disbursement voucher."""
no = IntegerField() no = IntegerField()
"""The order in the transaction.""" """The order in the voucher."""
code = StringField( code = StringField(
filters=[strip_text], filters=[strip_text],
validators=[CURRENCY_REQUIRED, validators=[CURRENCY_REQUIRED,
@ -236,9 +236,9 @@ class ExpenseCurrencyForm(CurrencyForm):
class TransferCurrencyForm(CurrencyForm): class TransferCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a transfer transaction.""" """The form to create or edit a currency in a transfer voucher."""
no = IntegerField() no = IntegerField()
"""The order in the transaction.""" """The order in the voucher."""
code = StringField( code = StringField(
filters=[strip_text], filters=[strip_text],
validators=[CURRENCY_REQUIRED, validators=[CURRENCY_REQUIRED,

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The journal entry sub-forms for the transaction management. """The journal entry sub-forms for the voucher management.
""" """
import re import re
@ -232,8 +232,8 @@ class NotExceedingOriginalEntryNetBalance:
return return
is_debit: bool = isinstance(form, DebitEntryForm) is_debit: bool = isinstance(form, DebitEntryForm)
existing_entry_id: set[int] = set() existing_entry_id: set[int] = set()
if form.txn_form.obj is not None: if form.voucher_form.obj is not None:
existing_entry_id = {x.id for x in form.txn_form.obj.entries} existing_entry_id = {x.id for x in form.voucher_form.obj.entries}
offset_total_func: sa.Function = sa.func.sum(sa.case( offset_total_func: sa.Function = sa.func.sum(sa.case(
(be(JournalEntry.is_debit == is_debit), JournalEntry.amount), (be(JournalEntry.is_debit == is_debit), JournalEntry.amount),
else_=-JournalEntry.amount)) else_=-JournalEntry.amount))
@ -244,7 +244,7 @@ class NotExceedingOriginalEntryNetBalance:
if offset_total_but_form is None: if offset_total_but_form is None:
offset_total_but_form = Decimal("0") offset_total_but_form = Decimal("0")
offset_total_on_form: Decimal = sum( offset_total_on_form: Decimal = sum(
[x.amount.data for x in form.txn_form.entries [x.amount.data for x in form.voucher_form.entries
if x.original_entry_id.data == original_entry.id if x.original_entry_id.data == original_entry.id
and x.amount != field and x.amount.data is not None]) and x.amount != field and x.amount.data is not None])
net_balance: Decimal = original_entry.amount - offset_total_but_form \ net_balance: Decimal = original_entry.amount - offset_total_but_form \
@ -288,15 +288,15 @@ class JournalEntryForm(FlaskForm):
"""The amount.""" """The amount."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Constructs a base transaction form. """Constructs a base journal entry form.
:param args: The arguments. :param args: The arguments.
:param kwargs: The keyword arguments. :param kwargs: The keyword arguments.
""" """
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
from .transaction import TransactionForm from .voucher import VoucherForm
self.txn_form: TransactionForm | None = None self.voucher_form: VoucherForm | None = None
"""The source transaction form.""" """The source voucher form."""
@property @property
def account_text(self) -> str: def account_text(self) -> str:
@ -333,7 +333,7 @@ class JournalEntryForm(FlaskForm):
:return: The text representation of the original entry. :return: The text representation of the original entry.
""" """
return None if self.__original_entry is None \ return None if self.__original_entry is None \
else self.__original_entry.transaction.date else self.__original_entry.voucher.date
@property @property
def original_entry_text(self) -> str | None: def original_entry_text(self) -> str | None:
@ -375,10 +375,10 @@ class JournalEntryForm(FlaskForm):
return [] return []
return JournalEntry.query\ return JournalEntry.query\
.filter(JournalEntry.original_entry_id == self.eid.data)\ .filter(JournalEntry.original_entry_id == self.eid.data)\
.options(selectinload(JournalEntry.transaction), .options(selectinload(JournalEntry.voucher),
selectinload(JournalEntry.account), selectinload(JournalEntry.account),
selectinload(JournalEntry.offsets) selectinload(JournalEntry.offsets)
.selectinload(JournalEntry.transaction)).all() .selectinload(JournalEntry.voucher)).all()
setattr(self, "__offsets", get_offsets()) setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets") return getattr(self, "__offsets")

View File

@ -0,0 +1,92 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 reorder forms for the voucher management.
"""
from datetime import date
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import Voucher
def sort_vouchers_in(voucher_date: date, exclude: int | None = None) -> None:
"""Sorts the vouchers under a date after changing the date or deleting
a voucher.
:param voucher_date: The date of the voucher.
:param exclude: The voucher ID to exclude.
:return: None.
"""
conditions: list[sa.BinaryExpression] = [Voucher.date == voucher_date]
if exclude is not None:
conditions.append(Voucher.id != exclude)
vouchers: list[Voucher] = Voucher.query\
.filter(*conditions)\
.order_by(Voucher.no).all()
for i in range(len(vouchers)):
if vouchers[i].no != i + 1:
vouchers[i].no = i + 1
class VoucherReorderForm:
"""The form to reorder the vouchers."""
def __init__(self, voucher_date: date):
"""Constructs the form to reorder the vouchers in a day.
:param voucher_date: The date.
"""
self.date: date = voucher_date
self.is_modified: bool = False
def save_order(self) -> None:
"""Saves the order of the account.
:return:
"""
vouchers: list[Voucher] = Voucher.query\
.filter(Voucher.date == self.date).all()
# Collects the specified order.
orders: dict[Voucher, int] = {}
for voucher in vouchers:
if f"{voucher.id}-no" in request.form:
try:
orders[voucher] = int(request.form[f"{voucher.id}-no"])
except ValueError:
pass
# Missing and invalid orders are appended to the end.
missing: list[Voucher] \
= [x for x in vouchers if x not in orders]
if len(missing) > 0:
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
for voucher in missing:
orders[voucher] = next_no
# Sort by the specified order first, and their original order.
vouchers.sort(key=lambda x: (orders[x], x.no))
# Update the orders.
with db.session.no_autoflush:
for i in range(len(vouchers)):
if vouchers[i].no != i + 1:
vouchers[i].no = i + 1
self.is_modified = True

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The transaction forms for the transaction management. """The voucher forms for the voucher management.
""" """
import datetime as dt import datetime as dt
@ -30,19 +30,19 @@ from wtforms.validators import DataRequired, ValidationError
from accounting import db from accounting import db
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Transaction, Account, JournalEntry, \ from accounting.models import Voucher, Account, JournalEntry, \
TransactionCurrency VoucherCurrency
from accounting.transaction.utils.account_option import AccountOption from accounting.voucher.utils.account_option import AccountOption
from accounting.transaction.utils.original_entries import \ from accounting.voucher.utils.original_entries import \
get_selectable_original_entries get_selectable_original_entries
from accounting.transaction.utils.summary_editor import SummaryEditor from accounting.voucher.utils.summary_editor import SummaryEditor
from accounting.utils.random_id import new_id from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .currency import CurrencyForm, IncomeCurrencyForm, ExpenseCurrencyForm, \ from .currency import CurrencyForm, CashReceiptCurrencyForm, \
TransferCurrencyForm CashDisbursementCurrencyForm, TransferCurrencyForm
from .journal_entry import JournalEntryForm, DebitEntryForm, CreditEntryForm from .journal_entry import JournalEntryForm, DebitEntryForm, CreditEntryForm
from .reorder import sort_transactions_in from .reorder import sort_vouchers_in
DATE_REQUIRED: DataRequired = DataRequired( DATE_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please fill in the date.")) lazy_gettext("Please fill in the date."))
@ -54,7 +54,7 @@ class NotBeforeOriginalEntries:
entries.""" entries."""
def __call__(self, form: FlaskForm, field: DateField) -> None: def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, TransactionForm) assert isinstance(form, VoucherForm)
if field.data is None: if field.data is None:
return return
min_date: dt.date | None = form.min_date min_date: dt.date | None = form.min_date
@ -69,7 +69,7 @@ class NotAfterOffsetEntries:
"""The validator to check if the date is not after the offset entries.""" """The validator to check if the date is not after the offset entries."""
def __call__(self, form: FlaskForm, field: DateField) -> None: def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, TransactionForm) assert isinstance(form, VoucherForm)
if field.data is None: if field.data is None:
return return
max_date: dt.date | None = form.max_date max_date: dt.date | None = form.max_date
@ -92,7 +92,7 @@ class CannotDeleteOriginalEntriesWithOffset:
"""The validator to check the original entries with offset.""" """The validator to check the original entries with offset."""
def __call__(self, form: FlaskForm, field: FieldList) -> None: def __call__(self, form: FlaskForm, field: FieldList) -> None:
assert isinstance(form, TransactionForm) assert isinstance(form, VoucherForm)
if form.obj is None: if form.obj is None:
return return
existing_matched_original_entry_id: set[int] \ existing_matched_original_entry_id: set[int] \
@ -105,8 +105,8 @@ class CannotDeleteOriginalEntriesWithOffset:
"Journal entries with offset cannot be deleted.")) "Journal entries with offset cannot be deleted."))
class TransactionForm(FlaskForm): class VoucherForm(FlaskForm):
"""The base form to create or edit a transaction.""" """The base form to create or edit a voucher."""
date = DateField() date = DateField()
"""The date.""" """The date."""
currencies = FieldList(FormField(CurrencyForm)) currencies = FieldList(FormField(CurrencyForm))
@ -115,20 +115,20 @@ class TransactionForm(FlaskForm):
"""The note.""" """The note."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Constructs a base transaction form. """Constructs a base voucher form.
:param args: The arguments. :param args: The arguments.
:param kwargs: The keyword arguments. :param kwargs: The keyword arguments.
""" """
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.is_modified: bool = False self.is_modified: bool = False
"""Whether the transaction is modified during populate_obj().""" """Whether the voucher is modified during populate_obj()."""
self.collector: t.Type[JournalEntryCollector] = JournalEntryCollector self.collector: t.Type[JournalEntryCollector] = JournalEntryCollector
"""The journal entry collector. The default is the base abstract """The journal entry collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should collector only to provide the correct type. The subclass forms should
provide their own collectors.""" provide their own collectors."""
self.obj: Transaction | None = kwargs.get("obj") self.obj: Voucher | None = kwargs.get("obj")
"""The transaction, when editing an existing one.""" """The voucher, when editing an existing one."""
self._is_need_payable: bool = False self._is_need_payable: bool = False
"""Whether we need the payable original entries.""" """Whether we need the payable original entries."""
self._is_need_receivable: bool = False self._is_need_receivable: bool = False
@ -139,17 +139,17 @@ class TransactionForm(FlaskForm):
"""The original entries whose net balances were exceeded by the """The original entries whose net balances were exceeded by the
amounts in the journal entry sub-forms.""" amounts in the journal entry sub-forms."""
for entry in self.entries: for entry in self.entries:
entry.txn_form = self entry.voucher_form = self
def populate_obj(self, obj: Transaction) -> None: def populate_obj(self, obj: Voucher) -> None:
"""Populates the form data into a transaction object. """Populates the form data into a voucher object.
:param obj: The transaction object. :param obj: The voucher object.
:return: None. :return: None.
""" """
is_new: bool = obj.id is None is_new: bool = obj.id is None
if is_new: if is_new:
obj.id = new_id(Transaction) obj.id = new_id(Voucher)
self.date: DateField self.date: DateField
self.__set_date(obj, self.date.data) self.__set_date(obj, self.date.data)
obj.note = self.note.data obj.note = self.note.data
@ -183,31 +183,31 @@ class TransactionForm(FlaskForm):
entries.extend(currency.entries) entries.extend(currency.entries)
return entries return entries
def __set_date(self, obj: Transaction, new_date: dt.date) -> None: def __set_date(self, obj: Voucher, new_date: dt.date) -> None:
"""Sets the transaction date and number. """Sets the voucher date and number.
:param obj: The transaction object. :param obj: The voucher object.
:param new_date: The new date. :param new_date: The new date.
:return: None. :return: None.
""" """
if obj.date is None or obj.date != new_date: if obj.date is None or obj.date != new_date:
if obj.date is not None: if obj.date is not None:
sort_transactions_in(obj.date, obj.id) sort_vouchers_in(obj.date, obj.id)
if self.max_date is not None and new_date == self.max_date: if self.max_date is not None and new_date == self.max_date:
db_min_no: int | None = db.session.scalar( db_min_no: int | None = db.session.scalar(
sa.select(sa.func.min(Transaction.no)) sa.select(sa.func.min(Voucher.no))
.filter(Transaction.date == new_date)) .filter(Voucher.date == new_date))
if db_min_no is None: if db_min_no is None:
obj.date = new_date obj.date = new_date
obj.no = 1 obj.no = 1
else: else:
obj.date = new_date obj.date = new_date
obj.no = db_min_no - 1 obj.no = db_min_no - 1
sort_transactions_in(new_date) sort_vouchers_in(new_date)
else: else:
sort_transactions_in(new_date, obj.id) sort_vouchers_in(new_date, obj.id)
count: int = Transaction.query\ count: int = Voucher.query\
.filter(Transaction.date == new_date).count() .filter(Voucher.date == new_date).count()
obj.date = new_date obj.date = new_date
obj.no = count + 1 obj.no = count + 1
@ -285,7 +285,7 @@ class TransactionForm(FlaskForm):
if x.original_entry_id.data is not None} if x.original_entry_id.data is not None}
if len(original_entry_id) == 0: if len(original_entry_id) == 0:
return None return None
select: sa.Select = sa.select(sa.func.max(Transaction.date))\ select: sa.Select = sa.select(sa.func.max(Voucher.date))\
.join(JournalEntry).filter(JournalEntry.id.in_(original_entry_id)) .join(JournalEntry).filter(JournalEntry.id.in_(original_entry_id))
return db.session.scalar(select) return db.session.scalar(select)
@ -297,29 +297,29 @@ class TransactionForm(FlaskForm):
""" """
entry_id: set[int] = {x.eid.data for x in self.entries entry_id: set[int] = {x.eid.data for x in self.entries
if x.eid.data is not None} if x.eid.data is not None}
select: sa.Select = sa.select(sa.func.min(Transaction.date))\ select: sa.Select = sa.select(sa.func.min(Voucher.date))\
.join(JournalEntry)\ .join(JournalEntry)\
.filter(JournalEntry.original_entry_id.in_(entry_id)) .filter(JournalEntry.original_entry_id.in_(entry_id))
return db.session.scalar(select) return db.session.scalar(select)
T = t.TypeVar("T", bound=TransactionForm) T = t.TypeVar("T", bound=VoucherForm)
"""A transaction form variant.""" """A voucher form variant."""
class JournalEntryCollector(t.Generic[T], ABC): class JournalEntryCollector(t.Generic[T], ABC):
"""The journal entry collector.""" """The journal entry collector."""
def __init__(self, form: T, obj: Transaction): def __init__(self, form: T, obj: Voucher):
"""Constructs the journal entry collector. """Constructs the journal entry collector.
:param form: The transaction form. :param form: The voucher form.
:param obj: The transaction. :param obj: The voucher.
""" """
self.form: T = form self.form: T = form
"""The transaction form.""" """The voucher form."""
self.__obj: Transaction = obj self.__obj: Voucher = obj
"""The transaction object.""" """The voucher object."""
self.__entries: list[JournalEntry] = list(obj.entries) self.__entries: list[JournalEntry] = list(obj.entries)
"""The existing journal entries.""" """The existing journal entries."""
self.__entries_by_id: dict[int, JournalEntry] \ self.__entries_by_id: dict[int, JournalEntry] \
@ -327,8 +327,8 @@ class JournalEntryCollector(t.Generic[T], ABC):
"""A dictionary from the entry ID to their entries.""" """A dictionary from the entry ID to their entries."""
self.__no_by_id: dict[int, int] = {x.id: x.no for x in self.__entries} self.__no_by_id: dict[int, int] = {x.id: x.no for x in self.__entries}
"""A dictionary from the entry number to their entries.""" """A dictionary from the entry number to their entries."""
self.__currencies: list[TransactionCurrency] = obj.currencies self.__currencies: list[VoucherCurrency] = obj.currencies
"""The currencies in the transaction.""" """The currencies in the voucher."""
self._debit_no: int = 1 self._debit_no: int = 1
"""The number index for the debit entries.""" """The number index for the debit entries."""
self._credit_no: int = 1 self._credit_no: int = 1
@ -371,11 +371,11 @@ class JournalEntryCollector(t.Generic[T], ABC):
def _make_cash_entry(self, forms: list[JournalEntryForm], is_debit: bool, def _make_cash_entry(self, forms: list[JournalEntryForm], is_debit: bool,
currency_code: str, no: int) -> None: currency_code: str, no: int) -> None:
"""Composes the cash journal entry at the other side of the cash """Composes the cash journal entry at the other side of the cash
transaction. voucher.
:param forms: The journal entry forms in the same currency. :param forms: The journal entry forms in the same currency.
:param is_debit: True for a cash income transaction, or False for a :param is_debit: True for a cash receipt voucher, or False for a
cash expense transaction. cash disbursement voucher.
:param currency_code: The code of the currency. :param currency_code: The code of the currency.
:param no: The number of the entry. :param no: The number of the entry.
:return: None. :return: None.
@ -441,14 +441,14 @@ class JournalEntryCollector(t.Generic[T], ABC):
ord_by_form.get(x))) ord_by_form.get(x)))
class IncomeTransactionForm(TransactionForm): class CashReceiptVoucherForm(VoucherForm):
"""The form to create or edit a cash income transaction.""" """The form to create or edit a cash receipt voucher."""
date = DateField( date = DateField(
validators=[DATE_REQUIRED, validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(), NotBeforeOriginalEntries(),
NotAfterOffsetEntries()]) NotAfterOffsetEntries()])
"""The date.""" """The date."""
currencies = FieldList(FormField(IncomeCurrencyForm), name="currency", currencies = FieldList(FormField(CashReceiptCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()]) validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies.""" """The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text]) note = TextAreaField(filters=[strip_multiline_text])
@ -461,11 +461,11 @@ class IncomeTransactionForm(TransactionForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._is_need_receivable = True self._is_need_receivable = True
class Collector(JournalEntryCollector[IncomeTransactionForm]): class Collector(JournalEntryCollector[CashReceiptVoucherForm]):
"""The journal entry collector for the cash income transactions.""" """The journal entry collector for the cash receipt vouchers."""
def collect(self) -> None: def collect(self) -> None:
currencies: list[IncomeCurrencyForm] \ currencies: list[CashReceiptCurrencyForm] \
= [x.form for x in self.form.currencies] = [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies) self._sort_currency_forms(currencies)
for currency in currencies: for currency in currencies:
@ -486,14 +486,15 @@ class IncomeTransactionForm(TransactionForm):
self.collector = Collector self.collector = Collector
class ExpenseTransactionForm(TransactionForm): class CashDisbursementVoucherForm(VoucherForm):
"""The form to create or edit a cash expense transaction.""" """The form to create or edit a cash disbursement voucher."""
date = DateField( date = DateField(
validators=[DATE_REQUIRED, validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(), NotBeforeOriginalEntries(),
NotAfterOffsetEntries()]) NotAfterOffsetEntries()])
"""The date.""" """The date."""
currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency", currencies = FieldList(FormField(CashDisbursementCurrencyForm),
name="currency",
validators=[NeedSomeCurrencies()]) validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies.""" """The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text]) note = TextAreaField(filters=[strip_multiline_text])
@ -506,12 +507,12 @@ class ExpenseTransactionForm(TransactionForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._is_need_payable = True self._is_need_payable = True
class Collector(JournalEntryCollector[ExpenseTransactionForm]): class Collector(JournalEntryCollector[CashDisbursementVoucherForm]):
"""The journal entry collector for the cash expense """The journal entry collector for the cash disbursement
transactions.""" vouchers."""
def collect(self) -> None: def collect(self) -> None:
currencies: list[ExpenseCurrencyForm] \ currencies: list[CashDisbursementCurrencyForm] \
= [x.form for x in self.form.currencies] = [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies) self._sort_currency_forms(currencies)
for currency in currencies: for currency in currencies:
@ -532,8 +533,8 @@ class ExpenseTransactionForm(TransactionForm):
self.collector = Collector self.collector = Collector
class TransferTransactionForm(TransactionForm): class TransferVoucherForm(VoucherForm):
"""The form to create or edit a transfer transaction.""" """The form to create or edit a transfer voucher."""
date = DateField( date = DateField(
validators=[DATE_REQUIRED, validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(), NotBeforeOriginalEntries(),
@ -553,8 +554,8 @@ class TransferTransactionForm(TransactionForm):
self._is_need_payable = True self._is_need_payable = True
self._is_need_receivable = True self._is_need_receivable = True
class Collector(JournalEntryCollector[TransferTransactionForm]): class Collector(JournalEntryCollector[TransferVoucherForm]):
"""The journal entry collector for the transfer transactions.""" """The journal entry collector for the transfer vouchers."""
def collect(self) -> None: def collect(self) -> None:
currencies: list[TransferCurrencyForm] \ currencies: list[TransferCurrencyForm] \

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The template filters for the transaction management. """The template filters for the voucher management.
""" """
from decimal import Decimal from decimal import Decimal
@ -26,10 +26,10 @@ from flask import request
def with_type(uri: str) -> str: def with_type(uri: str) -> str:
"""Adds the transaction type to the URI, if it is specified. """Adds the voucher type to the URI, if it is specified.
:param uri: The URI. :param uri: The URI.
:return: The result URL, optionally with the transaction type added. :return: The result URL, optionally with the voucher type added.
""" """
if "as" not in request.args: if "as" not in request.args:
return uri return uri
@ -43,10 +43,10 @@ def with_type(uri: str) -> str:
def to_transfer(uri: str) -> str: def to_transfer(uri: str) -> str:
"""Adds the transfer transaction type to the URI. """Adds the transfer voucher type to the URI.
:param uri: The URI. :param uri: The URI.
:return: The result URL, with the transfer transaction type added. :return: The result URL, with the transfer voucher type added.
""" """
uri_p: ParseResult = urlparse(uri) uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query) params: list[tuple[str, str]] = parse_qsl(uri_p.query)

View File

@ -14,6 +14,6 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The utilities for the transaction management. """The utilities for the voucher management.
""" """

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The account option for the transaction management. """The account option for the voucher management.
""" """
from accounting.models import Account from accounting.models import Account

View File

@ -0,0 +1,326 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# 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 operators for different voucher types.
"""
import typing as t
from abc import ABC, abstractmethod
from flask import render_template, request, abort
from flask_wtf import FlaskForm
from accounting.models import Voucher
from accounting.template_globals import default_currency_code
from accounting.utils.voucher_types import VoucherType
from accounting.voucher.forms import VoucherForm, CashReceiptVoucherForm, \
CashDisbursementVoucherForm, TransferVoucherForm
class VoucherOperator(ABC):
"""The base voucher operator."""
CHECK_ORDER: int = -1
"""The order when checking the voucher operator."""
@property
@abstractmethod
def form(self) -> t.Type[VoucherForm]:
"""Returns the form class.
:return: The form class.
"""
@abstractmethod
def render_create_template(self, form: FlaskForm) -> str:
"""Renders the template for the form to create a voucher.
:param form: The voucher form.
:return: the form to create a voucher.
"""
@abstractmethod
def render_detail_template(self, voucher: Voucher) -> str:
"""Renders the template for the detail page.
:param voucher: The voucher.
:return: the detail page.
"""
@abstractmethod
def render_edit_template(self, voucher: Voucher, form: FlaskForm) -> str:
"""Renders the template for the form to edit a voucher.
:param voucher: The voucher.
:param form: The form.
:return: the form to edit a voucher.
"""
@abstractmethod
def is_my_type(self, voucher: Voucher) -> bool:
"""Checks and returns whether the voucher belongs to the type.
:param voucher: The voucher.
:return: True if the voucher belongs to the type, or False
otherwise.
"""
@property
def _entry_template(self) -> str:
"""Renders and returns the template for the journal entry sub-form.
:return: The template for the journal entry sub-form.
"""
return render_template(
"accounting/voucher/include/form-entry-item.html",
currency_index="CURRENCY_INDEX",
entry_type="ENTRY_TYPE",
entry_index="ENTRY_INDEX")
class CashReceiptVoucher(VoucherOperator):
"""A cash receipt voucher."""
CHECK_ORDER: int = 2
"""The order when checking the voucher operator."""
@property
def form(self) -> t.Type[VoucherForm]:
"""Returns the form class.
:return: The form class.
"""
return CashReceiptVoucherForm
def render_create_template(self, form: CashReceiptVoucherForm) -> str:
"""Renders the template for the form to create a voucher.
:param form: The voucher form.
:return: the form to create a voucher.
"""
return render_template("accounting/voucher/receipt/create.html",
form=form,
voucher_type=VoucherType.CASH_RECEIPT,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def render_detail_template(self, voucher: Voucher) -> str:
"""Renders the template for the detail page.
:param voucher: The voucher.
:return: the detail page.
"""
return render_template("accounting/voucher/receipt/detail.html",
obj=voucher)
def render_edit_template(self, voucher: Voucher,
form: CashReceiptVoucherForm) -> str:
"""Renders the template for the form to edit a voucher.
:param voucher: The voucher.
:param form: The form.
:return: the form to edit a voucher.
"""
return render_template("accounting/voucher/receipt/edit.html",
voucher=voucher, form=form,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def is_my_type(self, voucher: Voucher) -> bool:
"""Checks and returns whether the voucher belongs to the type.
:param voucher: The voucher.
:return: True if the voucher belongs to the type, or False
otherwise.
"""
return voucher.is_cash_receipt
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/voucher/receipt/include/form-currency-item.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
credit_total="-")
class CashDisbursementVoucher(VoucherOperator):
"""A cash disbursement voucher."""
CHECK_ORDER: int = 1
"""The order when checking the voucher operator."""
@property
def form(self) -> t.Type[VoucherForm]:
"""Returns the form class.
:return: The form class.
"""
return CashDisbursementVoucherForm
def render_create_template(self, form: CashDisbursementVoucherForm) -> str:
"""Renders the template for the form to create a voucher.
:param form: The voucher form.
:return: the form to create a voucher.
"""
return render_template("accounting/voucher/disbursement/create.html",
form=form,
voucher_type=VoucherType.CASH_DISBURSEMENT,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def render_detail_template(self, voucher: Voucher) -> str:
"""Renders the template for the detail page.
:param voucher: The voucher.
:return: the detail page.
"""
return render_template("accounting/voucher/disbursement/detail.html",
obj=voucher)
def render_edit_template(self, voucher: Voucher,
form: CashDisbursementVoucherForm) -> str:
"""Renders the template for the form to edit a voucher.
:param voucher: The voucher.
:param form: The form.
:return: the form to edit a voucher.
"""
return render_template("accounting/voucher/disbursement/edit.html",
voucher=voucher, form=form,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def is_my_type(self, voucher: Voucher) -> bool:
"""Checks and returns whether the voucher belongs to the type.
:param voucher: The voucher.
:return: True if the voucher belongs to the type, or False
otherwise.
"""
return voucher.is_cash_disbursement
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/voucher/disbursement/include/form-currency-item.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
debit_total="-")
class TransferVoucher(VoucherOperator):
"""A transfer voucher."""
CHECK_ORDER: int = 3
"""The order when checking the voucher operator."""
@property
def form(self) -> t.Type[VoucherForm]:
"""Returns the form class.
:return: The form class.
"""
return TransferVoucherForm
def render_create_template(self, form: TransferVoucherForm) -> str:
"""Renders the template for the form to create a voucher.
:param form: The voucher form.
:return: the form to create a voucher.
"""
return render_template("accounting/voucher/transfer/create.html",
form=form,
voucher_type=VoucherType.TRANSFER,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def render_detail_template(self, voucher: Voucher) -> str:
"""Renders the template for the detail page.
:param voucher: The voucher.
:return: the detail page.
"""
return render_template("accounting/voucher/transfer/detail.html",
obj=voucher)
def render_edit_template(self, voucher: Voucher,
form: TransferVoucherForm) -> str:
"""Renders the template for the form to edit a voucher.
:param voucher: The voucher.
:param form: The form.
:return: the form to edit a voucher.
"""
return render_template("accounting/voucher/transfer/edit.html",
voucher=voucher, form=form,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def is_my_type(self, voucher: Voucher) -> bool:
"""Checks and returns whether the voucher belongs to the type.
:param voucher: The voucher.
:return: True if the voucher belongs to the type, or False
otherwise.
"""
return True
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/voucher/transfer/include/form-currency-item.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
debit_total="-", credit_total="-")
VOUCHER_TYPE_TO_OP: dict[VoucherType, VoucherOperator] \
= {VoucherType.CASH_RECEIPT: CashReceiptVoucher(),
VoucherType.CASH_DISBURSEMENT: CashDisbursementVoucher(),
VoucherType.TRANSFER: TransferVoucher()}
"""The map from the voucher types to their operators."""
def get_voucher_op(voucher: Voucher, is_check_as: bool = False) \
-> VoucherOperator:
"""Returns the voucher operator that may be specified in the "as" query
parameter. If it is not specified, check the voucher type from the
voucher.
:param voucher: The voucher.
:param is_check_as: True to check the "as" parameter, or False otherwise.
:return: None.
"""
if is_check_as and "as" in request.args:
type_dict: dict[str, VoucherType] \
= {x.value: x for x in VoucherType}
if request.args["as"] not in type_dict:
abort(404)
return VOUCHER_TYPE_TO_OP[type_dict[request.args["as"]]]
for voucher_type in sorted(VOUCHER_TYPE_TO_OP.values(),
key=lambda x: x.CHECK_ORDER):
if voucher_type.is_my_type(voucher):
return voucher_type

View File

@ -25,8 +25,8 @@ from sqlalchemy.orm import selectinload
from accounting import db from accounting import db
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Account, Transaction, JournalEntry from accounting.models import Account, Voucher, JournalEntry
from accounting.transaction.forms.journal_entry import JournalEntryForm from accounting.voucher.forms.journal_entry import JournalEntryForm
from accounting.utils.cast import be from accounting.utils.cast import be
from .offset_alias import offset_alias from .offset_alias import offset_alias
@ -71,11 +71,11 @@ def get_selectable_original_entries(
for x in db.session.execute(select_net_balances).all()} for x in db.session.execute(select_net_balances).all()}
entries: list[JournalEntry] = JournalEntry.query\ entries: list[JournalEntry] = JournalEntry.query\
.filter(JournalEntry.id.in_({x for x in net_balances}))\ .filter(JournalEntry.id.in_({x for x in net_balances}))\
.join(Transaction)\ .join(Voucher)\
.order_by(Transaction.date, JournalEntry.is_debit, JournalEntry.no)\ .order_by(Voucher.date, JournalEntry.is_debit, JournalEntry.no)\
.options(selectinload(JournalEntry.currency), .options(selectinload(JournalEntry.currency),
selectinload(JournalEntry.account), selectinload(JournalEntry.account),
selectinload(JournalEntry.transaction)).all() selectinload(JournalEntry.voucher)).all()
for entry in entries: for entry in entries:
entry.net_balance = entry.amount if net_balances[entry.id] is None \ entry.net_balance = entry.amount if net_balances[entry.id] is None \
else net_balances[entry.id] else net_balances[entry.id]

View File

@ -0,0 +1,221 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# 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 views for the voucher management.
"""
from datetime import date
from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, request, \
flash, url_for
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Voucher
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.voucher_types import VoucherType
from accounting.utils.user import get_current_user_pk
from .forms import sort_vouchers_in, VoucherReorderForm
from .template_filters import with_type, to_transfer, format_amount_input, \
text2html
from .utils.operators import VoucherOperator, VOUCHER_TYPE_TO_OP, \
get_voucher_op
bp: Blueprint = Blueprint("voucher", __name__)
"""The view blueprint for the voucher management."""
bp.add_app_template_filter(with_type, "accounting_voucher_with_type")
bp.add_app_template_filter(to_transfer, "accounting_voucher_to_transfer")
bp.add_app_template_filter(format_amount_input,
"accounting_voucher_format_amount_input")
bp.add_app_template_filter(text2html, "accounting_voucher_text2html")
@bp.get("/create/<voucherType:voucher_type>", endpoint="create")
@has_permission(can_edit)
def show_add_voucher_form(voucher_type: VoucherType) -> str:
"""Shows the form to add a voucher.
:param voucher_type: The voucher type.
:return: The form to add a voucher.
"""
voucher_op: VoucherOperator = VOUCHER_TYPE_TO_OP[voucher_type]
form: voucher_op.form
if "form" in session:
form = voucher_op.form(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = voucher_op.form()
form.date.data = date.today()
return voucher_op.render_create_template(form)
@bp.post("/store/<voucherType:voucher_type>", endpoint="store")
@has_permission(can_edit)
def add_voucher(voucher_type: VoucherType) -> redirect:
"""Adds a voucher.
:param voucher_type: The voucher type.
:return: The redirection to the voucher detail on success, or the
voucher creation form on error.
"""
voucher_op: VoucherOperator = VOUCHER_TYPE_TO_OP[voucher_type]
form: voucher_op.form = voucher_op.form(request.form)
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(with_type(
url_for("accounting.voucher.create", voucher_type=voucher_type))))
voucher: Voucher = Voucher()
form.populate_obj(voucher)
db.session.add(voucher)
db.session.commit()
flash(s(lazy_gettext("The voucher is added successfully")), "success")
return redirect(inherit_next(__get_detail_uri(voucher)))
@bp.get("/<voucher:voucher>", endpoint="detail")
@has_permission(can_view)
def show_voucher_detail(voucher: Voucher) -> str:
"""Shows the voucher detail.
:param voucher: The voucher.
:return: The detail.
"""
voucher_op: VoucherOperator = get_voucher_op(voucher)
return voucher_op.render_detail_template(voucher)
@bp.get("/<voucher:voucher>/edit", endpoint="edit")
@has_permission(can_edit)
def show_voucher_edit_form(voucher: Voucher) -> str:
"""Shows the form to edit a voucher.
:param voucher: The voucher.
:return: The form to edit the voucher.
"""
voucher_op: VoucherOperator = get_voucher_op(voucher, is_check_as=True)
form: voucher_op.form
if "form" in session:
form = voucher_op.form(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.obj = voucher
form.validate()
else:
form = voucher_op.form(obj=voucher)
return voucher_op.render_edit_template(voucher, form)
@bp.post("/<voucher:voucher>/update", endpoint="update")
@has_permission(can_edit)
def update_voucher(voucher: Voucher) -> redirect:
"""Updates a voucher.
:param voucher: The voucher.
:return: The redirection to the voucher detail on success, or the voucher
edit form on error.
"""
voucher_op: VoucherOperator = get_voucher_op(voucher, is_check_as=True)
form: voucher_op.form = voucher_op.form(request.form)
form.obj = voucher
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(with_type(
url_for("accounting.voucher.edit", voucher=voucher))))
with db.session.no_autoflush:
form.populate_obj(voucher)
if not form.is_modified:
flash(s(lazy_gettext("The voucher was not modified.")), "success")
return redirect(inherit_next(__get_detail_uri(voucher)))
voucher.updated_by_id = get_current_user_pk()
voucher.updated_at = sa.func.now()
db.session.commit()
flash(s(lazy_gettext("The voucher is updated successfully.")), "success")
return redirect(inherit_next(__get_detail_uri(voucher)))
@bp.post("/<voucher:voucher>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_voucher(voucher: Voucher) -> redirect:
"""Deletes a voucher.
:param voucher: The voucher.
:return: The redirection to the voucher list on success, or the voucher
detail on error.
"""
voucher.delete()
sort_vouchers_in(voucher.date, voucher.id)
db.session.commit()
flash(s(lazy_gettext("The voucher is deleted successfully.")), "success")
return redirect(or_next(__get_default_page_uri()))
@bp.get("/dates/<date:voucher_date>", endpoint="order")
@has_permission(can_view)
def show_voucher_order(voucher_date: date) -> str:
"""Shows the order of the vouchers in a same date.
:param voucher_date: The date.
:return: The order of the vouchers in the date.
"""
vouchers: list[Voucher] = Voucher.query \
.filter(Voucher.date == voucher_date) \
.order_by(Voucher.no).all()
return render_template("accounting/voucher/order.html",
date=voucher_date, list=vouchers)
@bp.post("/dates/<date:voucher_date>", endpoint="sort")
@has_permission(can_edit)
def sort_vouchers(voucher_date: date) -> redirect:
"""Reorders the vouchers in a date.
:param voucher_date: The date.
:return: The redirection to the incoming account or the account list. The
reordering operation does not fail.
"""
form: VoucherReorderForm = VoucherReorderForm(voucher_date)
form.save_order()
if not form.is_modified:
flash(s(lazy_gettext("The order was not modified.")), "success")
return redirect(or_next(__get_default_page_uri()))
db.session.commit()
flash(s(lazy_gettext("The order is updated successfully.")), "success")
return redirect(or_next(__get_default_page_uri()))
def __get_detail_uri(voucher: Voucher) -> str:
"""Returns the detail URI of a voucher.
:param voucher: The voucher.
:return: The detail URI of the voucher.
"""
return url_for("accounting.voucher.detail", voucher=voucher)
def __get_default_page_uri() -> str:
"""Returns the URI for the default page.
:return: The URI for the default page.
"""
return url_for("accounting.report.default")

View File

@ -27,12 +27,12 @@ from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import create_test_app, get_client from testlib import create_test_app, get_client
from testlib_offset import TestData, JournalEntryData, TransactionData, \ from testlib_offset import TestData, JournalEntryData, VoucherData, \
CurrencyData CurrencyData
from testlib_txn import Accounts, match_txn_detail from testlib_voucher import Accounts, match_voucher_detail
PREFIX: str = "/accounting/transactions" PREFIX: str = "/accounting/vouchers"
"""The URL prefix for the transaction management.""" """The URL prefix for the voucher management."""
class OffsetTestCase(unittest.TestCase): class OffsetTestCase(unittest.TestCase):
@ -48,7 +48,7 @@ class OffsetTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import BaseAccount, Transaction, \ from accounting.models import BaseAccount, Voucher, \
JournalEntry JournalEntry
result: Result result: Result
result = runner.invoke(args="init-db") result = runner.invoke(args="init-db")
@ -62,7 +62,7 @@ class OffsetTestCase(unittest.TestCase):
result = runner.invoke(args=["accounting-init-accounts", result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"]) "-u", "editor"])
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
Transaction.query.delete() Voucher.query.delete()
JournalEntry.query.delete() JournalEntry.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor") self.client, self.csrf_token = get_client(self.app, "editor")
@ -73,15 +73,15 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account, Transaction from accounting.models import Account, Voucher
create_uri: str = f"{PREFIX}/create/income?next=%2F_next" create_uri: str = f"{PREFIX}/create/receipt?next=%2F_next"
store_uri: str = f"{PREFIX}/store/income" store_uri: str = f"{PREFIX}/store/receipt"
form: dict[str, str] form: dict[str, str]
old_amount: Decimal old_amount: Decimal
response: httpx.Response response: httpx.Response
txn_data: TransactionData = TransactionData( voucher_data: VoucherData = VoucherData(
self.data.e_r_or3d.txn.days, [CurrencyData( self.data.e_r_or3d.voucher.days, [CurrencyData(
"USD", "USD",
[], [],
[JournalEntryData(Accounts.RECEIVABLE, [JournalEntryData(Accounts.RECEIVABLE,
@ -95,14 +95,14 @@ class OffsetTestCase(unittest.TestCase):
original_entry=self.data.e_r_or3d)])]) original_entry=self.data.e_r_or3d)])])
# Non-existing original entry ID # Non-existing original entry ID
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = "9999" form["currency-1-credit-1-original_entry_id"] = "9999"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The same side # The same side
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_or1c.id form["currency-1-credit-1-original_entry_id"] = self.data.e_p_or1c.id
form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account
form["currency-1-credit-1-amount"] = "100" form["currency-1-credit-1-amount"] = "100"
@ -115,8 +115,8 @@ class OffsetTestCase(unittest.TestCase):
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = False account.is_need_offset = False
db.session.commit() db.session.commit()
response = self.client.post(store_uri, response = self.client.post(
data=txn_data.new_form(self.csrf_token)) store_uri, data=voucher_data.new_form(self.csrf_token))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context(): with self.app.app_context():
@ -125,7 +125,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
# The original entry is also an offset # The original entry is also an offset
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_of1d.id form["currency-1-credit-1-original_entry_id"] = self.data.e_p_of1d.id
form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
@ -133,52 +133,54 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency # Not the same currency
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same account # Not the same account
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset # Not exceeding net balance - partially offset
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-credit-1-amount"] \ form["currency-1-credit-1-amount"] \
= str(txn_data.currencies[0].credit[0].amount + Decimal("0.01")) = str(voucher_data.currencies[0].credit[0].amount
+ Decimal("0.01"))
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched # Not exceeding net balance - unmatched
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-credit-3-amount"] \ form["currency-1-credit-3-amount"] \
= str(txn_data.currencies[0].credit[2].amount + Decimal("0.01")) = str(voucher_data.currencies[0].credit[2].amount
+ Decimal("0.01"))
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not before the original entries # Not before the original entries
old_days = txn_data.days old_days = voucher_data.days
txn_data.days = old_days + 1 voucher_data.days = old_days + 1
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
txn_data.days = old_days voucher_data.days = old_days
# Success # Success
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
txn_id: int = match_txn_detail(response.headers["Location"]) voucher_id: int = match_voucher_detail(response.headers["Location"])
with self.app.app_context(): with self.app.app_context():
txn = db.session.get(Transaction, txn_id) voucher = db.session.get(Voucher, voucher_id)
for offset in txn.currencies[0].credit: for offset in voucher.currencies[0].credit:
self.assertIsNotNone(offset.original_entry_id) self.assertIsNotNone(offset.original_entry_id)
def test_edit_receivable_offset(self) -> None: def test_edit_receivable_offset(self) -> None:
@ -187,27 +189,27 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account from accounting.models import Account
txn_data: TransactionData = self.data.t_r_of2 voucher_data: VoucherData = self.data.v_r_of2
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next" edit_uri: str = f"{PREFIX}/{voucher_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update" update_uri: str = f"{PREFIX}/{voucher_data.id}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
txn_data.days = self.data.t_r_or2.days voucher_data.days = self.data.v_r_or2.days
txn_data.currencies[0].debit[0].amount = Decimal("600") voucher_data.currencies[0].debit[0].amount = Decimal("600")
txn_data.currencies[0].credit[0].amount = Decimal("600") voucher_data.currencies[0].credit[0].amount = Decimal("600")
txn_data.currencies[0].debit[2].amount = Decimal("600") voucher_data.currencies[0].debit[2].amount = Decimal("600")
txn_data.currencies[0].credit[2].amount = Decimal("600") voucher_data.currencies[0].credit[2].amount = Decimal("600")
# Non-existing original entry ID # Non-existing original entry ID
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = "9999" form["currency-1-credit-1-original_entry_id"] = "9999"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The same side # The same side
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_or1c.id form["currency-1-credit-1-original_entry_id"] = self.data.e_p_or1c.id
form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account
form["currency-1-debit-1-amount"] = "100" form["currency-1-debit-1-amount"] = "100"
@ -221,8 +223,8 @@ class OffsetTestCase(unittest.TestCase):
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = False account.is_need_offset = False
db.session.commit() db.session.commit()
response = self.client.post(update_uri, response = self.client.post(
data=txn_data.update_form(self.csrf_token)) update_uri, data=voucher_data.update_form(self.csrf_token))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context(): with self.app.app_context():
@ -231,7 +233,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
# The original entry is also an offset # The original entry is also an offset
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_of1d.id form["currency-1-credit-1-original_entry_id"] = self.data.e_p_of1d.id
form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
@ -239,155 +241,159 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency # Not the same currency
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # Not the same account
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset # Not exceeding net balance - partially offset
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(txn_data.currencies[0].debit[0].amount + Decimal("0.01")) = str(voucher_data.currencies[0].debit[0].amount + Decimal("0.01"))
form["currency-1-credit-1-amount"] \ form["currency-1-credit-1-amount"] \
= str(txn_data.currencies[0].credit[0].amount + Decimal("0.01")) = str(voucher_data.currencies[0].credit[0].amount
+ Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched # Not exceeding net balance - unmatched
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-3-amount"] \ form["currency-1-debit-3-amount"] \
= str(txn_data.currencies[0].debit[2].amount + Decimal("0.01")) = str(voucher_data.currencies[0].debit[2].amount + Decimal("0.01"))
form["currency-1-credit-3-amount"] \ form["currency-1-credit-3-amount"] \
= str(txn_data.currencies[0].credit[2].amount + Decimal("0.01")) = str(voucher_data.currencies[0].credit[2].amount
+ Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not before the original entries # Not before the original entries
old_days: int = txn_data.days old_days: int = voucher_data.days
txn_data.days = old_days + 1 voucher_data.days = old_days + 1
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days voucher_data.days = old_days
# Success # Success
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{txn_data.id}?next=%2F_next") f"{PREFIX}/{voucher_data.id}?next=%2F_next")
def test_edit_receivable_original_entry(self) -> None: def test_edit_receivable_original_entry(self) -> None:
"""Tests to edit the receivable original entry. """Tests to edit the receivable original entry.
:return: None. :return: None.
""" """
from accounting.models import Transaction from accounting.models import Voucher
txn_data: TransactionData = self.data.t_r_or1 voucher_data: VoucherData = self.data.v_r_or1
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next" edit_uri: str = f"{PREFIX}/{voucher_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update" update_uri: str = f"{PREFIX}/{voucher_data.id}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
txn_data.days = self.data.t_r_of1.days voucher_data.days = self.data.v_r_of1.days
txn_data.currencies[0].debit[0].amount = Decimal("800") voucher_data.currencies[0].debit[0].amount = Decimal("800")
txn_data.currencies[0].credit[0].amount = Decimal("800") voucher_data.currencies[0].credit[0].amount = Decimal("800")
txn_data.currencies[0].debit[1].amount = Decimal("3.4") voucher_data.currencies[0].debit[1].amount = Decimal("3.4")
txn_data.currencies[0].credit[1].amount = Decimal("3.4") voucher_data.currencies[0].credit[1].amount = Decimal("3.4")
# Not the same currency # Not the same currency
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # Not the same account
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_RECEIVABLE form["currency-1-debit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset # Not less than offset total - partially offset
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(txn_data.currencies[0].debit[0].amount - Decimal("0.01")) = str(voucher_data.currencies[0].debit[0].amount - Decimal("0.01"))
form["currency-1-credit-1-amount"] \ form["currency-1-credit-1-amount"] \
= str(txn_data.currencies[0].credit[0].amount - Decimal("0.01")) = str(voucher_data.currencies[0].credit[0].amount
- Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset # Not less than offset total - fully offset
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-2-amount"] \ form["currency-1-debit-2-amount"] \
= str(txn_data.currencies[0].debit[1].amount - Decimal("0.01")) = str(voucher_data.currencies[0].debit[1].amount - Decimal("0.01"))
form["currency-1-credit-2-amount"] \ form["currency-1-credit-2-amount"] \
= str(txn_data.currencies[0].credit[1].amount - Decimal("0.01")) = str(voucher_data.currencies[0].credit[1].amount
- Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not after the offset entries # Not after the offset entries
old_days: int = txn_data.days old_days: int = voucher_data.days
txn_data.days = old_days - 1 voucher_data.days = old_days - 1
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days voucher_data.days = old_days
# Not deleting matched original entries # Not deleting matched original entries
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
del form["currency-1-debit-1-eid"] del form["currency-1-debit-1-eid"]
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Success # Success
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{txn_data.id}?next=%2F_next") f"{PREFIX}/{voucher_data.id}?next=%2F_next")
# The original entry is always before the offset entry, even when they # The original entry is always before the offset entry, even when they
# happen in the same day. # happen in the same day.
with self.app.app_context(): with self.app.app_context():
txn_or: Transaction | None = db.session.get( voucher_or: Voucher | None = db.session.get(
Transaction, txn_data.id) Voucher, voucher_data.id)
self.assertIsNotNone(txn_or) self.assertIsNotNone(voucher_or)
txn_of: Transaction | None = db.session.get( voucher_of: Voucher | None = db.session.get(
Transaction, self.data.t_r_of1.id) Voucher, self.data.v_r_of1.id)
self.assertIsNotNone(txn_of) self.assertIsNotNone(voucher_of)
self.assertEqual(txn_or.date, txn_of.date) self.assertEqual(voucher_or.date, voucher_of.date)
self.assertLess(txn_or.no, txn_of.no) self.assertLess(voucher_or.no, voucher_of.no)
def test_add_payable_offset(self) -> None: def test_add_payable_offset(self) -> None:
"""Tests to add the payable offset. """Tests to add the payable offset.
:return: None. :return: None.
""" """
from accounting.models import Account, Transaction from accounting.models import Account, Voucher
create_uri: str = f"{PREFIX}/create/expense?next=%2F_next" create_uri: str = f"{PREFIX}/create/disbursement?next=%2F_next"
store_uri: str = f"{PREFIX}/store/expense" store_uri: str = f"{PREFIX}/store/disbursement"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
txn_data: TransactionData = TransactionData( voucher_data: VoucherData = VoucherData(
self.data.e_p_or3c.txn.days, [CurrencyData( self.data.e_p_or3c.voucher.days, [CurrencyData(
"USD", "USD",
[JournalEntryData(Accounts.PAYABLE, [JournalEntryData(Accounts.PAYABLE,
self.data.e_p_or1c.summary, "500", self.data.e_p_or1c.summary, "500",
@ -401,14 +407,14 @@ class OffsetTestCase(unittest.TestCase):
[])]) [])])
# Non-existing original entry ID # Non-existing original entry ID
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = "9999" form["currency-1-debit-1-original_entry_id"] = "9999"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The same side # The same side
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_or1d.id form["currency-1-debit-1-original_entry_id"] = self.data.e_r_or1d.id
form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account
form["currency-1-debit-1-amount"] = "100" form["currency-1-debit-1-amount"] = "100"
@ -421,8 +427,8 @@ class OffsetTestCase(unittest.TestCase):
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = False account.is_need_offset = False
db.session.commit() db.session.commit()
response = self.client.post(store_uri, response = self.client.post(
data=txn_data.new_form(self.csrf_token)) store_uri, data=voucher_data.new_form(self.csrf_token))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context(): with self.app.app_context():
@ -431,7 +437,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
# The original entry is also an offset # The original entry is also an offset
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_of1c.id form["currency-1-debit-1-original_entry_id"] = self.data.e_r_of1c.id
form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
@ -439,52 +445,52 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency # Not the same currency
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same account # Not the same account
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset # Not exceeding net balance - partially offset
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(txn_data.currencies[0].debit[0].amount + Decimal("0.01")) = str(voucher_data.currencies[0].debit[0].amount + Decimal("0.01"))
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched # Not exceeding net balance - unmatched
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-debit-3-amount"] \ form["currency-1-debit-3-amount"] \
= str(txn_data.currencies[0].debit[2].amount + Decimal("0.01")) = str(voucher_data.currencies[0].debit[2].amount + Decimal("0.01"))
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not before the original entries # Not before the original entries
old_days: int = txn_data.days old_days: int = voucher_data.days
txn_data.days = old_days + 1 voucher_data.days = old_days + 1
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
txn_data.days = old_days voucher_data.days = old_days
# Success # Success
form = txn_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
txn_id: int = match_txn_detail(response.headers["Location"]) voucher_id: int = match_voucher_detail(response.headers["Location"])
with self.app.app_context(): with self.app.app_context():
txn = db.session.get(Transaction, txn_id) voucher = db.session.get(Voucher, voucher_id)
for offset in txn.currencies[0].debit: for offset in voucher.currencies[0].debit:
self.assertIsNotNone(offset.original_entry_id) self.assertIsNotNone(offset.original_entry_id)
def test_edit_payable_offset(self) -> None: def test_edit_payable_offset(self) -> None:
@ -492,28 +498,28 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account, Transaction from accounting.models import Account, Voucher
txn_data: TransactionData = self.data.t_p_of2 voucher_data: VoucherData = self.data.v_p_of2
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next" edit_uri: str = f"{PREFIX}/{voucher_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update" update_uri: str = f"{PREFIX}/{voucher_data.id}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
txn_data.days = self.data.t_p_or2.days voucher_data.days = self.data.v_p_or2.days
txn_data.currencies[0].debit[0].amount = Decimal("1100") voucher_data.currencies[0].debit[0].amount = Decimal("1100")
txn_data.currencies[0].credit[0].amount = Decimal("1100") voucher_data.currencies[0].credit[0].amount = Decimal("1100")
txn_data.currencies[0].debit[2].amount = Decimal("900") voucher_data.currencies[0].debit[2].amount = Decimal("900")
txn_data.currencies[0].credit[2].amount = Decimal("900") voucher_data.currencies[0].credit[2].amount = Decimal("900")
# Non-existing original entry ID # Non-existing original entry ID
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = "9999" form["currency-1-debit-1-original_entry_id"] = "9999"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The same side # The same side
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_or1d.id form["currency-1-debit-1-original_entry_id"] = self.data.e_r_or1d.id
form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account
form["currency-1-debit-1-amount"] = "100" form["currency-1-debit-1-amount"] = "100"
@ -527,8 +533,8 @@ class OffsetTestCase(unittest.TestCase):
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = False account.is_need_offset = False
db.session.commit() db.session.commit()
response = self.client.post(update_uri, response = self.client.post(
data=txn_data.update_form(self.csrf_token)) update_uri, data=voucher_data.update_form(self.csrf_token))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context(): with self.app.app_context():
@ -537,7 +543,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
# The original entry is also an offset # The original entry is also an offset
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_of1c.id form["currency-1-debit-1-original_entry_id"] = self.data.e_r_of1c.id
form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
@ -545,56 +551,58 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency # Not the same currency
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # Not the same account
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset # Not exceeding net balance - partially offset
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(txn_data.currencies[0].debit[0].amount + Decimal("0.01")) = str(voucher_data.currencies[0].debit[0].amount + Decimal("0.01"))
form["currency-1-credit-1-amount"] \ form["currency-1-credit-1-amount"] \
= str(txn_data.currencies[0].credit[0].amount + Decimal("0.01")) = str(voucher_data.currencies[0].credit[0].amount
+ Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched # Not exceeding net balance - unmatched
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-3-amount"] \ form["currency-1-debit-3-amount"] \
= str(txn_data.currencies[0].debit[2].amount + Decimal("0.01")) = str(voucher_data.currencies[0].debit[2].amount + Decimal("0.01"))
form["currency-1-credit-3-amount"] \ form["currency-1-credit-3-amount"] \
= str(txn_data.currencies[0].credit[2].amount + Decimal("0.01")) = str(voucher_data.currencies[0].credit[2].amount
+ Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not before the original entries # Not before the original entries
old_days: int = txn_data.days old_days: int = voucher_data.days
txn_data.days = old_days + 1 voucher_data.days = old_days + 1
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days voucher_data.days = old_days
# Success # Success
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
txn_id: int = match_txn_detail(response.headers["Location"]) voucher_id: int = match_voucher_detail(response.headers["Location"])
with self.app.app_context(): with self.app.app_context():
txn = db.session.get(Transaction, txn_id) voucher = db.session.get(Voucher, voucher_id)
for offset in txn.currencies[0].debit: for offset in voucher.currencies[0].debit:
self.assertIsNotNone(offset.original_entry_id) self.assertIsNotNone(offset.original_entry_id)
def test_edit_payable_original_entry(self) -> None: def test_edit_payable_original_entry(self) -> None:
@ -602,84 +610,86 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Transaction from accounting.models import Voucher
txn_data: TransactionData = self.data.t_p_or1 voucher_data: VoucherData = self.data.v_p_or1
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next" edit_uri: str = f"{PREFIX}/{voucher_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update" update_uri: str = f"{PREFIX}/{voucher_data.id}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
txn_data.days = self.data.t_p_of1.days voucher_data.days = self.data.v_p_of1.days
txn_data.currencies[0].debit[0].amount = Decimal("1200") voucher_data.currencies[0].debit[0].amount = Decimal("1200")
txn_data.currencies[0].credit[0].amount = Decimal("1200") voucher_data.currencies[0].credit[0].amount = Decimal("1200")
txn_data.currencies[0].debit[1].amount = Decimal("0.9") voucher_data.currencies[0].debit[1].amount = Decimal("0.9")
txn_data.currencies[0].credit[1].amount = Decimal("0.9") voucher_data.currencies[0].credit[1].amount = Decimal("0.9")
# Not the same currency # Not the same currency
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # Not the same account
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_PAYABLE form["currency-1-credit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset # Not less than offset total - partially offset
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(txn_data.currencies[0].debit[0].amount - Decimal("0.01")) = str(voucher_data.currencies[0].debit[0].amount - Decimal("0.01"))
form["currency-1-credit-1-amount"] \ form["currency-1-credit-1-amount"] \
= str(txn_data.currencies[0].credit[0].amount - Decimal("0.01")) = str(voucher_data.currencies[0].credit[0].amount
- Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset # Not less than offset total - fully offset
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-2-amount"] \ form["currency-1-debit-2-amount"] \
= str(txn_data.currencies[0].debit[1].amount - Decimal("0.01")) = str(voucher_data.currencies[0].debit[1].amount - Decimal("0.01"))
form["currency-1-credit-2-amount"] \ form["currency-1-credit-2-amount"] \
= str(txn_data.currencies[0].credit[1].amount - Decimal("0.01")) = str(voucher_data.currencies[0].credit[1].amount
- Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not after the offset entries # Not after the offset entries
old_days: int = txn_data.days old_days: int = voucher_data.days
txn_data.days = old_days - 1 voucher_data.days = old_days - 1
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days voucher_data.days = old_days
# Not deleting matched original entries # Not deleting matched original entries
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
del form["currency-1-credit-1-eid"] del form["currency-1-credit-1-eid"]
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Success # Success
form = txn_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{txn_data.id}?next=%2F_next") f"{PREFIX}/{voucher_data.id}?next=%2F_next")
# The original entry is always before the offset entry, even when they # The original entry is always before the offset entry, even when they
# happen in the same day # happen in the same day
with self.app.app_context(): with self.app.app_context():
txn_or: Transaction | None = db.session.get( voucher_or: Voucher | None = db.session.get(
Transaction, txn_data.id) Voucher, voucher_data.id)
self.assertIsNotNone(txn_or) self.assertIsNotNone(voucher_or)
txn_of: Transaction | None = db.session.get( voucher_of: Voucher | None = db.session.get(
Transaction, self.data.t_p_of1.id) Voucher, self.data.v_p_of1.id)
self.assertIsNotNone(txn_of) self.assertIsNotNone(voucher_of)
self.assertEqual(txn_or.date, txn_of.date) self.assertEqual(voucher_or.date, voucher_of.date)
self.assertLess(txn_or.no, txn_of.no) self.assertLess(voucher_or.no, voucher_of.no)

View File

@ -25,7 +25,7 @@ from flask import Flask
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from testlib import create_test_app, get_client from testlib import create_test_app, get_client
from testlib_txn import Accounts, NEXT_URI, add_txn from testlib_voucher import Accounts, NEXT_URI, add_voucher
class SummeryEditorTestCase(unittest.TestCase): class SummeryEditorTestCase(unittest.TestCase):
@ -41,7 +41,7 @@ class SummeryEditorTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import BaseAccount, Transaction, \ from accounting.models import BaseAccount, Voucher, \
JournalEntry JournalEntry
result: Result result: Result
result = runner.invoke(args="init-db") result = runner.invoke(args="init-db")
@ -55,7 +55,7 @@ class SummeryEditorTestCase(unittest.TestCase):
result = runner.invoke(args=["accounting-init-accounts", result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"]) "-u", "editor"])
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
Transaction.query.delete() Voucher.query.delete()
JournalEntry.query.delete() JournalEntry.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor") self.client, self.csrf_token = get_client(self.app, "editor")
@ -65,9 +65,9 @@ class SummeryEditorTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.transaction.utils.summary_editor import SummaryEditor from accounting.voucher.utils.summary_editor import SummaryEditor
for form in get_form_data(self.csrf_token): for form in get_form_data(self.csrf_token):
add_txn(self.client, form) add_voucher(self.client, form)
with self.app.app_context(): with self.app.app_context():
editor: SummaryEditor = SummaryEditor() editor: SummaryEditor = SummaryEditor()
@ -159,22 +159,22 @@ class SummeryEditorTestCase(unittest.TestCase):
def get_form_data(csrf_token: str) -> list[dict[str, str]]: def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"""Returns the form data for multiple transaction forms. """Returns the form data for multiple voucher forms.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:return: A list of the form data. :return: A list of the form data.
""" """
txn_date: str = date.today().isoformat() voucher_date: str = date.today().isoformat()
return [{"csrf_token": csrf_token, return [{"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": txn_date, "date": voucher_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-credit-0-account_code": Accounts.SERVICE, "currency-0-credit-0-account_code": Accounts.SERVICE,
"currency-0-credit-0-summary": " Salary ", "currency-0-credit-0-summary": " Salary ",
"currency-0-credit-0-amount": "2500"}, "currency-0-credit-0-amount": "2500"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": txn_date, "date": voucher_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL, "currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Fish ", "currency-0-debit-0-summary": " Lunch—Fish ",
@ -196,7 +196,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-2-amount": "4.25"}, "currency-0-credit-2-amount": "4.25"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": txn_date, "date": voucher_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL, "currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Salad ", "currency-0-debit-0-summary": " Lunch—Salad ",
@ -212,7 +212,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-1-amount": "8.28"}, "currency-0-credit-1-amount": "8.28"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": txn_date, "date": voucher_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL, "currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Pizza ", "currency-0-debit-0-summary": " Lunch—Pizza ",
@ -228,14 +228,14 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-1-amount": "7.47"}, "currency-0-credit-1-amount": "7.47"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": txn_date, "date": voucher_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL, "currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Airplane—Lake City↔Hill Town ", "currency-0-debit-0-summary": " Airplane—Lake City↔Hill Town ",
"currency-0-debit-0-amount": "800"}, "currency-0-debit-0-amount": "800"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": txn_date, "date": voucher_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL, "currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Bus—323—Downtown→Museum ", "currency-0-debit-0-summary": " Bus—323—Downtown→Museum ",
@ -263,7 +263,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-3-amount": "4.4"}, "currency-0-credit-3-amount": "4.4"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": txn_date, "date": voucher_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL, "currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Taxi—Museum→Office ", "currency-0-debit-0-summary": " Taxi—Museum→Office ",
@ -309,7 +309,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-6-amount": "5.5"}, "currency-0-credit-6-amount": "5.5"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": txn_date, "date": voucher_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PETTY_CASH, "currency-0-debit-0-account_code": Accounts.PETTY_CASH,
"currency-0-debit-0-summary": " Dinner—Steak ", "currency-0-debit-0-summary": " Dinner—Steak ",

File diff suppressed because it is too large Load Diff

View File

@ -26,7 +26,7 @@ import httpx
from flask import Flask from flask import Flask
from test_site import db from test_site import db
from testlib_txn import Accounts, match_txn_detail, NEXT_URI from testlib_voucher import Accounts, match_voucher_detail, NEXT_URI
class JournalEntryData: class JournalEntryData:
@ -41,7 +41,7 @@ class JournalEntryData:
:param amount: The amount. :param amount: The amount.
:param original_entry: The original entry. :param original_entry: The original entry.
""" """
self.txn: TransactionData | None = None self.voucher: VoucherData | None = None
self.id: int = -1 self.id: int = -1
self.no: int = -1 self.no: int = -1
self.original_entry: JournalEntryData | None = original_entry self.original_entry: JournalEntryData | None = original_entry
@ -73,11 +73,11 @@ class JournalEntryData:
class CurrencyData: class CurrencyData:
"""The transaction currency data.""" """The voucher currency data."""
def __init__(self, currency: str, debit: list[JournalEntryData], def __init__(self, currency: str, debit: list[JournalEntryData],
credit: list[JournalEntryData]): credit: list[JournalEntryData]):
"""Constructs the transaction currency data. """Constructs the voucher currency data.
:param currency: The currency code. :param currency: The currency code.
:param debit: The debit journal entries. :param debit: The debit journal entries.
@ -104,14 +104,14 @@ class CurrencyData:
return form return form
class TransactionData: class VoucherData:
"""The transaction data.""" """The voucher data."""
def __init__(self, days: int, currencies: list[CurrencyData]): def __init__(self, days: int, currencies: list[CurrencyData]):
"""Constructs a transaction. """Constructs a voucher.
:param days: The number of days before today. :param days: The number of days before today.
:param currencies: The transaction currency data. :param currencies: The voucher currency data.
""" """
self.id: int = -1 self.id: int = -1
self.days: int = days self.days: int = days
@ -119,38 +119,38 @@ class TransactionData:
self.note: str | None = None self.note: str | None = None
for currency in self.currencies: for currency in self.currencies:
for entry in currency.debit: for entry in currency.debit:
entry.txn = self entry.voucher = self
for entry in currency.credit: for entry in currency.credit:
entry.txn = self entry.voucher = self
def new_form(self, csrf_token: str) -> dict[str, str]: def new_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the transaction as a form. """Returns the voucher as a creation form.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:return: The transaction as a form. :return: The voucher as a creation form.
""" """
return self.__form(csrf_token, is_update=False) return self.__form(csrf_token, is_update=False)
def update_form(self, csrf_token: str) -> dict[str, str]: def update_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the transaction as a form. """Returns the voucher as a update form.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:return: The transaction as a form. :return: The voucher as a update form.
""" """
return self.__form(csrf_token, is_update=True) return self.__form(csrf_token, is_update=True)
def __form(self, csrf_token: str, is_update: bool = False) \ def __form(self, csrf_token: str, is_update: bool = False) \
-> dict[str, str]: -> dict[str, str]:
"""Returns the transaction as a form. """Returns the voucher as a form.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:param is_update: True for an update operation, or False otherwise :param is_update: True for an update operation, or False otherwise
:return: The transaction as a form. :return: The voucher as a form.
""" """
txn_date: date = date.today() - timedelta(days=self.days) voucher_date: date = date.today() - timedelta(days=self.days)
form: dict[str, str] = {"csrf_token": csrf_token, form: dict[str, str] = {"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": txn_date.isoformat()} "date": voucher_date.isoformat()}
for i in range(len(self.currencies)): for i in range(len(self.currencies)):
form.update(self.currencies[i].form(i + 1, is_update)) form.update(self.currencies[i].form(i + 1, is_update))
if self.note is not None: if self.note is not None:
@ -205,24 +205,24 @@ class TestData:
self.e_p_or4d, self.e_p_or4c = couple( self.e_p_or4d, self.e_p_or4c = couple(
"Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE) "Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
# Original transactions # Original vouchers
self.t_r_or1: TransactionData = TransactionData( self.v_r_or1: VoucherData = VoucherData(
50, [CurrencyData("USD", [self.e_r_or1d, self.e_r_or4d], 50, [CurrencyData("USD", [self.e_r_or1d, self.e_r_or4d],
[self.e_r_or1c, self.e_r_or4c])]) [self.e_r_or1c, self.e_r_or4c])])
self.t_r_or2: TransactionData = TransactionData( self.v_r_or2: VoucherData = VoucherData(
30, [CurrencyData("USD", [self.e_r_or2d, self.e_r_or3d], 30, [CurrencyData("USD", [self.e_r_or2d, self.e_r_or3d],
[self.e_r_or2c, self.e_r_or3c])]) [self.e_r_or2c, self.e_r_or3c])])
self.t_p_or1: TransactionData = TransactionData( self.v_p_or1: VoucherData = VoucherData(
40, [CurrencyData("USD", [self.e_p_or1d, self.e_p_or4d], 40, [CurrencyData("USD", [self.e_p_or1d, self.e_p_or4d],
[self.e_p_or1c, self.e_p_or4c])]) [self.e_p_or1c, self.e_p_or4c])])
self.t_p_or2: TransactionData = TransactionData( self.v_p_or2: VoucherData = VoucherData(
20, [CurrencyData("USD", [self.e_p_or2d, self.e_p_or3d], 20, [CurrencyData("USD", [self.e_p_or2d, self.e_p_or3d],
[self.e_p_or2c, self.e_p_or3c])]) [self.e_p_or2c, self.e_p_or3c])])
self.__add_txn(self.t_r_or1) self.__add_voucher(self.v_r_or1)
self.__add_txn(self.t_r_or2) self.__add_voucher(self.v_r_or2)
self.__add_txn(self.t_p_or1) self.__add_voucher(self.v_p_or1)
self.__add_txn(self.t_p_or2) self.__add_voucher(self.v_p_or2)
# Receivable offset entries # Receivable offset entries
self.e_r_of1d, self.e_r_of1c = couple( self.e_r_of1d, self.e_r_of1c = couple(
@ -258,52 +258,52 @@ class TestData:
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH) "Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of5d.original_entry = self.e_p_or4c self.e_p_of5d.original_entry = self.e_p_or4c
# Offset transactions # Offset vouchers
self.t_r_of1: TransactionData = TransactionData( self.v_r_of1: VoucherData = VoucherData(
25, [CurrencyData("USD", [self.e_r_of1d], [self.e_r_of1c])]) 25, [CurrencyData("USD", [self.e_r_of1d], [self.e_r_of1c])])
self.t_r_of2: TransactionData = TransactionData( self.v_r_of2: VoucherData = VoucherData(
20, [CurrencyData("USD", 20, [CurrencyData("USD",
[self.e_r_of2d, self.e_r_of3d, self.e_r_of4d], [self.e_r_of2d, self.e_r_of3d, self.e_r_of4d],
[self.e_r_of2c, self.e_r_of3c, self.e_r_of4c])]) [self.e_r_of2c, self.e_r_of3c, self.e_r_of4c])])
self.t_r_of3: TransactionData = TransactionData( self.v_r_of3: VoucherData = VoucherData(
15, [CurrencyData("USD", [self.e_r_of5d], [self.e_r_of5c])]) 15, [CurrencyData("USD", [self.e_r_of5d], [self.e_r_of5c])])
self.t_p_of1: TransactionData = TransactionData( self.v_p_of1: VoucherData = VoucherData(
15, [CurrencyData("USD", [self.e_p_of1d], [self.e_p_of1c])]) 15, [CurrencyData("USD", [self.e_p_of1d], [self.e_p_of1c])])
self.t_p_of2: TransactionData = TransactionData( self.v_p_of2: VoucherData = VoucherData(
10, [CurrencyData("USD", 10, [CurrencyData("USD",
[self.e_p_of2d, self.e_p_of3d, self.e_p_of4d], [self.e_p_of2d, self.e_p_of3d, self.e_p_of4d],
[self.e_p_of2c, self.e_p_of3c, self.e_p_of4c])]) [self.e_p_of2c, self.e_p_of3c, self.e_p_of4c])])
self.t_p_of3: TransactionData = TransactionData( self.v_p_of3: VoucherData = VoucherData(
5, [CurrencyData("USD", [self.e_p_of5d], [self.e_p_of5c])]) 5, [CurrencyData("USD", [self.e_p_of5d], [self.e_p_of5c])])
self.__add_txn(self.t_r_of1) self.__add_voucher(self.v_r_of1)
self.__add_txn(self.t_r_of2) self.__add_voucher(self.v_r_of2)
self.__add_txn(self.t_r_of3) self.__add_voucher(self.v_r_of3)
self.__add_txn(self.t_p_of1) self.__add_voucher(self.v_p_of1)
self.__add_txn(self.t_p_of2) self.__add_voucher(self.v_p_of2)
self.__add_txn(self.t_p_of3) self.__add_voucher(self.v_p_of3)
def __add_txn(self, txn_data: TransactionData) -> None: def __add_voucher(self, voucher_data: VoucherData) -> None:
"""Adds a transaction. """Adds a voucher.
:param txn_data: The transaction data. :param voucher_data: The voucher data.
:return: None. :return: None.
""" """
from accounting.models import Transaction from accounting.models import Voucher
store_uri: str = "/accounting/transactions/store/transfer" store_uri: str = "/accounting/vouchers/store/transfer"
response: httpx.Response = self.client.post( response: httpx.Response = self.client.post(
store_uri, data=txn_data.new_form(self.csrf_token)) store_uri, data=voucher_data.new_form(self.csrf_token))
assert response.status_code == 302 assert response.status_code == 302
txn_id: int = match_txn_detail(response.headers["Location"]) voucher_id: int = match_voucher_detail(response.headers["Location"])
txn_data.id = txn_id voucher_data.id = voucher_id
with self.app.app_context(): with self.app.app_context():
txn: Transaction | None = db.session.get(Transaction, txn_id) voucher: Voucher | None = db.session.get(Voucher, voucher_id)
assert txn is not None assert voucher is not None
for i in range(len(txn.currencies)): for i in range(len(voucher.currencies)):
for j in range(len(txn.currencies[i].debit)): for j in range(len(voucher.currencies[i].debit)):
txn_data.currencies[i].debit[j].id \ voucher_data.currencies[i].debit[j].id \
= txn.currencies[i].debit[j].id = voucher.currencies[i].debit[j].id
for j in range(len(txn.currencies[i].credit)): for j in range(len(voucher.currencies[i].credit)):
txn_data.currencies[i].credit[j].id \ voucher_data.currencies[i].credit[j].id \
= txn.currencies[i].credit[j].id = voucher.currencies[i].credit[j].id

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The common test libraries for the transaction test cases. """The common test libraries for the voucher test cases.
""" """
import re import re
@ -57,10 +57,10 @@ class Accounts:
def get_add_form(csrf_token: str) -> dict[str, str]: def get_add_form(csrf_token: str) -> dict[str, str]:
"""Returns the form data to add a new transaction. """Returns the form data to add a new voucher.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:return: The form data to add a new transaction. :return: The form data to add a new voucher.
""" """
return {"csrf_token": csrf_token, return {"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
@ -124,28 +124,28 @@ def get_add_form(csrf_token: str) -> dict[str, str]:
"note": f"\n \n\n \n{NON_EMPTY_NOTE} \n \n\n "} "note": f"\n \n\n \n{NON_EMPTY_NOTE} \n \n\n "}
def get_unchanged_update_form(txn_id: int, app: Flask, csrf_token: str) \ def get_unchanged_update_form(voucher_id: int, app: Flask, csrf_token: str) \
-> dict[str, str]: -> dict[str, str]:
"""Returns the form data to update a transaction, where the data are not """Returns the form data to update a voucher, where the data are not
changed. changed.
:param txn_id: The transaction ID. :param voucher_id: The voucher ID.
:param app: The Flask application. :param app: The Flask application.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:return: The form data to update the transaction, where the data are not :return: The form data to update the voucher, where the data are not
changed. changed.
""" """
from accounting.models import Transaction, TransactionCurrency from accounting.models import Voucher, VoucherCurrency
with app.app_context(): with app.app_context():
txn: Transaction | None = db.session.get(Transaction, txn_id) voucher: Voucher | None = db.session.get(Voucher, voucher_id)
assert txn is not None assert voucher is not None
currencies: list[TransactionCurrency] = txn.currencies currencies: list[VoucherCurrency] = voucher.currencies
form: dict[str, str] = {"csrf_token": csrf_token, form: dict[str, str] = {"csrf_token": csrf_token,
"next": NEXT_URI, "next": NEXT_URI,
"date": txn.date, "date": voucher.date,
"note": " \n \n\n " if txn.note is None "note": " \n \n\n " if voucher.note is None
else f"\n \n\n \n \n{txn.note} \n\n "} else f"\n \n\n \n \n{voucher.note} \n\n "}
currency_indices_used: set[int] = set() currency_indices_used: set[int] = set()
currency_no: int = 0 currency_no: int = 0
for currency in currencies: for currency in currencies:
@ -200,21 +200,21 @@ def __get_new_index(indices_used: set[int]) -> int:
return index return index
def get_update_form(txn_id: int, app: Flask, def get_update_form(voucher_id: int, app: Flask,
csrf_token: str, is_debit: bool | None) -> dict[str, str]: csrf_token: str, is_debit: bool | None) -> dict[str, str]:
"""Returns the form data to update a transaction, where the data are """Returns the form data to update a voucher, where the data are
changed. changed.
:param txn_id: The transaction ID. :param voucher_id: The voucher ID.
:param app: The Flask application. :param app: The Flask application.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:param is_debit: True for a cash expense transaction, False for a cash :param is_debit: True for a cash disbursement voucher, False for a cash
income transaction, or None for a transfer transaction receipt voucher, or None for a transfer voucher
:return: The form data to update the transaction, where the data are :return: The form data to update the voucher, where the data are
changed. changed.
""" """
form: dict[str, str] = get_unchanged_update_form( form: dict[str, str] = get_unchanged_update_form(
txn_id, app, csrf_token) voucher_id, app, csrf_token)
# Mess up the entries in a currency # Mess up the entries in a currency
currency_prefix: str = __get_currency_prefix(form, "USD") currency_prefix: str = __get_currency_prefix(form, "USD")
@ -240,7 +240,7 @@ def __mess_up_debit(form: dict[str, str], currency_prefix: str) \
key: str key: str
m: re.Match m: re.Match
# Remove the office expense # Remove the office disbursement
key = [x for x in form key = [x for x in form
if x.startswith(currency_prefix) if x.startswith(currency_prefix)
and form[x] == Accounts.OFFICE][0] and form[x] == Accounts.OFFICE][0]
@ -249,7 +249,7 @@ def __mess_up_debit(form: dict[str, str], currency_prefix: str) \
entry_prefix: str = m.group(1) entry_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{entry_prefix}amount"]) amount: Decimal = Decimal(form[f"{entry_prefix}amount"])
form = {x: form[x] for x in form if not x.startswith(entry_prefix)} form = {x: form[x] for x in form if not x.startswith(entry_prefix)}
# Add a new travel expense # Add a new travel disbursement
indices: set[int] = set() indices: set[int] = set()
for key in form: for key in form:
m = re.match(r"^.+-(\d+)-amount$", key) m = re.match(r"^.+-(\d+)-amount$", key)
@ -279,7 +279,7 @@ def __mess_up_credit(form: dict[str, str], currency_prefix: str) \
key: str key: str
m: re.Match m: re.Match
# Remove the sales income # Remove the sales receipt
key = [x for x in form key = [x for x in form
if x.startswith(currency_prefix) if x.startswith(currency_prefix)
and form[x] == Accounts.SALES][0] and form[x] == Accounts.SALES][0]
@ -288,7 +288,7 @@ def __mess_up_credit(form: dict[str, str], currency_prefix: str) \
entry_prefix: str = m.group(1) entry_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{entry_prefix}amount"]) amount: Decimal = Decimal(form[f"{entry_prefix}amount"])
form = {x: form[x] for x in form if not x.startswith(entry_prefix)} form = {x: form[x] for x in form if not x.startswith(entry_prefix)}
# Add a new agency income # Add a new agency receipt
indices: set[int] = set() indices: set[int] = set()
for key in form: for key in form:
m = re.match(r"^.+-(\d+)-amount$", key) m = re.match(r"^.+-(\d+)-amount$", key)
@ -389,35 +389,34 @@ def __get_currency_prefix(form: dict[str, str], code: str) -> str:
return m.group(1) return m.group(1)
def add_txn(client: httpx.Client, form: dict[str, str]) -> int: def add_voucher(client: httpx.Client, form: dict[str, str]) -> int:
"""Adds a transfer transaction. """Adds a transfer voucher.
:param client: The client. :param client: The client.
:param form: The form data. :param form: The form data.
:return: The newly-added transaction ID. :return: The newly-added voucher ID.
""" """
prefix: str = "/accounting/transactions" prefix: str = "/accounting/vouchers"
txn_type: str = "transfer" voucher_type: str = "transfer"
if len({x for x in form if "-debit-" in x}) == 0: if len({x for x in form if "-debit-" in x}) == 0:
txn_type = "income" voucher_type = "receipt"
elif len({x for x in form if "-credit-" in x}) == 0: elif len({x for x in form if "-credit-" in x}) == 0:
txn_type = "expense" voucher_type = "disbursement"
store_uri = f"{prefix}/store/{txn_type}" store_uri = f"{prefix}/store/{voucher_type}"
response: httpx.Response = client.post(store_uri, data=form) response: httpx.Response = client.post(store_uri, data=form)
assert response.status_code == 302 assert response.status_code == 302
return match_txn_detail(response.headers["Location"]) return match_voucher_detail(response.headers["Location"])
def match_txn_detail(location: str) -> int: def match_voucher_detail(location: str) -> int:
"""Validates if the redirect location is the transaction detail, and """Validates if the redirect location is the voucher detail, and
returns the transaction ID on success. returns the voucher ID on success.
:param location: The redirect location. :param location: The redirect location.
:return: The transaction ID. :return: The voucher ID.
:raise AssertionError: When the location is not the transaction detail. :raise AssertionError: When the location is not the voucher detail.
""" """
m: re.Match = re.match( m: re.Match = re.match(r"^/accounting/vouchers/(\d+)\?next=%2F_next",
r"^/accounting/transactions/(\d+)\?next=%2F_next",
location) location)
assert m is not None assert m is not None
return int(m.group(1)) return int(m.group(1))