Added to track the net balance and offset of the original entries.

This commit is contained in:
2023-03-17 22:32:01 +08:00
parent 40e329d37f
commit d88b3ac770
38 changed files with 3103 additions and 183 deletions

View File

@ -20,10 +20,10 @@
from datetime import date
from flask import abort
from sqlalchemy.orm import selectinload
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import Transaction
from accounting.models import Transaction, JournalEntry
from accounting.utils.txn_types import TransactionType
@ -37,7 +37,13 @@ class TransactionConverter(BaseConverter):
:param value: The transaction ID.
:return: The corresponding transaction.
"""
transaction: Transaction | None = db.session.get(Transaction, value)
transaction: Transaction | None = Transaction.query\
.join(JournalEntry)\
.filter(Transaction.id == value)\
.options(selectinload(Transaction.entries)
.selectinload(JournalEntry.offsets)
.selectinload(JournalEntry.transaction))\
.first()
if transaction is None:
abort(404)
return transaction

View File

@ -19,6 +19,7 @@
"""
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError, FieldList, IntegerField, \
@ -27,9 +28,11 @@ from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.models import Currency, JournalEntry
from accounting.transaction.utils.offset_alias import offset_alias
from accounting.utils.cast import be
from accounting.utils.strip_text import strip_text
from .journal_entry import CreditEntryForm, DebitEntryForm
from .journal_entry import JournalEntryForm, CreditEntryForm, DebitEntryForm
CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency."))
@ -47,6 +50,50 @@ class CurrencyExists:
"The currency does not exist."))
class SameCurrencyAsOriginalEntries:
"""The validator to check if the currency is the same as the original
entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
if field.data is None:
return
original_entry_id: set[int] = {x.original_entry_id.data
for x in form.entries
if x.original_entry_id.data is not None}
if len(original_entry_id) == 0:
return
original_entry_currency_codes: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.filter(JournalEntry.id.in_(original_entry_id))).all())
for currency_code in original_entry_currency_codes:
if field.data != currency_code:
raise ValidationError(lazy_gettext(
"The currency must be the same as the original entry."))
class KeepCurrencyWhenHavingOffset:
"""The validator to check if the currency is the same when there is
offset."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
if field.data is None:
return
offset: sa.Alias = offset_alias()
original_entries: list[JournalEntry] = JournalEntry.query\
.join(offset, be(JournalEntry.id == offset.c.original_entry_id),
isouter=True)\
.filter(JournalEntry.id.in_({x.eid.data for x in form.entries
if x.eid.data is not None}))\
.group_by(JournalEntry.id, JournalEntry.currency_code)\
.having(sa.func.count(offset.c.id) > 0).all()
for original_entry in original_entries:
if original_entry.currency_code != field.data:
raise ValidationError(lazy_gettext(
"The currency must not be changed when there is offset."))
class NeedSomeJournalEntries:
"""The validator to check if there is any journal entry sub-form."""
@ -78,6 +125,41 @@ class CurrencyForm(FlaskForm):
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def entries(self) -> list[JournalEntryForm]:
"""Returns the journal entry sub-forms.
:return: The journal entry sub-forms.
"""
entry_forms: list[JournalEntryForm] = []
if isinstance(self, IncomeCurrencyForm):
entry_forms.extend([x.form for x in self.credit])
elif isinstance(self, ExpenseCurrencyForm):
entry_forms.extend([x.form for x in self.debit])
elif isinstance(self, TransferCurrencyForm):
entry_forms.extend([x.form for x in self.debit])
entry_forms.extend([x.form for x in self.credit])
return entry_forms
@property
def is_code_locked(self) -> bool:
"""Returns whether the currency code should not be changed.
:return: True if the currency code should not be changed, or False
otherwise
"""
entry_forms: list[JournalEntryForm] = self.entries
original_entry_id: set[int] \
= {x.original_entry_id.data for x in entry_forms
if x.original_entry_id.data is not None}
if len(original_entry_id) > 0:
return True
entry_id: set[int] = {x.eid.data for x in entry_forms
if x.eid.data is not None}
select: sa.Select = sa.select(sa.func.count(JournalEntry.id))\
.filter(JournalEntry.original_entry_id.in_(entry_id))
return db.session.scalar(select) > 0
class IncomeCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash income transaction."""
@ -86,7 +168,9 @@ class IncomeCurrencyForm(CurrencyForm):
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists()])
CurrencyExists(),
SameCurrencyAsOriginalEntries(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
@ -121,7 +205,9 @@ class ExpenseCurrencyForm(CurrencyForm):
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists()])
CurrencyExists(),
SameCurrencyAsOriginalEntries(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
@ -156,7 +242,9 @@ class TransferCurrencyForm(CurrencyForm):
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists()])
CurrencyExists(),
SameCurrencyAsOriginalEntries(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])

View File

@ -18,14 +18,21 @@
"""
import re
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from sqlalchemy.orm import selectinload
from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import DataRequired
from wtforms.validators import DataRequired, Optional
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry
from accounting.template_filters import format_amount
from accounting.utils.cast import be
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
@ -35,6 +42,66 @@ ACCOUNT_REQUIRED: DataRequired = DataRequired(
"""The validator to check if the account code is empty."""
class OriginalEntryExists:
"""The validator to check if the original entry exists."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
if db.session.get(JournalEntry, field.data) is None:
raise ValidationError(lazy_gettext(
"The original entry does not exist."))
class OriginalEntryOppositeSide:
"""The validator to check if the original entry is on the opposite side."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, field.data)
if original_entry is None:
return
if isinstance(form, CreditEntryForm) and original_entry.is_debit:
return
if isinstance(form, DebitEntryForm) and not original_entry.is_debit:
return
raise ValidationError(lazy_gettext(
"The original entry is on the same side."))
class OriginalEntryNeedOffset:
"""The validator to check if the original entry needs offset."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, field.data)
if original_entry is None:
return
if not original_entry.account.is_offset_needed:
raise ValidationError(lazy_gettext(
"The original entry does not need offset."))
class OriginalEntryNotOffset:
"""The validator to check if the original entry is not itself an offset
entry."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, field.data)
if original_entry is None:
return
if original_entry.original_entry_id is not None:
raise ValidationError(lazy_gettext(
"The original entry cannot be an offset entry."))
class AccountExists:
"""The validator to check if the account exists."""
@ -74,6 +141,72 @@ class IsCreditAccount:
"This account is not for credit entries."))
class SameAccountAsOriginalEntry:
"""The validator to check if the account is the same as the original
entry."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.original_entry_id.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, form.original_entry_id.data)
if original_entry is None:
return
if field.data != original_entry.account_code:
raise ValidationError(lazy_gettext(
"The account must be the same as the original entry."))
class KeepAccountWhenHavingOffset:
"""The validator to check if the account is the same when having offset."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.eid.data is None:
return
entry: JournalEntry | None = db.session.query(JournalEntry)\
.filter(JournalEntry.id == form.eid.data)\
.options(selectinload(JournalEntry.offsets)).first()
if entry is None or len(entry.offsets) == 0:
return
if field.data != entry.account_code:
raise ValidationError(lazy_gettext(
"The account must not be changed when there is offset."))
class NotStartPayableFromDebit:
"""The validator to check that a payable journal entry does not start from
the debit side."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, DebitEntryForm)
if field.data is None \
or field.data[0] != "2" \
or form.original_entry_id.data is not None:
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_offset_needed:
raise ValidationError(lazy_gettext(
"A payable entry cannot start from the debit side."))
class NotStartReceivableFromCredit:
"""The validator to check that a receivable journal entry does not start
from the credit side."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CreditEntryForm)
if field.data is None \
or field.data[0] != "1" \
or form.original_entry_id.data is not None:
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_offset_needed:
raise ValidationError(lazy_gettext(
"A receivable entry cannot start from the credit side."))
class PositiveAmount:
"""The validator to check if the amount is positive."""
@ -85,17 +218,86 @@ class PositiveAmount:
"Please fill in a positive amount."))
class NotExceedingOriginalEntryNetBalance:
"""The validator to check if the amount exceeds the net balance of the
original entry."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.original_entry_id.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, form.original_entry_id.data)
if original_entry is None:
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}
offset_total_func: sa.Function = sa.func.sum(sa.case(
(be(JournalEntry.is_debit == is_debit), JournalEntry.amount),
else_=-JournalEntry.amount))
offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func)
.filter(be(JournalEntry.original_entry_id == original_entry.id),
JournalEntry.id.not_in(existing_entry_id)))
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
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 \
- offset_total_on_form
if field.data > net_balance:
raise ValidationError(lazy_gettext(
"The amount must not exceed the net balance %(balance)s of the"
" original entry.", balance=format_amount(net_balance)))
class NotLessThanOffsetTotal:
"""The validator to check if the amount is less than the offset total."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.eid.data is None:
return
is_debit: bool = isinstance(form, DebitEntryForm)
select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
(JournalEntry.is_debit != is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)))\
.filter(be(JournalEntry.original_entry_id == form.eid.data))
offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext(
"The amount must not be less than the offset total %(total)s.",
total=format_amount(offset_total)))
class JournalEntryForm(FlaskForm):
"""The base form to create or edit a journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
original_entry_id = IntegerField()
"""The Id of the original entry."""
account_code = StringField()
"""The account code."""
amount = DecimalField()
"""The amount."""
def __init__(self, *args, **kwargs):
"""Constructs a base transaction 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."""
@property
def account_text(self) -> str:
"""Returns the text representation of the account.
@ -109,6 +311,124 @@ class JournalEntryForm(FlaskForm):
return ""
return str(account)
@property
def __original_entry(self) -> JournalEntry | None:
"""Returns the original entry.
:return: The original entry.
"""
if not hasattr(self, "____original_entry"):
def get_entry() -> JournalEntry | None:
if self.original_entry_id.data is None:
return None
return db.session.get(JournalEntry,
self.original_entry_id.data)
setattr(self, "____original_entry", get_entry())
return getattr(self, "____original_entry")
@property
def original_entry_date(self) -> date | None:
"""Returns the text representation of the original entry.
:return: The text representation of the original entry.
"""
return None if self.__original_entry is None \
else self.__original_entry.transaction.date
@property
def original_entry_text(self) -> str | None:
"""Returns the text representation of the original entry.
:return: The text representation of the original entry.
"""
return None if self.__original_entry is None \
else str(self.__original_entry)
@property
def __entry(self) -> JournalEntry | None:
"""Returns the journal entry.
:return: The journal entry.
"""
if not hasattr(self, "____entry"):
def get_entry() -> JournalEntry | None:
if self.eid.data is None:
return None
return JournalEntry.query\
.filter(JournalEntry.id == self.eid.data)\
.options(selectinload(JournalEntry.transaction),
selectinload(JournalEntry.account),
selectinload(JournalEntry.offsets)
.selectinload(JournalEntry.transaction))\
.first()
setattr(self, "____entry", get_entry())
return getattr(self, "____entry")
@property
def is_original_entry(self) -> bool:
"""Returns whether the entry is an original entry.
:return: True if the entry is an original entry, or False otherwise.
"""
if self.account_code.data is None:
return False
if self.account_code.data[0] == "1":
if isinstance(self, CreditEntryForm):
return False
elif self.account_code.data[0] == "2":
if isinstance(self, DebitEntryForm):
return False
else:
return False
account: Account | None = Account.find_by_code(self.account_code.data)
return account is not None and account.is_offset_needed
@property
def offsets(self) -> list[JournalEntry]:
"""Returns the offsets.
:return: The offsets.
"""
if not hasattr(self, "__offsets"):
def get_offsets() -> list[JournalEntry]:
if not self.is_original_entry or self.eid.data is None:
return []
return JournalEntry.query\
.filter(JournalEntry.original_entry_id == self.eid.data)\
.options(selectinload(JournalEntry.transaction),
selectinload(JournalEntry.account),
selectinload(JournalEntry.offsets)
.selectinload(JournalEntry.transaction)).all()
setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets")
@property
def offset_total(self) -> Decimal | None:
"""Returns the total amount of the offsets.
:return: The total amount of the offsets.
"""
if not hasattr(self, "__offset_total"):
def get_offset_total():
if not self.is_original_entry or self.eid.data is None:
return None
is_debit: bool = isinstance(self, DebitEntryForm)
return sum([x.amount if x.is_debit != is_debit else -x.amount
for x in self.offsets])
setattr(self, "__offset_total", get_offset_total())
return getattr(self, "__offset_total")
@property
def net_balance(self) -> Decimal | None:
"""Returns the net balance.
:return: The net balance.
"""
if not self.is_original_entry or self.eid.data is None \
or self.amount.data is None:
return None
return self.amount.data - self.offset_total
@property
def all_errors(self) -> list[str | LazyString]:
"""Returns all the errors of the form.
@ -128,15 +448,30 @@ class DebitEntryForm(JournalEntryForm):
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
original_entry_id = IntegerField(
validators=[Optional(),
OriginalEntryExists(),
OriginalEntryOppositeSide(),
OriginalEntryNeedOffset(),
OriginalEntryNotOffset()])
"""The Id of the original entry."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount()])
IsDebitAccount(),
SameAccountAsOriginalEntry(),
KeepAccountWhenHavingOffset(),
NotStartPayableFromDebit()])
"""The account code."""
offset_original_entry_id = IntegerField()
"""The Id of the original entry."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalEntryNetBalance(),
NotLessThanOffsetTotal()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
@ -148,6 +483,7 @@ class DebitEntryForm(JournalEntryForm):
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.original_entry_id = self.original_entry_id.data
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = True
@ -164,15 +500,28 @@ class CreditEntryForm(JournalEntryForm):
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
original_entry_id = IntegerField(
validators=[Optional(),
OriginalEntryExists(),
OriginalEntryOppositeSide(),
OriginalEntryNeedOffset(),
OriginalEntryNotOffset()])
"""The Id of the original entry."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount()])
IsCreditAccount(),
SameAccountAsOriginalEntry(),
KeepAccountWhenHavingOffset(),
NotStartReceivableFromCredit()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalEntryNetBalance(),
NotLessThanOffsetTotal()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
@ -184,6 +533,7 @@ class CreditEntryForm(JournalEntryForm):
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.original_entry_id = self.original_entry_id.data
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = False

