Compare commits
	
		
			113 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| aea9dcae79 | |||
| 40278eaf06 | |||
| e00c14f277 | |||
| f20c462685 | |||
| 80ae4bd91c | |||
| 6ee3ee76ea | |||
| 2bfcc8b889 | |||
| 99564c02d0 | |||
| 25d9904180 | |||
| 1cf83adf87 | |||
| 8e3d1f11b5 | |||
| 0ab14aa34d | |||
| e0ed81ad1f | |||
| ece7481e9e | |||
| 50d4526e0b | |||
| 3f0a0b4227 | |||
| dcc9626b23 | |||
| 79eb077129 | |||
| d5719ad223 | |||
| eb3fa8f414 | |||
| 937908717b | |||
| 0104fa4c21 | |||
| 14365ca255 | |||
| cd86651606 | |||
| 9147744ff7 | |||
| 1a212a5330 | |||
| 0614457b7b | |||
| 545f49043b | |||
| cac0d66ca1 | |||
| 5ffd37c859 | |||
| 9ae8c1bce9 | |||
| ec0ff3e2e6 | |||
| 40a8080751 | |||
| 736a4086ee | |||
| 6723077b72 | |||
| 0ae00bce79 | |||
| 356d2010cc | |||
| 501c4b1d22 | |||
| 64b9c8c11f | |||
| 9072de82d4 | |||
| 30fd9c2164 | |||
| 7cb01b4cee | |||
| 9a4e04c41f | |||
| a9c4fa9de0 | |||
| 3a676e0b5a | |||
| 9cc7b64bb3 | |||
| 352867797d | |||
| 09a344d749 | |||
| 818c357613 | |||
| 822c8fc49b | |||
| 3b8a2e3bb1 | |||
| 9e4927ee0b | |||
| 3b030c577c | |||
| 60b33f2a3b | |||
| 08fdf59844 | |||
| b397515457 | |||
| abe90d3483 | |||
| 65e7dcdf6d | |||
| 74e414badf | |||
| 69175979ff | |||
| 2f69e0f215 | |||
| 961385c389 | |||
| a691cfd2da | |||
| 482a0faa23 | |||
| 0ecf7b6617 | |||
| 4408bbfc82 | |||
| 433110f486 | |||
| 0b1dd4f4fc | |||
| 46bd27e126 | |||
| b718d19450 | |||
| 2969e83afe | |||
| a732656746 | |||
| 1daed940b6 | |||
| f29cb00aec | |||
| 693f07a49c | |||
| 8c899776f2 | |||
| f9aa226bf9 | |||
| c9bb4197be | |||
| 9ae8d587d8 | |||
| 158058dcfb | |||
| 0bc9947234 | |||
| 8c58a9083a | |||
| f45663754c | |||
| cda9e4e3c6 | |||
| ee5b447c23 | |||
| 25bfcf4aa4 | |||
| 5956d2cd4c | |||
| 833285d924 | |||
| dee4f5e83f | |||
| f0d1cae32d | |||
| 5dc71697b3 | |||
| 1bb1e03c08 | |||
| 914ff92e0f | |||
| 8a1cf463b1 | |||
| d4cf224d6b | |||
| 8d412ec00a | |||
| 2986c518ce | |||
| f1351243a6 | |||
| 969e8c76a6 | |||
| 10f5e75752 | |||
| 169b3c292a | |||
| 3eb3aef2f2 | |||
| 6c455a615c | |||
| 4f3339bf68 | |||
| b5aa7e923f | |||
| 359c335662 | |||
| c11ae23885 | |||
| e083b11394 | |||
| 167990fc4c | |||
| d5c1be3d80 | |||
| f6567794e0 | |||
| ded85d88f7 | |||
| 6d780e9296 | 
@@ -38,3 +38,4 @@ python:
 | 
			
		||||
   install:
 | 
			
		||||
   - method: pip
 | 
			
		||||
     path: .
 | 
			
		||||
   - requirements: docs/requirements.txt
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,7 @@ Refer to the `change log`_.
 | 
			
		||||
Copyright
 | 
			
		||||
=========
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 Copyright (c) 2023-2024 imacat.
 | 
			
		||||
 | 
			
		||||
 Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 you may not use this file except in compliance with the License.
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								docs/requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								docs/requirements.txt
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
sphinx_rtd_theme
 | 
			
		||||
@@ -100,6 +100,14 @@ accounting.utils.strip\_text module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.title\_case module
 | 
			
		||||
-----------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.utils.title_case
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.user module
 | 
			
		||||
----------------------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,158 @@
 | 
			
		||||
Changes
 | 
			
		||||
=======
 | 
			
		||||
Change Log
 | 
			
		||||
==========
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.6.1
 | 
			
		||||
--------------
 | 
			
		||||
 | 
			
		||||
Released 2024/12/3
 | 
			
		||||
 | 
			
		||||
Fix test cases for compatibility with httpx 0.28.0.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.6.0
 | 
			
		||||
--------------
 | 
			
		||||
 | 
			
		||||
Released 2024/6/4
 | 
			
		||||
 | 
			
		||||
* Updated Python version to 3.12.
 | 
			
		||||
* Revised the calculation of "today" to use the client's timezone instead of
 | 
			
		||||
  the server's timezone.
 | 
			
		||||
* Updated the Bootstrap, FontAwesome, and Tempus-Dominus versions in the test
 | 
			
		||||
  site.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.5.11
 | 
			
		||||
--------------
 | 
			
		||||
 | 
			
		||||
Released 2023/12/26
 | 
			
		||||
 | 
			
		||||
Bug fix.
 | 
			
		||||
 | 
			
		||||
* Refined to enable the selection of the 3351-001 Accumulated Profit or Loss
 | 
			
		||||
  account.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.5.10
 | 
			
		||||
--------------
 | 
			
		||||
 | 
			
		||||
Released 2023/11/28
 | 
			
		||||
 | 
			
		||||
Bug fix.
 | 
			
		||||
 | 
			
		||||
* Fixed the form validator to enable the selection of Accumulated Profit or
 | 
			
		||||
  Loss accounts other than 3351-001.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.5.9
 | 
			
		||||
-------------
 | 
			
		||||
 | 
			
		||||
Released 2023/11/28
 | 
			
		||||
 | 
			
		||||
Bug fix.
 | 
			
		||||
 | 
			
		||||
* Refined to enable the selection of Accumulated Profit or Loss accounts other
 | 
			
		||||
  than 3351-001, facilitating the consolidation of existing balances.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.5.8
 | 
			
		||||
-------------
 | 
			
		||||
 | 
			
		||||
Released 2023/10/24
 | 
			
		||||
 | 
			
		||||
Bug fix.
 | 
			
		||||
 | 
			
		||||
* Fixed an icon in the detail of the cash receipt journal entry.
 | 
			
		||||
 | 
			
		||||
Released at Jaipur, India on vacation.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.5.7
 | 
			
		||||
-------------
 | 
			
		||||
 | 
			
		||||
Released 2023/7/29
 | 
			
		||||
 | 
			
		||||
Revised account title capitalization to capitalize account titles
 | 
			
		||||
upon initialization of base accounts, rather than when displaying
 | 
			
		||||
the accounts.  This prevents the system from incorrectly
 | 
			
		||||
capitalizing titles of user-added accounts.
 | 
			
		||||
 | 
			
		||||
For existing installation, run the ``accounting-titleize`` console
 | 
			
		||||
command to capitalize the existing account titles that were already
 | 
			
		||||
initialized.
 | 
			
		||||
 | 
			
		||||
Other fixes:
 | 
			
		||||
 | 
			
		||||
* Added missing documentation to the global variables, class
 | 
			
		||||
  properties, and object properties.
 | 
			
		||||
* Various minor fixes.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.5.6
 | 
			
		||||
-------------
 | 
			
		||||
 | 
			
		||||
Released 2023/5/23
 | 
			
		||||
 | 
			
		||||
Bug fixes.
 | 
			
		||||
 | 
			
		||||
* Fixed the return URI of the creation forms to decode the next URI.
 | 
			
		||||
* Fixed the unmatched offset list to use the encoded next URI.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.5.5
 | 
			
		||||
-------------
 | 
			
		||||
 | 
			
		||||
Released 2023/5/23
 | 
			
		||||
 | 
			
		||||
Security fixes.
 | 
			
		||||
 | 
			
		||||
* Revised the next URI utilities to encode and decode the next URI
 | 
			
		||||
  preventing tampering with the next URI.
 | 
			
		||||
* Added the integrity value of the CDN stylesheet links.
 | 
			
		||||
* Various fixes.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.5.4
 | 
			
		||||
-------------
 | 
			
		||||
 | 
			
		||||
Released 2023/5/18
 | 
			
		||||
 | 
			
		||||
Security fixes.
 | 
			
		||||
 | 
			
		||||
* Added safeguard to the next URI utilities, to prevent Cross-Site
 | 
			
		||||
  Scripting (XSS) attacks.
 | 
			
		||||
* Applied the safe next URI utilities to the test site.
 | 
			
		||||
* Added the ``SameSite`` and ``Secure`` flags to the session cookie
 | 
			
		||||
  of the test site.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.5.3
 | 
			
		||||
-------------
 | 
			
		||||
 | 
			
		||||
Released 2023/4/30
 | 
			
		||||
 | 
			
		||||
* Fixed the error of the net balance in the unmatched offset list.
 | 
			
		||||
* Revised the original line item editor not to override the existing
 | 
			
		||||
  amount when the existing amount is less or equal to the net
 | 
			
		||||
  balance.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.5.2
 | 
			
		||||
-------------
 | 
			
		||||
 | 
			
		||||
Released 2023/4/30
 | 
			
		||||
 | 
			
		||||
* Fixed the error of the net balance in the unmatched offset list.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.5.1
 | 
			
		||||
-------------
 | 
			
		||||
 | 
			
		||||
Released 2023/4/30
 | 
			
		||||
 | 
			
		||||
* Fixed the error calling the old ``setEnableDescriptionAccount``
 | 
			
		||||
  method in the ``saveOriginalLineItem`` method of the JavaScript
 | 
			
		||||
  ``JournalEntryLineItemEditor`` class.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Version 1.5.0
 | 
			
		||||
 
 | 
			
		||||
@@ -50,9 +50,9 @@ The following front-end JavaScript libraries must be loaded.  You may
 | 
			
		||||
download it locally or use CDN_.
 | 
			
		||||
 | 
			
		||||
* Bootstrap_ 5.2.3 or above
 | 
			
		||||
* FontAwesome_ 6.2.1 or above
 | 
			
		||||
* `Decimal.js`_ 6.4.3 or above
 | 
			
		||||
* `Tempus-Dominus`_ 6.4.3 or above
 | 
			
		||||
* FontAwesome_ 6.4.0 or above
 | 
			
		||||
* `decimal.js`_ 10.4.3 or above, or `decimal.js-light`_ 2.5.1 or above.
 | 
			
		||||
* `Tempus-Dominus`_ 6.7.7 or above
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Configuration
 | 
			
		||||
@@ -114,6 +114,7 @@ Check your Flask application and see how it works.
 | 
			
		||||
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
 | 
			
		||||
.. _Bootstrap: https://getbootstrap.com
 | 
			
		||||
.. _FontAwesome: https://fontawesome.com
 | 
			
		||||
.. _Decimal.js: https://mikemcl.github.io/decimal.js
 | 
			
		||||
.. _decimal.js: https://mikemcl.github.io/decimal.js
 | 
			
		||||
.. _decimal.js-light: https://mikemcl.github.io/decimal.js-light
 | 
			
		||||
.. _Tempus-Dominus: https://getdatepicker.com
 | 
			
		||||
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2022-2023 imacat.
 | 
			
		||||
#  Copyright (c) 2022-2024 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
@@ -20,7 +20,7 @@ name = "mia-accounting"
 | 
			
		||||
dynamic = ["version"]
 | 
			
		||||
description = "A Flask accounting module."
 | 
			
		||||
readme = "README.rst"
 | 
			
		||||
requires-python = ">=3.11"
 | 
			
		||||
requires-python = ">=3.12"
 | 
			
		||||
authors = [
 | 
			
		||||
    {name = "imacat", email = "imacat@mail.imacat.idv.tw"},
 | 
			
		||||
]
 | 
			
		||||
@@ -33,7 +33,7 @@ classifiers = [
 | 
			
		||||
    "Topic :: Office/Business :: Financial :: Accounting",
 | 
			
		||||
]
 | 
			
		||||
