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
currency.init_app(app, bp)
from . import transaction
transaction.init_app(app, bp)
from . import voucher
voucher.init_app(app, bp)
from . import report
report.init_app(app, bp)

View File

@ -447,12 +447,12 @@ class CurrencyL10n(db.Model):
"""The localized name."""
class TransactionCurrency:
"""A currency in a transaction."""
class VoucherCurrency:
"""A currency in a voucher."""
def __init__(self, code: str, debit: list[JournalEntry],
credit: list[JournalEntry]):
"""Constructs the currency in the transaction.
"""Constructs the currency in the voucher.
:param code: The currency code.
:param debit: The debit entries.
@ -490,13 +490,13 @@ class TransactionCurrency:
return sum([x.amount for x in self.credit])
class Transaction(db.Model):
"""A transaction."""
__tablename__ = "accounting_transactions"
class Voucher(db.Model):
"""A voucher."""
__tablename__ = "accounting_vouchers"
"""The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The transaction ID."""
"""The voucher ID."""
date = db.Column(db.Date, nullable=False)
"""The date."""
no = db.Column(db.Integer, nullable=False, default=text("1"))
@ -523,22 +523,22 @@ class Transaction(db.Model):
"""The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator."""
entries = db.relationship("JournalEntry", back_populates="transaction")
entries = db.relationship("JournalEntry", back_populates="voucher")
"""The journal entries."""
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:
return gettext("Cash Expense Transaction#%(id)s", id=self.id)
if self.is_cash_income:
return gettext("Cash Income Transaction#%(id)s", id=self.id)
return gettext("Transfer Transaction#%(id)s", id=self.id)
if self.is_cash_disbursement:
return gettext("Cash Disbursement Voucher#%(id)s", id=self.id)
if self.is_cash_receipt:
return gettext("Cash Receipt Voucher#%(id)s", id=self.id)
return gettext("Transfer Voucher#%(id)s", id=self.id)
@property
def currencies(self) -> list[TransactionCurrency]:
def currencies(self) -> list[VoucherCurrency]:
"""Returns the journal entries categorized by their currencies.
:return: The currency categories.
@ -551,18 +551,18 @@ class Transaction(db.Model):
codes.append(entry.currency_code)
by_currency[entry.currency_code] = []
by_currency[entry.currency_code].append(entry)
return [TransactionCurrency(code=x,
debit=[y for y in by_currency[x]
if y.is_debit],
credit=[y for y in by_currency[x]
if not y.is_debit])
return [VoucherCurrency(code=x,
debit=[y for y in by_currency[x]
if y.is_debit],
credit=[y for y in by_currency[x]
if not y.is_debit])
for x in codes]
@property
def is_cash_income(self) -> bool:
"""Returns whether this is a cash income transaction.
def is_cash_receipt(self) -> bool:
"""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:
if len(currency.debit) > 1:
@ -572,10 +572,10 @@ class Transaction(db.Model):
return True
@property
def is_cash_expense(self) -> bool:
"""Returns whether this is a cash expense transaction.
def is_cash_disbursement(self) -> bool:
"""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.
"""
for currency in self.currencies:
@ -587,9 +587,9 @@ class Transaction(db.Model):
@property
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"):
def has_offset() -> bool:
@ -601,12 +601,12 @@ class Transaction(db.Model):
return getattr(self, "__can_delete")
def delete(self) -> None:
"""Deletes the transaction.
"""Deletes the voucher.
:return: None.
"""
JournalEntry.query\
.filter(JournalEntry.transaction_id == self.id).delete()
.filter(JournalEntry.voucher_id == self.id).delete()
db.session.delete(self)
@ -617,18 +617,17 @@ class JournalEntry(db.Model):
id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The entry ID."""
transaction_id = db.Column(db.Integer,
db.ForeignKey(Transaction.id,
onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False)
"""The transaction ID."""
transaction = db.relationship(Transaction, back_populates="entries")
"""The transaction."""
voucher_id = db.Column(db.Integer,
db.ForeignKey(Voucher.id, onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False)
"""The voucher ID."""
voucher = db.relationship(Voucher, back_populates="entries")
"""The voucher."""
is_debit = db.Column(db.Boolean, nullable=False)
"""True for a debit entry, or False for a credit entry."""
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,
db.ForeignKey(id, onupdate="CASCADE"),
nullable=True)
@ -665,7 +664,7 @@ class JournalEntry(db.Model):
from accounting.template_filters import format_date, format_amount
setattr(self, "__str",
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
else self.summary,
amount=format_amount(self.amount)))
@ -750,12 +749,13 @@ class JournalEntry(db.Model):
frac: Decimal = (value - whole).normalize()
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
return ([summary],
[str(txn_day.year),
"{}/{}".format(txn_day.year, txn_day.month),
"{}/{}".format(txn_day.month, txn_day.day),
"{}/{}/{}".format(txn_day.year, txn_day.month, txn_day.day),
[str(voucher_day.year),
"{}/{}".format(voucher_day.year, voucher_day.month),
"{}/{}".format(voucher_day.month, voucher_day.day),
"{}/{}/{}".format(voucher_day.year, voucher_day.month,
voucher_day.day),
format_amount(self.amount),
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
from datetime import date
from accounting.models import Transaction
from accounting.models import Voucher
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
@ -61,8 +61,8 @@ class PeriodChooser:
self.url_template: str = get_url(TemplatePeriod())
"""The URL template."""
first: Transaction | None \
= Transaction.query.order_by(Transaction.date).first()
first: Voucher | None \
= Voucher.query.order_by(Voucher.date).first()
start: date | None = None if first is None else first.date
# Attributes

View File

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

View File

@ -24,7 +24,7 @@ from flask import render_template, Response
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \
from accounting.models import Currency, BaseAccount, Account, Voucher, \
JournalEntry
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
@ -259,14 +259,14 @@ class IncomeStatement(BaseReport):
= [JournalEntry.currency_code == self.__currency.code,
sa.or_(*sub_conditions)]
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:
conditions.append(Transaction.date <= self.__period.end)
conditions.append(Voucher.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, -JournalEntry.amount),
else_=JournalEntry.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\
.join(Voucher).join(Account)\
.filter(*conditions)\
.group_by(Account.id)\
.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 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.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
@ -47,8 +47,8 @@ class ReportEntry:
"""
self.entry: JournalEntry = entry
"""The journal entry."""
self.transaction: Transaction = entry.transaction
"""The transaction."""
self.voucher: Voucher = entry.voucher
"""The voucher."""
self.currency: Currency = entry.currency
"""The account."""
self.account: Account = entry.account
@ -66,7 +66,7 @@ class ReportEntry:
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, txn_date: str | date,
def __init__(self, voucher_date: str | date,
currency: str,
account: str,
summary: str | None,
@ -75,13 +75,13 @@ class CSVRow(BaseCSVRow):
note: str | None):
"""Constructs a row in the CSV.
:param txn_date: The transaction date.
:param voucher_date: The voucher date.
:param summary: The summary.
:param debit: The debit amount.
:param credit: The credit amount.
:param note: The note.
"""
self.date: str | date = txn_date
self.date: str | date = voucher_date
"""The date."""
self.currency: str = currency
"""The currency."""
@ -155,9 +155,9 @@ def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
gettext("Account"), gettext("Summary"),
gettext("Debit"), gettext("Credit"),
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,
x.debit, x.credit, x.transaction.note)
x.debit, x.credit, x.voucher.note)
for x in entries])
return rows
@ -182,18 +182,18 @@ class Journal(BaseReport):
"""
conditions: list[sa.BinaryExpression] = []
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:
conditions.append(Transaction.date <= self.__period.end)
return JournalEntry.query.join(Transaction)\
conditions.append(Voucher.date <= self.__period.end)
return JournalEntry.query.join(Voucher)\
.filter(*conditions)\
.order_by(Transaction.date,
Transaction.no,
.order_by(Voucher.date,
Voucher.no,
JournalEntry.is_debit.desc(),
JournalEntry.no)\
.options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all()
selectinload(JournalEntry.voucher)).all()
def csv(self) -> Response:
"""Returns the report as CSV for download.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
/* 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.

View File

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

View File

@ -105,7 +105,7 @@ class OriginalEntrySelector {
* Returns the net balance for an original entry.
*
* @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
* @return {Decimal} the net balance of the original entry
*/

View File

@ -1,5 +1,5 @@
/* 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.
@ -23,14 +23,14 @@
"use strict";
document.addEventListener("DOMContentLoaded", () => {
TransactionForm.initialize();
VoucherForm.initialize();
});
/**
* The transaction form
* The voucher form
*
*/
class TransactionForm {
class VoucherForm {
/**
* The form element
@ -105,7 +105,7 @@ class TransactionForm {
entryEditor;
/**
* Constructs the transaction form.
* Constructs the voucher form.
*
*/
constructor() {
@ -325,17 +325,17 @@ class TransactionForm {
}
/**
* The transaction form
* @type {TransactionForm}
* The voucher form
* @type {VoucherForm}
*/
static #form;
/**
* Initializes the transaction form.
* Initializes the voucher form.
*
*/
static initialize() {
this.#form = new TransactionForm()
this.#form = new VoucherForm()
}
}
@ -352,8 +352,8 @@ class CurrencySubForm {
element;
/**
* The transaction form
* @type {TransactionForm}
* The voucher form
* @type {VoucherForm}
*/
form;
@ -420,7 +420,7 @@ class CurrencySubForm {
/**
* 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
*/
constructor(form, element) {

View File

@ -1,5 +1,5 @@
/* 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.

View File

@ -14,7 +14,7 @@
# 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 template globals for the transaction management.
"""The template globals.
"""
from flask import current_app

View File

@ -37,7 +37,7 @@ First written: 2023/3/7
{% endwith %}
</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" %}

View File

@ -1,6 +1,6 @@
{#
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.
@ -22,13 +22,13 @@ First written: 2023/2/25
{% 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-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>
<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>
<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>
</div>

View File

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

View File

@ -38,7 +38,7 @@ First written: 2023/3/5
{% endwith %}
</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" %}

View File

@ -37,7 +37,7 @@ First written: 2023/3/7
{% endwith %}
</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" %}

View File

@ -36,7 +36,7 @@ First written: 2023/3/4
{% endwith %}
</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" %}
@ -60,8 +60,8 @@ First written: 2023/3/4
</div>
<div class="accounting-report-table-body">
{% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div>{{ entry.transaction.date|accounting_format_date }}</div>
<a class="accounting-report-table-row" href="{{ url_for("accounting.voucher.detail", voucher=entry.voucher)|accounting_append_next }}">
<div>{{ entry.voucher.date|accounting_format_date }}</div>
<div>{{ entry.currency.name }}</div>
<div>
<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">
{% 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 {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small">
{{ entry.transaction.date|accounting_format_date }}
{{ entry.voucher.date|accounting_format_date }}
{{ entry.account.title|title }}
{% if entry.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>

View File

@ -38,7 +38,7 @@ First written: 2023/3/5
{% endwith %}
</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" %}

View File

@ -35,7 +35,7 @@ First written: 2023/3/8
{% endwith %}
</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" %}
@ -57,8 +57,8 @@ First written: 2023/3/8
</div>
<div class="accounting-report-table-body">
{% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div>{{ entry.transaction.date|accounting_format_date }}</div>
<a class="accounting-report-table-row" href="{{ url_for("accounting.voucher.detail", voucher=entry.voucher)|accounting_append_next }}">
<div>{{ entry.voucher.date|accounting_format_date }}</div>
<div>{{ entry.currency.name }}</div>
<div>
<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">
{% 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 {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small">
{{ entry.transaction.date|accounting_format_date }}
{{ entry.voucher.date|accounting_format_date }}
{{ entry.account.title|title }}
{% if entry.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>

View File

@ -37,7 +37,7 @@ First written: 2023/3/5
{% endwith %}
</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" %}

View File

@ -1,6 +1,6 @@
{#
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.
@ -19,10 +19,10 @@ create.html: The cash expense transaction creation form
Author: imacat@mail.imacat.idv.tw (imacat)
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 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
detail.html: The account detail
detail.html: The cash disbursement voucher detail
Copyright (c) 2023 imacat.
@ -19,26 +19,26 @@ detail.html: The account detail
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% extends "accounting/voucher/include/detail.html" %}
{% 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>
{{ A_("To Transfer") }}
</a>
{% endblock %}
{% block transaction_currencies %}
{% block voucher_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<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 %}
{% include "accounting/transaction/include/detail-entries.html" %}
{% include "accounting/voucher/include/detail-entries.html" %}
{% 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>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -1,6 +1,6 @@
{#
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.
@ -19,10 +19,10 @@ edit.html: The cash income transaction edit form
Author: imacat@mail.imacat.idv.tw (imacat)
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
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.
@ -64,11 +64,11 @@ First written: 2023/2/25
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
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_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% include "accounting/voucher/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>

View File

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

View File

@ -1,6 +1,6 @@
{#
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.
@ -21,7 +21,7 @@ First written: 2023/3/14
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
{% 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>
<div class="small">{{ entry.account }}</div>
@ -30,7 +30,7 @@ First written: 2023/3/14
{% endif %}
{% if entry.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>
</div>
@ -43,8 +43,8 @@ First written: 2023/3/14
<ul class="ms-2 ps-0">
{% for offset in entry.offsets %}
<li>
<a href="{{ url_for("accounting.transaction.detail", txn=offset.transaction)|accounting_append_next }}">
{{ offset.transaction.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
<a href="{{ url_for("accounting.voucher.detail", voucher=offset.voucher)|accounting_append_next }}">
{{ offset.voucher.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
</a>
</li>
{% endfor %}

View File

@ -31,12 +31,12 @@ First written: 2023/2/26
{{ A_("Back") }}
</a>
{% 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>
{{ A_("Settings") }}
</a>
{% 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>
{{ A_("Order") }}
</a>
@ -58,14 +58,14 @@ First written: 2023/2/26
{% if accounting_can_edit() %}
<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>
</a>
</div>
{% endif %}
{% 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() }}">
{% if 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-content">
<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>
</div>
<div class="modal-body">
{{ A_("Do you really want to delete this transaction?") }}
{{ A_("Do you really want to delete this voucher?") }}
</div>
<div class="modal-footer">
<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 }}
</div>
{% block transaction_currencies %}{% endblock %}
{% block voucher_currencies %}{% endblock %}
{% if obj.note %}
<div class="card mb-3">
<div class="card-body">
<i class="far fa-comment-dots"></i>
{{ obj.note|accounting_txn_text2html|safe }}
{{ obj.note|accounting_voucher_text2html|safe }}
</div>
</div>
{% endif %}

View File

@ -1,6 +1,6 @@
{#
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.
@ -43,7 +43,7 @@ First written: 2023/2/25
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% 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 %}
</ul>
</div>

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
form.html: The transfer transaction form
form.html: The base voucher form
Copyright (c) 2023 imacat.
@ -23,7 +23,7 @@ First written: 2023/2/26
{% 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/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/account-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>
</form>
{% include "accounting/transaction/include/journal-entry-editor-modal.html" %}
{% include "accounting/voucher/include/journal-entry-editor-modal.html" %}
{% block form_modals %}{% endblock %}
{% include "accounting/transaction/include/original-entry-selector-modal.html" %}
{% include "accounting/voucher/include/original-entry-selector-modal.html" %}
{% 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">
{% 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">
<div>{{ entry.transaction.date|accounting_format_date }} {{ entry.summary|accounting_default }}</div>
<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.voucher.date|accounting_format_date }} {{ entry.summary|accounting_default }}</div>
<div>
<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>

View File

@ -1,6 +1,6 @@
{#
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.
@ -23,10 +23,10 @@ First written: 2023/2/26
{% 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/transaction-order.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/voucher-order.js") }}"></script>
{% 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 %}
@ -38,7 +38,7 @@ First written: 2023/2/26
</div>
{% 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() }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
create.html: The transfer transaction creation form
create.html: The cash receipt voucher creation form
Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ create.html: The transfer transaction creation form
Author: imacat@mail.imacat.idv.tw (imacat)
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 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
detail.html: The account detail
detail.html: The cash receipt voucher detail
Copyright (c) 2023 imacat.
@ -19,26 +19,26 @@ detail.html: The account detail
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% extends "accounting/voucher/include/detail.html" %}
{% 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>
{{ A_("To Transfer") }}
</a>
{% endblock %}
{% block transaction_currencies %}
{% block voucher_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<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 %}
{% include "accounting/transaction/include/detail-entries.html" %}
{% include "accounting/voucher/include/detail-entries.html" %}
{% 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>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -1,6 +1,6 @@
{#
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.
@ -19,10 +19,10 @@ edit.html: The cash expense transaction edit form
Author: imacat@mail.imacat.idv.tw (imacat)
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
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.
@ -64,11 +64,11 @@ First written: 2023/2/25
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
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_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% include "accounting/voucher/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>

View File

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

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
create.html: The cash income transaction creation form
create.html: The transfer voucher creation form
Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ create.html: The cash income transaction creation form
Author: imacat@mail.imacat.idv.tw (imacat)
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 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
detail.html: The account detail
detail.html: The transfer voucher detail
Copyright (c) 2023 imacat.
@ -19,9 +19,9 @@ detail.html: The account detail
Author: imacat@mail.imacat.idv.tw (imacat)
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 %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
@ -30,11 +30,11 @@ First written: 2023/2/26
{# The debit entries #}
<div class="col-sm-6 mb-2">
<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 %}
{% include "accounting/transaction/include/detail-entries.html" %}
{% include "accounting/voucher/include/detail-entries.html" %}
{% 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>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
@ -46,11 +46,11 @@ First written: 2023/2/26
{# The credit entries #}
<div class="col-sm-6 mb-2">
<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 %}
{% include "accounting/transaction/include/detail-entries.html" %}
{% include "accounting/voucher/include/detail-entries.html" %}
{% 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>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
edit.html: The transfer transaction edit form
edit.html: The transfer voucher edit form
Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ edit.html: The transfer transaction edit form
Author: imacat@mail.imacat.idv.tw (imacat)
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
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.
@ -66,11 +66,11 @@ First written: 2023/2/25
offset_total = entry_form.offset_total|accounting_default,
net_balance_data = entry_form.net_balance,
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_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% include "accounting/voucher/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
@ -114,11 +114,11 @@ First written: 2023/2/25
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
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_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% include "accounting/voucher/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>

View File

@ -1,6 +1,6 @@
{#
The Mia! Accounting Flask Project
form.html: The transfer transaction form
form.html: The transfer voucher form
Copyright (c) 2023 imacat.
@ -19,7 +19,7 @@ form.html: The transfer transaction form
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% extends "accounting/voucher/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
@ -36,7 +36,7 @@ First written: 2023/2/25
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
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 %}
{% endfor %}
{% else %}
@ -45,24 +45,24 @@ First written: 2023/2/25
currency_code_data = accounting_default_currency_code(),
debit_total = "-",
credit_total = "-" %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
{% include "accounting/voucher/transfer/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block form_modals %}
{% with summary_editor = form.summary_editor.debit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %}
{% include "accounting/voucher/include/summary-editor-modal.html" %}
{% endwith %}
{% with summary_editor = form.summary_editor.credit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %}
{% include "accounting/voucher/include/summary-editor-modal.html" %}
{% endwith %}
{% with entry_type = "debit",
account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% include "accounting/voucher/include/account-selector-modal.html" %}
{% endwith %}
{% with entry_type = "credit",
account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% include "accounting/voucher/include/account-selector-modal.html" %}
{% endwith %}
{% 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.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The transaction types.
"""The voucher types.
"""
from enum import Enum
class TransactionType(Enum):
"""The transaction types."""
CASH_INCOME: str = "income"
"""The cash income transaction."""
CASH_EXPENSE: str = "expense"
"""The cash expense transaction."""
class VoucherType(Enum):
"""The voucher types."""
CASH_RECEIPT: str = "receipt"
"""The cash receipt voucher."""
CASH_DISBURSEMENT: str = "disbursement"
"""The cash disbursement voucher."""
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.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The transaction management.
"""The voucher management.
"""
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.
:return: None.
"""
from .converters import TransactionConverter, TransactionTypeConverter, \
from .converters import VoucherConverter, VoucherTypeConverter, \
DateConverter
app.url_map.converters["transaction"] = TransactionConverter
app.url_map.converters["transactionType"] = TransactionTypeConverter
app.url_map.converters["voucher"] = VoucherConverter
app.url_map.converters["voucherType"] = VoucherTypeConverter
app.url_map.converters["date"] = DateConverter
from .views import bp as transaction_bp
bp.register_blueprint(transaction_bp, url_prefix="/transactions")
from .views import bp as voucher_bp
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.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The path converters for the transaction management.
"""The path converters for the voucher management.
"""
from datetime import date
@ -23,61 +23,59 @@ from flask import abort
from sqlalchemy.orm import selectinload
from werkzeug.routing import BaseConverter
from accounting.models import Transaction, JournalEntry
from accounting.utils.txn_types import TransactionType
from accounting.models import Voucher, JournalEntry
from accounting.utils.voucher_types import VoucherType
class TransactionConverter(BaseConverter):
"""The transaction converter to convert the transaction ID from and to the
corresponding transaction in the routes."""
class VoucherConverter(BaseConverter):
"""The voucher converter to convert the voucher ID from and to the
corresponding voucher in the routes."""
def to_python(self, value: str) -> Transaction:
"""Converts a transaction ID to a transaction.
def to_python(self, value: str) -> Voucher:
"""Converts a voucher ID to a voucher.
:param value: The transaction ID.
:return: The corresponding transaction.
:param value: The voucher ID.
:return: The corresponding voucher.
"""
transaction: Transaction | None = Transaction.query\
.join(JournalEntry)\
.filter(Transaction.id == value)\
.options(selectinload(Transaction.entries)
voucher: Voucher | None = Voucher.query.join(JournalEntry)\
.filter(Voucher.id == value)\
.options(selectinload(Voucher.entries)
.selectinload(JournalEntry.offsets)
.selectinload(JournalEntry.transaction))\
.selectinload(JournalEntry.voucher))\
.first()
if transaction is None:
if voucher is None:
abort(404)
return transaction
return voucher
def to_url(self, value: Transaction) -> str:
"""Converts a transaction to its ID.
def to_url(self, value: Voucher) -> str:
"""Converts a voucher to its ID.
:param value: The transaction.
:param value: The voucher.
:return: The ID.
"""
return str(value.id)
class TransactionTypeConverter(BaseConverter):
"""The transaction converter to convert the transaction type ID from and to
the corresponding transaction type in the routes."""
class VoucherTypeConverter(BaseConverter):
"""The voucher converter to convert the voucher type ID from and to the
corresponding voucher type in the routes."""
def to_python(self, value: str) -> TransactionType:
"""Converts a transaction ID to a transaction.
def to_python(self, value: str) -> VoucherType:
"""Converts a voucher ID to a voucher.
:param value: The transaction ID.
:return: The corresponding transaction.
:param value: The voucher ID.
:return: The corresponding voucher type.
"""
type_dict: dict[str, TransactionType] \
= {x.value: x for x in TransactionType}
txn_type: TransactionType | None = type_dict.get(value)
if txn_type is None:
type_dict: dict[str, VoucherType] = {x.value: x for x in VoucherType}
voucher_type: VoucherType | None = type_dict.get(value)
if voucher_type is None:
abort(404)
return txn_type
return voucher_type
def to_url(self, value: TransactionType) -> str:
"""Converts a transaction type to its ID.
def to_url(self, value: VoucherType) -> str:
"""Converts a voucher type to its ID.
:param value: The transaction type.
:param value: The voucher type.
:return: The ID.
"""
return str(value.value)

View File

@ -14,9 +14,9 @@
# 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 forms for the transaction management.
"""The forms for the voucher management.
"""
from .reorder import sort_transactions_in, TransactionReorderForm
from .transaction import TransactionForm, IncomeTransactionForm, \
ExpenseTransactionForm, TransferTransactionForm
from .reorder import sort_vouchers_in, VoucherReorderForm
from .voucher import VoucherForm, CashReceiptVoucherForm, \
CashDisbursementVoucherForm, TransferVoucherForm

View File

@ -14,7 +14,7 @@
# 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 currency sub-forms for the transaction management.
"""The currency sub-forms for the voucher management.
"""
from decimal import Decimal
@ -29,7 +29,7 @@ from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
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.strip_text import strip_text
from .journal_entry import JournalEntryForm, CreditEntryForm, DebitEntryForm
@ -117,9 +117,9 @@ class IsBalanced:
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()
"""The order in the transaction."""
"""The order in the voucher."""
code = StringField()
"""The currency code."""
whole_form = BooleanField()
@ -132,9 +132,9 @@ class CurrencyForm(FlaskForm):
:return: The journal entry sub-forms.
"""
entry_forms: list[JournalEntryForm] = []
if isinstance(self, IncomeCurrencyForm):
if isinstance(self, CashReceiptCurrencyForm):
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])
elif isinstance(self, TransferCurrencyForm):
entry_forms.extend([x.form for x in self.debit])
@ -161,10 +161,10 @@ class CurrencyForm(FlaskForm):
return db.session.scalar(select) > 0
class IncomeCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash income transaction."""
class CashReceiptCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash receipt voucher."""
no = IntegerField()
"""The order in the transaction."""
"""The order in the voucher."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
@ -198,10 +198,10 @@ class IncomeCurrencyForm(CurrencyForm):
if isinstance(x, str) or isinstance(x, LazyString)]
class ExpenseCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash expense transaction."""
class CashDisbursementCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash disbursement voucher."""
no = IntegerField()
"""The order in the transaction."""
"""The order in the voucher."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
@ -236,9 +236,9 @@ class ExpenseCurrencyForm(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()
"""The order in the transaction."""
"""The order in the voucher."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,

View File

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

View File

@ -14,7 +14,7 @@
# 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 template filters for the transaction management.
"""The template filters for the voucher management.
"""
from decimal import Decimal
@ -26,10 +26,10 @@ from flask import request
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.
: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:
return uri
@ -43,10 +43,10 @@ def with_type(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.
: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)
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.
# See the License for the specific language governing permissions and
# 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.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The account option for the transaction management.
"""The account option for the voucher management.
"""
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.locale import lazy_gettext
from accounting.models import Account, Transaction, JournalEntry
from accounting.transaction.forms.journal_entry import JournalEntryForm
from accounting.models import Account, Voucher, JournalEntry
from accounting.voucher.forms.journal_entry import JournalEntryForm
from accounting.utils.cast import be
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()}
entries: list[JournalEntry] = JournalEntry.query\
.filter(JournalEntry.id.in_({x for x in net_balances}))\
.join(Transaction)\
.order_by(Transaction.date, JournalEntry.is_debit, JournalEntry.no)\
.join(Voucher)\
.order_by(Voucher.date, JournalEntry.is_debit, JournalEntry.no)\
.options(selectinload(JournalEntry.currency),
selectinload(JournalEntry.account),
selectinload(JournalEntry.transaction)).all()
selectinload(JournalEntry.voucher)).all()
for entry in entries:
entry.net_balance = entry.amount if net_balances[entry.id] is None \
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 testlib import create_test_app, get_client
from testlib_offset import TestData, JournalEntryData, TransactionData, \
from testlib_offset import TestData, JournalEntryData, VoucherData, \
CurrencyData
from testlib_txn import Accounts, match_txn_detail
from testlib_voucher import Accounts, match_voucher_detail
PREFIX: str = "/accounting/transactions"
"""The URL prefix for the transaction management."""
PREFIX: str = "/accounting/vouchers"
"""The URL prefix for the voucher management."""
class OffsetTestCase(unittest.TestCase):
@ -48,7 +48,7 @@ class OffsetTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Transaction, \
from accounting.models import BaseAccount, Voucher, \
JournalEntry
result: Result
result = runner.invoke(args="init-db")
@ -62,7 +62,7 @@ class OffsetTestCase(unittest.TestCase):
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Transaction.query.delete()
Voucher.query.delete()
JournalEntry.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
@ -73,15 +73,15 @@ class OffsetTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account, Transaction
create_uri: str = f"{PREFIX}/create/income?next=%2F_next"
store_uri: str = f"{PREFIX}/store/income"
from accounting.models import Account, Voucher
create_uri: str = f"{PREFIX}/create/receipt?next=%2F_next"
store_uri: str = f"{PREFIX}/store/receipt"
form: dict[str, str]
old_amount: Decimal
response: httpx.Response
txn_data: TransactionData = TransactionData(
self.data.e_r_or3d.txn.days, [CurrencyData(
voucher_data: VoucherData = VoucherData(
self.data.e_r_or3d.voucher.days, [CurrencyData(
"USD",
[],
[JournalEntryData(Accounts.RECEIVABLE,
@ -95,14 +95,14 @@ class OffsetTestCase(unittest.TestCase):
original_entry=self.data.e_r_or3d)])])
# 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"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# 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-account_code"] = self.data.e_p_or1c.account
form["currency-1-credit-1-amount"] = "100"
@ -115,8 +115,8 @@ class OffsetTestCase(unittest.TestCase):
account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = False
db.session.commit()
response = self.client.post(store_uri,
data=txn_data.new_form(self.csrf_token))
response = self.client.post(
store_uri, data=voucher_data.new_form(self.csrf_token))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
@ -125,7 +125,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
# 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-account_code"] = self.data.e_p_of1d.account
response = self.client.post(store_uri, data=form)
@ -133,52 +133,54 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri)
# 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"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# 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
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# 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"] \
= 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)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# 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"] \
= 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)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not before the original entries
old_days = txn_data.days
txn_data.days = old_days + 1
form = txn_data.new_form(self.csrf_token)
old_days = voucher_data.days
voucher_data.days = old_days + 1
form = voucher_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
txn_data.days = old_days
voucher_data.days = old_days
# 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)
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():
txn = db.session.get(Transaction, txn_id)
for offset in txn.currencies[0].credit:
voucher = db.session.get(Voucher, voucher_id)
for offset in voucher.currencies[0].credit:
self.assertIsNotNone(offset.original_entry_id)
def test_edit_receivable_offset(self) -> None:
@ -187,27 +189,27 @@ class OffsetTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account
txn_data: TransactionData = self.data.t_r_of2
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update"
voucher_data: VoucherData = self.data.v_r_of2
edit_uri: str = f"{PREFIX}/{voucher_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{voucher_data.id}/update"
form: dict[str, str]
response: httpx.Response
txn_data.days = self.data.t_r_or2.days
txn_data.currencies[0].debit[0].amount = Decimal("600")
txn_data.currencies[0].credit[0].amount = Decimal("600")
txn_data.currencies[0].debit[2].amount = Decimal("600")
txn_data.currencies[0].credit[2].amount = Decimal("600")
voucher_data.days = self.data.v_r_or2.days
voucher_data.currencies[0].debit[0].amount = Decimal("600")
voucher_data.currencies[0].credit[0].amount = Decimal("600")
voucher_data.currencies[0].debit[2].amount = Decimal("600")
voucher_data.currencies[0].credit[2].amount = Decimal("600")
# 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"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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-account_code"] = self.data.e_p_or1c.account
form["currency-1-debit-1-amount"] = "100"
@ -221,8 +223,8 @@ class OffsetTestCase(unittest.TestCase):
account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = False
db.session.commit()
response = self.client.post(update_uri,
data=txn_data.update_form(self.csrf_token))
response = self.client.post(
update_uri, data=voucher_data.update_form(self.csrf_token))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context():
@ -231,7 +233,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
# 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-account_code"] = self.data.e_p_of1d.account
response = self.client.post(update_uri, data=form)
@ -239,155 +241,159 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# 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"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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"] \
= 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"] \
= 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)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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"] \
= 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"] \
= 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)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not before the original entries
old_days: int = txn_data.days
txn_data.days = old_days + 1
form = txn_data.update_form(self.csrf_token)
old_days: int = voucher_data.days
voucher_data.days = old_days + 1
form = voucher_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days
voucher_data.days = old_days
# 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)
self.assertEqual(response.status_code, 302)
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:
"""Tests to edit the receivable original entry.
:return: None.
"""
from accounting.models import Transaction
txn_data: TransactionData = self.data.t_r_or1
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update"
from accounting.models import Voucher
voucher_data: VoucherData = self.data.v_r_or1
edit_uri: str = f"{PREFIX}/{voucher_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{voucher_data.id}/update"
form: dict[str, str]
response: httpx.Response
txn_data.days = self.data.t_r_of1.days
txn_data.currencies[0].debit[0].amount = Decimal("800")
txn_data.currencies[0].credit[0].amount = Decimal("800")
txn_data.currencies[0].debit[1].amount = Decimal("3.4")
txn_data.currencies[0].credit[1].amount = Decimal("3.4")
voucher_data.days = self.data.v_r_of1.days
voucher_data.currencies[0].debit[0].amount = Decimal("800")
voucher_data.currencies[0].credit[0].amount = Decimal("800")
voucher_data.currencies[0].debit[1].amount = Decimal("3.4")
voucher_data.currencies[0].credit[1].amount = Decimal("3.4")
# 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"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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"] \
= 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"] \
= 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)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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"] \
= 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"] \
= 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)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not after the offset entries
old_days: int = txn_data.days
txn_data.days = old_days - 1
form = txn_data.update_form(self.csrf_token)
old_days: int = voucher_data.days
voucher_data.days = old_days - 1
form = voucher_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days
voucher_data.days = old_days
# 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"]
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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)
self.assertEqual(response.status_code, 302)
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
# happen in the same day.
with self.app.app_context():
txn_or: Transaction | None = db.session.get(
Transaction, txn_data.id)
self.assertIsNotNone(txn_or)
txn_of: Transaction | None = db.session.get(
Transaction, self.data.t_r_of1.id)
self.assertIsNotNone(txn_of)
self.assertEqual(txn_or.date, txn_of.date)
self.assertLess(txn_or.no, txn_of.no)
voucher_or: Voucher | None = db.session.get(
Voucher, voucher_data.id)
self.assertIsNotNone(voucher_or)
voucher_of: Voucher | None = db.session.get(
Voucher, self.data.v_r_of1.id)
self.assertIsNotNone(voucher_of)
self.assertEqual(voucher_or.date, voucher_of.date)
self.assertLess(voucher_or.no, voucher_of.no)
def test_add_payable_offset(self) -> None:
"""Tests to add the payable offset.
:return: None.
"""
from accounting.models import Account, Transaction
create_uri: str = f"{PREFIX}/create/expense?next=%2F_next"
store_uri: str = f"{PREFIX}/store/expense"
from accounting.models import Account, Voucher
create_uri: str = f"{PREFIX}/create/disbursement?next=%2F_next"
store_uri: str = f"{PREFIX}/store/disbursement"
form: dict[str, str]
response: httpx.Response
txn_data: TransactionData = TransactionData(
self.data.e_p_or3c.txn.days, [CurrencyData(
voucher_data: VoucherData = VoucherData(
self.data.e_p_or3c.voucher.days, [CurrencyData(
"USD",
[JournalEntryData(Accounts.PAYABLE,
self.data.e_p_or1c.summary, "500",
@ -401,14 +407,14 @@ class OffsetTestCase(unittest.TestCase):
[])])
# 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"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# 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-account_code"] = self.data.e_r_or1d.account
form["currency-1-debit-1-amount"] = "100"
@ -421,8 +427,8 @@ class OffsetTestCase(unittest.TestCase):
account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = False
db.session.commit()
response = self.client.post(store_uri,
data=txn_data.new_form(self.csrf_token))
response = self.client.post(
store_uri, data=voucher_data.new_form(self.csrf_token))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
@ -431,7 +437,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
# 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-account_code"] = self.data.e_r_of1c.account
response = self.client.post(store_uri, data=form)
@ -439,52 +445,52 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri)
# 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"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# 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
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# 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"] \
= 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)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# 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"] \
= 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)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not before the original entries
old_days: int = txn_data.days
txn_data.days = old_days + 1
form = txn_data.new_form(self.csrf_token)
old_days: int = voucher_data.days
voucher_data.days = old_days + 1
form = voucher_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
txn_data.days = old_days
voucher_data.days = old_days
# 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)
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():
txn = db.session.get(Transaction, txn_id)
for offset in txn.currencies[0].debit:
voucher = db.session.get(Voucher, voucher_id)
for offset in voucher.currencies[0].debit:
self.assertIsNotNone(offset.original_entry_id)
def test_edit_payable_offset(self) -> None:
@ -492,28 +498,28 @@ class OffsetTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account, Transaction
txn_data: TransactionData = self.data.t_p_of2
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update"
from accounting.models import Account, Voucher
voucher_data: VoucherData = self.data.v_p_of2
edit_uri: str = f"{PREFIX}/{voucher_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{voucher_data.id}/update"
form: dict[str, str]
response: httpx.Response
txn_data.days = self.data.t_p_or2.days
txn_data.currencies[0].debit[0].amount = Decimal("1100")
txn_data.currencies[0].credit[0].amount = Decimal("1100")
txn_data.currencies[0].debit[2].amount = Decimal("900")
txn_data.currencies[0].credit[2].amount = Decimal("900")
voucher_data.days = self.data.v_p_or2.days
voucher_data.currencies[0].debit[0].amount = Decimal("1100")
voucher_data.currencies[0].credit[0].amount = Decimal("1100")
voucher_data.currencies[0].debit[2].amount = Decimal("900")
voucher_data.currencies[0].credit[2].amount = Decimal("900")
# 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"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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-account_code"] = self.data.e_r_or1d.account
form["currency-1-debit-1-amount"] = "100"
@ -527,8 +533,8 @@ class OffsetTestCase(unittest.TestCase):
account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = False
db.session.commit()
response = self.client.post(update_uri,
data=txn_data.update_form(self.csrf_token))
response = self.client.post(
update_uri, data=voucher_data.update_form(self.csrf_token))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context():
@ -537,7 +543,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
# 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-account_code"] = self.data.e_r_of1c.account
response = self.client.post(update_uri, data=form)
@ -545,56 +551,58 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# 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"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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"] \
= 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"] \
= 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)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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"] \
= 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"] \
= 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)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not before the original entries
old_days: int = txn_data.days
txn_data.days = old_days + 1
form = txn_data.update_form(self.csrf_token)
old_days: int = voucher_data.days
voucher_data.days = old_days + 1
form = voucher_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days
voucher_data.days = old_days
# 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)
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():
txn = db.session.get(Transaction, txn_id)
for offset in txn.currencies[0].debit:
voucher = db.session.get(Voucher, voucher_id)
for offset in voucher.currencies[0].debit:
self.assertIsNotNone(offset.original_entry_id)
def test_edit_payable_original_entry(self) -> None:
@ -602,84 +610,86 @@ class OffsetTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Transaction
txn_data: TransactionData = self.data.t_p_or1
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update"
from accounting.models import Voucher
voucher_data: VoucherData = self.data.v_p_or1
edit_uri: str = f"{PREFIX}/{voucher_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{voucher_data.id}/update"
form: dict[str, str]
response: httpx.Response
txn_data.days = self.data.t_p_of1.days
txn_data.currencies[0].debit[0].amount = Decimal("1200")
txn_data.currencies[0].credit[0].amount = Decimal("1200")
txn_data.currencies[0].debit[1].amount = Decimal("0.9")
txn_data.currencies[0].credit[1].amount = Decimal("0.9")
voucher_data.days = self.data.v_p_of1.days
voucher_data.currencies[0].debit[0].amount = Decimal("1200")
voucher_data.currencies[0].credit[0].amount = Decimal("1200")
voucher_data.currencies[0].debit[1].amount = Decimal("0.9")
voucher_data.currencies[0].credit[1].amount = Decimal("0.9")
# 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"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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"] \
= 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"] \
= 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)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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"] \
= 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"] \
= 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)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not after the offset entries
old_days: int = txn_data.days
txn_data.days = old_days - 1
form = txn_data.update_form(self.csrf_token)
old_days: int = voucher_data.days
voucher_data.days = old_days - 1
form = voucher_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days
voucher_data.days = old_days
# 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"]
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# 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)
self.assertEqual(response.status_code, 302)
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
# happen in the same day
with self.app.app_context():
txn_or: Transaction | None = db.session.get(
Transaction, txn_data.id)
self.assertIsNotNone(txn_or)
txn_of: Transaction | None = db.session.get(
Transaction, self.data.t_p_of1.id)
self.assertIsNotNone(txn_of)
self.assertEqual(txn_or.date, txn_of.date)
self.assertLess(txn_or.no, txn_of.no)
voucher_or: Voucher | None = db.session.get(
Voucher, voucher_data.id)
self.assertIsNotNone(voucher_or)
voucher_of: Voucher | None = db.session.get(
Voucher, self.data.v_p_of1.id)
self.assertIsNotNone(voucher_of)
self.assertEqual(voucher_or.date, voucher_of.date)
self.assertLess(voucher_or.no, voucher_of.no)