View File

@ -19,13 +19,14 @@
"""
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:
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.
@ -33,9 +34,11 @@ def sort_transactions_in(txn_date: date, exclude: int) -> None:
: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(Transaction.date == txn_date,
Transaction.id != exclude)\
.filter(*conditions)\
.order_by(Transaction.no).all()
for i in range(len(transactions)):
if transactions[i].no != i + 1:

View File

@ -17,14 +17,15 @@
"""The transaction forms for the transaction management.
"""
import datetime as dt
import typing as t
from abc import ABC, abstractmethod
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import DateField, FieldList, FormField, \
TextAreaField
from wtforms import DateField, FieldList, FormField, TextAreaField, \
BooleanField
from wtforms.validators import DataRequired, ValidationError
from accounting import db
@ -32,6 +33,8 @@ 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 \
get_selectable_original_entries
from accounting.transaction.utils.summary_editor import SummaryEditor
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text
@ -46,6 +49,37 @@ DATE_REQUIRED: DataRequired = DataRequired(
"""The validator to check if the date is empty."""
class NotBeforeOriginalEntries:
"""The validator to check if the date is not before the original
entries."""
def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, TransactionForm)
if field.data is None:
return
min_date: dt.date | None = form.min_date
if min_date is None:
return
if field.data < min_date:
raise ValidationError(lazy_gettext(
"The date cannot be earlier than the original entries."))
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)
if field.data is None:
return
max_date: dt.date | None = form.max_date
if max_date is None:
return
if field.data > max_date:
raise ValidationError(lazy_gettext(
"The date cannot be later than the offset entries."))
class NeedSomeCurrencies:
"""The validator to check if there is any currency sub-form."""
@ -54,6 +88,23 @@ class NeedSomeCurrencies:
raise ValidationError(lazy_gettext("Please add some currencies."))
class CannotDeleteOriginalEntriesWithOffset:
"""The validator to check the original entries with offset."""
def __call__(self, form: FlaskForm, field: FieldList) -> None:
assert isinstance(form, TransactionForm)
if form.obj is None:
return
existing_matched_original_entry_id: set[int] \
= {x.id for x in form.obj.entries if len(x.offsets) > 0}
entry_id_in_form: set[int] \
= {x.eid.data for x in form.entries if x.eid.data is not None}
for entry_id in existing_matched_original_entry_id:
if entry_id not in entry_id_in_form:
raise ValidationError(lazy_gettext(
"Journal entries with offset cannot be deleted."))
class TransactionForm(FlaskForm):
"""The base form to create or edit a transaction."""
date = DateField()
@ -76,6 +127,19 @@ class TransactionForm(FlaskForm):
"""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._is_payable_needed: bool = False
"""Whether we need the payable original entries."""
self._is_receivable_needed: bool = False
"""Whether we need the receivable original entries."""
self.__original_entry_options: list[JournalEntry] | None = None
"""The options of the original entries."""
self.__net_balance_exceeded: dict[int, LazyString] | None = None
"""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
def populate_obj(self, obj: Transaction) -> None:
"""Populates the form data into a transaction object.
@ -86,6 +150,7 @@ class TransactionForm(FlaskForm):
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(Transaction)
self.date: DateField
self.__set_date(obj, self.date.data)
obj.note = self.note.data
@ -107,8 +172,18 @@ class TransactionForm(FlaskForm):
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
@staticmethod
def __set_date(obj: Transaction, new_date: date) -> None:
@property
def entries(self) -> list[JournalEntryForm]:
"""Collects and returns the journal entry sub-forms.
:return: The journal entry sub-forms.
"""
entries: list[JournalEntryForm] = []
for currency in self.currencies:
entries.extend(currency.entries)
return entries
def __set_date(self, obj: Transaction, new_date: dt.date) -> None:
"""Sets the transaction date and number.
:param obj: The transaction object.
@ -118,11 +193,23 @@ class TransactionForm(FlaskForm):
if obj.date is None or obj.date != new_date:
if obj.date is not None:
sort_transactions_in(obj.date, obj.id)
sort_transactions_in(new_date, obj.id)
count: int = Transaction.query\
.filter(Transaction.date == new_date).count()
obj.date = new_date
obj.no = count + 1
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))
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)
else:
sort_transactions_in(new_date, obj.id)
count: int = Transaction.query\
.filter(Transaction.date == new_date).count()
obj.date = new_date
obj.no = count + 1
@property
def debit_account_options(self) -> list[AccountOption]:
@ -131,7 +218,8 @@ class TransactionForm(FlaskForm):
:return: The selectable debit accounts.
"""
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.debit()]
= [AccountOption(x) for x in Account.debit()
if not (x.code[0] == "2" and x.is_offset_needed)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(JournalEntry.is_debit)
@ -147,7 +235,8 @@ class TransactionForm(FlaskForm):
:return: The selectable credit accounts.
"""
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.credit()]
= [AccountOption(x) for x in Account.credit()
if not (x.code[0] == "1" and x.is_offset_needed)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(sa.not_(JournalEntry.is_debit))
@ -173,6 +262,46 @@ class TransactionForm(FlaskForm):
"""
return SummaryEditor()
@property
def original_entry_options(self) -> list[JournalEntry]:
"""Returns the selectable original entries.
:return: The selectable original entries.
"""
if self.__original_entry_options is None:
self.__original_entry_options = get_selectable_original_entries(
{x.eid.data for x in self.entries if x.eid.data is not None},
self._is_payable_needed, self._is_receivable_needed)
return self.__original_entry_options
@property
def min_date(self) -> dt.date | None:
"""Returns the minimal available date.
:return: The minimal available date.
"""
original_entry_id: set[int] \
= {x.original_entry_id.data for x in self.entries
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))\
.join(JournalEntry).filter(JournalEntry.id.in_(original_entry_id))
return db.session.scalar(select)
@property
def max_date(self) -> dt.date | None:
"""Returns the maximum available date.
:return: The maximum available date.
"""
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))\
.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."""
@ -314,16 +443,23 @@ class JournalEntryCollector(t.Generic[T], ABC):
class IncomeTransactionForm(TransactionForm):
"""The form to create or edit a cash income transaction."""
date = DateField(validators=[DATE_REQUIRED])
date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(),
NotAfterOffsetEntries()])
"""The date."""
currencies = FieldList(FormField(IncomeCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalEntriesWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_receivable_needed = True
class Collector(JournalEntryCollector[IncomeTransactionForm]):
"""The journal entry collector for the cash income transactions."""
@ -352,16 +488,23 @@ class IncomeTransactionForm(TransactionForm):
class ExpenseTransactionForm(TransactionForm):
"""The form to create or edit a cash expense transaction."""
date = DateField(validators=[DATE_REQUIRED])
date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(),
NotAfterOffsetEntries()])
"""The date."""
currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalEntriesWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_payable_needed = True
class Collector(JournalEntryCollector[ExpenseTransactionForm]):
"""The journal entry collector for the cash expense
@ -391,16 +534,24 @@ class ExpenseTransactionForm(TransactionForm):
class TransferTransactionForm(TransactionForm):
"""The form to create or edit a transfer transaction."""
date = DateField(validators=[DATE_REQUIRED])
date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(),
NotAfterOffsetEntries()])
"""The date."""
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalEntriesWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_payable_needed = True
self._is_receivable_needed = True
class Collector(JournalEntryCollector[TransferTransactionForm]):
"""The journal entry collector for the transfer transactions."""