dependencies = [
 | 
			
		||||
    "flask",
 | 
			
		||||
    "Flask",
 | 
			
		||||
    "SQLAlchemy >= 2",
 | 
			
		||||
    "Flask-SQLAlchemy",
 | 
			
		||||
    "Flask-WTF",
 | 
			
		||||
@@ -42,9 +42,8 @@ dependencies = [
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[project.optional-dependencies]
 | 
			
		||||
test = [
 | 
			
		||||
    "unittest",
 | 
			
		||||
    "httpx",
 | 
			
		||||
devel = [
 | 
			
		||||
    "httpx >= 0.20.0",
 | 
			
		||||
    "OpenCC",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#  Copyright (c) 2023-2024 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
@@ -24,7 +24,7 @@ from flask_sqlalchemy import SQLAlchemy
 | 
			
		||||
 | 
			
		||||
from accounting.utils.user import UserUtilityInterface
 | 
			
		||||
 | 
			
		||||
VERSION: str = "1.5.0"
 | 
			
		||||
VERSION: str = "1.6.1"
 | 
			
		||||
"""The package version."""
 | 
			
		||||
db: SQLAlchemy = SQLAlchemy()
 | 
			
		||||
"""The database instance."""
 | 
			
		||||
@@ -63,8 +63,9 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
 | 
			
		||||
    bp.add_app_template_global(default_currency_code,
 | 
			
		||||
                               "accounting_default_currency_code")
 | 
			
		||||
 | 
			
		||||
    from .commands import init_db_command
 | 
			
		||||
    from .commands import init_db_command, titleize_command
 | 
			
		||||
    app.cli.add_command(init_db_command)
 | 
			
		||||
    app.cli.add_command(titleize_command)
 | 
			
		||||
 | 
			
		||||
    from . import locale
 | 
			
		||||
    locale.init_app(app, bp)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#  Copyright (c) 2023-2024 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
@@ -17,17 +17,17 @@
 | 
			
		||||
"""The console commands for the account management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from secrets import randbelow
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
import click
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.models import BaseAccount, Account, AccountL10n
 | 
			
		||||
from accounting.utils.user import get_user_pk
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
AccountData = tuple[int, str, int, str, str, str, bool]
 | 
			
		||||
type AccountData = tuple[int, str, int, str, str, str, bool]
 | 
			
		||||
"""The format of the account data, as a list of (ID, base account code, number,
 | 
			
		||||
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
 | 
			
		||||
 | 
			
		||||
@@ -63,8 +63,8 @@ def init_accounts_command(username: str) -> None:
 | 
			
		||||
                existing_id.add(new_id)
 | 
			
		||||
                return new_id
 | 
			
		||||
 | 
			
		||||
    data: list[dict[str, t.Any]] = []
 | 
			
		||||
    l10n_data: list[dict[str, t.Any]] = []
 | 
			
		||||
    data: list[dict[str, Any]] = []
 | 
			
		||||
    l10n_data: list[dict[str, Any]] = []
 | 
			
		||||
    for base in bases_to_add:
 | 
			
		||||
        l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
 | 
			
		||||
        account_id: int = get_new_id()
 | 
			
		||||
 
 | 
			
		||||
@@ -168,7 +168,9 @@ class AccountReorderForm:
 | 
			
		||||
        :param base: The base account.
 | 
			
		||||
        """
 | 
			
		||||
        self.base: BaseAccount = base
 | 
			
		||||
        """The base account."""
 | 
			
		||||
        self.is_modified: bool = False
 | 
			
		||||
        """Whether the order is modified."""
 | 
			
		||||
 | 
			
		||||
    def save_order(self) -> None:
 | 
			
		||||
        """Saves the order of the account.
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,7 @@ import sqlalchemy as sa
 | 
			
		||||
from accounting import data_dir
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.models import BaseAccount, BaseAccountL10n
 | 
			
		||||
from accounting.utils.title_case import title_case
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def init_base_accounts_command() -> None:
 | 
			
		||||
@@ -34,7 +35,7 @@ def init_base_accounts_command() -> None:
 | 
			
		||||
    with open(data_dir / "base_accounts.csv") as fp:
 | 
			
		||||
        data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
 | 
			
		||||
    account_data: list[dict[str, str]] = [{"code": x["code"],
 | 
			
		||||
                                           "title_l10n": x["title"]}
 | 
			
		||||
                                           "title_l10n": title_case(x["title"])}
 | 
			
		||||
                                          for x in data]
 | 
			
		||||
    locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
 | 
			
		||||
    l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,10 @@ from accounting import db
 | 
			
		||||
from accounting.account import init_accounts_command
 | 
			
		||||
from accounting.base_account import init_base_accounts_command
 | 
			
		||||
from accounting.currency import init_currencies_command
 | 
			
		||||
from accounting.utils.user import has_user
 | 
			
		||||
from accounting.models import BaseAccount, Account
 | 
			
		||||
from accounting.utils.title_case import title_case
 | 
			
		||||
from accounting.utils.user import has_user, get_user_pk
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
 | 
			
		||||
@@ -60,3 +63,32 @@ def init_db_command(username: str) -> None:
 | 
			
		||||
    init_currencies_command(username)
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
    click.echo("Accounting database initialized.")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@click.command("accounting-titleize")
 | 
			
		||||
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
 | 
			
		||||
              help="The username.", callback=__validate_username,
 | 
			
		||||
              default=lambda: os.getlogin())
 | 
			
		||||
@with_appcontext
 | 
			
		||||
def titleize_command(username: str) -> None:
 | 
			
		||||
    """Capitalize the account titles."""
 | 
			
		||||
    updater_pk: int = get_user_pk(username)
 | 
			
		||||
    updated: int = 0
 | 
			
		||||
    for base in BaseAccount.query:
 | 
			
		||||
        new_title: str = title_case(base.title_l10n)
 | 
			
		||||
        if base.title_l10n != new_title:
 | 
			
		||||
            base.title_l10n = new_title
 | 
			
		||||
            updated = updated + 1
 | 
			
		||||
    for account in Account.query:
 | 
			
		||||
        if account.title_l10n.lower() == account.base.title_l10n.lower():
 | 
			
		||||
            new_title: str = title_case(account.title_l10n)
 | 
			
		||||
            if account.title_l10n != new_title:
 | 
			
		||||
                account.title_l10n = new_title
 | 
			
		||||
                account.updated_at = sa.func.now()
 | 
			
		||||
                account.updated_by_id = updater_pk
 | 
			
		||||
                updated = updated + 1
 | 
			
		||||
    if updated == 0:
 | 
			
		||||
        click.echo("All account titles were already capitalized.")
 | 
			
		||||
        return
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
    click.echo(f"{updated} account titles capitalized.")
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import csv
 | 
			
		||||
import typing as t
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
@@ -39,11 +39,11 @@ def init_currencies_command(username: str) -> None:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    creator_pk: int = get_user_pk(username)
 | 
			
		||||
    currency_data: list[dict[str, t.Any]] = [{"code": x["code"],
 | 
			
		||||
                                              "name_l10n": x["name"],
 | 
			
		||||
                                              "created_by_id": creator_pk,
 | 
			
		||||
                                              "updated_by_id": creator_pk}
 | 
			
		||||
                                             for x in to_add]
 | 
			
		||||
    currency_data: list[dict[str, Any]] = [{"code": x["code"],
 | 
			
		||||
                                            "name_l10n": x["name"],
 | 
			
		||||
                                            "created_by_id": creator_pk,
 | 
			
		||||
                                            "updated_by_id": creator_pk}
 | 
			
		||||
                                           for x in to_add]
 | 
			
		||||
    locales: list[str] = [x[5:] for x in to_add[0] if x.startswith("l10n-")]
 | 
			
		||||
    l10n_data: list[dict[str, str]] = [{"currency_code": x["code"],
 | 
			
		||||
                                        "locale": y,
 | 
			
		||||
 
 | 
			
		||||
@@ -65,12 +65,12 @@ class IsDebitAccount:
 | 
			
		||||
        :param message: The error message.
 | 
			
		||||
        """
 | 
			
		||||
        self.__message: str | LazyString = message
 | 
			
		||||
        """The error message."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if re.match(r"^(?:[1235689]|7[5678])", field.data) \
 | 
			
		||||
                and not field.data.startswith("3351-") \
 | 
			
		||||
                and not field.data.startswith("3353-"):
 | 
			
		||||
            return
 | 
			
		||||
        raise ValidationError(self.__message)
 | 
			
		||||
@@ -85,12 +85,12 @@ class IsCreditAccount:
 | 
			
		||||
        :param message: The error message.
 | 
			
		||||
        """
 | 
			
		||||
        self.__message: str | LazyString = message
 | 
			
		||||
        """The error message."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data is None:
 | 
			
		||||
            return
 | 
			
		||||
        if re.match(r"^(?:[123489]|7[1234])", field.data) \
 | 
			
		||||
                and not field.data.startswith("3351-") \
 | 
			
		||||
                and not field.data.startswith("3353-"):
 | 
			
		||||
            return
 | 
			
		||||
        raise ValidationError(self.__message)
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The path converters for the journal entry management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
 | 
			
		||||
from flask import abort
 | 
			
		||||
from werkzeug.routing import BaseConverter
 | 
			
		||||
@@ -82,18 +82,18 @@ class DateConverter(BaseConverter):
 | 
			
		||||
    """The date converter to convert the ISO date from and to the
 | 
			
		||||
    corresponding date in the routes."""
 | 
			
		||||
 | 
			
		||||
    def to_python(self, value: str) -> date:
 | 
			
		||||
    def to_python(self, value: str) -> dt.date:
 | 
			
		||||
        """Converts an ISO date to a date.
 | 
			
		||||
 | 
			
		||||
        :param value: The ISO date.
 | 
			
		||||
        :return: The corresponding date.
 | 
			
		||||
        """
 | 
			
		||||
        try:
 | 
			
		||||
            return date.fromisoformat(value)
 | 
			
		||||
            return dt.date.fromisoformat(value)
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            abort(404)
 | 
			
		||||
 | 
			
		||||
    def to_url(self, value: date) -> str:
 | 
			
		||||
    def to_url(self, value: dt.date) -> str:
 | 
			
		||||
        """Converts a date to its ISO date.
 | 
			
		||||
 | 
			
		||||
        :param value: The date.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#  Copyright (c) 2023-2024 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
@@ -18,8 +18,8 @@
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import datetime as dt
 | 
			
		||||
import typing as t
 | 
			
		||||
from abc import ABC, abstractmethod
 | 
			
		||||
from typing import Type
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from flask_babel import LazyString
 | 
			
		||||
@@ -29,13 +29,13 @@ from wtforms import DateField, FieldList, FormField, TextAreaField, \
 | 
			
		||||
from wtforms.validators import DataRequired, ValidationError
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.journal_entry.utils.account_option import AccountOption
 | 
			
		||||
from accounting.journal_entry.utils.description_editor import DescriptionEditor
 | 
			
		||||
from accounting.journal_entry.utils.original_line_items import \
 | 
			
		||||
    get_selectable_original_line_items
 | 
			
		||||
from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
 | 
			
		||||
    JournalEntryCurrency
 | 
			
		||||
from accounting.journal_entry.utils.account_option import AccountOption
 | 
			
		||||
from accounting.journal_entry.utils.original_line_items import \
 | 
			
		||||
    get_selectable_original_line_items
 | 
			
		||||
from accounting.journal_entry.utils.description_editor import DescriptionEditor
 | 
			
		||||
from accounting.utils.random_id import new_id
 | 
			
		||||
from accounting.utils.strip_text import strip_multiline_text
 | 
			
		||||
from accounting.utils.user import get_current_user_pk
 | 
			
		||||
@@ -123,7 +123,7 @@ class JournalEntryForm(FlaskForm):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.is_modified: bool = False
 | 
			
		||||
        """Whether the journal entry is modified during populate_obj()."""
 | 
			
		||||
        self.collector: t.Type[LineItemCollector] = LineItemCollector
 | 
			
		||||
        self.collector: Type[LineItemCollector] = LineItemCollector
 | 
			
		||||
        """The line item collector.  The default is the base abstract
 | 
			
		||||
        collector only to provide the correct type.  The subclass forms should
 | 
			
		||||
        provide their own collectors."""
 | 
			
		||||
@@ -151,11 +151,10 @@ class JournalEntryForm(FlaskForm):
 | 
			
		||||
        is_new: bool = obj.id is None
 | 
			
		||||
        if is_new:
 | 
			
		||||
            obj.id = new_id(JournalEntry)
 | 
			
		||||
        self.date: DateField
 | 
			
		||||
        self.__set_date(obj, self.date.data)
 | 
			
		||||
        obj.note = self.note.data
 | 
			
		||||
 | 
			
		||||
        collector_cls: t.Type[LineItemCollector] = self.collector
 | 
			
		||||
        collector_cls: Type[LineItemCollector] = self.collector
 | 
			
		||||
        collector: collector_cls = collector_cls(self, obj)
 | 
			
		||||
        collector.collect()
 | 
			
		||||
 | 
			
		||||
@@ -309,11 +308,7 @@ class JournalEntryForm(FlaskForm):
 | 
			
		||||
        return db.session.scalar(select)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
T = t.TypeVar("T", bound=JournalEntryForm)
 | 
			
		||||
"""A journal entry form variant."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LineItemCollector(t.Generic[T], ABC):
 | 
			
		||||
class LineItemCollector[T: JournalEntryForm](ABC):
 | 
			
		||||
    """The line item collector."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, form: T, obj: JournalEntry):
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The line item sub-forms for the journal entry management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
@@ -307,7 +307,7 @@ class LineItemForm(FlaskForm):
 | 
			
		||||
        return getattr(self, "____original_line_item")
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def original_line_item_date(self) -> date | None:
 | 
			
		||||
    def original_line_item_date(self) -> dt.date | None:
 | 
			
		||||
        """Returns the text representation of the original line item.
 | 
			
		||||
 | 
			
		||||
        :return: The text representation of the original line item.
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The reorder forms for the journal entry management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from flask import request
 | 
			
		||||
@@ -26,17 +26,15 @@ from accounting import db
 | 
			
		||||
from accounting.models import JournalEntry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def sort_journal_entries_in(journal_entry_date: date,
 | 
			
		||||
                            exclude: int | None = None) -> None:
 | 
			
		||||
def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
 | 
			
		||||
    """Sorts the journal entries under a date after changing the date or
 | 
			
		||||
    deleting a journal entry.
 | 
			
		||||
 | 
			
		||||
    :param journal_entry_date: The date of the journal entry.
 | 
			
		||||
    :param date: The date of the journal entry.
 | 
			
		||||
    :param exclude: The journal entry ID to exclude.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    conditions: list[sa.BinaryExpression] \
 | 
			
		||||
        = [JournalEntry.date == journal_entry_date]
 | 
			
		||||
    conditions: list[sa.BinaryExpression] = [JournalEntry.date == date]
 | 
			
		||||
    if exclude is not None:
 | 
			
		||||
        conditions.append(JournalEntry.id != exclude)
 | 
			
		||||
    journal_entries: list[JournalEntry] = JournalEntry.query\
 | 
			
		||||
@@ -50,13 +48,15 @@ def sort_journal_entries_in(journal_entry_date: date,
 | 
			
		||||
class JournalEntryReorderForm:
 | 
			
		||||
    """The form to reorder the journal entries."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, journal_entry_date: date):
 | 
			
		||||
    def __init__(self, date: dt.date):
 | 
			
		||||
        """Constructs the form to reorder the journal entries in a day.
 | 
			
		||||
 | 
			
		||||
        :param journal_entry_date: The date.
 | 
			
		||||
        :param date: The date.
 | 
			
		||||
        """
 | 
			
		||||
        self.date: date = journal_entry_date
 | 
			
		||||
        self.date: dt.date = date
 | 
			
		||||
        """The date."""
 | 
			
		||||
        self.is_modified: bool = False
 | 
			
		||||
        """Whether the order is modified."""
 | 
			
		||||
 | 
			
		||||
    def save_order(self) -> None:
 | 
			
		||||
        """Saves the order of the account.
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import re
 | 
			
		||||
import typing as t
 | 
			
		||||
from typing import Literal
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
@@ -124,12 +124,12 @@ class DescriptionTag:
 | 
			
		||||
class DescriptionType:
 | 
			
		||||
    """A description type"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
 | 
			
		||||
    def __init__(self, type_id: Literal["general", "travel", "bus"]):
 | 
			
		||||
        """Constructs a description type.
 | 
			
		||||
 | 
			
		||||
        :param type_id: The type ID, either "general", "travel", or "bus".
 | 
			
		||||
        """
 | 
			
		||||
        self.id: t.Literal["general", "travel", "bus"] = type_id
 | 
			
		||||
        self.id: Literal["general", "travel", "bus"] = type_id
 | 
			
		||||
        """The type ID."""
 | 
			
		||||
        self.__tag_dict: dict[str, DescriptionTag] = {}
 | 
			
		||||
        """A dictionary from the tag name to their corresponding tag."""
 | 
			
		||||
@@ -166,8 +166,11 @@ class DescriptionRecurring:
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        """
 | 
			
		||||
        self.name: str = name
 | 
			
		||||
        """The name."""
 | 
			
		||||
        self.account: DescriptionAccount = DescriptionAccount(account, 0)
 | 
			
		||||
        """The account."""
 | 
			
		||||
        self.description_template: str = description_template
 | 
			
		||||
        """The description template."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def account_codes(self) -> list[str]:
 | 
			
		||||
@@ -181,12 +184,12 @@ class DescriptionRecurring:
 | 
			
		||||
class DescriptionDebitCredit:
 | 
			
		||||
    """The description on debit or credit."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, debit_credit: t.Literal["debit", "credit"]):
 | 
			
		||||
    def __init__(self, debit_credit: Literal["debit", "credit"]):
 | 
			
		||||
        """Constructs the description on debit or credit.
 | 
			
		||||
 | 
			
		||||
        :param debit_credit: Either "debit" or "credit".
 | 
			
		||||
        """
 | 
			
		||||
        self.debit_credit: t.Literal["debit", "credit"] = debit_credit
 | 
			
		||||
        self.debit_credit: Literal["debit", "credit"] = debit_credit
 | 
			
		||||
        """Either debit or credit."""
 | 
			
		||||
        self.general: DescriptionType = DescriptionType("general")
 | 
			
		||||
        """The general tags."""
 | 
			
		||||
@@ -194,14 +197,14 @@ class DescriptionDebitCredit:
 | 
			
		||||
        """The travel tags."""
 | 
			
		||||
        self.bus: DescriptionType = DescriptionType("bus")
 | 
			
		||||
        """The bus tags."""
 | 
			
		||||
        self.__type_dict: dict[t.Literal["general", "travel", "bus"],
 | 
			
		||||
        self.__type_dict: dict[Literal["general", "travel", "bus"],
 | 
			
		||||
                               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"],
 | 
			
		||||
    def add_tag(self, tag_type: Literal["general", "travel", "bus"],
 | 
			
		||||
                name: str, account: Account, freq: int) -> None:
 | 
			
		||||
        """Adds a tag.
 | 
			
		||||
 | 
			
		||||
@@ -278,7 +281,7 @@ class DescriptionEditor:
 | 
			
		||||
        accounts: dict[int, Account] \
 | 
			
		||||
            = {x.id: x for x in Account.query
 | 
			
		||||
               .filter(Account.id.in_({x.account_id for x in result})).all()}
 | 
			
		||||
        debit_credit_dict: dict[t.Literal["debit", "credit"],
 | 
			
		||||
        debit_credit_dict: dict[Literal["debit", "credit"],
 | 
			
		||||
                                DescriptionDebitCredit] \
 | 
			
		||||
            = {x.debit_credit: x for x in {self.debit, self.credit}}
 | 
			
		||||
        for row in result:
 | 
			
		||||
 
 | 
			
		||||
@@ -17,19 +17,19 @@
 | 
			
		||||
"""The operators for different journal entry types.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from abc import ABC, abstractmethod
 | 
			
		||||
from typing import Type
 | 
			
		||||
 | 
			
		||||
from flask import render_template, request, abort
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
 | 
			
		||||
from accounting.models import JournalEntry
 | 
			
		||||
from accounting.template_globals import default_currency_code
 | 
			
		||||
from accounting.utils.journal_entry_types import JournalEntryType
 | 
			
		||||
from accounting.journal_entry.forms import JournalEntryForm, \
 | 
			
		||||
    CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
 | 
			
		||||
    TransferJournalEntryForm
 | 
			
		||||
from accounting.journal_entry.forms.line_item import LineItemForm
 | 
			
		||||
from accounting.models import JournalEntry
 | 
			
		||||
from accounting.template_globals import default_currency_code
 | 
			
		||||
from accounting.utils.journal_entry_types import JournalEntryType
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JournalEntryOperator(ABC):
 | 
			
		||||
@@ -39,7 +39,7 @@ class JournalEntryOperator(ABC):
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def form(self) -> t.Type[JournalEntryForm]:
 | 
			
		||||
    def form(self) -> Type[JournalEntryForm]:
 | 
			
		||||
        """Returns the form class.
 | 
			
		||||
 | 
			
		||||
        :return: The form class.
 | 
			
		||||
@@ -100,7 +100,7 @@ class CashReceiptJournalEntry(JournalEntryOperator):
 | 
			
		||||
    """The order when checking the journal entry operator."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def form(self) -> t.Type[JournalEntryForm]:
 | 
			
		||||
    def form(self) -> Type[JournalEntryForm]:
 | 
			
		||||
        """Returns the form class.
 | 
			
		||||
 | 
			
		||||
        :return: The form class.
 | 
			
		||||
@@ -170,7 +170,7 @@ class CashDisbursementJournalEntry(JournalEntryOperator):
 | 
			
		||||
    """The order when checking the journal entry operator."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def form(self) -> t.Type[JournalEntryForm]:
 | 
			
		||||
    def form(self) -> Type[JournalEntryForm]:
 | 
			
		||||
        """Returns the form class.
 | 
			
		||||
 | 
			
		||||
        :return: The form class.
 | 
			
		||||
@@ -243,7 +243,7 @@ class TransferJournalEntry(JournalEntryOperator):
 | 
			
		||||
    """The order when checking the journal entry operator."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def form(self) -> t.Type[JournalEntryForm]:
 | 
			
		||||
    def form(self) -> Type[JournalEntryForm]:
 | 
			
		||||
        """Returns the form class.
 | 
			
		||||
 | 
			
		||||
        :return: The form class.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#  Copyright (c) 2023-2024 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The views for the journal entry management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from urllib.parse import parse_qsl, urlencode
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
@@ -30,9 +30,10 @@ from accounting.locale import lazy_gettext
 | 
			
		||||
from accounting.models import JournalEntry
 | 
			
		||||
from accounting.utils.cast import s
 | 
			
		||||
from accounting.utils.flash_errors import flash_form_errors
 | 
			
		||||
from accounting.utils.journal_entry_types import JournalEntryType
 | 
			
		||||
from accounting.utils.next_uri import inherit_next, or_next
 | 
			
		||||
from accounting.utils.permission import has_permission, can_view, can_edit
 | 
			
		||||
from accounting.utils.journal_entry_types import JournalEntryType
 | 
			
		||||
from accounting.utils.timezone import get_tz_today
 | 
			
		||||
from accounting.utils.user import get_current_user_pk
 | 
			
		||||
from .forms import sort_journal_entries_in, JournalEntryReorderForm
 | 
			
		||||
from .template_filters import with_type, to_transfer, format_amount_input, \
 | 
			
		||||
@@ -67,7 +68,7 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
 | 
			
		||||
        form.validate()
 | 
			
		||||
    else:
 | 
			
		||||
        form = journal_entry_op.form()
 | 
			
		||||
        form.date.data = date.today()
 | 
			
		||||
        form.date.data = get_tz_today()
 | 
			
		||||
    return journal_entry_op.render_create_template(form)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -186,31 +187,31 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
 | 
			
		||||
    return redirect(or_next(__get_default_page_uri()))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.get("dates/<date:journal_entry_date>", endpoint="order")
 | 
			
		||||
@bp.get("dates/<date:date>", endpoint="order")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def show_journal_entry_order(journal_entry_date: date) -> str:
 | 
			
		||||
def show_journal_entry_order(date: dt.date) -> str:
 | 
			
		||||
    """Shows the order of the journal entries in a same date.
 | 
			
		||||
 | 
			
		||||
    :param journal_entry_date: The date.
 | 
			
		||||
    :param date: The date.
 | 
			
		||||
    :return: The order of the journal entries in the date.
 | 
			
		||||
    """
 | 
			
		||||
    journal_entries: list[JournalEntry] = JournalEntry.query \
 | 
			
		||||
        .filter(JournalEntry.date == journal_entry_date) \
 | 
			
		||||
        .filter(JournalEntry.date == date) \
 | 
			
		||||
        .order_by(JournalEntry.no).all()
 | 
			
		||||
    return render_template("accounting/journal-entry/order.html",
 | 
			
		||||
                           date=journal_entry_date, list=journal_entries)
 | 
			
		||||
                           date=date, list=journal_entries)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@bp.post("dates/<date:journal_entry_date>", endpoint="sort")
 | 
			
		||||
@bp.post("dates/<date:date>", endpoint="sort")
 | 
			
		||||
@has_permission(can_edit)
 | 
			
		||||
def sort_journal_entries(journal_entry_date: date) -> redirect:
 | 
			
		||||
def sort_journal_entries(date: dt.date) -> redirect:
 | 
			
		||||
    """Reorders the journal entries in a date.
 | 
			
		||||
 | 
			
		||||
    :param journal_entry_date: The date.
 | 
			
		||||
    :param date: The date.
 | 
			
		||||
    :return: The redirection to the incoming account or the account list.  The
 | 
			
		||||
        reordering operation does not fail.
 | 
			
		||||
    """
 | 
			
		||||
    form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date)
 | 
			
		||||
    form: JournalEntryReorderForm = JournalEntryReorderForm(date)
 | 
			
		||||
    form.save_order()
 | 
			
		||||
    if not form.is_modified:
 | 
			
		||||
        flash(s(lazy_gettext("The order was not modified.")), "success")
 | 
			
		||||
 
 | 
			
		||||
@@ -25,8 +25,10 @@ from flask_babel import LazyString, Domain
 | 
			
		||||
from flask_babel_js import JAVASCRIPT, c2js
 | 
			
		||||
 | 
			
		||||
translation_dir: Path = Path(__file__).parent / "translations"
 | 
			
		||||
"""The directory of the translation files."""
 | 
			
		||||
domain: Domain = Domain(translation_directories=[translation_dir],
 | 
			
		||||
                        domain="accounting")
 | 
			
		||||
"""The message domain."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def gettext(string, **variables) -> str:
 | 
			
		||||
@@ -120,6 +122,5 @@ def init_app(app: Flask, bp: Blueprint) -> None:
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    bp.add_url_rule("/_jstrans.js", "babel_catalog",
 | 
			
		||||
                    __babel_js_catalog_view)
 | 
			
		||||
    bp.add_url_rule("/_jstrans.js", "babel_catalog", __babel_js_catalog_view)
 | 
			
		||||
    app.jinja_env.globals["A_"] = domain.gettext
 | 
			
		||||
 
 | 
			
		||||
@@ -21,8 +21,8 @@ from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import datetime as dt
 | 
			
		||||
import re
 | 
			
		||||
import typing as t
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from typing import Type, Self
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from babel import Locale
 | 
			
		||||
@@ -40,7 +40,7 @@ class BaseAccount(db.Model):
 | 
			
		||||
    __tablename__ = "accounting_base_accounts"
 | 
			
		||||
    """The table name."""
 | 
			
		||||
    code: Mapped[str] = mapped_column(primary_key=True)
 | 
			
		||||
    """The code."""
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    title_l10n: Mapped[str] = mapped_column("title")
 | 
			
		||||
    """The title."""
 | 
			
		||||
    l10n: Mapped[list[BaseAccountL10n]] \
 | 
			
		||||
@@ -54,7 +54,7 @@ class BaseAccount(db.Model):
 | 
			
		||||
 | 
			
		||||
        :return: The string representation of the base account.
 | 
			
		||||
        """
 | 
			
		||||
        return f"{self.code} {self.title.title()}"
 | 
			
		||||
        return f"{self.code} {self.title}"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def title(self) -> str:
 | 
			
		||||
@@ -87,7 +87,7 @@ class BaseAccountL10n(db.Model):
 | 
			
		||||
        = mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
 | 
			
		||||
                                      ondelete="CASCADE"),
 | 
			
		||||
                        primary_key=True)
 | 
			
		||||
    """The code of the account."""
 | 
			
		||||
    """The account code."""
 | 
			
		||||
    account: Mapped[BaseAccount] = db.relationship(back_populates="l10n")
 | 
			
		||||
    """The account."""
 | 
			
		||||
    locale: Mapped[str] = mapped_column(primary_key=True)
 | 
			
		||||
@@ -117,21 +117,21 @@ class Account(db.Model):
 | 
			
		||||
    created_at: Mapped[dt.datetime] \
 | 
			
		||||
        = mapped_column(db.DateTime(timezone=True),
 | 
			
		||||
                        server_default=db.func.now())
 | 
			
		||||
    """The time of creation."""
 | 
			
		||||
    """The date and time when this record was created."""
 | 
			
		||||
    created_by_id: Mapped[int] \
 | 
			
		||||
        = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
 | 
			
		||||
    """The ID of the creator."""
 | 
			
		||||
    """The ID of the user who created the record."""
 | 
			
		||||
    created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
 | 
			
		||||
    """The creator."""
 | 
			
		||||
    """The user who created the record."""
 | 
			
		||||
    updated_at: Mapped[dt.datetime] \
 | 
			
		||||
        = mapped_column(db.DateTime(timezone=True),
 | 
			
		||||
                        server_default=db.func.now())
 | 
			
		||||
    """The time of last update."""
 | 
			
		||||
    """The date and time when this record was last updated."""
 | 
			
		||||
    updated_by_id: Mapped[int] \
 | 
			
		||||
        = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
 | 
			
		||||
    """The ID of the updator."""
 | 
			
		||||
    """The ID of the last user who updated the record."""
 | 
			
		||||
    updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
 | 
			
		||||
    """The updator."""
 | 
			
		||||
    """The last user who updated the record."""
 | 
			
		||||
    l10n: Mapped[list[AccountL10n]] \
 | 
			
		||||
        = db.relationship(back_populates="account", lazy=False)
 | 
			
		||||
    """The localized titles."""
 | 
			
		||||
@@ -151,7 +151,7 @@ class Account(db.Model):
 | 
			
		||||
 | 
			
		||||
        :return: The string representation of this account.
 | 
			
		||||
        """
 | 
			
		||||
        return f"{self.base_code}-{self.no:03d} {self.title.title()}"
 | 
			
		||||
        return f"{self.base_code}-{self.no:03d} {self.title}"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def code(self) -> str:
 | 
			
		||||
@@ -182,6 +182,8 @@ class Account(db.Model):
 | 
			
		||||
        :param value: The new title.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        if self.title == value:
 | 
			
		||||
            return
 | 
			
		||||
        if self.title_l10n is None:
 | 
			
		||||
            self.title_l10n = value
 | 
			
		||||
            return
 | 
			
		||||
@@ -222,13 +224,13 @@ class Account(db.Model):
 | 
			
		||||
        return getattr(self, "__count")
 | 
			
		||||
 | 
			
		||||
    @count.setter
 | 
			
		||||
    def count(self, count: int) -> None:
 | 
			
		||||
    def count(self, value: int) -> None:
 | 
			
		||||
        """Sets the number of items in the account.
 | 
			
		||||
 | 
			
		||||
        :param count: The number of items in the account.
 | 
			
		||||
        :param value: The number of items in the account.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        setattr(self, "__count", count)
 | 
			
		||||
        setattr(self, "__count", value)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def query_values(self) -> list[str]:
 | 
			
		||||
@@ -267,11 +269,11 @@ class Account(db.Model):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        AccountL10n.query.filter(AccountL10n.account == self).delete()
 | 
			
		||||
        cls: t.Type[t.Self] = self.__class__
 | 
			
		||||
        cls: Type[Self] = self.__class__
 | 
			
		||||
        cls.query.filter(cls.id == self.id).delete()
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def find_by_code(cls, code: str) -> t.Self | None:
 | 
			
		||||
    def find_by_code(cls, code: str) -> Self | None:
 | 
			
		||||
        """Finds an account by its code.
 | 
			
		||||
 | 
			
		||||
        :param code: The code.
 | 
			
		||||
@@ -284,7 +286,7 @@ class Account(db.Model):
 | 
			
		||||
                                cls.no == int(m.group(2))).first()
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def selectable_debit(cls) -> list[t.Self]:
 | 
			
		||||
    def selectable_debit(cls) -> list[Self]:
 | 
			
		||||
        """Returns the selectable debit accounts.
 | 
			
		||||
        Payable line items can not start from debit.
 | 
			
		||||
 | 
			
		||||
@@ -302,12 +304,11 @@ class Account(db.Model):
 | 
			
		||||
                                       cls.base_code.startswith("78"),
 | 
			
		||||
                                       cls.base_code.startswith("8"),
 | 
			
		||||
                                       cls.base_code.startswith("9")),
 | 
			
		||||
                                cls.base_code != "3351",
 | 
			
		||||
                                cls.base_code != "3353")\
 | 
			
		||||
            .order_by(cls.base_code, cls.no).all()
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def selectable_credit(cls) -> list[t.Self]:
 | 
			
		||||
    def selectable_credit(cls) -> list[Self]:
 | 
			
		||||
        """Returns the selectable debit accounts.
 | 
			
		||||
        Receivable line items can not start from credit.
 | 
			
		||||
 | 
			
		||||
@@ -324,12 +325,11 @@ class Account(db.Model):
 | 
			
		||||
                                       cls.base_code.startswith("74"),
 | 
			
		||||
                                       cls.base_code.startswith("8"),
 | 
			
		||||
                                       cls.base_code.startswith("9")),
 | 
			
		||||
                                cls.base_code != "3351",
 | 
			
		||||
                                cls.base_code != "3353")\
 | 
			
		||||
            .order_by(cls.base_code, cls.no).all()
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def cash(cls) -> t.Self:
 | 
			
		||||
    def cash(cls) -> Self:
 | 
			
		||||
        """Returns the cash account.
 | 
			
		||||
 | 
			
		||||
        :return: The cash account
 | 
			
		||||
@@ -337,7 +337,7 @@ class Account(db.Model):
 | 
			
		||||
        return cls.find_by_code(cls.CASH_CODE)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def accumulated_change(cls) -> t.Self:
 | 
			
		||||
    def accumulated_change(cls) -> Self:
 | 
			
		||||
        """Returns the accumulated-change account.
 | 
			
		||||
 | 
			
		||||
        :return: The accumulated-change account
 | 
			
		||||
@@ -367,28 +367,28 @@ class Currency(db.Model):
 | 
			
		||||
    __tablename__ = "accounting_currencies"
 | 
			
		||||
    """The table name."""
 | 
			
		||||
    code: Mapped[str] = mapped_column(primary_key=True)
 | 
			
		||||
    """The code."""
 | 
			
		||||
    """The currency code."""
 | 
			
		||||
    name_l10n: Mapped[str] = mapped_column("name")
 | 
			
		||||
    """The name."""
 | 
			
		||||
    """The currency name."""
 | 
			
		||||
    created_at: Mapped[dt.datetime] \
 | 
			
		||||
        = mapped_column(db.DateTime(timezone=True),
 | 
			
		||||
                        server_default=db.func.now())
 | 
			
		||||
    """The time of creation."""
 | 
			
		||||
    """The date and time when this record was created."""
 | 
			
		||||
    created_by_id: Mapped[int] \
 | 
			
		||||
        = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
 | 
			
		||||
    """The ID of the creator."""
 | 
			
		||||
    """The ID of the user who created the record."""
 | 
			
		||||
    created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
 | 
			
		||||
    """The creator."""
 | 
			
		||||
    """The user who created the record."""
 | 
			
		||||
    updated_at: Mapped[dt.datetime] \
 | 
			
		||||
        = mapped_column(db.DateTime(timezone=True),
 | 
			
		||||
                        server_default=db.func.now())
 | 
			
		||||
    """The time of last update."""
 | 
			
		||||
    """The date and time when this record was last updated."""
 | 
			
		||||
    updated_by_id: Mapped[int] \
 | 
			
		||||
        = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
 | 
			
		||||
    """The ID of the updator."""
 | 
			
		||||
    """The ID of the last user who updated the record."""
 | 
			
		||||
    updated_by: Mapped[user_cls] \
 | 
			
		||||
        = db.relationship(foreign_keys=updated_by_id)
 | 
			
		||||
    """The updator."""
 | 
			
		||||
    """The last user who updated the record."""
 | 
			
		||||
    l10n: Mapped[list[CurrencyL10n]] \
 | 
			
		||||
        = db.relationship(back_populates="currency", lazy=False)
 | 
			
		||||
    """The localized names."""
 | 
			
		||||
@@ -424,6 +424,8 @@ class Currency(db.Model):
 | 
			
		||||
        :param value: The new name.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        if self.name == value:
 | 
			
		||||
            return
 | 
			
		||||
        if self.name_l10n is None:
 | 
			
		||||
            self.name_l10n = value
 | 
			
		||||
            return
 | 
			
		||||
@@ -467,7 +469,7 @@ class Currency(db.Model):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
 | 
			
		||||
        cls: t.Type[t.Self] = self.__class__
 | 
			
		||||
        cls: Type[Self] = self.__class__
 | 
			
		||||
        cls.query.filter(cls.code == self.code).delete()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -540,27 +542,27 @@ class JournalEntry(db.Model):
 | 
			
		||||
    date: Mapped[dt.date]
 | 
			
		||||
    """The date."""
 | 
			
		||||
    no: Mapped[int] = mapped_column(default=text("1"))
 | 
			
		||||
    """The account number under the date."""
 | 
			
		||||
    """The journal entry number under the date."""
 | 
			
		||||
    note: Mapped[str | None]
 | 
			
		||||
    """The note."""
 | 
			
		||||
    created_at: Mapped[dt.datetime] \
 | 
			
		||||
        = mapped_column(db.DateTime(timezone=True),
 | 
			
		||||
                        server_default=db.func.now())
 | 
			
		||||
    """The time of creation."""
 | 
			
		||||
    """The date and time when this record was created."""
 | 
			
		||||
    created_by_id: Mapped[int] \
 | 
			
		||||
        = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
 | 
			
		||||
    """The ID of the creator."""
 | 
			
		||||
    """The ID of the user who created the record."""
 | 
			
		||||
    created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
 | 
			
		||||
    """The creator."""
 | 
			
		||||
    """The user who created the record."""
 | 
			
		||||
    updated_at: Mapped[dt.datetime] \
 | 
			
		||||
        = mapped_column(db.DateTime(timezone=True),
 | 
			
		||||
                        server_default=db.func.now())
 | 
			
		||||
    """The time of last update."""
 | 
			
		||||
    """The date and time when this record was last updated."""
 | 
			
		||||
    updated_by_id: Mapped[int] \
 | 
			
		||||
        = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
 | 
			
		||||
    """The ID of the updator."""
 | 
			
		||||
    """The ID of the last user who updated the record."""
 | 
			
		||||
    updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
 | 
			
		||||
    """The updator."""
 | 
			
		||||
    """The last user who updated the record."""
 | 
			
		||||
    line_items: Mapped[list[JournalEntryLineItem]] \
 | 
			
		||||
        = db.relationship(back_populates="journal_entry")
 | 
			
		||||
    """The line items."""
 | 
			
		||||
@@ -735,13 +737,13 @@ class JournalEntryLineItem(db.Model):
 | 
			
		||||
        return getattr(self, "__debit")
 | 
			
		||||
 | 
			
		||||
    @debit.setter
 | 
			
		||||
    def debit(self, debit: Decimal | None) -> None:
 | 
			
		||||
    def debit(self, value: Decimal | None) -> None:
 | 
			
		||||
        """Sets the debit amount.
 | 
			
		||||
 | 
			
		||||
        :param debit: The debit amount.
 | 
			
		||||
        :param value: The debit amount.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        setattr(self, "__debit", debit)
 | 
			
		||||
        setattr(self, "__debit", value)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def credit(self) -> Decimal | None:
 | 
			
		||||
