Compare commits

..

27 Commits

Author SHA1 Message Date
d8afadda02 Advanced to version 0.8.0. 2023-03-22 01:52:24 +08:00
c8e1270d8f Updated the translation. 2023-03-22 01:50:18 +08:00
2a78799404 Revised the page to reorder the journal entries in a same day. 2023-03-22 01:47:11 +08:00
863d7a9368 Simplified the "can_delete" pseudo property of the JournalEntry data model. SQLAlchemy caches the query result. There is no need to cache the result again. 2023-03-22 01:02:09 +08:00
6fd37b21d9 Fixed so that the journal entries that has offset cannot be deleted. 2023-03-22 00:59:43 +08:00
bbf3ee3320 Added the limitation so that the default currency and the currencies in use cannot be deleted. 2023-03-22 00:37:39 +08:00
b60cc7902d Revised the test_delete test in the AccountTestCase test case. 2023-03-22 00:37:26 +08:00
623313b58a Renamed the constants to be upper-cased in test_account.py. 2023-03-22 00:37:26 +08:00
d0d2d77a2e Added the limitation so that essential accounts, like cash, and the accounts in use, cannot be deleted. 2023-03-22 00:37:26 +08:00
494faeffea Revised the toolbar of the reports to fit better in desktop browsers. 2023-03-21 23:16:47 +08:00
871a5fd1d8 Changed the "settings" button to "edit" in the account, currency, and journal entry detail pages. 2023-03-21 23:10:33 +08:00
e615ad2690 Revised the style of the toolbar buttons for better layout on mobile devices. Hid the "Back" button on mobile devices for better layout and saving spaces. 2023-03-21 23:07:05 +08:00
da92a0b42c Replaced the BABEL_DEFAULT_LOCALE configuration variable with the default_locale from the Flask-Babel instance, to get rid of the dependency to the specific configuration variable. 2023-03-21 22:34:44 +08:00
678d0aa773 Fixed the CSS version of Tempus-Dominus in the base template of the test site. 2023-03-21 21:22:48 +08:00
9248ba7e3b Removed the redundant Flask App context from the default_currency_code Jinja2 global and the default_ie_account_code function. They are always under the Flask app context. 2023-03-21 21:17:10 +08:00
446087b212 Added the ACCOUNTING_DEFAULT_CURRENCY and ACCOUNTING_DEFAULT_IE_ACCOUNT configuration to the test site configuration, for demonstration. 2023-03-21 21:15:14 +08:00
a42e7d13a2 Renamed the configuration DEFAULT_CURRENCY, DEFAULT_IE_ACCOUNT, and RECURRING to "ACCOUNTING_DEFAULT_CURRENCY", "ACCOUNTING_DEFAULT_IE_ACCOUNT", and "ACCOUNTING_RECURRING", respectively. 2023-03-21 21:13:03 +08:00
a82f5091f1 Revised the styles of the buttons in the description editor. 2023-03-21 19:50:57 +08:00
3455827c09 Added the recurring transactions. 2023-03-21 19:45:56 +08:00
5dccf99a55 Renamed "regular" to "recurring" in the description editor. 2023-03-21 17:48:19 +08:00
8818b46e01 Moved the tag initialization from the constructor to the __init_tags method in the DescriptionEditor class. 2023-03-21 17:32:07 +08:00
2f3ad99467 Removed redundant code in the templates of the journal entry form. 2023-03-21 11:54:45 +08:00
592910187b Added the common form-debit-credit.html template to reduce the duplicated code for the currency sub-forms in the transaction form. 2023-03-21 11:47:05 +08:00
cb7a0d377f Added the common form-currency.html template to reduce the duplicated code for the currency sub-forms in the transaction form. 2023-03-21 11:26:28 +08:00
79175285f8 Changed "to transfer" to "as transfer", and updated its Font Awesome icon in the toolbar of the journal entries. 2023-03-21 11:04:45 +08:00
fef474977c Adjust the location of the Material Design floating action buttons for mobile screen. 2023-03-21 10:57:08 +08:00
fa1a55cd3d Adjusted the style for the mobile toolbar for Firefox on Android with large font size. 2023-03-21 10:56:21 +08:00
39 changed files with 1067 additions and 678 deletions

View File

@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Mia! Accounting Flask'
copyright = '2023, imacat'
author = 'imacat'
release = '0.7.0'
release = '0.8.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -17,7 +17,7 @@
[metadata]
name = mia-accounting-flask
version = 0.7.0
version = 0.8.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project.

View File

@ -157,6 +157,9 @@ def delete_account(account: Account) -> redirect:
:return: The redirection to the account list on success, or the account
detail on error.
"""
if not account.can_delete:
flash(s(lazy_gettext("The account cannot be deleted.")), "error")
return redirect(inherit_next(__get_detail_uri(account)))
account.delete()
sort_accounts_in(account.base_code, account.id)
db.session.commit()

View File

@ -160,6 +160,9 @@ def delete_currency(currency: Currency) -> redirect:
:return: The redirection to the currency list on success, or the currency
detail on error.
"""
if not currency.can_delete:
flash(s(lazy_gettext("The currency cannot be deleted.")), "error")
return redirect(inherit_next(__get_detail_uri(currency)))
currency.delete()
db.session.commit()
flash(s(lazy_gettext("The currency is deleted successfully.")), "success")

View File

@ -17,9 +17,11 @@
"""The description editor.
"""
import re
import typing as t
import sqlalchemy as sa
from flask import current_app
from accounting import db
from accounting.models import Account, JournalEntryLineItem
@ -143,6 +145,29 @@ class DescriptionType:
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
class DescriptionRecurring:
"""A recurring transaction."""
def __init__(self, name: str, template: str, account: Account):
"""Constructs a recurring transaction.
:param name: The name.
:param template: The template.
:param account: The account.
"""
self.name: str = name
self.template: str = template
self.account: DescriptionAccount = DescriptionAccount(account, 0)
@property
def account_codes(self) -> list[str]:
"""Returns the account codes by the order of their frequencies.
:return: The account codes by the order of their frequencies.
"""
return [self.account.code]
class DescriptionDebitCredit:
"""The description on debit or credit."""
@ -163,6 +188,8 @@ class DescriptionDebitCredit:
DescriptionType] \
= {x.id: x for x in {self.general, self.travel, self.bus}}
"""A dictionary from the type ID to the corresponding tags."""
self.recurring: list[DescriptionRecurring] = []
"""The recurring transactions."""
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
name: str, account: Account, freq: int) -> None:
@ -193,6 +220,10 @@ class DescriptionDebitCredit:
freq[account.id] = 0
freq[account.id] \
= freq[account.id] + account.freq
for recurring in self.recurring:
accounts[recurring.account.id] = recurring.account
if recurring.account.id not in freq:
freq[recurring.account.id] = 0
return [accounts[y] for y in sorted(freq.keys(),
key=lambda x: -freq[x])]
@ -206,6 +237,14 @@ class DescriptionEditor:
"""The debit tags."""
self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit")
"""The credit tags."""
self.__init_tags()
self.__init_recurring()
def __init_tags(self):
"""Initializes the tags.
:return: None.
"""
debit_credit: sa.Label = sa.case(
(JournalEntryLineItem.is_debit, "debit"),
else_="credit").label("debit_credit")
@ -236,6 +275,49 @@ class DescriptionEditor:
debit_credit_dict[row.debit_credit].add_tag(
row.tag_type, row.tag, accounts[row.account_id], row.freq)
def __init_recurring(self) -> None:
"""Initializes the recurring transactions.
:return: None.
"""
if "ACCOUNTING_RECURRING" not in current_app.config:
return
data: list[tuple[t.Literal["debit", "credit"], str, str, str]] \
= [x.split("|")
for x in current_app.config["ACCOUNTING_RECURRING"].split(",")]
debit_credit_dict: dict[t.Literal["debit", "credit"],
DescriptionDebitCredit] \
= {x.debit_credit: x for x in {self.debit, self.credit}}
accounts: dict[str, Account] \
= self.__get_accounts({x[1] for x in data})
for row in data:
debit_credit_dict[row[0]].recurring.append(
DescriptionRecurring(row[2], row[3], accounts[row[1]]))
@staticmethod
def __get_accounts(codes: set[str]) -> dict[str, Account]:
"""Finds and returns the accounts by codes.
:param codes: The account codes.
:return: The account.
"""
def get_condition(code0: str) -> sa.BinaryExpression:
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
assert m is not None,\
f"Malformed account code \"{code0}\" for regular transactions."
return sa.and_(Account.base_code == m.group(1),
Account.no == int(m.group(2)))
conditions: list[sa.BinaryExpression] \
= [get_condition(x) for x in codes]
accounts: dict[str, Account] \
= {x.code: x for x in
Account.query.filter(sa.or_(*conditions)).all()}
for code in codes:
assert code in accounts,\
f"Unknown account \"{code}\" for regular transactions."
return accounts
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
-> sa.Function:

View File