View File

@ -25,7 +25,7 @@ from flask import Flask
from flask.testing import FlaskCliRunner
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):
@ -41,7 +41,7 @@ class SummeryEditorTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Transaction, \
from accounting.models import BaseAccount, Voucher, \
JournalEntry
result: Result
result = runner.invoke(args="init-db")
@ -55,7 +55,7 @@ class SummeryEditorTestCase(unittest.TestCase):
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Transaction.query.delete()
Voucher.query.delete()
JournalEntry.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
@ -65,9 +65,9 @@ class SummeryEditorTestCase(unittest.TestCase):
: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):
add_txn(self.client, form)
add_voucher(self.client, form)
with self.app.app_context():
editor: SummaryEditor = SummaryEditor()
@ -159,22 +159,22 @@ class SummeryEditorTestCase(unittest.TestCase):
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.
:return: A list of the form data.
"""
txn_date: str = date.today().isoformat()
voucher_date: str = date.today().isoformat()
return [{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"date": voucher_date,
"currency-0-code": "USD",
"currency-0-credit-0-account_code": Accounts.SERVICE,
"currency-0-credit-0-summary": " Salary ",
"currency-0-credit-0-amount": "2500"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"date": voucher_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"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"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"date": voucher_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"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"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"date": voucher_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"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"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"date": voucher_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Airplane—Lake City↔Hill Town ",
"currency-0-debit-0-amount": "800"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"date": voucher_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"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"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"date": voucher_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"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"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"date": voucher_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PETTY_CASH,
"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 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:
@ -41,7 +41,7 @@ class JournalEntryData:
:param amount: The amount.
:param original_entry: The original entry.
"""
self.txn: TransactionData | None = None
self.voucher: VoucherData | None = None
self.id: int = -1
self.no: int = -1
self.original_entry: JournalEntryData | None = original_entry
@ -73,11 +73,11 @@ class JournalEntryData:
class CurrencyData:
"""The transaction currency data."""
"""The voucher currency data."""
def __init__(self, currency: str, debit: list[JournalEntryData],
credit: list[JournalEntryData]):
"""Constructs the transaction currency data.
"""Constructs the voucher currency data.
:param currency: The currency code.
:param debit: The debit journal entries.
@ -104,14 +104,14 @@ class CurrencyData:
return form
class TransactionData:
"""The transaction data."""
class VoucherData:
"""The voucher data."""
def __init__(self, days: int, currencies: list[CurrencyData]):
"""Constructs a transaction.
"""Constructs a voucher.
: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.days: int = days
@ -119,38 +119,38 @@ class TransactionData:
self.note: str | None = None
for currency in self.currencies:
for entry in currency.debit:
entry.txn = self
entry.voucher = self
for entry in currency.credit:
entry.txn = self
entry.voucher = self
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.
:return: The transaction as a form.
:return: The voucher as a creation form.
"""
return self.__form(csrf_token, is_update=False)
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.
:return: The transaction as a form.
:return: The voucher as a update form.
"""
return self.__form(csrf_token, is_update=True)
def __form(self, csrf_token: str, is_update: bool = False) \
-> dict[str, str]:
"""Returns the transaction as a form.
"""Returns the voucher as a form.
:param csrf_token: The CSRF token.
: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,
"next": NEXT_URI,
"date": txn_date.isoformat()}
"date": voucher_date.isoformat()}
for i in range(len(self.currencies)):
form.update(self.currencies[i].form(i + 1, is_update))
if self.note is not None:
@ -205,24 +205,24 @@ class TestData:
self.e_p_or4d, self.e_p_or4c = couple(
"Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
# Original transactions
self.t_r_or1: TransactionData = TransactionData(
# Original vouchers
self.v_r_or1: VoucherData = VoucherData(
50, [CurrencyData("USD", [self.e_r_or1d, self.e_r_or4d],
[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],
[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],
[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],
[self.e_p_or2c, self.e_p_or3c])])
self.__add_txn(self.t_r_or1)
self.__add_txn(self.t_r_or2)
self.__add_txn(self.t_p_or1)
self.__add_txn(self.t_p_or2)
self.__add_voucher(self.v_r_or1)
self.__add_voucher(self.v_r_or2)
self.__add_voucher(self.v_p_or1)
self.__add_voucher(self.v_p_or2)
# Receivable offset entries
self.e_r_of1d, self.e_r_of1c = couple(
@ -258,52 +258,52 @@ class TestData:
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of5d.original_entry = self.e_p_or4c
# Offset transactions
self.t_r_of1: TransactionData = TransactionData(
# Offset vouchers
self.v_r_of1: VoucherData = VoucherData(
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",
[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.t_r_of3: TransactionData = TransactionData(
self.v_r_of3: VoucherData = VoucherData(
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])])
self.t_p_of2: TransactionData = TransactionData(
self.v_p_of2: VoucherData = VoucherData(
10, [CurrencyData("USD",
[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.t_p_of3: TransactionData = TransactionData(
self.v_p_of3: VoucherData = VoucherData(
5, [CurrencyData("USD", [self.e_p_of5d], [self.e_p_of5c])])
self.__add_txn(self.t_r_of1)
self.__add_txn(self.t_r_of2)
self.__add_txn(self.t_r_of3)
self.__add_txn(self.t_p_of1)
self.__add_txn(self.t_p_of2)
self.__add_txn(self.t_p_of3)
self.__add_voucher(self.v_r_of1)
self.__add_voucher(self.v_r_of2)
self.__add_voucher(self.v_r_of3)
self.__add_voucher(self.v_p_of1)
self.__add_voucher(self.v_p_of2)
self.__add_voucher(self.v_p_of3)
def __add_txn(self, txn_data: TransactionData) -> None:
"""Adds a transaction.
def __add_voucher(self, voucher_data: VoucherData) -> None:
"""Adds a voucher.
:param txn_data: The transaction data.
:param voucher_data: The voucher data.
:return: None.
"""
from accounting.models import Transaction
store_uri: str = "/accounting/transactions/store/transfer"
from accounting.models import Voucher
store_uri: str = "/accounting/vouchers/store/transfer"
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
txn_id: int = match_txn_detail(response.headers["Location"])
txn_data.id = txn_id
voucher_id: int = match_voucher_detail(response.headers["Location"])
voucher_data.id = voucher_id
with self.app.app_context():
txn: Transaction | None = db.session.get(Transaction, txn_id)
assert txn is not None
for i in range(len(txn.currencies)):
for j in range(len(txn.currencies[i].debit)):
txn_data.currencies[i].debit[j].id \
= txn.currencies[i].debit[j].id
for j in range(len(txn.currencies[i].credit)):
txn_data.currencies[i].credit[j].id \
= txn.currencies[i].credit[j].id
voucher: Voucher | None = db.session.get(Voucher, voucher_id)
assert voucher is not None
for i in range(len(voucher.currencies)):
for j in range(len(voucher.currencies[i].debit)):
voucher_data.currencies[i].debit[j].id \
= voucher.currencies[i].debit[j].id
for j in range(len(voucher.currencies[i].credit)):
voucher_data.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.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The common test libraries for the transaction test cases.
"""The common test libraries for the voucher test cases.
"""
import re
@ -57,10 +57,10 @@ class Accounts:
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.
:return: The form data to add a new transaction.
:return: The form data to add a new voucher.
"""
return {"csrf_token": csrf_token,
"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 "}
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]:
"""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.
:param txn_id: The transaction ID.
:param voucher_id: The voucher ID.
:param app: The Flask application.
: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.
"""
from accounting.models import Transaction, TransactionCurrency
from accounting.models import Voucher, VoucherCurrency
with app.app_context():
txn: Transaction | None = db.session.get(Transaction, txn_id)
assert txn is not None
currencies: list[TransactionCurrency] = txn.currencies
voucher: Voucher | None = db.session.get(Voucher, voucher_id)
assert voucher is not None
currencies: list[VoucherCurrency] = voucher.currencies
form: dict[str, str] = {"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn.date,
"note": " \n \n\n " if txn.note is None
else f"\n \n\n \n \n{txn.note} \n\n "}
"date": voucher.date,
"note": " \n \n\n " if voucher.note is None
else f"\n \n\n \n \n{voucher.note} \n\n "}
currency_indices_used: set[int] = set()
currency_no: int = 0
for currency in currencies:
@ -200,21 +200,21 @@ def __get_new_index(indices_used: set[int]) -> int:
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]:
"""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.
:param txn_id: The transaction ID.
:param voucher_id: The voucher ID.
:param app: The Flask application.
:param csrf_token: The CSRF token.
:param is_debit: True for a cash expense transaction, False for a cash
income transaction, or None for a transfer transaction
:return: The form data to update the transaction, where the data are
:param is_debit: True for a cash disbursement voucher, False for a cash
receipt voucher, or None for a transfer voucher
:return: The form data to update the voucher, where the data are
changed.
"""
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
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
m: re.Match
# Remove the office expense
# Remove the office disbursement
key = [x for x in form
if x.startswith(currency_prefix)
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)
amount: Decimal = Decimal(form[f"{entry_prefix}amount"])
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()
for key in form:
m = re.match(r"^.+-(\d+)-amount$", key)
@ -279,7 +279,7 @@ def __mess_up_credit(form: dict[str, str], currency_prefix: str) \
key: str
m: re.Match
# Remove the sales income
# Remove the sales receipt
key = [x for x in form
if x.startswith(currency_prefix)
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)
amount: Decimal = Decimal(form[f"{entry_prefix}amount"])
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()
for key in form:
m = re.match(r"^.+-(\d+)-amount$", key)
@ -389,36 +389,35 @@ def __get_currency_prefix(form: dict[str, str], code: str) -> str:
return m.group(1)
def add_txn(client: httpx.Client, form: dict[str, str]) -> int:
"""Adds a transfer transaction.
def add_voucher(client: httpx.Client, form: dict[str, str]) -> int:
"""Adds a transfer voucher.
:param client: The client.
:param form: The form data.
:return: The newly-added transaction ID.
:return: The newly-added voucher ID.
"""
prefix: str = "/accounting/transactions"
txn_type: str = "transfer"
prefix: str = "/accounting/vouchers"
voucher_type: str = "transfer"
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:
txn_type = "expense"
store_uri = f"{prefix}/store/{txn_type}"
voucher_type = "disbursement"
store_uri = f"{prefix}/store/{voucher_type}"
response: httpx.Response = client.post(store_uri, data=form)
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:
"""Validates if the redirect location is the transaction detail, and
returns the transaction ID on success.
def match_voucher_detail(location: str) -> int:
"""Validates if the redirect location is the voucher detail, and
returns the voucher ID on success.
:param location: The redirect location.
:return: The transaction ID.
:raise AssertionError: When the location is not the transaction detail.
:return: The voucher ID.
:raise AssertionError: When the location is not the voucher detail.
"""
m: re.Match = re.match(
r"^/accounting/transactions/(\d+)\?next=%2F_next",
location)
m: re.Match = re.match(r"^/accounting/vouchers/(\d+)\?next=%2F_next",
location)
assert m is not None
return int(m.group(1))