@@ -754,13 +756,13 @@ class JournalEntryLineItem(db.Model):
 | 
			
		||||
        return getattr(self, "__credit")
 | 
			
		||||
 | 
			
		||||
    @credit.setter
 | 
			
		||||
    def credit(self, credit: Decimal | None) -> None:
 | 
			
		||||
    def credit(self, value: Decimal | None) -> None:
 | 
			
		||||
        """Sets the credit amount.
 | 
			
		||||
 | 
			
		||||
        :param credit: The credit amount.
 | 
			
		||||
        :param value: The credit amount.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        setattr(self, "__credit", credit)
 | 
			
		||||
        setattr(self, "__credit", value)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def net_balance(self) -> Decimal:
 | 
			
		||||
@@ -775,42 +777,42 @@ class JournalEntryLineItem(db.Model):
 | 
			
		||||
        return getattr(self, "__net_balance")
 | 
			
		||||
 | 
			
		||||
    @net_balance.setter
 | 
			
		||||
    def net_balance(self, net_balance: Decimal) -> None:
 | 
			
		||||
    def net_balance(self, value: Decimal) -> None:
 | 
			
		||||
        """Sets the net balance.
 | 
			
		||||
 | 
			
		||||
        :param net_balance: The net balance.
 | 
			
		||||
        :param value: The net balance.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        setattr(self, "__net_balance", net_balance)
 | 
			
		||||
        setattr(self, "__net_balance", value)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def balance(self) -> Decimal:
 | 
			
		||||
        """Returns the net balance.
 | 
			
		||||
        """Returns the balance.
 | 
			
		||||
 | 
			
		||||
        :return: The net balance.
 | 
			
		||||
        :return: The balance.
 | 
			
		||||
        """
 | 
			
		||||
        if not hasattr(self, "__balance"):
 | 
			
		||||
            setattr(self, "__balance", Decimal("0"))
 | 
			
		||||
        return getattr(self, "__balance")
 | 
			
		||||
 | 
			
		||||
    @balance.setter
 | 
			
		||||
    def balance(self, balance: Decimal) -> None:
 | 
			
		||||
        """Sets the net balance.
 | 
			
		||||
    def balance(self, value: Decimal) -> None:
 | 
			
		||||
        """Sets the balance.
 | 
			
		||||
 | 
			
		||||
        :param balance: The net balance.
 | 
			
		||||
        :param value: The balance.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        setattr(self, "__balance", balance)
 | 
			
		||||
        setattr(self, "__balance", value)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def offsets(self) -> list[t.Self]:
 | 
			
		||||
    def offsets(self) -> list[Self]:
 | 
			
		||||
        """Returns the offset items.
 | 
			
		||||
 | 
			
		||||
        :return: The offset items.
 | 
			
		||||
        """
 | 
			
		||||
        if not hasattr(self, "__offsets"):
 | 
			
		||||
            cls: t.Type[t.Self] = self.__class__
 | 
			
		||||
            offsets: list[t.Self] = cls.query.join(JournalEntry)\
 | 
			
		||||
            cls: Type[Self] = self.__class__
 | 
			
		||||
            offsets: list[Self] = cls.query.join(JournalEntry)\
 | 
			
		||||
                .filter(JournalEntryLineItem.original_line_item_id == self.id)\
 | 
			
		||||
                .order_by(JournalEntry.date, JournalEntry.no,
 | 
			
		||||
                          cls.is_debit, cls.no).all()
 | 
			
		||||
@@ -828,17 +830,16 @@ class JournalEntryLineItem(db.Model):
 | 
			
		||||
        return getattr(self, "__is_offset")
 | 
			
		||||
 | 
			
		||||
    @is_offset.setter
 | 
			
		||||
    def is_offset(self, is_offset: bool) -> None:
 | 
			
		||||
    def is_offset(self, value: bool) -> None:
 | 
			
		||||
        """Sets whether the line item is an offset.
 | 
			
		||||
 | 
			
		||||
        :param is_offset: True if the line item is an offset, or False
 | 
			
		||||
            otherwise.
 | 
			
		||||
        :param value: True if the line item is an offset, or False otherwise.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        setattr(self, "__is_offset", is_offset)
 | 
			
		||||
        setattr(self, "__is_offset", value)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def match(self) -> t.Self | None:
 | 
			
		||||
    def match(self) -> Self | None:
 | 
			
		||||
        """Returns the match of the line item.
 | 
			
		||||
 | 
			
		||||
        :return: The match of the line item.
 | 
			
		||||
@@ -848,13 +849,13 @@ class JournalEntryLineItem(db.Model):
 | 
			
		||||
        return getattr(self, "__match")
 | 
			
		||||
 | 
			
		||||
    @match.setter
 | 
			
		||||
    def match(self, match: t.Self) -> None:
 | 
			
		||||
    def match(self, value: Self) -> None:
 | 
			
		||||
        """Sets the match of the line item.
 | 
			
		||||
 | 
			
		||||
        :param match: The matcho of the line item.
 | 
			
		||||
        :param value: The matcho of the line item.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        setattr(self, "__match", match)
 | 
			
		||||
        setattr(self, "__match", value)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def query_values(self) -> list[str]:
 | 
			
		||||
@@ -886,18 +887,18 @@ class Option(db.Model):
 | 
			
		||||
    created_at: Mapped[dt.datetime] \
 | 
			
		||||
        = mapped_column(db.DateTime(timezone=True),
 | 
			
		||||
                        server_default=db.func.now())
 | 
			
		||||
    """The time of creation."""
 | 
			
		||||
    """The date and time when this record was created."""
 | 
			
		||||
    created_by_id: Mapped[int] \
 | 
			
		||||
        = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
 | 
			
		||||
    """The ID of the creator."""
 | 
			
		||||
    """The ID of the user who created the record."""
 | 
			
		||||
    created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
 | 
			
		||||
    """The creator."""
 | 
			
		||||
    """The user who created the record."""
 | 
			
		||||
    updated_at: Mapped[dt.datetime] \
 | 
			
		||||
        = mapped_column(db.DateTime(timezone=True),
 | 
			
		||||
                        server_default=db.func.now())
 | 
			
		||||
    """The time of last update."""
 | 
			
		||||
    """The date and time when this record was last updated."""
 | 
			
		||||
    updated_by_id: Mapped[int] \
 | 
			
		||||
        = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
 | 
			
		||||
    """The ID of the updator."""
 | 
			
		||||
    """The ID of the last user who updated the record."""
 | 
			
		||||
    updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
 | 
			
		||||
    """The updator."""
 | 
			
		||||
    """The last user who updated the record."""
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#  Copyright (c) 2023-2024 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
@@ -20,10 +20,11 @@ This file is largely taken from the NanoParma ERP project, first written in
 | 
			
		||||
2021/9/16 by imacat (imacat@nanoparma.com).
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
 | 
			
		||||
from accounting.models import JournalEntry
 | 
			
		||||
from accounting.utils.timezone import get_tz_today
 | 
			
		||||
from .period import Period
 | 
			
		||||
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
 | 
			
		||||
    LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
 | 
			
		||||
@@ -32,13 +33,13 @@ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
 | 
			
		||||
class PeriodChooser:
 | 
			
		||||
    """The period chooser."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, get_url: t.Callable[[Period], str]):
 | 
			
		||||
    def __init__(self, get_url: Callable[[Period], str]):
 | 
			
		||||
        """Constructs a period chooser.
 | 
			
		||||
 | 
			
		||||
        :param get_url: The callback to return the URL of the current report in
 | 
			
		||||
            a period.
 | 
			
		||||
        """
 | 
			
		||||
        self.__get_url: t.Callable[[Period], str] = get_url
 | 
			
		||||
        self.__get_url: Callable[[Period], str] = get_url
 | 
			
		||||
        """The callback to return the URL of the current report in a period."""
 | 
			
		||||
 | 
			
		||||
        # Shortcut periods
 | 
			
		||||
@@ -63,10 +64,10 @@ class PeriodChooser:
 | 
			
		||||
 | 
			
		||||
        first: JournalEntry | None \
 | 
			
		||||
            = JournalEntry.query.order_by(JournalEntry.date).first()
 | 
			
		||||
        start: date | None = None if first is None else first.date
 | 
			
		||||
        start: dt.date | None = None if first is None else first.date
 | 
			
		||||
 | 
			
		||||
        # Attributes
 | 
			
		||||
        self.data_start: date | None = start
 | 
			
		||||
        self.data_start: dt.date | None = start
 | 
			
		||||
        """The start of the data."""
 | 
			
		||||
        self.has_data: bool = start is not None
 | 
			
		||||
        """Whether there is any data."""
 | 
			
		||||
@@ -80,8 +81,8 @@ class PeriodChooser:
 | 
			
		||||
        """The available years."""
 | 
			
		||||
 | 
			
		||||
        if self.has_data:
 | 
			
		||||
            today: date = date.today()
 | 
			
		||||
            self.has_last_month = start < date(today.year, today.month, 1)
 | 
			
		||||
            today: dt.date = get_tz_today()
 | 
			
		||||
            self.has_last_month = start < dt.date(today.year, today.month, 1)
 | 
			
		||||
            self.has_last_year = start.year < today.year
 | 
			
		||||
            self.has_yesterday = start < today
 | 
			
		||||
            if start.year < today.year - 1:
 | 
			
		||||
 
 | 
			
		||||
@@ -17,12 +17,12 @@
 | 
			
		||||
"""The period description composer.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
import datetime as dt
 | 
			
		||||
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_desc(start: date | None, end: date | None) -> str:
 | 
			
		||||
def get_desc(start: dt.date | None, end: dt.date | None) -> str:
 | 
			
		||||
    """Returns the period description.
 | 
			
		||||
 | 
			
		||||
    :param start: The start of the period.
 | 
			
		||||
@@ -46,7 +46,7 @@ def get_desc(start: date | None, end: date | None) -> str:
 | 
			
		||||
    return __get_day_desc(start, end)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_since_desc(start: date) -> str:
 | 
			
		||||
def __get_since_desc(start: dt.date) -> str:
 | 
			
		||||
    """Returns the description without the end day.
 | 
			
		||||
 | 
			
		||||
    :param start: The start of the period.
 | 
			
		||||
@@ -67,7 +67,7 @@ def __get_since_desc(start: date) -> str:
 | 
			
		||||
    return gettext("since %(start)s", start=get_start_desc())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_until_desc(end: date) -> str:
 | 
			
		||||
def __get_until_desc(end: dt.date) -> str:
 | 
			
		||||
    """Returns the description without the start day.
 | 
			
		||||
 | 
			
		||||
    :param end: The end of the period.
 | 
			
		||||
@@ -81,14 +81,14 @@ def __get_until_desc(end: date) -> str:
 | 
			
		||||
        """
 | 
			
		||||
        if end.month == 12 and end.day == 31:
 | 
			
		||||
            return str(end.year)
 | 
			
		||||
        if (end + timedelta(days=1)).day == 1:
 | 
			
		||||
        if (end + dt.timedelta(days=1)).day == 1:
 | 
			
		||||
            return __format_month(end)
 | 
			
		||||
        return __format_day(end)
 | 
			
		||||
 | 
			
		||||
    return gettext("until %(end)s", end=get_end_desc())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_year_desc(start: date, end: date) -> str:
 | 
			
		||||
def __get_year_desc(start: dt.date, end: dt.date) -> str:
 | 
			
		||||
    """Returns the description as a year range.
 | 
			
		||||
 | 
			
		||||
    :param start: The start of the period.
 | 
			
		||||
@@ -105,7 +105,7 @@ def __get_year_desc(start: date, end: date) -> str:
 | 
			
		||||
    return __get_from_to_desc(start_text, str(end.year))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_month_desc(start: date, end: date) -> str:
 | 
			
		||||
def __get_month_desc(start: dt.date, end: dt.date) -> str:
 | 
			
		||||
    """Returns the description as a month range.
 | 
			
		||||
 | 
			
		||||
    :param start: The start of the period.
 | 
			
		||||
@@ -113,7 +113,7 @@ def __get_month_desc(start: date, end: date) -> str:
 | 
			
		||||
    :return: The description as a month range.
 | 
			
		||||
    :raise ValueError: The period is not a month range.
 | 
			
		||||
    """
 | 
			
		||||
    if start.day != 1 or (end + timedelta(days=1)).day != 1:
 | 
			
		||||
    if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
 | 
			
		||||
        raise ValueError
 | 
			
		||||
    start_text: str = __format_month(start)
 | 
			
		||||
    if start.year == end.year and start.month == end.month:
 | 
			
		||||
@@ -123,7 +123,7 @@ def __get_month_desc(start: date, end: date) -> str:
 | 
			
		||||
    return __get_from_to_desc(start_text, __format_month(end))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_day_desc(start: date, end: date) -> str:
 | 
			
		||||
def __get_day_desc(start: dt.date, end: dt.date) -> str:
 | 
			
		||||
    """Returns the description as a day range.
 | 
			
		||||
 | 
			
		||||
    :param start: The start of the period.
 | 
			
		||||
@@ -142,7 +142,7 @@ def __get_day_desc(start: date, end: date) -> str:
 | 
			
		||||
    return __get_from_to_desc(start_text, __format_day(end))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __format_month(month: date) -> str:
 | 
			
		||||
def __format_month(month: dt.date) -> str:
 | 
			
		||||
    """Formats a month.
 | 
			
		||||
 | 
			
		||||
    :param month: The month.
 | 
			
		||||
@@ -151,7 +151,7 @@ def __format_month(month: date) -> str:
 | 
			
		||||
    return f"{month.year}/{month.month}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __format_day(day: date) -> str:
 | 
			
		||||
def __format_day(day: dt.date) -> str:
 | 
			
		||||
    """Formats a day.
 | 
			
		||||
 | 
			
		||||
    :param day: The day.
 | 
			
		||||
 
 | 
			
		||||
@@ -18,14 +18,14 @@
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import calendar
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def month_end(day: date) -> date:
 | 
			
		||||
def month_end(day: dt.date) -> dt.date:
 | 
			
		||||
    """Returns the end day of month for a date.
 | 
			
		||||
 | 
			
		||||
    :param day: The date.
 | 
			
		||||
    :return: The end day of the month of that day.
 | 
			
		||||
    """
 | 
			
		||||
    last_day: int = calendar.monthrange(day.year, day.month)[1]
 | 
			
		||||
    return date(day.year, day.month, last_day)
 | 
			
		||||
    return dt.date(day.year, day.month, last_day)
 | 
			
		||||
 
 | 
			
		||||
@@ -18,9 +18,10 @@
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import calendar
 | 
			
		||||
import datetime as dt
 | 
			
		||||
import re
 | 
			
		||||
import typing as t
 | 
			
		||||
from datetime import date
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
from typing import Type
 | 
			
		||||
 | 
			
		||||
from .period import Period
 | 
			
		||||
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
 | 
			
		||||
@@ -39,7 +40,7 @@ def get_period(spec: str | None = None) -> Period:
 | 
			
		||||
    """
 | 
			
		||||
    if spec is None:
 | 
			
		||||
        return ThisMonth()
 | 
			
		||||
    named_periods: dict[str, t.Type[t.Callable[[], Period]]] = {
 | 
			
		||||
    named_periods: dict[str, Type[Callable[[], Period]]] = {
 | 
			
		||||
        "this-month": lambda: ThisMonth(),
 | 
			
		||||
        "last-month": lambda: LastMonth(),
 | 
			
		||||
        "since-last-month": lambda: SinceLastMonth(),
 | 
			
		||||
@@ -57,7 +58,7 @@ def get_period(spec: str | None = None) -> Period:
 | 
			
		||||
    return Period(start, end)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __parse_spec(text: str) -> tuple[date | None, date | None]:
 | 
			
		||||
def __parse_spec(text: str) -> tuple[dt.date | None, dt.date | None]:
 | 
			
		||||
    """Parses the period specification.
 | 
			
		||||
 | 
			
		||||
    :param text: The period specification.
 | 
			
		||||
@@ -84,7 +85,7 @@ def __parse_spec(text: str) -> tuple[date | None, date | None]:
 | 
			
		||||
    raise ValueError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_start(year: str, month: str | None, day: str | None) -> date:
 | 
			
		||||
def __get_start(year: str, month: str | None, day: str | None) -> dt.date:
 | 
			
		||||
    """Returns the start of the period from the date representation.
 | 
			
		||||
 | 
			
		||||
    :param year: The year.
 | 
			
		||||
@@ -94,13 +95,13 @@ def __get_start(year: str, month: str | None, day: str | None) -> date:
 | 
			
		||||
    :raise ValueError: When the date is invalid.
 | 
			
		||||
    """
 | 
			
		||||
    if day is not None:
 | 
			
		||||
        return date(int(year), int(month), int(day))
 | 
			
		||||
        return dt.date(int(year), int(month), int(day))
 | 
			
		||||
    if month is not None:
 | 
			
		||||
        return date(int(year), int(month), 1)
 | 
			
		||||
    return date(int(year), 1, 1)
 | 
			
		||||
        return dt.date(int(year), int(month), 1)
 | 
			
		||||
    return dt.date(int(year), 1, 1)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_end(year: str, month: str | None, day: str | None) -> date:
 | 
			
		||||
def __get_end(year: str, month: str | None, day: str | None) -> dt.date:
 | 
			
		||||
    """Returns the end of the period from the date representation.
 | 
			
		||||
 | 
			
		||||
    :param year: The year.
 | 
			
		||||
@@ -110,10 +111,10 @@ def __get_end(year: str, month: str | None, day: str | None) -> date:
 | 
			
		||||
    :raise ValueError: When the date is invalid.
 | 
			
		||||
    """
 | 
			
		||||
    if day is not None:
 | 
			
		||||
        return date(int(year), int(month), int(day))
 | 
			
		||||
        return dt.date(int(year), int(month), int(day))
 | 
			
		||||
    if month is not None:
 | 
			
		||||
        year_n: int = int(year)
 | 
			
		||||
        month_n: int = int(month)
 | 
			
		||||
        day_n: int = calendar.monthrange(year_n, month_n)[1]
 | 
			
		||||
        return date(year_n, month_n, day_n)
 | 
			
		||||
    return date(int(year), 12, 31)
 | 
			
		||||
        return dt.date(year_n, month_n, day_n)
 | 
			
		||||
    return dt.date(int(year), 12, 31)
 | 
			
		||||
 
 | 
			
		||||
@@ -20,8 +20,8 @@ This file is largely taken from the NanoParma ERP project, first written in
 | 
			
		||||
2021/9/16 by imacat (imacat@nanoparma.com).
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from typing import Self
 | 
			
		||||
 | 
			
		||||
from .description import get_desc
 | 
			
		||||
from .month_end import month_end
 | 
			
		||||
@@ -31,18 +31,18 @@ from .specification import get_spec
 | 
			
		||||
class Period:
 | 
			
		||||
    """A date period."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, start: date | None, end: date | None):
 | 
			
		||||
    def __init__(self, start: dt.date | None, end: dt.date | None):
 | 
			
		||||
        """Constructs a new date period.
 | 
			
		||||
 | 
			
		||||
        :param start: The start date, or None from the very beginning.
 | 
			
		||||
        :param end: The end date, or None till no end.
 | 
			
		||||
        """
 | 
			
		||||
        self.start: date | None = start
 | 
			
		||||
        self.start: dt.date | None = start
 | 
			
		||||
        """The start of the period."""
 | 
			
		||||
        self.end: date | None = end
 | 
			
		||||
        self.end: dt.date | None = end
 | 
			
		||||
        """The end of the period."""
 | 
			
		||||
        self.is_default: bool = False
 | 
			
		||||
        """Whether the is the default period."""
 | 
			
		||||
        """Whether this is the default period."""
 | 
			
		||||
        self.is_this_month: bool = False
 | 
			
		||||
        """Whether the period is this month."""
 | 
			
		||||
        self.is_last_month: bool = False
 | 
			
		||||
@@ -95,8 +95,8 @@ class Period:
 | 
			
		||||
        self.is_a_month = self.start.day == 1 \
 | 
			
		||||
            and self.end == month_end(self.start)
 | 
			
		||||
        self.is_type_month = self.is_a_month
 | 
			
		||||
        self.is_a_year = self.start == date(self.start.year, 1, 1) \
 | 
			
		||||
            and self.end == date(self.start.year, 12, 31)
 | 
			
		||||
        self.is_a_year = self.start == dt.date(self.start.year, 1, 1) \
 | 
			
		||||
            and self.end == dt.date(self.start.year, 12, 31)
 | 
			
		||||
        self.is_a_day = self.start == self.end
 | 
			
		||||
 | 
			
		||||
    def is_year(self, year: int) -> bool:
 | 
			
		||||
@@ -119,11 +119,11 @@ class Period:
 | 
			
		||||
            and not self.is_a_day
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def before(self) -> t.Self | None:
 | 
			
		||||
    def before(self) -> Self | None:
 | 
			
		||||
        """Returns the period before this period.
 | 
			
		||||
 | 
			
		||||
        :return: The period before this period.
 | 
			
		||||
        """
 | 
			
		||||
        if self.start is None:
 | 
			
		||||
            return None
 | 
			
		||||
        return Period(None, self.start - timedelta(days=1))
 | 
			
		||||
        return Period(None, self.start - dt.timedelta(days=1))
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#  Copyright (c) 2023-2024 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
@@ -17,9 +17,10 @@
 | 
			
		||||
"""The named shortcut periods.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
import datetime as dt
 | 
			
		||||
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.utils.timezone import get_tz_today
 | 
			
		||||
from .month_end import month_end
 | 
			
		||||
from .period import Period
 | 
			
		||||
 | 
			
		||||
@@ -27,8 +28,8 @@ from .period import Period
 | 
			
		||||
class ThisMonth(Period):
 | 
			
		||||
    """The period of this month."""
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        today: date = date.today()
 | 
			
		||||
        this_month_start: date = date(today.year, today.month, 1)
 | 
			
		||||
        today: dt.date = get_tz_today()
 | 
			
		||||
        this_month_start: dt.date = dt.date(today.year, today.month, 1)
 | 
			
		||||
        super().__init__(this_month_start, month_end(today))
 | 
			
		||||
        self.is_default = True
 | 
			
		||||
        self.is_this_month = True
 | 
			
		||||