@ -175,6 +175,9 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
:return: The redirection to the journal entry list on success, or the
journal entry detail on error.
"""
if not journal_entry.can_delete:
flash(s(lazy_gettext("The journal entry cannot be deleted.")), "error")
return redirect(inherit_next(__get_detail_uri(journal_entry)))
journal_entry.delete()
sort_journal_entries_in(journal_entry.date, journal_entry.id)
db.session.commit()

View File

@ -25,8 +25,8 @@ from datetime import date
from decimal import Decimal
import sqlalchemy as sa
from flask import current_app
from flask_babel import get_locale
from babel import Locale
from flask_babel import get_locale, get_babel
from sqlalchemy import text
from accounting import db
@ -61,11 +61,11 @@ class BaseAccount(db.Model):
:return: The title in the current locale.
"""
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
current_locale: Locale = get_locale()
if current_locale == get_babel().instance.default_locale:
return self.title_l10n
for l10n in self.l10n:
if l10n.locale == current_locale:
if l10n.locale == str(current_locale):
return l10n.title
return self.title_l10n
@ -171,11 +171,11 @@ class Account(db.Model):
:return: The title in the current locale.
"""
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
current_locale: Locale = get_locale()
if current_locale == get_babel().instance.default_locale:
return self.title_l10n
for l10n in self.l10n:
if l10n.locale == current_locale:
if l10n.locale == str(current_locale):
return l10n.title
return self.title_l10n
@ -189,15 +189,15 @@ class Account(db.Model):
if self.title_l10n is None:
self.title_l10n = value
return
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
current_locale: Locale = get_locale()
if current_locale == get_babel().instance.default_locale:
self.title_l10n = value
return
for l10n in self.l10n:
if l10n.locale == current_locale:
if l10n.locale == str(current_locale):
l10n.title = value
return
self.l10n.append(AccountL10n(locale=current_locale, title=value))
self.l10n.append(AccountL10n(locale=str(current_locale), title=value))
@property
def is_real(self) -> bool:
@ -236,6 +236,16 @@ class Account(db.Model):
return True
return False
@property
def can_delete(self) -> bool:
"""Returns whether the account can be deleted.
:return: True if the account can be deleted, or False otherwise.
"""
if self.code in {"1111-001", "3351-001", "3353-001"}:
return False
return len(self.line_items) == 0
def delete(self) -> None:
"""Deletes this account.
@ -381,11 +391,11 @@ class Currency(db.Model):
:return: The name in the current locale.
"""
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
current_locale: Locale = get_locale()
if current_locale == get_babel().instance.default_locale:
return self.name_l10n
for l10n in self.l10n:
if l10n.locale == current_locale:
if l10n.locale == str(current_locale):
return l10n.name
return self.name_l10n
@ -399,15 +409,15 @@ class Currency(db.Model):
if self.name_l10n is None:
self.name_l10n = value
return
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
current_locale: Locale = get_locale()
if current_locale == get_babel().instance.default_locale:
self.name_l10n = value
return
for l10n in self.l10n:
if l10n.locale == current_locale:
if l10n.locale == str(current_locale):
l10n.name = value
return
self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
self.l10n.append(CurrencyL10n(locale=str(current_locale), name=value))
@property
def is_modified(self) -> bool:
@ -422,6 +432,17 @@ class Currency(db.Model):
return True
return False
@property
def can_delete(self) -> bool:
"""Returns whether the currency can be deleted.
:return: True if the currency can be deleted, or False otherwise.
"""
from accounting.template_globals import default_currency_code
if self.code == default_currency_code():
return False
return len(self.line_items) == 0
def delete(self) -> None:
"""Deletes the currency.
@ -597,14 +618,10 @@ class JournalEntry(db.Model):
:return: True if the journal entry can be deleted, or False otherwise.
"""
if not hasattr(self, "__can_delete"):
def has_offset() -> bool:
for line_item in self.line_items:
if len(line_item.offsets) > 0:
return True
for line_item in self.line_items:
if len(line_item.offsets) > 0:
return False
setattr(self, "__can_delete", not has_offset())
return getattr(self, "__can_delete")
return True
def delete(self) -> None:
"""Deletes the journal entry.

View File

@ -71,8 +71,8 @@ def default_ie_account_code() -> str:
:return: The default account code for the income and expenses log.
"""
with current_app.app_context():
return current_app.config.get("DEFAULT_IE_ACCOUNT", Account.CASH_CODE)
return current_app.config.get("ACCOUNTING_DEFAULT_IE_ACCOUNT",
Account.CASH_CODE)
def default_ie_account() -> IncomeExpensesAccount:

View File

@ -72,11 +72,14 @@
}
}
@media(max-width:767px) {
.accounting-toolbar {
width: 100%;
justify-content: space-evenly;
}
.accounting-toolbar > .btn:not(form), .accounting-toolbar > .btn-group > .btn {
height: 3.2rem;
width: 3.2rem;
border-radius: 50%;
margin-left: 1rem;
}
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
padding-top: 0.7rem;
@ -315,6 +318,22 @@ a.accounting-report-table-row {
padding-left: 1rem;
}
/* The description editor */
.accounting-description-editor-buttons .btn {
margin-bottom: 0.3rem;
}
/* The order of the journal entries in a same day */
.accounting-journal-entry-order-item, .accounting-journal-entry-order-item:hover {
color: inherit;
text-decoration: none;
}
.accounting-journal-entry-order-item-currency {
margin-left: 0.5rem;
border-top: thin solid lightgray;
margin-top: 0.2rem;
}
/* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field {
position: relative;
@ -339,7 +358,7 @@ a.accounting-report-table-row {
.accounting-material-fab {
position: fixed;
right: 2rem;
bottom: 1rem;
bottom: 2rem;
z-index: 10;
flex-direction: column-reverse;
}

View File

@ -32,7 +32,7 @@ class DescriptionEditor {
* The line item editor
* @type {JournalEntryLineItemEditor}
*/
#lineItemEditor;
lineItemEditor;
/**
* The description editor form
@ -102,7 +102,7 @@ class DescriptionEditor {
/**
* The tab planes
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}}
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, recurring: RecurringTransactionTab, annotation: AnnotationTab}}
*/
tabPlanes = {};
@ -113,7 +113,7 @@ class DescriptionEditor {
* @param debitCredit {string} either "debit" or "credit"
*/
constructor(lineItemEditor, debitCredit) {
this.#lineItemEditor = lineItemEditor;
this.lineItemEditor = lineItemEditor;
this.debitCredit = debitCredit;
this.prefix = "accounting-description-editor-" + debitCredit;
this.#form = document.getElementById(this.prefix);
@ -125,14 +125,14 @@ class DescriptionEditor {
// noinspection JSValidateTypes
this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account"));
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) {
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RecurringTransactionTab, AnnotationTab]) {
const tab = new cls(this);
this.tabPlanes[tab.tabId()] = tab;
}
this.currentTab = this.tabPlanes.general;
this.#initializeSuggestedAccounts();
this.description.onchange = () => this.#onDescriptionChange();
this.#offsetButton.onclick = () => this.#lineItemEditor.originalLineItemSelector.onOpen();
this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen();
this.#form.onsubmit = () => {
if (this.currentTab.validate()) {
this.#submit();
@ -147,7 +147,7 @@ class DescriptionEditor {
*/
#onDescriptionChange() {
this.description.value = this.description.value.trim();
for (const tabPlane of [this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
if (tabPlane.populate()) {
break;
}
@ -158,27 +158,31 @@ class DescriptionEditor {
/**
* Filters the suggested accounts.
*
* @param tagButton {HTMLButtonElement|null} the tag button
* @param tagButton {HTMLButtonElement} the tag button
*/
filterSuggestedAccounts(tagButton) {
for (const accountButton of this.#accountButtons) {
accountButton.classList.add("d-none");
}
if (tagButton === null) {
this.#selectAccount(null);
return;
}
this.clearSuggestedAccounts();
const suggested = JSON.parse(tagButton.dataset.accounts);
let selectedAccountButton = null;
for (const accountButton of this.#accountButtons) {
if (suggested.includes(accountButton.dataset.code)) {
accountButton.classList.remove("d-none");
if (accountButton.dataset.code === suggested[0]) {
selectedAccountButton = accountButton;
this.#selectAccount(accountButton);
return;
}
}
}
this.#selectAccount(selectedAccountButton);
}
/**
* Clears the suggested accounts.
*
*/
clearSuggestedAccounts() {
for (const accountButton of this.#accountButtons) {
accountButton.classList.add("d-none");
}
this.#selectAccount(null);
}
/**
@ -215,9 +219,9 @@ class DescriptionEditor {
#submit() {
bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
if (this.#selectedAccount !== null) {
this.#lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
this.lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
} else {
this.#lineItemEditor.saveDescription(this.description.value);
this.lineItemEditor.saveDescription(this.description.value);
}
}
@ -227,7 +231,7 @@ class DescriptionEditor {
*/
onOpen() {
this.#reset();
this.description.value = this.#lineItemEditor.description === null? "": this.#lineItemEditor.description;
this.description.value = this.lineItemEditor.description === null? "": this.lineItemEditor.description;
this.#onDescriptionChange();
}
@ -418,7 +422,7 @@ class TagTabPlane extends TabPlane {
}
}
if (!isMatched) {
this.editor.filterSuggestedAccounts(null);
this.editor.clearSuggestedAccounts();
}
this.validateTag();
}
@ -436,14 +440,13 @@ class TagTabPlane extends TabPlane {
*/
switchToMe() {
super.switchToMe();
let selectedTagButton = null;
for (const tagButton of this.tagButtons) {
if (tagButton.classList.contains("btn-primary")) {
selectedTagButton = tagButton;
break;
this.editor.filterSuggestedAccounts(tagButton);
return;
}
}
this.editor.filterSuggestedAccounts(selectedTagButton);
this.editor.clearSuggestedAccounts();
}
/**
@ -561,13 +564,6 @@ class GeneralTagTab extends TagTabPlane {
this.tag.value = found[1];
this.onTagChange();
}
for (const tagButton of this.tagButtons) {
if (tagButton.dataset.value === this.tag.value) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.editor.filterSuggestedAccounts(tagButton);
}
}
this.switchToMe();
return true;
}
@ -732,13 +728,6 @@ class GeneralTripTab extends TagTabPlane {
}
}
this.#to.value = found[4];
for (const tagButton of this.tagButtons) {
if (tagButton.dataset.value === this.tag.value) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.editor.filterSuggestedAccounts(tagButton);
}
}
this.switchToMe();
return true;
}
@ -917,14 +906,6 @@ class BusTripTab extends TagTabPlane {
this.#route.value = found[2];
this.#from.value = found[3];
this.#to.value = found[4];
for (const tagButton of this.tagButtons) {
if (tagButton.dataset.value === this.tag.value) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.editor.filterSuggestedAccounts(tagButton);
break;
}
}
this.switchToMe();
return true;
}
@ -985,17 +966,23 @@ class BusTripTab extends TagTabPlane {
}
/**
* The regular payment tab plane.
* The recurring transaction tab plane.
*
* @private
*/
class RegularPaymentTab extends TabPlane {
class RecurringTransactionTab extends TabPlane {
/**
* The payment buttons
* The month names
* @type {string[]}
*/
#monthNames;
/**
* The buttons of the recurring items
* @type {HTMLButtonElement[]}
*/
#payments;
#itemButtons;
// noinspection JSValidateTypes
/**
@ -1006,8 +993,44 @@ class RegularPaymentTab extends TabPlane {
*/
constructor(editor) {
super(editor);
this.#monthNames = [
"",
A_("January"), A_("February"), A_("March"), A_("April"),
A_("May"), A_("June"), A_("July"), A_("August"),
A_("September"), A_("October"), A_("November"), A_("December"),
];
// noinspection JSValidateTypes
this.#payments = Array.from(document.getElementsByClassName(this.prefix + "-payment"));
this.#itemButtons = Array.from(document.getElementsByClassName(this.prefix + "-item"));
for (const itemButton of this.#itemButtons) {
itemButton.onclick = () => {
this.reset();
itemButton.classList.add("btn-primary");
itemButton.classList.remove("btn-outline-primary");
this.editor.description.value = this.#getDescription(itemButton);
this.editor.filterSuggestedAccounts(itemButton);
};
}
}
/**
* Returns the description for a recurring item.
*
* @param itemButton {HTMLButtonElement} the recurring item
* @return {string} the description of the recurring item
*/
#getDescription(itemButton) {
const today = new Date(this.editor.lineItemEditor.form.getDate());
const thisMonth = today.getMonth() + 1;
const lastMonth = (thisMonth + 10) % 12 + 1;
const lastBimonthlyFrom = ((thisMonth + thisMonth % 2 + 8) % 12 + 1);
const lastBimonthlyTo = ((thisMonth + thisMonth % 2 + 9) % 12 + 1);
return itemButton.dataset.template
.replaceAll("{this_month_number}", String(thisMonth))
.replaceAll("{this_month_name}", this.#monthNames[thisMonth])
.replaceAll("{last_month_number}", String(lastMonth))
.replaceAll("{last_month_name}", this.#monthNames[lastMonth])
.replaceAll("{last_bimonthly_number}", String(lastBimonthlyFrom) + "" + String(lastBimonthlyTo))
.replaceAll("{last_bimonthly_name}", this.#monthNames[lastBimonthlyFrom] + "" + this.#monthNames[lastBimonthlyTo]);
}
/**
@ -1017,7 +1040,7 @@ class RegularPaymentTab extends TabPlane {
* @abstract
*/
tabId() {
return "regular";
return "recurring";
};
/**
@ -1026,9 +1049,9 @@ class RegularPaymentTab extends TabPlane {
* @override
*/
reset() {
for (const payment of this.#payments) {
payment.classList.remove("btn-primary");
payment.classList.add("btn-outline-primary");
for (const itemButton of this.#itemButtons) {
itemButton.classList.remove("btn-primary");
itemButton.classList.add("btn-outline-primary");
}
}
@ -1039,9 +1062,32 @@ class RegularPaymentTab extends TabPlane {
* @override
*/
populate() {
for (const itemButton of this.#itemButtons) {
if (this.#getDescription(itemButton) === this.editor.description.value) {
itemButton.classList.add("btn-primary");
itemButton.classList.remove("btn-outline-primary");
this.switchToMe();
return true;
}
}
return false;
}
/**
* Switches to the tab plane.
*
*/
switchToMe() {
super.switchToMe();
for (const itemButton of this.#itemButtons) {
if (itemButton.classList.contains("btn-primary")) {
this.editor.filterSuggestedAccounts(itemButton);
return;
}
}
this.editor.clearSuggestedAccounts();
}
/**
* Validates the input in the tab plane.
*

View File

@ -35,5 +35,4 @@ def default_currency_code() -> str:
:return: The default currency code.
"""
with current_app.app_context():
return current_app.config.get("DEFAULT_CURRENCY", "USD")
return current_app.config.get("ACCOUNTING_DEFAULT_CURRENCY", "USD")

