22 Commits

Author SHA1 Message Date
8e5377a416 Replaced the payable account with the petty-cash account in the SummeryEditorTestCase test case. 2023-03-12 01:34:47 +08:00
4299fd6fbd Revised the code in the JavaScript initializeBaseAccountSelector function in the account form. 2023-03-12 01:34:45 +08:00
1d6a53f7cd Revised the account form so that the if-offset-needed option is only available for real accounts. 2023-03-12 01:34:42 +08:00
bb2993b0c0 Reordered the code in the "accounting.transaction.forms.journal_entry" module. 2023-03-11 20:36:38 +08:00
f6946c1165 Revised the IsBalanced validator so that it no longer need the __future__ annotation. 2023-03-11 19:10:47 +08:00
8e219d8006 Fixed the type hint of the form parameter in the NeedSomeJournalEntries validator. 2023-03-11 19:10:44 +08:00
53565eb9e6 Changed the IsBalanced validator from an inner class inside the TransferCurrencyForm form to an independent class. 2023-03-11 19:10:42 +08:00
965e78d8ad Revised the rule for the accounts that need offset in the accounting-init-accounts console command. 2023-03-11 17:15:08 +08:00
74b81d3e23 Renamed the offset_original_id column to original_entry_id, and the offset_original relationship to original_entry in the JournalEntry data model. 2023-03-11 16:34:30 +08:00
a0fba6387f Added the order to the search report. 2023-03-11 16:34:30 +08:00
d28bdf2064 Revised the parameter order in the template of the currency sub-form of the transaction form. 2023-03-11 16:34:29 +08:00
edf0c00e34 Shortened the names of the #filterAccountOptions, #getAccountCodeUsedInForm, and #shouldAccountOptionShow methods to #filterOptions, #getCodesUsedInForm, and #shouldOptionShow, respectively, in the JavaScript AccountSelector class. 2023-03-11 16:34:29 +08:00
107d161379 Removed a debug output from the JavaScript AccountSelector class. 2023-03-11 16:34:29 +08:00
f2c184f769 Rewrote the JavaScript AccountSelector to store the page elements in the object. 2023-03-11 16:34:28 +08:00
b45986ecfc Fixed the parameter type for the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
a2c2452ec5 Added a missing blank line to the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
5194258b48 Removed the redundant #init method from the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
3fe7eb41ac Removed the unused "__in_use_account_id" property from the TransactionForm form. 2023-03-11 16:34:28 +08:00
7fb9e2f0a1 Added missing documentation to the OptionLink data model in the "accounting.report.utils.option_link" module. 2023-03-11 16:34:28 +08:00
1d443f7b76 Renamed the "accounting.transaction.form" module to "accounting.transaction.forms". It only contains forms now. 2023-03-11 16:34:28 +08:00
6ad4fba9cd Moved the "accounting.transaction.operators", "accounting.transaction.summary_editor" and "accounting.transaction.form.account_option" modules into the "accounting.transaction.utils" module. 2023-03-11 16:34:28 +08:00
3dda6531b5 Split the "accounting.transaction.forms" module into various submodules in the "accounting.transaction.form" module. 2023-03-11 16:33:51 +08:00
22 changed files with 828 additions and 568 deletions

View File

@ -18,7 +18,6 @@
""" """
import os import os
import re
from secrets import randbelow from secrets import randbelow
import click import click
@ -93,14 +92,36 @@ def init_accounts_command(username: str) -> None:
data: list[AccountData] = [] data: list[AccountData] = []
for base in bases_to_add: for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n} l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \ is_offset_needed: bool = __is_offset_needed(base.code)
else False
data.append((get_new_id(), base.code, 1, base.title_l10n, data.append((get_new_id(), base.code, 1, base.title_l10n,
l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed)) l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed))
__add_accounting_accounts(data, creator_pk) __add_accounting_accounts(data, creator_pk)
click.echo(F"{len(data)} added. Accounting accounts initialized.") click.echo(F"{len(data)} added. Accounting accounts initialized.")
def __is_offset_needed(base_code: str) -> bool:
"""Checks that whether entries in the account need offset.
:param base_code: The code of the base account.
:return: True if entries in the account need offset, or False otherwise.
"""
# Assets
if base_code[0] == "1":
if base_code[:3] in {"113", "114", "118", "184"}:
return True
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
"1581", "1611", "1851", ""}:
return True
return False
# Liabilities
if base_code[0] == "2":
if base_code in {"2111", "2114", "2284", "2293"}:
return False
return True
# Only assets and liabilities need offset
return False
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
-> None: -> None:
"""Adds the accounts. """Adds the accounts.

View File