@@ -43,13 +44,13 @@ class ThisMonth(Period):
 | 
			
		||||
class LastMonth(Period):
 | 
			
		||||
    """The period of this month."""
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        today: date = date.today()
 | 
			
		||||
        today: dt.date = get_tz_today()
 | 
			
		||||
        year: int = today.year
 | 
			
		||||
        month: int = today.month - 1
 | 
			
		||||
        if month < 1:
 | 
			
		||||
            year = year - 1
 | 
			
		||||
            month = 12
 | 
			
		||||
        start: date = date(year, month, 1)
 | 
			
		||||
        start: dt.date = dt.date(year, month, 1)
 | 
			
		||||
        super().__init__(start, month_end(start))
 | 
			
		||||
        self.is_last_month = True
 | 
			
		||||
 | 
			
		||||
@@ -63,13 +64,13 @@ class LastMonth(Period):
 | 
			
		||||
class SinceLastMonth(Period):
 | 
			
		||||
    """The period of this month."""
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        today: date = date.today()
 | 
			
		||||
        today: dt.date = get_tz_today()
 | 
			
		||||
        year: int = today.year
 | 
			
		||||
        month: int = today.month - 1
 | 
			
		||||
        if month < 1:
 | 
			
		||||
            year = year - 1
 | 
			
		||||
            month = 12
 | 
			
		||||
        start: date = date(year, month, 1)
 | 
			
		||||
        start: dt.date = dt.date(year, month, 1)
 | 
			
		||||
        super().__init__(start, None)
 | 
			
		||||
        self.is_since_last_month = True
 | 
			
		||||
 | 
			
		||||
@@ -82,9 +83,9 @@ class SinceLastMonth(Period):
 | 
			
		||||
class ThisYear(Period):
 | 
			
		||||
    """The period of this year."""
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        year: int = date.today().year
 | 
			
		||||
        start: date = date(year, 1, 1)
 | 
			
		||||
        end: date = date(year, 12, 31)
 | 
			
		||||
        year: int = get_tz_today().year
 | 
			
		||||
        start: dt.date = dt.date(year, 1, 1)
 | 
			
		||||
        end: dt.date = dt.date(year, 12, 31)
 | 
			
		||||
        super().__init__(start, end)
 | 
			
		||||
        self.is_this_year = True
 | 
			
		||||
 | 
			
		||||
@@ -97,9 +98,9 @@ class ThisYear(Period):
 | 
			
		||||
class LastYear(Period):
 | 
			
		||||
    """The period of last year."""
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        year: int = date.today().year
 | 
			
		||||
        start: date = date(year - 1, 1, 1)
 | 
			
		||||
        end: date = date(year - 1, 12, 31)
 | 
			
		||||
        year: int = get_tz_today().year
 | 
			
		||||
        start: dt.date = dt.date(year - 1, 1, 1)
 | 
			
		||||
        end: dt.date = dt.date(year - 1, 12, 31)
 | 
			
		||||
        super().__init__(start, end)
 | 
			
		||||
        self.is_last_year = True
 | 
			
		||||
 | 
			
		||||
@@ -112,7 +113,7 @@ class LastYear(Period):
 | 
			
		||||
class Today(Period):
 | 
			
		||||
    """The period of today."""
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        today: date = date.today()
 | 
			
		||||
        today: dt.date = get_tz_today()
 | 
			
		||||
        super().__init__(today, today)
 | 
			
		||||
        self.is_today = True
 | 
			
		||||
 | 
			
		||||
@@ -125,7 +126,7 @@ class Today(Period):
 | 
			
		||||
class Yesterday(Period):
 | 
			
		||||
    """The period of yesterday."""
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        yesterday: date = date.today() - timedelta(days=1)
 | 
			
		||||
        yesterday: dt.date = get_tz_today() - dt.timedelta(days=1)
 | 
			
		||||
        super().__init__(yesterday, yesterday)
 | 
			
		||||
        self.is_yesterday = True
 | 
			
		||||
 | 
			
		||||
@@ -163,6 +164,6 @@ class YearPeriod(Period):
 | 
			
		||||
 | 
			
		||||
        :param year: The year.
 | 
			
		||||
        """
 | 
			
		||||
        start: date = date(year, 1, 1)
 | 
			
		||||
        end: date = date(year, 12, 31)
 | 
			
		||||
        start: dt.date = dt.date(year, 1, 1)
 | 
			
		||||
        end: dt.date = dt.date(year, 12, 31)
 | 
			
		||||
        super().__init__(start, end)
 | 
			
		||||
 
 | 
			
		||||
@@ -17,10 +17,10 @@
 | 
			
		||||
"""The period specification composer.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
import datetime as dt
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_spec(start: date | None, end: date | None) -> str:
 | 
			
		||||
def get_spec(start: dt.date | None, end: dt.date | None) -> str:
 | 
			
		||||
    """Returns the period specification.
 | 
			
		||||
 | 
			
		||||
    :param start: The start of the period.
 | 
			
		||||
@@ -44,7 +44,7 @@ def get_spec(start: date | None, end: date | None) -> str:
 | 
			
		||||
    return __get_day_spec(start, end)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_since_spec(start: date) -> str:
 | 
			
		||||
def __get_since_spec(start: dt.date) -> str:
 | 
			
		||||
    """Returns the period specification without the end day.
 | 
			
		||||
 | 
			
		||||
    :param start: The start of the period.
 | 
			
		||||
@@ -57,7 +57,7 @@ def __get_since_spec(start: date) -> str:
 | 
			
		||||
    return start.strftime("%Y-%m-%d-")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_until_spec(end: date) -> str:
 | 
			
		||||
def __get_until_spec(end: dt.date) -> str:
 | 
			
		||||
    """Returns the period specification without the start day.
 | 
			
		||||
 | 
			
		||||
    :param end: The end of the period.
 | 
			
		||||
@@ -65,12 +65,12 @@ def __get_until_spec(end: date) -> str:
 | 
			
		||||
    """
 | 
			
		||||
    if end.month == 12 and end.day == 31:
 | 
			
		||||
        return end.strftime("-%Y")
 | 
			
		||||
    if (end + timedelta(days=1)).day == 1:
 | 
			
		||||
    if (end + dt.timedelta(days=1)).day == 1:
 | 
			
		||||
        return end.strftime("-%Y-%m")
 | 
			
		||||
    return end.strftime("-%Y-%m-%d")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_year_spec(start: date, end: date) -> str:
 | 
			
		||||
def __get_year_spec(start: dt.date, end: dt.date) -> str:
 | 
			
		||||
    """Returns the period specification as a year range.
 | 
			
		||||
 | 
			
		||||
    :param start: The start of the period.
 | 
			
		||||
@@ -88,7 +88,7 @@ def __get_year_spec(start: date, end: date) -> str:
 | 
			
		||||
    return f"{start_spec}-{end_spec}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_month_spec(start: date, end: date) -> str:
 | 
			
		||||
def __get_month_spec(start: dt.date, end: dt.date) -> str:
 | 
			
		||||
    """Returns the period specification as a month range.
 | 
			
		||||
 | 
			
		||||
    :param start: The start of the period.
 | 
			
		||||
@@ -96,7 +96,7 @@ def __get_month_spec(start: date, end: date) -> str:
 | 
			
		||||
    :return: The period specification as a month range.
 | 
			
		||||
    :raise ValueError: The period is not a month range.
 | 
			
		||||
    """
 | 
			
		||||
    if start.day != 1 or (end + timedelta(days=1)).day != 1:
 | 
			
		||||
    if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
 | 
			
		||||
        raise ValueError
 | 
			
		||||
    start_spec: str = start.strftime("%Y-%m")
 | 
			
		||||
    if start.year == end.year and start.month == end.month:
 | 
			
		||||
@@ -105,7 +105,7 @@ def __get_month_spec(start: date, end: date) -> str:
 | 
			
		||||
    return f"{start_spec}-{end_spec}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_day_spec(start: date, end: date) -> str:
 | 
			
		||||
def __get_day_spec(start: dt.date, end: dt.date) -> str:
 | 
			
		||||
    """Returns the period specification as a day range.
 | 
			
		||||
 | 
			
		||||
    :param start: The start of the period.
 | 
			
		||||
 
 | 
			
		||||
@@ -145,6 +145,7 @@ class AccountCollector:
 | 
			
		||||
            .filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
 | 
			
		||||
                           Account.base_code == "3351",
 | 
			
		||||
                           Account.base_code == "3353")).all()
 | 
			
		||||
        """The accounts."""
 | 
			
		||||
        account_by_id: dict[int, Account] \
 | 
			
		||||
            = {x.id: x for x in self.__all_accounts}
 | 
			
		||||
        self.accounts: list[ReportAccount] \
 | 
			
		||||
@@ -154,6 +155,7 @@ class AccountCollector:
 | 
			
		||||
                                            account_by_id[x.id],
 | 
			
		||||
                                            self.__period))
 | 
			
		||||
               for x in account_balances]
 | 
			
		||||
        """The accounts on the balance sheet."""
 | 
			
		||||
        self.__add_accumulated()
 | 
			
		||||
        self.__add_current_period()
 | 
			
		||||
        self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
 | 
			
		||||
@@ -452,11 +454,11 @@ class BalanceSheet(BaseReport):
 | 
			
		||||
        :return: The CSV rows for the section.
 | 
			
		||||
        """
 | 
			
		||||
        rows: list[CSVHalfRow] \
 | 
			
		||||
            = [CSVHalfRow(section.title.title.title(), None)]
 | 
			
		||||
            = [CSVHalfRow(section.title.title, None)]
 | 
			
		||||
        for subsection in section.subsections:
 | 
			
		||||
            rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None))
 | 
			
		||||
            rows.append(CSVHalfRow(f" {subsection.title.title}", None))
 | 
			
		||||
            for account in subsection.accounts:
 | 
			
		||||
                rows.append(CSVHalfRow(f"  {str(account.account).title()}",
 | 
			
		||||
                rows.append(CSVHalfRow(f"  {str(account.account)}",
 | 
			
		||||
                                       account.amount))
 | 
			
		||||
        return rows
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The income and expenses log.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
@@ -53,7 +53,7 @@ class ReportLineItem:
 | 
			
		||||
        """Whether this is the brought-forward line item."""
 | 
			
		||||
        self.is_total: bool = False
 | 
			
		||||
        """Whether this is the total line item."""
 | 
			
		||||
        self.date: date | None = None
 | 
			
		||||
        self.date: dt.date | None = None
 | 
			
		||||
        """The date."""
 | 
			
		||||
        self.account: Account | None = None
 | 
			
		||||
        """The account."""
 | 
			
		||||
@@ -213,7 +213,7 @@ class LineItemCollector:
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """A row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, journal_entry_date: date | str | None,
 | 
			
		||||
    def __init__(self, date: dt.date | str | None,
 | 
			
		||||
                 account: str | None,
 | 
			
		||||
                 description: str | None,
 | 
			
		||||
                 income: str | Decimal | None,
 | 
			
		||||
@@ -222,7 +222,7 @@ class CSVRow(BaseCSVRow):
 | 
			
		||||
                 note: str | None):
 | 
			
		||||
        """Constructs a row in the CSV.
 | 
			
		||||
 | 
			
		||||
        :param journal_entry_date: The journal entry date.
 | 
			
		||||
        :param date: The journal entry date.
 | 
			
		||||
        :param account: The account.
 | 
			
		||||
        :param description: The description.
 | 
			
		||||
        :param income: The income.
 | 
			
		||||
@@ -230,7 +230,7 @@ class CSVRow(BaseCSVRow):
 | 
			
		||||
        :param balance: The balance.
 | 
			
		||||
        :param note: The note.
 | 
			
		||||
        """
 | 
			
		||||
        self.date: date | str | None = journal_entry_date
 | 
			
		||||
        self.date: dt.date | str | None = date
 | 
			
		||||
        """The date."""
 | 
			
		||||
        self.account: str | None = account
 | 
			
		||||
        """The account."""
 | 
			
		||||
