Added the transaction management.

This commit is contained in:
依瑪貓 2023-02-27 15:28:45 +08:00
parent 9383f5484f
commit 05fde3a742
42 changed files with 7090 additions and 2 deletions

View File

@ -73,6 +73,9 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
from . import currency from . import currency
currency.init_app(app, bp) currency.init_app(app, bp)
from . import transaction
transaction.init_app(app, bp)
from .utils import next_uri from .utils import next_uri
next_uri.init_app(bp) next_uri.init_app(bp)

View File

@ -17,8 +17,11 @@
"""The data models. """The data models.
""" """
from __future__ import annotations
import re import re
import typing as t import typing as t
from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from flask import current_app from flask import current_app
@ -26,6 +29,7 @@ from flask_babel import get_locale
from sqlalchemy import text from sqlalchemy import text
from accounting import db from accounting import db
from accounting.locale import gettext
from accounting.utils.user import user_cls, user_pk_column from accounting.utils.user import user_cls, user_pk_column
@ -134,6 +138,8 @@ class Account(db.Model):
l10n = db.relationship("AccountL10n", back_populates="account", l10n = db.relationship("AccountL10n", back_populates="account",
lazy=False) lazy=False)
"""The localized titles.""" """The localized titles."""
entries = db.relationship("JournalEntry", back_populates="account")
"""The journal entries."""
__CASH = "1111-001" __CASH = "1111-001"
"""The code of the cash account,""" """The code of the cash account,"""
@ -197,6 +203,25 @@ class Account(db.Model):
return return
self.l10n.append(AccountL10n(locale=current_locale, title=value)) self.l10n.append(AccountL10n(locale=current_locale, title=value))
@property
def is_in_use(self) -> bool:
"""Returns whether the account is in use.
:return: True if the account is in use, or False otherwise.
"""
if not hasattr(self, "__is_in_use"):
setattr(self, "__is_in_use", len(self.entries) > 0)
return getattr(self, "__is_in_use")
@is_in_use.setter
def is_in_use(self, is_in_use: bool) -> None:
"""Sets whether the account is in use.
:param is_in_use: True if the account is in use, or False otherwise.
:return: None.
"""
setattr(self, "__is_in_use", is_in_use)
@classmethod @classmethod
def find_by_code(cls, code: str) -> t.Self | None: def find_by_code(cls, code: str) -> t.Self | None:
"""Finds an account by its code. """Finds an account by its code.
@ -251,6 +276,14 @@ class Account(db.Model):
cls.base_code != "3353")\ cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all() .order_by(cls.base_code, cls.no).all()
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
@classmethod @classmethod
def cash(cls) -> t.Self: def cash(cls) -> t.Self:
"""Returns the cash account. """Returns the cash account.
@ -370,6 +403,8 @@ class Currency(db.Model):
l10n = db.relationship("CurrencyL10n", back_populates="currency", l10n = db.relationship("CurrencyL10n", back_populates="currency",
lazy=False) lazy=False)
"""The localized names.""" """The localized names."""
entries = db.relationship("JournalEntry", back_populates="currency")
"""The journal entries."""
def __str__(self) -> str: def __str__(self) -> str:
"""Returns the string representation of the currency. """Returns the string representation of the currency.
@ -450,3 +485,215 @@ class CurrencyL10n(db.Model):
"""The locale.""" """The locale."""
name = db.Column(db.String, nullable=False) name = db.Column(db.String, nullable=False)
"""The localized name.""" """The localized name."""
class TransactionCurrency:
"""A currency in a transaction."""
def __init__(self, code: str, debit: list[JournalEntry],
credit: list[JournalEntry]):
"""Constructs the currency in the transaction.
:param code: The currency code.
:param debit: The debit entries.
:param credit: The credit entries.
"""
self.code: str = code
"""The currency code."""
self.debit: list[JournalEntry] = debit
"""The debit entries."""
self.credit: list[JournalEntry] = credit
"""The credit entries."""
@property
def name(self) -> str:
"""Returns the currency name.
:return: The currency name.
"""
return db.session.get(Currency, self.code).name
@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 for x in self.debit])
@property
def credit_total(self) -> str:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount for x in self.credit])
class Transaction(db.Model):
"""A transaction."""
__tablename__ = "accounting_transactions"
"""The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The transaction ID."""
date = db.Column(db.Date, nullable=False)
"""The date."""
no = db.Column(db.Integer, nullable=False, default=text("1"))
"""The account number under the date."""
note = db.Column(db.String)
"""The note."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
created_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
"""The creator."""
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of last update."""
updated_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator."""
entries = db.relationship("JournalEntry", back_populates="transaction")
"""The journal entries."""
def __str__(self) -> str:
"""Returns the string representation of this transaction.
:return: The string representation of this transaction.
"""
if self.is_cash_expense:
return gettext("Cash Expense Transaction#%(id)s", id=self.id)
if self.is_cash_income:
return gettext("Cash Income Transaction#%(id)s", id=self.id)
return gettext("Transfer Transaction#%(id)s", id=self.id)
@property
def currencies(self) -> list[TransactionCurrency]:
"""Returns the journal entries categorized by their currencies.
:return: The currency categories.
"""
entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no)
codes: list[str] = []
by_currency: dict[str, list[JournalEntry]] = {}
for entry in entries:
if entry.currency_code not in by_currency:
codes.append(entry.currency_code)
by_currency[entry.currency_code] = []
by_currency[entry.currency_code].append(entry)
return [TransactionCurrency(code=x,
debit=[y for y in by_currency[x]
if y.is_debit],
credit=[y for y in by_currency[x]
if not y.is_debit])
for x in codes]
@property
def is_cash_income(self) -> bool:
"""Returns whether this is a cash income transaction.
:return: True if this is a cash income transaction, or False otherwise.
"""
for currency in self.currencies:
if len(currency.debit) > 1:
return False
if currency.debit[0].account.code != "1111-001":
return False
return True
@property
def is_cash_expense(self) -> bool:
"""Returns whether this is a cash expense transaction.
:return: True if this is a cash expense transaction, or False
otherwise.
"""
for currency in self.currencies:
if len(currency.credit) > 1:
return False
if currency.credit[0].account.code != "1111-001":
return False
return True
def delete(self) -> None:
"""Deletes the transaction.
:return: None.
"""
JournalEntry.query\
.filter(JournalEntry.transaction_id == self.id).delete()
db.session.delete(self)
class JournalEntry(db.Model):
"""An accounting journal entry."""
__tablename__ = "accounting_journal_entries"
"""The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The entry ID."""
transaction_id = db.Column(db.Integer,
db.ForeignKey(Transaction.id,
onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False)
"""The transaction ID."""
transaction = db.relationship(Transaction, back_populates="entries")
"""The transaction."""
is_debit = db.Column(db.Boolean, nullable=False)
"""True for a debit entry, or False for a credit entry."""
no = db.Column(db.Integer, nullable=False)
"""The entry number under the transaction and debit or credit."""
pay_off_target_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"),
nullable=True)
"""The ID of the pay-off target entry."""
pay_off_target = db.relationship("JournalEntry", back_populates="pay_off",
remote_side=id, passive_deletes=True)
"""The pay-off target entry."""
pay_off = db.relationship("JournalEntry", back_populates="pay_off_target")
"""The pay-off entries."""
currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"),
nullable=False)
"""The currency code."""
currency = db.relationship(Currency, back_populates="entries")
"""The currency."""
account_id = db.Column(db.Integer,
db.ForeignKey(Account.id,
onupdate="CASCADE"),
nullable=False)
"""The account ID."""
account = db.relationship(Account, back_populates="entries", lazy=False)
"""The account."""
summary = db.Column(db.String, nullable=True)
"""The summary."""
amount = db.Column(db.Numeric(14, 2), nullable=False)
"""The amount."""
@property
def eid(self) -> int | None:
"""Returns the journal entry ID. This is the alternative name of the
ID field, to work with WTForms.
:return: The journal entry ID.
"""
return self.id
@property
def account_code(self) -> str:
"""Returns the account code.
:return: The account code.
"""
return self.account.code

View File

@ -65,6 +65,50 @@
overflow-y: scroll; overflow-y: scroll;
} }
/** The transaction management */
.accounting-currency-control {
background-color: transparent;
}
.accounting-currency-content {
width: calc(100% - 3rem);
}
.accounting-entry-content {
width: calc(100% - 3rem);
background-color: transparent;
}
.accounting-entry-control {
border-color: transparent;
}
.accounting-transaction-card {
padding: 2em 1.5em;
margin: 1em;
background-color: #F8F9FA;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
}
.accounting-transaction-card h2 {
border-bottom: thick double slategray;
}
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
background-color: #f2f2f2;
}
.accounting-list-group-stripped .list-group-item-success:nth-child(2n+1) {
background-color: #c7dbd2;
}
.accounting-list-group-hover .list-group-item:hover {
background-color: #ececec;
}
.accounting-transaction-entry {
border: none;
}
.accounting-transaction-entry-header {
font-weight: bolder;
border-bottom: thick double slategray;
}
.list-group-item.accounting-transaction-entry-total {
font-weight: bolder;
border-top: thick double slategray;
}
/* The Material Design text field (floating form control in Bootstrap) */ /* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field { .accounting-material-text-field {
position: relative; position: relative;
@ -103,6 +147,36 @@
.accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus { .accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus {
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12); box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
} }
.accounting-btn-material-fab {
transition: transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out;
}
.show .accounting-btn-material-fab {
transform: scale(1.5) rotate(-45deg);
}
.accounting-material-fab-speed-dial-group {
position: absolute;
right: -2rem;
bottom: -7rem;
text-align: right;
opacity: 0;
transform: scale(0.1);
line-height: 5.5rem;
transition: opacity .1s ease-in-out, transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out;
}
.show .accounting-material-fab-speed-dial-group {
opacity: 1;
transform: scale(0.6);
right: -0.5rem;
bottom: 0.7rem;
}
.accounting-material-fab-speed-dial-group .btn {
background-color: white;
white-space: nowrap;
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
}
.accounting-material-fab-speed-dial-group .btn:hover, .accounting-material-fab-speed-dial-group .btn:focus {
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
}
/* The Material Design form switch */ /* The Material Design form switch */
@media(max-width:767px) { @media(max-width:767px) {

View File

@ -0,0 +1,44 @@
/* The Mia! Accounting Flask Project
* material-fab-speed-dial.js: The JavaScript for the speed dial for the material floating buttons
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/25
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
initializeMaterialFabSpeedDial();
});
/**
* Initializes the speed dial of the material floating buttons.
*
* @private
*/
function initializeMaterialFabSpeedDial() {
const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial");
const fab = document.getElementById(btnFab.dataset.target);
btnFab.onclick = function () {
if (fab.classList.contains("show")) {
fab.classList.remove("show");
} else {
fab.classList.add("show");
}
}
}

View File

@ -0,0 +1,832 @@
/* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/25
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
initializeCurrencyForms();
initializeJournalEntries();
initializeAccountSelectors();
initializeFormValidation();
});
/**
* Escapes the HTML special characters and returns.
*
* @param s {string} the original string
* @returns {string} the string with HTML special character escaped
* @private
*/
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
/**
* Formats a Decimal number.
*
* @param number {Decimal} the Decimal number
* @returns {string} the formatted Decimal number
*/
function formatDecimal(number) {
if (number.equals(new Decimal("0"))) {
return "-";
}
const frac = number.modulo(1);
const whole = Number(number.minus(frac)).toLocaleString();
return whole + String(frac).substring(1);
}
/**
* Initializes the currency forms.
*
* @private
*/
function initializeCurrencyForms() {
const form = document.getElementById("accounting-form");
const btnNew = document.getElementById("accounting-btn-new-currency");
const currencyList = document.getElementById("accounting-currency-list");
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
const onReorder = function () {
const currencies = Array.from(currencyList.children);
for (let i = 0; i < currencies.length; i++) {
const no = document.getElementById(currencies[i].dataset.prefix + "-no");
no.value = String(i + 1);
}
};
btnNew.onclick = function () {
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
let maxIndex = 0;
currencies.forEach(function (currency) {
const index = parseInt(currency.dataset.index);
if (maxIndex < index) {
maxIndex = index;
}
});
const newIndex = String(maxIndex + 1);
const html = form.dataset.currencyTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(newIndex));
currencyList.insertAdjacentHTML("beforeend", html);
const newEntryButtons = Array.from(document.getElementsByClassName("accounting-currency-" + newIndex + "-btn-new-entry"));
const btnDelete = document.getElementById("accounting-btn-delete-currency-" + newIndex);
newEntryButtons.forEach(initializeNewEntryButton);
initializeBtnDeleteCurrency(btnDelete);
resetDeleteCurrencyButtons();
initializeDragAndDropReordering(currencyList, onReorder);
};
deleteButtons.forEach(initializeBtnDeleteCurrency);
initializeDragAndDropReordering(currencyList, onReorder);
}
/**
* Initializes the button to delete a currency.
*
* @param button {HTMLButtonElement} the button to delete a currency.
* @private
*/
function initializeBtnDeleteCurrency(button) {
const target = document.getElementById(button.dataset.target);
button.onclick = function () {
target.parentElement.removeChild(target);
resetDeleteCurrencyButtons();
};
}
/**
* Resets the status of the delete currency buttons.
*
* @private
*/
function resetDeleteCurrencyButtons() {
const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
if (buttons.length > 1) {
buttons.forEach(function (button) {
button.classList.remove("d-none");
});
} else {
buttons[0].classList.add("d-none");
}
}
/**
* Initializes the journal entry forms.
*
* @private
*/
function initializeJournalEntries() {
const newButtons = Array.from(document.getElementsByClassName("accounting-btn-new-entry"));
const entryLists = Array.from(document.getElementsByClassName("accounting-entry-list"));
const entries = Array.from(document.getElementsByClassName("accounting-entry"))
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-entry"));
newButtons.forEach(initializeNewEntryButton);
entryLists.forEach(initializeJournalEntryListReorder);
entries.forEach(initializeJournalEntry);
deleteButtons.forEach(initializeDeleteJournalEntryButton);
initializeJournalEntryFormModal();
}
/**
* Initializes the button to add a new journal entry.
*
* @param button {HTMLButtonElement} the button to add a new journal entry
*/
function initializeNewEntryButton(button) {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const formAccountError = document.getElementById("accounting-entry-form-account-error")
const formSummary = document.getElementById("accounting-entry-form-summary");
const formSummaryError = document.getElementById("accounting-entry-form-summary-error");
const formAmount = document.getElementById("accounting-entry-form-amount");
const formAmountError = document.getElementById("accounting-entry-form-amount-error");
button.onclick = function () {
entryForm.dataset.currencyIndex = button.dataset.currencyIndex;
entryForm.dataset.entryType = button.dataset.entryType;
entryForm.dataset.entryIndex = button.dataset.entryIndex;
formAccountControl.classList.remove("accounting-not-empty")
formAccountControl.classList.remove("is-invalid");
formAccountControl.dataset.bsTarget = button.dataset.accountModal;
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
formAccountError.innerText = "";
formSummary.value = "";
formSummary.classList.remove("is-invalid");
formSummaryError.innerText = ""
formAmount.value = "";
formAmount.classList.remove("is-invalid");
formAmountError.innerText = "";
};
}
/**
* Initializes the reordering of a journal entry list.
*
* @param entryList {HTMLUListElement} the journal entry list.
*/
function initializeJournalEntryListReorder(entryList) {
initializeDragAndDropReordering(entryList, function () {
const entries = Array.from(entryList.children);
for (let i = 0; i < entries.length; i++) {
const no = document.getElementById(entries[i].dataset.prefix + "-no");
no.value = String(i + 1);
}
});
}
/**
* Initializes the journal entry.
*
* @param entry {HTMLLIElement} the journal entry.
*/
function initializeJournalEntry(entry) {
const entryForm = document.getElementById("accounting-entry-form");
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
const summary = document.getElementById(entry.dataset.prefix + "-summary");
const amount = document.getElementById(entry.dataset.prefix + "-amount");
const control = document.getElementById(entry.dataset.prefix + "-control");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formAmount = document.getElementById("accounting-entry-form-amount");
control.onclick = function () {
entryForm.dataset.currencyIndex = entry.dataset.currencyIndex;
entryForm.dataset.entryType = entry.dataset.entryType;
entryForm.dataset.entryIndex = entry.dataset.entryIndex;
if (accountCode.value === "") {
formAccountControl.classList.remove("accounting-not-empty");
} else {
formAccountControl.classList.add("accounting-not-empty");
}
formAccountControl.dataset.bsTarget = entry.dataset.accountModal;
formAccount.innerText = accountCode.dataset.text;
formAccount.dataset.code = accountCode.value;
formAccount.dataset.text = accountCode.dataset.text;
formSummary.value = summary.value;
formAmount.value = amount.value;
validateJournalEntryForm();
};
}
/**
* Initializes the journal entry form modal.
*
* @private
*/
function initializeJournalEntryFormModal() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formAmount = document.getElementById("accounting-entry-form-amount");
const modal = document.getElementById("accounting-entry-form-modal");
formAccountControl.onclick = function () {
const prefix = "accounting-" + entryForm.dataset.entryType + "-account";
const query = document.getElementById(prefix + "-selector-query")
const more = document.getElementById(prefix + "-more");
const options = Array.from(document.getElementsByClassName(prefix + "-option"));
const btnClear = document.getElementById(prefix + "-btn-clear");
query.value = "";
more.classList.remove("d-none");
filterAccountOptions(prefix);
options.forEach(function (option) {
if (option.dataset.code === formAccount.dataset.code) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
});
if (formAccount.dataset.code === "") {
btnClear.classList.add("btn-secondary");
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
} else {
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary");
btnClear.disabled = false;
}
};
formSummary.onchange = validateJournalEntrySummary;
formAmount.onchange = validateJournalEntryAmount;
entryForm.onsubmit = function () {
if (validateJournalEntryForm()) {
saveJournalEntryForm();
bootstrap.Modal.getInstance(modal).hide();
}
return false;
}
}
/**
* Validates the journal entry form modal.
*
* @return {boolean} true if the form is valid, or false otherwise.
* @private
*/
function validateJournalEntryForm() {
let isValid = true;
isValid = validateJournalEntryAccount() && isValid;
isValid = validateJournalEntrySummary() && isValid;
isValid = validateJournalEntryAmount() && isValid
return isValid;
}
/**
* Validates the account in the journal entry form modal.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntryAccount() {
const field = document.getElementById("accounting-entry-form-account");
const error = document.getElementById("accounting-entry-form-account-error");
const control = document.getElementById("accounting-entry-form-account-control");
if (field.dataset.code === "") {
control.classList.add("is-invalid");
error.innerText = A_("Please select the account.");
return false;
}
control.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the summary in the journal entry form modal.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntrySummary() {
const field = document.getElementById("accounting-entry-form-summary");
const error = document.getElementById("accounting-entry-form-summary-error");
field.value = field.value.trim();
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the amount in the journal entry form modal.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntryAmount() {
const field = document.getElementById("accounting-entry-form-amount");
const error = document.getElementById("accounting-entry-form-amount-error");
field.value = field.value.trim();
field.classList.remove("is-invalid");
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the amount.");
return false;
}
error.innerText = "";
return true;
}
/**
* Saves the journal entry form modal to the form.
*
* @private
*/
function saveJournalEntryForm() {
const form = document.getElementById("accounting-form");
const entryForm = document.getElementById("accounting-entry-form");
const formAccount = document.getElementById("accounting-entry-form-account");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formAmount = document.getElementById("accounting-entry-form-amount");
const currencyIndex = entryForm.dataset.currencyIndex;
const entryType = entryForm.dataset.entryType;
let entryIndex;
if (entryForm.dataset.entryIndex === "new") {
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list")
let maxIndex = 0;
entries.forEach(function (entry) {
const index = parseInt(entry.dataset.entryIndex);
if (maxIndex < index) {
maxIndex = index;
}
});
entryIndex = String(maxIndex + 1);
const html = form.dataset.entryTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex))
.replaceAll("ENTRY_TYPE", escapeHtml(entryType))
.replaceAll("ENTRY_INDEX", escapeHtml(entryIndex));
entryList.insertAdjacentHTML("beforeend", html);
initializeJournalEntryListReorder(entryList);
} else {
entryIndex = entryForm.dataset.entryIndex;
}
const currency = document.getElementById("accounting-currency-" + currencyIndex);
const entry = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-" + entryIndex);
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
const accountText = document.getElementById(entry.dataset.prefix + "-account-text");
const summary = document.getElementById(entry.dataset.prefix + "-summary");
const summaryText = document.getElementById(entry.dataset.prefix + "-summary-text");
const amount = document.getElementById(entry.dataset.prefix + "-amount");
const amountText = document.getElementById(entry.dataset.prefix + "-amount-text");
accountCode.value = formAccount.dataset.code;
accountCode.dataset.text = formAccount.dataset.text;
accountText.innerText = formAccount.dataset.text;
summary.value = formSummary.value;
summaryText.innerText = formSummary.value;
amount.value = formAmount.value;
amountText.innerText = formatDecimal(new Decimal(formAmount.value));
if (entryForm.dataset.entryIndex === "new") {
const btnDelete = document.getElementById(entry.dataset.prefix + "-btn-delete");
initializeJournalEntry(entry);
initializeDeleteJournalEntryButton(btnDelete);
resetDeleteJournalEntryButtons(btnDelete.dataset.sameClass);
}
updateBalance(currencyIndex, entryType);
validateJournalEntriesReal(currencyIndex, entryType);
validateBalance(currency);
}
/**
* Initializes the button to delete a journal entry.
*
* @param button {HTMLButtonElement} the button to delete a journal entry
*/
function initializeDeleteJournalEntryButton(button) {
const target = document.getElementById(button.dataset.target);
const currencyIndex = target.dataset.currencyIndex;
const entryType = target.dataset.entryType;
const currency = document.getElementById("accounting-currency-" + currencyIndex);
button.onclick = function () {
target.parentElement.removeChild(target);
resetDeleteJournalEntryButtons(button.dataset.sameClass);
updateBalance(currencyIndex, entryType);
validateJournalEntriesReal(currencyIndex, entryType);
validateBalance(currency);
};
}
/**
* Resets the status of the delete journal entry buttons.
*
* @param sameClass {string} the class of the buttons
* @private
*/
function resetDeleteJournalEntryButtons(sameClass) {
const buttons = Array.from(document.getElementsByClassName(sameClass));
if (buttons.length > 1) {
buttons.forEach(function (button) {
button.classList.remove("d-none");
});
} else {
buttons[0].classList.add("d-none");
}
}
/**
* Updates the balance.
*
* @param currencyIndex {string} the currency index.
* @param entryType {string} the journal entry type, either "debit" or "credit"
* @private
*/
function updateBalance(currencyIndex, entryType) {
const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
const amounts = Array.from(document.getElementsByClassName(prefix + "-amount"));
const totalText = document.getElementById(prefix + "-total");
let total = new Decimal("0");
amounts.forEach(function (amount) {
if (amount.value !== "") {
total = total.plus(new Decimal(amount.value));
}
});
totalText.innerText = formatDecimal(total);
}
/**
* Initializes the account selectors.
*
* @private
*/
function initializeAccountSelectors() {
const selectors = Array.from(document.getElementsByClassName("accounting-selector-modal"));
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
selectors.forEach(function (selector) {
const more = document.getElementById(selector.dataset.prefix + "-more");
const btnClear = document.getElementById(selector.dataset.prefix + "-btn-clear");
const options = Array.from(document.getElementsByClassName(selector.dataset.prefix + "-option"));
more.onclick = function () {
more.classList.add("d-none");
filterAccountOptions(selector.dataset.prefix);
};
initializeAccountQuery(selector);
btnClear.onclick = function () {
formAccountControl.classList.remove("accounting-not-empty");
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount();
};
options.forEach(function (option) {
option.onclick = function () {
formAccountControl.classList.add("accounting-not-empty");
formAccount.innerText = option.dataset.content;
formAccount.dataset.code = option.dataset.code;
formAccount.dataset.text = option.dataset.content;
validateJournalEntryAccount();
};
});
});
}
/**
* Initializes the query on the account options.
*
* @param selector {HTMLDivElement} the selector modal
* @private
*/
function initializeAccountQuery(selector) {
const query = document.getElementById(selector.dataset.prefix + "-selector-query");
query.addEventListener("input", function () {
filterAccountOptions(selector.dataset.prefix);
});
}
/**
* Filters the account options.
*
* @param prefix {string} the HTML ID and class prefix
* @private
*/
function filterAccountOptions(prefix) {
const query = document.getElementById(prefix + "-selector-query");
const optionList = document.getElementById(prefix + "-option-list");
if (optionList === null) {
console.log(prefix + "-option-list");
}
const options = Array.from(document.getElementsByClassName(prefix + "-option"));
const more = document.getElementById(prefix + "-more");
const queryNoResult = document.getElementById(prefix + "-option-no-result");
const codesInUse = getAccountCodeUsedInForm();
let hasAnyMatched = false;
options.forEach(function (option) {
const isMatched = shouldAccountOptionShow(option, more, codesInUse, query);
if (isMatched) {
option.classList.remove("d-none");
hasAnyMatched = true;
} else {
option.classList.add("d-none");
}
});
if (!hasAnyMatched) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
}
}
/**
* Returns whether an account option should show.
*
* @param option {HTMLLIElement} the account option
* @param more {HTMLLIElement} the more account element
* @param inUse {string[]} the account codes that are used in the form
* @param query {HTMLInputElement} the query element, if any
* @return {boolean} true if the account option should show, or false otherwise
* @private
*/
function shouldAccountOptionShow(option, more, inUse, query) {
const isQueryMatched = function () {
if (query.value === "") {
return true;
}
const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
return true;
}
}
return false;
};
const isMoreMatched = function () {
if (more.classList.contains("d-none")) {
return true;
}
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
};
return isMoreMatched() && isQueryMatched();
}
/**
* Returns the account codes that are used in the form.
*
* @return {string[]} the account codes that are used in the form
* @private
*/
function getAccountCodeUsedInForm() {
const accountCodes = Array.from(document.getElementsByClassName("accounting-account-code"));
const formAccount = document.getElementById("accounting-entry-form-account");
const inUse = [formAccount.dataset.code];
accountCodes.forEach(function (accountCode) {
inUse.push(accountCode.value);
});
return inUse
}
/**
* Initializes the form validation.
*
* @private
*/
function initializeFormValidation() {
const date = document.getElementById("accounting-date");
const note = document.getElementById("accounting-note");
const form = document.getElementById("accounting-form");
date.onchange = validateDate;
note.onchange = validateNote;
form.onsubmit = validateForm;
}
/**
* Validates the form.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateForm() {
let isValid = true;
isValid = validateDate() && isValid;
isValid = validateCurrencies() && isValid;
isValid = validateNote() && isValid;
return isValid;
}
/**
* Validates the date.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateDate() {
const field = document.getElementById("accounting-date");
const error = document.getElementById("accounting-date-error");
field.value = field.value.trim();
field.classList.remove("is-invalid");
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the date.");
return false;
}
error.innerText = "";
return true;
}
/**
* Validates the currency sub-forms.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateCurrencies() {
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
let isValid = true;
isValid = validateCurrenciesReal() && isValid;
currencies.forEach(function (currency) {
isValid = validateCurrency(currency) && isValid;
});
return isValid;
}
/**
* Validates the currency sub-forms, the validator itself.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateCurrenciesReal() {
const field = document.getElementById("accounting-currencies");
const error = document.getElementById("accounting-currencies-error");
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
if (currencies.length === 0) {
field.classList.add("is-invalid");
error.innerText = A_("Please add some currencies.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates a currency sub-form.
*
* @param currency {HTMLDivElement} the currency sub-form
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateCurrency(currency) {
const prefix = "accounting-currency-" + currency.dataset.index;
const debit = document.getElementById(prefix + "-debit");
const credit = document.getElementById(prefix + "-credit");
let isValid = true;
if (debit !== null) {
isValid = validateJournalEntries(currency, "debit") && isValid;
}
if (credit !== null) {
isValid = validateJournalEntries(currency, "credit") && isValid;
}
if (debit !== null && credit !== null) {
isValid = validateBalance(currency) && isValid;
}
return isValid;
}
/**
* Validates the journal entries in a currency sub-form.
*
* @param currency {HTMLDivElement} the currency
* @param entryType {string} the journal entry type, either "debit" or "credit"
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntries(currency, entryType) {
const currencyIndex = currency.dataset.index;
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
let isValid = true;
isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid;
entries.forEach(function (entry) {
isValid = validateJournalEntry(entry) && isValid;
})
return isValid;
}
/**
* Validates the journal entries, the validator itself.
*
* @param currencyIndex {string} the currency index
* @param entryType {string} the journal entry type, either "debit" or "credit"
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntriesReal(currencyIndex, entryType) {
const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
const field = document.getElementById(prefix);
const error = document.getElementById(prefix + "-error");
const entries = Array.from(document.getElementsByClassName(prefix));
if (entries.length === 0) {
field.classList.add("is-invalid");
error.innerText = A_("Please add some journal entries.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates a journal entry sub-form in a currency sub-form.
*
* @param entry {HTMLLIElement} the journal entry
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntry(entry) {
const control = document.getElementById(entry.dataset.prefix + "-control");
const error = document.getElementById(entry.dataset.prefix + "-error");
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
const amount = document.getElementById(entry.dataset.prefix + "-amount");
if (accountCode.value === "") {
control.classList.add("is-invalid");
error.innerText = A_("Please select the account.");
return false;
}
if (amount.value === "") {
control.classList.add("is-invalid");
error.innerText = A_("Please fill in the amount.");
return false;
}
control.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the balance of a currency sub-form.
*
* @param currency {HTMLDivElement} the currency sub-form
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateBalance(currency) {
const prefix = "accounting-currency-" + currency.dataset.index;
const control = document.getElementById(prefix + "-control");
const error = document.getElementById(prefix + "-error");
const debit = document.getElementById(prefix + "-debit");
const debitAmounts = Array.from(document.getElementsByClassName(prefix + "-debit-amount"));
const credit = document.getElementById(prefix + "-credit");
const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount"));
if (debit !== null && credit !== null) {
let debitTotal = new Decimal("0");
debitAmounts.forEach(function (amount) {
if (amount.value !== "") {
debitTotal = debitTotal.plus(new Decimal(amount.value));
}
});
let creditTotal = new Decimal("0");
creditAmounts.forEach(function (amount) {
if (amount.value !== "") {
creditTotal = creditTotal.plus(new Decimal(amount.value));
}
});
if (!debitTotal.equals(creditTotal)) {
control.classList.add("is-invalid");
error.innerText = A_("The totals of the debit and credit amounts do not match.");
return false;
}
}
control.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the note.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateNote() {
const field = document.getElementById("accounting-note");
const error = document.getElementById("accounting-note-error");
field.value = field.value
.replace(/^\s*\n/, "")
.replace(/\s+$/, "");
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}

View File

@ -0,0 +1,37 @@
/* The Mia! Accounting Flask Project
* transaction-order.js: The JavaScript for the transaction order
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/26
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
const list = document.getElementById("accounting-order-list");
if (list !== null) {
const onReorder = function () {
const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
no.value = String(i + 1);
}
};
initializeDragAndDropReordering(list, onReorder);
}
});

View File

@ -26,6 +26,12 @@ First written: 2023/1/26
{{ A_("Accounting") }} {{ A_("Accounting") }}
</span> </span>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.transaction.") %} active {% endif %}" href="{{ url_for("accounting.transaction.list") }}">
<i class="fa-solid fa-receipt"></i>
{{ A_("Transactions") }}
</a>
</li>
<li> <li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}"> <a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
<i class="fa-solid fa-list"></i> <i class="fa-solid fa-list"></i>

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The cash expense transaction creation form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/expense/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}

View File

@ -0,0 +1,53 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% block transaction_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% for entry in currency.debit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
</div>
</li>
</ul>
</div>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
edit.html: The cash expense transaction edit form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/expense/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}

View File

@ -0,0 +1,83 @@
{#
The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the cash expense transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="mb-3">
<div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Content") }}</label>
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list">
{% for entry_form in debit_forms %}
{% with currency_index = currency_index,
entry_type = "debit",
entry_index = loop.index,
entry_id = entry_form.eid.data,
only_one_entry_form = debit_forms|length == 1,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div>
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
</div>

View File

@ -0,0 +1,50 @@
{#
The Mia! Accounting Flask Project
form.html: The cash expense transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
{% for currency_form in form.currencies %}
{% with currency_index = loop.index,
only_one_currency_form = form.currencies|length == 1,
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_txn_format_amount %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
debit_total = "-" %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/debit-account-modal.html" %}
{% endblock %}

View File

@ -0,0 +1,39 @@
{#
The Mia! Accounting Flask Project
add-new-material-fab.html: The material floating action buttons to add a new transaction
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% if accounting_can_edit() %}
<div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
<div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.expense)|accounting_append_next }}">
{{ A_("Cash expense") }}
</a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.income)|accounting_append_next }}">
{{ A_("Cash income") }}
</a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.transfer)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</div>
<button id="accounting-btn-material-fab-speed-dial" class="btn btn-primary rounded-circle accounting-btn-material-fab" type="button" data-target="accounting-material-fab-speed-dial">
<i class="fas fa-plus"></i>
</button>
</div>
{% endif %}

View File

@ -0,0 +1,54 @@
{#
The Mia! Accounting Flask Project
credit-modals.html: The modals for the credit journal entry sub-form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-credit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-credit-account" tabindex="-1" aria-labelledby="accounting-credit-account-selector-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-credit-account-selector-modal-label">{{ A_("Select Credit Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-credit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-credit-account-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-credit-account-option-list" class="list-group accounting-selector-list">
{% for account in form.credit_account_options %}
<li id="accounting-credit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-credit-account-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-credit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-credit-account-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-credit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,54 @@
{#
The Mia! Accounting Flask Project
credit-modals.html: The modals for the debit journal entry sub-form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-debit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-debit-account" tabindex="-1" aria-labelledby="accounting-debit-account-selector-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-debit-account-selector-modal-label">{{ A_("Select Debit Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-debit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-debit-account-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-debit-account-option-list" class="list-group accounting-selector-list">
{% for account in form.debit_account_options %}
<li id="accounting-debit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-debit-account-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-debit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-debit-account-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-debit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,111 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
</a>
{% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.order", txn_date=obj.date)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("Order") }}
</a>
{% if accounting_can_edit() %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% endif %}
</div>
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>
{% endif %}
{% if accounting_can_edit() %}
<form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Transaction Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
{{ A_("Do you really want to delete this transaction?") }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
<div class="accounting-transaction-card">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ obj }}</h2>
</div>
<div class="mb-3">
{{ obj.date|accounting_txn_format_date }}
</div>
{% block transaction_currencies %}{% endblock %}
{% if obj.note %}
<div class="card mb-3">
<div class="card-body">
<i class="far fa-comment-dots"></i>
{{ obj.note|accounting_txn_text2html|safe }}
</div>
</div>
{% endif %}
<div class="small text-secondary fst-italic">
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
<div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,58 @@
{#
The Mia! Accounting Flask Project
entry-form-modal.html: The modal of the journal entry sub-form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<form id="accounting-entry-form" data-currency-index="" data-entry-type="" data-entry-index="">
<div id="accounting-entry-form-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-form-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-entry-form-modal-label">{{ A_("Journal Entry Content") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div id="accounting-entry-form-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-entry-form-account">{{ A_("Account") }}</label>
<div id="accounting-entry-form-account" data-code="" data-text=""></div>
</div>
<div id="accounting-entry-form-account-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-entry-form-summary" class="form-control" type="text" value="" placeholder=" ">
<label for="accounting-entry-form-summary">{{ A_("Summary") }}</label>
<div id="accounting-entry-form-summary-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-entry-form-amount" class="form-control" type="number" value="" min="0.01" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-entry-form-amount">{{ A_("Amount") }}</label>
<div id="accounting-entry-form-amount-error" class="invalid-feedback"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button id="accounting-entry-form-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,46 @@
{#
The Mia! Accounting Flask Project
entry-sub-form.html: The journal entry sub-form in the transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-account-modal="#accounting-{{ entry_type }}-account-selector-modal" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
{% if entry_id %}
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
{% endif %}
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}">
<div class="accounting-entry-content">
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between accounting-entry-control {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-text" class="small">{{ account_text }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ "" if summary_data is none else summary_data }}</div>
</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_data }}</span></div>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-error" class="invalid-feedback">{% if entry_errors %}{{ entry_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-btn-delete" class="btn btn-danger rounded-circle accounting-btn-delete-entry accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry {% if only_one_entry_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" data-same-class="accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry">
<i class="fas fa-minus"></i>
</button>
</div>
</li>

View File

@ -0,0 +1,90 @@
{#
The Mia! Accounting Flask Project
form.html: The transfer transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script>
{% endblock %}
{% block content %}
<div class="btn-group btn-actions mb-3">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-entry-template="{{ entry_template }}">
{{ form.csrf_token }}
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="form-floating mb-3">
<input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ "" if form.date.data is none else form.date.data }}" placeholder=" " required="required">
<label class="form-label" for="accounting-date">{{ A_("Date") }}</label>
<div id="accounting-date-error" class="invalid-feedback">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div>
</div>
<div class="mb-3">
<div id="accounting-currencies" class="form-control accounting-material-text-field accounting-not-empty {% if form.currencies_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currencies">{{ A_("Content") }}</label>
<div id="accounting-currency-list" class="mt-2">
{% block currency_sub_forms %}{% endblock %}
</div>
<div>
<button id="accounting-btn-new-currency" class="btn btn-primary" type="button">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currencies-error" class="invalid-feedback">{% if form.currencies_errors %}{{ form.currencies_errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<textarea id="accounting-note" class="form-control form-control-lg {% if form.note.errors %} is-invalid {% endif %}" name="note" rows="5" placeholder=" ">{{ "" if form.note.data is none else form.note.data }}</textarea>
<label class="form-label" for="accounting-note">{{ A_("Note") }}</label>
<div id="accounting-note-error" class="invalid-feedback">{% if form.note.errors %}{{ form.note.errors[0] }}{% endif %}</div>
</div>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% include "accounting/transaction/include/entry-form-modal.html" %}
{% block account_selector_modals %}{% endblock %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The cash income transaction creation form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/income/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Cash Income Transaction") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}

View File

@ -0,0 +1,53 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% block transaction_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% for entry in currency.credit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
</div>
</li>
</ul>
</div>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
edit.html: The cash income transaction edit form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/income/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}

View File

@ -0,0 +1,83 @@
{#
The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the cash income transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="mb-3">
<div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Content") }}</label>
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
{% for entry_form in credit_forms %}
{% with currency_index = currency_index,
entry_type = "credit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div>
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
</div>

View File

@ -0,0 +1,50 @@
{#
The Mia! Accounting Flask Project
form.html: The cash income transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
{% for currency_form in form.currencies %}
{% with currency_index = loop.index,
only_one_currency_form = form.currencies|length == 1,
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_txn_format_amount %}
{% include "accounting/transaction/income/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
credit_total = "-" %}
{% include "accounting/transaction/income/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/credit-account-modal.html" %}
{% endblock %}

View File

@ -0,0 +1,96 @@
{#
The Mia! Accounting Flask Project
list.html: The transaction list
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/18
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Transaction Management") }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.expense)|accounting_append_next }}">
{{ A_("Cash Expense") }}</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.income)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.transfer)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.transaction.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search-desktop" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
<div class="btn-group mb-2 d-md-none">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.transaction.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search-mobile" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
{% include "accounting/transaction/include/add-new-material-fab.html" %}
{% if list %}
{% include "accounting/include/pagination.html" %}
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=item)|accounting_append_next }}">
{{ item.date|accounting_txn_format_date }} {{ item }}
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,83 @@
{#
The Mia! Accounting Flask Project
order.html: The order of the transactions in a same day
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-order.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Transactions on %(date)s", date=date) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
{% if list|length > 1 and accounting_can_edit() %}
<form action="{{ url_for("accounting.transaction.sort", txn_date=date) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<ul id="accounting-order-list" class="list-group mb-3">
{% for item in list %}
<li class="list-group-item d-flex justify-content-between" data-id="{{ item.id }}">
<input id="accounting-order-{{ item.id }}-no" type="hidden" name="{{ item.id }}-no" value="{{ loop.index }}">
<div>
{{ item }}
</div>
<i class="fa-solid fa-bars"></i>
</li>
{% endfor %}
</ul>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% elif list %}
<ul class="list-group mb-3">
{% for item in list %}
<li class="list-group-item">
{{ item }}
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The transfer transaction creation form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/transfer/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Transfer Transaction") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}

View File

@ -0,0 +1,84 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% block transaction_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<div class="row">
{# The debit entries #}
<div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Debit") }}</li>
{% for entry in currency.debit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
</div>
</li>
</ul>
</div>
{# The credit entries #}
<div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Credit") }}</li>
{% for entry in currency.credit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
edit.html: The transfer transaction edit form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/transfer/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}

View File

@ -0,0 +1,126 @@
{#
The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the transfer transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="row">
{# The debit entries #}
<div class="col-sm-6 mb-3">
<div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Debit") }}</label>
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list accounting-currency-{{ currency_index }}-entry-list">
{% for entry_form in debit_forms %}
{% with currency_index = currency_index,
entry_type = "debit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div>
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
</div>
{# The credit entries #}
<div class="col-sm-6 mb-3">
<div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Credit") }}</label>
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
{% for entry_form in credit_forms %}
{% with currency_index = currency_index,
entry_id = entry_form.eid.data,
entry_type = "credit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div>
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div>
</div>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
</div>

View File

@ -0,0 +1,55 @@
{#
The Mia! Accounting Flask Project
form.html: The transfer transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
{% for currency_form in form.currencies %}
{% with currency_index = loop.index,
only_one_currency_form = form.currencies|length == 1,
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_txn_format_amount,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_txn_format_amount %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
debit_total = "-",
credit_total = "-" %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/debit-account-modal.html" %}
{% include "accounting/transaction/include/credit-account-modal.html" %}
{% endblock %}

View File

@ -0,0 +1,37 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The transaction management.
"""
from flask import Flask, Blueprint
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .converters import TransactionConverter, TransactionTypeConverter, \
DateConverter
app.url_map.converters["transaction"] = TransactionConverter
app.url_map.converters["transactionType"] = TransactionTypeConverter
app.url_map.converters["date"] = DateConverter
from .views import bp as transaction_bp
bp.register_blueprint(transaction_bp, url_prefix="/transactions")