View File

@ -25,26 +25,33 @@ First written: 2023/1/31
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.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.account.edit", account=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
<i class="fa-solid fa-pen-to-square"></i>
{{ A_("Edit") }}
</a>
{% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("Order") }}
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
</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>
{% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% endif %}
{% endif %}
</div>
@ -56,7 +63,7 @@ First written: 2023/1/31
</div>
{% endif %}
{% if accounting_can_edit() %}
{% if accounting_can_edit() and obj.can_delete %}
<form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}

View File

@ -27,7 +27,7 @@ First written: 2023/2/1
{% block content %}
<div class="btn-group mb-3">
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}

View File

@ -30,7 +30,7 @@ First written: 2023/2/2
{% block content %}
<div class="btn-group mb-3">
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}

View File

@ -25,7 +25,7 @@ First written: 2023/2/1
{% block content %}
<div class="btn-group mb-3">
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}

View File

@ -25,22 +25,29 @@ First written: 2023/2/6
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.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.currency.edit", currency=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
<i class="fa-solid fa-pen-to-square"></i>
{{ A_("Edit") }}
</a>
{% endif %}
{% 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>
{% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i>
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% endif %}
{% endif %}
</div>
@ -52,7 +59,7 @@ First written: 2023/2/6
</div>
{% endif %}
{% if accounting_can_edit() %}
{% if accounting_can_edit() and obj.can_delete %}
<form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}

View File

@ -27,7 +27,7 @@ First written: 2023/2/6
{% block content %}
<div class="btn-group mb-3">
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}

View File

@ -21,10 +21,10 @@ First written: 2023/2/26
#}
{% extends "accounting/journal-entry/include/detail.html" %}
{% block to_transfer %}
{% block as_trasfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }}
<i class="fa-solid fa-table-columns"></i>
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
</a>
{% endblock %}

View File

@ -19,57 +19,15 @@ form-currency.html: The currency sub-form in the cash disbursement journal entry
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 }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
<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-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
{% for currency in accounting_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-select">{{ 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>
{% extends "accounting/journal-entry/include/form-currency.html" %}
<div>
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% 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-line-item-list">
{% for line_item_form in debit_forms %}
{% with currency_index = currency_index,
debit_credit = "debit",
line_item_index = loop.index,
only_one_line_item_form = debit_forms|length == 1,
form = line_item_form.form %}
{% include "accounting/journal-entry/include/form-line-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 id="accounting-currency-{{ currency_index }}-debit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="debit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-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>
{% block line_items %}
{% with currency_index = currency_index,
debit_credit = "debit",
line_item_forms = debit_forms,
header = A_("Content"),
debit_credit_total = debit_total,
debit_credit_errors = debit_errors %}
{% include "accounting/journal-entry/include/form-debit-credit.html" %}
{% endwith %}
{% endblock %}

View File

@ -32,7 +32,7 @@ First written: 2023/2/25
currency_code_is_locked = currency_form.is_code_locked,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_format_amount %}
debit_total = currency_form.debit_total|accounting_format_amount %}
{% include "accounting/journal-entry/disbursement/include/form-currency.html" %}
{% endwith %}
{% endfor %}

View File