@@ -407,13 +407,13 @@ class IncomeExpenses(BaseReport):
 | 
			
		||||
                                     gettext("Note"))]
 | 
			
		||||
        if self.__brought_forward is not None:
 | 
			
		||||
            rows.append(CSVRow(self.__brought_forward.date,
 | 
			
		||||
                               str(self.__brought_forward.account).title(),
 | 
			
		||||
                               str(self.__brought_forward.account),
 | 
			
		||||
                               self.__brought_forward.description,
 | 
			
		||||
                               self.__brought_forward.income,
 | 
			
		||||
                               self.__brought_forward.expense,
 | 
			
		||||
                               self.__brought_forward.balance,
 | 
			
		||||
                               None))
 | 
			
		||||
        rows.extend([CSVRow(x.date, str(x.account).title(), x.description,
 | 
			
		||||
        rows.extend([CSVRow(x.date, str(x.account), x.description,
 | 
			
		||||
                            x.income, x.expense, x.balance, x.note)
 | 
			
		||||
                     for x in self.__line_items])
 | 
			
		||||
        if self.__total is not None:
 | 
			
		||||
 
 | 
			
		||||
@@ -106,6 +106,7 @@ class Section:
 | 
			
		||||
        """The subsections in the section."""
 | 
			
		||||
        self.accumulated: AccumulatedTotal \
 | 
			
		||||
            = AccumulatedTotal(accumulated_title)
 | 
			
		||||
        """The accumulated total."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def total(self) -> Decimal:
 | 
			
		||||
@@ -225,12 +226,12 @@ class IncomeStatement(BaseReport):
 | 
			
		||||
                                          for x in balances})).all()
 | 
			
		||||
 | 
			
		||||
        total_titles: dict[str, str] \
 | 
			
		||||
            = {"4": gettext("total operating revenue"),
 | 
			
		||||
               "5": gettext("gross income"),
 | 
			
		||||
               "6": gettext("operating income"),
 | 
			
		||||
               "7": gettext("before tax income"),
 | 
			
		||||
               "8": gettext("after tax income"),
 | 
			
		||||
               "9": gettext("net income or loss for current period")}
 | 
			
		||||
            = {"4": gettext("Total Operating Revenue"),
 | 
			
		||||
               "5": gettext("Gross Income"),
 | 
			
		||||
               "6": gettext("Operating Income"),
 | 
			
		||||
               "7": gettext("Before Tax Income"),
 | 
			
		||||
               "8": gettext("After Tax Income"),
 | 
			
		||||
               "9": gettext("Net Income or Loss for Current Period")}
 | 
			
		||||
 | 
			
		||||
        sections: dict[str, Section] \
 | 
			
		||||
            = {x.code: Section(x, total_titles[x.code]) for x in titles}
 | 
			
		||||
@@ -300,14 +301,14 @@ class IncomeStatement(BaseReport):
 | 
			
		||||
        total_str: str = gettext("Total")
 | 
			
		||||
        rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
 | 
			
		||||
        for section in self.__sections:
 | 
			
		||||
            rows.append(CSVRow(str(section.title).title(), None))
 | 
			
		||||
            rows.append(CSVRow(str(section.title), None))
 | 
			
		||||
            for subsection in section.subsections:
 | 
			
		||||
                rows.append(CSVRow(f" {str(subsection.title).title()}", None))
 | 
			
		||||
                rows.append(CSVRow(f" {str(subsection.title)}", None))
 | 
			
		||||
                for account in subsection.accounts:
 | 
			
		||||
                    rows.append(CSVRow(f"  {str(account.account).title()}",
 | 
			
		||||
                    rows.append(CSVRow(f"  {str(account.account)}",
 | 
			
		||||
                                       account.amount))
 | 
			
		||||
                rows.append(CSVRow(f" {total_str}", subsection.total))
 | 
			
		||||
            rows.append(CSVRow(section.accumulated.title.title(),
 | 
			
		||||
            rows.append(CSVRow(section.accumulated.title,
 | 
			
		||||
                               section.accumulated.amount))
 | 
			
		||||
            rows.append(CSVRow(None, None))
 | 
			
		||||
        rows = rows[:-1]
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The journal.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
@@ -67,7 +67,7 @@ class ReportLineItem:
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """A row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, journal_entry_date: str | date,
 | 
			
		||||
    def __init__(self, journal_entry_date: str | dt.date,
 | 
			
		||||
                 currency: str,
 | 
			
		||||
                 account: str,
 | 
			
		||||
                 description: str | None,
 | 
			
		||||
@@ -84,7 +84,7 @@ class CSVRow(BaseCSVRow):
 | 
			
		||||
        :param credit: The credit amount.
 | 
			
		||||
        :param note: The note.
 | 
			
		||||
        """
 | 
			
		||||
        self.date: str | date = journal_entry_date
 | 
			
		||||
        self.date: str | dt.date = journal_entry_date
 | 
			
		||||
        """The date."""
 | 
			
		||||
        self.currency: str = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
@@ -160,7 +160,7 @@ def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
 | 
			
		||||
                                 gettext("Debit"), gettext("Credit"),
 | 
			
		||||
                                 gettext("Note"))]
 | 
			
		||||
    rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
 | 
			
		||||
                        str(x.account).title(), x.description,
 | 
			
		||||
                        str(x.account), x.description,
 | 
			
		||||
                        x.debit, x.credit, x.journal_entry.note)
 | 
			
		||||
                 for x in line_items])
 | 
			
		||||
    return rows
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The ledger.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
@@ -52,7 +52,7 @@ class ReportLineItem:
 | 
			
		||||
        """Whether this is the brought-forward line item."""
 | 
			
		||||
        self.is_total: bool = False
 | 
			
		||||
        """Whether this is the total line item."""
 | 
			
		||||
        self.date: date | None = None
 | 
			
		||||
        self.date: dt.date | None = None
 | 
			
		||||
        """The date."""
 | 
			
		||||
        self.description: str | None = None
 | 
			
		||||
        """The description."""
 | 
			
		||||
@@ -196,7 +196,7 @@ class LineItemCollector:
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """A row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, journal_entry_date: date | str | None,
 | 
			
		||||
    def __init__(self, date: dt.date | str | None,
 | 
			
		||||
                 description: str | None,
 | 
			
		||||
                 debit: str | Decimal | None,
 | 
			
		||||
                 credit: str | Decimal | None,
 | 
			
		||||
@@ -204,14 +204,14 @@ class CSVRow(BaseCSVRow):
 | 
			
		||||
                 note: str | None):
 | 
			
		||||
        """Constructs a row in the CSV.
 | 
			
		||||
 | 
			
		||||
        :param journal_entry_date: The journal entry date.
 | 
			
		||||
        :param date: The journal entry date.
 | 
			
		||||
        :param description: The description.
 | 
			
		||||
        :param debit: The debit amount.
 | 
			
		||||
        :param credit: The credit amount.
 | 
			
		||||
        :param balance: The balance.
 | 
			
		||||
        :param note: The note.
 | 
			
		||||
        """
 | 
			
		||||
        self.date: date | str | None = journal_entry_date
 | 
			
		||||
        self.date: dt.date | str | None = date
 | 
			
		||||
        """The date."""
 | 
			
		||||
        self.description: str | None = description
 | 
			
		||||
        """The description."""
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The search.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
@@ -124,40 +124,33 @@ class LineItemCollector:
 | 
			
		||||
        """
 | 
			
		||||
        conditions: list[sa.BinaryExpression] \
 | 
			
		||||
            = [JournalEntry.note.icontains(k)]
 | 
			
		||||
        journal_entry_date: datetime
 | 
			
		||||
        date: dt.datetime
 | 
			
		||||
        try:
 | 
			
		||||
            journal_entry_date = datetime.strptime(k, "%Y")
 | 
			
		||||
            conditions.append(sa.extract("year", JournalEntry.date)
 | 
			
		||||
                              == journal_entry_date.year)
 | 
			
		||||
            date = dt.datetime.strptime(k, "%Y")
 | 
			
		||||
            conditions.append(
 | 
			
		||||
                sa.extract("year", JournalEntry.date) == date.year)
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            pass
 | 
			
		||||
        try:
 | 
			
		||||
            journal_entry_date = datetime.strptime(k, "%Y/%m")
 | 
			
		||||
            date = dt.datetime.strptime(k, "%Y/%m")
 | 
			
		||||
            conditions.append(sa.and_(
 | 
			
		||||
                sa.extract("year", JournalEntry.date)
 | 
			
		||||
                == journal_entry_date.year,
 | 
			
		||||
                sa.extract("month", JournalEntry.date)
 | 
			
		||||
                == journal_entry_date.month))
 | 
			
		||||
                sa.extract("year", JournalEntry.date) == date.year,
 | 
			
		||||
                sa.extract("month", JournalEntry.date) == date.month))
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            pass
 | 
			
		||||
        try:
 | 
			
		||||
            journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
 | 
			
		||||
            date = dt.datetime.strptime(f"2000/{k}", "%Y/%m/%d")
 | 
			
		||||
            conditions.append(sa.and_(
 | 
			
		||||
                sa.extract("month", JournalEntry.date)
 | 
			
		||||
                == journal_entry_date.month,
 | 
			
		||||
                sa.extract("day", JournalEntry.date)
 | 
			
		||||
                == journal_entry_date.day))
 | 
			
		||||
                sa.extract("month", JournalEntry.date) == date.month,
 | 
			
		||||
                sa.extract("day", JournalEntry.date) == date.day))
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            pass
 | 
			
		||||
        try:
 | 
			
		||||
            journal_entry_date = datetime.strptime(k, "%Y/%m/%d")
 | 
			
		||||
            date = dt.datetime.strptime(k, "%Y/%m/%d")
 | 
			
		||||
            conditions.append(sa.and_(
 | 
			
		||||
                sa.extract("year", JournalEntry.date)
 | 
			
		||||
                == journal_entry_date.year,
 | 
			
		||||
                sa.extract("month", JournalEntry.date)
 | 
			
		||||
                == journal_entry_date.month,
 | 
			
		||||
                sa.extract("day", JournalEntry.date)
 | 
			
		||||
                == journal_entry_date.day))
 | 
			
		||||
                sa.extract("year", JournalEntry.date) == date.year,
 | 
			
		||||
                sa.extract("month", JournalEntry.date) == date.month,
 | 
			
		||||
                sa.extract("day", JournalEntry.date) == date.day))
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            pass
 | 
			
		||||
        return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
 | 
			
		||||
 
 | 
			
		||||
@@ -224,7 +224,7 @@ class TrialBalance(BaseReport):
 | 
			
		||||
        """
 | 
			
		||||
        rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
 | 
			
		||||
                                     gettext("Credit"))]
 | 
			
		||||
        rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit)
 | 
			
		||||
        rows.extend([CSVRow(str(x.account), x.debit, x.credit)
 | 
			
		||||
                     for x in self.__accounts])
 | 
			
		||||
        rows.append(CSVRow(gettext("Total"), self.__total.debit,
 | 
			
		||||
                           self.__total.credit))
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The unapplied original line items.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
from flask import render_template, Response
 | 
			
		||||
@@ -41,7 +41,7 @@ from accounting.utils.pagination import Pagination
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """A row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, journal_entry_date: str | date, currency: str,
 | 
			
		||||
    def __init__(self, journal_entry_date: str | dt.date, currency: str,
 | 
			
		||||
                 description: str | None, amount: str | Decimal,
 | 
			
		||||
                 net_balance: str | Decimal):
 | 
			
		||||
        """Constructs a row in the CSV.
 | 
			
		||||
@@ -52,7 +52,7 @@ class CSVRow(BaseCSVRow):
 | 
			
		||||
        :param amount: The amount.
 | 
			
		||||
        :param net_balance: The net balance.
 | 
			
		||||
        """
 | 
			
		||||
        self.date: str | date = journal_entry_date
 | 
			
		||||
        self.date: str | dt.date = journal_entry_date
 | 
			
		||||
        """The date."""
 | 
			
		||||
        self.currency: str = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
@@ -64,7 +64,7 @@ class CSVRow(BaseCSVRow):
 | 
			
		||||
        """The net balance."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def values(self) -> list[str | date | Decimal | None]:
 | 
			
		||||
    def values(self) -> list[str | dt.date | Decimal | None]:
 | 
			
		||||
        """Returns the values of the row.
 | 
			
		||||
 | 
			
		||||
        :return: The values of the row.
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The accounts with unapplied original line items.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
from flask import render_template, Response
 | 
			
		||||
@@ -49,7 +49,7 @@ class CSVRow(BaseCSVRow):
 | 
			
		||||
        """The number of unapplied original line items."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def values(self) -> list[str | date | Decimal | None]:
 | 
			
		||||
    def values(self) -> list[str | dt.date | Decimal | None]:
 | 
			
		||||
        """Returns the values of the row.
 | 
			
		||||
 | 
			
		||||
        :return: The values of the row.
 | 
			
		||||
@@ -120,8 +120,7 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
 | 
			
		||||
    :return: The CSV rows.
 | 
			
		||||
    """
 | 
			
		||||
    rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
 | 
			
		||||
    rows.extend([CSVRow(str(x).title(), x.count)
 | 
			
		||||
                 for x in accounts])
 | 
			
		||||
    rows.extend([CSVRow(str(x), x.count) for x in accounts])
 | 
			
		||||
    return rows
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -143,7 +142,7 @@ class AccountsWithUnappliedOriginalLineItems(BaseReport):
 | 
			
		||||
 | 
			
		||||
        :return: The response of the report for download.
 | 
			
		||||
        """
 | 
			
		||||
        filename: str = f"unapplied-accounts.csv"
 | 
			
		||||
        filename: str = "unapplied-accounts.csv"
 | 
			
		||||
        return csv_download(filename, get_csv_rows(self.__accounts))
 | 
			
		||||
 | 
			
		||||
    def html(self) -> str:
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The unmatched offsets.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
from flask import render_template, Response
 | 
			
		||||
@@ -40,7 +40,7 @@ from accounting.utils.pagination import Pagination
 | 
			
		||||
class CSVRow(BaseCSVRow):
 | 
			
		||||
    """A row in the CSV."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, journal_entry_date: str | date, currency: str,
 | 
			
		||||
    def __init__(self, journal_entry_date: str | dt.date, currency: str,
 | 
			
		||||
                 description: str | None, debit: str | Decimal,
 | 
			
		||||
                 credit: str | Decimal, balance: str | Decimal):
 | 
			
		||||
        """Constructs a row in the CSV.
 | 
			
		||||
@@ -52,7 +52,7 @@ class CSVRow(BaseCSVRow):
 | 
			
		||||
        :param credit: The credit amount.
 | 
			
		||||
        :param balance: The balance.
 | 
			
		||||
        """
 | 
			
		||||
        self.date: str | date = journal_entry_date
 | 
			
		||||
        self.date: str | dt.date = journal_entry_date
 | 
			
		||||
        """The date."""
 | 
			
		||||
        self.currency: str = currency
 | 
			
		||||
        """The currency."""
 | 
			
		||||
@@ -66,7 +66,7 @@ class CSVRow(BaseCSVRow):
 | 
			
		||||
        """The balance."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def values(self) -> list[str | date | Decimal | None]:
 | 
			
		||||
    def values(self) -> list[str | dt.date | Decimal | None]:
 | 
			
		||||
        """Returns the values of the row.
 | 
			
		||||
 | 
			
		||||
        :return: The values of the row.
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The accounts with unmatched offsets.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from datetime import date
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
 | 
			
		||||
from flask import render_template, Response
 | 
			
		||||
@@ -49,7 +49,7 @@ class CSVRow(BaseCSVRow):
 | 
			
		||||
        """The number of unapplied original line items."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def values(self) -> list[str | date | Decimal | None]:
 | 
			
		||||
    def values(self) -> list[str | dt.date | Decimal | None]:
 | 
			
		||||
        """Returns the values of the row.
 | 
			
		||||
 | 
			
		||||
        :return: The values of the row.
 | 
			
		||||
@@ -120,8 +120,7 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
 | 
			
		||||
    :return: The CSV rows.
 | 
			
		||||
    """
 | 
			
		||||
    rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
 | 
			
		||||
    rows.extend([CSVRow(str(x).title(), x.count)
 | 
			
		||||
                 for x in accounts])
 | 
			
		||||
    rows.extend([CSVRow(str(x), x.count) for x in accounts])
 | 
			
		||||
    return rows
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -144,7 +143,7 @@ class AccountsWithUnmatchedOffsets(BaseReport):
 | 
			
		||||
 | 
			
		||||
        :return: The response of the report for download.
 | 
			
		||||
        """
 | 
			
		||||
        filename: str = f"unapplied-accounts.csv"
 | 
			
		||||
        filename: str = "unmatched-accounts.csv"
 | 
			
		||||
        return csv_download(filename, get_csv_rows(self.__accounts))
 | 
			
		||||
 | 
			
		||||
    def html(self) -> str:
 | 
			
		||||
 
 | 
			
		||||
@@ -17,8 +17,9 @@
 | 
			
		||||
"""The page parameters of a report.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from abc import ABC, abstractmethod
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
from typing import Type
 | 
			
		||||
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
 | 
			
		||||
    urlunparse
 | 
			
		||||
 | 
			
		||||
@@ -52,7 +53,7 @@ class BasePageParams(ABC):
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def journal_entry_types(self) -> t.Type[JournalEntryType]:
 | 
			
		||||
    def journal_entry_types(self) -> Type[JournalEntryType]:
 | 
			
		||||
        """Returns the journal entry types.
 | 
			
		||||
 | 
			
		||||
        :return: The journal entry types.
 | 
			
		||||
@@ -72,7 +73,7 @@ class BasePageParams(ABC):
 | 
			
		||||
        return urlunparse(parts)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _get_currency_options(get_url: t.Callable[[Currency], str],
 | 
			
		||||
    def _get_currency_options(get_url: Callable[[Currency], str],
 | 
			
		||||
                              active_currency: Currency) -> list[OptionLink]:
 | 
			
		||||
        """Returns the currency options.
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -18,8 +18,8 @@
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import csv
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from abc import ABC, abstractmethod
 | 
			
		||||
from datetime import timedelta, date
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from io import StringIO
 | 
			
		||||
from urllib.parse import quote
 | 
			
		||||
@@ -77,7 +77,7 @@ def period_spec(period: Period) -> str:
 | 
			
		||||
    return f"{start}-{end}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_start_str(start: date | None) -> str | None:
 | 
			
		||||
def __get_start_str(start: dt.date | None) -> str | None:
 | 
			
		||||
    """Returns the string representation of the start date.
 | 
			
		||||
 | 
			
		||||
    :param start: The start date.
 | 
			
		||||
@@ -93,7 +93,7 @@ def __get_start_str(start: date | None) -> str | None:
 | 
			
		||||
    return start.strftime("%Y%m%d")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_end_str(end: date | None) -> str | None:
 | 
			
		||||
def __get_end_str(end: dt.date | None) -> str | None:
 | 
			
		||||
    """Returns the string representation of the end date.
 | 
			
		||||
 | 
			
		||||
    :param end: The end date.
 | 
			
		||||
@@ -104,6 +104,6 @@ def __get_end_str(end: date | None) -> str | None:
 | 
			
		||||
        return None
 | 
			
		||||
    if end.month == 12 and end.day == 31:
 | 
			
		||||
        return str(end.year)
 | 
			
		||||
    if (end + timedelta(days=1)).day == 1:
 | 
			
		||||
    if (end + dt.timedelta(days=1)).day == 1:
 | 
			
		||||
        return end.strftime("%Y%m")
 | 
			
		||||
    return end.strftime("%Y%m%d")
 | 
			
		||||
 
 | 
			
		||||
@@ -123,15 +123,13 @@ class OffsetMatcher:
 | 
			
		||||
            .options(selectinload(JournalEntryLineItem.currency),
 | 
			
		||||
                     selectinload(JournalEntryLineItem.journal_entry)).all()
 | 
			
		||||
        for line_item in self.line_items:
 | 
			
		||||
            line_item.is_offset = line_item.id in net_balances
 | 
			
		||||
        self.unapplied = [x for x in self.line_items
 | 
			
		||||
                          if x.is_offset]
 | 
			
		||||
            line_item.is_offset = line_item.id not in net_balances
 | 
			
		||||
        self.unapplied = [x for x in self.line_items if not x.is_offset]
 | 
			
		||||
        for line_item in self.unapplied:
 | 
			
		||||
            line_item.net_balance = line_item.amount \
 | 
			
		||||
                if net_balances[line_item.id] is None \
 | 
			
		||||
                else net_balances[line_item.id]
 | 
			
		||||
        self.unmatched = [x for x in self.line_items
 | 
			
		||||
                          if not x.is_offset]
 | 
			
		||||
        self.unmatched = [x for x in self.line_items if x.is_offset]
 | 
			
		||||
        self.__populate_accumulated_balances()
 | 
			
		||||
 | 
			
		||||
    def __populate_accumulated_balances(self) -> None:
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,7 @@ This file is largely taken from the NanoParma ERP project, first written in
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import re
 | 
			
		||||
import typing as t
 | 
			
		||||
from collections.abc import Iterator
 | 
			
		||||
 | 
			
		||||
from flask_babel import LazyString
 | 
			
		||||
 | 
			
		||||
@@ -190,7 +190,7 @@ class ReportChooser:
 | 
			
		||||
                          self.__active_report == ReportType.UNMATCHED,
 | 
			
		||||
                          fa_icon="fa-solid fa-file-circle-question")
 | 
			
		||||
 | 
			
		||||
    def __iter__(self) -> t.Iterator[OptionLink]:
 | 
			
		||||
    def __iter__(self) -> Iterator[OptionLink]:
 | 
			
		||||
        """Returns the iteration of the reports.
 | 
			
		||||
 | 
			
		||||
        :return: The iteration of the reports.
 | 
			
		||||
 
 | 
			
		||||
@@ -276,7 +276,6 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
        this.originalLineItemDate = originalLineItem.date;
 | 
			
		||||
        this.originalLineItemText = originalLineItem.text;
 | 
			
		||||
        this.#originalLineItemText.innerText = originalLineItem.text;
 | 
			
		||||
        this.#setEnableDescriptionAccount(false);
 | 
			
		||||
        if (this.description === null) {
 | 
			
		||||
            if (originalLineItem.description === "") {
 | 
			
		||||
                this.#descriptionControl.classList.remove("accounting-not-empty");
 | 
			
		||||
@@ -291,7 +290,9 @@ class JournalEntryLineItemEditor {
 | 
			
		||||
        this.account = originalLineItem.account.copy();
 | 
			
		||||
        this.isAccountConfirmed = false;
 | 
			
		||||
        this.#accountText.innerText = this.account.text;
 | 
			
		||||
        this.#amountInput.value = String(originalLineItem.netBalance);
 | 
			
		||||
        if (this.#amountInput.value === "" || new Decimal(this.#amountInput.value).greaterThan(originalLineItem.netBalance)) {
 | 
			
		||||
            this.#amountInput.value = String(originalLineItem.netBalance);
 | 
			
		||||
        }
 | 
			
		||||
        this.#amountInput.max = String(originalLineItem.netBalance);
 | 
			
		||||
        this.#amountInput.min = "0";
 | 
			
		||||
        this.#validate();
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								src/accounting/static/js/timezone.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/accounting/static/js/timezone.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
/* The Mia! Accounting Project
 | 
			
		||||
 * timezone.js: The JavaScript for the timezone
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/*  Copyright (c) 2024 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: 2024/6/4
 | 
			
		||||
 */
 | 
			
		||||
"use strict";
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    setTimeZone();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Sets the time zone.
 | 
			
		||||
 *
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function setTimeZone() {
 | 
			
		||||
    document.cookie = `accounting-tz=${Intl.DateTimeFormat().resolvedOptions().timeZone}; SameSite=Strict`;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#  Copyright (c) 2023-2024 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
@@ -17,13 +17,14 @@
 | 
			
		||||
"""The template filters.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from datetime import date, timedelta
 | 
			
		||||
import datetime as dt
 | 
			
		||||
from decimal import Decimal
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from flask_babel import get_locale
 | 
			
		||||
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.utils.timezone import get_tz_today
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def format_amount(value: Decimal | None) -> str | None:
 | 
			
		||||
@@ -41,24 +42,24 @@ def format_amount(value: Decimal | None) -> str | None:
 | 
			
		||||
    return "{:,}".format(whole) + str(abs(frac))[1:]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def format_date(value: date) -> str:
 | 
			
		||||
def format_date(value: dt.date) -> str:
 | 
			
		||||
    """Formats a date to be human-friendly.
 | 
			
		||||
 | 
			
		||||
    :param value: The date.
 | 
			
		||||
    :return: The human-friendly date text.
 | 
			
		||||
    """
 | 
			
		||||
    today: date = date.today()
 | 
			
		||||
    today: dt.date = get_tz_today()
 | 
			
		||||
    if value == today:
 | 
			
		||||
        return gettext("Today")
 | 
			
		||||
    if value == today - timedelta(days=1):
 | 
			
		||||
    if value == today - dt.timedelta(days=1):
 | 
			
		||||
        return gettext("Yesterday")
 | 
			
		||||
    if value == today + timedelta(days=1):
 | 
			
		||||
    if value == today + dt.timedelta(days=1):
 | 
			
		||||
        return gettext("Tomorrow")
 | 
			
		||||
    locale = str(get_locale())
 | 
			
		||||
    if locale == "zh" or locale.startswith("zh_"):
 | 
			
		||||
        if value == today - timedelta(days=2):
 | 
			
		||||
        if value == today - dt.timedelta(days=2):
 | 
			
		||||
            return gettext("The day before yesterday")
 | 
			
		||||
        if value == today + timedelta(days=2):
 | 
			
		||||
        if value == today + dt.timedelta(days=2):
 | 
			
		||||
            return gettext("The day after tomorrow")
 | 
			
		||||
    if locale == "zh" or locale.startswith("zh_"):
 | 
			
		||||
        weekdays = ["一", "二", "三", "四", "五", "六", "日"]
 | 
			
		||||
@@ -71,7 +72,7 @@ def format_date(value: date) -> str:
 | 
			
		||||
    return "{}/{}({})".format(value.month, value.day, weekday)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def default(value: t.Any, default_value: t.Any = "") -> t.Any:
 | 
			
		||||
def default(value: Any, default_value: Any = "") -> Any:
 | 
			
		||||
    """Returns the default value if the given value is None.
 | 
			
		||||
 | 
			
		||||
    :param value: The value.
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,6 @@ First written: 2023/2/1
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.account.list") }}{% endblock %}
 | 
			
		||||
{% block back_url %}{{ url_for("accounting.account.list")|accounting_or_next }}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -90,7 +90,7 @@ First written: 2023/1/31
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
<div class="accounting-card col-sm-6">
 | 
			
		||||
  <div class="accounting-card-title">{{ obj.title|title }}</div>
 | 
			
		||||
  <div class="accounting-card-title">{{ obj.title }}</div>
 | 
			
		||||
  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
			
		||||
  {% if obj.is_need_offset %}
 | 
			
		||||
    <div>
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@ First written: 2023/1/30
 | 
			
		||||
      {{ A_("New") }}
 | 
			
		||||
    </a>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
 | 
			
		||||
  <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
 | 
			
		||||
    <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
 | 
			
		||||
    <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
 | 
			
		||||
      <button type="submit">
 | 
			
		||||
 
 | 
			
		||||
@@ -33,7 +33,7 @@ First written: 2023/2/1
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="accounting-card col-sm-6">
 | 
			
		||||
  <div class="accounting-card-title">{{ obj.title|title }}</div>
 | 
			
		||||
  <div class="accounting-card-title">{{ obj.title }}</div>
 | 
			
		||||
  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
			
		||||
  {% if obj.accounts %}
 | 
			
		||||
    <div>
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@ First written: 2023/1/26
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="mb-2 accounting-toolbar">
 | 
			
		||||
  <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
 | 
			
		||||
  <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
 | 
			
		||||
    <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
 | 
			
		||||
    <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
 | 
			
		||||
      <button type="submit">
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@
 | 
			
		||||
The Mia! Accounting Project
 | 
			
		||||
base.html: The application-wide base template.
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2023 imacat.
 | 
			
		||||
 Copyright (c) 2023-2024 imacat.
 | 
			
		||||
 | 
			
		||||
 Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 you may not use this file except in compliance with the License.
 | 
			
		||||
@@ -27,5 +27,6 @@ First written: 2023/1/27
 | 
			
		||||
 | 
			
		||||
{% block scripts %}
 | 
			
		||||
  <script src="{{ url_for("accounting.babel_catalog") }}"></script>
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/timezone.js") }}"></script>
 | 
			
		||||
  {% block accounting_scripts %}{% endblock %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,6 @@ First written: 2023/2/6
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %}
 | 
			
		||||
{% block back_url %}{{ url_for("accounting.currency.list")|accounting_or_next }}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@ First written: 2023/2/6
 | 
			
		||||
      {{ A_("New") }}
 | 
			
		||||
    </a>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
 | 
			
		||||
  <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
 | 
			
		||||
    <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
 | 
			
		||||
    <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
 | 
			
		||||
      <button type="submit">
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,6 @@ First written: 2023/2/25
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %}
 | 
			
		||||
{% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/2/28
 | 
			
		||||
#}
 | 
			
		||||
<form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}">
 | 
			
		||||
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
  <div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true">
 | 
			
		||||
    <div class="modal-dialog">
 | 
			
		||||
      <div class="modal-content">
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@ First written: 2023/3/14
 | 
			
		||||
      <div>
 | 
			
		||||
        <div class="small">
 | 
			
		||||
          <span class="d-none d-md-inline">{{ line_item.account.code }}</span>
 | 
			
		||||
          {{ line_item.account.title|title }}
 | 
			
		||||
          {{ line_item.account.title }}
 | 
			
		||||
        </div>
 | 
			
		||||
        {% if line_item.description is not none %}
 | 
			
		||||
          <div>{{ line_item.description }}</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,7 @@ First written: 2023/2/26
 | 
			
		||||
      {{ A_("Edit") }}
 | 
			
		||||
    </a>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
 | 
			
		||||
  <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", date=obj.date)|accounting_append_next }}">
 | 
			
		||||
    <i class="fa-solid fa-bars-staggered"></i>
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("Order") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/2/25
 | 
			
		||||
#}
 | 
			
		||||
<form id="accounting-line-item-editor">
 | 
			
		||||
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
  <div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true">
 | 
			
		||||
    <div class="modal-dialog">
 | 
			
		||||
      <div class="modal-content">
 | 
			
		||||
 
 | 
			
		||||
@@ -42,7 +42,7 @@ First written: 2023/2/25
 | 
			
		||||
                <div class="small">
 | 
			
		||||
                  {{ line_item.journal_entry.date|accounting_format_date }}
 | 
			
		||||
                  <span class="d-none d-md-inline">{{ line_item.account.code }}</span>
 | 
			
		||||
                  {{ line_item.account.title|title }}
 | 
			
		||||
                  {{ line_item.account.title }}
 | 
			
		||||
                </div>
 | 
			
		||||
                {{ line_item.description|accounting_default }}
 | 
			
		||||
              </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -38,7 +38,7 @@ First written: 2023/2/26
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% if list|length > 1 and accounting_can_edit() %}
 | 
			
		||||
  <form action="{{ url_for("accounting.journal-entry.sort", journal_entry_date=date) }}" method="post">
 | 
			
		||||
  <form action="{{ url_for("accounting.journal-entry.sort", date=date) }}" method="post">
 | 
			
		||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
    {% if request.args.next %}
 | 
			
		||||
      <input type="hidden" name="next" value="{{ request.args.next }}">
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,6 @@ First written: 2023/2/25
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Add a New Cash Receipt Journal Entry") }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %}
 | 
			
		||||
{% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@ First written: 2023/2/26
 | 
			
		||||
 | 
			
		||||
{% block as_trasfer %}
 | 
			
		||||
  <a class="btn btn-primary" role="button" 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>
 | 
			
		||||
    <i class="fa-solid fa-table-columns"></i>
 | 
			
		||||
    <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
 | 
			
		||||
  </a>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,6 @@ First written: 2023/2/25
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Add a New Transfer Journal Entry") }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %}
 | 
			
		||||
{% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/3/22
 | 
			
		||||
#}
 | 
			
		||||
<form id="accounting-recurring-item-editor-{{ expense_income }}">
 | 
			
		||||
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
  <div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true">
 | 
			
		||||
    <div class="modal-dialog">
 | 
			
		||||
      <div class="modal-content">
 | 
			
		||||
 
 | 
			
		||||
@@ -20,21 +20,21 @@ Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/3/8
 | 
			
		||||
#}
 | 
			
		||||
<div class="accounting-report-table-row accounting-balance-sheet-section">
 | 
			
		||||
  <div>{{ section.title.title|title }}</div>
 | 
			
		||||
  <div>{{ section.title.title }}</div>
 | 
			
		||||
</div>
 | 
			
		||||
<div class="accounting-report-table-body">
 | 
			
		||||
  {% for subsection in section.subsections %}
 | 
			
		||||
    <div class="accounting-report-table-row accounting-balance-sheet-subsection">
 | 
			
		||||
      <div>
 | 
			
		||||
        <span class="d-none d-md-inline">{{ subsection.title.code }}</span>
 | 
			
		||||
        {{ subsection.title.title|title }}
 | 
			
		||||
        {{ subsection.title.title }}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    {% for account in subsection.accounts %}
 | 
			
		||||
      <a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}">
 | 
			
		||||
        <div>
 | 
			
		||||
          <span class="d-none d-md-inline">{{ account.account.code }}</span>
 | 
			
		||||
          {{ account.account.title|title }}
 | 
			
		||||
          {{ account.account.title }}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
 | 
			
		||||
      </a>
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/3/8
 | 
			
		||||
#}
 | 
			
		||||
<div>{{ line_item.date|accounting_format_date }}</div>
 | 
			
		||||
<div>{{ line_item.account.title|title }}</div>
 | 
			
		||||
<div>{{ line_item.account.title }}</div>
 | 
			
		||||
<div>{{ line_item.description|accounting_default }}</div>
 | 
			
		||||
<div class="accounting-amount">{{ line_item.income|accounting_format_amount|accounting_default }}</div>
 | 
			
		||||
<div class="accounting-amount">{{ line_item.expense|accounting_format_amount|accounting_default }}</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@ First written: 2023/3/5
 | 
			
		||||
        {{ line_item.date|accounting_format_date }}
 | 
			
		||||
      {% endif %}
 | 
			
		||||
      {% if line_item.account %}
 | 
			
		||||
        {{ line_item.account.title|title }}
 | 
			
		||||
        {{ line_item.account.title }}
 | 
			
		||||
      {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,7 @@ search-modal.html: The search modal
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/3/8
 | 
			
		||||
#}
 | 
			
		||||
<form action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-search-modal-label">
 | 
			
		||||
<form action="{{ url_for("accounting-report.search") }}" name="accounting-search-form" method="get" role="search" aria-labelledby="accounting-search-modal-label">
 | 
			
		||||
  <div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-modal-label" aria-hidden="true">
 | 
			
		||||
    <div class="modal-dialog">
 | 
			
		||||
      <div class="modal-content">
 | 
			
		||||
 
 | 
			
		||||
@@ -93,7 +93,7 @@ First written: 2023/3/8
 | 
			
		||||
      {% for account in report.account_options %}
 | 
			
		||||
        <li>
 | 
			
		||||
          <a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
 | 
			
		||||
            {{ account.title|title }}
 | 
			
		||||
            {{ account.title }}
 | 
			
		||||
          </a>
 | 
			
		||||
        </li>
 | 
			
		||||
      {% endfor %}
 | 
			
		||||
@@ -118,7 +118,7 @@ First written: 2023/3/8
 | 
			
		||||
  </button>
 | 
			
		||||
{% endif %}
 | 
			
		||||
{% if use_search %}
 | 
			
		||||
  <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
 | 
			
		||||
  <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
 | 
			
		||||
    <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
 | 
			
		||||
    <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
 | 
			
		||||
      <button type="submit">
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@ First written: 2023/3/5
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Income and Expenses Log of %(account)s %(period)s", account=report.account.title|title, period=report.period.desc|title) }}{% else %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Income and Expenses Log of %(account)s %(period)s", account=report.account.title, period=report.period.desc|title) }}{% else %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -66,21 +66,21 @@ First written: 2023/3/7
 | 
			
		||||
          <div class="accounting-report-table-row accounting-income-statement-section">
 | 
			
		||||
            <div>
 | 
			
		||||
              <span class="d-none d-md-inline">{{ section.title.code }}</span>
 | 
			
		||||
              {{ section.title.title|title }}
 | 
			
		||||
              {{ section.title.title }}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          {% for subsection in section.subsections %}
 | 
			
		||||
            <div class="accounting-report-table-row accounting-income-statement-subsection">
 | 
			
		||||
              <div>
 | 
			
		||||
                <span class="d-none d-md-inline">{{ subsection.title.code }}</span>
 | 
			
		||||
                {{ subsection.title.title|title }}
 | 
			
		||||
                {{ subsection.title.title }}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            {% for account in subsection.accounts %}
 | 
			
		||||
              <a class="accounting-report-table-row accounting-income-statement-account" href="{{ account.url }}">
 | 
			
		||||
                <div>
 | 
			
		||||
                  <span class="d-none d-md-inline">{{ account.account.code }}</span>
 | 
			
		||||
                  {{ account.account.title|title }}
 | 
			
		||||
                  {{ account.account.title }}
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
 | 
			
		||||
              </a>
 | 
			
		||||
@@ -91,7 +91,7 @@ First written: 2023/3/7
 | 
			
		||||
            </div>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
          <div class="accounting-report-table-row accounting-income-statement-total">
 | 
			
		||||
            <div>{{ section.accumulated.title|title }}</div>
 | 
			
		||||
            <div>{{ section.accumulated.title }}</div>
 | 
			
		||||
            <div class="accounting-amount {% if section.accumulated.amount < 0 %} text-danger {% endif %}">{{ section.accumulated.amount|accounting_report_format_amount }}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
 
 | 
			
		||||
@@ -65,7 +65,7 @@ First written: 2023/3/4
 | 
			
		||||
          <div>{{ line_item.currency.name }}</div>
 | 
			
		||||
          <div>
 | 
			
		||||
            <span class="d-none d-md-inline">{{ line_item.account.code }}</span>
 | 
			
		||||
            {{ line_item.account.title|title }}
 | 
			
		||||
            {{ line_item.account.title }}
 | 
			
		||||
          </div>
 | 
			
		||||
          <div>{{ line_item.description|accounting_default }}</div>
 | 
			
		||||
          <div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
 | 
			
		||||
@@ -82,7 +82,7 @@ First written: 2023/3/4
 | 
			
		||||
        <div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
 | 
			
		||||
          <div class="text-muted small">
 | 
			
		||||
            {{ line_item.journal_entry.date|accounting_format_date }}
 | 
			
		||||
            {{ line_item.account.title|title }}
 | 
			
		||||
            {{ line_item.account.title }}
 | 
			
		||||
            {% if line_item.currency.code != accounting_default_currency_code() %}
 | 
			
		||||
              <span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@ First written: 2023/3/5
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Ledger of %(account)s %(period)s", account=report.account.title|title, period=report.period.desc|title) }}{% else %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Ledger of %(account)s %(period)s", account=report.account.title, period=report.period.desc|title) }}{% else %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -62,7 +62,7 @@ First written: 2023/3/8
 | 
			
		||||
          <div>{{ line_item.currency.name }}</div>
 | 
			
		||||
          <div>
 | 
			
		||||
            <span class="d-none d-md-inline">{{ line_item.account.code }}</span>
 | 
			
		||||
            {{ line_item.account.title|title }}
 | 
			
		||||
            {{ line_item.account.title }}
 | 
			
		||||
          </div>
 | 
			
		||||
          <div>{{ line_item.description|accounting_default }}</div>
 | 
			
		||||
          <div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
 | 
			
		||||
@@ -79,7 +79,7 @@ First written: 2023/3/8
 | 
			
		||||
        <div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
 | 
			
		||||
          <div class="text-muted small">
 | 
			
		||||
            {{ line_item.journal_entry.date|accounting_format_date }}
 | 
			
		||||
            {{ line_item.account.title|title }}
 | 
			
		||||
            {{ line_item.account.title }}
 | 
			
		||||
            {% if line_item.currency.code != accounting_default_currency_code() %}
 | 
			
		||||
              <span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
 
 | 
			
		||||
@@ -68,7 +68,7 @@ First written: 2023/3/5
 | 
			
		||||
          <a class="accounting-report-table-row" href="{{ account.url }}">
 | 
			
		||||
            <div>
 | 
			
		||||
              <span class="d-none d-md-inline">{{ account.account.code }}</span>
 | 
			
		||||
              {{ account.account.title|title }}
 | 
			
		||||
              {{ account.account.title }}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="accounting-amount">{{ account.debit|accounting_format_amount|accounting_default }}</div>
 | 
			
		||||
            <div class="accounting-amount">{{ account.credit|accounting_format_amount|accounting_default }}</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@ First written: 2023/4/8
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts with Unapplied Items") }}{% else %}{{ A_("Accounts with Unapplied Items in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unapplied Items") }}{% else %}{{ A_("Accounts With Unapplied Items in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
@@ -46,9 +46,9 @@ First written: 2023/4/8
 | 
			
		||||
    <div class="d-none d-sm-flex justify-content-center mb-3">
 | 
			
		||||
      <h2 class="text-center">
 | 
			
		||||
        {% if report.currency.code == accounting_default_currency_code() %}
 | 
			
		||||
          {{ A_("Accounts with Unapplied Items") }}
 | 
			
		||||
          {{ A_("Accounts With Unapplied Items") }}
 | 
			
		||||
        {% else %}
 | 
			
		||||
          {{ A_("Accounts with Unapplied Items in %(currency)s", currency=report.currency.name|title) }}
 | 
			
		||||
          {{ A_("Accounts With Unapplied Items in %(currency)s", currency=report.currency.name|title) }}
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </h2>
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -64,7 +64,7 @@ First written: 2023/4/8
 | 
			
		||||
          <a class="accounting-report-table-row" href="{{ url_for("accounting-report.unapplied", currency=report.currency, account=account, period=report.period) }}">
 | 
			
		||||
            <div>
 | 
			
		||||
              <span class="d-none d-md-inline">{{ account.code }}</span>
 | 
			
		||||
              {{ account.title|title }}
 | 
			
		||||
              {{ account.title }}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="accounting-amount">{{ account.count }}</div>
 | 
			
		||||
          </a>
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@ First written: 2023/4/7
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unapplied Items of %(account)s", account=report.account.title|title) }}{% else %}{{ A_("Unapplied Items of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title|title) }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unapplied Items of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unapplied Items of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@ First written: 2023/4/17
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts with Unmatched Offsets") }}{% else %}{{ A_("Accounts with Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unmatched Offsets") }}{% else %}{{ A_("Accounts With Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
@@ -46,9 +46,9 @@ First written: 2023/4/17
 | 
			
		||||
    <div class="d-none d-sm-flex justify-content-center mb-3">
 | 
			
		||||
      <h2 class="text-center">
 | 
			
		||||
        {% if report.currency.code == accounting_default_currency_code() %}
 | 
			
		||||
          {{ A_("Accounts with Unmatched Offsets") }}
 | 
			
		||||
          {{ A_("Accounts With Unmatched Offsets") }}
 | 
			
		||||
        {% else %}
 | 
			
		||||
          {{ A_("Accounts with Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}
 | 
			
		||||
          {{ A_("Accounts With Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </h2>
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -64,7 +64,7 @@ First written: 2023/4/17
 | 
			
		||||
          <a class="accounting-report-table-row" href="{{ url_for("accounting-report.unmatched", currency=report.currency, account=account, period=report.period) }}">
 | 
			
		||||
            <div>
 | 
			
		||||
              <span class="d-none d-md-inline">{{ account.code }}</span>
 | 
			
		||||
              {{ account.title|title }}
 | 
			
		||||
              {{ account.title }}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="accounting-amount">{{ account.count }}</div>
 | 
			
		||||
          </a>
 | 
			
		||||
 
 | 
			
		||||
@@ -26,7 +26,7 @@ First written: 2023/4/17
 | 
			
		||||
  <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unmatched Offsets of %(account)s", account=report.account.title|title) }}{% else %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title|title) }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unmatched Offsets of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
@@ -49,7 +49,7 @@ First written: 2023/4/17
 | 
			
		||||
 | 
			
		||||
  <form action="{{ url_for("accounting-report.match-offsets", currency=report.currency, account=report.account) }}" method="post">
 | 
			
		||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
    <input type="hidden" name="next" value="{{ request.full_path if request.query_string else request.path }}">
 | 
			
		||||
    <input type="hidden" name="next" value="{{ accounting_as_next() }}">
 | 
			
		||||
    <div class="modal fade" id="accounting-match-modal" tabindex="-1" aria-labelledby="accounting-match-modal-label" aria-hidden="true">
 | 
			
		||||
      <div class="modal-dialog">
 | 
			
		||||
        <div class="modal-content">
 | 
			
		||||
 
 | 
			
		||||
@@ -8,8 +8,8 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: mia-accounting 1.4.0\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
 | 
			
		||||
"POT-Creation-Date: 2023-04-18 09:32+0800\n"
 | 
			
		||||
"PO-Revision-Date: 2023-04-18 09:32+0800\n"
 | 
			
		||||
"POT-Creation-Date: 2023-07-29 08:55+0800\n"
 | 
			
		||||
"PO-Revision-Date: 2023-07-29 08:56+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"
 | 
			
		||||
@@ -21,7 +21,7 @@ msgstr ""
 | 
			
		||||
 | 
			
		||||
#: src/accounting/forms.py:33
 | 
			
		||||
#: src/accounting/static/js/journal-entry-form.js:1080
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:411
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:415
 | 
			
		||||
#: src/accounting/static/js/option-form.js:537
 | 
			
		||||
#: src/accounting/static/js/option-form.js:803
 | 
			
		||||
msgid "Please select the account."
 | 
			
		||||
@@ -35,22 +35,22 @@ msgstr "沒有這個貨幣。"
 | 
			
		||||
msgid "The account does not exist."
 | 
			
		||||
msgstr "沒有這個科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:581
 | 
			
		||||
#: src/accounting/models.py:578
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Cash Disbursement Journal Entry#%(id)s"
 | 
			
		||||
msgstr "現金支出傳票#%(id)s"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:584
 | 
			
		||||
#: src/accounting/models.py:581
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Cash Receipt Journal Entry#%(id)s"
 | 
			
		||||
msgstr "現金收入傳票#%(id)s"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:585
 | 
			
		||||
#: src/accounting/models.py:582
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Transfer Journal Entry#%(id)s"
 | 
			
		||||
msgstr "轉帳傳票#%(id)s"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/models.py:714
 | 
			
		||||
#: src/accounting/models.py:706
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "%(date)s %(description)s %(amount)s"
 | 
			
		||||
msgstr "%(date)s %(description)s %(amount)s"
 | 
			
		||||
@@ -101,7 +101,7 @@ msgid "Please fill in the title"
 | 
			
		||||
msgstr "請填上標題。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/queries.py:50
 | 
			
		||||
#: src/accounting/report/reports/search.py:101
 | 
			
		||||
#: src/accounting/report/reports/search.py:100
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:97
 | 
			
		||||
#: src/accounting/templates/accounting/account/list.html:62
 | 
			
		||||
msgid "Needs Offset"
 | 
			
		||||
@@ -205,24 +205,24 @@ msgstr "傳票不可刪除。"
 | 
			
		||||
msgid "The journal entry is deleted successfully."
 | 
			
		||||
msgstr "傳票刪掉了"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:39
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:38
 | 
			
		||||
msgid "Please select the currency."
 | 
			
		||||
msgstr "請選擇貨幣。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:62
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:61
 | 
			
		||||
msgid "The currency must be the same as the original line item."
 | 
			
		||||
msgstr "貨幣需和原始分錄相同。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:89
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:88
 | 
			
		||||
msgid "The currency must not be changed when there is offset."
 | 
			
		||||
msgstr "抵銷過不可變更貨幣。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:98
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:97
 | 
			
		||||
#: src/accounting/static/js/journal-entry-form.js:773
 | 
			
		||||
msgid "Please add some line items."
 | 
			
		||||
msgstr "請加上分錄。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:111
 | 
			
		||||
#: src/accounting/journal_entry/forms/currency.py:110
 | 
			
		||||
#: src/accounting/static/js/journal-entry-form.js:522
 | 
			
		||||
msgid "The totals of the debit and credit amounts do not match."
 | 
			
		||||
msgstr "借方貸方合計不符。 "
 | 
			
		||||
@@ -251,62 +251,62 @@ msgstr "請加上貨幣。"
 | 
			
		||||
msgid "Line items with offset cannot be deleted."
 | 
			
		||||
msgstr "無法刪除抵銷過的分錄。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:49
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:48
 | 
			
		||||
msgid "The original line item does not exist."
 | 
			
		||||
msgstr "沒有這筆原始分錄。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:70
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:69
 | 
			
		||||
msgid "The original line item is on the same debit or credit."
 | 
			
		||||
msgstr "原始分錄在借貸同一邊。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:85
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:84
 | 
			
		||||
msgid "The original line item does not need offset."
 | 
			
		||||
msgstr "這筆原始分錄不需抵銷。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:101
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:100
 | 
			
		||||
msgid "The original line item cannot be an offset item."
 | 
			
		||||
msgstr "原始分錄不可以是抵銷分錄。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:119
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:118
 | 
			
		||||
msgid "The account must be the same as the original line item."
 | 
			
		||||
msgstr "科目需和原始分錄相同。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:135
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:134
 | 
			
		||||
msgid "The account must not be changed when there is offset."
 | 
			
		||||
msgstr "抵銷過不可變更科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:151
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:150
 | 
			
		||||
msgid "A payable line item cannot start from debit."
 | 
			
		||||
msgstr "不可由借方新建應付款。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:167
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:166
 | 
			
		||||
msgid "A receivable line item cannot start from credit."
 | 
			
		||||
msgstr "不可由貸方新建應收款。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:178
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:436
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:177
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:440
 | 
			
		||||
msgid "Please fill in a positive amount."
 | 
			
		||||
msgstr "金額請填正數。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:220
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:442
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:219
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:446
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"The amount must not exceed the net balance %(balance)s of the original "
 | 
			
		||||
"line item."
 | 
			
		||||
msgstr "金額不可超過原始分錄凈額 %(balance)s 。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:241
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:450
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:239
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:454
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "The amount must not be less than the offset total %(total)s."
 | 
			
		||||
msgstr "金額不可低於抵銷總額 %(total)s 。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:426
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:424
 | 
			
		||||
msgid "This account is not for debit line items."
 | 
			
		||||
msgstr "科目不是借方科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:478
 | 
			
		||||
#: src/accounting/journal_entry/forms/line_item.py:476
 | 
			
		||||
msgid "This account is not for credit line items."
 | 
			
		||||
msgstr "科目不是貸方科目。"
 | 
			
		||||
 | 
			
		||||
@@ -417,15 +417,15 @@ msgstr "去年"
 | 
			
		||||
msgid "All"
 | 
			
		||||
msgstr "全部"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/balance_sheet.py:423
 | 
			
		||||
#: src/accounting/report/reports/balance_sheet.py:427
 | 
			
		||||
#: src/accounting/report/reports/balance_sheet.py:439
 | 
			
		||||
#: src/accounting/report/reports/balance_sheet.py:425
 | 
			
		||||
#: src/accounting/report/reports/balance_sheet.py:429
 | 
			
		||||
#: src/accounting/report/reports/balance_sheet.py:441
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:189
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:423
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:300
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:171
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:380
 | 
			
		||||
#: src/accounting/report/reports/balance_sheet.py:443
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:187
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:420
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:301
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:168
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:376
 | 
			
		||||
#: src/accounting/report/reports/trial_balance.py:229
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:43
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form-debit-credit.html:38
 | 
			
		||||
@@ -445,14 +445,14 @@ msgstr "全部"
 | 
			
		||||
msgid "Total"
 | 
			
		||||
msgstr "合計"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:136
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:132
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:134
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:129
 | 
			
		||||
msgid "Brought forward"
 | 
			
		||||
msgstr "前期轉入"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:407
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:404
 | 
			
		||||
#: src/accounting/report/reports/journal.py:158
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:366
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:362
 | 
			
		||||
#: src/accounting/report/reports/unapplied.py:148
 | 
			
		||||
#: src/accounting/report/reports/unmatched.py:158
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:50
 | 
			
		||||
@@ -466,13 +466,13 @@ msgstr "前期轉入"
 | 
			
		||||
msgid "Date"
 | 
			
		||||
msgstr "日期"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:407
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:404
 | 
			
		||||
#: src/accounting/report/reports/journal.py:159
 | 
			
		||||
#: src/accounting/report/reports/trial_balance.py:225
 | 
			
		||||
#: src/accounting/report/reports/unapplied_accounts.py:122
 | 
			
		||||
#: src/accounting/report/reports/unmatched_accounts.py:122
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:57
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:39
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:58
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:40
 | 
			
		||||
#: 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
 | 
			
		||||
@@ -481,13 +481,13 @@ msgstr "日期"
 | 
			
		||||
msgid "Account"
 | 
			
		||||
msgstr "科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:408
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:405
 | 
			
		||||
#: src/accounting/report/reports/journal.py:159
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:366
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:362
 | 
			
		||||
#: src/accounting/report/reports/unapplied.py:149
 | 
			
		||||
#: src/accounting/report/reports/unmatched.py:159
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:28
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:49
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:29
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:50
 | 
			
		||||
#: src/accounting/templates/accounting/report/income-expenses.html:57
 | 
			
		||||
#: src/accounting/templates/accounting/report/journal.html:56
 | 
			
		||||
#: src/accounting/templates/accounting/report/ledger.html:56
 | 
			
		||||
@@ -497,18 +497,18 @@ msgstr "科目"
 | 
			
		||||
msgid "Description"
 | 
			
		||||
msgstr "摘要"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:408
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:405
 | 
			
		||||
#: src/accounting/templates/accounting/report/income-expenses.html:58
 | 
			
		||||
msgid "Income"
 | 
			
		||||
msgstr "收入"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:409
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:406
 | 
			
		||||
#: src/accounting/templates/accounting/report/income-expenses.html:59
 | 
			
		||||
msgid "Expense"
 | 
			
		||||
msgstr "支出"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:409
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:368
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:406
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:364
 | 
			
		||||
#: src/accounting/report/reports/unmatched.py:160
 | 
			
		||||
#: src/accounting/templates/accounting/report/income-expenses.html:60
 | 
			
		||||
#: src/accounting/templates/accounting/report/ledger.html:60
 | 
			
		||||
@@ -516,41 +516,41 @@ msgstr "支出"
 | 
			
		||||
msgid "Balance"
 | 
			
		||||
msgstr "餘額"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:410
 | 
			
		||||
#: src/accounting/report/reports/income_expenses.py:407
 | 
			
		||||
#: src/accounting/report/reports/journal.py:161
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:368
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:178
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:364
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:179
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:73
 | 
			
		||||
msgid "Note"
 | 
			
		||||
msgstr "備註"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:228
 | 
			
		||||
msgid "total operating revenue"
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:229
 | 
			
		||||
msgid "Total Operating Revenue"
 | 
			
		||||
msgstr "營業收入總額"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:229
 | 
			
		||||
msgid "gross income"
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:230
 | 
			
		||||
msgid "Gross Income"
 | 
			
		||||
msgstr "營業毛利"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:230
 | 
			
		||||
msgid "operating income"
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:231
 | 
			
		||||
msgid "Operating Income"
 | 
			
		||||
msgstr "營業淨利"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:231
 | 
			
		||||
msgid "before tax income"
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:232
 | 
			
		||||
msgid "Before Tax Income"
 | 
			
		||||
msgstr "稅前淨利"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:232
 | 
			
		||||
msgid "after tax income"
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:233
 | 
			
		||||
msgid "After Tax Income"
 | 
			
		||||
msgstr "稅後淨利"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:233
 | 
			
		||||
msgid "net income or loss for current period"
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:234
 | 
			
		||||
msgid "Net Income or Loss for Current Period"
 | 
			
		||||
msgstr "本期損益"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:301
 | 
			
		||||
#: src/accounting/report/reports/income_statement.py:302
 | 
			
		||||
#: src/accounting/report/reports/unapplied.py:149
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:65
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:66
 | 
			
		||||
#: src/accounting/templates/accounting/report/income-statement.html:61
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied.html:54
 | 
			
		||||
msgid "Amount"
 | 
			
		||||
@@ -567,7 +567,7 @@ msgid "Currency"
 | 
			
		||||
msgstr "貨幣"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/journal.py:160
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:367
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:363
 | 
			
		||||
#: src/accounting/report/reports/trial_balance.py:225
 | 
			
		||||
#: src/accounting/report/reports/unmatched.py:159
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33
 | 
			
		||||
@@ -581,7 +581,7 @@ msgid "Debit"
 | 
			
		||||
msgstr "借方"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/reports/journal.py:160
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:367
 | 
			
		||||
#: src/accounting/report/reports/ledger.py:363
 | 
			
		||||
#: src/accounting/report/reports/trial_balance.py:226
 | 
			
		||||
#: src/accounting/report/reports/unmatched.py:160
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49
 | 
			
		||||
@@ -614,16 +614,16 @@ msgstr "淨額"
 | 
			
		||||
msgid "Count"
 | 
			
		||||
msgstr "數量"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/offset_matcher.py:163
 | 
			
		||||
#: src/accounting/report/utils/offset_matcher.py:161
 | 
			
		||||
msgid "There is no unmatched offset."
 | 
			
		||||
msgstr "沒有遺漏的抵銷分錄"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/offset_matcher.py:167
 | 
			
		||||
#: src/accounting/report/utils/offset_matcher.py:165
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "%(total)s unmatched offsets without original items."
 | 
			
		||||
msgstr "%(total)s 筆遺漏的抵銷分錄無法自動抵銷。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/report/utils/offset_matcher.py:172
 | 
			
		||||
#: src/accounting/report/utils/offset_matcher.py:170
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"%(matches)s unmatched offsets out of %(total)s can match with their "
 | 
			
		||||
@@ -752,7 +752,7 @@ msgid "December"
 | 
			
		||||
msgstr "十二月"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/journal-entry-form.js:1085
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:430
 | 
			
		||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:434
 | 
			
		||||
msgid "Please fill in the amount."
 | 
			
		||||
msgstr "請填上金額。"
 | 
			
		||||
 | 
			
		||||
@@ -833,12 +833,12 @@ msgstr "確認刪除科目"
 | 
			
		||||
#: src/accounting/templates/accounting/account/include/form.html:91
 | 
			
		||||
#: 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/description-editor-modal.html:31
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:78
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:28
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:29
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:27
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:27
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:28
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:29
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:27
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/search-modal.html:28
 | 
			
		||||
#: src/accounting/templates/accounting/report/unmatched.html:58
 | 
			
		||||
@@ -853,11 +853,11 @@ msgstr "你確定要刪掉這個科目嗎?"
 | 
			
		||||
#: src/accounting/templates/accounting/account/include/form.html:112
 | 
			
		||||
#: 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:194
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:195
 | 
			
		||||
#: 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/journal-entry/include/journal-entry-line-item-editor-modal.html:71
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:48
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:65
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:66
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/search-modal.html:37
 | 
			
		||||
#: src/accounting/templates/accounting/report/unmatched.html:74
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
@@ -942,12 +942,12 @@ 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:195
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:196
 | 
			
		||||
#: 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/include/journal-entry-line-item-editor-modal.html:72
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/order.html:61
 | 
			
		||||
#: src/accounting/templates/accounting/option/form.html:80
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:66
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:67
 | 
			
		||||
msgid "Save"
 | 
			
		||||
msgstr "儲存"
 | 
			
		||||
 | 
			
		||||
@@ -1008,7 +1008,7 @@ msgid "Code"
 | 
			
		||||
msgstr "代碼"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/currency/include/form.html:50
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:33
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:34
 | 
			
		||||
msgid "Name"
 | 
			
		||||
msgstr "名稱"
 | 
			
		||||
 | 
			
		||||
@@ -1077,53 +1077,53 @@ msgstr "選擇科目"
 | 
			
		||||
msgid "More…"
 | 
			
		||||
msgstr "更多…"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:36
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:37
 | 
			
		||||
msgid "Offset..."
 | 
			
		||||
msgstr "抵銷…"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:44
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:45
 | 
			
		||||
msgid "General"
 | 
			
		||||
msgstr "一般"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:49
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:50
 | 
			
		||||
msgid "Travel"
 | 
			
		||||
msgstr "差旅"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:54
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:55
 | 
			
		||||
msgid "Bus"
 | 
			
		||||
msgstr "公車"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:59
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:60
 | 
			
		||||
msgid "Recurring"
 | 
			
		||||
msgstr "常用"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:64
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:65
 | 
			
		||||
msgid "Annotation"
 | 
			
		||||
msgstr "註記"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:73
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:90
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:125
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:74
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:91
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:126
 | 
			
		||||
msgid "Tag"
 | 
			
		||||
msgstr "標籤"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:105
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:146
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:106
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:147
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:129
 | 
			
		||||
msgid "From"
 | 
			
		||||
msgstr "從"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:114
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:151
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:115
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:152
 | 
			
		||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:135
 | 
			
		||||
msgid "To"
 | 
			
		||||
msgstr "至"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:130
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:131
 | 
			
		||||
msgid "Route"
 | 
			
		||||
msgstr "路線"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:172
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:173
 | 
			
		||||
msgid "The Number of Items"
 | 
			
		||||
msgstr "數量"
 | 
			
		||||
 | 
			
		||||
@@ -1155,11 +1155,11 @@ msgstr "確認刪除傳票"
 | 
			
		||||
msgid "Do you really want to delete this journal entry?"
 | 
			
		||||
msgstr "你確定要刪掉這張傳票嗎?"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:27
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:28
 | 
			
		||||
msgid "Line Item Content"
 | 
			
		||||
msgstr "分錄內容"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:34
 | 
			
		||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:35
 | 
			
		||||
msgid "Original Line Item"
 | 
			
		||||
msgstr "原始分錄"
 | 
			
		||||
 | 
			
		||||
@@ -1215,43 +1215,43 @@ msgstr "常用支出"
 | 
			
		||||
msgid "Recurring Income"
 | 
			
		||||
msgstr "常用收入"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:47
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:48
 | 
			
		||||
msgid "Description Template"
 | 
			
		||||
msgstr "摘要範本"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:52
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:53
 | 
			
		||||
msgid "Available template variables:"
 | 
			
		||||
msgstr "範本變數說明:"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:54
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:55
 | 
			
		||||
msgid "This month, as a number."
 | 
			
		||||
msgstr "這個月的數字。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:55
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:56
 | 
			
		||||
msgid "This month, in its name."
 | 
			
		||||
msgstr "這個月的名稱。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:56
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:57
 | 
			
		||||
msgid "Last month, as a number."
 | 
			
		||||
msgstr "上個月的數字。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:57
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:58
 | 
			
		||||
msgid "Last month, in its name."
 | 
			
		||||
msgstr "上個月的名稱。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:58
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:59
 | 
			
		||||
msgid "The previous bimonthly period, as numbers."
 | 
			
		||||
msgstr "前個雙月期的數字。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:59
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:60
 | 
			
		||||
msgid "The previous bimonthly period, as their names."
 | 
			
		||||
msgstr "前個雙月期的名稱。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:62
 | 
			
		||||
msgid "Example:"
 | 
			
		||||
msgstr "範例:"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61
 | 
			
		||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:62
 | 
			
		||||
msgid "Water bill for {last_bimonthly_name}"
 | 
			
		||||
msgstr "水費{last_bimonthly_number}月"
 | 
			
		||||
 | 
			
		||||
@@ -1318,13 +1318,13 @@ msgstr "%(period)s%(currency)s試算表"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:29
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:49
 | 
			
		||||
msgid "Accounts with Unapplied Items"
 | 
			
		||||
msgid "Accounts With Unapplied Items"
 | 
			
		||||
msgstr "含未抵銷項目的科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:29
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:51
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Accounts with Unapplied Items in %(currency)s"
 | 
			
		||||
msgid "Accounts With Unapplied Items in %(currency)s"
 | 
			
		||||
msgstr "%(currency)s含未抵銷項目的科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/unapplied.html:29
 | 
			
		||||
@@ -1339,13 +1339,13 @@ msgstr "%(currency)s%(account)s未抵銷項目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/unmatched-accounts.html:29
 | 
			
		||||
#: src/accounting/templates/accounting/report/unmatched-accounts.html:49
 | 
			
		||||
msgid "Accounts with Unmatched Offsets"
 | 
			
		||||
msgid "Accounts With Unmatched Offsets"
 | 
			
		||||
msgstr "含遺漏抵銷項目的科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/unmatched-accounts.html:29
 | 
			
		||||
#: src/accounting/templates/accounting/report/unmatched-accounts.html:51
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Accounts with Unmatched Offsets in %(currency)s"
 | 
			
		||||
msgid "Accounts With Unmatched Offsets in %(currency)s"
 | 
			
		||||
msgstr "%(currency)s含遺漏抵銷項目的科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/report/unmatched.html:29
 | 
			
		||||
@@ -1415,12 +1415,12 @@ msgstr "下載"
 | 
			
		||||
msgid "current assets and liabilities"
 | 
			
		||||
msgstr "流動資產與負債"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/utils/pagination.py:206
 | 
			
		||||
#: src/accounting/utils/pagination.py:207
 | 
			
		||||
msgctxt "Pagination|"
 | 
			
		||||
msgid "Previous"
 | 
			
		||||
msgstr "上一頁"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/utils/pagination.py:255
 | 
			
		||||
#: src/accounting/utils/pagination.py:256
 | 
			
		||||
msgctxt "Pagination|"
 | 
			
		||||
msgid "Next"
 | 
			
		||||
msgstr "下一頁"
 | 
			
		||||
 
 | 
			
		||||
@@ -14,18 +14,15 @@
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The utility to cast a SQLAlchemy column into the column type, to avoid
 | 
			
		||||
warnings from the IDE.
 | 
			
		||||
"""The utilities to cast values into desired types, to avoid IDE warnings.
 | 
			
		||||
 | 
			
		||||
This module should not import any other module from the application.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def s(message: t.Any) -> str:
 | 
			
		||||
def s(message: Any) -> str:
 | 
			
		||||
    """Casts the LazyString message to the string type.
 | 
			
		||||
 | 
			
		||||
    :param message: The message.
 | 
			
		||||
 
 | 
			
		||||
@@ -17,12 +17,12 @@
 | 
			
		||||
"""The current assets and liabilities account.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from typing import Self
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.models import Account
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrentAccount:
 | 
			
		||||
@@ -54,7 +54,7 @@ class CurrentAccount:
 | 
			
		||||
        return self.str
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def current_assets_and_liabilities(cls) -> t.Self:
 | 
			
		||||
    def current_assets_and_liabilities(cls) -> Self:
 | 
			
		||||
        """Returns the pseudo account for all current assets and liabilities.
 | 
			
		||||
 | 
			
		||||
        :return: The pseudo account for all current assets and liabilities.
 | 
			
		||||
@@ -67,14 +67,14 @@ class CurrentAccount:
 | 
			
		||||
        return account
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def accounts(cls) -> list[t.Self]:
 | 
			
		||||
    def accounts(cls) -> list[Self]:
 | 
			
		||||
        """Returns the current assets and liabilities accounts.
 | 
			
		||||
 | 
			
		||||
        :return: The current assets and liabilities accounts.
 | 
			
		||||
        """
 | 
			
		||||
        accounts: list[cls] = [cls.current_assets_and_liabilities()]
 | 
			
		||||
        accounts.extend([CurrentAccount(x)
 | 
			
		||||
                         for x in db.session.query(Account)
 | 
			
		||||
                         for x in Account.query
 | 
			
		||||
                        .filter(cls.sql_condition())
 | 
			
		||||
                        .order_by(Account.base_code, Account.no)])
 | 
			
		||||
        return accounts
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,7 @@
 | 
			
		||||
This module should not import any other module from the application.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from flask import flash
 | 
			
		||||
from flask_wtf import FlaskForm
 | 
			
		||||
@@ -34,7 +34,7 @@ def flash_form_errors(form: FlaskForm) -> None:
 | 
			
		||||
    __flash_errors(form.errors)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __flash_errors(error: t.Any) -> None:
 | 
			
		||||
def __flash_errors(error: Any) -> None:
 | 
			
		||||
    """Flash all errors recursively.
 | 
			
		||||
 | 
			
		||||
    :param error: The errors.
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,17 @@ This module should not import any other module from the application.
 | 
			
		||||
from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
 | 
			
		||||
    urlunparse
 | 
			
		||||
 | 
			
		||||
from flask import request, Blueprint
 | 
			
		||||
from flask import request, Blueprint, current_app
 | 
			
		||||
from itsdangerous import URLSafeSerializer, BadData
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __as_next() -> str:
 | 
			
		||||
    """Encodes the current request URI as value for the next URI.
 | 
			
		||||
 | 
			
		||||
    :return: The current request URI as value for the next URI.
 | 
			
		||||
    """
 | 
			
		||||
    return encode_next(
 | 
			
		||||
        request.full_path if request.query_string else request.path)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def append_next(uri: str) -> str:
 | 
			
		||||
@@ -41,11 +51,8 @@ def inherit_next(uri: str) -> str:
 | 
			
		||||
    :param uri: The URI.
 | 
			
		||||
    :return: The URI with the current next URI added at the query argument.
 | 
			
		||||
    """
 | 
			
		||||
    next_uri: str | None = request.form.get("next") \
 | 
			
		||||
        if request.method == "POST" else request.args.get("next")
 | 
			
		||||
    if next_uri is None:
 | 
			
		||||
        return uri
 | 
			
		||||
    return __set_next(uri, next_uri)
 | 
			
		||||
    next_uri: str | None = __get_next()
 | 
			
		||||
    return uri if next_uri is None else __set_next(uri, next_uri)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def or_next(uri: str) -> str:
 | 
			
		||||
@@ -54,9 +61,23 @@ def or_next(uri: str) -> str:
 | 
			
		||||
    :param uri: The URI.
 | 
			
		||||
    :return: The next URI or the supplied URI.
 | 
			
		||||
    """
 | 
			
		||||
    next_uri: str | None = __get_next()
 | 
			
		||||
    return uri if next_uri is None else next_uri
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __get_next() -> str | None:
 | 
			
		||||
    """Returns the valid next URI.
 | 
			
		||||
 | 
			
		||||
    :return: The valid next URI.
 | 
			
		||||
    """
 | 
			
		||||
    next_uri: str | None = request.form.get("next") \
 | 
			
		||||
        if request.method == "POST" else request.args.get("next")
 | 
			
		||||
    return uri if next_uri is None else next_uri
 | 
			
		||||
    if next_uri is None:
 | 
			
		||||
        return None
 | 
			
		||||
    try:
 | 
			
		||||
        return decode_next(next_uri)
 | 
			
		||||
    except BadData:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __set_next(uri: str, next_uri: str) -> str:
 | 
			
		||||
@@ -69,18 +90,39 @@ def __set_next(uri: str, next_uri: str) -> str:
 | 
			
		||||
    uri_p: ParseResult = urlparse(uri)
 | 
			
		||||
    params: list[tuple[str, str]] = parse_qsl(uri_p.query)
 | 
			
		||||
    params = [x for x in params if x[0] != "next"]
 | 
			
		||||
    params.append(("next", next_uri))
 | 
			
		||||
    params.append(("next", encode_next(next_uri)))
 | 
			
		||||
    parts: list[str] = list(uri_p)
 | 
			
		||||
    parts[4] = urlencode(params)
 | 
			
		||||
    return urlunparse(parts)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def encode_next(uri: str) -> str:
 | 
			
		||||
    """Encodes the next URI.
 | 
			
		||||
 | 
			
		||||
    :param uri: The next URI.
 | 
			
		||||
    :return: The encoded next URI.
 | 
			
		||||
    """
 | 
			
		||||
    return URLSafeSerializer(current_app.config["SECRET_KEY"])\
 | 
			
		||||
        .dumps(uri, "next")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def decode_next(uri: str) -> str:
 | 
			
		||||
    """Decodes the encoded next URI.
 | 
			
		||||
 | 
			
		||||
    :param uri: The encoded next URI.
 | 
			
		||||
    :return: The next URI.
 | 
			
		||||
    """
 | 
			
		||||
    return URLSafeSerializer(current_app.config["SECRET_KEY"])\
 | 
			
		||||
        .loads(uri, "next")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def init_app(bp: Blueprint) -> None:
 | 
			
		||||
    """Initializes the application.
 | 
			
		||||
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    bp.add_app_template_global(__as_next, "accounting_as_next")
 | 
			
		||||
    bp.add_app_template_filter(append_next, "accounting_append_next")
 | 
			
		||||
    bp.add_app_template_filter(inherit_next, "accounting_inherit_next")
 | 
			
		||||
    bp.add_app_template_filter(or_next, "accounting_or_next")
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
"""The SQLAlchemy alias for the offset items.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
@@ -30,10 +30,10 @@ def offset_alias() -> sa.Alias:
 | 
			
		||||
    :return: The SQLAlchemy alias for the offset items.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def as_from(model_cls: t.Any) -> sa.FromClause:
 | 
			
		||||
    def as_from(model_cls: Any) -> sa.FromClause:
 | 
			
		||||
        return model_cls
 | 
			
		||||
 | 
			
		||||
    def as_alias(alias: t.Any) -> sa.Alias:
 | 
			
		||||
    def as_alias(alias: Any) -> sa.Alias:
 | 
			
		||||
        return alias
 | 
			
		||||
 | 
			
		||||
    return as_alias(sa.alias(as_from(JournalEntryLineItem), name="offset"))
 | 
			
		||||
 
 | 
			
		||||
@@ -39,8 +39,11 @@ class RecurringItem:
 | 
			
		||||
        :param description_template: The description template.
 | 
			
		||||
        """
 | 
			
		||||
        self.name: str = name
 | 
			
		||||
        """The name."""
 | 
			
		||||
        self.account_code: str = account_code
 | 
			
		||||
        """The account code."""
 | 
			
		||||
        self.description_template: str = description_template
 | 
			
		||||
        """The description template."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def account_text(self) -> str:
 | 
			
		||||
@@ -61,8 +64,10 @@ class Recurring:
 | 
			
		||||
        """
 | 
			
		||||
        self.expenses: list[RecurringItem] \
 | 
			
		||||
            = [RecurringItem(x[0], x[1], x[2]) for x in data["expense"]]
 | 
			
		||||
        """The recurring expenses."""
 | 
			
		||||
        self.incomes: list[RecurringItem] \
 | 
			
		||||
            = [RecurringItem(x[0], x[1], x[2]) for x in data["income"]]
 | 
			
		||||
        """The recurring incomes."""
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def codes(self) -> set[str]:
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#  Copyright (c) 2023-2024 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
@@ -19,7 +19,6 @@
 | 
			
		||||
This module should not import any other module from the application.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
 | 
			
		||||
    ParseResult
 | 
			
		||||
 | 
			
		||||
@@ -62,10 +61,8 @@ class Redirection(RequestRedirect):
 | 
			
		||||
DEFAULT_PAGE_SIZE: int = 10
 | 
			
		||||
"""The default page size."""
 | 
			
		||||
 | 
			
		||||
T = t.TypeVar("T")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Pagination(t.Generic[T]):
 | 
			
		||||
class Pagination[T]:
 | 
			
		||||
    """The pagination utility."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, items: list[T], is_reversed: bool = False):
 | 
			
		||||
@@ -91,7 +88,7 @@ class Pagination(t.Generic[T]):
 | 
			
		||||
        """The options to the number of items in a page."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AbstractPagination(t.Generic[T]):
 | 
			
		||||
class AbstractPagination[T]:
 | 
			
		||||
    """An abstract pagination."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
@@ -108,12 +105,12 @@ class AbstractPagination(t.Generic[T]):
 | 
			
		||||
        """The options to the number of items in a page."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EmptyPagination(AbstractPagination[T]):
 | 
			
		||||
class EmptyPagination[T](AbstractPagination[T]):
 | 
			
		||||
    """The pagination from empty data."""
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NonEmptyPagination(AbstractPagination[T]):
 | 
			
		||||
class NonEmptyPagination[T](AbstractPagination[T]):
 | 
			
		||||
    """The pagination with real data."""
 | 
			
		||||
    PAGE_SIZE_OPTION_VALUES: list[int] = [10, 100, 200]
 | 
			
		||||
    """The page size options."""
 | 
			
		||||
 
 | 
			
		||||
@@ -19,21 +19,21 @@
 | 
			
		||||
This module should not import any other module from the application.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
 | 
			
		||||
from flask import abort, Blueprint, Response
 | 
			
		||||
 | 
			
		||||
from accounting.utils.user import get_current_user, UserUtilityInterface
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
 | 
			
		||||
def has_permission(rule: Callable[[], bool]) -> Callable:
 | 
			
		||||
    """The permission decorator to check whether the current user is allowed.
 | 
			
		||||
 | 
			
		||||
    :param rule: The permission rule.
 | 
			
		||||
    :return: The view decorator.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def decorator(view: t.Callable) -> t.Callable:
 | 
			
		||||
    def decorator(view: Callable) -> Callable:
 | 
			
		||||
        """The view decorator to decorate a view with permission tests.
 | 
			
		||||
 | 
			
		||||
        :param view: The view.
 | 
			
		||||
@@ -61,16 +61,16 @@ def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
 | 
			
		||||
    return decorator
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
__can_view_func: t.Callable[[], bool] = lambda: True
 | 
			
		||||
__can_view_func: Callable[[], bool] = lambda: True
 | 
			
		||||
"""The callback that returns whether the current user can view the accounting
 | 
			
		||||
data."""
 | 
			
		||||
__can_edit_func: t.Callable[[], bool] = lambda: True
 | 
			
		||||
__can_edit_func: Callable[[], bool] = lambda: True
 | 
			
		||||
"""The callback that returns whether the current user can edit the accounting
 | 
			
		||||
data."""
 | 
			
		||||
__can_admin_func: t.Callable[[], bool] = lambda: True
 | 
			
		||||
__can_admin_func: Callable[[], bool] = lambda: True
 | 
			
		||||
"""The callback that returns whether the current user can administrate the
 | 
			
		||||
accounting settings."""
 | 
			
		||||
_unauthorized_func: t.Callable[[], Response | None] \
 | 
			
		||||
_unauthorized_func: Callable[[], Response | None] \
 | 
			
		||||
    = lambda: Response(status=403)
 | 
			
		||||
"""The callback that returns the response to require the user to log in."""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -14,22 +14,22 @@
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The random ID mixin for the data models.
 | 
			
		||||
"""The random ID utility for the data models.
 | 
			
		||||
 | 
			
		||||
This module should not import any other module from the application.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from secrets import randbelow
 | 
			
		||||
from typing import Type
 | 
			
		||||
 | 
			
		||||
from accounting import db
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def new_id(cls: t.Type):
 | 
			
		||||
    """Returns a new random ID for the data model.
 | 
			
		||||
def new_id(cls: Type[db.Model]):
 | 
			
		||||
    """Generates and returns a new, unused random ID for the data model.
 | 
			
		||||
 | 
			
		||||
    :param cls: The data model.
 | 
			
		||||
    :return: The generated new random ID.
 | 
			
		||||
    :return: The newly-generated, unused random ID.
 | 
			
		||||
    """
 | 
			
		||||
    while True:
 | 
			
		||||
        obj_id: int = 100000000 + randbelow(900000000)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								src/accounting/utils/timezone.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/accounting/utils/timezone.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2024/6/4
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2024 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The timezone utility.
 | 
			
		||||
 | 
			
		||||
This module should not import any other module from the application.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import datetime as dt
 | 
			
		||||
 | 
			
		||||
import pytz
 | 
			
		||||
from flask import request
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_tz_today() -> dt.date:
 | 
			
		||||
    """Returns today in the client timezone.
 | 
			
		||||
 | 
			
		||||
    :return: today in the client timezone.
 | 
			
		||||
    """
 | 
			
		||||
    tz_name: str | None = request.cookies.get("accounting-tz")
 | 
			
		||||
    if tz_name is None:
 | 
			
		||||
        return dt.date.today()
 | 
			
		||||
    return dt.datetime.now(tz=pytz.timezone(tz_name)).date()
 | 
			
		||||
							
								
								
									
										59
									
								
								src/accounting/utils/title_case.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								src/accounting/utils/title_case.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,59 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/7/29
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
#  You may obtain a copy of the License at
 | 
			
		||||
#
 | 
			
		||||
#      http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
#
 | 
			
		||||
#  Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
#  distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
"""The title case capitalization for the base account titles.
 | 
			
		||||
This follows the APA style title case capitalization.  See
 | 
			
		||||
https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case .
 | 
			
		||||
 | 
			
		||||
This module should not import any other module from the application.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
CONJUNCTIONS: set[str] = {"and", "as", "but", "for", "if", "nor", "or", "so",
 | 
			
		||||
                          "yet"}
 | 
			
		||||
"""Short conjunctions."""
 | 
			
		||||
ARTICLES: set[str] = {"a", "an", "the"}
 | 
			
		||||
"""Articles."""
 | 
			
		||||
PREPOSITIONS: set[str] = {"as", "at", "by", "for", "in", "of", "on", "per",
 | 
			
		||||
                          "to", "up", "via"}
 | 
			
		||||
"""Short prepositions."""
 | 
			
		||||
MINOR_WORDS: set[str] \
 | 
			
		||||
    = CONJUNCTIONS.copy().union(ARTICLES).union(PREPOSITIONS)
 | 
			
		||||
"""Minor words that should be in lowercase."""
 | 
			
		||||
# Excludes "by" as in "1223 by-products"
 | 
			
		||||
MINOR_WORDS.remove("by")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def title_case(s: str) -> str:
 | 
			
		||||
    """Capitalize a title string for the base account titles.  Do not use it
 | 
			
		||||
    in other places.  This excludes "by" as in "1223 by-products".
 | 
			
		||||
 | 
			
		||||
    :param s: The title string.
 | 
			
		||||
    :return: The capitalized title string.
 | 
			
		||||
    """
 | 
			
		||||
    return re.sub(r"\w+", __cap_word, s)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def __cap_word(m: re.Match) -> str:
 | 
			
		||||
    """Capitalize a matched title word.
 | 
			
		||||
 | 
			
		||||
    :param m: The matched title word.
 | 
			
		||||
    :return: The capitalized title word.
 | 
			
		||||
    """
 | 
			
		||||
    if m.group(0).lower() in MINOR_WORDS:
 | 
			
		||||
        return m.group(0)
 | 
			
		||||
    return m.group(0).title()
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
# The Mia! Accounting Project.
 | 
			
		||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
 | 
			
		||||
 | 
			
		||||
#  Copyright (c) 2023 imacat.
 | 
			
		||||
#  Copyright (c) 2023-2024 imacat.
 | 
			
		||||
#
 | 
			
		||||
#  Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
#  you may not use this file except in compliance with the License.
 | 
			
		||||
@@ -19,17 +19,15 @@
 | 
			
		||||
This module should not import any other module from the application.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from abc import ABC, abstractmethod
 | 
			
		||||
from typing import Type
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from flask import g, Response
 | 
			
		||||
from flask_sqlalchemy.model import Model
 | 
			
		||||
 | 
			
		||||
T = t.TypeVar("T", bound=Model)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserUtilityInterface(t.Generic[T], ABC):
 | 
			
		||||
class UserUtilityInterface[T: Model](ABC):
 | 
			
		||||
    """The interface for the user utilities."""
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
@@ -72,7 +70,7 @@ class UserUtilityInterface(t.Generic[T], ABC):
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def cls(self) -> t.Type[T]:
 | 
			
		||||
    def cls(self) -> Type[T]:
 | 
			
		||||
        """Returns the class of the user data model.
 | 
			
		||||
 | 
			
		||||
        :return: The class of the user data model.
 | 
			
		||||
@@ -112,7 +110,7 @@ class UserUtilityInterface(t.Generic[T], ABC):
 | 
			
		||||
 | 
			
		||||
__user_utils: UserUtilityInterface
 | 
			
		||||
"""The user utilities."""
 | 
			
		||||
user_cls: t.Type[Model] = Model
 | 
			
		||||
type user_cls = Model
 | 
			
		||||
"""The user class."""
 | 
			
		||||
user_pk_column: sa.Column = sa.Column(sa.Integer)
 | 
			
		||||
"""The primary key column of the user class."""
 | 
			
		||||
 
 | 
			
		||||
@@ -28,8 +28,11 @@ from babel.messages.frontend import CommandLineInterface
 | 
			
		||||
from opencc import OpenCC
 | 
			
		||||
 | 
			
		||||
root_dir: Path = Path(__file__).parent.parent
 | 
			
		||||
"""The project root directory."""
 | 
			
		||||
translation_dir: Path = root_dir / "tests" / "test_site" / "translations"
 | 
			
		||||
"""The directory of the translation files."""
 | 
			
		||||
domain: str = "messages"
 | 
			
		||||
"""The message domain."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@click.group()
 | 
			
		||||
 
 | 
			
		||||
@@ -28,8 +28,11 @@ from babel.messages.frontend import CommandLineInterface
 | 
			
		||||
from opencc import OpenCC
 | 
			
		||||
 | 
			
		||||
root_dir: Path = Path(__file__).parent.parent
 | 
			
		||||
"""The project root directory."""
 | 
			
		||||
translation_dir: Path = root_dir / "src" / "accounting" / "translations"
 | 
			
		||||
"""The directory of the translation files."""
 | 
			
		||||
domain: str = "accounting"
 | 
			
		||||
"""The message domain."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@click.group()
 | 
			
		||||
 
 | 
			
		||||
@@ -17,15 +17,16 @@
 | 
			
		||||
"""The test for the account management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import datetime as dt
 | 
			
		||||
import unittest
 | 
			
		||||
from datetime import timedelta, date
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from flask import Flask
 | 
			
		||||
 | 
			
		||||
from accounting.utils.next_uri import encode_next
 | 
			
		||||
from test_site import db
 | 
			
		||||
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
 | 
			
		||||
    add_journal_entry
 | 
			
		||||
from testlib import NEXT_URI, create_test_app, get_client, get_csrf_token, \
 | 
			
		||||
    set_locale, add_journal_entry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountData:
 | 
			
		||||
@@ -71,29 +72,35 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
        self.__app: Flask = create_test_app()
 | 
			
		||||
        """The Flask application."""
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            from accounting.models import Account, AccountL10n
 | 
			
		||||
            AccountL10n.query.delete()
 | 
			
		||||
            Account.query.delete()
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
            self.__encoded_next_uri: str = encode_next(NEXT_URI)
 | 
			
		||||
            """The encoded next URI."""
 | 
			
		||||
 | 
			
		||||
        self.client, self.csrf_token = get_client(self.app, "editor")
 | 
			
		||||
        self.__client: httpx.Client = get_client(self.__app, "editor")
 | 
			
		||||
        """The user client."""
 | 
			
		||||
        self.__csrf_token: str = get_csrf_token(self.__client)
 | 
			
		||||
        """The CSRF token."""
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": CASH.base_code,
 | 
			
		||||
                                          "title": CASH.title})
 | 
			
		||||
        response = self.__client.post(f"{PREFIX}/store",
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": CASH.base_code,
 | 
			
		||||
                                            "title": CASH.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         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})
 | 
			
		||||
        response = self.__client.post(f"{PREFIX}/store",
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": BANK.base_code,
 | 
			
		||||
                                            "title": BANK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         f"{PREFIX}/{BANK.code}")
 | 
			
		||||
@@ -104,7 +111,8 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        client, csrf_token = get_client(self.app, "nobody")
 | 
			
		||||
        client: httpx.Client = get_client(self.__app, "nobody")
 | 
			
		||||
        csrf_token: str = get_csrf_token(client)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
@@ -138,12 +146,12 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response = client.get(f"{PREFIX}/bases/{CASH.base_code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            cash_id: int = Account.find_by_code(CASH.code).id
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "next": NEXT_URI,
 | 
			
		||||
                                     "next": self.__encoded_next_uri,
 | 
			
		||||
                                     f"{cash_id}-no": "5"})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
@@ -153,7 +161,8 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        client, csrf_token = get_client(self.app, "viewer")
 | 
			
		||||
        client: httpx.Client = get_client(self.__app, "viewer")
 | 
			
		||||
        csrf_token: str = get_csrf_token(client)
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
@@ -187,12 +196,12 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response = client.get(f"{PREFIX}/bases/{CASH.base_code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            cash_id: int = Account.find_by_code(CASH.code).id
 | 
			
		||||
 | 
			
		||||
        response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "next": NEXT_URI,
 | 
			
		||||
                                     "next": self.__encoded_next_uri,
 | 
			
		||||
                                     f"{cash_id}-no": "5"})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
@@ -204,48 +213,48 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(PREFIX)
 | 
			
		||||
        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")
 | 
			
		||||
        response = self.__client.get(f"{PREFIX}/create")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": STOCK.base_code,
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        response = self.__client.post(f"{PREFIX}/store",
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": STOCK.base_code,
 | 
			
		||||
                                            "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         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",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": CASH.base_code,
 | 
			
		||||
                                          "title": f"{CASH.title}-2"})
 | 
			
		||||
        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"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{BANK.code}/delete",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        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():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            cash_id: int = Account.find_by_code(CASH.code).id
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/bases/{CASH.base_code}",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "next": NEXT_URI,
 | 
			
		||||
                                          f"{cash_id}-no": "5"})
 | 
			
		||||
        response = self.__client.post(f"{PREFIX}/bases/{CASH.base_code}",
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "next": self.__encoded_next_uri,
 | 
			
		||||
                                            f"{cash_id}-no": "5"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], NEXT_URI)
 | 
			
		||||
 | 
			
		||||
@@ -260,96 +269,97 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        detail_uri: str = f"{PREFIX}/{STOCK.code}"
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            self.assertEqual({x.code for x in Account.query.all()},
 | 
			
		||||
                             {CASH.code, BANK.code})
 | 
			
		||||
 | 
			
		||||
        # Missing CSRF token
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"base_code": STOCK.base_code,
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        response = self.__client.post(store_uri,
 | 
			
		||||
                                      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})
 | 
			
		||||
        response = self.__client.post(store_uri,
 | 
			
		||||
                                      data={"csrf_token":
 | 
			
		||||
                                            f"{self.__csrf_token}-2",
 | 
			
		||||
                                            "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})
 | 
			
		||||
        response = self.__client.post(store_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": " ",
 | 
			
		||||
                                            "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
        # Non-existing base account
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "9999",
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        response = self.__client.post(store_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": "9999",
 | 
			
		||||
                                            "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
        # Unavailable base account
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "1",
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        response = self.__client.post(store_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": "1",
 | 
			
		||||
                                            "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,
 | 
			
		||||
                                          "title": " "})
 | 
			
		||||
        response = self.__client.post(store_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": STOCK.base_code,
 | 
			
		||||
                                            "title": " "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
        # A nominal account that needs offset
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "6172",
 | 
			
		||||
                                          "title": STOCK.title,
 | 
			
		||||
                                          "is_need_offset": "yes"})
 | 
			
		||||
        response = self.__client.post(store_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": "6172",
 | 
			
		||||
                                            "title": STOCK.title,
 | 
			
		||||
                                            "is_need_offset": "yes"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], create_uri)
 | 
			
		||||
 | 
			
		||||
        # 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} "})
 | 
			
		||||
        response = self.__client.post(store_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "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})
 | 
			
		||||
        response = self.__client.post(store_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "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")
 | 
			
		||||
 | 
			
		||||
        # Success under the same base, with order in a mess.
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            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})
 | 
			
		||||
        response = self.__client.post(store_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "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")
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        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",
 | 
			
		||||
@@ -372,71 +382,71 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        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 "})
 | 
			
		||||
        response = self.__client.post(update_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "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():
 | 
			
		||||
        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")
 | 
			
		||||
 | 
			
		||||
        # Empty base account code
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": " ",
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        response = self.__client.post(update_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": " ",
 | 
			
		||||
                                            "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
        # Non-existing base account
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "9999",
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        response = self.__client.post(update_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": "9999",
 | 
			
		||||
                                            "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
        # Unavailable base account
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "1",
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        response = self.__client.post(update_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": "1",
 | 
			
		||||
                                            "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,
 | 
			
		||||
                                          "title": " "})
 | 
			
		||||
        response = self.__client.post(update_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": STOCK.base_code,
 | 
			
		||||
                                            "title": " "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
        # A nominal account that needs offset
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "6172",
 | 
			
		||||
                                          "title": STOCK.title,
 | 
			
		||||
                                          "is_need_offset": "yes"})
 | 
			
		||||
        response = self.__client.post(update_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": "6172",
 | 
			
		||||
                                            "title": STOCK.title,
 | 
			
		||||
                                            "is_need_offset": "yes"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], edit_uri)
 | 
			
		||||
 | 
			
		||||
        # Change the base account
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": STOCK.base_code,
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        response = self.__client.post(update_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": STOCK.base_code,
 | 
			
		||||
                                            "title": STOCK.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_c_uri)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(detail_uri)
 | 
			
		||||
        response = self.__client.get(detail_uri)
 | 
			
		||||
        self.assertEqual(response.status_code, 404)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(detail_c_uri)
 | 
			
		||||
        response = self.__client.get(detail_c_uri)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
    def test_update_not_modified(self) -> None:
 | 
			
		||||
@@ -450,29 +460,29 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        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} "})
 | 
			
		||||
        response = self.__client.post(update_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "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():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertIsNotNone(account)
 | 
			
		||||
            account.created_at \
 | 
			
		||||
                = account.created_at - timedelta(seconds=5)
 | 
			
		||||
                = account.created_at - dt.timedelta(seconds=5)
 | 
			
		||||
            account.updated_at = account.created_at
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(update_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": CASH.base_code,
 | 
			
		||||
                                          "title": STOCK.title})
 | 
			
		||||
        response = self.__client.post(update_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "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():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertIsNotNone(account)
 | 
			
		||||
            self.assertLess(account.created_at,
 | 
			
		||||
@@ -485,13 +495,14 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        editor_username, admin_username = "editor", "admin"
 | 
			
		||||
        client, csrf_token = get_client(self.app, admin_username)
 | 
			
		||||
        client: httpx.Client = get_client(self.__app, admin_username)
 | 
			
		||||
        csrf_token: str = get_csrf_token(client)
 | 
			
		||||
        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():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertEqual(account.created_by.username, editor_username)
 | 
			
		||||
            self.assertEqual(account.updated_by.username, editor_username)
 | 
			
		||||
@@ -503,7 +514,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            account = Account.find_by_code(CASH.code)
 | 
			
		||||
            self.assertEqual(account.created_by.username,
 | 
			
		||||
                             editor_username)
 | 
			
		||||
@@ -521,51 +532,51 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        account: Account
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            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")
 | 
			
		||||
        set_locale(self.__app, 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"})
 | 
			
		||||
        response = self.__client.post(update_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "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():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            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")})
 | 
			
		||||
 | 
			
		||||
        set_locale(self.client, self.csrf_token, "en")
 | 
			
		||||
        set_locale(self.__app, 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"})
 | 
			
		||||
        response = self.__client.post(update_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "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():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            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")})
 | 
			
		||||
 | 
			
		||||
        set_locale(self.client, self.csrf_token, "zh_Hant")
 | 
			
		||||
        set_locale(self.__app, 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"})
 | 
			
		||||
        response = self.__client.post(update_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "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():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            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},
 | 
			
		||||
@@ -582,53 +593,53 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        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})
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
        add_journal_entry(self.client,
 | 
			
		||||
                          form={"csrf_token": self.csrf_token,
 | 
			
		||||
                                "next": NEXT_URI,
 | 
			
		||||
                                "date": date.today().isoformat(),
 | 
			
		||||
        add_journal_entry(self.__client,
 | 
			
		||||
                          form={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                "next": self.__encoded_next_uri,
 | 
			
		||||
                                "date": dt.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():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            self.assertEqual({x.code for x in Account.query.all()},
 | 
			
		||||
                             {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})
 | 
			
		||||
        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})
 | 
			
		||||
        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)
 | 
			
		||||
        response = self.__client.get(detail_uri)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        response = self.client.post(delete_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        response = self.__client.post(delete_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], list_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            self.assertEqual({x.code for x in Account.query.all()},
 | 
			
		||||
                             {CASH.code, BANK.code})
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(detail_uri)
 | 
			
		||||
        response = self.__client.get(detail_uri)
 | 
			
		||||
        self.assertEqual(response.status_code, 404)
 | 
			
		||||
        response = self.client.post(delete_uri,
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token})
 | 
			
		||||
        response = self.__client.post(delete_uri,
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 404)
 | 
			
		||||
 | 
			
		||||
    def test_change_base_code(self) -> None:
 | 
			
		||||
@@ -640,15 +651,15 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        for i in range(2, 6):
 | 
			
		||||
            response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                        data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                              "base_code": "1111",
 | 
			
		||||
                                              "title": "Title"})
 | 
			
		||||
            response = self.__client.post(f"{PREFIX}/store",
 | 
			
		||||
                                          data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                                "base_code": "1111",
 | 
			
		||||
                                                "title": "Title"})
 | 
			
		||||
            self.assertEqual(response.status_code, 302)
 | 
			
		||||
            self.assertEqual(response.headers["Location"],
 | 
			
		||||
                             f"{PREFIX}/1111-00{i}")
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            account_1: Account = Account.find_by_code("1111-001")
 | 
			
		||||
            id_1: int = account_1.id
 | 
			
		||||
            account_2: Account = Account.find_by_code("1111-002")
 | 
			
		||||
@@ -668,14 +679,14 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
            account_5.no = 6
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/1111-005/update",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "1112",
 | 
			
		||||
                                          "title": "Title"})
 | 
			
		||||
        response = self.__client.post(f"{PREFIX}/1111-005/update",
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "base_code": "1112",
 | 
			
		||||
                                            "title": "Title"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/1112-003")
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_1).no, 1)
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_2).no, 3)
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_3).no, 2)
 | 
			
		||||
@@ -691,34 +702,34 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        for i in range(2, 6):
 | 
			
		||||
            response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                        data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                              "base_code": "1111",
 | 
			
		||||
                                              "title": "Title"})
 | 
			
		||||
            response = self.__client.post(f"{PREFIX}/store",
 | 
			
		||||
                                          data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                                "base_code": "1111",
 | 
			
		||||
                                                "title": "Title"})
 | 
			
		||||
            self.assertEqual(response.status_code, 302)
 | 
			
		||||
            self.assertEqual(response.headers["Location"],
 | 
			
		||||
                             f"{PREFIX}/1111-00{i}")
 | 
			
		||||
 | 
			
		||||
        # Normal reorder
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            id_1: int = Account.find_by_code("1111-001").id
 | 
			
		||||
            id_2: int = Account.find_by_code("1111-002").id
 | 
			
		||||
            id_3: int = Account.find_by_code("1111-003").id
 | 
			
		||||
            id_4: int = Account.find_by_code("1111-004").id
 | 
			
		||||
            id_5: int = Account.find_by_code("1111-005").id
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/bases/1111",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "next": NEXT_URI,
 | 
			
		||||
                                          f"{id_1}-no": "4",
 | 
			
		||||
                                          f"{id_2}-no": "1",
 | 
			
		||||
                                          f"{id_3}-no": "5",
 | 
			
		||||
                                          f"{id_4}-no": "2",
 | 
			
		||||
                                          f"{id_5}-no": "3"})
 | 
			
		||||
        response = self.__client.post(f"{PREFIX}/bases/1111",
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "next": self.__encoded_next_uri,
 | 
			
		||||
                                            f"{id_1}-no": "4",
 | 
			
		||||
                                            f"{id_2}-no": "1",
 | 
			
		||||
                                            f"{id_3}-no": "5",
 | 
			
		||||
                                            f"{id_4}-no": "2",
 | 
			
		||||
                                            f"{id_5}-no": "3"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], NEXT_URI)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_1).code, "1111-004")
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_2).code, "1111-001")
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_3).code, "1111-005")
 | 
			
		||||
@@ -726,7 +737,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_5).code, "1111-003")
 | 
			
		||||
 | 
			
		||||
        # Malformed orders
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            db.session.get(Account, id_1).no = 3
 | 
			
		||||
            db.session.get(Account, id_2).no = 4
 | 
			
		||||
            db.session.get(Account, id_3).no = 6
 | 
			
		||||
@@ -734,16 +745,16 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
            db.session.get(Account, id_5).no = 9
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/bases/1111",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "next": NEXT_URI,
 | 
			
		||||
                                          f"{id_2}-no": "3a",
 | 
			
		||||
                                          f"{id_3}-no": "5",
 | 
			
		||||
                                          f"{id_4}-no": "2"})
 | 
			
		||||
        response = self.__client.post(f"{PREFIX}/bases/1111",
 | 
			
		||||
                                      data={"csrf_token": self.__csrf_token,
 | 
			
		||||
                                            "next": self.__encoded_next_uri,
 | 
			
		||||
                                            f"{id_2}-no": "3a",
 | 
			
		||||
                                            f"{id_3}-no": "5",
 | 
			
		||||
                                            f"{id_4}-no": "2"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], NEXT_URI)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_1).code, "1111-003")
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_2).code, "1111-004")
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
 | 
			
		||||
 
 | 
			
		||||
@@ -39,14 +39,15 @@ class BaseAccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
        self.__app: Flask = create_test_app()
 | 
			
		||||
        """The Flask application."""
 | 
			
		||||
 | 
			
		||||
    def test_nobody(self) -> None:
 | 
			
		||||
        """Test the permission as nobody.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "nobody")
 | 
			
		||||
        client: httpx.Client = get_client(self.__app, "nobody")
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(LIST_URI)
 | 
			
		||||
@@ -60,7 +61,7 @@ class BaseAccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "viewer")
 | 
			
		||||
        client: httpx.Client = get_client(self.__app, "viewer")
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(LIST_URI)
 | 
			
		||||
@@ -74,7 +75,7 @@ class BaseAccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self.app, "editor")
 | 
			
		||||
        client: httpx.Client = get_client(self.__app, "editor")
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = client.get(LIST_URI)
 | 
			
		||||
 
 | 
			
		||||
@@ -18,8 +18,10 @@
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import csv
 | 
			
		||||
import typing as t
 | 
			
		||||
import datetime as dt
 | 
			
		||||
import re
 | 
			
		||||
import unittest
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from click.testing import Result
 | 
			
		||||
@@ -40,11 +42,17 @@ class ConsoleCommandTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.app: Flask = create_test_app()
 | 
			
		||||
        self.__app: Flask = create_test_app()
 | 
			
		||||
        """The Flask application."""
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            # Drop every accounting table, to see if accounting-init recreates
 | 
			
		||||
            # them correctly.
 | 
			
		||||
    def test_init_db(self) -> None:
 | 
			
		||||
        """Tests the "accounting-init-db" console command.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            # Drop every accounting table, to see if accounting-init-db
 | 
			
		||||
            # recreates them correctly.
 | 
			
		||||
            tables: list[sa.Table] \
 | 
			
		||||
                = [db.metadata.tables[x] for x in db.metadata.tables
 | 
			
		||||
                   if x.startswith("accounting_")]
 | 
			
		||||
@@ -56,13 +64,8 @@ class ConsoleCommandTestCase(unittest.TestCase):
 | 
			
		||||
                                  if x.startswith("accounting_")}),
 | 
			
		||||
                             0)
 | 
			
		||||
 | 
			
		||||
    def test_init(self) -> None:
 | 
			
		||||
        """Tests the "accounting-init" console command.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        runner: FlaskCliRunner = self.app.test_cli_runner()
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        runner: FlaskCliRunner = self.__app.test_cli_runner()
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            result: Result = runner.invoke(
 | 
			
		||||
                args=["accounting-init-db", "-u", "editor"])
 | 
			
		||||
        self.assertEqual(result.exit_code, 0,
 | 
			
		||||
@@ -80,20 +83,23 @@ class ConsoleCommandTestCase(unittest.TestCase):
 | 
			
		||||
        from accounting.models import BaseAccount
 | 
			
		||||
 | 
			
		||||
        with open(data_dir / "base_accounts.csv") as fp:
 | 
			
		||||
            data: dict[dict[str, t.Any]] \
 | 
			
		||||
                = {x["code"]: {"code": x["code"],
 | 
			
		||||
                               "title": x["title"],
 | 
			
		||||
                               "l10n": {y[5:]: x[y]
 | 
			
		||||
                                        for y in x if y.startswith("l10n-")}}
 | 
			
		||||
                   for x in csv.DictReader(fp)}
 | 
			
		||||
            rows: list[dict[str, str]] = list(csv.DictReader(fp))
 | 
			
		||||
        data: dict[dict[str, Any]] \
 | 
			
		||||
            = {x["code"]: {"code": x["code"],
 | 
			
		||||
                           "title": x["title"],
 | 
			
		||||
                           "l10n": {y[5:]: x[y]
 | 
			
		||||
                                    for y in x if y.startswith("l10n-")}}
 | 
			
		||||
               for x in rows}
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            accounts: list[BaseAccount] = BaseAccount.query.all()
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(len(accounts), len(data))
 | 
			
		||||
        for account in accounts:
 | 
			
		||||
            self.assertIn(account.code, data)
 | 
			
		||||
            self.assertEqual(account.title_l10n, data[account.code]["title"])
 | 
			
		||||
            self.assertEqual(account.title_l10n.lower(),
 | 
			
		||||
                             data[account.code]["title"].lower())
 | 
			
		||||
            self.__test_title_case(account.title_l10n)
 | 
			
		||||
            l10n: dict[str, str] = {x.locale: x.title for x in account.l10n}
 | 
			
		||||
            self.assertEqual(len(l10n), len(data[account.code]["l10n"]))
 | 
			
		||||
            for locale in l10n:
 | 
			
		||||
@@ -101,6 +107,23 @@ class ConsoleCommandTestCase(unittest.TestCase):
 | 
			
		||||
                self.assertEqual(l10n[locale],
 | 
			
		||||
                                 data[account.code]["l10n"][locale])
 | 
			
		||||
 | 
			
		||||
    def __test_title_case(self, s: str) -> None:
 | 
			
		||||
        """Tests the case of a base account title.
 | 
			
		||||
 | 
			
		||||
        :param s: The base account title.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.utils.title_case import MINOR_WORDS
 | 
			
		||||
 | 
			
		||||
        self.assertTrue(s[0].isupper(), s)
 | 
			
		||||
        for word in re.findall(r"\w+", s):
 | 
			
		||||
            if len(word) >= 4:
 | 
			
		||||
                self.assertTrue(word.istitle(), s)
 | 
			
		||||
            elif word in MINOR_WORDS:
 | 
			
		||||
                self.assertTrue(word.islower(), s)
 | 
			
		||||
            else:
 | 
			
		||||
                self.assertTrue(word.istitle(), s)
 | 
			
		||||
 | 
			
		||||
    def __test_account_data(self) -> None:
 | 
			
		||||
        """Tests the account data.
 | 
			
		||||
 | 
			
		||||
@@ -108,7 +131,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import BaseAccount, Account, AccountL10n
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            bases: list[BaseAccount] = BaseAccount.query\
 | 
			
		||||
                .filter(sa.func.char_length(BaseAccount.code) == 4).all()
 | 
			
		||||
            accounts: list[Account] = Account.query.all()
 | 
			
		||||
@@ -135,14 +158,14 @@ class ConsoleCommandTestCase(unittest.TestCase):
 | 
			
		||||
        from accounting.models import Currency
 | 
			
		||||
 | 
			
		||||
        with open(data_dir / "currencies.csv") as fp:
 | 
			
		||||
            data: dict[dict[str, t.Any]] \
 | 
			
		||||
            data: dict[dict[str, Any]] \
 | 
			
		||||
                = {x["code"]: {"code": x["code"],
 | 
			
		||||
                               "name": x["name"],
 | 
			
		||||
                               "l10n": {y[5:]: x[y]
 | 
			
		||||
                                        for y in x if y.startswith("l10n-")}}
 | 
			
		||||
                   for x in csv.DictReader(fp)}
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            currencies: list[Currency] = Currency.query.all()
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(len(currencies), len(data))
 | 
			
		||||
@@ -155,3 +178,69 @@ class ConsoleCommandTestCase(unittest.TestCase):
 | 
			
		||||
                self.assertIn(locale, data[currency.code]["l10n"])
 | 
			
		||||
                self.assertEqual(l10n[locale],
 | 
			
		||||
                                 data[currency.code]["l10n"][locale])
 | 
			
		||||
 | 
			
		||||
    def test_titleize(self) -> None:
 | 
			
		||||
        """Tests the "accounting-titleize" console command.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import BaseAccount, Account
 | 
			
		||||
        from accounting.utils.random_id import new_id
 | 
			
		||||
        from accounting.utils.user import get_user_pk
 | 
			
		||||
        runner: FlaskCliRunner = self.__app.test_cli_runner()
 | 
			
		||||
 | 
			
		||||
        with self.__app.app_context():
 | 
			
		||||
            # Resets the accounts.
 | 
			
		||||
            tables: list[sa.Table] \
 | 
			
		||||
                = [db.metadata.tables[x] for x in db.metadata.tables
 | 
			
		||||
                   if x.startswith("accounting_")]
 | 
			
		||||
            for table in tables:
 | 
			
		||||
                db.session.execute(DropTable(table))
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
            inspector: sa.Inspector = sa.inspect(db.session.connection())
 | 
			
		||||
            self.assertEqual(len({x for x in inspector.get_table_names()
 | 
			
		||||
                                  if x.startswith("accounting_")}),
 | 
			
		||||
                             0)
 | 
			
		||||
            result: Result = runner.invoke(
 | 
			
		||||
                args=["accounting-init-db", "-u", "editor"])
 | 
			
		||||
            self.assertEqual(result.exit_code, 0,
 | 
			
		||||
                             result.output + str(result.exception))
 | 
			
		||||
 | 
			
		||||
            # Turns the titles into lowercase.
 | 
			
		||||
            for base in BaseAccount.query:
 | 
			
		||||
                base.title_l10n = base.title_l10n.lower()
 | 
			
		||||
            for account in Account.query:
 | 
			
		||||
                account.title_l10n = account.title_l10n.lower()
 | 
			
		||||
                account.created_at \
 | 
			
		||||
                    = account.created_at - dt.timedelta(seconds=5)
 | 
			
		||||
                account.updated_at = account.created_at
 | 
			
		||||
 | 
			
		||||
            # Adds a custom account.
 | 
			
		||||
            custom_title = "MBK Bank"
 | 
			
		||||
            creator_pk: int = get_user_pk("editor")
 | 
			
		||||
            new_account: Account = Account(
 | 
			
		||||
                id=new_id(Account),
 | 
			
		||||
                base_code="1112",
 | 
			
		||||
                no="2",
 | 
			
		||||
                title_l10n=custom_title,
 | 
			
		||||
                is_need_offset=False,
 | 
			
		||||
                created_by_id=creator_pk,
 | 
			
		||||
                updated_by_id=creator_pk)
 | 
			
		||||
            db.session.add(new_account)
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 | 
			
		||||
            result: Result = runner.invoke(
 | 
			
		||||
                args=["accounting-titleize", "-u", "editor"])
 | 
			
		||||
            self.assertEqual(result.exit_code, 0,
 | 
			
		||||
                             result.output + str(result.exception))
 | 
			
		||||
            for base in BaseAccount.query:
 | 
			
		||||
                self.__test_title_case(base.title_l10n)
 | 
			
		||||
            for account in Account.query:
 | 
			
		||||
                if account.id != new_account.id:
 | 
			
		||||
                    self.__test_title_case(account.title_l10n)
 | 
			
		||||
                    self.assertNotEqual(account.created_at, account.updated_at)
 | 
			
		||||
                else:
 | 
			
		||||
                    self.assertEqual(account.title_l10n, custom_title)
 | 
			
		||||
 | 
			
		||||
            db.session.delete(new_account)
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user