@ -53,6 +53,21 @@ class BaseAccountAvailable:
"The base account is not available.")) "The base account is not available."))
class NoOffsetNominalAccount:
"""The validator to check nominal account is not to be offset."""
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
if not field.data:
return
if not isinstance(form, AccountForm):
return
if form.base_code.data is None:
return
if form.base_code.data[0] not in {"1", "2"}:
raise ValidationError(lazy_gettext(
"A nominal account does not need offset."))
class AccountForm(FlaskForm): class AccountForm(FlaskForm):
"""The form to create or edit an account.""" """The form to create or edit an account."""
base_code = StringField( base_code = StringField(
@ -66,7 +81,8 @@ class AccountForm(FlaskForm):
filters=[strip_text], filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the title"))]) validators=[DataRequired(lazy_gettext("Please fill in the title"))])
"""The title.""" """The title."""
is_offset_needed = BooleanField() is_offset_needed = BooleanField(
validators=[NoOffsetNominalAccount()])
"""Whether the the entries of this account need offset.""" """Whether the the entries of this account need offset."""
def populate_obj(self, obj: Account) -> None: def populate_obj(self, obj: Account) -> None:
@ -87,7 +103,10 @@ class AccountForm(FlaskForm):
obj.base_code = self.base_code.data obj.base_code = self.base_code.data
obj.no = count + 1 obj.no = count + 1
obj.title = self.title.data obj.title = self.title.data
if self.base_code.data[0] in {"1", "2"}:
obj.is_offset_needed = self.is_offset_needed.data obj.is_offset_needed = self.is_offset_needed.data
else:
obj.is_offset_needed = False
if is_new: if is_new:
current_user_pk: int = get_current_user_pk() current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk obj.created_by_id = current_user_pk

View File

@ -597,14 +597,14 @@ class JournalEntry(db.Model):
"""True for a debit entry, or False for a credit entry.""" """True for a debit entry, or False for a credit entry."""
no = db.Column(db.Integer, nullable=False) no = db.Column(db.Integer, nullable=False)
"""The entry number under the transaction and debit or credit.""" """The entry number under the transaction and debit or credit."""
offset_original_id = db.Column(db.Integer, original_entry_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"), db.ForeignKey(id, onupdate="CASCADE"),
nullable=True) nullable=True)
"""The ID of the original entry to offset.""" """The ID of the original entry."""
offset_original = db.relationship("JournalEntry", back_populates="offsets", original_entry = db.relationship("JournalEntry", back_populates="offsets",
remote_side=id, passive_deletes=True) remote_side=id, passive_deletes=True)
"""The original entry to offset.""" """The original entry."""
offsets = db.relationship("JournalEntry", back_populates="offset_original") offsets = db.relationship("JournalEntry", back_populates="original_entry")
"""The offset entries.""" """The offset entries."""
currency_code = db.Column(db.String, currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"), db.ForeignKey(Currency.code, onupdate="CASCADE"),

View File

@ -68,7 +68,10 @@ class EntryCollector:
except ArithmeticError: except ArithmeticError:
pass pass
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
return JournalEntry.query.filter(*conditions)\ return JournalEntry.query.join(Transaction).filter(*conditions)\
.order_by(Transaction.date,
JournalEntry.is_debit,
JournalEntry.no)\
.options(selectinload(JournalEntry.account), .options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency), selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all() selectinload(JournalEntry.transaction)).all()

View File

@ -27,10 +27,15 @@ class OptionLink:
"""Constructs an option link. """Constructs an option link.
:param title: The title. :param title: The title.
:param url: The URI. :param url: The URL.
:param is_active: True if active, or False otherwise :param is_active: True if active, or False otherwise
:param fa_icon: The font-awesome icon, if any.
""" """
self.title: str = title self.title: str = title
"""The title."""
self.url: str = url self.url: str = url
"""The URL."""
self.is_active: bool = is_active self.is_active: bool = is_active
"""True if active, or False otherwise."""
self.fa_icon: str | None = fa_icon self.fa_icon: str | None = fa_icon
"""The font-awesome icon, if any."""

View File

@ -43,9 +43,11 @@ function initializeBaseAccountSelector() {
const base = document.getElementById("accounting-base"); const base = document.getElementById("accounting-base");
const baseCode = document.getElementById("accounting-base-code"); const baseCode = document.getElementById("accounting-base-code");
const baseContent = document.getElementById("accounting-base-content"); const baseContent = document.getElementById("accounting-base-content");
const isOffsetNeededControl = document.getElementById("accounting-is-offset-needed-control");
const isOffsetNeeded = document.getElementById("accounting-is-offset-needed");
const options = Array.from(document.getElementsByClassName("accounting-base-option")); const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const btnClear = document.getElementById("accounting-btn-clear-base"); const btnClear = document.getElementById("accounting-btn-clear-base");
selector.addEventListener("show.bs.modal", () => { base.onclick = () => {
base.classList.add("accounting-not-empty"); base.classList.add("accounting-not-empty");
for (const option of options) { for (const option of options) {
option.classList.remove("active"); option.classList.remove("active");
@ -54,7 +56,7 @@ function initializeBaseAccountSelector() {
if (selected !== null) { if (selected !== null) {
selected.classList.add("active"); selected.classList.add("active");
} }
}); };
selector.addEventListener("hidden.bs.modal", () => { selector.addEventListener("hidden.bs.modal", () => {
if (baseCode.value === "") { if (baseCode.value === "") {
base.classList.remove("accounting-not-empty"); base.classList.remove("accounting-not-empty");
@ -64,6 +66,14 @@ function initializeBaseAccountSelector() {
option.onclick = () => { option.onclick = () => {
baseCode.value = option.dataset.code; baseCode.value = option.dataset.code;
baseContent.innerText = option.dataset.content; baseContent.innerText = option.dataset.content;
if (["1", "2"].includes(option.dataset.content.substring(0, 1))) {
isOffsetNeededControl.classList.remove("d-none");
isOffsetNeeded.disabled = false;
} else {
isOffsetNeededControl.classList.add("d-none");
isOffsetNeeded.disabled = true;
isOffsetNeeded.checked = false;
}
btnClear.classList.add("btn-danger"); btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary") btnClear.classList.remove("btn-secondary")
btnClear.disabled = false; btnClear.disabled = false;

View File

@ -45,78 +45,91 @@ class AccountSelector {
*/ */
#prefix; #prefix;
/**
* The button to clear the account
* @type {HTMLButtonElement}
*/
#clearButton
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The options
* @type {HTMLLIElement[]}
*/
#options;
/**
* The more item to show all accounts
* @type {HTMLLIElement}
*/
#more;
/** /**
* Constructs an account selector. * Constructs an account selector.
* *
* @param modal {HTMLFormElement} the account selector modal * @param modal {HTMLDivElement} the account selector modal
*/ */
constructor(modal) { constructor(modal) {
this.#entryType = modal.dataset.entryType; this.#entryType = modal.dataset.entryType;
this.#prefix = "accounting-account-selector-" + modal.dataset.entryType; this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
this.#init(); this.#query = document.getElementById(this.#prefix + "-query");
} this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
this.#optionList = document.getElementById(this.#prefix + "-option-list");
/** // noinspection JSValidateTypes
* Initializes the account selector. this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
* this.#more = document.getElementById(this.#prefix + "-more");
*/ this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
#init() { this.#more.onclick = () => {
const formAccountControl = document.getElementById("accounting-entry-form-account-control"); this.#more.classList.add("d-none");
const formAccount = document.getElementById("accounting-entry-form-account"); this.#filterOptions();
const more = document.getElementById(this.#prefix + "-more");
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
more.onclick = () => {
more.classList.add("d-none");
this.#filterAccountOptions();
}; };
this.#initializeAccountQuery(); this.#clearButton.onclick = () => {
btnClear.onclick = () => { AccountSelector.#formAccountControl.classList.remove("accounting-not-empty");
formAccountControl.classList.remove("accounting-not-empty"); AccountSelector.#formAccount.innerText = "";
formAccount.innerText = ""; AccountSelector.#formAccount.dataset.code = "";
formAccount.dataset.code = ""; AccountSelector.#formAccount.dataset.text = "";
formAccount.dataset.text = "";
validateJournalEntryAccount(); validateJournalEntryAccount();
}; };
for (const option of options) { for (const option of this.#options) {
option.onclick = () => { option.onclick = () => {
formAccountControl.classList.add("accounting-not-empty"); AccountSelector.#formAccountControl.classList.add("accounting-not-empty");
formAccount.innerText = option.dataset.content; AccountSelector.#formAccount.innerText = option.dataset.content;
formAccount.dataset.code = option.dataset.code; AccountSelector.#formAccount.dataset.code = option.dataset.code;
formAccount.dataset.text = option.dataset.content; AccountSelector.#formAccount.dataset.text = option.dataset.content;
validateJournalEntryAccount(); validateJournalEntryAccount();
}; };
} }
} this.#query.addEventListener("input", () => {
this.#filterOptions();
/**
* Initializes the query on the account options.
*
*/
#initializeAccountQuery() {
const query = document.getElementById(this.#prefix + "-query");
query.addEventListener("input", () => {
this.#filterAccountOptions();
}); });
} }
/** /**
* Filters the account options. * Filters the options.
* *
*/ */
#filterAccountOptions() { #filterOptions() {
const query = document.getElementById(this.#prefix + "-query"); const codesInUse = this.#getCodesUsedInForm();
const optionList = document.getElementById(this.#prefix + "-option-list");
if (optionList === null) {
console.log(this.#prefix + "-option-list");
}
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const more = document.getElementById(this.#prefix + "-more");
const queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
const codesInUse = this.#getAccountCodeUsedInForm();
let shouldAnyShow = false; let shouldAnyShow = false;
for (const option of options) { for (const option of this.#options) {
const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query); const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
if (shouldShow) { if (shouldShow) {
option.classList.remove("d-none"); option.classList.remove("d-none");
shouldAnyShow = true; shouldAnyShow = true;
@ -124,12 +137,12 @@ class AccountSelector {
option.classList.add("d-none"); option.classList.add("d-none");
} }
} }
if (!shouldAnyShow && more.classList.contains("d-none")) { if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
optionList.classList.add("d-none"); this.#optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none"); this.#queryNoResult.classList.remove("d-none");
} else { } else {
optionList.classList.remove("d-none"); this.#optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none"); this.#queryNoResult.classList.add("d-none");
} }
} }
@ -138,10 +151,9 @@ class AccountSelector {
* *
* @return {string[]} the account codes that are used in the form * @return {string[]} the account codes that are used in the form
*/ */
#getAccountCodeUsedInForm() { #getCodesUsedInForm() {
const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code")); const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code"));
const formAccount = document.getElementById("accounting-entry-form-account"); const inUse = [AccountSelector.#formAccount.dataset.code];
const inUse = [formAccount.dataset.code];
for (const accountCode of accountCodes) { for (const accountCode of accountCodes) {
inUse.push(accountCode.value); inUse.push(accountCode.value);
} }
@ -149,15 +161,15 @@ class AccountSelector {
} }
/** /**
* Returns whether an account option should show. * Returns whether an option should show.
* *
* @param option {HTMLLIElement} the account option * @param option {HTMLLIElement} the option
* @param more {HTMLLIElement} the more account element * @param more {HTMLLIElement} the more element
* @param inUse {string[]} the account codes that are used in the form * @param inUse {string[]} the account codes that are used in the form
* @param query {HTMLInputElement} the query element, if any * @param query {HTMLInputElement} the query element, if any
* @return {boolean} true if the account option should show, or false otherwise * @return {boolean} true if the option should show, or false otherwise
*/ */
#shouldAccountOptionShow(option, more, inUse, query) { #shouldOptionShow(option, more, inUse, query) {
const isQueryMatched = () => { const isQueryMatched = () => {
if (query.value === "") { if (query.value === "") {
return true; return true;
@ -180,33 +192,28 @@ class AccountSelector {
} }
/** /**
* Initializes the account selector when it is shown. * The callback when the account selector is shown.
* *
*/ */
initShow() { #onOpen() {
const formAccount = document.getElementById("accounting-entry-form-account"); this.#query.value = "";
const query = document.getElementById(this.#prefix + "-query") this.#more.classList.remove("d-none");
const more = document.getElementById(this.#prefix + "-more"); this.#filterOptions();
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option")); for (const option of this.#options) {
const btnClear = document.getElementById(this.#prefix + "-btn-clear"); if (option.dataset.code === AccountSelector.#formAccount.dataset.code) {
query.value = "";
more.classList.remove("d-none");
this.#filterAccountOptions();
for (const option of options) {
if (option.dataset.code === formAccount.dataset.code) {
option.classList.add("active"); option.classList.add("active");
} else { } else {
option.classList.remove("active"); option.classList.remove("active");
} }
} }
if (formAccount.dataset.code === "") { if (AccountSelector.#formAccount.dataset.code === "") {
btnClear.classList.add("btn-secondary"); this.#clearButton.classList.add("btn-secondary");
btnClear.classList.remove("btn-danger"); this.#clearButton.classList.remove("btn-danger");
btnClear.disabled = true; this.#clearButton.disabled = true;
} else { } else {
btnClear.classList.add("btn-danger"); this.#clearButton.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary"); this.#clearButton.classList.remove("btn-secondary");
btnClear.disabled = false; this.#clearButton.disabled = false;
} }
} }
@ -216,11 +223,32 @@ class AccountSelector {
*/ */
static #selectors = {} static #selectors = {}
/**
* The journal entry form.
* @type {HTMLFormElement}
*/
static #entryForm;
/**
* The control of the account on the journal entry form
* @type {HTMLDivElement}
*/
static #formAccountControl;
/**
* The account on the journal entry form
* @type {HTMLDivElement}
*/
static #formAccount;
/** /**
* Initializes the account selectors. * Initializes the account selectors.
* *
*/ */
static initialize() { static initialize() {
this.#entryForm = document.getElementById("accounting-entry-form");
this.#formAccountControl = document.getElementById("accounting-entry-form-account-control");
this.#formAccount = document.getElementById("accounting-entry-form-account");
const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal")); const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal"));
for (const modal of modals) { for (const modal of modals) {
const selector = new AccountSelector(modal); const selector = new AccountSelector(modal);
@ -234,17 +262,14 @@ class AccountSelector {
* *
*/ */
static #initializeTransactionForm() { static #initializeTransactionForm() {
const entryForm = document.getElementById("accounting-entry-form"); this.#formAccountControl.onclick = () => this.#selectors[this.#entryForm.dataset.entryType].#onOpen();
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.onclick = () => this.#selectors[entryForm.dataset.entryType].initShow();
} }
/** /**
* Initializes the account selector for the journal entry form. * Initializes the account selector for the journal entry form.
*x *x
*/ */
static initializeJournalEntryForm() { static initializeJournalEntryForm() {
const entryForm = document.getElementById("accounting-entry-form"); this.#formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#entryForm.dataset.entryType + "-modal";
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
} }
} }

View File

@ -62,7 +62,7 @@ First written: 2023/2/1
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div> <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
</div> </div>
<div class="form-check form-switch mb-3"> <div id="accounting-is-offset-needed-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2"] %} d-none {% endif %}">
<input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}> <input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="accounting-is-offset-needed"> <label class="form-check-label" for="accounting-is-offset-needed">
{{ A_("The entries in the account need offset.") }} {{ A_("The entries in the account need offset.") }}

View File

@ -88,10 +88,10 @@ First written: 2023/2/25
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list"> <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
{% for entry_form in credit_forms %} {% for entry_form in credit_forms %}
{% with currency_index = currency_index, {% with currency_index = currency_index,
entry_id = entry_form.eid.data,
entry_type = "credit", entry_type = "credit",
entry_index = loop.index, entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1, only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = entry_form.account_code.data|accounting_default, account_code_data = entry_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors, account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text, account_text = entry_form.account_text,

View File

@ -0,0 +1,22 @@
# 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 forms for the transaction management.
"""
from .reorder import sort_transactions_in, TransactionReorderForm
from .transaction import TransactionForm, IncomeTransactionForm, \
ExpenseTransactionForm, TransferTransactionForm

View File

@ -0,0 +1,207 @@
# 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 currency sub-forms for the transaction management.
"""
from decimal import Decimal
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError, FieldList, IntegerField, \
BooleanField, FormField
from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.strip_text import strip_text
from .journal_entry import CreditEntryForm, DebitEntryForm
CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency."))
"""The validator to check if the currency code is empty."""
class CurrencyExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if db.session.get(Currency, field.data) is None:
raise ValidationError(lazy_gettext(
"The currency does not exist."))
class NeedSomeJournalEntries:
"""The validator to check if there is any journal entry sub-form."""
def __call__(self, form: FlaskForm, field: FieldList) -> None:
if len(field) == 0:
raise ValidationError(lazy_gettext(
"Please add some journal entries."))
class IsBalanced:
"""The validator to check that the total amount of the debit and credit
entries are equal."""
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
if not isinstance(form, TransferCurrencyForm):
return
if len(form.debit) == 0 or len(form.credit) == 0:
return
if form.debit_total != form.credit_total:
raise ValidationError(lazy_gettext(
"The totals of the debit and credit amounts do not match."))
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency in a transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField()
"""The currency code."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
class IncomeCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash income transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists()])
"""The currency code."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
"""The credit entries."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount.data for x in self.credit
if x.amount.data is not None])
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.credit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class ExpenseCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash expense transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
"""The debit entries."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries.
:return: The total amount of the debit journal entries.
"""
return sum([x.amount.data for x in self.debit
if x.amount.data is not None])
@property
def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.debit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class TransferCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a transfer transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
"""The debit entries."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
"""The credit entries."""
whole_form = BooleanField(validators=[IsBalanced()])
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries.
:return: The total amount of the debit journal entries.
"""
return sum([x.amount.data for x in self.debit
if x.amount.data is not None])
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount.data for x in self.credit
if x.amount.data is not None])
@property
def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.debit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.credit.errors
if isinstance(x, str) or isinstance(x, LazyString)]

View File

@ -0,0 +1,196 @@
# 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 journal entry sub-forms for the transaction management.
"""
from __future__ import annotations
import re
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import DataRequired
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry
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
ACCOUNT_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the account."))
"""The validator to check if the account code is empty."""
class AccountExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if Account.find_by_code(field.data) is None:
raise ValidationError(lazy_gettext(
"The account does not exist."))
class PositiveAmount:
"""The validator to check if the amount is positive."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
if field.data is None:
return
if field.data <= 0:
raise ValidationError(lazy_gettext(
"Please fill in a positive amount."))
class IsDebitAccount:
"""The validator to check if the account is for debit journal entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(lazy_gettext(
"This account is not for debit entries."))
class IsCreditAccount:
"""The validator to check if the account is for credit journal entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[123489]|7[1234])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(lazy_gettext(
"This account is not for credit entries."))
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."""
account_code = StringField()
"""The account code."""
amount = DecimalField()
"""The amount."""
@property
def account_text(self) -> str:
"""Returns the text representation of the account.
:return: The text representation of the account.
"""
if self.account_code.data is None:
return ""
account: Account | None = Account.find_by_code(self.account_code.data)
if account is None:
return ""
return str(account)
@property
def all_errors(self) -> list[str | LazyString]:
"""Returns all the errors of the form.
:return: All the errors of the form.
"""
all_errors: list[str | LazyString] = []
for key in self.errors:
if key != "csrf_token":
all_errors.extend(self.errors[key])
return all_errors
class DebitEntryForm(JournalEntryForm):
"""The form to create or edit a debit journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
:param obj: The journal entry object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = True
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
class CreditEntryForm(JournalEntryForm):
"""The form to create or edit a credit journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
:param obj: The journal entry object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = False
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk

View File

@ -0,0 +1,91 @@
# 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 __future__ import annotations
from datetime import date
from flask import request
from accounting import db
from accounting.models import Transaction
def sort_transactions_in(txn_date: date, exclude: int) -> 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.
"""
transactions: list[Transaction] = Transaction.query\
.filter(Transaction.date == txn_date,
Transaction.id != exclude)\
.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

@ -14,38 +14,35 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The forms for the transaction management. """The transaction forms for the transaction management.
""" """
from __future__ import annotations from __future__ import annotations
import re
import typing as t import typing as t
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import date
from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from flask import request
from flask_babel import LazyString from flask_babel import LazyString
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import DateField, StringField, FieldList, FormField, \ from wtforms import DateField, FieldList, FormField, \
IntegerField, TextAreaField, DecimalField, BooleanField TextAreaField
from wtforms.validators import DataRequired, ValidationError from wtforms.validators import DataRequired, ValidationError
from accounting import db from accounting import db
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Transaction, Account, JournalEntry, \ from accounting.models import Transaction, Account, JournalEntry, \
TransactionCurrency, Currency TransactionCurrency
from accounting.transaction.summary_editor import SummaryEditor from accounting.transaction.utils.account_option import AccountOption
from accounting.transaction.utils.summary_editor import SummaryEditor
from accounting.utils.random_id import new_id from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text, strip_multiline_text from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .currency import CurrencyForm, IncomeCurrencyForm, ExpenseCurrencyForm, \
TransferCurrencyForm
from .journal_entry import JournalEntryForm, DebitEntryForm, CreditEntryForm
from .reorder import sort_transactions_in
MISSING_CURRENCY: LazyString = lazy_gettext("Please select the currency.")
"""The error message when the currency code is empty."""
MISSING_ACCOUNT: LazyString = lazy_gettext("Please select the account.")
"""The error message when the account code is empty."""
DATE_REQUIRED: DataRequired = DataRequired( DATE_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please fill in the date.")) lazy_gettext("Please fill in the date."))
"""The validator to check if the date is empty.""" """The validator to check if the date is empty."""
@ -61,223 +58,6 @@ class NeedSomeCurrencies:
"Please add some currencies.")) "Please add some currencies."))
class CurrencyExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if db.session.get(Currency, field.data) is None:
raise ValidationError(lazy_gettext(
"The currency does not exist."))
class NeedSomeJournalEntries:
"""The validator to check if there is any journal entry sub-form."""
def __call__(self, form: TransferCurrencyForm, field: FieldList) \
-> None:
if len(field) == 0:
raise ValidationError(lazy_gettext(
"Please add some journal entries."))
class AccountExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if Account.find_by_code(field.data) is None:
raise ValidationError(lazy_gettext(
"The account does not exist."))
class PositiveAmount:
"""The validator to check if the amount is positive."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
if field.data is None:
return
if field.data <= 0:
raise ValidationError(lazy_gettext(
"Please fill in a positive amount."))
class IsDebitAccount:
"""The validator to check if the account is for debit journal entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(lazy_gettext(
"This account is not for debit entries."))
class AccountOption:
"""An account option."""
def __init__(self, account: Account):
"""Constructs an account option.
:param account: The account.
"""
self.id: str = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
self.query_values: list[str] = account.query_values
"""The values to be queried."""
self.__str: str = str(account)
"""The string representation of the account option."""
self.is_in_use: bool = False
"""True if this account is in use, or False otherwise."""
def __str__(self) -> str:
"""Returns the string representation of the account option.
:return: The string representation of the account option.
"""
return self.__str
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."""
account_code = StringField()
"""The account code."""
amount = DecimalField()
"""The amount."""
@property
def account_text(self) -> str:
"""Returns the text representation of the account.
:return: The text representation of the account.
"""
if self.account_code.data is None:
return ""
account: Account | None = Account.find_by_code(self.account_code.data)
if account is None:
return ""
return str(account)
@property
def all_errors(self) -> list[str | LazyString]:
"""Returns all the errors of the form.
:return: All the errors of the form.
"""
all_errors: list[str | LazyString] = []
for key in self.errors:
if key != "csrf_token":
all_errors.extend(self.errors[key])
return all_errors
class DebitEntryForm(JournalEntryForm):
"""The form to create or edit a debit journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_ACCOUNT),
AccountExists(),
IsDebitAccount()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
:param obj: The journal entry object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = True
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
class IsCreditAccount:
"""The validator to check if the account is for credit journal entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[123489]|7[1234])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(lazy_gettext(
"This account is not for credit entries."))
class CreditEntryForm(JournalEntryForm):
"""The form to create or edit a credit journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_ACCOUNT),
AccountExists(),
IsCreditAccount()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
:param obj: The journal entry object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = False
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency in a transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField()
"""The currency code."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
class TransactionForm(FlaskForm): class TransactionForm(FlaskForm):
"""The base form to create or edit a transaction.""" """The base form to create or edit a transaction."""
date = DateField() date = DateField()
@ -300,8 +80,6 @@ class TransactionForm(FlaskForm):
"""The journal entry collector. The default is the base abstract """The journal entry collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should collector only to provide the correct type. The subclass forms should
provide their own collectors.""" provide their own collectors."""
self.__in_use_account_id: set[int] | None = None
"""The ID of the accounts that are in use."""
def populate_obj(self, obj: Transaction) -> None: def populate_obj(self, obj: Transaction) -> None:
"""Populates the form data into a transaction object. """Populates the form data into a transaction object.
@ -538,41 +316,6 @@ class JournalEntryCollector(t.Generic[T], ABC):
ord_by_form.get(x))) ord_by_form.get(x)))
class IncomeCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash income transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_CURRENCY),
CurrencyExists()])
"""The currency code."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
"""The credit entries."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount.data for x in self.credit
if x.amount.data is not None])
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.credit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class IncomeTransactionForm(TransactionForm): class IncomeTransactionForm(TransactionForm):
"""The form to create or edit a cash income transaction.""" """The form to create or edit a cash income transaction."""
date = DateField(validators=[DATE_REQUIRED]) date = DateField(validators=[DATE_REQUIRED])
@ -611,41 +354,6 @@ class IncomeTransactionForm(TransactionForm):
self.collector = Collector self.collector = Collector
class ExpenseCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash expense transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_CURRENCY),
CurrencyExists()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
"""The debit entries."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries.
:return: The total amount of the debit journal entries.
"""
return sum([x.amount.data for x in self.debit
if x.amount.data is not None])
@property
def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.debit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class ExpenseTransactionForm(TransactionForm): class ExpenseTransactionForm(TransactionForm):
"""The form to create or edit a cash expense transaction.""" """The form to create or edit a cash expense transaction."""
date = DateField(validators=[DATE_REQUIRED]) date = DateField(validators=[DATE_REQUIRED])
@ -685,76 +393,6 @@ class ExpenseTransactionForm(TransactionForm):
self.collector = Collector self.collector = Collector
class TransferCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a transfer transaction."""
class IsBalanced:
"""The validator to check that the total amount of the debit and credit
entries are equal."""
def __call__(self, form: TransferCurrencyForm, field: BooleanField)\
-> None:
if len(form.debit) == 0 or len(form.credit) == 0:
return
if form.debit_total != form.credit_total:
raise ValidationError(lazy_gettext(
"The totals of the debit and credit amounts do not"
" match."))
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_CURRENCY),
CurrencyExists()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
"""The debit entries."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
"""The credit entries."""
whole_form = BooleanField(validators=[IsBalanced()])
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries.
:return: The total amount of the debit journal entries.
"""
return sum([x.amount.data for x in self.debit
if x.amount.data is not None])
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount.data for x in self.credit
if x.amount.data is not None])
@property
def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.debit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.credit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class TransferTransactionForm(TransactionForm): class TransferTransactionForm(TransactionForm):
"""The form to create or edit a transfer transaction.""" """The form to create or edit a transfer transaction."""
date = DateField(validators=[DATE_REQUIRED]) date = DateField(validators=[DATE_REQUIRED])
@ -795,67 +433,3 @@ class TransferTransactionForm(TransactionForm):
self._credit_no = self._credit_no + 1 self._credit_no = self._credit_no + 1
self.collector = Collector self.collector = Collector
def sort_transactions_in(txn_date: date, exclude: int) -> 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.
"""
transactions: list[Transaction] = Transaction.query\
.filter(Transaction.date == txn_date,
Transaction.id != exclude)\
.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

@ -0,0 +1,19 @@
# 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 utilities for the transaction management.
"""

View File

@ -0,0 +1,49 @@
# 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 account option for the transaction management.
"""
from __future__ import annotations
from accounting.models import Account
class AccountOption:
"""An account option."""
def __init__(self, account: Account):
"""Constructs an account option.
:param account: The account.
"""
self.id: str = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
self.query_values: list[str] = account.query_values
"""The values to be queried."""
self.__str: str = str(account)
"""The string representation of the account option."""
self.is_in_use: bool = False
"""True if this account is in use, or False otherwise."""
def __str__(self) -> str:
"""Returns the string representation of the account option.
:return: The string representation of the account option.
"""
return self.__str

View File

@ -26,8 +26,8 @@ from flask_wtf import FlaskForm
from accounting.models import Transaction from accounting.models import Transaction
from accounting.template_globals import default_currency_code from accounting.template_globals import default_currency_code
from accounting.utils.txn_types import TransactionType from accounting.utils.txn_types import TransactionType
from .forms import TransactionForm, IncomeTransactionForm, \ from accounting.transaction.forms import TransactionForm, \
ExpenseTransactionForm, TransferTransactionForm IncomeTransactionForm, ExpenseTransactionForm, TransferTransactionForm
class TransactionOperator(ABC): class TransactionOperator(ABC):

View File

@ -34,9 +34,9 @@ from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.txn_types import TransactionType from accounting.utils.txn_types import TransactionType
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .forms import sort_transactions_in, TransactionReorderForm from .forms import sort_transactions_in, TransactionReorderForm
from .operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op
from .template_filters import with_type, to_transfer, format_amount_input, \ from .template_filters import with_type, to_transfer, format_amount_input, \
text2html text2html
from .utils.operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op
bp: Blueprint = Blueprint("transaction", __name__) bp: Blueprint = Blueprint("transaction", __name__)
"""The view blueprint for the transaction management.""" """The view blueprint for the transaction management."""

View File

@ -372,6 +372,15 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# A nominal account that needs offset
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "6172",
"title": stock.title,
"is_offset_needed": "yes"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Success, with spaces to be stripped # Success, with spaces to be stripped
response = self.client.post(store_uri, response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
@ -470,6 +479,15 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# A nominal account that needs offset
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "6172",
"title": stock.title,
"is_offset_needed": "yes"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Change the base account # Change the base account
response = self.client.post(update_uri, response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,

View File

@ -66,7 +66,7 @@ class SummeryEditorTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.transaction.summary_editor import SummaryEditor from accounting.transaction.utils.summary_editor import SummaryEditor
for form in get_form_data(self.csrf_token): for form in get_form_data(self.csrf_token):
add_txn(self.client, form) add_txn(self.client, form)
with self.app.app_context(): with self.app.app_context():
@ -79,13 +79,13 @@ class SummeryEditorTestCase(unittest.TestCase):
self.assertEqual(editor.debit.general.tags[0].accounts[0].code, self.assertEqual(editor.debit.general.tags[0].accounts[0].code,
Accounts.MEAL) Accounts.MEAL)
self.assertEqual(editor.debit.general.tags[0].accounts[1].code, self.assertEqual(editor.debit.general.tags[0].accounts[1].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
self.assertEqual(editor.debit.general.tags[1].name, "Dinner") self.assertEqual(editor.debit.general.tags[1].name, "Dinner")
self.assertEqual(len(editor.debit.general.tags[1].accounts), 2) self.assertEqual(len(editor.debit.general.tags[1].accounts), 2)
self.assertEqual(editor.debit.general.tags[1].accounts[0].code, self.assertEqual(editor.debit.general.tags[1].accounts[0].code,
Accounts.MEAL) Accounts.MEAL)
self.assertEqual(editor.debit.general.tags[1].accounts[1].code, self.assertEqual(editor.debit.general.tags[1].accounts[1].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
# Debit-Travel # Debit-Travel
self.assertEqual(len(editor.debit.travel.tags), 3) self.assertEqual(len(editor.debit.travel.tags), 3)
@ -118,7 +118,7 @@ class SummeryEditorTestCase(unittest.TestCase):
self.assertEqual(editor.credit.general.tags[0].name, "Lunch") self.assertEqual(editor.credit.general.tags[0].name, "Lunch")
self.assertEqual(len(editor.credit.general.tags[0].accounts), 3) self.assertEqual(len(editor.credit.general.tags[0].accounts), 3)
self.assertEqual(editor.credit.general.tags[0].accounts[0].code, self.assertEqual(editor.credit.general.tags[0].accounts[0].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
self.assertEqual(editor.credit.general.tags[0].accounts[1].code, self.assertEqual(editor.credit.general.tags[0].accounts[1].code,
Accounts.BANK) Accounts.BANK)
self.assertEqual(editor.credit.general.tags[0].accounts[2].code, self.assertEqual(editor.credit.general.tags[0].accounts[2].code,
@ -128,20 +128,20 @@ class SummeryEditorTestCase(unittest.TestCase):
self.assertEqual(editor.credit.general.tags[1].accounts[0].code, self.assertEqual(editor.credit.general.tags[1].accounts[0].code,
Accounts.BANK) Accounts.BANK)
self.assertEqual(editor.credit.general.tags[1].accounts[1].code, self.assertEqual(editor.credit.general.tags[1].accounts[1].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
# Credit-Travel # Credit-Travel
self.assertEqual(len(editor.credit.travel.tags), 2) self.assertEqual(len(editor.credit.travel.tags), 2)
self.assertEqual(editor.credit.travel.tags[0].name, "Bike") self.assertEqual(editor.credit.travel.tags[0].name, "Bike")
self.assertEqual(len(editor.credit.travel.tags[0].accounts), 2) self.assertEqual(len(editor.credit.travel.tags[0].accounts), 2)
self.assertEqual(editor.credit.travel.tags[0].accounts[0].code, self.assertEqual(editor.credit.travel.tags[0].accounts[0].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
self.assertEqual(editor.credit.travel.tags[0].accounts[1].code, self.assertEqual(editor.credit.travel.tags[0].accounts[1].code,
Accounts.PREPAID) Accounts.PREPAID)
self.assertEqual(editor.credit.travel.tags[1].name, "Taxi") self.assertEqual(editor.credit.travel.tags[1].name, "Taxi")
self.assertEqual(len(editor.credit.travel.tags[1].accounts), 2) self.assertEqual(len(editor.credit.travel.tags[1].accounts), 2)
self.assertEqual(editor.credit.travel.tags[1].accounts[0].code, self.assertEqual(editor.credit.travel.tags[1].accounts[0].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
self.assertEqual(editor.credit.travel.tags[1].accounts[1].code, self.assertEqual(editor.credit.travel.tags[1].accounts[1].code,
Accounts.CASH) Accounts.CASH)
@ -152,7 +152,7 @@ class SummeryEditorTestCase(unittest.TestCase):
self.assertEqual(editor.credit.bus.tags[0].accounts[0].code, self.assertEqual(editor.credit.bus.tags[0].accounts[0].code,
Accounts.PREPAID) Accounts.PREPAID)
self.assertEqual(editor.credit.bus.tags[0].accounts[1].code, self.assertEqual(editor.credit.bus.tags[0].accounts[1].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
self.assertEqual(editor.credit.bus.tags[1].name, "Bus") self.assertEqual(editor.credit.bus.tags[1].name, "Bus")
self.assertEqual(len(editor.credit.bus.tags[1].accounts), 1) self.assertEqual(len(editor.credit.bus.tags[1].accounts), 1)
self.assertEqual(editor.credit.bus.tags[1].accounts[0].code, self.assertEqual(editor.credit.bus.tags[1].accounts[0].code,
@ -186,7 +186,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-debit-1-account_code": Accounts.MEAL, "currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Fries ", "currency-0-debit-1-summary": " Lunch—Fries ",
"currency-0-debit-1-amount": "2.15", "currency-0-debit-1-amount": "2.15",
"currency-0-credit-1-account_code": Accounts.PAYABLE, "currency-0-credit-1-account_code": Accounts.PETTY_CASH,
"currency-0-credit-1-summary": " Lunch—Fries ", "currency-0-credit-1-summary": " Lunch—Fries ",
"currency-0-credit-1-amount": "2.15", "currency-0-credit-1-amount": "2.15",
"currency-0-debit-2-account_code": Accounts.MEAL, "currency-0-debit-2-account_code": Accounts.MEAL,
@ -208,7 +208,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-debit-1-account_code": Accounts.MEAL, "currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Dinner—Steak ", "currency-0-debit-1-summary": " Dinner—Steak ",
"currency-0-debit-1-amount": "8.28", "currency-0-debit-1-amount": "8.28",
"currency-0-credit-1-account_code": Accounts.PAYABLE, "currency-0-credit-1-account_code": Accounts.PETTY_CASH,
"currency-0-credit-1-summary": " Dinner—Steak ", "currency-0-credit-1-summary": " Dinner—Steak ",
"currency-0-credit-1-amount": "8.28"}, "currency-0-credit-1-amount": "8.28"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
@ -218,13 +218,13 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-debit-0-account_code": Accounts.MEAL, "currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Pizza ", "currency-0-debit-0-summary": " Lunch—Pizza ",
"currency-0-debit-0-amount": "5.49", "currency-0-debit-0-amount": "5.49",
"currency-0-credit-0-account_code": Accounts.PAYABLE, "currency-0-credit-0-account_code": Accounts.PETTY_CASH,
"currency-0-credit-0-summary": " Lunch—Pizza ", "currency-0-credit-0-summary": " Lunch—Pizza ",
"currency-0-credit-0-amount": "5.49", "currency-0-credit-0-amount": "5.49",
"currency-0-debit-1-account_code": Accounts.MEAL, "currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Noodles ", "currency-0-debit-1-summary": " Lunch—Noodles ",
"currency-0-debit-1-amount": "7.47", "currency-0-debit-1-amount": "7.47",
"currency-0-credit-1-account_code": Accounts.PAYABLE, "currency-0-credit-1-account_code": Accounts.PETTY_CASH,
"currency-0-credit-1-summary": " Lunch—Noodles ", "currency-0-credit-1-summary": " Lunch—Noodles ",
"currency-0-credit-1-amount": "7.47"}, "currency-0-credit-1-amount": "7.47"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
@ -259,7 +259,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-debit-3-account_code": Accounts.TRAVEL, "currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Train—Red—Mall→Museum ", "currency-0-debit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-debit-3-amount": "4.4", "currency-0-debit-3-amount": "4.4",
"currency-0-credit-3-account_code": Accounts.PAYABLE, "currency-0-credit-3-account_code": Accounts.PETTY_CASH,
"currency-0-credit-3-summary": " Train—Red—Mall→Museum ", "currency-0-credit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-credit-3-amount": "4.4"}, "currency-0-credit-3-amount": "4.4"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
@ -275,31 +275,31 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-debit-1-account_code": Accounts.TRAVEL, "currency-0-debit-1-account_code": Accounts.TRAVEL,
"currency-0-debit-1-summary": " Taxi—Office→Restaurant ", "currency-0-debit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-debit-1-amount": "12", "currency-0-debit-1-amount": "12",
"currency-0-credit-1-account_code": Accounts.PAYABLE, "currency-0-credit-1-account_code": Accounts.PETTY_CASH,
"currency-0-credit-1-summary": " Taxi—Office→Restaurant ", "currency-0-credit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-credit-1-amount": "12", "currency-0-credit-1-amount": "12",
"currency-0-debit-2-account_code": Accounts.TRAVEL, "currency-0-debit-2-account_code": Accounts.TRAVEL,
"currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ", "currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-debit-2-amount": "8", "currency-0-debit-2-amount": "8",
"currency-0-credit-2-account_code": Accounts.PAYABLE, "currency-0-credit-2-account_code": Accounts.PETTY_CASH,
"currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ", "currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-credit-2-amount": "8", "currency-0-credit-2-amount": "8",
"currency-0-debit-3-account_code": Accounts.TRAVEL, "currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Bike—City Hall→Office ", "currency-0-debit-3-summary": " Bike—City Hall→Office ",
"currency-0-debit-3-amount": "3.5", "currency-0-debit-3-amount": "3.5",
"currency-0-credit-3-account_code": Accounts.PAYABLE, "currency-0-credit-3-account_code": Accounts.PETTY_CASH,
"currency-0-credit-3-summary": " Bike—City Hall→Office ", "currency-0-credit-3-summary": " Bike—City Hall→Office ",
"currency-0-credit-3-amount": "3.5", "currency-0-credit-3-amount": "3.5",
"currency-0-debit-4-account_code": Accounts.TRAVEL, "currency-0-debit-4-account_code": Accounts.TRAVEL,
"currency-0-debit-4-summary": " Bike—Restaurant→Office ", "currency-0-debit-4-summary": " Bike—Restaurant→Office ",
"currency-0-debit-4-amount": "4", "currency-0-debit-4-amount": "4",
"currency-0-credit-4-account_code": Accounts.PAYABLE, "currency-0-credit-4-account_code": Accounts.PETTY_CASH,
"currency-0-credit-4-summary": " Bike—Restaurant→Office ", "currency-0-credit-4-summary": " Bike—Restaurant→Office ",
"currency-0-credit-4-amount": "4", "currency-0-credit-4-amount": "4",
"currency-0-debit-5-account_code": Accounts.TRAVEL, "currency-0-debit-5-account_code": Accounts.TRAVEL,
"currency-0-debit-5-summary": " Bike—Office→Theatre ", "currency-0-debit-5-summary": " Bike—Office→Theatre ",
"currency-0-debit-5-amount": "1.5", "currency-0-debit-5-amount": "1.5",
"currency-0-credit-5-account_code": Accounts.PAYABLE, "currency-0-credit-5-account_code": Accounts.PETTY_CASH,
"currency-0-credit-5-summary": " Bike—Office→Theatre ", "currency-0-credit-5-summary": " Bike—Office→Theatre ",
"currency-0-credit-5-amount": "1.5", "currency-0-credit-5-amount": "1.5",
"currency-0-debit-6-account_code": Accounts.TRAVEL, "currency-0-debit-6-account_code": Accounts.TRAVEL,
@ -312,13 +312,13 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"next": NEXT_URI, "next": NEXT_URI,
"date": txn_date, "date": txn_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PAYABLE, "currency-0-debit-0-account_code": Accounts.PETTY_CASH,
"currency-0-debit-0-summary": " Dinner—Steak ", "currency-0-debit-0-summary": " Dinner—Steak ",
"currency-0-debit-0-amount": "8.28", "currency-0-debit-0-amount": "8.28",
"currency-0-credit-0-account_code": Accounts.BANK, "currency-0-credit-0-account_code": Accounts.BANK,
"currency-0-credit-0-summary": " Dinner—Steak ", "currency-0-credit-0-summary": " Dinner—Steak ",
"currency-0-credit-0-amount": "8.28", "currency-0-credit-0-amount": "8.28",
"currency-0-debit-1-account_code": Accounts.PAYABLE, "currency-0-debit-1-account_code": Accounts.PETTY_CASH,
"currency-0-debit-1-summary": " Lunch—Pizza ", "currency-0-debit-1-summary": " Lunch—Pizza ",
"currency-0-debit-1-amount": "5.49", "currency-0-debit-1-amount": "5.49",
"currency-0-credit-1-account_code": Accounts.BANK, "currency-0-credit-1-account_code": Accounts.BANK,

View File

@ -38,6 +38,7 @@ EMPTY_NOTE: str = " \n\n "
class Accounts: class Accounts:
"""The shortcuts to the common accounts.""" """The shortcuts to the common accounts."""
CASH: str = "1111-001" CASH: str = "1111-001"
PETTY_CASH: str = "1112-001"
BANK: str = "1113-001" BANK: str = "1113-001"
PREPAID: str = "1258-001" PREPAID: str = "1258-001"
PAYABLE: str = "2141-001" PAYABLE: str = "2141-001"