@ -55,8 +55,8 @@ First written: 2023/2/28
</span>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-regular-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Regular") }}
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Recurring") }}
</span>
</li>
<li class="nav-item">
@ -74,7 +74,7 @@ First written: 2023/2/28
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag-error" class="invalid-feedback"></div>
</div>
<div>
<div class="accounting-description-editor-buttons">
{% for tag in description_editor.general.tags %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
@ -91,7 +91,7 @@ First written: 2023/2/28
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag-error" class="invalid-feedback"></div>
</div>
<div>
<div class="accounting-description-editor-buttons">
{% for tag in description_editor.travel.tags %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
@ -99,7 +99,7 @@ First written: 2023/2/28
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="d-flex justify-content-between">
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from">{{ A_("From") }}</label>
@ -132,7 +132,7 @@ First written: 2023/2/28
</div>
</div>
<div>
<div class="accounting-description-editor-buttons">
{% for tag in description_editor.bus.tags %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
@ -140,7 +140,7 @@ First written: 2023/2/28
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="d-flex justify-content-between">
<div class="form-floating me-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from">{{ A_("From") }}</label>
@ -154,9 +154,15 @@ First written: 2023/2/28
</div>
</div>
{# A regular income or payment #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-regular-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-regular-tab">
{# TODO: To be done #}
{# A recurring transaction #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab">
<div class="accounting-description-editor-buttons">
{% for recurring in description_editor.recurring %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}">
{{ recurring.name }}
</button>
{% endfor %}
</div>
</div>
{# The annotation #}

View File

@ -25,32 +25,32 @@ First written: 2023/2/26
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.report.default")|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.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
<i class="fa-solid fa-pen-to-square"></i>
{{ A_("Edit") }}
</a>
{% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("Order") }}
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
</a>
{% if accounting_can_edit() %}
{% block to_transfer %}{% endblock %}
{% block as_trasfer %}{% endblock %}
{% if obj.can_delete %}
<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") }}
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
<span class="d-none d-md-inline">{{ A_("Delete") }}</span>
</button>
{% endif %}
{% endif %}

View File

@ -0,0 +1,47 @@
{#
The Mia! Accounting Flask Project
form-currency.html: The currency sub-form in the journal entry 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/3/21
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
<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-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
{% for currency in accounting_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-select">{{ 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-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% 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>
{% block line_items %}{% endblock %}
</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,49 @@
{#
The Mia! Accounting Flask Project
form-debit-credit.html: The debit or credit line items in the journal entry 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/3/21
#}
<div class="mb-2">
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}" 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_credit }}">{{ header }}</label>
<ul id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-list" class="list-group accounting-line-item-list">
{% for line_item_form in line_item_forms %}
{% with currency_index = currency_index,
line_item_index = loop.index,
only_one_line_item_form = line_item_forms|length == 1,
form = line_item_form.form %}
{% include "accounting/journal-entry/include/form-line-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_credit }}-total" class="badge rounded-pill bg-primary">{{ debit_credit_total }}</span></div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-error" class="invalid-feedback">{% if debit_credit_errors %}{{ debit_credit_errors[0] }}{% endif %}</div>
</div>

View File

@ -32,10 +32,10 @@ First written: 2023/2/26
{% block content %}
<div class="btn-group mb-3">
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>

View File

@ -0,0 +1,57 @@
{#
The Mia! Accounting Flask Project
order-journal-entry.html: The journal entry in the journal entry order page
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/3/21
#}
<a class="small w-100 accounting-journal-entry-order-item" href="{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_append_next }}">
<div>
{{ journal_entry.date|accounting_format_date }}
{% if journal_entry.is_cash_disbursement %}
{{ A_("Cash Disbursement") }}
{% elif journal_entry.is_cash_receipt %}
{{ A_("Cash Receipt") }}
{% else %}
{{ A_("Transfer") }}
{% endif %}
</div>
{% for currency in journal_entry.currencies %}
<div class="d-flex justify-content-between accounting-journal-entry-order-item-currency">
<div>
{% if not journal_entry.is_cash_receipt %}
{% for line_item in currency.debit %}
<div>{{ line_item.description|accounting_default }}</div>
{% endfor %}
{% endif %}
{% if not journal_entry.is_cash_disbursement %}
{% for line_item in currency.credit %}
<div class="accounting-mobile-journal-credit">{{ line_item.description|accounting_default }}</div>
{% endfor %}
{% endif %}
</div>
<div>
<span class="badge bg-info rounded-pill">
{% if currency.code != accounting_default_currency_code() %}
{{ currency.code }}
{% endif %}
{{ currency.debit_total|accounting_format_amount }}
</span>
</div>
</div>
{% endfor %}
</a>

View File

@ -30,7 +30,7 @@ First written: 2023/2/26
{% block content %}
<div class="btn-group mb-3">
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
@ -47,9 +47,9 @@ First written: 2023/2/26
{% 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>
{% with journal_entry = item %}
{% include "accounting/journal-entry/include/order-journal-entry.html" %}
{% endwith %}
<i class="fa-solid fa-bars"></i>
</li>
{% endfor %}
@ -72,7 +72,9 @@ First written: 2023/2/26
<ul class="list-group mb-3">
{% for item in list %}
<li class="list-group-item">
{{ item }}
{% with journal_entry = item %}
{% include "accounting/journal-entry/include/order-journal-entry.html" %}
{% endwith %}
</li>
{% endfor %}
</ul>

View File

@ -21,10 +21,10 @@ First written: 2023/2/26
#}
{% extends "accounting/journal-entry/include/detail.html" %}
{% block to_transfer %}
{% block as_trasfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }}
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
</a>
{% endblock %}

View File

@ -19,57 +19,15 @@ form-currency.html: The currency sub-form in the cash receipt journal entry form
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 }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
<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-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
{% for currency in accounting_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-select">{{ 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>
{% extends "accounting/journal-entry/include/form-currency.html" %}
<div>
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% 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-line-item-list">
{% for line_item_form in credit_forms %}
{% with currency_index = currency_index,
debit_credit = "credit",
line_item_index = loop.index,
only_one_line_item_form = credit_forms|length == 1,
form = line_item_form.form %}
{% include "accounting/journal-entry/include/form-line-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 id="accounting-currency-{{ currency_index }}-credit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="credit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-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>
{% block line_items %}
{% with currency_index = currency_index,
debit_credit = "credit",
line_item_forms = credit_forms,
header = A_("Content"),
debit_credit_total = credit_total,
debit_credit_errors = credit_errors %}
{% include "accounting/journal-entry/include/form-debit-credit.html" %}
{% endwith %}
{% endblock %}

View File

@ -32,7 +32,7 @@ First written: 2023/2/25
currency_code_is_locked = currency_form.is_code_locked,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_format_amount %}
credit_total = currency_form.credit_total|accounting_format_amount %}
{% include "accounting/journal-entry/receipt/include/form-currency.html" %}
{% endwith %}
{% endfor %}

View File

@ -19,91 +19,30 @@ form-currency.html: The currency sub-form in the transfer journal entry form
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 }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
<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-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
{% for currency in accounting_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-select">{{ 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>
{% extends "accounting/journal-entry/include/form-currency.html" %}
<div>
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% 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 line items #}
<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-line-item-list">
{% for line_item_form in debit_forms %}
{% with currency_index = currency_index,
debit_credit = "debit",
line_item_index = loop.index,
only_one_line_item_form = debit_forms|length == 1,
form = line_item_form.form %}
{% include "accounting/journal-entry/include/form-line-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 id="accounting-currency-{{ currency_index }}-debit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="debit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-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 line items #}
<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-line-item-list">
{% for line_item_form in credit_forms %}
{% with currency_index = currency_index,
debit_credit = "credit",
line_item_index = loop.index,
only_one_line_item_form = credit_forms|length == 1,
form = line_item_form.form %}
{% include "accounting/journal-entry/include/form-line-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 id="accounting-currency-{{ currency_index }}-credit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="credit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-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>
{% block line_items %}
<div class="row">
<div class="col-sm-6">
{% with currency_index = currency_index,
debit_credit = "debit",
line_item_forms = debit_forms,
header = A_("Debit"),
debit_credit_total = debit_total,
debit_credit_errors = debit_errors %}
{% include "accounting/journal-entry/include/form-debit-credit.html" %}
{% endwith %}
</div>
<div class="col-sm-6">
{% with currency_index = currency_index,
debit_credit = "credit",
line_item_forms = credit_forms,
header = A_("Credit"),
debit_credit_total = credit_total,
debit_credit_errors = credit_errors %}
{% include "accounting/journal-entry/include/form-debit-credit.html" %}
{% endwith %}
</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
</div>
{% endblock %}

View File

@ -32,10 +32,10 @@ First written: 2023/2/25
currency_code_is_locked = currency_form.is_code_locked,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_format_amount,
debit_total = currency_form.debit_total|accounting_format_amount,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_format_amount %}
credit_total = currency_form.credit_total|accounting_format_amount %}
{% include "accounting/journal-entry/transfer/include/form-currency.html" %}
{% endwith %}
{% endfor %}

View File

@ -45,11 +45,11 @@ First written: 2023/3/8
</div>
{% endif %}
<div class="btn-group" role="group">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<button id="accounting-choose-report" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-book"></i>
<span class="d-none d-md-inline">{{ report.report_chooser.current_report }}</span>
<span class="d-none d-md-inline">{{ A_("Report") }}</span>
</button>
<ul class="dropdown-menu" aria-label="{{ A_("Report") }}">
<ul class="dropdown-menu" aria-labelledby="accounting-choose-report">
{% for report in report.report_chooser %}
<li>
<a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">
@ -68,11 +68,11 @@ First written: 2023/3/8
</div>
{% if use_currency_chooser %}
<div class="btn-group" role="group">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<button id="accounting-choose-currency" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
<span class="d-none d-md-inline">{{ report.currency.name|title }}</span>
<span class="d-none d-md-inline">{{ A_("Currency") }}</span>
</button>
<ul class="dropdown-menu" aria-label="{{ A_("Currency") }}">
<ul class="dropdown-menu" aria-labelledby="accounting-choose-currency">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
@ -85,11 +85,11 @@ First written: 2023/3/8
{% endif %}
{% if use_account_chooser %}
<div class="btn-group" role="group">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<button id="accounting-choose-account" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-clipboard"></i>
<span class="d-none d-md-inline">{{ report.account.title|title }}</span>
<span class="d-none d-md-inline">{{ A_("Account") }}</span>
</button>
<ul class="dropdown-menu" aria-label="{{ A_("Account") }}">
<ul class="dropdown-menu" aria-labelledby="accounting-choose-account">
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
@ -103,7 +103,7 @@ First written: 2023/3/8
{% if use_period_chooser %}
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
<i class="fa-solid fa-calendar-day"></i>
<span class="d-none d-md-inline">{{ report.period.desc|title }}</span>
<span class="d-none d-md-inline">{{ A_("Period") }}</span>
</button>
{% endif %}
{% if report.has_data %}

View File

@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-03-20 23:51+0800\n"
"PO-Revision-Date: 2023-03-20 23:51+0800\n"
"POT-Creation-Date: 2023-03-22 01:47+0800\n"
"PO-Revision-Date: 2023-03-22 01:47+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -19,22 +19,22 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.12.1\n"
#: src/accounting/models.py:538
#: src/accounting/models.py:559
#, python-format
msgid "Cash Disbursement Journal Entry#%(id)s"
msgstr "現金支出傳票#%(id)s"
#: src/accounting/models.py:541
#: src/accounting/models.py:562
#, python-format
msgid "Cash Receipt Journal Entry#%(id)s"
msgstr "現金收入傳票#%(id)s"
#: src/accounting/models.py:542
#: src/accounting/models.py:563
#, python-format
msgid "Transfer Journal Entry#%(id)s"
msgstr "轉帳傳票#%(id)s"
#: src/accounting/models.py:679
#: src/accounting/models.py:696
#, python-format
msgid "%(date)s %(description)s %(amount)s"
msgstr "%(date)s %(description)s %(amount)s"
@ -86,7 +86,7 @@ msgstr "請填上標題。"
#: src/accounting/account/queries.py:50
#: src/accounting/report/reports/search.py:101
#: src/accounting/templates/accounting/account/detail.html:90
#: src/accounting/templates/accounting/account/detail.html:97
#: src/accounting/templates/accounting/account/list.html:62
msgid "Needs Offset"
msgstr "需抵銷"
@ -103,17 +103,21 @@ msgstr "科目未異動。"
msgid "The account is updated successfully."
msgstr "科目存好了。"
#: src/accounting/account/views.py:163
#: src/accounting/account/views.py:161
msgid "The account cannot be deleted."
msgstr "科目不可刪除。"
#: src/accounting/account/views.py:166
msgid "The account is deleted successfully."
msgstr "科目刪掉了"
#: src/accounting/account/views.py:190
#: src/accounting/journal_entry/views.py:213
#: src/accounting/account/views.py:193
#: src/accounting/journal_entry/views.py:216
msgid "The order was not modified."
msgstr "順序未異動。"
#: src/accounting/account/views.py:193
#: src/accounting/journal_entry/views.py:216
#: src/accounting/account/views.py:196
#: src/accounting/journal_entry/views.py:219
msgid "The order is updated successfully."
msgstr "順序存好了。"
@ -154,7 +158,11 @@ msgstr "貨幣未異動。"
msgid "The currency is updated successfully."
msgstr "貨幣存好了。"
#: src/accounting/currency/views.py:165
#: src/accounting/currency/views.py:164
msgid "The currency cannot be deleted."
msgstr "貨幣不可刪除。"
#: src/accounting/currency/views.py:168
msgid "The currency is deleted successfully."
msgstr "貨幣刪掉了"
@ -170,7 +178,11 @@ msgstr "傳票未異動。"
msgid "The journal entry is updated successfully."
msgstr "傳票存好了。"
#: src/accounting/journal_entry/views.py:181
#: src/accounting/journal_entry/views.py:179
msgid "The journal entry cannot be deleted."
msgstr "傳票不可刪除。"
#: src/accounting/journal_entry/views.py:184
msgid "The journal entry is deleted successfully."
msgstr "傳票刪掉了"
@ -358,13 +370,10 @@ msgstr "全部"
#: src/accounting/report/reports/ledger.py:380
#: src/accounting/report/reports/trial_balance.py:228
#: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:43
#: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:60
#: src/accounting/templates/accounting/journal-entry/include/form-debit-credit.html:37
#: src/accounting/templates/accounting/journal-entry/receipt/detail.html:43
#: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:60
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:39
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:55
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:62
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:93
#: src/accounting/templates/accounting/report/balance-sheet.html:59
#: src/accounting/templates/accounting/report/balance-sheet.html:71
#: src/accounting/templates/accounting/report/balance-sheet.html:81
@ -399,7 +408,7 @@ msgstr "日期"
#: src/accounting/report/reports/journal.py:156
#: src/accounting/report/reports/trial_balance.py:224
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:57
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:92
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:90
#: src/accounting/templates/accounting/report/income-expenses.html:56
#: src/accounting/templates/accounting/report/journal.html:55
#: src/accounting/templates/accounting/report/search.html:52
@ -439,7 +448,7 @@ msgstr "餘額"
#: src/accounting/report/reports/income_expenses.py:416
#: src/accounting/report/reports/journal.py:158
#: src/accounting/report/reports/ledger.py:368
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:172
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:178
#: src/accounting/templates/accounting/journal-entry/include/form.html:73
msgid "Note"
msgstr "備註"
@ -475,10 +484,8 @@ msgid "Amount"
msgstr "金額"
#: src/accounting/report/reports/journal.py:155
#: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:33
#: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:33
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:33
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:75
#: src/accounting/templates/accounting/journal-entry/include/form-currency.html:33
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:73
#: src/accounting/templates/accounting/report/journal.html:54
#: src/accounting/templates/accounting/report/search.html:51
msgid "Currency"
@ -488,7 +495,7 @@ msgstr "貨幣"
#: src/accounting/report/reports/ledger.py:367
#: src/accounting/report/reports/trial_balance.py:224
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:48
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:30
#: src/accounting/templates/accounting/report/journal.html:57
#: src/accounting/templates/accounting/report/ledger.html:57
#: src/accounting/templates/accounting/report/search.html:54
@ -500,7 +507,7 @@ msgstr "借方"
#: src/accounting/report/reports/ledger.py:367
#: src/accounting/report/reports/trial_balance.py:225
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:79
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:41
#: src/accounting/templates/accounting/report/journal.html:58
#: src/accounting/templates/accounting/report/ledger.html:58
#: src/accounting/templates/accounting/report/search.html:55
@ -555,25 +562,73 @@ msgstr "資產負債表"
msgid "Please fill in the title."
msgstr "請填上標題。"
#: src/accounting/static/js/description-editor.js:767
#: src/accounting/static/js/description-editor.js:953
#: src/accounting/static/js/description-editor.js:756
#: src/accounting/static/js/description-editor.js:934
msgid "Please fill in the tag."
msgstr "請填上標籤。"
#: src/accounting/static/js/description-editor.js:777
#: src/accounting/static/js/description-editor.js:973
#: src/accounting/static/js/description-editor.js:766
#: src/accounting/static/js/description-editor.js:954
msgid "Please fill in the origin."
msgstr "請填上起點。"
#: src/accounting/static/js/description-editor.js:787
#: src/accounting/static/js/description-editor.js:983
#: src/accounting/static/js/description-editor.js:776
#: src/accounting/static/js/description-editor.js:964
msgid "Please fill in the destination."
msgstr "請填上終點。"
#: src/accounting/static/js/description-editor.js:963
#: src/accounting/static/js/description-editor.js:944
msgid "Please fill in the route."
msgstr "請填上路線名稱。"
#: src/accounting/static/js/description-editor.js:998
msgid "January"
msgstr "一月"
#: src/accounting/static/js/description-editor.js:998
msgid "February"
msgstr "二月"
#: src/accounting/static/js/description-editor.js:998
msgid "March"
msgstr "三月"
#: src/accounting/static/js/description-editor.js:998
msgid "April"
msgstr "四月"
#: src/accounting/static/js/description-editor.js:999
msgid "May"
msgstr "五月"
#: src/accounting/static/js/description-editor.js:999
msgid "June"
msgstr "六月"
#: src/accounting/static/js/description-editor.js:999
msgid "July"
msgstr "七月"
#: src/accounting/static/js/description-editor.js:999
msgid "August"
msgstr "八月"
#: src/accounting/static/js/description-editor.js:1000
msgid "September"
msgstr "九月"
#: src/accounting/static/js/description-editor.js:1000
msgid "October"
msgstr "十月"
#: src/accounting/static/js/description-editor.js:1000
msgid "November"
msgstr "十一月"
#: src/accounting/static/js/description-editor.js:1000
msgid "December"
msgstr "十二月"
#: src/accounting/static/js/journal-entry-form.js:985
#: src/accounting/static/js/journal-entry-line-item-editor.js:449
msgid "Please fill in the amount."
@ -629,28 +684,30 @@ msgstr "回上頁"
#: src/accounting/templates/accounting/account/detail.html:36
#: src/accounting/templates/accounting/currency/detail.html:36
#: src/accounting/templates/accounting/journal-entry/include/detail.html:36
msgid "Settings"
msgstr "設定"
msgid "Edit"
msgstr "編輯"
#: src/accounting/templates/accounting/account/detail.html:41
#: src/accounting/templates/accounting/journal-entry/include/detail.html:41
msgid "Order"
msgstr "次序"
#: src/accounting/templates/accounting/account/detail.html:46
#: src/accounting/templates/accounting/currency/detail.html:42
#: src/accounting/templates/accounting/account/detail.html:47
#: src/accounting/templates/accounting/account/detail.html:52
#: src/accounting/templates/accounting/currency/detail.html:43
#: src/accounting/templates/accounting/currency/detail.html:48
#: src/accounting/templates/accounting/journal-entry/include/detail.html:48
#: src/accounting/templates/accounting/journal-entry/include/detail.html:53
msgid "Delete"
msgstr "刪除"
#: src/accounting/templates/accounting/account/detail.html:69
#: src/accounting/templates/accounting/account/detail.html:76
msgid "Confirm Delete Account"
msgstr "刪除科目確認"
msgstr "確認刪除科目"
#: src/accounting/templates/accounting/account/detail.html:70
#: src/accounting/templates/accounting/account/detail.html:77
#: src/accounting/templates/accounting/account/include/form.html:91
#: src/accounting/templates/accounting/currency/detail.html:66
#: src/accounting/templates/accounting/currency/detail.html:73
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:27
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:30
#: src/accounting/templates/accounting/journal-entry/include/detail.html:78
@ -661,36 +718,36 @@ msgstr "刪除科目確認"
msgid "Close"
msgstr "關閉"
#: src/accounting/templates/accounting/account/detail.html:73
#: src/accounting/templates/accounting/account/detail.html:80
msgid "Do you really want to delete this account?"
msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/account/detail.html:76
#: src/accounting/templates/accounting/account/detail.html:83
#: src/accounting/templates/accounting/account/include/form.html:112
#: src/accounting/templates/accounting/currency/detail.html:72
#: src/accounting/templates/accounting/currency/detail.html:79
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:49
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:187
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:193
#: src/accounting/templates/accounting/journal-entry/include/detail.html:84
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:70
#: src/accounting/templates/accounting/report/include/search-modal.html:37
msgid "Cancel"
msgstr "取消"
#: src/accounting/templates/accounting/account/detail.html:77
#: src/accounting/templates/accounting/currency/detail.html:73
#: src/accounting/templates/accounting/account/detail.html:84
#: src/accounting/templates/accounting/currency/detail.html:80
#: src/accounting/templates/accounting/journal-entry/include/detail.html:85
#: src/accounting/templates/accounting/report/include/period-chooser.html:141
msgid "Confirm"
msgstr "確定"
#: src/accounting/templates/accounting/account/detail.html:94
#: src/accounting/templates/accounting/currency/detail.html:85
#: src/accounting/templates/accounting/account/detail.html:101
#: src/accounting/templates/accounting/currency/detail.html:92
#: src/accounting/templates/accounting/journal-entry/include/detail.html:114
msgid "Created"
msgstr "建檔"
#: src/accounting/templates/accounting/account/detail.html:95
#: src/accounting/templates/accounting/currency/detail.html:86
#: src/accounting/templates/accounting/account/detail.html:102
#: src/accounting/templates/accounting/currency/detail.html:93
#: src/accounting/templates/accounting/journal-entry/include/detail.html:115
msgid "Updated"
msgstr "更新"
@ -714,11 +771,8 @@ msgstr "科目管理"
#: src/accounting/templates/accounting/account/list.html:32
#: src/accounting/templates/accounting/currency/list.html:32
#: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:67
#: src/accounting/templates/accounting/journal-entry/include/form-debit-credit.html:44
#: src/accounting/templates/accounting/journal-entry/include/form.html:64
#: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:67
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:69
#: src/accounting/templates/accounting/journal-entry/transfer/include/form-currency.html:100
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:26
msgid "New"
msgstr "新增"
@ -730,7 +784,7 @@ msgstr "新增"
#: src/accounting/templates/accounting/currency/list.html:65
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:46
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:51
#: src/accounting/templates/accounting/journal-entry/order.html:80
#: src/accounting/templates/accounting/journal-entry/order.html:82
#: src/accounting/templates/accounting/report/balance-sheet.html:110
#: src/accounting/templates/accounting/report/income-expenses.html:113
#: src/accounting/templates/accounting/report/income-statement.html:96
@ -749,7 +803,7 @@ msgstr "%(base)s下的科目"
#: src/accounting/templates/accounting/account/include/form.html:75
#: src/accounting/templates/accounting/account/order.html:62
#: src/accounting/templates/accounting/currency/include/form.html:57
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:188
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:194
#: src/accounting/templates/accounting/journal-entry/include/form.html:80
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:71
#: src/accounting/templates/accounting/journal-entry/order.html:61
@ -790,11 +844,11 @@ msgstr "基本科目管理"
msgid "Add a New Currency"
msgstr "新增貨幣"
#: src/accounting/templates/accounting/currency/detail.html:65
#: src/accounting/templates/accounting/currency/detail.html:72
msgid "Confirm Delete Currency"
msgstr "刪除貨幣確認"
msgstr "確認刪除貨幣"
#: src/accounting/templates/accounting/currency/detail.html:69
#: src/accounting/templates/accounting/currency/detail.html:76
msgid "Do you really want to delete this currency?"
msgstr "你確定要刪掉這個貨幣嗎?"
@ -850,14 +904,14 @@ msgstr "新增現金支出傳票"
#: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:27
#: src/accounting/templates/accounting/journal-entry/receipt/detail.html:27
msgid "To Transfer"
msgid "As Transfer"
msgstr "改轉帳"
#: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:37
#: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:46
#: src/accounting/templates/accounting/journal-entry/disbursement/include/form-currency.html:28
#: src/accounting/templates/accounting/journal-entry/include/form.html:56
#: src/accounting/templates/accounting/journal-entry/receipt/detail.html:37
#: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:46
#: src/accounting/templates/accounting/journal-entry/receipt/include/form-currency.html:28
msgid "Content"
msgstr "內容"
@ -893,8 +947,8 @@ msgid "Bus"
msgstr "公車"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:59
msgid "Regular"
msgstr "帳單"
msgid "Recurring"
msgstr "常用"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:64
msgid "Annotation"
@ -922,7 +976,7 @@ msgstr "至"
msgid "Route"
msgstr "路線"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:166
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:172
msgid "The Number of Items"
msgstr "數量"
@ -948,7 +1002,7 @@ msgstr "未抵銷"
#: src/accounting/templates/accounting/journal-entry/include/detail.html:77
msgid "Confirm Delete Journal Entry"
msgstr "刪除傳票確認"
msgstr "確認刪除傳票"
#: src/accounting/templates/accounting/journal-entry/include/detail.html:81
msgid "Do you really want to delete this journal entry?"
@ -962,6 +1016,24 @@ msgstr "分錄內容"
msgid "Original Line Item"
msgstr "原始分錄"
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:26
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:26
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:31
msgid "Cash Disbursement"
msgstr "現金支出"
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:28
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:29
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:36
msgid "Cash Receipt"
msgstr "現金收入"
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:30
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:32
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:41
msgid "Transfer"
msgstr "轉帳"
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:26
msgid "Select Original Line Item"
msgstr "選擇原始分錄"
@ -1007,21 +1079,6 @@ msgstr "%(period)s%(currency)s%(account)s分類帳"
msgid "Trial Balance of %(currency)s %(period)s"
msgstr "%(period)s%(currency)s試算表"
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:26
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:31
msgid "Cash Disbursement"
msgstr "現金支出"
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:29
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:36
msgid "Cash Receipt"
msgstr "現金收入"
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:32
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:41
msgid "Transfer"
msgstr "轉帳"
#: src/accounting/templates/accounting/report/include/period-chooser.html:26
msgid "Period Chooser"
msgstr "選擇日期範圍"
@ -1042,10 +1099,14 @@ msgstr "日"
msgid "Custom"
msgstr "自訂"
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:52
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:50
msgid "Report"
msgstr "報表"
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:106
msgid "Period"
msgstr "期間"
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:112
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:117
msgid "Download"

View File

@ -18,7 +18,7 @@
"""
import unittest
from datetime import timedelta
from datetime import timedelta, date
import httpx
import sqlalchemy as sa
@ -28,6 +28,7 @@ from flask.testing import FlaskCliRunner
from test_site import db
from testlib import create_test_app, get_client, set_locale
from testlib_journal_entry import add_journal_entry
NEXT_URI: str = "/_next"
"""The next URI."""
@ -53,13 +54,15 @@ class AccountData:
"""The code."""
cash: AccountData = AccountData("1111", 1, "Cash")
CASH: AccountData = AccountData("1111", 1, "Cash")
"""The cash account."""
bank: AccountData = AccountData("1113", 1, "Bank")
PETTY: AccountData = AccountData("1112", 1, "Bank")
"""The petty cash account."""
BANK: AccountData = AccountData("1113", 1, "Bank")
"""The bank account."""
stock: AccountData = AccountData("1121", 1, "Stock")
STOCK: AccountData = AccountData("1121", 1, "Stock")
"""The stock account."""
loan: AccountData = AccountData("2112", 1, "Loan")
LOAN: AccountData = AccountData("2112", 1, "Loan")
"""The loan account."""
PREFIX: str = "/accounting/accounts"
"""The URL prefix for the account management."""
@ -147,19 +150,19 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": cash.title})
"base_code": CASH.base_code,
"title": CASH.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{cash.code}")
f"{PREFIX}/{CASH.code}")
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": bank.base_code,
"title": bank.title})
"base_code": BANK.base_code,
"title": BANK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{bank.code}")
f"{PREFIX}/{BANK.code}")
def test_nobody(self) -> None:
"""Test the permission as nobody.
@ -173,7 +176,7 @@ class AccountTestCase(unittest.TestCase):
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{cash.code}")
response = client.get(f"{PREFIX}/{CASH.code}")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/create")
@ -181,33 +184,33 @@ class AccountTestCase(unittest.TestCase):
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"base_code": stock.base_code,
"title": stock.title})
"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{cash.code}/edit")
response = client.get(f"{PREFIX}/{CASH.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/update",
response = client.post(f"{PREFIX}/{CASH.code}/update",
data={"csrf_token": csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
"base_code": CASH.base_code,
"title": f"{CASH.title}-2"})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/delete",
response = client.post(f"{PREFIX}/{BANK.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
response = client.get(f"{PREFIX}/bases/{CASH.base_code}")
self.assertEqual(response.status_code, 403)
with self.app.app_context():
bank_id: int = Account.find_by_code(bank.code).id
cash_id: int = Account.find_by_code(CASH.code).id
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": csrf_token,
"next": NEXT_URI,
f"{bank_id}-no": "5"})
f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
@ -222,7 +225,7 @@ class AccountTestCase(unittest.TestCase):
response = client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/{cash.code}")
response = client.get(f"{PREFIX}/{CASH.code}")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/create")
@ -230,33 +233,33 @@ class AccountTestCase(unittest.TestCase):
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"base_code": stock.base_code,
"title": stock.title})
"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{cash.code}/edit")
response = client.get(f"{PREFIX}/{CASH.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/update",
response = client.post(f"{PREFIX}/{CASH.code}/update",
data={"csrf_token": csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
"base_code": CASH.base_code,
"title": f"{CASH.title}-2"})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/delete",
response = client.post(f"{PREFIX}/{BANK.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
response = client.get(f"{PREFIX}/bases/{CASH.base_code}")
self.assertEqual(response.status_code, 200)
with self.app.app_context():
bank_id: int = Account.find_by_code(bank.code).id
cash_id: int = Account.find_by_code(CASH.code).id
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": csrf_token,
"next": NEXT_URI,
f"{bank_id}-no": "5"})
f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 403)
def test_editor(self) -> None:
@ -270,7 +273,7 @@ class AccountTestCase(unittest.TestCase):
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{cash.code}")
response = self.client.get(f"{PREFIX}/{CASH.code}")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/create")
@ -278,37 +281,37 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.code}")
f"{PREFIX}/{STOCK.code}")
response = self.client.get(f"{PREFIX}/{cash.code}/edit")
response = self.client.get(f"{PREFIX}/{CASH.code}/edit")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{cash.code}/update",
response = self.client.post(f"{PREFIX}/{CASH.code}/update",
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
"base_code": CASH.base_code,
"title": f"{CASH.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}")
self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}")
response = self.client.post(f"{PREFIX}/{cash.code}/delete",
response = self.client.post(f"{PREFIX}/{BANK.code}/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], PREFIX)
response = self.client.get(f"{PREFIX}/bases/{cash.base_code}")
response = self.client.get(f"{PREFIX}/bases/{CASH.base_code}")
self.assertEqual(response.status_code, 200)
with self.app.app_context():
bank_id: int = Account.find_by_code(bank.code).id
cash_id: int = Account.find_by_code(CASH.code).id
response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
response = self.client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": self.csrf_token,
"next": NEXT_URI,
f"{bank_id}-no": "5"})
f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -320,31 +323,31 @@ class AccountTestCase(unittest.TestCase):
from accounting.models import Account
create_uri: str = f"{PREFIX}/create"
store_uri: str = f"{PREFIX}/store"
detail_uri: str = f"{PREFIX}/{stock.code}"
detail_uri: str = f"{PREFIX}/{STOCK.code}"
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code})
{CASH.code, BANK.code})
# Missing CSRF token
response = self.client.post(store_uri,
data={"base_code": stock.base_code,
"title": stock.title})
data={"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 400)
# CSRF token mismatch
response = self.client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2",
"base_code": stock.base_code,
"title": stock.title})
"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 400)
# Empty base account code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": " ",
"title": stock.title})
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
@ -352,7 +355,7 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "9999",
"title": stock.title})
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
@ -360,14 +363,14 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "1",
"title": stock.title})
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Empty name
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"base_code": STOCK.base_code,
"title": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
@ -376,7 +379,7 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "6172",
"title": stock.title,
"title": STOCK.title,
"is_need_offset": "yes"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
@ -384,43 +387,43 @@ class AccountTestCase(unittest.TestCase):
# Success, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {stock.base_code} ",
"title": f" {stock.title} "})
"base_code": f" {STOCK.base_code} ",
"title": f" {STOCK.title} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
# Success under the same base
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.base_code}-002")
f"{PREFIX}/{STOCK.base_code}-002")
# Success under the same base, with order in a mess.
with self.app.app_context():
stock_2: Account = Account.find_by_code(f"{stock.base_code}-002")
stock_2: Account = Account.find_by_code(f"{STOCK.base_code}-002")
stock_2.no = 66
db.session.commit()
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.base_code}-003")
f"{PREFIX}/{STOCK.base_code}-003")
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code, stock.code,
f"{stock.base_code}-002",
f"{stock.base_code}-003"})
{CASH.code, BANK.code, STOCK.code,
f"{STOCK.base_code}-002",
f"{STOCK.base_code}-003"})
account: Account = Account.find_by_code(stock.code)
self.assertEqual(account.base_code, stock.base_code)
self.assertEqual(account.title_l10n, stock.title)
account: Account = Account.find_by_code(STOCK.code)
self.assertEqual(account.base_code, STOCK.base_code)
self.assertEqual(account.title_l10n, STOCK.title)
def test_basic_update(self) -> None:
"""Tests the basic rules to update a user.
@ -428,30 +431,30 @@ class AccountTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
edit_uri: str = f"{PREFIX}/{cash.code}/edit"
update_uri: str = f"{PREFIX}/{cash.code}/update"
detail_c_uri: str = f"{PREFIX}/{stock.code}"
detail_uri: str = f"{PREFIX}/{CASH.code}"
edit_uri: str = f"{PREFIX}/{CASH.code}/edit"
update_uri: str = f"{PREFIX}/{CASH.code}/update"
detail_c_uri: str = f"{PREFIX}/{STOCK.code}"
response: httpx.Response
# Success, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {cash.base_code} ",
"title": f" {cash.title}-1 "})
"base_code": f" {CASH.base_code} ",
"title": f" {CASH.title}-1 "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
account: Account = Account.find_by_code(cash.code)
self.assertEqual(account.base_code, cash.base_code)
self.assertEqual(account.title_l10n, f"{cash.title}-1")
account: Account = Account.find_by_code(CASH.code)
self.assertEqual(account.base_code, CASH.base_code)
self.assertEqual(account.title_l10n, f"{CASH.title}-1")
# Empty base account code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": " ",
"title": stock.title})
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -459,7 +462,7 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "9999",
"title": stock.title})
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -467,14 +470,14 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "1",
"title": stock.title})
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Empty name
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"base_code": STOCK.base_code,
"title": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -483,7 +486,7 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "6172",
"title": stock.title,
"title": STOCK.title,
"is_need_offset": "yes"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -491,8 +494,8 @@ class AccountTestCase(unittest.TestCase):
# Change the base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_c_uri)
@ -508,20 +511,20 @@ class AccountTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
detail_uri: str = f"{PREFIX}/{CASH.code}"
update_uri: str = f"{PREFIX}/{CASH.code}/update"
account: Account
response: httpx.Response
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {cash.base_code} ",
"title": f" {cash.title} "})
"base_code": f" {CASH.base_code} ",
"title": f" {CASH.title} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
account = Account.find_by_code(cash.code)
account = Account.find_by_code(CASH.code)
self.assertIsNotNone(account)
account.created_at \
= account.created_at - timedelta(seconds=5)
@ -530,13 +533,13 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": stock.title})
"base_code": CASH.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
account = Account.find_by_code(cash.code)
account = Account.find_by_code(CASH.code)
self.assertIsNotNone(account)
self.assertLess(account.created_at,
account.updated_at)
@ -549,25 +552,25 @@ class AccountTestCase(unittest.TestCase):
from accounting.models import Account
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
detail_uri: str = f"{PREFIX}/{CASH.code}"
update_uri: str = f"{PREFIX}/{CASH.code}/update"
account: Account
response: httpx.Response
with self.app.app_context():
account = Account.find_by_code(cash.code)
account = Account.find_by_code(CASH.code)
self.assertEqual(account.created_by.username, editor_username)
self.assertEqual(account.updated_by.username, editor_username)
response = client.post(update_uri,
data={"csrf_token": csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
"base_code": CASH.base_code,
"title": f"{CASH.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
account = Account.find_by_code(cash.code)
account = Account.find_by_code(CASH.code)
self.assertEqual(account.created_by.username,
editor_username)
self.assertEqual(account.updated_by.username,
@ -579,60 +582,60 @@ class AccountTestCase(unittest.TestCase):
:return: None
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
detail_uri: str = f"{PREFIX}/{CASH.code}"
update_uri: str = f"{PREFIX}/{CASH.code}/update"
account: Account
response: httpx.Response
with self.app.app_context():
account = Account.find_by_code(cash.code)
self.assertEqual(account.title_l10n, cash.title)
account = Account.find_by_code(CASH.code)
self.assertEqual(account.title_l10n, CASH.title)
self.assertEqual(account.l10n, [])
set_locale(self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-zh_Hant"})
"base_code": CASH.base_code,
"title": f"{CASH.title}-zh_Hant"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
account = Account.find_by_code(cash.code)
self.assertEqual(account.title_l10n, cash.title)
account = Account.find_by_code(CASH.code)
self.assertEqual(account.title_l10n, CASH.title)
self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant")})
{("zh_Hant", f"{CASH.title}-zh_Hant")})
set_locale(self.client, self.csrf_token, "en")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
"base_code": CASH.base_code,
"title": f"{CASH.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
account = Account.find_by_code(cash.code)
self.assertEqual(account.title_l10n, f"{cash.title}-2")
account = Account.find_by_code(CASH.code)
self.assertEqual(account.title_l10n, f"{CASH.title}-2")
self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant")})
{("zh_Hant", f"{CASH.title}-zh_Hant")})
set_locale(self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-zh_Hant-2"})
"base_code": CASH.base_code,
"title": f"{CASH.title}-zh_Hant-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
account = Account.find_by_code(cash.code)
self.assertEqual(account.title_l10n, f"{cash.title}-2")
account = Account.find_by_code(CASH.code)
self.assertEqual(account.title_l10n, f"{CASH.title}-2")
self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant-2")})
{("zh_Hant", f"{CASH.title}-zh_Hant-2")})
def test_delete(self) -> None:
"""Tests to delete a currency.
@ -640,15 +643,51 @@ class AccountTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
delete_uri: str = f"{PREFIX}/{cash.code}/delete"
detail_uri: str = f"{PREFIX}/{PETTY.code}"
delete_uri: str = f"{PREFIX}/{PETTY.code}/delete"
list_uri: str = PREFIX
response: httpx.Response
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": PETTY.base_code,
"title": PETTY.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
response = self.client.post("/accounting/currencies/store",
data={"csrf_token": self.csrf_token,
"code": "USD",
"name": "US Dollars"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/currencies/USD")
add_journal_entry(self.client,
form={"csrf_token": self.csrf_token,
"next": NEXT_URI,
"date": date.today().isoformat(),
"currency-1-code": "USD",
"currency-1-credit-1-account_code": BANK.code,
"currency-1-credit-1-amount": "20"})
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code})
{CASH.code, PETTY.code, BANK.code})
# Cannot delete the cash account
response = self.client.post(f"{PREFIX}/{CASH.code}/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}")
# Cannot delete the account that is in use
response = self.client.post(f"{PREFIX}/{BANK.code}/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{BANK.code}")
# Success
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
@ -658,7 +697,7 @@ class AccountTestCase(unittest.TestCase):
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{bank.code})
{CASH.code, BANK.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)