View File

@ -0,0 +1,100 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The path converters for the transaction management.
"""
from datetime import date
from flask import abort
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import Transaction
from accounting.transaction.dispatcher import TransactionType, \
TXN_TYPE_DICT
class TransactionConverter(BaseConverter):
"""The transaction converter to convert the transaction ID from and to the
corresponding transaction in the routes."""
def to_python(self, value: str) -> Transaction:
"""Converts a transaction ID to a transaction.
:param value: The transaction ID.
:return: The corresponding transaction.
"""
transaction: Transaction | None = db.session.get(Transaction, value)
if transaction is None:
abort(404)
return transaction
def to_url(self, value: Transaction) -> str:
"""Converts a transaction to its ID.
:param value: The transaction.
:return: The ID.
"""
return str(value.id)
class TransactionTypeConverter(BaseConverter):
"""The transaction converter to convert the transaction type ID from and to
the corresponding transaction type in the routes."""
def to_python(self, value: str) -> TransactionType:
"""Converts a transaction ID to a transaction.
:param value: The transaction ID.
:return: The corresponding transaction.
"""
txn_type: TransactionType | None = TXN_TYPE_DICT.get(value)
if txn_type is None:
abort(404)
return txn_type
def to_url(self, value: TransactionType) -> str:
"""Converts a transaction type to its ID.
:param value: The transaction type.
:return: The ID.
"""
return str(value.ID)
class DateConverter(BaseConverter):
"""The date converter to convert the ISO date from and to the
corresponding date in the routes."""
def to_python(self, value: str) -> date:
"""Converts an ISO date to a date.
:param value: The ISO date.
:return: The corresponding date.
"""
try:
return date.fromisoformat(value)
except ValueError:
abort(404)
def to_url(self, value: date) -> str:
"""Converts a date to its ISO date.
:param value: The date.
:return: The ISO date.
"""
return value.isoformat()

View File

@ -0,0 +1,344 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The transaction type dispatcher.
"""
import typing as t
from abc import ABC, abstractmethod
from flask import render_template, request, abort
from flask_wtf import FlaskForm
from accounting.models import Transaction
from .forms import TransactionForm, IncomeTransactionForm, \
ExpenseTransactionForm, TransferTransactionForm
from .template import default_currency_code
class TransactionType(ABC):
"""An abstract transaction type."""
ID: str = ""
"""The transaction type ID."""
CHECK_ORDER: int = -1
"""The order when checking the transaction type."""
@property
@abstractmethod
def form(self) -> t.Type[TransactionForm]:
"""Returns the form class.
:return: The form class.
"""
@abstractmethod
def render_create_template(self, form: FlaskForm) -> str:
"""Renders the template for the form to create a transaction.
:param form: The transaction form.
:return: the form to create a transaction.
"""
@abstractmethod
def render_detail_template(self, txn: Transaction) -> str:
"""Renders the template for the detail page.
:param txn: The transaction.
:return: the detail page.
"""
@abstractmethod
def render_edit_template(self, txn: Transaction, form: FlaskForm) -> str:
"""Renders the template for the form to edit a transaction.
:param txn: The transaction.
:param form: The form.
:return: the form to edit a transaction.
"""
@abstractmethod
def is_my_type(self, txn: Transaction) -> bool:
"""Checks and returns whether the transaction belongs to the type.
:param txn: The transaction.
:return: True if the transaction belongs to the type, or False
otherwise.
"""
@property
def _entry_template(self) -> str:
"""Renders and returns the template for the journal entry sub-form.
:return: The template for the journal entry sub-form.
"""
return render_template(
"accounting/transaction/include/form-entry-item.html",
currency_index="CURRENCY_INDEX",
entry_type="ENTRY_TYPE",
entry_index="ENTRY_INDEX")
class IncomeTransaction(TransactionType):
"""An income transaction."""
ID: str = "income"
"""The transaction type ID."""
CHECK_ORDER: int = 2
"""The order when checking the transaction type."""
@property
def form(self) -> t.Type[TransactionForm]:
"""Returns the form class.
:return: The form class.
"""
return IncomeTransactionForm
def render_create_template(self, form: IncomeTransactionForm) -> str:
"""Renders the template for the form to create a transaction.
:param form: The transaction form.
:return: the form to create a transaction.
"""
return render_template("accounting/transaction/income/create.html",
form=form, txn_type=self,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def render_detail_template(self, txn: Transaction) -> str:
"""Renders the template for the detail page.
:param txn: The transaction.
:return: the detail page.
"""
return render_template("accounting/transaction/income/detail.html",
obj=txn)
def render_edit_template(self, txn: Transaction,
form: IncomeTransactionForm) -> str:
"""Renders the template for the form to edit a transaction.
:param txn: The transaction.
:param form: The form.
:return: the form to edit a transaction.
"""
return render_template("accounting/transaction/income/edit.html",
txn=txn, form=form,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def is_my_type(self, txn: Transaction) -> bool:
"""Checks and returns whether the transaction belongs to the type.
:param txn: The transaction.
:return: True if the transaction belongs to the type, or False
otherwise.
"""
return txn.is_cash_income
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/transaction/income/include/form-currency-item.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
credit_total="-")
class ExpenseTransaction(TransactionType):
"""An expense transaction."""
ID: str = "expense"
"""The transaction type ID."""
CHECK_ORDER: int = 1
"""The order when checking the transaction type."""
@property
def form(self) -> t.Type[TransactionForm]:
"""Returns the form class.
:return: The form class.
"""
return ExpenseTransactionForm
def render_create_template(self, form: ExpenseTransactionForm) -> str:
"""Renders the template for the form to create a transaction.
:param form: The transaction form.
:return: the form to create a transaction.
"""
return render_template("accounting/transaction/expense/create.html",
form=form, txn_type=self,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def render_detail_template(self, txn: Transaction) -> str:
"""Renders the template for the detail page.
:param txn: The transaction.
:return: the detail page.
"""
return render_template("accounting/transaction/expense/detail.html",
obj=txn)
def render_edit_template(self, txn: Transaction,
form: ExpenseTransactionForm) -> str:
"""Renders the template for the form to edit a transaction.
:param txn: The transaction.
:param form: The form.
:return: the form to edit a transaction.
"""
return render_template("accounting/transaction/expense/edit.html",
txn=txn, form=form,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def is_my_type(self, txn: Transaction) -> bool:
"""Checks and returns whether the transaction belongs to the type.
:param txn: The transaction.
:return: True if the transaction belongs to the type, or False
otherwise.
"""
return txn.is_cash_expense
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/transaction/expense/include/form-currency-item.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
debit_total="-")
class TransferTransaction(TransactionType):
"""A transfer transaction."""
ID: str = "transfer"
"""The transaction type ID."""
CHECK_ORDER: int = 3
"""The order when checking the transaction type."""
@property
def form(self) -> t.Type[TransactionForm]:
"""Returns the form class.
:return: The form class.
"""
return TransferTransactionForm
def render_create_template(self, form: TransferTransactionForm) -> str:
"""Renders the template for the form to create a transaction.
:param form: The transaction form.
:return: the form to create a transaction.
"""
return render_template("accounting/transaction/transfer/create.html",
form=form, txn_type=self,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def render_detail_template(self, txn: Transaction) -> str:
"""Renders the template for the detail page.
:param txn: The transaction.
:return: the detail page.
"""
return render_template("accounting/transaction/transfer/detail.html",
obj=txn)
def render_edit_template(self, txn: Transaction,
form: TransferTransactionForm) -> str:
"""Renders the template for the form to edit a transaction.
:param txn: The transaction.
:param form: The form.
:return: the form to edit a transaction.
"""
return render_template("accounting/transaction/transfer/edit.html",
txn=txn, form=form,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def is_my_type(self, txn: Transaction) -> bool:
"""Checks and returns whether the transaction belongs to the type.
:param txn: The transaction.
:return: True if the transaction belongs to the type, or False
otherwise.
"""
return True
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/transaction/transfer/include/form-currency-item.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
debit_total="-", credit_total="-")
class TransactionTypes:
"""The transaction types, as object properties."""
def __init__(self, income: IncomeTransaction, expense: ExpenseTransaction,
transfer: TransferTransaction):
"""Constructs the transaction types as object properties.
:param income: The income transaction type.
:param expense: The expense transaction type.
:param transfer: The transfer transaction type.
"""
self.income: IncomeTransaction = income
self.expense: ExpenseTransaction = expense
self.transfer: TransferTransaction = transfer
TXN_TYPE_DICT: dict[str, TransactionType] \
= {x.ID: x() for x in {IncomeTransaction,
ExpenseTransaction,
TransferTransaction}}
"""The transaction types, as a dictionary."""
TXN_TYPE_OBJ: TransactionTypes = TransactionTypes(**TXN_TYPE_DICT)
"""The transaction types, as an object."""
def get_txn_type(txn: Transaction) -> TransactionType:
"""Returns the transaction type that may be specified in the "as" query
parameter. If it is not specified, check the transaction type from the
transaction.
:param txn: The transaction.
:return: None.
"""
if "as" in request.args:
if request.args["as"] not in TXN_TYPE_DICT:
abort(404)
return TXN_TYPE_DICT[request.args["as"]]
for txn_type in sorted(TXN_TYPE_DICT.values(),
key=lambda x: x.CHECK_ORDER):
if txn_type.is_my_type(txn):
return txn_type

View File

@ -0,0 +1,832 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The forms for the transaction management.
"""
from __future__ import annotations
import re
import typing as t
from abc import ABC, abstractmethod
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
from flask import request
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import DateField, StringField, FieldList, FormField, \
IntegerField, TextAreaField, DecimalField, BooleanField
from wtforms.validators import DataRequired, ValidationError
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Transaction, Account, JournalEntry, \
TransactionCurrency, Currency
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text, strip_multiline_text
from accounting.utils.user import get_current_user_pk
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."""
class NeedSomeCurrencies:
"""The validator to check if there is any currency sub-form."""
def __call__(self, form: CurrencyForm, field: FieldList) \
-> None:
if len(field) == 0:
raise ValidationError(lazy_gettext(
"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 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):
"""The base form to create or edit a transaction."""
date = DateField()
"""The date."""
currencies = FieldList(FormField(CurrencyForm))
"""The journal entries categorized by their currencies."""
note = TextAreaField()
"""The note."""
def __init__(self, *args, **kwargs):
"""Constructs a base transaction form.
:param args: The arguments.
:param kwargs: The keyword arguments.
"""
super().__init__(*args, **kwargs)
self.is_modified: bool = False
"""Whether the transaction is modified during populate_obj()."""
self.collector: t.Type[JournalEntryCollector] = JournalEntryCollector
"""The journal entry collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should
provide their own collectors."""
self.__in_use_account_id: set[int] | None = None
"""The ID of the accounts that are in use."""
def populate_obj(self, obj: Transaction) -> None:
"""Populates the form data into a transaction object.
:param obj: The transaction object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(Transaction)
self.__set_date(obj, self.date.data)
obj.note = self.note.data
entries: list[JournalEntry] = obj.entries
collector_cls: t.Type[JournalEntryCollector] = self.collector
collector: collector_cls = collector_cls(self, obj.id, entries,
obj.currencies)
collector.collect()
to_delete: set[int] = {x.id for x in entries
if x.id not in collector.to_keep}
if len(to_delete) > 0:
JournalEntry.query.filter(JournalEntry.id.in_(to_delete)).delete()
self.is_modified = True
if is_new or db.session.is_modified(obj):
self.is_modified = True
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
@staticmethod
def __set_date(obj: Transaction, new_date: date) -> None:
"""Sets the transaction date and number.
:param obj: The transaction object.
:param new_date: The new date.
:return: None.
"""
if obj.date is None or obj.date != new_date:
if obj.date is not None:
sort_transactions_in(obj.date, obj.id)
sort_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[Account]:
"""The selectable debit accounts.
:return: The selectable debit accounts.
"""
accounts: list[Account] = Account.debit()
in_use: set[int] = self.__get_in_use_account_id()
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
@property
def credit_account_options(self) -> list[Account]:
"""The selectable credit accounts.
:return: The selectable credit accounts.
"""
accounts: list[Account] = Account.credit()
in_use: set[int] = self.__get_in_use_account_id()
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
def __get_in_use_account_id(self) -> set[int]:
"""Returns the ID of the accounts that are in use.
:return: The ID of the accounts that are in use.
"""
if self.__in_use_account_id is None:
self.__in_use_account_id = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.group_by(JournalEntry.account_id)).all())
return self.__in_use_account_id
@property
def currencies_errors(self) -> list[str | LazyString]:
"""Returns the currency errors, without the errors in their sub-forms.
:return:
"""
return [x for x in self.currencies.errors
if isinstance(x, str) or isinstance(x, LazyString)]
T = t.TypeVar("T", bound=TransactionForm)
"""A transaction form variant."""
class JournalEntryCollector(t.Generic[T], ABC):
"""The journal entry collector."""
def __init__(self, form: T, txn_id: int, entries: list[JournalEntry],
currencies: list[TransactionCurrency]):
"""Constructs the journal entry collector.
:param form: The transaction form.
:param txn_id: The transaction ID.
:param entries: The existing journal entries.
:param currencies: The currencies in the transaction.
"""
self.form: T = form
"""The transaction form."""
self.entries: list[JournalEntry] = entries
"""The existing journal entries."""
self.txn_id: int = txn_id
"""The transaction ID."""
self.__entries_by_id: dict[int, JournalEntry] \
= {x.id: x for x in entries}
"""A dictionary from the entry ID to their entries."""
self.__no_by_id: dict[int, int] = {x.id: x.no for x in entries}
"""A dictionary from the entry number to their entries."""
self.__currencies: list[TransactionCurrency] = currencies
"""The currencies in the transaction."""
self._debit_no: int = 1
"""The number index for the debit entries."""
self._credit_no: int = 1
"""The number index for the credit entries."""
self.to_keep: set[int] = set()
"""The ID of the existing journal entries to keep."""
@abstractmethod
def collect(self) -> set[int]:
"""Collects the journal entries.
:return: The ID of the journal entries to keep.
"""
def _add_entry(self, form: JournalEntryForm, currency_code: str, no: int) \
-> None:
"""Composes a journal entry from the form.
:param form: The journal entry form.
:param currency_code: The code of the currency.
:param no: The number of the entry.
:return: None.
"""
entry: JournalEntry | None = self.__entries_by_id.get(form.eid.data)
if entry is not None:
self.to_keep.add(entry.id)
entry.currency_code = currency_code
form.populate_obj(entry)
entry.no = no
if db.session.is_modified(entry):
self.form.is_modified = True
else:
entry = JournalEntry()
entry.transaction_id = self.txn_id
entry.currency_code = currency_code
form.populate_obj(entry)
entry.no = no
db.session.add(entry)
self.form.is_modified = True
def _make_cash_entry(self, forms: list[JournalEntryForm], is_debit: bool,
currency_code: str, no: int) -> None:
"""Composes the cash journal entry at the other side of the cash
transaction.
:param forms: The journal entry forms in the same currency.
:param is_debit: True for a cash income transaction, or False for a
cash expense transaction.
:param currency_code: The code of the currency.
:param no: The number of the entry.
:return: None.
"""
candidates: list[JournalEntry] = [x for x in self.entries
if x.is_debit == is_debit
and x.currency_code == currency_code]
entry: JournalEntry
if len(candidates) > 0:
candidates.sort(key=lambda x: x.no)
entry = candidates[0]
self.to_keep.add(entry.id)
entry.account_id = Account.cash().id
entry.summary = None
entry.amount = sum([x.amount.data for x in forms])
entry.no = no
if db.session.is_modified(entry):
self.form.is_modified = True
else:
entry = JournalEntry()
entry.id = new_id(JournalEntry)
entry.transaction_id = self.txn_id
entry.is_debit = is_debit
entry.currency_code = currency_code
entry.account_id = Account.cash().id
entry.summary = None
entry.amount = sum([x.amount.data for x in forms])
entry.no = no
db.session.add(entry)
self.form.is_modified = True
def _sort_entry_forms(self, forms: list[JournalEntryForm]) -> None:
"""Sorts the journal entry forms.
:param forms: The journal entry forms.
:return: None.
"""
missing_no: int = 100 if len(self.__no_by_id) == 0 \
else max(self.__no_by_id.values()) + 100
ord_by_form: dict[JournalEntryForm, int] \
= {forms[i]: i for i in range(len(forms))}
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
missing_no if x.eid.data is None else
self.__no_by_id.get(x.eid.data, missing_no),
ord_by_form.get(x)))
def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None:
"""Sorts the currency forms.
:param forms: The currency forms.
:return: None.
"""
missing_no: int = len(self.__currencies) + 100
no_by_code: dict[str, int] = {self.__currencies[i].code: i
for i in range(len(self.__currencies))}
ord_by_form: dict[CurrencyForm, int] \
= {forms[i]: i for i in range(len(forms))}
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
no_by_code.get(x.code.data, missing_no),
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):
"""The form to create or edit a cash income transaction."""
date = DateField(default=date.today())
"""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."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class Collector(JournalEntryCollector[IncomeTransactionForm]):
"""The journal entry collector for the cash income transactions."""
def collect(self) -> None:
currencies: list[IncomeCurrencyForm] \
= [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies)
for currency in currencies:
# The debit cash entry
self._make_cash_entry(list(currency.credit), True,
currency.code.data, self._debit_no)
self._debit_no = self._debit_no + 1
# The credit forms
credit_forms: list[CreditEntryForm] \
= [x.form for x in currency.credit]
self._sort_entry_forms(credit_forms)
for credit_form in credit_forms:
self._add_entry(credit_form, currency.code.data,
self._credit_no)
self._credit_no = self._credit_no + 1
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):
"""The form to create or edit a cash expense transaction."""
date = DateField(default=date.today())
"""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."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class Collector(JournalEntryCollector[ExpenseTransactionForm]):
"""The journal entry collector for the cash expense
transactions."""
def collect(self) -> None:
currencies: list[ExpenseCurrencyForm] \
= [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies)
for currency in currencies:
# The debit forms
debit_forms: list[DebitEntryForm] \
= [x.form for x in currency.debit]
self._sort_entry_forms(debit_forms)
for debit_form in debit_forms:
self._add_entry(debit_form, currency.code.data,
self._debit_no)
self._debit_no = self._debit_no + 1
# The credit forms
self._make_cash_entry(list(currency.debit), False,
currency.code.data, self._credit_no)
self._credit_no = self._credit_no + 1
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):
"""The form to create or edit a transfer transaction."""
date = DateField(default=date.today())
"""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."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class Collector(JournalEntryCollector[TransferTransactionForm]):
"""The journal entry collector for the transfer transactions."""
def collect(self) -> None:
currencies: list[TransferCurrencyForm] \
= [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies)
for currency in currencies:
# The debit forms
debit_forms: list[DebitEntryForm] \
= [x.form for x in currency.debit]
self._sort_entry_forms(debit_forms)
for debit_form in debit_forms:
self._add_entry(debit_form, currency.code.data,
self._debit_no)
self._debit_no = self._debit_no + 1
# The credit forms
credit_forms: list[CreditEntryForm] \
= [x.form for x in currency.credit]
self._sort_entry_forms(credit_forms)
for credit_form in credit_forms:
self._add_entry(credit_form, currency.code.data,
self._credit_no)
self._credit_no = self._credit_no + 1
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,65 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The transaction query.
"""
from datetime import datetime
import sqlalchemy as sa
from flask import request
from accounting.models import Transaction
from accounting.utils.query import parse_query_keywords
def get_transaction_query() -> list[Transaction]:
"""Returns the transactions, optionally filtered by the query.
:return: The transactions.
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return Transaction.query\
.order_by(Transaction.date, Transaction.no).all()
conditions: list[sa.BinaryExpression] = []
for k in keywords:
sub_conditions: list[sa.BinaryExpression] \
= [Transaction.note.contains(k)]
date: datetime
try:
date = datetime.strptime(k, "%Y")
sub_conditions.append(
sa.extract("year", Transaction.date) == date.year)
except ValueError:
pass
try:
date = datetime.strptime(k, "%Y/%m")
sub_conditions.append(sa.and_(
sa.extract("year", Transaction.date) == date.year,
sa.extract("month", Transaction.date) == date.month))
except ValueError:
pass
try:
date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
sub_conditions.append(sa.and_(
sa.extract("month", Transaction.date) == date.month,
sa.extract("day", Transaction.date) == date.day))
except ValueError:
pass
conditions.append(sa.or_(*sub_conditions))
return Transaction.query.filter(*conditions)\
.order_by(Transaction.date, Transaction.no).all()

View File

@ -0,0 +1,119 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
# 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 template filters and globals for the transaction management.
"""
from datetime import date, timedelta
from decimal import Decimal
from html import escape
from urllib.parse import ParseResult, urlparse, parse_qsl, urlencode, \
urlunparse
from flask import request, current_app
from flask_babel import get_locale
from accounting.locale import gettext
from accounting.models import Currency
def with_type(uri: str) -> str:
"""Adds the transaction type to the URI, if it is specified.
:param uri: The URI.
:return: The result URL, optionally with the transaction type added.
"""
if "as" not in request.args:
return uri
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] != "next"]
params.append(("as", request.args["as"]))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)
def format_amount(value: Decimal | None) -> str:
"""Formats an amount for readability.
:param value: The amount.
:return: The formatted amount text.
"""
if value is None or value == 0:
return "-"
whole: int = int(value)
frac: Decimal = (value - whole).normalize()
return "{:,}".format(whole) + str(frac)[1:]
def format_date(value: date) -> str:
"""Formats a date to be human-friendly.
:param value: The date.
:return: The human-friendly date text.
"""
today: date = date.today()
if value == today:
return gettext("Today")
if value == today - timedelta(days=1):
return gettext("Yesterday")
if value == today + timedelta(days=1):
return gettext("Tomorrow")
locale = str(get_locale())
if locale == "zh" or locale.startswith("zh_"):
if value == today - timedelta(days=2):
return gettext("The day before yesterday")
if value == today + timedelta(days=2):
return gettext("The day after tomorrow")
if locale == "zh" or locale.startswith("zh_"):
weekdays = ["", "", "", "", "", "", ""]
weekday = weekdays[value.weekday()]
else:
weekday = value.strftime("%a")
if value.year != today.year:
return "{}/{}/{}({})".format(
value.year, value.month, value.day, weekday)
return "{}/{}({})".format(value.month, value.day, weekday)
def text2html(value: str) -> str:
"""Converts plain text into HTML.
:param value: The plain text.
:return: The HTML.
"""
s: str = escape(value)
s = s.replace("\n", "<br>")
s = s.replace(" ", " &nbsp;")
return s
def currency_options() -> str:
"""Returns the currency options.
:return: The currency options.
"""
return Currency.query.order_by(Currency.code).all()
def default_currency_code() -> str:
"""Returns the default currency code.
:return: The default currency code.
"""
with current_app.app_context():
return current_app.config.get("DEFAULT_CURRENCY", "USD")

View File

@ -0,0 +1,223 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The views for the transaction management.
"""
from datetime import date
from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, request, \
flash, url_for
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Transaction
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.user import get_current_user_pk
from .dispatcher import TransactionType, get_txn_type, TXN_TYPE_OBJ
from .template import with_type, format_amount, format_date, text2html, \
currency_options, default_currency_code
from .forms import sort_transactions_in, TransactionReorderForm
from .query import get_transaction_query
bp: Blueprint = Blueprint("transaction", __name__)
"""The view blueprint for the transaction management."""
bp.add_app_template_filter(with_type, "accounting_txn_with_type")
bp.add_app_template_filter(format_amount, "accounting_txn_format_amount")
bp.add_app_template_filter(format_date, "accounting_txn_format_date")
bp.add_app_template_filter(text2html, "accounting_txn_text2html")
bp.add_app_template_global(currency_options, "accounting_txn_currency_options")
bp.add_app_template_global(default_currency_code,
"accounting_txn_default_currency_code")
@bp.get("", endpoint="list")
@has_permission(can_view)
def list_transactions() -> str:
"""Lists the transactions.
:return: The transaction list.
"""
transactions: list[Transaction] = get_transaction_query()
pagination: Pagination = Pagination[Transaction](transactions)
return render_template("accounting/transaction/list.html",
list=pagination.list, pagination=pagination,
types=TXN_TYPE_OBJ)
@bp.get("/create/<transactionType:txn_type>", endpoint="create")
@has_permission(can_edit)
def show_add_transaction_form(txn_type: TransactionType) -> str:
"""Shows the form to add a transaction.
:param txn_type: The transaction type.
:return: The form to add a transaction.
"""
form: txn_type.form
if "form" in session:
form = txn_type.form(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = txn_type.form()
return txn_type.render_create_template(form)
@bp.post("/store/<transactionType:txn_type>", endpoint="store")
@has_permission(can_edit)
def add_transaction(txn_type: TransactionType) -> redirect:
"""Adds a transaction.
:param txn_type: The transaction type.
:return: The redirection to the transaction detail on success, or the
transaction creation form on error.
"""
form: txn_type.form = txn_type.form(request.form)
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(with_type(
url_for("accounting.transaction.create", txn_type=txn_type))))
txn: Transaction = Transaction()
form.populate_obj(txn)
db.session.add(txn)
db.session.commit()
flash(lazy_gettext("The transaction is added successfully"), "success")
return redirect(inherit_next(__get_detail_uri(txn)))
@bp.get("/<transaction:txn>", endpoint="detail")
@has_permission(can_view)
def show_transaction_detail(txn: Transaction) -> str:
"""Shows the transaction detail.
:param txn: The transaction.
:return: The detail.
"""
txn_type: TransactionType = get_txn_type(txn)
return txn_type.render_detail_template(txn)
@bp.get("/<transaction:txn>/edit", endpoint="edit")
@has_permission(can_edit)
def show_transaction_edit_form(txn: Transaction) -> str:
"""Shows the form to edit a transaction.
:param txn: The transaction.
:return: The form to edit the transaction.
"""
txn_type: TransactionType = get_txn_type(txn)
form: txn_type.form
if "form" in session:
form = txn_type.form(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = txn_type.form(obj=txn)
return txn_type.render_edit_template(txn, form)
@bp.post("/<transaction:txn>/update", endpoint="update")
@has_permission(can_edit)
def update_transaction(txn: Transaction) -> redirect:
"""Updates a transaction.
:param txn: The transaction.
:return: The redirection to the transaction detail on success, or the
transaction edit form on error.
"""
txn_type: TransactionType = get_txn_type(txn)
form: txn_type.form = txn_type.form(request.form)
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(with_type(
url_for("accounting.transaction.edit", txn=txn))))
with db.session.no_autoflush:
form.populate_obj(txn)
if not form.is_modified:
flash(lazy_gettext("The transaction was not modified."), "success")
return redirect(inherit_next(with_type(__get_detail_uri(txn))))
txn.updated_by_id = get_current_user_pk()
txn.updated_at = sa.func.now()
db.session.commit()
flash(lazy_gettext("The transaction is updated successfully."), "success")
return redirect(inherit_next(with_type(__get_detail_uri(txn))))
@bp.post("/<transaction:txn>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_transaction(txn: Transaction) -> redirect:
"""Deletes a transaction.
:param txn: The transaction.
:return: The redirection to the transaction list on success, or the
transaction detail on error.
"""
txn.delete()
sort_transactions_in(txn.date, txn.id)
db.session.commit()
flash(lazy_gettext("The transaction is deleted successfully."), "success")
return redirect(or_next(with_type(url_for("accounting.transaction.list"))))
@bp.get("/dates/<date:txn_date>", endpoint="order")
@has_permission(can_view)
def show_transaction_order(txn_date: date) -> str:
"""Shows the order of the transactions in a same date.
:param txn_date: The date.
:return: The order of the transactions in the date.
"""
transactions: list[Transaction] = Transaction.query\
.filter(Transaction.date == txn_date)\
.order_by(Transaction.no).all()
return render_template("accounting/transaction/order.html",
date=txn_date, list=transactions)
@bp.post("/dates/<date:txn_date>", endpoint="sort")
@has_permission(can_edit)
def sort_accounts(txn_date: date) -> redirect:
"""Reorders the transactions in a date.
:param txn_date: The date.
:return: The redirection to the incoming account or the account list. The
reordering operation does not fail.
"""
form: TransactionReorderForm = TransactionReorderForm(txn_date)
form.save_order()
if not form.is_modified:
flash(lazy_gettext("The order was not modified."), "success")
return redirect(or_next(url_for("accounting.account.list")))
db.session.commit()
flash(lazy_gettext("The order is updated successfully."), "success")
return redirect(or_next(url_for("accounting.account.list")))
def __get_detail_uri(txn: Transaction) -> str:
"""Returns the detail URI of a transaction.
:param txn: The transaction.
:return: The detail URI of the transaction.
"""
return url_for("accounting.transaction.detail", txn=txn)

View File

@ -115,7 +115,7 @@ class EmptyPagination(AbstractPagination[T]):
class NonEmptyPagination(AbstractPagination[T]): class NonEmptyPagination(AbstractPagination[T]):
"""The pagination with real data.""" """The pagination with real data."""
PAGE_SIZE_OPTIONS: list[int] = [10, 100, 200] PAGE_SIZE_OPTION_VALUES: list[int] = [10, 100, 200]
"""The page size options.""" """The page size options."""
def __init__(self, items: list[T], is_reversed: bool = False): def __init__(self, items: list[T], is_reversed: bool = False):
@ -278,7 +278,7 @@ class NonEmptyPagination(AbstractPagination[T]):
return [] return []
return [Link(str(x), self.__uri_size(x), return [Link(str(x), self.__uri_size(x),
is_current=x == self.page_size) is_current=x == self.page_size)
for x in self.PAGE_SIZE_OPTIONS] for x in self.PAGE_SIZE_OPTION_VALUES]
def __uri_size(self, page_size: int) -> str: def __uri_size(self, page_size: int) -> str:
"""Returns the URI of a page size. """Returns the URI of a page size.

View File

@ -19,6 +19,7 @@
This module should not import any other module from the application. This module should not import any other module from the application.
""" """
import re
def strip_text(s: str | None) -> str | None: def strip_text(s: str | None) -> str | None:
@ -31,3 +32,15 @@ def strip_text(s: str | None) -> str | None:
return None return None
s = s.strip() s = s.strip()
return s if s != "" else None return s if s != "" else None
def strip_multiline_text(s: str | None) -> str | None:
"""The filter to strip a piece of multi-line text.
:param s: The text input string.
:return: The filtered string.
"""
if s is None:
return None
s = re.sub(r"^\s*\n", "", s.rstrip())
return s if s != "" else None

2233
tests/test_transaction.py Normal file

File diff suppressed because it is too large Load Diff

443
tests/testlib_txn.py Normal file
View File

@ -0,0 +1,443 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# 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 common test libraries for the transaction test cases.
"""
import re
from decimal import Decimal
from datetime import date
from secrets import randbelow
from flask import Flask
from test_site import db
NEXT_URI: str = "/_next"
"""The next URI."""
NON_EMPTY_NOTE: str = " This is \n\na test."
"""The stripped content of an non-empty note."""
EMPTY_NOTE: str = " \n\n "
"""The empty note content."""
class Accounts:
"""The shortcuts to the common accounts."""
CASH: str = "1111-001"
BANK: str = "1113-001"
SALES: str = "4111-001"
SERVICE: str = "4611-001"
AGENCY: str = "4711-001"
OFFICE: str = "5153-001"
TRAVEL: str = "5154-001"
INTEREST: str = "4111-001"
DONATION: str = "7481-001"
RENT: str = "7482-001"
def get_add_form(csrf_token: str) -> dict[str, str]:
"""Returns the form data to add a new transaction.
:param csrf_token: The CSRF token.
:return: The form data to add a new transaction.
"""
return {"csrf_token": csrf_token,
"next": NEXT_URI,
"date": date.today().isoformat(),
"currency-0-code": "USD",
"currency-0-debit-0-no": "16",
"currency-0-debit-0-account_code": Accounts.CASH,
"currency-0-debit-0-summary": " ",
"currency-0-debit-0-amount": " 495.26 ",
"currency-0-debit-6-no": "2",
"currency-0-debit-6-account_code": Accounts.BANK,
"currency-0-debit-6-summary": " Deposit ",
"currency-0-debit-6-amount": "6000",
"currency-0-debit-12-no": "2",
"currency-0-debit-12-account_code": Accounts.OFFICE,
"currency-0-debit-12-summary": " Pens ",
"currency-0-debit-12-amount": "4.99",
"currency-0-credit-2-no": "6",
"currency-0-credit-2-account_code": Accounts.SERVICE,
"currency-0-credit-2-summary": " ",
"currency-0-credit-2-amount": "5500",
"currency-0-credit-7-account_code": Accounts.SALES,
"currency-0-credit-7-summary": " ",
"currency-0-credit-7-amount": "950",
"currency-0-credit-27-account_code": Accounts.INTEREST,
"currency-0-credit-27-summary": " ",
"currency-0-credit-27-amount": "50.25",
"currency-3-no": "2",
"currency-3-code": "JPY",
"currency-3-debit-2-no": "2",
"currency-3-debit-2-account_code": Accounts.CASH,
"currency-3-debit-2-summary": " ",
"currency-3-debit-2-amount": "15000",
"currency-3-debit-9-no": "5",
"currency-3-debit-9-account_code": Accounts.BANK,
"currency-3-debit-9-summary": " Deposit ",
"currency-3-debit-9-amount": "95000",
"currency-3-credit-3-account_code": Accounts.AGENCY,
"currency-3-credit-3-summary": " Realtor ",
"currency-3-credit-3-amount": "65000",
"currency-3-credit-5-no": "4",
"currency-3-credit-5-account_code": Accounts.DONATION,
"currency-3-credit-5-summary": " Donation ",
"currency-3-credit-5-amount": "45000",
"currency-16-code": "TWD",
"currency-16-debit-2-no": "2",
"currency-16-debit-2-account_code": Accounts.CASH,
"currency-16-debit-2-summary": " ",
"currency-16-debit-2-amount": "10000",
"currency-16-debit-9-no": "2",
"currency-16-debit-9-account_code": Accounts.TRAVEL,
"currency-16-debit-9-summary": " Gas ",
"currency-16-debit-9-amount": "30000",
"currency-16-credit-6-no": "6",
"currency-16-credit-6-account_code": Accounts.RENT,
"currency-16-credit-6-summary": " Rent ",
"currency-16-credit-6-amount": "35000",
"currency-16-credit-9-account_code": Accounts.DONATION,
"currency-16-credit-9-summary": " Donation ",
"currency-16-credit-9-amount": "5000",
"note": f"\n \n\n \n{NON_EMPTY_NOTE} \n \n\n "}
def get_unchanged_update_form(txn_id: int, app: Flask, csrf_token: str) \
-> dict[str, str]:
"""Returns the form data to update a transaction, where the data are not
changed.
:param txn_id: The transaction ID.
:param app: The Flask application.
:param csrf_token: The CSRF token.
:return: The form data to update the transaction, where the data are not
changed.
"""
from accounting.models import Transaction, TransactionCurrency
with app.app_context():
txn: Transaction | None = db.session.get(Transaction, txn_id)
assert txn is not None
currencies: list[TransactionCurrency] = txn.currencies
form: dict[str, str] = {"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn.date,
"note": " \n \n\n " if txn.note is None
else f"\n \n\n \n \n{txn.note} \n\n "}
currency_indices_used: set[int] = set()
currency_no: int = 0
for currency in currencies:
currency_index: int = __get_new_index(currency_indices_used)
currency_no = currency_no + 3 + randbelow(3)
currency_prefix: str = f"currency-{currency_index}"
form[f"{currency_prefix}-no"] = str(currency_no)
form[f"{currency_prefix}-code"] = currency.code
entry_indices_used: set[int]
entry_no: int
prefix: str
entry_indices_used = set()
entry_no = 0
for entry in currency.debit:
entry_index: int = __get_new_index(entry_indices_used)
entry_no = entry_no + 3 + randbelow(3)
prefix = f"{currency_prefix}-debit-{entry_index}"
form[f"{prefix}-eid"] = str(entry.id)
form[f"{prefix}-no"] = str(entry_no)
form[f"{prefix}-account_code"] = entry.account.code
form[f"{prefix}-summary"] \
= " " if entry.summary is None else f" {entry.summary} "
form[f"{prefix}-amount"] = str(entry.amount)
entry_indices_used = set()
entry_no = 0
for entry in currency.credit:
entry_index: int = __get_new_index(entry_indices_used)
entry_no = entry_no + 3 + randbelow(3)
prefix = f"{currency_prefix}-credit-{entry_index}"
form[f"{prefix}-eid"] = str(entry.id)
form[f"{prefix}-no"] = str(entry_no)
form[f"{prefix}-account_code"] = entry.account.code
form[f"{prefix}-summary"] \
= " " if entry.summary is None else f" {entry.summary} "
form[f"{prefix}-amount"] = str(entry.amount)
return form
def __get_new_index(indices_used: set[int]) -> int:
"""Returns a new random index that is not used.
:param indices_used: The set of indices that are already used.
:return: The newly-generated random index that is not used.
"""
while True:
index: int = randbelow(100)
if index not in indices_used:
indices_used.add(index)
return index
def get_update_form(txn_id: int, app: Flask,
csrf_token: str, is_debit: bool | None) -> dict[str, str]:
"""Returns the form data to update a transaction, where the data are
changed.
:param txn_id: The transaction ID.
:param app: The Flask application.
:param csrf_token: The CSRF token.
:param is_debit: True for a cash expense transaction, False for a cash
income transaction, or None for a transfer transaction
:return: The form data to update the transaction, where the data are
changed.
"""
form: dict[str, str] = get_unchanged_update_form(
txn_id, app, csrf_token)
# Mess up the entries in a currency
currency_prefix: str = __get_currency_prefix(form, "USD")
if is_debit is None or is_debit:
form = __mess_up_debit(form, currency_prefix)
if is_debit is None or not is_debit:
form = __mess_up_credit(form, currency_prefix)
# Mess-up the currencies
form = __mess_up_currencies(form)
return form
def __mess_up_debit(form: dict[str, str], currency_prefix: str) \
-> dict[str, str]:
"""Mess up the debit journal entries in the form data.
:param form: The form data.
:param currency_prefix: The key prefix of the currency sub-form.
:return: The messed-up form.
"""
key: str
m: re.Match
# Remove the office expense
key = [x for x in form
if x.startswith(currency_prefix)
and form[x] == Accounts.OFFICE][0]
m = re.match(r"^((.+-)\d+-)account_code$", key)
debit_prefix: str = m.group(2)
entry_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{entry_prefix}amount"])
form = {x: form[x] for x in form if not x.startswith(entry_prefix)}
# Add a new travel expense
indices: set[int] = set()
for key in form:
m = re.match(r"^.+-(\d+)-amount$", key)
if m is not None:
indices.add(int(m.group(1)))
new_index: int = max(indices) + 5 + randbelow(20)
min_no: int = min({int(form[x]) for x in form if x.endswith("-no")
and x.startswith(debit_prefix)})
form[f"{debit_prefix}{new_index}-no"] = str(1 + randbelow(min_no - 1))
form[f"{debit_prefix}{new_index}-amount"] = str(amount)
form[f"{debit_prefix}{new_index}-account_code"] = Accounts.TRAVEL
# Swap the cash and the bank order
key_cash: str = __get_entry_no_key(form, currency_prefix, Accounts.CASH)
key_bank: str = __get_entry_no_key(form, currency_prefix, Accounts.BANK)
form[key_cash], form[key_bank] = form[key_bank], form[key_cash]
return form
def __mess_up_credit(form: dict[str, str], currency_prefix: str) \
-> dict[str, str]:
"""Mess up the credit journal entries in the form data.
:param form: The form data.
:param currency_prefix: The key prefix of the currency sub-form.
:return: The messed-up form.
"""
key: str
m: re.Match
# Remove the sales income
key = [x for x in form
if x.startswith(currency_prefix)
and form[x] == Accounts.SALES][0]
m = re.match(r"^((.+-)\d+-)account_code$", key)
credit_prefix: str = m.group(2)
entry_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{entry_prefix}amount"])
form = {x: form[x] for x in form if not x.startswith(entry_prefix)}
# Add a new agency income
indices: set[int] = set()
for key in form:
m = re.match(r"^.+-(\d+)-amount$", key)
if m is not None:
indices.add(int(m.group(1)))
new_index: int = max(indices) + 5 + randbelow(20)
min_no: int = min({int(form[x]) for x in form if x.endswith("-no")
and x.startswith(credit_prefix)})
form[f"{credit_prefix}{new_index}-no"] = str(1 + randbelow(min_no - 1))
form[f"{credit_prefix}{new_index}-amount"] = str(amount)
form[f"{credit_prefix}{new_index}-account_code"] = Accounts.AGENCY
# Swap the service and the interest order
key_srv: str = __get_entry_no_key(form, currency_prefix, Accounts.SERVICE)
key_int: str = __get_entry_no_key(form, currency_prefix, Accounts.INTEREST)
form[key_srv], form[key_int] = form[key_int], form[key_srv]
return form
def __mess_up_currencies(form: dict[str, str]) -> dict[str, str]:
"""Mess up the currency sub-forms in the form data.
:param form: The form data.
:return: The messed-up form.
"""
key: str
m: re.Match
# Remove JPY
currency_prefix: str = __get_currency_prefix(form, "JPY")
form = {x: form[x] for x in form if not x.startswith(currency_prefix)}
# Add AUD
indices: set[int] = set()
for key in form:
m = re.match(r"^currency-(\d+)-code$", key)
if m is not None:
indices.add(int(m.group(1)))
new_index: int = max(indices) + 5 + randbelow(20)
min_no: int = min({int(form[x]) for x in form if x.endswith("-no")
and "-debit-" not in x and "-credit-" not in x})
prefix: str = f"currency-{new_index}-"
form.update({
f"{prefix}code": "AUD",
f"{prefix}no": str(1 + randbelow(min_no - 1)),
f"{prefix}debit-0-no": "6",
f"{prefix}debit-0-account_code": Accounts.OFFICE,
f"{prefix}debit-0-summary": " Envelop ",
f"{prefix}debit-0-amount": "5.45",
f"{prefix}debit-14-no": "6",
f"{prefix}debit-14-account_code": Accounts.CASH,
f"{prefix}debit-14-summary": " ",
f"{prefix}debit-14-amount": "14.55",
f"{prefix}credit-16-no": "7",
f"{prefix}credit-16-account_code": Accounts.RENT,
f"{prefix}credit-16-summary": " Bike ",
f"{prefix}credit-16-amount": "19.5",
f"{prefix}credit-22-no": "5",
f"{prefix}credit-22-account_code": Accounts.DONATION,
f"{prefix}credit-22-summary": " Artist ",
f"{prefix}credit-22-amount": "0.5",
})
# Swap the USD and TWD order
usd_prefix: str = __get_currency_prefix(form, "USD")
key_usd: str = f"{usd_prefix}no"
twd_prefix: str = __get_currency_prefix(form, "TWD")
key_twd: str = f"{twd_prefix}no"
form[key_usd], form[key_twd] = form[key_twd], form[key_usd]
# Change TWD to EUR
key = [x for x in form if form[x] == "TWD"][0]
form[key] = "EUR"
return form
def __get_entry_no_key(form: dict[str, str], currency_prefix: str,
code: str) -> str:
"""Returns the key of an entry number in the form data.
:param form: The form data.
:param currency_prefix: The prefix of the currency.
:param code: The code of the account.
:return: The key of the entry number in the form data.
"""
key: str = [x for x in form
if x.startswith(currency_prefix)
and form[x] == code][0]
m: re.Match = re.match(r"^(.+-\d+-)account_code$", key)
return f"{m.group(1)}no"
def __get_currency_prefix(form: dict[str, str], code: str) -> str:
"""Returns the prefix of a currency in the form data.
:param form: The form data.
:param code: The code of the currency.
:return: The prefix of the currency.
"""
key: str = [x for x in form if form[x] == code][0]
m: re.Match = re.match(r"^(.+-)code$", key)
return m.group(1)
def match_txn_detail(location: str) -> int:
"""Validates if the redirect location is the transaction detail, and
returns the transaction ID on success.
:param location: The redirect location.
:return: The transaction ID.
:raise AssertionError: When the location is not the transaction detail.
"""
m: re.Match = re.match(
r"^/accounting/transactions/(\d+)\?next=%2F_next",
location)
assert m is not None
return int(m.group(1))
def set_negative_amount(form: dict[str, str]) -> None:
"""Sets a negative amount in the form data, keeping the balance.
:param form: The form data.
:return: None.
"""
amount_keys: list[str] = []
prefix: str = ""
for key in form.keys():
m: re.Match = re.match(r"^(.+)-\d+-amount$", key)
if m is None:
continue
if prefix != "" and prefix != m.group(1):
continue
prefix = m.group(1)
amount_keys.append(key)
form[amount_keys[0]] = str(-Decimal(form[amount_keys[0]]))
form[amount_keys[1]] = str(Decimal(form[amount_keys[1]])
+ 2 * Decimal(form[amount_keys[0]]))
def remove_debit_in_a_currency(form: dict[str, str]) -> None:
"""Removes credit entries in a currency sub-form.
:param form: The form data.
:return: None.
"""
key: str = [x for x in form if "-debit-" in x][0]
m: re.Match = re.match(r"^(.+-debit-)", key)
keys: set[str] = {x for x in form if x.startswith(m.group(1))}
for key in keys:
del form[key]
def remove_credit_in_a_currency(form: dict[str, str]) -> None:
"""Removes credit entries in a currency sub-form.
:param form: The form data.
:return: None.
"""
key: str = [x for x in form if "-credit-" in x][0]
m: re.Match = re.match(r"^(.+-credit-)", key)
keys: set[str] = {x for x in form if x.startswith(m.group(1))}
for key in keys:
del form[key]