View File

@ -38,6 +38,10 @@ class AccountOption:
"""The string representation of the account option."""
self.is_in_use: bool = False
"""True if this account is in use, or False otherwise."""
self.is_offset_needed: bool = account.is_offset_needed
"""True if this account needs offset, or False otherwise."""
self.is_offset_chooser_needed: bool = False
"""True if this account needs an offset chooser, or False otherwise."""
def __str__(self) -> str:
"""Returns the string representation of the account option.

View File

@ -0,0 +1,39 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/15
# 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 SQLAlchemy alias for the offset entries.
"""
import typing as t
import sqlalchemy as sa
from accounting.models import JournalEntry
def offset_alias() -> sa.Alias:
"""Returns the SQLAlchemy alias for the offset entries.
:return: The SQLAlchemy alias for the offset entries.
"""
def as_from(model_cls: t.Any) -> sa.FromClause:
return model_cls
def as_alias(alias: t.Any) -> sa.Alias:
return alias
return as_alias(sa.alias(as_from(JournalEntry), name="offset"))

View File

@ -0,0 +1,82 @@
# 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 selectable original entries.
"""
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
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.utils.cast import be
from .offset_alias import offset_alias
def get_selectable_original_entries(
entry_id_on_form: set[int], is_payable: bool, is_receivable: bool) \
-> list[JournalEntry]:
"""Queries and returns the selectable original entries, with their net
balances. The offset amounts of the form is excluded.
:param entry_id_on_form: The ID of the journal entries on the form.
:param is_payable: True to check the payable original entries, or False
otherwise.
:param is_receivable: True to check the receivable original entries, or
False otherwise.
:return: The selectable original entries, with their net balances.
"""
assert is_payable or is_receivable
offset: sa.Alias = offset_alias()
net_balance: sa.Label = (JournalEntry.amount + sa.func.sum(sa.case(
(offset.c.id.in_(entry_id_on_form), 0),
(be(offset.c.is_debit == JournalEntry.is_debit), offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_offset_needed]
sub_conditions: list[sa.BinaryExpression] = []
if is_payable:
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntry.is_debit)))
if is_receivable:
sub_conditions.append(sa.and_(Account.base_code.startswith("1"),
JournalEntry.is_debit))
conditions.append(sa.or_(*sub_conditions))
select_net_balances: sa.Select = sa.select(JournalEntry.id, net_balance)\
.join(Account)\
.join(offset, be(JournalEntry.id == offset.c.original_entry_id),
isouter=True)\
.filter(*conditions)\
.group_by(JournalEntry.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
net_balances: dict[int, Decimal] \
= {x.id: x.net_balance
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)\
.options(selectinload(JournalEntry.currency),
selectinload(JournalEntry.account),
selectinload(JournalEntry.transaction)).all()
for entry in entries:
entry.net_balance = entry.amount if net_balances[entry.id] is None \
else net_balances[entry.id]
return entries

View File

@ -218,7 +218,8 @@ class SummaryEditor:
JournalEntry.account_id,
sa.func.count().label("freq"))\
.filter(JournalEntry.summary.is_not(None),
JournalEntry.summary.like("_%—_%"))\
JournalEntry.summary.like("_%—_%"),
JournalEntry.original_entry_id.is_(None))\
.group_by(entry_type, tag_type, tag, JournalEntry.account_id)
result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \

View File

@ -117,6 +117,7 @@ def show_transaction_edit_form(txn: Transaction) -> str:
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)
@ -134,6 +135,7 @@ def update_transaction(txn: Transaction) -> redirect:
"""
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()))