View File

@ -20,7 +20,7 @@
import csv
import typing as t
import unittest
from datetime import timedelta
from datetime import timedelta, date
import httpx
from click.testing import Result
@ -29,6 +29,7 @@ from flask.testing import FlaskCliRunner
from test_site import db
from testlib import create_test_app, get_client, set_locale
from testlib_journal_entry import add_journal_entry, NEXT_URI
class CurrencyData:
@ -46,14 +47,14 @@ class CurrencyData:
"""The name."""
zza: CurrencyData = CurrencyData("ZZA", "Testing Dollar #A")
"""The first test currency."""
zzb: CurrencyData = CurrencyData("ZZB", "Testing Dollar #B")
"""The second test currency."""
zzc: CurrencyData = CurrencyData("ZZC", "Testing Dollar #C")
"""The third test currency."""
zzd: CurrencyData = CurrencyData("ZZD", "Testing Dollar #D")
"""The fourth test currency."""
USD: CurrencyData = CurrencyData("USD", "US Dollar")
"""The US dollars."""
EUR: CurrencyData = CurrencyData("EUR", "Euro")
"""The European dollars."""
TWD: CurrencyData = CurrencyData("TWD", "Taiwan dollars")
"""The Taiwan dollars."""
JPY: CurrencyData = CurrencyData("JPY", "Japanese yen")
"""The Japanese yen."""
PREFIX: str = "/accounting/currencies"
"""The URL prefix for the currency management."""
@ -140,17 +141,17 @@ class CurrencyTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": zza.name})
"code": USD.code,
"name": USD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zza.code}")
self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}")
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": zzb.code,
"name": zzb.name})
"code": EUR.code,
"name": EUR.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzb.code}")
self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}")
def test_nobody(self) -> None:
"""Test the permission as nobody.
@ -163,7 +164,7 @@ class CurrencyTestCase(unittest.TestCase):
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{zza.code}")
response = client.get(f"{PREFIX}/{USD.code}")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/create")
@ -171,20 +172,20 @@ class CurrencyTestCase(unittest.TestCase):
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"code": zzc.code,
"name": zzc.name})
"code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{zza.code}/edit")
response = client.get(f"{PREFIX}/{USD.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zza.code}/update",
response = client.post(f"{PREFIX}/{USD.code}/update",
data={"csrf_token": csrf_token,
"code": zzd.code,
"name": zzd.name})
"code": JPY.code,
"name": JPY.name})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zzb.code}/delete",
response = client.post(f"{PREFIX}/{EUR.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
@ -199,7 +200,7 @@ class CurrencyTestCase(unittest.TestCase):
response = client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/{zza.code}")
response = client.get(f"{PREFIX}/{USD.code}")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/create")
@ -207,20 +208,20 @@ class CurrencyTestCase(unittest.TestCase):
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"code": zzc.code,
"name": zzc.name})
"code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{zza.code}/edit")
response = client.get(f"{PREFIX}/{USD.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zza.code}/update",
response = client.post(f"{PREFIX}/{USD.code}/update",
data={"csrf_token": csrf_token,
"code": zzd.code,
"name": zzd.name})
"code": JPY.code,
"name": JPY.name})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zzb.code}/delete",
response = client.post(f"{PREFIX}/{EUR.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
@ -234,7 +235,7 @@ class CurrencyTestCase(unittest.TestCase):
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{zza.code}")
response = self.client.get(f"{PREFIX}/{USD.code}")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/create")
@ -242,22 +243,22 @@ class CurrencyTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": zzc.name})
"code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzc.code}")
self.assertEqual(response.headers["Location"], f"{PREFIX}/{TWD.code}")
response = self.client.get(f"{PREFIX}/{zza.code}/edit")
response = self.client.get(f"{PREFIX}/{USD.code}/edit")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{zza.code}/update",
response = self.client.post(f"{PREFIX}/{USD.code}/update",
data={"csrf_token": self.csrf_token,
"code": zzd.code,
"name": zzd.name})
"code": JPY.code,
"name": JPY.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzd.code}")
self.assertEqual(response.headers["Location"], f"{PREFIX}/{JPY.code}")
response = self.client.post(f"{PREFIX}/{zzb.code}/delete",
response = self.client.post(f"{PREFIX}/{EUR.code}/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], PREFIX)
@ -270,31 +271,31 @@ class CurrencyTestCase(unittest.TestCase):
from accounting.models import Currency
create_uri: str = f"{PREFIX}/create"
store_uri: str = f"{PREFIX}/store"
detail_uri: str = f"{PREFIX}/{zzc.code}"
detail_uri: str = f"{PREFIX}/{TWD.code}"
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code})
{USD.code, EUR.code})
# Missing CSRF token
response = self.client.post(store_uri,
data={"code": zzc.code,
"name": zzc.name})
data={"code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 400)
# CSRF token mismatch
response = self.client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2",
"code": zzc.code,
"name": zzc.name})
"code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 400)
# Empty code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " ",
"name": zzc.name})
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
@ -302,7 +303,7 @@ class CurrencyTestCase(unittest.TestCase):
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " create ",
"name": zzc.name})
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
@ -310,14 +311,14 @@ class CurrencyTestCase(unittest.TestCase):
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " zzc ",
"name": zzc.name})
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Empty name
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"code": TWD.code,
"name": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
@ -325,26 +326,26 @@ class CurrencyTestCase(unittest.TestCase):
# Success, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": f" {zzc.code} ",
"name": f" {zzc.name} "})
"code": f" {TWD.code} ",
"name": f" {TWD.name} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
# Duplicated code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": zzc.name})
"code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code, zzc.code})
{USD.code, EUR.code, TWD.code})
currency: Currency = db.session.get(Currency, zzc.code)
self.assertEqual(currency.code, zzc.code)
self.assertEqual(currency.name_l10n, zzc.name)
currency: Currency = db.session.get(Currency, TWD.code)
self.assertEqual(currency.code, TWD.code)
self.assertEqual(currency.name_l10n, TWD.name)
def test_basic_update(self) -> None:
"""Tests the basic rules to update a user.
@ -352,30 +353,30 @@ class CurrencyTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{zza.code}"
edit_uri: str = f"{PREFIX}/{zza.code}/edit"
update_uri: str = f"{PREFIX}/{zza.code}/update"
detail_c_uri: str = f"{PREFIX}/{zzc.code}"
detail_uri: str = f"{PREFIX}/{USD.code}"
edit_uri: str = f"{PREFIX}/{USD.code}/edit"
update_uri: str = f"{PREFIX}/{USD.code}/update"
detail_c_uri: str = f"{PREFIX}/{TWD.code}"
response: httpx.Response
# Success, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": f" {zza.code} ",
"name": f" {zza.name}-1 "})
"code": f" {USD.code} ",
"name": f" {USD.name}-1 "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(currency.code, zza.code)
self.assertEqual(currency.name_l10n, f"{zza.name}-1")
currency: Currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.code, USD.code)
self.assertEqual(currency.name_l10n, f"{USD.name}-1")
# Empty code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": " ",
"name": zzc.name})
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -383,7 +384,7 @@ class CurrencyTestCase(unittest.TestCase):
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": " create ",
"name": zzc.name})
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -391,14 +392,14 @@ class CurrencyTestCase(unittest.TestCase):
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": "abc/def",
"name": zzc.name})
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Empty name
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"code": TWD.code,
"name": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -406,16 +407,16 @@ class CurrencyTestCase(unittest.TestCase):
# Duplicated code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zzb.code,
"name": zzc.name})
"code": EUR.code,
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Change code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": zzc.name})
"code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_c_uri)
@ -431,20 +432,20 @@ class CurrencyTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
detail_uri: str = f"{PREFIX}/{USD.code}"
update_uri: str = f"{PREFIX}/{USD.code}/update"
currency: Currency | None
response: httpx.Response
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": f" {zza.code} ",
"name": f" {zza.name} "})
"code": f" {USD.code} ",
"name": f" {USD.name} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
currency = db.session.get(Currency, zza.code)
currency = db.session.get(Currency, USD.code)
self.assertIsNotNone(currency)
currency.created_at \
= currency.created_at - timedelta(seconds=5)
@ -453,13 +454,13 @@ class CurrencyTestCase(unittest.TestCase):
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": zzc.name})
"code": USD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
currency = db.session.get(Currency, zza.code)
currency = db.session.get(Currency, USD.code)
self.assertIsNotNone(currency)
self.assertLess(currency.created_at,
currency.updated_at)
@ -472,25 +473,25 @@ class CurrencyTestCase(unittest.TestCase):
from accounting.models import Currency
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
detail_uri: str = f"{PREFIX}/{USD.code}"
update_uri: str = f"{PREFIX}/{USD.code}/update"
currency: Currency
response: httpx.Response
with self.app.app_context():
currency = db.session.get(Currency, zza.code)
currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.created_by.username, editor_username)
self.assertEqual(currency.updated_by.username, editor_username)
response = client.post(update_uri,
data={"csrf_token": csrf_token,
"code": zza.code,
"name": f"{zza.name}-2"})
"code": USD.code,
"name": f"{USD.name}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
currency = db.session.get(Currency, zza.code)
currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.created_by.username, editor_username)
self.assertEqual(currency.updated_by.username, editor2_username)
@ -502,14 +503,14 @@ class CurrencyTestCase(unittest.TestCase):
response: httpx.Response
response = self.client.get(
f"/accounting/api/currencies/exists-code?q={zza.code}")
f"/accounting/api/currencies/exists-code?q={USD.code}")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(set(data.keys()), {"exists"})
self.assertTrue(data["exists"])
response = self.client.get(
f"/accounting/api/currencies/exists-code?q={zza.code}-1")
f"/accounting/api/currencies/exists-code?q={USD.code}-1")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(set(data.keys()), {"exists"})
@ -521,60 +522,60 @@ class CurrencyTestCase(unittest.TestCase):
:return: None
"""
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
detail_uri: str = f"{PREFIX}/{USD.code}"
update_uri: str = f"{PREFIX}/{USD.code}/update"
currency: Currency
response: httpx.Response
with self.app.app_context():
currency = db.session.get(Currency, zza.code)
self.assertEqual(currency.name_l10n, zza.name)
currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.name_l10n, USD.name)
self.assertEqual(currency.l10n, [])
set_locale(self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": f"{zza.name}-zh_Hant"})
"code": USD.code,
"name": f"{USD.name}-zh_Hant"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
currency = db.session.get(Currency, zza.code)
self.assertEqual(currency.name_l10n, zza.name)
currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.name_l10n, USD.name)
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant")})
{("zh_Hant", f"{USD.name}-zh_Hant")})
set_locale(self.client, self.csrf_token, "en")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": f"{zza.name}-2"})
"code": USD.code,
"name": f"{USD.name}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
currency = db.session.get(Currency, zza.code)
self.assertEqual(currency.name_l10n, f"{zza.name}-2")
currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.name_l10n, f"{USD.name}-2")
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant")})
{("zh_Hant", f"{USD.name}-zh_Hant")})
set_locale(self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": f"{zza.name}-zh_Hant-2"})
"code": USD.code,
"name": f"{USD.name}-zh_Hant-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
currency = db.session.get(Currency, zza.code)
self.assertEqual(currency.name_l10n, f"{zza.name}-2")
currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.name_l10n, f"{USD.name}-2")
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant-2")})
{("zh_Hant", f"{USD.name}-zh_Hant-2")})
def test_delete(self) -> None:
"""Tests to delete a currency.
@ -582,15 +583,58 @@ class CurrencyTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{zza.code}"
delete_uri: str = f"{PREFIX}/{zza.code}/delete"
detail_uri: str = f"{PREFIX}/{JPY.code}"
delete_uri: str = f"{PREFIX}/{JPY.code}/delete"
list_uri: str = PREFIX
response: httpx.Response
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Cash"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1111-001")
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": JPY.code,
"name": JPY.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
add_journal_entry(self.client,
form={"csrf_token": self.csrf_token,
"next": NEXT_URI,
"date": date.today().isoformat(),
"currency-1-code": EUR.code,
"currency-1-credit-1-account_code": "1111-001",
"currency-1-credit-1-amount": "20"})
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code})
{USD.code, EUR.code, JPY.code})
# Cannot delete the default currency
response = self.client.post(f"{PREFIX}/{USD.code}/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}")
# Cannot delete the account that is in use
response = self.client.post(f"{PREFIX}/{EUR.code}/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}")
# Success
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
@ -600,7 +644,7 @@ class CurrencyTestCase(unittest.TestCase):
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zzb.code})
{USD.code, EUR.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)

