Added to track the net balance and offset of the original entries.
This commit is contained in:
@ -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
|
||||
|
@ -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()])
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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."""
|
||||
|
@ -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.
|
||||
|
39
src/accounting/transaction/utils/offset_alias.py
Normal file
39
src/accounting/transaction/utils/offset_alias.py
Normal 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"))
|
82
src/accounting/transaction/utils/original_entries.py
Normal file
82
src/accounting/transaction/utils/original_entries.py
Normal 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
|
@ -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] \
|
||||
|
@ -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()))
|
||||
|
Reference in New Issue
Block a user