View File

@ -569,12 +569,43 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
:return: None.
"""
journal_entry_id: int \
from accounting.models import JournalEntry, JournalEntryLineItem
journal_entry_id_1: int \
= add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
delete_uri: str = f"{PREFIX}/{journal_entry_id}/delete"
detail_uri: str = f"{PREFIX}/{journal_entry_id_1}?next=%2F_next"
delete_uri: str = f"{PREFIX}/{journal_entry_id_1}/delete"
response: httpx.Response
form: dict[str, str] = self.__get_add_form()
key: str = [x for x in form if x.endswith("-account_code")][0]
form[key] = Accounts.PAYABLE
journal_entry_id_2: int = add_journal_entry(self.client, form)
with self.app.app_context():
journal_entry: JournalEntry | None \
= db.session.get(JournalEntry, journal_entry_id_2)
self.assertIsNotNone(journal_entry)
line_item: JournalEntryLineItem \
= [x for x in journal_entry.line_items
if x.account_code == Accounts.PAYABLE][0]
add_journal_entry(
self.client,
form={"csrf_token": self.csrf_token,
"next": NEXT_URI,
"date": date.today().isoformat(),
"currency-1-code": line_item.currency_code,
"currency-1-debit-1-original_line_item_id": line_item.id,
"currency-1-debit-1-account_code": line_item.account_code,
"currency-1-debit-1-amount": "1"})
# Cannot delete the journal entry that is in use
response = self.client.post(f"{PREFIX}/{journal_entry_id_2}/delete",
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{journal_entry_id_2}?next=%2F_next")
# Success
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,

View File

@ -50,6 +50,18 @@ def create_app(is_testing: bool = False) -> Flask:
"SQLALCHEMY_DATABASE_URI": db_uri,
"BABEL_DEFAULT_LOCALE": "en",
"ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",
"ACCOUNTING_DEFAULT_CURRENCY": "USD",
"ACCOUNTING_DEFAULT_IE_ACCOUNT": "1111-001",
"ACCOUNTING_RECURRING": (
"debit|1314-001|Pension|Pension for {last_month_name},"
"debit|6262-001|Health insurance"
"|Health insurance for {last_month_name},"
"debit|6261-001|Electricity bill"
"|Electricity bill for {last_bimonthly_name},"
"debit|6261-001|Water bill|Water bill for {last_bimonthly_name},"
"debit|6261-001|Gas bill|Gas bill for {last_bimonthly_name},"
"debit|6261-001|Phone bill|Phone bill for {last_month_name},"
"credit|4611-001|Payroll|Payroll for {last_month_name}"),
})
if is_testing:
app.config["TESTING"] = True

View File

@ -27,7 +27,7 @@ First written: 2023/1/27
<meta name="author" content="{{ "imacat" }}" />
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.2.10/dist/css/tempus-dominus.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.4.3/dist/css/tempus-dominus.min.css" crossorigin="anonymous">
{% block styles %}{% endblock %}
<script src="{{ url_for("babel_catalog") }}"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>