50 Commits

Author SHA1 Message Date
621020b0f0 Advanced to version 1.3.2. 2023-04-12 18:05:13 +08:00
6ad36cfaa3 Updated the translation of the test site. 2023-04-12 18:05:13 +08:00
20b0412091 Added the sample data generation and database reset on the test site for live demonstration. 2023-04-12 18:05:13 +08:00
3ca246d3e0 Revised the strings in babel-utils.py and babel-utils-test-site.py. 2023-04-12 15:04:32 +08:00
85d1b13ccd Added the "populate" method to the BaseTestData class, and changed it so that the tests need to call the "populate" method to populate the data, so that it may return the data with populating the database in the future. 2023-04-12 12:28:34 +08:00
3bada28b8f Revised the BaseTestData class in testlib.py to add journal entries directly to the database instead of through the API, in order to allow the data to be reused, and to speed up the test. 2023-04-12 12:12:11 +08:00
8f2cef8d81 Revised the imports in the accounting.journal_entry.converters module. 2023-04-12 00:41:29 +08:00
e62316c477 Advanced to version 1.3.1. 2023-04-11 22:32:22 +08:00
24ddb0c278 Updated the translation of the test site. 2023-04-11 22:27:31 +08:00
536f3390aa Revised the home page of the test site. 2023-04-11 22:27:31 +08:00
fadd8e73b6 Revised the log in process of the test site to return to the previous page after logging in. 2023-04-11 22:27:11 +08:00
12ccf658bf Revised the documentation in README.rst and intro.rst. 2023-04-11 21:56:49 +08:00
e30d1257e5 Revised the navigation bar so that viewers do not see the menu of the unmatched offsets. 2023-04-11 07:50:10 +08:00
404b902d88 Advanced to version 1.3.0. 2023-04-11 00:08:53 +08:00
a560ff175a Updated the Sphinx documentation. 2023-04-11 00:06:17 +08:00
4be1ead6b5 Added the "accounting-init-db" console command to the database initialization of the test site, for simplicity. 2023-04-10 23:58:08 +08:00
700e4f822a Merged the "init-db" console command to the Flask application initialization in the test site, to simplify the code. 2023-04-10 23:50:16 +08:00
c21ed59dfe Replaced SQLAlchemy 1.x-style bulk_save_objects(objects) with SQLAlchemy 2.x-style execute(insert(model), data). 2023-04-10 23:38:31 +08:00
c4a8326bfc Added the "accounting-init-db" console command to replace the trivial "accounting-init-base", "accounting-init-accounts" and "accounting-init-currencies" console commands. 2023-04-10 23:38:27 +08:00
371c80f668 Removed the unused CurrencyData custom type from the "accounting.currency.commands" module. 2023-04-10 23:12:49 +08:00
40be3fb664 Replaced SQLAlchemy 1.x-style bulk_insert_mappings(model, data) with SQLAlchemy 2.x-style execute(insert(model), data). 2023-04-10 19:56:16 +08:00
1e56403b35 Advanced to version 1.2.1. 2023-04-09 21:04:18 +08:00
650c26036a Fixed the search result to allow full year/month/day specification. 2023-04-09 21:03:18 +08:00
b19a6e5ffe Advanced to version 1.2.0. 2023-04-09 12:16:20 +08:00
1224d6f83e Added the CSV_MIME constant to test_report.py to simplify the ReportTestCase test case. 2023-04-09 12:09:52 +08:00
3a8618f7c3 Fixed the csv_download function when downloading data with non-US-ASCII filenames in the "accounting.report.utils.csv_export" module. 2023-04-09 12:07:31 +08:00
5d87205659 Changed the data in the ReportTestData class to be non-US-ASCII. 2023-04-09 11:55:15 +08:00
04de4f5c5e Merged testlib_offset.py into testlib.py. 2023-04-09 11:46:55 +08:00
f8ea863b80 Moved the add_journal_entry and match_journal_entry_detail functions from testlib_journal_entry.py to testlib.py. They are used by everyone, and testlib_journal_entry.py is only for test_journal_entry.py to shorten the code in one single file. 2023-04-09 11:46:55 +08:00
5ae0d03b32 Revised the imports in testlib_journal_entry.py. 2023-04-09 11:46:55 +08:00
a9a3ad5871 Fixed the data type of the original line item ID in the forms in the OffsetTestCase test case. 2023-04-09 11:46:55 +08:00
5edc95afce Moved the TestData class from testlib_offset.py to test_offset.py, and renamed it to OffsetTestData. It is only used in test_offset.py now. 2023-04-09 11:46:55 +08:00
943ace6fc7 Added ReportTestData as the test data for the ReportTestCase test case. 2023-04-09 11:46:46 +08:00
a63bc977e9 Added the _add_simple_journal_entry method to the BaseTestData class in testlib_offset.py to simplify the code. 2023-04-09 10:50:50 +08:00
dabe6ddbca Renamed the _set_is_need_offset method to _set_need_offset in the BaseTestData class in testlib_offset.py. 2023-04-09 10:42:18 +08:00
f47e9b3150 Renamed the CurrencyData class to JournalEntryCurrencyData in testlib_offset.py, to be clear. 2023-04-09 10:42:18 +08:00
bb5383febe Removed the test data from the OptionTestCase test case. It does not need data. 2023-04-09 10:42:18 +08:00
87f9063ceb Added the BaseTestData class in testlib_offset.py to simplify the test data, and changed the TestData, DifferentTestData, and SameTestData classes to its subclasses. 2023-04-09 10:31:44 +08:00
51f0185bcf Added to test the search in the ReportTestCase test case. 2023-04-09 10:08:23 +08:00
7ca08d6cc8 Added to test the CSV download in the ReportTestCase test case. 2023-04-09 10:08:23 +08:00
c8e9e562be Fixed a URL in the test_nobody test of the ReportTestCase test case. 2023-04-09 10:08:23 +08:00
ba43bd7e90 Simplify the URL of the default reports. 2023-04-09 10:08:23 +08:00
4e550413ba Revised the styles for blueprints to specify the URL, for consistency in the base account, account, currency, and journal entry management. 2023-04-09 10:08:22 +08:00
59a3cbb472 Added the ReportTestCase test case. 2023-04-09 10:08:22 +08:00
d1b64d069e Added the test_empty_db test to the UnmatchedOffsetTestCase test case. 2023-04-09 10:08:11 +08:00
d823d3254f Fixed the date in test_unmatched_offset.py. 2023-04-09 10:07:56 +08:00
5e9a2fb0c3 Renamed test_offset_matcher.py to test_unmatched_offset.py, and the OffsetMatcherTestCase test case to UnmatchedOffsetTestCase. 2023-04-09 10:06:53 +08:00
3f2e659ba5 Added the test_nobody, test_viewer, and test_editor tests to test the permissions in the OffsetMatcherTestCase test case. 2023-04-09 10:06:33 +08:00
9f7bb6b9de Added match_uri to the tests of the OffsetMatcherTestCase test case, for readability. 2023-04-09 08:25:34 +08:00
6857164702 Added the PREFIX constant to simplify the OffsetMatcherTestCase test case. 2023-04-09 08:22:25 +08:00
49 changed files with 2499 additions and 1686 deletions

View File

@ -7,7 +7,8 @@ Description
===========
*Mia! Accounting* is an accounting module for Flask_ applications.
It implements `double-entry bookkeeping`_, and generates the following
It is designed both for mobile and desktop environments. It
implements `double-entry bookkeeping`_. It generates the following
accounting reports:
* Trial balance
@ -18,6 +19,18 @@ In addition, *Mia! Accounting* tracks offsets for unpaid payables and
receivables.
Live Demonstration and Test Site
================================
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
same code as the `test site`_ in the `source distribution`_. It is
the simplest website that works with *Mia! Accounting*. It is also
used in the automatic tests.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Installation
============
@ -27,27 +40,16 @@ Install *Mia! Accounting* with ``pip``:
pip install mia-accounting
You may also download the from the `PyPI project page`_ or the
You may also download from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_.
Test Site and Live Demonstration
================================
You may find a working example in the `test site`_ in the
`source distribution`_. It is the simplest website that works with
*Mia! Accounting*. It is used in the automatic tests. It is the same
code run for `live demonstration`_.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Prerequisites
=============
You need a running Flask application with database user login.
The primary key of the user data model must be integer.
The primary key of the user data model must be integer. You also
need at least one user.
The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_.
@ -122,24 +124,13 @@ The following is an example configuration for *Mia! Accounting*.
Database Initialization
=======================
After the configuration, you need to run
`flask_sqlalchemy.SQLAlchemy.create_all`_ to create the
database tables that *Mia! Accounting* uses.
*Mia! Accounting* adds three console commands:
* ``accounting-init-base``
* ``accounting-init-accounts``
* ``accounting-init-currencies``
After database tables are created, run
``accounting-init-base`` first, and then the other two commands.
After the configuration, run the ``accounting-init-db`` console
command to initialize the accounting database. You need to specify
the username of a user as the data creator.
::
% flask --app myapp accounting-init-base
% flask --app myapp accounting-init-accounts
% flask --app myapp accounting-init-currencies
% flask --app myapp accounting-init-db -u username
Navigation Menu
@ -201,9 +192,9 @@ Authors
.. _Flask: https://flask.palletsprojects.com
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _live demonstration: https://accounting.imacat.idv.tw
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _live demonstration: https://accounting.imacat.idv.tw
.. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases
.. _Git repository: https://github.com/imacat/mia-accounting
@ -214,6 +205,5 @@ Authors
.. _Tempus-Dominus: https://getdatepicker.com
.. _UserUtilityInterface: https://mia-accounting.readthedocs.io/en/latest/accounting.utils.html#accounting.utils.user.UserUtilityInterface
.. _init_app: https://mia-accounting.readthedocs.io/en/latest/accounting.html#accounting.init_app
.. _flask_sqlalchemy.SQLAlchemy.create_all: https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/api/#flask_sqlalchemy.SQLAlchemy.create_all
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io

View File

@ -19,6 +19,14 @@ Subpackages
Submodules
----------
accounting.commands module
--------------------------
.. automodule:: accounting.commands
:members:
:undoc-members:
:show-inheritance:
accounting.forms module
-----------------------

View File

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

View File

@ -2,7 +2,8 @@ Introduction
============
*Mia! Accounting* is an accounting module for Flask_ applications.
It implements `double-entry bookkeeping`_, and generates the following
It is designed both for mobile and desktop environments. It
implements `double-entry bookkeeping`_. It generates the following
accounting reports:
* Trial balance
@ -13,6 +14,18 @@ In addition, *Mia! Accounting* tracks offsets for unpaid payables and
receivables.
Live Demonstration and Test Site
--------------------------------
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
same code as the `test site`_ in the `source distribution`_. It is
the simplest website that works with *Mia! Accounting*. It is also
used in the automatic tests.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Installation
------------
@ -22,27 +35,16 @@ Install *Mia! Accounting* with ``pip``:
pip install mia-accounting
You may also download the from the `PyPI project page`_ or the
You may also download from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_.
Test Site and Live Demonstration
--------------------------------
You may find a working example in the `test site`_ in the
`source distribution`_. It is the simplest website that works with
*Mia! Accounting*. It is used in the automatic tests. It is the same
code run for `live demonstration`_.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Prerequisites
-------------
You need a running Flask application with database user login.
The primary key of the user data model must be integer.
The primary key of the user data model must be integer. You also
need at least one user.
The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_.
@ -67,24 +69,13 @@ See an example in :ref:`example-userutils`.
Database Initialization
-----------------------
After the configuration, you need to run
`flask_sqlalchemy.SQLAlchemy.create_all`_ to create the
database tables that *Mia! Accounting* uses.
*Mia! Accounting* adds three console commands:
* ``accounting-init-base``
* ``accounting-init-accounts``
* ``accounting-init-currencies``
After database tables are created, run
``accounting-init-base`` first, and then the other two commands.
After the configuration, run the ``accounting-init-db`` console
command to initialize the accounting database. You need to specify
the username of a user as the data creator.
::
% flask --app myapp accounting-init-base
% flask --app myapp accounting-init-accounts
% flask --app myapp accounting-init-currencies
% flask --app myapp accounting-init-db -u username
Navigation Menu
@ -120,9 +111,9 @@ Refer to the `documentation on Read the Docs`_.
.. _Flask: https://flask.palletsprojects.com
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _live demonstration: https://accounting.imacat.idv.tw
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _live demonstration: https://accounting.imacat.idv.tw
.. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases
.. _Git repository: https://github.com/imacat/mia-accounting
@ -131,6 +122,5 @@ Refer to the `documentation on Read the Docs`_.
.. _FontAwesome: https://fontawesome.com
.. _Decimal.js: https://mikemcl.github.io/decimal.js
.. _Tempus-Dominus: https://getdatepicker.com
.. _flask_sqlalchemy.SQLAlchemy.create_all: https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/api/#flask_sqlalchemy.SQLAlchemy.create_all
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io

View File

@ -17,7 +17,7 @@
[project]
name = "mia-accounting"
version = "1.1.0"
version = "1.3.2"
description = "A Flask accounting module."
readme = "README.rst"
requires-python = ">=3.11"

View File

@ -61,6 +61,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
app.cli.add_command(init_db_command)
from . import locale
locale.init_app(app, bp)

View File

@ -19,6 +19,8 @@
"""
from flask import Flask, Blueprint
from .commands import init_accounts_command
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
@ -32,6 +34,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as account_bp
bp.register_blueprint(account_bp, url_prefix="/accounts")
from .commands import init_accounts_command
app.cli.add_command(init_accounts_command)

View File

@ -17,44 +17,21 @@
"""The console commands for the account management.
"""
import os
import typing as t
from secrets import randbelow
import click
from flask.cli import with_appcontext
from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import has_user, get_user_pk
from accounting.utils.user import get_user_pk
import sqlalchemy as sa
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."""
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-accounts")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_accounts_command(username: str) -> None:
"""Initializes the accounts."""
creator_pk: int = get_user_pk(username)
@ -63,8 +40,6 @@ def init_accounts_command(username: str) -> None:
.filter(db.func.length(BaseAccount.code) == 4)\
.order_by(BaseAccount.code).all()
if len(bases) == 0:
click.echo("Please initialize the base accounts with "
"\"flask accounting-init-base\" first.")
raise click.Abort
existing: list[Account] = Account.query.all()
@ -73,7 +48,6 @@ def init_accounts_command(username: str) -> None:
bases_to_add: list[BaseAccount] = [x for x in bases
if x.code not in existing_base_code]
if len(bases_to_add) == 0:
click.echo("No more account to import.")
return
existing_id: set[int] = {x.id for x in existing}
@ -89,14 +63,24 @@ def init_accounts_command(username: str) -> None:
existing_id.add(new_id)
return new_id
data: list[AccountData] = []
data: list[dict[str, t.Any]] = []
l10n_data: list[dict[str, t.Any]] = []
for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
is_need_offset: bool = __is_need_offset(base.code)
data.append((get_new_id(), base.code, 1, base.title_l10n,
l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset))
__add_accounting_accounts(data, creator_pk)
click.echo(F"{len(data)} added. Accounting accounts initialized.")
account_id: int = get_new_id()
data.append({"id": account_id,
"base_code": base.code,
"no": 1,
"title_l10n": base.title_l10n,
"is_need_offset": __is_need_offset(base.code),
"created_by_id": creator_pk,
"updated_by_id": creator_pk})
for locale in {"zh_Hant", "zh_Hans"}:
l10n_data.append({"account_id": account_id,
"locale": locale,
"title": l10n[locale]})
db.session.execute(sa.insert(Account), data)
db.session.execute(sa.insert(AccountL10n), l10n_data)
def __is_need_offset(base_code: str) -> bool:
@ -121,29 +105,3 @@ def __is_need_offset(base_code: str) -> bool:
return True
# Only assets and liabilities need offset
return False
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
-> None:
"""Adds the accounts.
:param data: A list of (base code, number, title) tuples.
:param creator_pk: The primary key of the creator.
:return: None.
"""
accounts: list[Account] = [Account(id=x[0],
base_code=x[1],
no=x[2],
title_l10n=x[3],
is_need_offset=x[6],
created_by_id=creator_pk,
updated_by_id=creator_pk)
for x in data]
l10n: list[AccountL10n] = [AccountL10n(account_id=x[0],
locale=y[0],
title=y[1])
for x in data
for y in (("zh_Hant", x[4]), ("zh_Hans", x[5]))]
db.session.bulk_save_objects(accounts)
db.session.bulk_save_objects(l10n)
db.session.commit()

View File

@ -53,7 +53,7 @@ def list_accounts() -> str:
list=pagination.list, pagination=pagination)
@bp.get("/create", endpoint="create")
@bp.get("create", endpoint="create")
@has_permission(can_edit)
def show_add_account_form() -> str:
"""Shows the form to add an account.
@ -70,7 +70,7 @@ def show_add_account_form() -> str:
form=form)
@bp.post("/store", endpoint="store")
@bp.post("store", endpoint="store")
@has_permission(can_edit)
def add_account() -> redirect:
"""Adds an account.
@ -91,7 +91,7 @@ def add_account() -> redirect:
return redirect(inherit_next(__get_detail_uri(account)))
@bp.get("/<account:account>", endpoint="detail")
@bp.get("<account:account>", endpoint="detail")
@has_permission(can_view)
def show_account_detail(account: Account) -> str:
"""Shows the account detail.
@ -102,7 +102,7 @@ def show_account_detail(account: Account) -> str:
return render_template("accounting/account/detail.html", obj=account)
@bp.get("/<account:account>/edit", endpoint="edit")
@bp.get("<account:account>/edit", endpoint="edit")
@has_permission(can_edit)
def show_account_edit_form(account: Account) -> str:
"""Shows the form to edit an account.
@ -121,7 +121,7 @@ def show_account_edit_form(account: Account) -> str:
account=account, form=form)
@bp.post("/<account:account>/update", endpoint="update")
@bp.post("<account:account>/update", endpoint="update")
@has_permission(can_edit)
def update_account(account: Account) -> redirect:
"""Updates an account.
@ -148,7 +148,7 @@ def update_account(account: Account) -> redirect:
return redirect(inherit_next(__get_detail_uri(account)))
@bp.post("/<account:account>/delete", endpoint="delete")
@bp.post("<account:account>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_account(account: Account) -> redirect:
"""Deletes an account.
@ -167,7 +167,7 @@ def delete_account(account: Account) -> redirect:
return redirect(or_next(__get_list_uri()))
@bp.get("/bases/<baseAccount:base>", endpoint="order")
@bp.get("bases/<baseAccount:base>", endpoint="order")
@has_permission(can_view)
def show_account_order(base: BaseAccount) -> str:
"""Shows the order of the accounts under a same base account.
@ -178,7 +178,7 @@ def show_account_order(base: BaseAccount) -> str:
return render_template("accounting/account/order.html", base=base)
@bp.post("/bases/<baseAccount:base>", endpoint="sort")
@bp.post("bases/<baseAccount:base>", endpoint="sort")
@has_permission(can_edit)
def sort_accounts(base: BaseAccount) -> redirect:
"""Reorders the accounts under a base account.

View File

@ -19,6 +19,8 @@
"""
from flask import Flask, Blueprint
from .commands import init_base_accounts_command
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
@ -32,6 +34,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as base_account_bp
bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")
from .commands import init_base_accounts_command
app.cli.add_command(init_base_accounts_command)

View File

@ -19,21 +19,17 @@
"""
import csv
import click
from flask.cli import with_appcontext
import sqlalchemy as sa
from accounting import data_dir
from accounting import db
from accounting.models import BaseAccount, BaseAccountL10n
@click.command("accounting-init-base")
@with_appcontext
def init_base_accounts_command() -> None:
"""Initializes the base accounts."""
if BaseAccount.query.first() is not None:
click.echo("Base accounts already exist.")
raise click.Abort
return
with open(data_dir / "base_accounts.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
@ -45,7 +41,5 @@ def init_base_accounts_command() -> None:
"locale": y,
"title": x[f"l10n-{y}"]}
for x in data for y in locales]
db.session.bulk_insert_mappings(BaseAccount, account_data)
db.session.bulk_insert_mappings(BaseAccountL10n, l10n_data)
db.session.commit()
click.echo("Base accounts initialized.")
db.session.execute(sa.insert(BaseAccount), account_data)
db.session.execute(sa.insert(BaseAccountL10n), l10n_data)

View File

@ -41,7 +41,7 @@ def list_accounts() -> str:
list=pagination.list, pagination=pagination)
@bp.get("/<baseAccount:account>", endpoint="detail")
@bp.get("<baseAccount:account>", endpoint="detail")
@has_permission(can_view)
def show_account_detail(account: BaseAccount) -> str:
"""Shows the account detail.

View File

@ -0,0 +1,62 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The console commands.
"""
import os
import click
from flask.cli import with_appcontext
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
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-db")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_db_command(username: str) -> None:
"""Initializes the accounting database."""
db.create_all()
init_base_accounts_command()
init_accounts_command(username)
init_currencies_command(username)
db.session.commit()
click.echo("Accounting database initialized.")

View File

@ -19,6 +19,8 @@
"""
from flask import Flask, Blueprint
from .commands import init_currencies_command
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
@ -33,6 +35,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as currency_bp, api_bp as currency_api_bp
bp.register_blueprint(currency_bp, url_prefix="/currencies")
bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
from .commands import init_currencies_command
app.cli.add_command(init_currencies_command)

View File

@ -18,42 +18,15 @@
"""
import csv
import os
import typing as t
import click
from flask.cli import with_appcontext
import sqlalchemy as sa
from accounting import db, data_dir
from accounting.models import Currency, CurrencyL10n
from accounting.utils.user import has_user, get_user_pk
CurrencyData = tuple[str, str, str, str]
from accounting.utils.user import get_user_pk
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-currencies")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_currencies_command(username: str) -> None:
"""Initializes the currencies."""
existing_codes: set[str] = {x.code for x in Currency.query.all()}
@ -63,7 +36,6 @@ def init_currencies_command(username: str) -> None:
to_add: list[dict[str, str]] = [x for x in data
if x["code"] not in existing_codes]
if len(to_add) == 0:
click.echo("No more currency to add.")
return
creator_pk: int = get_user_pk(username)
@ -77,8 +49,5 @@ def init_currencies_command(username: str) -> None:
"locale": y,
"name": x[f"l10n-{y}"]}
for x in to_add for y in locales]
db.session.bulk_insert_mappings(Currency, currency_data)
db.session.bulk_insert_mappings(CurrencyL10n, l10n_data)
db.session.commit()
click.echo(F"{len(to_add)} added. Currencies initialized.")
db.session.execute(sa.insert(Currency), currency_data)
db.session.execute(sa.insert(CurrencyL10n), l10n_data)

View File

@ -55,7 +55,7 @@ def list_currencies() -> str:
list=pagination.list, pagination=pagination)
@bp.get("/create", endpoint="create")
@bp.get("create", endpoint="create")
@has_permission(can_edit)
def show_add_currency_form() -> str:
"""Shows the form to add a currency.
@ -72,7 +72,7 @@ def show_add_currency_form() -> str:
form=form)
@bp.post("/store", endpoint="store")
@bp.post("store", endpoint="store")
@has_permission(can_edit)
def add_currency() -> redirect:
"""Adds a currency.
@ -93,7 +93,7 @@ def add_currency() -> redirect:
return redirect(inherit_next(__get_detail_uri(currency)))
@bp.get("/<currency:currency>", endpoint="detail")
@bp.get("<currency:currency>", endpoint="detail")
@has_permission(can_view)
def show_currency_detail(currency: Currency) -> str:
"""Shows the currency detail.
@ -104,7 +104,7 @@ def show_currency_detail(currency: Currency) -> str:
return render_template("accounting/currency/detail.html", obj=currency)
@bp.get("/<currency:currency>/edit", endpoint="edit")
@bp.get("<currency:currency>/edit", endpoint="edit")
@has_permission(can_edit)
def show_currency_edit_form(currency: Currency) -> str:
"""Shows the form to edit a currency.
@ -123,7 +123,7 @@ def show_currency_edit_form(currency: Currency) -> str:
currency=currency, form=form)
@bp.post("/<currency:currency>/update", endpoint="update")
@bp.post("<currency:currency>/update", endpoint="update")
@has_permission(can_edit)
def update_currency(currency: Currency) -> redirect:
"""Updates a currency.
@ -151,7 +151,7 @@ def update_currency(currency: Currency) -> redirect:
return redirect(inherit_next(__get_detail_uri(currency)))
@bp.post("/<currency:currency>/delete", endpoint="delete")
@bp.post("<currency:currency>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_currency(currency: Currency) -> redirect:
"""Deletes a currency.
@ -169,7 +169,7 @@ def delete_currency(currency: Currency) -> redirect:
return redirect(or_next(url_for("accounting.currency.list")))
@api_bp.get("/exists-code", endpoint="exists")
@api_bp.get("exists-code", endpoint="exists")
@has_permission(can_edit)
def exists_code() -> dict[str, bool]:
"""Validates whether a currency code exists.

View File

@ -20,11 +20,10 @@
from datetime import date
from flask import abort
from sqlalchemy.orm import selectinload
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import JournalEntry, JournalEntryLineItem
from accounting.models import JournalEntry
from accounting.utils.journal_entry_types import JournalEntryType

View File

@ -49,7 +49,7 @@ bp.add_app_template_filter(format_amount_input,
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
@bp.get("/create/<journalEntryType:journal_entry_type>", endpoint="create")
@bp.get("create/<journalEntryType:journal_entry_type>", endpoint="create")
@has_permission(can_edit)
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
"""Shows the form to add a journal entry.
@ -71,7 +71,7 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
return journal_entry_op.render_create_template(form)
@bp.post("/store/<journalEntryType:journal_entry_type>", endpoint="store")
@bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store")
@has_permission(can_edit)
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
"""Adds a journal entry.
@ -98,7 +98,7 @@ def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
return redirect(inherit_next(__get_detail_uri(journal_entry)))
@bp.get("/<journalEntry:journal_entry>", endpoint="detail")
@bp.get("<journalEntry:journal_entry>", endpoint="detail")
@has_permission(can_view)
def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
"""Shows the journal entry detail.
@ -111,7 +111,7 @@ def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
return journal_entry_op.render_detail_template(journal_entry)
@bp.get("/<journalEntry:journal_entry>/edit", endpoint="edit")
@bp.get("<journalEntry:journal_entry>/edit", endpoint="edit")
@has_permission(can_edit)
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
"""Shows the form to edit a journal entry.
@ -133,7 +133,7 @@ def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
return journal_entry_op.render_edit_template(journal_entry, form)
@bp.post("/<journalEntry:journal_entry>/update", endpoint="update")
@bp.post("<journalEntry:journal_entry>/update", endpoint="update")
@has_permission(can_edit)
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
"""Updates a journal entry.
@ -166,7 +166,7 @@ def update_journal_entry(journal_entry: JournalEntry) -> redirect:
return redirect(inherit_next(__get_detail_uri(journal_entry)))
@bp.post("/<journalEntry:journal_entry>/delete", endpoint="delete")
@bp.post("<journalEntry:journal_entry>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
"""Deletes a journal entry.
@ -186,7 +186,7 @@ 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:journal_entry_date>", endpoint="order")
@has_permission(can_view)
def show_journal_entry_order(journal_entry_date: date) -> str:
"""Shows the order of the journal entries in a same date.
@ -201,7 +201,7 @@ def show_journal_entry_order(journal_entry_date: date) -> str:
date=journal_entry_date, list=journal_entries)
@bp.post("/dates/<date:journal_entry_date>", endpoint="sort")
@bp.post("dates/<date:journal_entry_date>", endpoint="sort")
@has_permission(can_edit)
def sort_journal_entries(journal_entry_date: date) -> redirect:
"""Reorders the journal entries in a date.

View File

@ -151,6 +151,17 @@ class LineItemCollector:
== journal_entry_date.day))
except ValueError:
pass
try:
journal_entry_date = 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))
except ValueError:
pass
return sa.select(JournalEntry.id).filter(sa.or_(*conditions))

View File

@ -22,6 +22,7 @@ from abc import ABC, abstractmethod
from datetime import timedelta, date
from decimal import Decimal
from io import StringIO
from urllib.parse import quote
from flask import Response
@ -53,7 +54,7 @@ def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
fp.seek(0)
response: Response = Response(fp.read(), mimetype="text/csv")
response.headers["Content-Disposition"] \
= f"attachment; filename={filename}"
= f"attachment; filename={quote(filename)}"
return response

View File

@ -47,9 +47,10 @@ def ledger_url(currency: Currency, account: Account, period: Period) \
:param period: The period.
:return: The URL of the ledger.
"""
if period.is_default:
return url_for("accounting-report.ledger-default",
currency=currency, account=account)
if currency.code == default_currency_code() \
and account.code == Account.CASH_CODE \
and period.is_default:
return url_for("accounting-report.ledger-default")
return url_for("accounting-report.ledger",
currency=currency, account=account,
period=period)
@ -68,9 +69,6 @@ def income_expenses_url(currency: Currency, account: CurrentAccount,
and account.code == options.default_ie_account_code \
and period.is_default:
return url_for("accounting-report.default")
if period.is_default:
return url_for("accounting-report.income-expenses-default",
currency=currency, account=account)
return url_for("accounting-report.income-expenses",
currency=currency, account=account,
period=period)
@ -83,9 +81,8 @@ def trial_balance_url(currency: Currency, period: Period) -> str:
:param period: The period.
:return: The URL of the trial balance.
"""
if period.is_default:
return url_for("accounting-report.trial-balance-default",
currency=currency)
if currency.code == default_currency_code() and period.is_default:
return url_for("accounting-report.trial-balance-default")
return url_for("accounting-report.trial-balance",
currency=currency, period=period)
@ -97,9 +94,8 @@ def income_statement_url(currency: Currency, period: Period) -> str:
:param period: The period.
:return: The URL of the income statement.
"""
if period.is_default:
return url_for("accounting-report.income-statement-default",
currency=currency)
if currency.code == default_currency_code() and period.is_default:
return url_for("accounting-report.income-statement-default")
return url_for("accounting-report.income-statement",
currency=currency, period=period)
@ -111,9 +107,8 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
:param period: The period.
:return: The URL of the balance sheet.
"""
if period.is_default:
return url_for("accounting-report.balance-sheet-default",
currency=currency)
if currency.code == default_currency_code() and period.is_default:
return url_for("accounting-report.balance-sheet-default")
return url_for("accounting-report.balance-sheet",
currency=currency, period=period)

View File

@ -44,10 +44,7 @@ def get_default_report() -> str | Response:
:return: The income and expenses log in the default period.
"""
return __get_income_expenses(
db.session.get(Currency, default_currency_code()),
options.default_ie_account,
get_period())
return get_default_income_expenses()
@bp.get("journal", endpoint="journal-default")
@ -83,17 +80,15 @@ def __get_journal(period: Period) -> str | Response:
return report.html()
@bp.get("ledger/<currency:currency>/<account:account>",
endpoint="ledger-default")
@bp.get("ledger", endpoint="ledger-default")
@has_permission(can_view)
def get_default_ledger(currency: Currency, account: Account) -> str | Response:
"""Returns the ledger in the default period.
def get_default_ledger() -> str | Response:
"""Returns the ledger in the default currency, cash, and default period.
:param currency: The currency.
:param account: The account.
:return: The ledger in the default period.
:return: The ledger in the default currency, cash, and default period.
"""
return __get_ledger(currency, account, get_period())
return __get_ledger(db.session.get(Currency, default_currency_code()),
Account.cash(), get_period())
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
@ -126,18 +121,17 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
return report.html()
@bp.get("income-expenses/<currency:currency>/<currentAccount:account>",
endpoint="income-expenses-default")
@bp.get("income-expenses", endpoint="income-expenses-default")
@has_permission(can_view)
def get_default_income_expenses(currency: Currency, account: CurrentAccount) \
-> str | Response:
def get_default_income_expenses() -> str | Response:
"""Returns the income and expenses log in the default period.
:param currency: The currency.
:param account: The account.
:return: The income and expenses log in the default period.
"""
return __get_income_expenses(currency, account, get_period())
return __get_income_expenses(
db.session.get(Currency, default_currency_code()),
options.default_ie_account,
get_period())
@bp.get("income-expenses/<currency:currency>/<currentAccount:account>/"
@ -170,16 +164,15 @@ def __get_income_expenses(currency: Currency, account: CurrentAccount,
return report.html()
@bp.get("trial-balance/<currency:currency>",
endpoint="trial-balance-default")
@bp.get("trial-balance", endpoint="trial-balance-default")
@has_permission(can_view)
def get_default_trial_balance(currency: Currency) -> str | Response:
def get_default_trial_balance() -> str | Response:
"""Returns the trial balance in the default period.
:param currency: The currency.
:return: The trial balance in the default period.
"""
return __get_trial_balance(currency, get_period())
return __get_trial_balance(
db.session.get(Currency, default_currency_code()), get_period())
@bp.get("trial-balance/<currency:currency>/<period:period>",
@ -208,16 +201,15 @@ def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
return report.html()
@bp.get("income-statement/<currency:currency>",
endpoint="income-statement-default")
@bp.get("income-statement", endpoint="income-statement-default")
@has_permission(can_view)
def get_default_income_statement(currency: Currency) -> str | Response:
def get_default_income_statement() -> str | Response:
"""Returns the income statement in the default period.
:param currency: The currency.
:return: The income statement in the default period.
"""
return __get_income_statement(currency, get_period())
return __get_income_statement(
db.session.get(Currency, default_currency_code()), get_period())
@bp.get("income-statement/<currency:currency>/<period:period>",
@ -247,16 +239,15 @@ def __get_income_statement(currency: Currency, period: Period) \
return report.html()
@bp.get("balance-sheet/<currency:currency>",
endpoint="balance-sheet-default")
@bp.get("balance-sheet", endpoint="balance-sheet-default")
@has_permission(can_view)
def get_default_balance_sheet(currency: Currency) -> str | Response:
def get_default_balance_sheet() -> str | Response:
"""Returns the balance sheet in the default period.
:param currency: The currency.
:return: The balance sheet in the default period.
"""
return __get_balance_sheet(currency, get_period())
return __get_balance_sheet(
db.session.get(Currency, default_currency_code()), get_period())
@bp.get("balance-sheet/<currency:currency>/<period:period>",

View File

@ -51,12 +51,14 @@ First written: 2023/1/26
{{ A_("Currencies") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.unmatched-offset.") %} active {% endif %}" href="{{ url_for("accounting.unmatched-offset.dashboard") }}">
<i class="fa-solid fa-link-slash"></i>
{{ A_("Unmatched Offsets") }}
</a>
</li>
{% if accounting_can_edit() %}
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.unmatched-offset.") %} active {% endif %}" href="{{ url_for("accounting.unmatched-offset.dashboard") }}">
<i class="fa-solid fa-link-slash"></i>
{{ A_("Unmatched Offsets") }}
</a>
</li>
{% endif %}
{% if accounting_can_admin() %}
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.option.") %} active {% endif %}" href="{{ url_for("accounting.option.detail") }}">

View File

@ -129,5 +129,5 @@ def __update_file_rev_date(file: Path) -> None:
main.add_command(babel_extract)
main.add_command(babel_compile)
if __name__ == '__main__':
if __name__ == "__main__":
main()

View File

@ -129,5 +129,5 @@ def __update_file_rev_date(file: Path) -> None:
main.add_command(babel_extract)
main.add_command(babel_compile)
if __name__ == '__main__':
if __name__ == "__main__":
main()

306
tests/make-sample.py Executable file
View File

@ -0,0 +1,306 @@
#! env python3
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/9
# 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 sample data generation.
"""
from datetime import date, timedelta
import click
from testlib import Accounts, create_test_app, JournalEntryLineItemData, \
JournalEntryCurrencyData, JournalEntryData, \
BaseTestData
@click.command()
@click.argument("file")
def main(file) -> None:
"""Creates the sample data and output to a file."""
data: SampleData = SampleData(create_test_app(), "editor")
with open(file, "wt") as fp:
fp.write(data.json())
class SampleData(BaseTestData):
"""The sample data."""
def _init_data(self) -> None:
self.__add_recurring()
self.__add_offsets()
self.__add_meals()
def __add_recurring(self) -> None:
"""Adds the recurring data.
:return: None.
"""
self.__add_usd_recurring()
self.__add_twd_recurring()
def __add_usd_recurring(self) -> None:
"""Adds the recurring data in USD.
:return: None.
"""
today: date = date.today()
days: int
year: int
month: int
# Recurring in USD
j_date: date = date(today.year - 5, today.month, today.day)
j_date = j_date + timedelta(days=(4 - j_date.weekday()))
days = (today - j_date).days
while True:
if days < 0:
break
self.__add_journal_entry(
days, "USD", "2600",
Accounts.BANK, "Transfer", Accounts.SERVICE, "Payroll")
days = days - 1
if days < 0:
break
self.__add_journal_entry(
days, "USD", "1200",
Accounts.CASH, None, Accounts.BANK, "Withdraw")
days = days - 13
year = today.year - 5
month = today.month
while True:
month = month + 1
if month > 12:
year = year + 1
month = 1
days = (today - date(year, month, 1)).days
if days < 0:
break
self.__add_journal_entry(
days, "USD", "1800",
Accounts.RENT_EXPENSE, "Rent", Accounts.BANK, "Transfer")
def __add_twd_recurring(self) -> None:
"""Adds the recurring data in TWD.
:return: None.
"""
today: date = date.today()
year: int = today.year - 5
month: int = today.month
while True:
days: int = (today - date(year, month, 5)).days
if days < 0:
break
self.__add_journal_entry(
days, "TWD", "50000",
Accounts.BANK, "薪資轉帳", Accounts.SERVICE, "薪水")
days = days - 1
if days < 0:
break
self.__add_journal_entry(
days, "TWD", "25000",
Accounts.CASH, None, Accounts.BANK, "提款")
days = days - 4
if days < 0:
break
self.__add_journal_entry(
days, "TWD", "18000",
Accounts.RENT_EXPENSE, "房租", Accounts.BANK, "轉帳")
month = month + 1
if month > 12:
year = year + 1
month = 1
def __add_offsets(self) -> None:
"""Adds the offset data.
:return: None.
"""
days: int
year: int
month: int
description: str
line_item_or: JournalEntryLineItemData
line_item_of: JournalEntryLineItemData
# Full offset and unmatched in USD
description = "Speaking—Institute"
line_item_or = JournalEntryLineItemData(
Accounts.RECEIVABLE, description, "120")
self._add_journal_entry(JournalEntryData(
40, [JournalEntryCurrencyData(
"USD", [line_item_or], [JournalEntryLineItemData(
Accounts.SERVICE, description, "120")])]))
line_item_of = JournalEntryLineItemData(
Accounts.RECEIVABLE, description, "120",
original_line_item=line_item_or)
self._add_journal_entry(JournalEntryData(
5, [JournalEntryCurrencyData(
"USD", [JournalEntryLineItemData(
Accounts.BANK, description, "120")],
[line_item_of])]))
self.__add_journal_entry(
30, "USD", "120",
Accounts.BANK, description, Accounts.SERVICE, description)
# Partial offset in USD
line_item_or = JournalEntryLineItemData(
Accounts.PAYABLE, "Computer", "1600")
self._add_journal_entry(JournalEntryData(
60, [JournalEntryCurrencyData(
"USD", [JournalEntryLineItemData(
Accounts.MACHINERY, "Computer", "1600")],
[line_item_or])]))
line_item_of = JournalEntryLineItemData(
Accounts.PAYABLE, "Computer", "800",
original_line_item=line_item_or)
self._add_journal_entry(JournalEntryData(
35, [JournalEntryCurrencyData(
"USD", [line_item_of], [JournalEntryLineItemData(
Accounts.BANK, "Computer", "800")])]))
line_item_of = JournalEntryLineItemData(
Accounts.PAYABLE, "Computer", "400",
original_line_item=line_item_or)
self._add_journal_entry(JournalEntryData(
10, [JournalEntryCurrencyData(
"USD", [line_item_of], [JournalEntryLineItemData(
Accounts.CASH, "Computer", "400")])]))
# Full offset and unmatched in TWD
description = "演講費—母校"
line_item_or = JournalEntryLineItemData(
Accounts.RECEIVABLE, description, "3000")
self._add_journal_entry(JournalEntryData(
45, [JournalEntryCurrencyData(
"TWD", [line_item_or], [JournalEntryLineItemData(
Accounts.SERVICE, description, "3000")])]))
line_item_of = JournalEntryLineItemData(
Accounts.RECEIVABLE, description, "3000",
original_line_item=line_item_or)
self._add_journal_entry(JournalEntryData(
6, [JournalEntryCurrencyData(
"TWD", [JournalEntryLineItemData(
Accounts.BANK, description, "3000")],
[line_item_of])]))
self.__add_journal_entry(
25, "TWD", "3000",
Accounts.BANK, description, Accounts.SERVICE, description)
# Partial offset in TWD
line_item_or = JournalEntryLineItemData(
Accounts.PAYABLE, "手機", "30000")
self._add_journal_entry(JournalEntryData(
55, [JournalEntryCurrencyData(
"TWD", [JournalEntryLineItemData(
Accounts.MACHINERY, "手機", "30000")],
[line_item_or])]))
line_item_of = JournalEntryLineItemData(
Accounts.PAYABLE, "手機", "16000",
original_line_item=line_item_or)
self._add_journal_entry(JournalEntryData(
27, [JournalEntryCurrencyData(
"TWD", [line_item_of], [JournalEntryLineItemData(
Accounts.BANK, "手機", "16000")])]))
line_item_of = JournalEntryLineItemData(
Accounts.PAYABLE, "手機", "6000",
original_line_item=line_item_or)
self._add_journal_entry(JournalEntryData(
8, [JournalEntryCurrencyData(
"TWD", [line_item_of], [JournalEntryLineItemData(
Accounts.CASH, "手機", "6000")])]))
def __add_meals(self) -> None:
"""Adds the meal data.
:return: None.
"""
days = 60
while days >= 0:
# Meals in USD
if days % 4 == 2:
self.__add_journal_entry(
days, "USD", "2.9",
Accounts.MEAL, "Lunch—Coffee", Accounts.CASH, None)
else:
self.__add_journal_entry(
days, "USD", "3.9",
Accounts.MEAL, "Lunch—Coffee", Accounts.CASH, None)
if days % 15 == 3:
self.__add_journal_entry(
days, "USD", "5.45",
Accounts.MEAL, "Dinner—Pizza",
Accounts.PAYABLE, "Dinner—Pizza")
else:
self.__add_journal_entry(
days, "USD", "5.9",
Accounts.MEAL, "Dinner—Pasta", Accounts.CASH, None)
# Meals in TWD
if days % 5 == 3:
self.__add_journal_entry(
days, "TWD", "125",
Accounts.MEAL, "午餐—鄰家咖啡", Accounts.CASH, None)
else:
self.__add_journal_entry(
days, "TWD", "80",
Accounts.MEAL, "午餐—便當", Accounts.CASH, None)
if days % 15 == 3:
self.__add_journal_entry(
days, "TWD", "320",
Accounts.MEAL, "晚餐—牛排", Accounts.PAYABLE, "晚餐—牛排")
else:
self.__add_journal_entry(
days, "TWD", "100",
Accounts.MEAL, "晚餐—自助餐", Accounts.CASH, None)
days = days - 1
def __add_journal_entry(
self, days: int, currency: str, amount: str,
debit_account: str, debit_description: str | None,
credit_account: str, credit_description: str | None) -> None:
"""Adds a simple journal entry.
:param days: The number of days before today.
:param currency: The currency code.
:param amount: The amount.
:param debit_account: The debit account code.
:param debit_description: The debit description.
:param credit_account: The credit account code.
:param credit_description: The credit description.
:return: None.
"""
self._add_journal_entry(JournalEntryData(
days,
[JournalEntryCurrencyData(
currency,
[JournalEntryLineItemData(
debit_account, debit_description, amount)],
[JournalEntryLineItemData(
credit_account, credit_description, amount)])]))
if __name__ == "__main__":
main()

View File

@ -21,14 +21,11 @@ import unittest
from datetime import timedelta, date
import httpx
import sqlalchemy as sa
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db
from testlib import NEXT_URI, create_test_app, get_client, set_locale
from testlib_journal_entry import add_journal_entry
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
add_journal_entry
class AccountData:
@ -65,59 +62,6 @@ PREFIX: str = "/accounting/accounts"
"""The URL prefix for the account management."""
class AccountCommandTestCase(unittest.TestCase):
"""The account console command test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Account, AccountL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
AccountL10n.query.delete()
Account.query.delete()
db.session.commit()
def test_init(self) -> None:
"""Tests the "accounting-init-account" console command.
:return: None.
"""
from accounting.models import BaseAccount, Account, AccountL10n
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
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()
l10n: list[AccountL10n] = AccountL10n.query.all()
self.assertEqual({x.code for x in bases},
{x.base_code for x in accounts})
self.assertEqual(len(accounts), len(bases))
self.assertEqual(len(l10n), len(bases) * 2)
base_dict: dict[str, BaseAccount] = {x.code: x for x in bases}
for account in accounts:
base: BaseAccount = base_dict[account.base_code]
self.assertEqual(account.no, 1)
self.assertEqual(account.title_l10n, base.title_l10n)
self.assertEqual({x.locale: x.title for x in account.l10n},
{x.locale: x.title for x in base.l10n})
class AccountTestCase(unittest.TestCase):
"""The account test case."""
@ -129,15 +73,8 @@ class AccountTestCase(unittest.TestCase):
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Account, AccountL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
from accounting.models import Account, AccountL10n
AccountL10n.query.delete()
Account.query.delete()
db.session.commit()
@ -652,14 +589,6 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
response = self.client.post("/accounting/currencies/store",
data={"csrf_token": self.csrf_token,
"code": "USD",
"name": "US Dollars"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/currencies/USD")
add_journal_entry(self.client,
form={"csrf_token": self.csrf_token,
"next": NEXT_URI,

View File

@ -17,14 +17,10 @@
"""The test for the base account management.
"""
import csv
import typing as t
import unittest
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from testlib import create_test_app, get_client
@ -34,59 +30,6 @@ DETAIL_URI: str = "/accounting/base-accounts/1111"
"""The detail URI."""
class BaseAccountCommandTestCase(unittest.TestCase):
"""The base account console command test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
from accounting.models import BaseAccount, BaseAccountL10n
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
BaseAccountL10n.query.delete()
BaseAccount.query.delete()
def test_init(self) -> None:
"""Tests the "accounting-init-base" console command.
:return: None.
"""
from accounting import data_dir
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)}
runner: FlaskCliRunner = self.app.test_cli_runner()
result: Result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
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"])
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:
self.assertIn(locale, data[account.code]["l10n"])
self.assertEqual(l10n[locale],
data[account.code]["l10n"][locale])
class BaseAccountTestCase(unittest.TestCase):
"""The base account test case."""
@ -96,17 +39,8 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import BaseAccount
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
def test_nobody(self) -> None:
"""Test the permission as nobody.

157
tests/test_commands.py Normal file
View File

@ -0,0 +1,157 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The test for the console commands.
"""
import csv
import typing as t
import unittest
import sqlalchemy as sa
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from sqlalchemy.sql.ddl import DropTable
from test_site import db
from testlib import create_test_app
class ConsoleCommandTestCase(unittest.TestCase):
"""The console command test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
with self.app.app_context():
# Drop every accounting table, to see if accounting-init recreates
# them correctly.
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)
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():
result: Result = runner.invoke(
args=["accounting-init-db", "-u", "editor"])
self.assertEqual(result.exit_code, 0,
result.output + str(result.exception))
self.__test_base_account_data()
self.__test_account_data()
self.__test_currency_data()
def __test_base_account_data(self) -> None:
"""Tests the base account data.
:return: None.
"""
from accounting import data_dir
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)}
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"])
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:
self.assertIn(locale, data[account.code]["l10n"])
self.assertEqual(l10n[locale],
data[account.code]["l10n"][locale])
def __test_account_data(self) -> None:
"""Tests the account data.
:return: None.
"""
from accounting.models import BaseAccount, Account, AccountL10n
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()
l10n: list[AccountL10n] = AccountL10n.query.all()
self.assertEqual({x.code for x in bases},
{x.base_code for x in accounts})
self.assertEqual(len(accounts), len(bases))
self.assertEqual(len(l10n), len(bases) * 2)
base_dict: dict[str, BaseAccount] = {x.code: x for x in bases}
for account in accounts:
base: BaseAccount = base_dict[account.base_code]
self.assertEqual(account.no, 1)
self.assertEqual(account.title_l10n, base.title_l10n)
self.assertEqual({x.locale: x.title for x in account.l10n},
{x.locale: x.title for x in base.l10n})
def __test_currency_data(self) -> None:
"""Tests the currency data.
:return: None.
"""
from accounting import data_dir
from accounting.models import Currency
with open(data_dir / "currencies.csv") as fp:
data: dict[dict[str, t.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():
currencies: list[Currency] = Currency.query.all()
self.assertEqual(len(currencies), len(data))
for currency in currencies:
self.assertIn(currency.code, data)
self.assertEqual(currency.name_l10n, data[currency.code]["name"])
l10n: dict[str, str] = {x.locale: x.name for x in currency.l10n}
self.assertEqual(len(l10n), len(data[currency.code]["l10n"]))
for locale in l10n:
self.assertIn(locale, data[currency.code]["l10n"])
self.assertEqual(l10n[locale],
data[currency.code]["l10n"][locale])

View File

@ -17,19 +17,15 @@
"""The test for the currency management.
"""
import csv
import typing as t
import unittest
from datetime import timedelta, date
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db
from testlib import NEXT_URI, create_test_app, get_client, set_locale
from testlib_journal_entry import add_journal_entry
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
add_journal_entry
class CurrencyData:
@ -59,62 +55,6 @@ PREFIX: str = "/accounting/currencies"
"""The URL prefix for the currency management."""
class CurrencyCommandTestCase(unittest.TestCase):
"""The account console command test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import Currency, CurrencyL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
CurrencyL10n.query.delete()
Currency.query.delete()
db.session.commit()
def test_init(self) -> None:
"""Tests the "accounting-init-currencies" console command.
:return: None.
"""
from accounting import data_dir
from accounting.models import Currency
with open(data_dir / "currencies.csv") as fp:
data: dict[dict[str, t.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)}
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(
args=["accounting-init-currencies", "-u", "editor"])
self.assertEqual(result.exit_code, 0)
currencies: list[Currency] = Currency.query.all()
self.assertEqual(len(currencies), len(data))
for currency in currencies:
self.assertIn(currency.code, data)
self.assertEqual(currency.name_l10n, data[currency.code]["name"])
l10n: dict[str, str] = {x.locale: x.name for x in currency.l10n}
self.assertEqual(len(l10n), len(data[currency.code]["l10n"]))
for locale in l10n:
self.assertIn(locale, data[currency.code]["l10n"])
self.assertEqual(l10n[locale],
data[currency.code]["l10n"][locale])
class CurrencyTestCase(unittest.TestCase):
"""The currency test case."""
@ -126,12 +66,8 @@ class CurrencyTestCase(unittest.TestCase):
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import Currency, CurrencyL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
CurrencyL10n.query.delete()
Currency.query.delete()
db.session.commit()
@ -588,21 +524,6 @@ class CurrencyTestCase(unittest.TestCase):
list_uri: str = PREFIX
response: httpx.Response
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Cash"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1111-001")
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": JPY.code,

View File

@ -20,12 +20,10 @@
import unittest
from datetime import date
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from testlib import NEXT_URI, Accounts, create_test_app, get_client
from testlib_journal_entry import add_journal_entry
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
add_journal_entry
class DescriptionEditorTestCase(unittest.TestCase):
@ -39,22 +37,8 @@ class DescriptionEditorTestCase(unittest.TestCase):
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()

View File

@ -22,16 +22,15 @@ from datetime import date, timedelta
from decimal import Decimal
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db
from testlib import NEXT_URI, Accounts, create_test_app, get_client
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
add_journal_entry, match_journal_entry_detail
from testlib_journal_entry import NON_EMPTY_NOTE, EMPTY_NOTE, \
get_add_form, get_unchanged_update_form, get_update_form, \
match_journal_entry_detail, set_negative_amount, \
remove_debit_in_a_currency, remove_credit_in_a_currency, add_journal_entry
set_negative_amount, remove_debit_in_a_currency, \
remove_credit_in_a_currency
PREFIX: str = "/accounting/journal-entries"
"""The URL prefix for the journal entry management."""
@ -50,22 +49,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
@ -669,22 +654,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
@ -1264,22 +1235,9 @@ class TransferJournalEntryTestCase(unittest.TestCase):
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, JournalEntry, \
from accounting.models import JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
@ -2138,22 +2096,8 @@ class JournalEntryReorderTestCase(unittest.TestCase):
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()

View File

@ -17,19 +17,18 @@
"""The test for the offset.
"""
from __future__ import annotations
import unittest
from decimal import Decimal
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db
from testlib import Accounts, create_test_app, get_client
from testlib_journal_entry import match_journal_entry_detail
from testlib_offset import TestData, JournalEntryLineItemData, \
JournalEntryData, CurrencyData
from testlib import Accounts, create_test_app, get_client, \
match_journal_entry_detail, JournalEntryLineItemData, \
JournalEntryCurrencyData, JournalEntryData, BaseTestData
PREFIX: str = "/accounting/journal-entries"
"""The URL prefix for the journal entry management."""
@ -46,27 +45,14 @@ class OffsetTestCase(unittest.TestCase):
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
self.data: TestData = TestData(self.app, self.client, self.csrf_token)
self.data: OffsetTestData = OffsetTestData(self.app, "editor")
self.data.populate()
def test_add_receivable_offset(self) -> None:
"""Tests to add the receivable offset.
@ -81,7 +67,7 @@ class OffsetTestCase(unittest.TestCase):
response: httpx.Response
journal_entry_data: JournalEntryData = JournalEntryData(
self.data.l_r_or3d.journal_entry.days, [CurrencyData(
self.data.l_r_or3d.journal_entry.days, [JournalEntryCurrencyData(
"USD",
[],
[JournalEntryLineItemData(
@ -107,7 +93,7 @@ class OffsetTestCase(unittest.TestCase):
# The same debit or credit
form = journal_entry_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_line_item_id"] \
= self.data.l_p_or1c.id
= str(self.data.l_p_or1c.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
form["currency-1-credit-1-amount"] = "100"
response = self.client.post(store_uri, data=form)
@ -131,7 +117,7 @@ class OffsetTestCase(unittest.TestCase):
# The original line item is also an offset
form = journal_entry_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_line_item_id"] \
= self.data.l_p_of1d.id
= str(self.data.l_p_of1d.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
@ -217,7 +203,7 @@ class OffsetTestCase(unittest.TestCase):
# The same debit or credit
form = journal_entry_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_line_item_id"] \
= self.data.l_p_or1c.id
= str(self.data.l_p_or1c.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
form["currency-1-debit-1-amount"] = "100"
form["currency-1-credit-1-amount"] = "100"
@ -242,7 +228,7 @@ class OffsetTestCase(unittest.TestCase):
# The original line item is also an offset
form = journal_entry_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_line_item_id"] \
= self.data.l_p_of1d.id
= str(self.data.l_p_of1d.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
@ -405,7 +391,7 @@ class OffsetTestCase(unittest.TestCase):
response: httpx.Response
journal_entry_data: JournalEntryData = JournalEntryData(
self.data.l_p_or3c.journal_entry.days, [CurrencyData(
self.data.l_p_or3c.journal_entry.days, [JournalEntryCurrencyData(
"USD",
[JournalEntryLineItemData(
Accounts.PAYABLE,
@ -431,7 +417,7 @@ class OffsetTestCase(unittest.TestCase):
# The same debit or credit
form = journal_entry_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_line_item_id"] \
= self.data.l_r_or1d.id
= str(self.data.l_r_or1d.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
form["currency-1-debit-1-amount"] = "100"
response = self.client.post(store_uri, data=form)
@ -455,7 +441,7 @@ class OffsetTestCase(unittest.TestCase):
# The original line item is also an offset
form = journal_entry_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_line_item_id"] \
= self.data.l_r_of1c.id
= str(self.data.l_r_of1c.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
@ -541,7 +527,7 @@ class OffsetTestCase(unittest.TestCase):
# The same debit or credit
form = journal_entry_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_line_item_id"] \
= self.data.l_r_or1d.id
= str(self.data.l_r_or1d.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
form["currency-1-debit-1-amount"] = "100"
form["currency-1-credit-1-amount"] = "100"
@ -566,7 +552,7 @@ class OffsetTestCase(unittest.TestCase):
# The original line item is also an offset
form = journal_entry_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_line_item_id"] \
= self.data.l_r_of1c.id
= str(self.data.l_r_of1c.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
@ -720,3 +706,114 @@ class OffsetTestCase(unittest.TestCase):
self.assertIsNotNone(journal_entry_of)
self.assertEqual(journal_entry_or.date, journal_entry_of.date)
self.assertLess(journal_entry_or.no, journal_entry_of.no)
class OffsetTestData(BaseTestData):
"""The offset test data."""
def _init_data(self) -> None:
# Receivable original line items
self.l_r_or1d, self.l_r_or1c = self._couple(
"Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
self.l_r_or2d, self.l_r_or2c = self._couple(
"Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or3d, self.l_r_or3c = self._couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or4d, self.l_r_or4c = self._couple(
"Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
# Payable original line items
self.l_p_or1d, self.l_p_or1c = self._couple(
"Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
self.l_p_or2d, self.l_p_or2c = self._couple(
"Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
self.l_p_or3d, self.l_p_or3c = self._couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or4d, self.l_p_or4c = self._couple(
"Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
# Original journal entries
self.j_r_or1: JournalEntryData = JournalEntryData(
50, [JournalEntryCurrencyData(
"USD", [self.l_r_or1d, self.l_r_or4d],
[self.l_r_or1c, self.l_r_or4c])])
self.j_r_or2: JournalEntryData = JournalEntryData(
30, [JournalEntryCurrencyData(
"USD", [self.l_r_or2d, self.l_r_or3d],
[self.l_r_or2c, self.l_r_or3c])])
self.j_p_or1: JournalEntryData = JournalEntryData(
40, [JournalEntryCurrencyData(
"USD", [self.l_p_or1d, self.l_p_or4d],
[self.l_p_or1c, self.l_p_or4c])])
self.j_p_or2: JournalEntryData = JournalEntryData(
20, [JournalEntryCurrencyData(
"USD", [self.l_p_or2d, self.l_p_or3d],
[self.l_p_or2c, self.l_p_or3c])])
self._add_journal_entry(self.j_r_or1)
self._add_journal_entry(self.j_r_or2)
self._add_journal_entry(self.j_p_or1)
self._add_journal_entry(self.j_p_or2)
# Receivable offset items
self.l_r_of1d, self.l_r_of1c = self._couple(
"Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of1c.original_line_item = self.l_r_or1d
self.l_r_of2d, self.l_r_of2c = self._couple(
"Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of2c.original_line_item = self.l_r_or1d
self.l_r_of3d, self.l_r_of3c = self._couple(
"Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of3c.original_line_item = self.l_r_or1d
self.l_r_of4d, self.l_r_of4c = self._couple(
"Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of4c.original_line_item = self.l_r_or2d
self.l_r_of5d, self.l_r_of5c = self._couple(
"Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of5c.original_line_item = self.l_r_or4d
# Payable offset items
self.l_p_of1d, self.l_p_of1c = self._couple(
"Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of1d.original_line_item = self.l_p_or1c
self.l_p_of2d, self.l_p_of2c = self._couple(
"Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of2d.original_line_item = self.l_p_or1c
self.l_p_of3d, self.l_p_of3c = self._couple(
"Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of3d.original_line_item = self.l_p_or1c
self.l_p_of4d, self.l_p_of4c = self._couple(
"Phone", "400", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of4d.original_line_item = self.l_p_or2c
self.l_p_of5d, self.l_p_of5c = self._couple(
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of5d.original_line_item = self.l_p_or4c
# Offset journal entries
self.j_r_of1: JournalEntryData = JournalEntryData(
25, [JournalEntryCurrencyData(
"USD", [self.l_r_of1d], [self.l_r_of1c])])
self.j_r_of2: JournalEntryData = JournalEntryData(
20, [JournalEntryCurrencyData(
"USD", [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
[self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
self.j_r_of3: JournalEntryData = JournalEntryData(
15, [JournalEntryCurrencyData(
"USD", [self.l_r_of5d], [self.l_r_of5c])])
self.j_p_of1: JournalEntryData = JournalEntryData(
15, [JournalEntryCurrencyData(
"USD", [self.l_p_of1d], [self.l_p_of1c])])
self.j_p_of2: JournalEntryData = JournalEntryData(
10, [JournalEntryCurrencyData(
"USD", [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
[self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
self.j_p_of3: JournalEntryData = JournalEntryData(
5, [JournalEntryCurrencyData(
"USD", [self.l_p_of5d], [self.l_p_of5c])])
self._add_journal_entry(self.j_r_of1)
self._add_journal_entry(self.j_r_of2)
self._add_journal_entry(self.j_r_of3)
self._add_journal_entry(self.j_p_of1)
self._add_journal_entry(self.j_p_of2)
self._add_journal_entry(self.j_p_of3)

View File

@ -1,692 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/11
# 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 test for the offset matcher.
"""
import unittest
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db
from testlib import create_test_app, get_client, Accounts
from testlib_journal_entry import match_journal_entry_detail
from testlib_offset import JournalEntryData, CurrencyData, \
JournalEntryLineItemData
class OffsetMatcherTestCase(unittest.TestCase):
"""The offset matcher test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
def test_different(self) -> None:
"""Tests to match against different descriptions and amounts.
:return: None.
"""
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.offset_matcher import OffsetMatcher
data: DifferentTestData \
= DifferentTestData(self.app, self.client, self.csrf_token)
account: Account | None
line_item: JournalEntryLineItem | None
matcher: OffsetMatcher
list_uri: str
response: httpx.Response
# The receivables
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or1d.id, data.l_r_or2d.id,
data.l_r_or3d.id, data.l_r_or4d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id,
data.l_r_of5c.id})
self.assertEqual({(x.original_line_item.id, x.offset.id)
for x in matcher.matched_pairs},
{(data.l_r_or4d.id, data.l_r_of5c.id)})
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id,
data.l_r_of5c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
list_uri = f"/accounting/unmatched-offsets/{Accounts.RECEIVABLE}"
response = self.client.post(list_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():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or1d.id, data.l_r_or2d.id,
data.l_r_or3d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id})
self.assertEqual(matcher.matches, 0)
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of5c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
# The payables
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or1c.id, data.l_p_or2c.id,
data.l_p_or3c.id, data.l_p_or4c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id,
data.l_p_of5d.id})
self.assertEqual({(x.original_line_item.id, x.offset.id)
for x in matcher.matched_pairs},
{(data.l_p_or4c.id, data.l_p_of5d.id)})
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id,
data.l_p_of5d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
list_uri = f"/accounting/unmatched-offsets/{Accounts.PAYABLE}"
response = self.client.post(list_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():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or1c.id, data.l_p_or2c.id,
data.l_p_or3c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id})
self.assertEqual(matcher.matches, 0)
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of5d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or4c.id)
def test_same(self) -> None:
"""Tests to match against same descriptions and amounts.
:return: None.
"""
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.offset_matcher import OffsetMatcher
data: SameTestData \
= SameTestData(self.app, self.client, self.csrf_token)
account: Account | None
line_item: JournalEntryLineItem | None
matcher: OffsetMatcher
list_uri: str
response: httpx.Response
# The receivables
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or1d.id, data.l_r_or3d.id,
data.l_r_or4d.id, data.l_r_or5d.id,
data.l_r_or6d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of4c.id, data.l_r_of5c.id,
data.l_r_of6c.id})
self.assertEqual({(x.original_line_item.id, x.offset.id)
for x in matcher.matched_pairs},
{(data.l_r_or1d.id, data.l_r_of2c.id),
(data.l_r_or3d.id, data.l_r_of4c.id),
(data.l_r_or4d.id, data.l_r_of6c.id)})
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of4c.id, data.l_r_of5c.id,
data.l_r_of6c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of3c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
list_uri = f"/accounting/unmatched-offsets/{Accounts.RECEIVABLE}"
response = self.client.post(list_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():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or5d.id, data.l_r_or6d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_r_of1c.id, data.l_r_of5c.id})
self.assertEqual(matcher.matches, 0)
for line_item_id in {data.l_r_of1c.id, data.l_r_of5c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of2c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or1d.id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of3c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of4c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or3d.id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of6c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
# The payables
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or1c.id, data.l_p_or3c.id,
data.l_p_or4c.id, data.l_p_or5c.id,
data.l_p_or6c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of4d.id, data.l_p_of5d.id,
data.l_p_of6d.id})
self.assertEqual({(x.original_line_item.id, x.offset.id)
for x in matcher.matched_pairs},
{(data.l_p_or1c.id, data.l_p_of2d.id),
(data.l_p_or3c.id, data.l_p_of4d.id),
(data.l_p_or4c.id, data.l_p_of6d.id)})
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of4d.id, data.l_p_of5d.id,
data.l_p_of6d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of3d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
list_uri = f"/accounting/unmatched-offsets/{Accounts.PAYABLE}"
response = self.client.post(list_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():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or5c.id, data.l_p_or6c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_p_of1d.id, data.l_p_of5d.id})
self.assertEqual(matcher.matches, 0)
for line_item_id in {data.l_p_of1d.id, data.l_p_of5d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of2d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or1c.id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of3d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of4d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or3c.id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of6d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or4c.id)
class DifferentTestData:
"""The test data for different descriptions and amounts."""
def __init__(self, app: Flask, client: httpx.Client, csrf_token: str):
"""Constructs the test data.
:param app: The Flask application.
:param client: The client.
:param csrf_token: The CSRF token.
"""
self.app: Flask = app
self.client: httpx.Client = client
self.csrf_token: str = csrf_token
def couple(description: str, amount: str, debit: str, credit: str) \
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
"""Returns a couple of debit-credit line items.
:param description: The description.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit line item and credit line item.
"""
return JournalEntryLineItemData(debit, description, amount),\
JournalEntryLineItemData(credit, description, amount)
# Receivable original line items
self.l_r_or1d, self.l_r_or1c = couple(
"Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
self.l_r_or2d, self.l_r_or2c = couple(
"Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or3d, self.l_r_or3c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or4d, self.l_r_or4c = couple(
"Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
# Payable original line items
self.l_p_or1d, self.l_p_or1c = couple(
"Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
self.l_p_or2d, self.l_p_or2c = couple(
"Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
self.l_p_or3d, self.l_p_or3c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or4d, self.l_p_or4c = couple(
"Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
# Original journal entries
self.j_r_or1: JournalEntryData = JournalEntryData(
50, [CurrencyData("USD", [self.l_r_or1d, self.l_r_or4d],
[self.l_r_or1c, self.l_r_or4c])])
self.j_r_or2: JournalEntryData = JournalEntryData(
30, [CurrencyData("USD", [self.l_r_or2d, self.l_r_or3d],
[self.l_r_or2c, self.l_r_or3c])])
self.j_p_or1: JournalEntryData = JournalEntryData(
40, [CurrencyData("USD", [self.l_p_or1d, self.l_p_or4d],
[self.l_p_or1c, self.l_p_or4c])])
self.j_p_or2: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD", [self.l_p_or2d, self.l_p_or3d],
[self.l_p_or2c, self.l_p_or3c])])
self.__add_journal_entry(self.j_r_or1)
self.__add_journal_entry(self.j_r_or2)
self.__add_journal_entry(self.j_p_or1)
self.__add_journal_entry(self.j_p_or2)
# Receivable offset items
self.l_r_of1d, self.l_r_of1c = couple(
"Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of2d, self.l_r_of2c = couple(
"Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of3d, self.l_r_of3c = couple(
"Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of4d, self.l_r_of4c = couple(
"Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of5d, self.l_r_of5c = couple(
"Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
# Payable offset items
self.l_p_of1d, self.l_p_of1c = couple(
"Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of2d, self.l_p_of2c = couple(
"Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of3d, self.l_p_of3c = couple(
"Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of4d, self.l_p_of4c = couple(
"Phone", "400", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of5d, self.l_p_of5c = couple(
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
# Offset journal entries
self.j_r_of1: JournalEntryData = JournalEntryData(
25, [CurrencyData("USD", [self.l_r_of1d], [self.l_r_of1c])])
self.j_r_of2: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD",
[self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
[self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
self.j_r_of3: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.l_r_of5d], [self.l_r_of5c])])
self.j_p_of1: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.l_p_of1d], [self.l_p_of1c])])
self.j_p_of2: JournalEntryData = JournalEntryData(
10, [CurrencyData("USD",
[self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
[self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
self.j_p_of3: JournalEntryData = JournalEntryData(
5, [CurrencyData("USD", [self.l_p_of5d], [self.l_p_of5c])])
self.__set_is_need_offset(False)
self.__add_journal_entry(self.j_r_of1)
self.__add_journal_entry(self.j_r_of2)
self.__add_journal_entry(self.j_r_of3)
self.__add_journal_entry(self.j_p_of1)
self.__add_journal_entry(self.j_p_of2)
self.__add_journal_entry(self.j_p_of3)
self.__set_is_need_offset(True)
def __set_is_need_offset(self, is_need_offset: bool) -> None:
"""Sets whether the payables and receivables need offset.
:param is_need_offset: True if payables and receivables need offset, or
False otherwise.
:return:
"""
from accounting.models import Account
with self.app.app_context():
for code in {Accounts.RECEIVABLE, Accounts.PAYABLE}:
account: Account | None = Account.find_by_code(code)
assert account is not None
account.is_need_offset = is_need_offset
db.session.commit()
def __add_journal_entry(self, journal_entry_data: JournalEntryData) \
-> None:
"""Adds a journal entry.
:param journal_entry_data: The journal entry data.
:return: None.
"""
from accounting.models import JournalEntry
store_uri: str = "/accounting/journal-entries/store/transfer"
response: httpx.Response = self.client.post(
store_uri, data=journal_entry_data.new_form(self.csrf_token))
assert response.status_code == 302
journal_entry_id: int \
= match_journal_entry_detail(response.headers["Location"])
journal_entry_data.id = journal_entry_id
with self.app.app_context():
journal_entry: JournalEntry | None \
= db.session.get(JournalEntry, journal_entry_id)
assert journal_entry is not None
for i in range(len(journal_entry.currencies)):
for j in range(len(journal_entry.currencies[i].debit)):
journal_entry_data.currencies[i].debit[j].id \
= journal_entry.currencies[i].debit[j].id
for j in range(len(journal_entry.currencies[i].credit)):
journal_entry_data.currencies[i].credit[j].id \
= journal_entry.currencies[i].credit[j].id
class SameTestData:
"""The test data with same descriptions and amounts."""
def __init__(self, app: Flask, client: httpx.Client, csrf_token: str):
"""Constructs the test data.
:param app: The Flask application.
:param client: The client.
:param csrf_token: The CSRF token.
"""
self.app: Flask = app
self.client: httpx.Client = client
self.csrf_token: str = csrf_token
def couple(description: str, amount: str, debit: str, credit: str) \
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
"""Returns a couple of debit-credit line items.
:param description: The description.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit line item and credit line item.
"""
return JournalEntryLineItemData(debit, description, amount),\
JournalEntryLineItemData(credit, description, amount)
# Receivable original line items
self.l_r_or1d, self.l_r_or1c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or2d, self.l_r_or2c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or3d, self.l_r_or3c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or4d, self.l_r_or4c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or5d, self.l_r_or5c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or6d, self.l_r_or6c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
# Payable original line items
self.l_p_or1d, self.l_p_or1c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or2d, self.l_p_or2c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or3d, self.l_p_or3c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or4d, self.l_p_or4c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or5d, self.l_p_or5c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or6d, self.l_p_or6c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
# Original journal entries
self.j_r_or1: JournalEntryData = JournalEntryData(
60, [CurrencyData("USD", [self.l_r_or1d], [self.l_r_or1c])])
self.j_r_or2: JournalEntryData = JournalEntryData(
50, [CurrencyData("USD", [self.l_r_or2d], [self.l_r_or2c])])
self.j_r_or3: JournalEntryData = JournalEntryData(
40, [CurrencyData("USD", [self.l_r_or3d], [self.l_r_or3c])])
self.j_r_or4: JournalEntryData = JournalEntryData(
30, [CurrencyData("USD", [self.l_r_or4d], [self.l_r_or4c])])
self.j_r_or5: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD", [self.l_r_or5d], [self.l_r_or5c])])
self.j_r_or6: JournalEntryData = JournalEntryData(
10, [CurrencyData("USD", [self.l_r_or6d], [self.l_r_or6c])])
self.j_p_or1: JournalEntryData = JournalEntryData(
60, [CurrencyData("USD", [self.l_p_or1d], [self.l_p_or1c])])
self.j_p_or2: JournalEntryData = JournalEntryData(
50, [CurrencyData("USD", [self.l_p_or2d], [self.l_p_or2c])])
self.j_p_or3: JournalEntryData = JournalEntryData(
40, [CurrencyData("USD", [self.l_p_or3d], [self.l_p_or3c])])
self.j_p_or4: JournalEntryData = JournalEntryData(
30, [CurrencyData("USD", [self.l_p_or4d], [self.l_p_or4c])])
self.j_p_or5: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD", [self.l_p_or5d], [self.l_p_or5c])])
self.j_p_or6: JournalEntryData = JournalEntryData(
10, [CurrencyData("USD", [self.l_p_or6d], [self.l_p_or6c])])
self.__add_journal_entry(self.j_r_or1)
self.__add_journal_entry(self.j_r_or2)
self.__add_journal_entry(self.j_r_or3)
self.__add_journal_entry(self.j_r_or4)
self.__add_journal_entry(self.j_r_or5)
self.__add_journal_entry(self.j_r_or6)
self.__add_journal_entry(self.j_p_or1)
self.__add_journal_entry(self.j_p_or2)
self.__add_journal_entry(self.j_p_or3)
self.__add_journal_entry(self.j_p_or4)
self.__add_journal_entry(self.j_p_or5)
self.__add_journal_entry(self.j_p_or6)
# Receivable offset items
self.l_r_of1d, self.l_r_of1c = couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of2d, self.l_r_of2c = couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of3d, self.l_r_of3c = couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of3c.original_line_item = self.l_r_or2d
self.l_r_of4d, self.l_r_of4c = couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of5d, self.l_r_of5c = couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of6d, self.l_r_of6c = couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
# Payable offset items
self.l_p_of1d, self.l_p_of1c = couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of2d, self.l_p_of2c = couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of3d, self.l_p_of3c = couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of3d.original_line_item = self.l_p_or2c
self.l_p_of4d, self.l_p_of4c = couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of5d, self.l_p_of5c = couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of6d, self.l_p_of6c = couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
# Offset journal entries
self.j_r_of1: JournalEntryData = JournalEntryData(
65, [CurrencyData("USD", [self.l_r_of1d], [self.l_r_of1c])])
self.j_r_of2: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_r_of2d], [self.l_r_of2c])])
self.j_r_of3: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_r_of3d], [self.l_r_of3c])])
self.j_r_of4: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_r_of4d], [self.l_r_of4c])])
self.j_r_of5: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_r_of5d], [self.l_r_of5c])])
self.j_r_of6: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.l_r_of6d], [self.l_r_of6c])])
self.j_p_of1: JournalEntryData = JournalEntryData(
65, [CurrencyData("USD", [self.l_p_of1d], [self.l_p_of1c])])
self.j_p_of2: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_p_of2d], [self.l_p_of2c])])
self.j_p_of3: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_p_of3d], [self.l_p_of3c])])
self.j_p_of4: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_p_of4d], [self.l_p_of4c])])
self.j_p_of5: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_p_of5d], [self.l_p_of5c])])
self.j_p_of6: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.l_p_of6d], [self.l_p_of6c])])
self.__set_is_need_offset(False)
self.__add_journal_entry(self.j_r_of1)
self.__add_journal_entry(self.j_r_of2)
self.__add_journal_entry(self.j_r_of4)
self.__add_journal_entry(self.j_r_of5)
self.__add_journal_entry(self.j_r_of6)
self.__add_journal_entry(self.j_p_of1)
self.__add_journal_entry(self.j_p_of2)
self.__add_journal_entry(self.j_p_of4)
self.__add_journal_entry(self.j_p_of5)
self.__add_journal_entry(self.j_p_of6)
self.__set_is_need_offset(True)
self.__add_journal_entry(self.j_r_of3)
self.__add_journal_entry(self.j_p_of3)
def __set_is_need_offset(self, is_need_offset: bool) -> None:
"""Sets whether the payables and receivables need offset.
:param is_need_offset: True if payables and receivables need offset, or
False otherwise.
:return:
"""
from accounting.models import Account
with self.app.app_context():
for code in {Accounts.RECEIVABLE, Accounts.PAYABLE}:
account: Account | None = Account.find_by_code(code)
assert account is not None
account.is_need_offset = is_need_offset
db.session.commit()
def __add_journal_entry(self, journal_entry_data: JournalEntryData) \
-> None:
"""Adds a journal entry.
:param journal_entry_data: The journal entry data.
:return: None.
"""
from accounting.models import JournalEntry
store_uri: str = "/accounting/journal-entries/store/transfer"
response: httpx.Response = self.client.post(
store_uri, data=journal_entry_data.new_form(self.csrf_token))
assert response.status_code == 302
journal_entry_id: int \
= match_journal_entry_detail(response.headers["Location"])
journal_entry_data.id = journal_entry_id
with self.app.app_context():
journal_entry: JournalEntry | None \
= db.session.get(JournalEntry, journal_entry_id)
assert journal_entry is not None
for i in range(len(journal_entry.currencies)):
for j in range(len(journal_entry.currencies[i].debit)):
journal_entry_data.currencies[i].debit[j].id \
= journal_entry.currencies[i].debit[j].id
for j in range(len(journal_entry.currencies[i].credit)):
journal_entry_data.currencies[i].credit[j].id \
= journal_entry.currencies[i].credit[j].id

View File

@ -21,13 +21,10 @@ import unittest
from datetime import datetime, timedelta
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db
from testlib import NEXT_URI, Accounts, create_test_app, get_client
from testlib_offset import TestData
PREFIX: str = "/accounting/options"
"""The URL prefix for the option management."""
@ -50,25 +47,11 @@ class OptionTestCase(unittest.TestCase):
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Option
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
from accounting.models import Option
Option.query.delete()
self.client, self.csrf_token = get_client(self.app, "admin")
self.data: TestData = TestData(self.app, self.client, self.csrf_token)
def test_nobody(self) -> None:
"""Test the permission as nobody.

399
tests/test_report.py Normal file
View File

@ -0,0 +1,399 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/9
# 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 test for the reports.
"""
import unittest
from datetime import date
import httpx
from flask import Flask
from testlib import create_test_app, get_client, Accounts, BaseTestData
PREFIX: str = "/accounting"
"""The URL prefix for the reports."""
CSV_MIME: str = "text/csv; charset=utf-8"
"""The MIME type of the downloaded CSV files."""
class ReportTestCase(unittest.TestCase):
"""The report test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
with self.app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
ReportTestData(self.app, "editor").populate()
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/journal")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/journal?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/ledger")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/ledger?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/income-expenses")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/income-expenses?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/trial-balance")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/trial-balance?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/income-statement")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/income-statement?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/balance-sheet")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/balance-sheet?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/unapplied")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/unapplied?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/search?q=Salary")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/search?q=Salary&as=csv")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/search?q=薪水")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/search?q=薪水&as=csv")
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
"""Test the permission as viewer.
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
ReportTestData(self.app, "editor").populate()
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = client.get(f"{PREFIX}/journal")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/journal?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = client.get(f"{PREFIX}/ledger")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/ledger?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = client.get(f"{PREFIX}/income-expenses")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/income-expenses?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = client.get(f"{PREFIX}/trial-balance")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/trial-balance?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = client.get(f"{PREFIX}/income-statement")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/income-statement?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = client.get(f"{PREFIX}/balance-sheet")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/balance-sheet?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = client.get(f"{PREFIX}/unapplied")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/unapplied?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = client.get(f"{PREFIX}/search?q=Salary")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/search?q=Salary&as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = client.get(f"{PREFIX}/search?q=薪水")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/search?q=薪水&as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
def test_editor(self) -> None:
"""Test the permission as editor.
:return: None.
"""
ReportTestData(self.app, "editor").populate()
response: httpx.Response
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/journal")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/journal?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/ledger")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/ledger?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/income-expenses")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/income-expenses?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/trial-balance")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/trial-balance?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/income-statement")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/income-statement?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/balance-sheet")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/balance-sheet?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unapplied")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/unapplied?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = self.client.get(
f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/search?q=Salary")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/search?q=Salary&as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/search?q=薪水")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/search?q=薪水&as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
def test_empty_db(self) -> None:
"""Tests the empty database.
:return: None.
"""
response: httpx.Response
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/journal")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/journal?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/ledger")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/ledger?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/income-expenses")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/income-expenses?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/trial-balance")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/trial-balance?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/income-statement")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/income-statement?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/balance-sheet")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/balance-sheet?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unapplied")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/unapplied?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = self.client.get(
f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/search?q=Salary")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/search?q=Salary&as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
class ReportTestData(BaseTestData):
"""The report test data."""
def _init_data(self) -> None:
today: date = date.today()
year: int = today.year - 5
month: int = today.month
while True:
j_date: date = date(year, month, 5)
if j_date > today:
break
self._add_simple_journal_entry(
(j_date - today).days, "USD",
"Salary薪水", "1200", Accounts.BANK, Accounts.SERVICE)
month = month + 1
if month > 12:
year = year + 1
month = 1
self._add_simple_journal_entry(
1, "USD", "Withdraw領錢", "1000", Accounts.CASH, Accounts.BANK)
self._add_simple_journal_entry(
0, "USD", "Dinner晚餐", "40", Accounts.MEAL, Accounts.CASH)

View File

@ -21,9 +21,10 @@ import os
import typing as t
from secrets import token_urlsafe
import click
from flask import Flask, Blueprint, render_template, redirect, Response
from flask.cli import with_appcontext
from click.testing import Result
from flask import Flask, Blueprint, render_template, redirect, Response, \
url_for
from flask.testing import FlaskCliRunner
from flask_babel_js import BabelJS
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect
@ -63,7 +64,6 @@ def create_app(is_testing: bool = False) -> Flask:
db.init_app(app)
app.register_blueprint(bp, url_prefix="/")
app.cli.add_command(init_db_command)
from . import locale
locale.init_app(app)
@ -71,6 +71,9 @@ def create_app(is_testing: bool = False) -> Flask:
from . import auth
auth.init_app(app)
from . import reset
reset.init_app(app)
class UserUtilities(accounting.UserUtilityInterface[auth.User]):
def can_view(self) -> bool:
@ -87,7 +90,8 @@ def create_app(is_testing: bool = False) -> Flask:
and auth.current_user().username == "admin"
def unauthorized(self) -> Response:
return redirect("/login")
from accounting.utils.next_uri import append_next
return redirect(append_next(url_for("auth.login-form")))
@property
def cls(self) -> t.Type[auth.User]:
@ -110,20 +114,27 @@ def create_app(is_testing: bool = False) -> Flask:
accounting.init_app(app, user_utils=UserUtilities())
with app.app_context():
init_db(app)
return app
@click.command("init-db")
@with_appcontext
def init_db_command() -> None:
"""Initializes the database."""
def init_db(app: Flask) -> None:
"""Initializes the database.
:param app: The Flask application.
:return: None.
"""
db.create_all()
from .auth import User
for username in ["viewer", "editor", "admin", "nobody"]:
if User.query.filter(User.username == username).first() is None:
db.session.add(User(username=username))
db.session.commit()
click.echo("Database initialized successfully.")
runner: FlaskCliRunner = app.test_cli_runner()
result: Result = runner.invoke(args=["accounting-init-db", "-u", "editor"])
assert result.exit_code == 0, result.output + str(result.exception)
@bp.get("/", endpoint="home")

View File

@ -18,7 +18,7 @@
"""
from flask import Blueprint, render_template, Flask, redirect, url_for, \
session, request, g
session, request, g, Response
from . import db
@ -44,11 +44,13 @@ class User(db.Model):
@bp.get("login", endpoint="login-form")
def show_login_form() -> str:
def show_login_form() -> str | Response:
"""Shows the login form.
:return: The login form.
"""
if "user" in session:
return redirect(url_for("accounting-report.default"))
return render_template("login.html")
@ -58,11 +60,12 @@ def login() -> redirect:
:return: The redirection to the home page.
"""
from accounting.utils.next_uri import inherit_next, or_next
if request.form.get("username") not in {"viewer", "editor", "admin",
"nobody"}:
return redirect(url_for("auth.login"))
return redirect(inherit_next(url_for("auth.login")))
session["user"] = request.form.get("username")
return redirect(url_for("home.home"))
return redirect(or_next(url_for("accounting-report.default")))
@bp.post("logout", endpoint="logout")

File diff suppressed because one or more lines are too long

153
tests/test_site/reset.py Normal file
View File

@ -0,0 +1,153 @@
# The Mia! Accounting Demonstration Website.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/12
# 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 data reset for the Mia! Accounting demonstration website.
"""
import json
import typing as t
from datetime import date, timedelta
from decimal import Decimal
from pathlib import Path
import sqlalchemy as sa
from flask import Flask, Blueprint, url_for, flash, redirect, session, \
render_template
from flask_babel import lazy_gettext
from accounting.utils.cast import s
from . import db
from .auth import User, current_user
bp: Blueprint = Blueprint("reset", __name__, url_prefix="/")
@bp.get("reset", endpoint="reset-page")
def reset() -> str:
"""Resets the sample data.
:return: Redirection to the accounting application.
"""
return render_template("reset.html")
@bp.post("sample", endpoint="sample")
def reset_sample() -> redirect:
"""Resets the sample data.
:return: Redirection to the accounting application.
"""
__reset_database()
__populate_sample_data()
flash(s(lazy_gettext(
"The sample data are emptied and reset successfully.")), "success")
return redirect(url_for("accounting-report.default"))
@bp.post("reset", endpoint="clean-up")
def clean_up() -> redirect:
"""Clean-up the database data.
:return: Redirection to the accounting application.
"""
__reset_database()
db.session.commit()
flash(s(lazy_gettext("The database is emptied successfully.")), "success")
return redirect(url_for("accounting-report.default"))
def __populate_sample_data() -> None:
"""Populates the sample data.
:return: None.
"""
from accounting.models import Account, JournalEntry, JournalEntryLineItem
file: Path = Path(__file__).parent / "data" / "sample.json"
with open(file) as fp:
json_data = json.load(fp)
today: date = date.today()
user: User | None = current_user()
assert user is not None
def filter_journal_entry(data: list[t.Any]) -> dict[str, t.Any]:
"""Filters the journal entry data from JSON.
:param data: The journal entry data.
:return: The journal entry data from JSON.
"""
return {"id": data[0],
"date": today - timedelta(days=data[1]),
"no": data[2],
"note": data[3],
"created_by_id": user.id,
"updated_by_id": user.id}
def filter_line_item(data: list[t.Any]) -> dict[str, t.Any]:
"""Filters the journal entry line item data from JSON.
:param data: The journal entry line item data.
:return: The journal entry line item data from JSON.
"""
return {"id": data[0],
"journal_entry_id": data[1],
"original_line_item_id": data[2],
"is_debit": data[3],
"no": data[4],
"account_id": Account.find_by_code(data[5]).id,
"currency_code": data[6],
"description": data[7],
"amount": Decimal(data[8])}
db.session.execute(sa.insert(JournalEntry),
[filter_journal_entry(x) for x in json_data[0]])
db.session.execute(sa.insert(JournalEntryLineItem),
[filter_line_item(x) for x in json_data[1]])
db.session.commit()
def __reset_database() -> None:
"""Resets the database.
:return: None.
"""
from accounting.models import Currency, CurrencyL10n, BaseAccount, \
BaseAccountL10n, Account, AccountL10n, JournalEntry, \
JournalEntryLineItem
from accounting.base_account import init_base_accounts_command
from accounting.account import init_accounts_command
from accounting.currency import init_currencies_command
JournalEntryLineItem.query.delete()
JournalEntry.query.delete()
CurrencyL10n.query.delete()
Currency.query.delete()
AccountL10n.query.delete()
Account.query.delete()
BaseAccountL10n.query.delete()
BaseAccount.query.delete()
init_base_accounts_command()
init_accounts_command(session["user"])
init_currencies_command(session["user"])
def init_app(app: Flask) -> None:
"""Initialize the localization.
:param app: The Flask application.
:return: None.
"""
app.register_blueprint(bp)

View File

@ -72,6 +72,14 @@ First written: 2023/1/27
</button>
</form>
</li>
{% if current_user().username == "admin" %}
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("reset.") %} active {% endif %}" href="{{ url_for("reset.reset-page") }}">
<i class="fa-solid fa-rotate-right"></i>
{{ _("Reset") }}
</a>
</li>
{% endif %}
</ul>
</li>
{% else %}

View File

@ -21,4 +21,12 @@ First written: 2023/1/27
#}
{% extends "base.html" %}
{% block header %}{% block title %}{{ _("Home") }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ _("Mia! Accounting Live Demonstration") }}{% endblock %}{% endblock %}
{% block content %}
<p>{{ _("This is the live demonstration of the Mia! Accounting project. Please <a href=\"/login?next=%%2Faccounting\">log in</a> to continue.") }}</p>
<p>{{ _("You may also want to check the <a href=\"https://mia-accounting.readthedocs.io\">full documentation</a> and the <a href=\"https://github.com/imacat/mia-accounting\">Github repository</a>.") }}</p>
{% endblock %}

View File

@ -27,6 +27,9 @@ First written: 2023/1/27
<form action="{{ url_for("auth.login") }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
{% endif %}
<button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
<button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
<button class="btn btn-primary" type="submit" name="username" value="admin">{{ _("Administrator") }}</button>

View File

@ -0,0 +1,48 @@
{#
The Mia! Accounting Demonstration Website
reset.html: The reset page.
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/4/12
#}
{% extends "base.html" %}
{% block header %}{% block title %}{{ _("Reset Database") }}{% endblock %}{% endblock %}
{% block content %}
<p>{{ _("Warning: All the current accounting data will be deleted. This cannot be undone. Please backup your database first.") }}</p>
<p>{{ _("Database reset is provided by the live demonstration. This is not part of the Mia! Accounting project.") }}</p>
<form class="mb-2" action="{{ url_for("reset.clean-up") }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
{% endif %}
<button class="btn btn-primary" type="submit">{{ _("Empty the Database") }}</button>
</form>
<form class="mb-2" action="{{ url_for("reset.sample") }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
{% endif %}
<button class="btn btn-primary" type="submit">{{ _("Empty and reset the Sample Data") }}</button>
</form>
{% endblock %}

View File

@ -9,8 +9,8 @@ msgid ""
msgstr ""
"Project-Id-Version: mia-accounting-test-site 1.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-04-06 02:34+0800\n"
"PO-Revision-Date: 2023-04-06 02:34+0800\n"
"POT-Creation-Date: 2023-04-12 17:59+0800\n"
"PO-Revision-Date: 2023-04-12 18:00+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"
@ -20,12 +20,19 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.12.1\n"
#: tests/test_site/reset.py:55
msgid "The sample data are emptied and reset successfully."
msgstr "範例資料已清空重設。"
#: tests/test_site/reset.py:68
msgid "The database is emptied successfully."
msgstr "資料庫已清空。"
#: tests/test_site/templates/base.html:23
msgid "en"
msgstr "zh-Hant"
#: tests/test_site/templates/base.html:46
#: tests/test_site/templates/home.html:24
msgid "Home"
msgstr "首頁"
@ -33,28 +40,76 @@ msgstr "首頁"
msgid "Log Out"
msgstr "登出"
#: tests/test_site/templates/base.html:81
#: tests/test_site/templates/base.html:79
msgid "Reset"
msgstr "重設"
#: tests/test_site/templates/base.html:89
#: tests/test_site/templates/login.html:24
msgid "Log In"
msgstr "登入"
#: tests/test_site/templates/base.html:122
#: tests/test_site/templates/base.html:130
msgid "Error:"
msgstr "錯誤:"
#: tests/test_site/templates/login.html:30
#: tests/test_site/templates/home.html:24
msgid "Mia! Accounting Live Demonstration"
msgstr "Mia! Accounting 示範站"
#: tests/test_site/templates/home.html:28
#, python-format
msgid ""
"This is the live demonstration of the Mia! Accounting project. Please <a"
" href=\"/login?next=%%2Faccounting\">log in</a> to continue."
msgstr "這是 Mia! Accounting 專案的示範站。請先<a href=\"/login?next=%%2Faccounting\">登入</a>。"
#: tests/test_site/templates/home.html:30
msgid ""
"You may also want to check the <a href=\"https://mia-"
"accounting.readthedocs.io\">full documentation</a> and the <a "
"href=\"https://github.com/imacat/mia-accounting\">Github repository</a>."
msgstr ""
"詳情請參閱<a href=\"https://mia-accounting.readthedocs.io\">完整說明文件</a>與<a "
"href=\"https://github.com/imacat/mia-accounting\">Github 專案庫</a>。"
#: tests/test_site/templates/login.html:33
msgid "Viewer"
msgstr "讀報表者"
#: tests/test_site/templates/login.html:31
#: tests/test_site/templates/login.html:34
msgid "Editor"
msgstr "記帳者"
#: tests/test_site/templates/login.html:32
#: tests/test_site/templates/login.html:35
msgid "Administrator"
msgstr "管理者"
#: tests/test_site/templates/login.html:33
#: tests/test_site/templates/login.html:36
msgid "Nobody"
msgstr "沒有權限者"
#: tests/test_site/templates/reset.html:24
msgid "Reset Database"
msgstr "資料庫重設"
#: tests/test_site/templates/reset.html:28
msgid ""
"Warning: All the current accounting data will be deleted. This cannot be"
" undone. Please backup your database first."
msgstr "警告:現有資料會全部刪除,無法復原。請先備份您的資料。"
#: tests/test_site/templates/reset.html:30
msgid ""
"Database reset is provided by the live demonstration. This is not part "
"of the Mia! Accounting project."
msgstr "資料庫重設是示範站的功能,不是 Mia! Accounting 的功能。"
#: tests/test_site/templates/reset.html:37
msgid "Empty the Database"
msgstr "清空資料庫"
#: tests/test_site/templates/reset.html:45
msgid "Empty and reset the Sample Data"
msgstr "清空並重設範例資料"

View File

@ -0,0 +1,563 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# 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 test for the unmatched offsets.
"""
import unittest
import httpx
from flask import Flask
from test_site import db
from testlib import create_test_app, get_client, Accounts, \
JournalEntryCurrencyData, JournalEntryData, BaseTestData
PREFIX: str = "/accounting/unmatched-offsets"
"""The URL prefix for the unmatched offset management."""
class UnmatchedOffsetTestCase(unittest.TestCase):
"""The unmatched offset test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
with self.app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
DifferentTestData(self.app, "nobody").populate()
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
"""Test the permission as viewer.
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
DifferentTestData(self.app, "viewer").populate()
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
def test_editor(self) -> None:
"""Test the permission as editor.
:return: None.
"""
DifferentTestData(self.app, "editor").populate()
response: httpx.Response
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{Accounts.PAYABLE}")
def test_empty_db(self) -> None:
"""Test the empty database.
:return: None.
"""
response: httpx.Response
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{Accounts.PAYABLE}")
def test_different(self) -> None:
"""Tests to match against different descriptions and amounts.
:return: None.
"""
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.offset_matcher import OffsetMatcher
data: DifferentTestData = DifferentTestData(self.app, "editor")
data.populate()
account: Account | None
line_item: JournalEntryLineItem | None
matcher: OffsetMatcher
list_uri: str
match_uri: str
response: httpx.Response
# The receivables
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or1d.id, data.l_r_or2d.id,
data.l_r_or3d.id, data.l_r_or4d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id,
data.l_r_of5c.id})
self.assertEqual({(x.original_line_item.id, x.offset.id)
for x in matcher.matched_pairs},
{(data.l_r_or4d.id, data.l_r_of5c.id)})
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id,
data.l_r_of5c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
list_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
response = self.client.post(match_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():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or1d.id, data.l_r_or2d.id,
data.l_r_or3d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id})
self.assertEqual(matcher.matches, 0)
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of5c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
# The payables
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or1c.id, data.l_p_or2c.id,
data.l_p_or3c.id, data.l_p_or4c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id,
data.l_p_of5d.id})
self.assertEqual({(x.original_line_item.id, x.offset.id)
for x in matcher.matched_pairs},
{(data.l_p_or4c.id, data.l_p_of5d.id)})
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id,
data.l_p_of5d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
list_uri = f"{PREFIX}/{Accounts.PAYABLE}"
match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
response = self.client.post(match_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():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or1c.id, data.l_p_or2c.id,
data.l_p_or3c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id})
self.assertEqual(matcher.matches, 0)
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of5d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or4c.id)
def test_same(self) -> None:
"""Tests to match against same descriptions and amounts.
:return: None.
"""
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.offset_matcher import OffsetMatcher
data: SameTestData = SameTestData(self.app, "editor")
data.populate()
account: Account | None
line_item: JournalEntryLineItem | None
matcher: OffsetMatcher
list_uri: str
match_uri: str
response: httpx.Response
# The receivables
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or1d.id, data.l_r_or3d.id,
data.l_r_or4d.id, data.l_r_or5d.id,
data.l_r_or6d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of4c.id, data.l_r_of5c.id,
data.l_r_of6c.id})
self.assertEqual({(x.original_line_item.id, x.offset.id)
for x in matcher.matched_pairs},
{(data.l_r_or1d.id, data.l_r_of2c.id),
(data.l_r_or3d.id, data.l_r_of4c.id),
(data.l_r_or4d.id, data.l_r_of6c.id)})
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of4c.id, data.l_r_of5c.id,
data.l_r_of6c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of3c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
list_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
response = self.client.post(match_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():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or5d.id, data.l_r_or6d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_r_of1c.id, data.l_r_of5c.id})
self.assertEqual(matcher.matches, 0)
for line_item_id in {data.l_r_of1c.id, data.l_r_of5c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of2c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or1d.id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of3c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of4c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or3d.id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of6c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
# The payables
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or1c.id, data.l_p_or3c.id,
data.l_p_or4c.id, data.l_p_or5c.id,
data.l_p_or6c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of4d.id, data.l_p_of5d.id,
data.l_p_of6d.id})
self.assertEqual({(x.original_line_item.id, x.offset.id)
for x in matcher.matched_pairs},
{(data.l_p_or1c.id, data.l_p_of2d.id),
(data.l_p_or3c.id, data.l_p_of4d.id),
(data.l_p_or4c.id, data.l_p_of6d.id)})
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of4d.id, data.l_p_of5d.id,
data.l_p_of6d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of3d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
list_uri = f"{PREFIX}/{Accounts.PAYABLE}"
match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
response = self.client.post(match_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():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or5c.id, data.l_p_or6c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_p_of1d.id, data.l_p_of5d.id})
self.assertEqual(matcher.matches, 0)
for line_item_id in {data.l_p_of1d.id, data.l_p_of5d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of2d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or1c.id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of3d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of4d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or3c.id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of6d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or4c.id)
class DifferentTestData(BaseTestData):
"""The test data for different descriptions and amounts."""
def _init_data(self) -> None:
# Receivable original line items
self.l_r_or1d, self.l_r_or1c = self._couple(
"Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
self.l_r_or2d, self.l_r_or2c = self._couple(
"Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or3d, self.l_r_or3c = self._couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or4d, self.l_r_or4c = self._couple(
"Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
# Payable original line items
self.l_p_or1d, self.l_p_or1c = self._couple(
"Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
self.l_p_or2d, self.l_p_or2c = self._couple(
"Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
self.l_p_or3d, self.l_p_or3c = self._couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or4d, self.l_p_or4c = self._couple(
"Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
# Original journal entries
self.j_r_or1: JournalEntryData = JournalEntryData(
50, [JournalEntryCurrencyData(
"USD", [self.l_r_or1d, self.l_r_or4d],
[self.l_r_or1c, self.l_r_or4c])])
self.j_r_or2: JournalEntryData = JournalEntryData(
30, [JournalEntryCurrencyData(
"USD", [self.l_r_or2d, self.l_r_or3d],
[self.l_r_or2c, self.l_r_or3c])])
self.j_p_or1: JournalEntryData = JournalEntryData(
40, [JournalEntryCurrencyData(
"USD", [self.l_p_or1d, self.l_p_or4d],
[self.l_p_or1c, self.l_p_or4c])])
self.j_p_or2: JournalEntryData = JournalEntryData(
20, [JournalEntryCurrencyData(
"USD", [self.l_p_or2d, self.l_p_or3d],
[self.l_p_or2c, self.l_p_or3c])])
self._add_journal_entry(self.j_r_or1)
self._add_journal_entry(self.j_r_or2)
self._add_journal_entry(self.j_p_or1)
self._add_journal_entry(self.j_p_or2)
# Receivable offset items
self.l_r_of1d, self.l_r_of1c = self._couple(
"Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of2d, self.l_r_of2c = self._couple(
"Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of3d, self.l_r_of3c = self._couple(
"Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of4d, self.l_r_of4c = self._couple(
"Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of5d, self.l_r_of5c = self._couple(
"Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
# Payable offset items
self.l_p_of1d, self.l_p_of1c = self._couple(
"Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of2d, self.l_p_of2c = self._couple(
"Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of3d, self.l_p_of3c = self._couple(
"Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of4d, self.l_p_of4c = self._couple(
"Phone", "400", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of5d, self.l_p_of5c = self._couple(
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
# Offset journal entries
self.j_r_of1: JournalEntryData = JournalEntryData(
25, [JournalEntryCurrencyData(
"USD", [self.l_r_of1d], [self.l_r_of1c])])
self.j_r_of2: JournalEntryData = JournalEntryData(
20, [JournalEntryCurrencyData(
"USD", [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
[self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
self.j_r_of3: JournalEntryData = JournalEntryData(
15, [JournalEntryCurrencyData(
"USD", [self.l_r_of5d], [self.l_r_of5c])])
self.j_p_of1: JournalEntryData = JournalEntryData(
15, [JournalEntryCurrencyData(
"USD", [self.l_p_of1d], [self.l_p_of1c])])
self.j_p_of2: JournalEntryData = JournalEntryData(
10, [JournalEntryCurrencyData(
"USD", [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
[self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
self.j_p_of3: JournalEntryData = JournalEntryData(
5, [JournalEntryCurrencyData(
"USD", [self.l_p_of5d], [self.l_p_of5c])])
self._add_journal_entry(self.j_r_of1)
self._add_journal_entry(self.j_r_of2)
self._add_journal_entry(self.j_r_of3)
self._add_journal_entry(self.j_p_of1)
self._add_journal_entry(self.j_p_of2)
self._add_journal_entry(self.j_p_of3)
class SameTestData(BaseTestData):
"""The test data with same descriptions and amounts."""
def _init_data(self) -> None:
# Receivable original line items
self.l_r_or1d, self.l_r_or1c = self._add_simple_journal_entry(
60, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or2d, self.l_r_or2c = self._add_simple_journal_entry(
50, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or3d, self.l_r_or3c = self._add_simple_journal_entry(
40, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or4d, self.l_r_or4c = self._add_simple_journal_entry(
30, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or5d, self.l_r_or5c = self._add_simple_journal_entry(
20, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or6d, self.l_r_or6c = self._add_simple_journal_entry(
10, "USD", "Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
# Payable original line items
self.l_p_or1d, self.l_p_or1c = self._add_simple_journal_entry(
60, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or2d, self.l_p_or2c = self._add_simple_journal_entry(
50, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or3d, self.l_p_or3c = self._add_simple_journal_entry(
40, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or4d, self.l_p_or4c = self._add_simple_journal_entry(
30, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or5d, self.l_p_or5c = self._add_simple_journal_entry(
20, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or6d, self.l_p_or6c = self._add_simple_journal_entry(
10, "USD", "Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
# Receivable offset items
self.l_r_of1d, self.l_r_of1c = self._add_simple_journal_entry(
65, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of2d, self.l_r_of2c = self._add_simple_journal_entry(
35, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of3d, self.l_r_of3c = self._couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of3c.original_line_item = self.l_r_or2d
j_r_of3: JournalEntryData = JournalEntryData(
35, [JournalEntryCurrencyData(
"USD", [self.l_r_of3d], [self.l_r_of3c])])
self.l_r_of4d, self.l_r_of4c = self._add_simple_journal_entry(
35, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of5d, self.l_r_of5c = self._add_simple_journal_entry(
35, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of6d, self.l_r_of6c = self._add_simple_journal_entry(
15, "USD", "Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
# Payable offset items
self.l_p_of1d, self.l_p_of1c = self._add_simple_journal_entry(
65, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of2d, self.l_p_of2c = self._add_simple_journal_entry(
35, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of3d, self.l_p_of3c = self._couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of3d.original_line_item = self.l_p_or2c
j_p_of3: JournalEntryData = JournalEntryData(
35, [JournalEntryCurrencyData(
"USD", [self.l_p_of3d], [self.l_p_of3c])])
self.l_p_of4d, self.l_p_of4c = self._add_simple_journal_entry(
35, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of5d, self.l_p_of5c = self._add_simple_journal_entry(
35, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of6d, self.l_p_of6c = self._add_simple_journal_entry(
15, "USD", "Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self._add_journal_entry(j_r_of3)
self._add_journal_entry(j_p_of3)

View File

@ -17,12 +17,23 @@
"""The common test libraries.
"""
from __future__ import annotations
import json
import re
import typing as t
from abc import ABC, abstractmethod
from datetime import date, timedelta
from secrets import randbelow
from decimal import Decimal
import sqlalchemy as sa
import httpx
from flask import Flask, render_template_string
from test_site import create_app
from test_site import create_app, db
from test_site.auth import User
TEST_SERVER: str = "https://testserver"
"""The test server URI."""
@ -37,6 +48,7 @@ class Accounts:
BANK: str = "1113-001"
NOTES_RECEIVABLE: str = "1131-001"
RECEIVABLE: str = "1141-001"
MACHINERY: str = "1441-001"
PREPAID: str = "1258-001"
NOTES_PAYABLE: str = "2131-001"
PAYABLE: str = "2141-001"
@ -96,6 +108,7 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
csrf_token: str = get_csrf_token(client)
response: httpx.Response = client.post("/login",
data={"csrf_token": csrf_token,
"next": "/",
"username": username})
assert response.status_code == 302
assert response.headers["Location"] == "/"
@ -117,3 +130,367 @@ def set_locale(client: httpx.Client, csrf_token: str,
"next": "/next"})
assert response.status_code == 302
assert response.headers["Location"] == "/next"
def add_journal_entry(client: httpx.Client, form: dict[str, str]) -> int:
"""Adds a transfer journal entry.
:param client: The client.
:param form: The form data.
:return: The newly-added journal entry ID.
"""
prefix: str = "/accounting/journal-entries"
journal_entry_type: str = "transfer"
if len({x for x in form if "-debit-" in x}) == 0:
journal_entry_type = "receipt"
elif len({x for x in form if "-credit-" in x}) == 0:
journal_entry_type = "disbursement"
store_uri = f"{prefix}/store/{journal_entry_type}"
response: httpx.Response = client.post(store_uri, data=form)
assert response.status_code == 302
return match_journal_entry_detail(response.headers["Location"])
def match_journal_entry_detail(location: str) -> int:
"""Validates if the redirect location is the journal entry detail, and
returns the journal entry ID on success.
:param location: The redirect location.
:return: The journal entry ID.
:raise AssertionError: When the location is not the journal entry detail.
"""
m: re.Match = re.match(
r"^/accounting/journal-entries/(\d+)\?next=%2F_next", location)
assert m is not None
return int(m.group(1))
class JournalEntryLineItemData:
"""The journal entry line item data."""
def __init__(self, account: str, description: str | None, amount: str,
original_line_item: JournalEntryLineItemData | None = None):
"""Constructs the journal entry line item data.
:param account: The account code.
:param description: The description.
:param amount: The amount.
:param original_line_item: The original journal entry line item.
"""
self.journal_entry: JournalEntryData | None = None
self.id: int = -1
self.no: int = -1
self.original_line_item: JournalEntryLineItemData | None \
= original_line_item
self.account: str = account
self.description: str | None = description
self.amount: Decimal = Decimal(amount)
def form(self, prefix: str, debit_credit: str, index: int,
is_update: bool) -> dict[str, str]:
"""Returns the line item as form data.
:param prefix: The prefix of the form fields.
:param debit_credit: Either "debit" or "credit".
:param index: The line item index.
:param is_update: True for an update operation, or False otherwise
:return: The form data.
"""
prefix = f"{prefix}-{debit_credit}-{index}"
form: dict[str, str] = {f"{prefix}-account_code": self.account,
f"{prefix}-description": self.description,
f"{prefix}-amount": str(self.amount)}
if is_update and self.id != -1:
form[f"{prefix}-id"] = str(self.id)
form[f"{prefix}-no"] = str(index) if self.no == -1 else str(self.no)
if self.original_line_item is not None:
assert self.original_line_item.id != -1
form[f"{prefix}-original_line_item_id"] \
= str(self.original_line_item.id)
return form
class JournalEntryCurrencyData:
"""The journal entry currency data."""
def __init__(self, currency: str, debit: list[JournalEntryLineItemData],
credit: list[JournalEntryLineItemData]):
"""Constructs the journal entry currency data.
:param currency: The currency code.
:param debit: The debit line items.
:param credit: The credit line items.
"""
self.code: str = currency
self.debit: list[JournalEntryLineItemData] = debit
self.credit: list[JournalEntryLineItemData] = credit
def form(self, index: int, is_update: bool) -> dict[str, str]:
"""Returns the currency as form data.
:param index: The currency index.
:param is_update: True for an update operation, or False otherwise
:return: The form data.
"""
prefix: str = f"currency-{index}"
form: dict[str, str] = {f"{prefix}-code": self.code}
for i in range(len(self.debit)):
form.update(self.debit[i].form(prefix, "debit", i + 1, is_update))
for i in range(len(self.credit)):
form.update(self.credit[i].form(prefix, "credit", i + 1,
is_update))
return form
class JournalEntryData:
"""The journal entry data."""
def __init__(self, days: int, currencies: list[JournalEntryCurrencyData]):
"""Constructs a journal entry.
:param days: The number of days before today.
:param currencies: The journal entry currency data.
"""
self.id: int = -1
self.days: int = days
self.currencies: list[JournalEntryCurrencyData] = currencies
self.note: str | None = None
for currency in self.currencies:
for line_item in currency.debit:
line_item.journal_entry = self
for line_item in currency.credit:
line_item.journal_entry = self
def new_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the journal entry as a creation form.
:param csrf_token: The CSRF token.
:return: The journal entry as a creation form.
"""
return self.__form(csrf_token, is_update=False)
def update_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the journal entry as an update form.
:param csrf_token: The CSRF token.
:return: The journal entry as an update form.
"""
return self.__form(csrf_token, is_update=True)
def __form(self, csrf_token: str, is_update: bool = False) \
-> dict[str, str]:
"""Returns the journal entry as a form.
:param csrf_token: The CSRF token.
:param is_update: True for an update operation, or False otherwise
:return: The journal entry as a form.
"""
journal_entry_date: date = date.today() - timedelta(days=self.days)
form: dict[str, str] = {"csrf_token": csrf_token,
"next": NEXT_URI,
"date": journal_entry_date.isoformat()}
for i in range(len(self.currencies)):
form.update(self.currencies[i].form(i + 1, is_update))
if self.note is not None:
form["note"] = self.note
return form
class BaseTestData(ABC):
"""The base test data."""
def __init__(self, app: Flask, username: str):
"""Constructs the test data.
:param app: The Flask application.
:param username: The username.
"""
self.__app: Flask = app
with self.__app.app_context():
current_user: User | None = User.query\
.filter(User.username == username).first()
assert current_user is not None
self.__current_user_id: int = current_user.id
self.__journal_entries: list[dict[str, t.Any]] = []
self.__line_items: list[dict[str, t.Any]] = []
self._init_data()
@abstractmethod
def _init_data(self) -> None:
"""Initializes the test data.
:return: None
"""
def populate(self) -> None:
"""Populates the data into the database.
:return: None
"""
from accounting.models import JournalEntry, JournalEntryLineItem
with self.__app.app_context():
db.session.execute(sa.insert(JournalEntry), self.__journal_entries)
db.session.execute(sa.insert(JournalEntryLineItem),
self.__line_items)
db.session.commit()
def json(self) -> str:
"""Returns the data as JSON.
:return: The JSON string.
"""
from accounting.models import Account
today: date = date.today()
def filter_journal_entry(data: dict[str, t.Any]) -> list[t.Any]:
"""Filters the journal entry data for JSON encoding.
:param data: The journal entry data.
:return: The journal entry data for JSON encoding.
"""
data = data.copy()
data["date"] = (today - data["date"]).days
del data["created_by_id"]
del data["updated_by_id"]
return [data[x] for x in ["id", "date", "no", "note"]]
def filter_line_item(data: dict[str, t.Any]) -> list[t.Any]:
"""Filters the journal entry line item data for JSON encoding.
:param data: The journal entry line item data.
:return: The journal entry line item data for JSON encoding.
"""
data = data.copy()
with self.__app.app_context():
data["account_id"] \
= db.session.get(Account, data["account_id"]).code
data["amount"] = str(data["amount"])
if "original_line_item_id" not in data:
data["original_line_item_id"] = None
return [data[x] for x in ["id", "journal_entry_id",
"original_line_item_id", "is_debit",
"no", "account_id", "currency_code",
"description", "amount"]]
return json.dumps(
[[filter_journal_entry(x) for x in self.__journal_entries],
[filter_line_item(x) for x in self.__line_items]],
ensure_ascii=False, separators=(",", ":"))
@staticmethod
def _couple(description: str, amount: str, debit: str, credit: str) \
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
"""Returns a couple of debit-credit line items.
:param description: The description.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit line item and credit line item.
"""
return JournalEntryLineItemData(debit, description, amount),\
JournalEntryLineItemData(credit, description, amount)
def _add_journal_entry(self, journal_entry_data: JournalEntryData) -> None:
"""Adds a journal entry.
:param journal_entry_data: The journal entry data.
:return: None.
"""
from accounting.models import Account
existing_j_id: set[int] = {x["id"] for x in self.__journal_entries}
existing_l_id: set[int] = {x["id"] for x in self.__line_items}
journal_entry_data.id = self.__new_id(existing_j_id)
j_date: date = date.today() - timedelta(days=journal_entry_data.days)
self.__journal_entries.append(
{"id": journal_entry_data.id,
"date": j_date,
"no": self.__next_j_no(j_date),
"note": journal_entry_data.note,
"created_by_id": self.__current_user_id,
"updated_by_id": self.__current_user_id})
debit_no: int = 0
credit_no: int = 0
for currency in journal_entry_data.currencies:
for line_item in currency.debit:
account: Account | None \
= Account.find_by_code(line_item.account)
assert account is not None
debit_no = debit_no + 1
line_item.id = self.__new_id(existing_l_id)
data: dict[str, t.Any] \
= {"id": line_item.id,
"journal_entry_id": journal_entry_data.id,
"is_debit": True,
"no": debit_no,
"account_id": account.id,
"currency_code": currency.code,
"description": line_item.description,
"amount": line_item.amount}
if line_item.original_line_item is not None:
data["original_line_item_id"] \
= line_item.original_line_item.id
self.__line_items.append(data)
for line_item in currency.credit:
account: Account | None \
= Account.find_by_code(line_item.account)
assert account is not None
credit_no = credit_no + 1
line_item.id = self.__new_id(existing_l_id)
data: dict[str, t.Any] \
= {"id": line_item.id,
"journal_entry_id": journal_entry_data.id,
"is_debit": False,
"no": credit_no,
"account_id": account.id,
"currency_code": currency.code,
"description": line_item.description,
"amount": line_item.amount}
if line_item.original_line_item is not None:
data["original_line_item_id"] \
= line_item.original_line_item.id
self.__line_items.append(data)
@staticmethod
def __new_id(existing_id: set[int]) -> int:
"""Generates and returns a new random unique ID.
:param existing_id: The existing ID.
:return: The newly-generated random unique ID.
"""
while True:
obj_id: int = 100000000 + randbelow(900000000)
if obj_id not in existing_id:
existing_id.add(obj_id)
return obj_id
def __next_j_no(self, j_date: date) -> int:
"""Returns the next journal entry number in a day.
:param j_date: The journal entry date.
:return: The next journal entry number.
"""
existing: set[int] = {x["no"] for x in self.__journal_entries
if x["date"] == j_date}
return 1 if len(existing) == 0 else max(existing) + 1
def _add_simple_journal_entry(
self, days: int, currency: str, description: str, amount: str,
debit: str, credit: str) \
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
"""Adds a simple journal entry.
:param days: The number of days before today.
:param currency: The currency code.
:param description: The description.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit line item and credit line item.
"""
debit_item, credit_item = self._couple(
description, amount, debit, credit)
self._add_journal_entry(JournalEntryData(
days, [JournalEntryCurrencyData(
currency, [debit_item], [credit_item])]))
return debit_item, credit_item

View File

@ -18,11 +18,10 @@
"""
import re
from decimal import Decimal
from datetime import date
from decimal import Decimal
from secrets import randbelow
import httpx
from flask import Flask
from test_site import db
@ -375,39 +374,6 @@ def __get_currency_prefix(form: dict[str, str], code: str) -> str:
return m.group(1)
def add_journal_entry(client: httpx.Client, form: dict[str, str]) -> int:
"""Adds a transfer journal entry.
:param client: The client.
:param form: The form data.
:return: The newly-added journal entry ID.
"""
prefix: str = "/accounting/journal-entries"
journal_entry_type: str = "transfer"
if len({x for x in form if "-debit-" in x}) == 0:
journal_entry_type = "receipt"
elif len({x for x in form if "-credit-" in x}) == 0:
journal_entry_type = "disbursement"
store_uri = f"{prefix}/store/{journal_entry_type}"
response: httpx.Response = client.post(store_uri, data=form)
assert response.status_code == 302
return match_journal_entry_detail(response.headers["Location"])
def match_journal_entry_detail(location: str) -> int:
"""Validates if the redirect location is the journal entry detail, and
returns the journal entry ID on success.
:param location: The redirect location.
:return: The journal entry ID.
:raise AssertionError: When the location is not the journal entry detail.
"""
m: re.Match = re.match(
r"^/accounting/journal-entries/(\d+)\?next=%2F_next", location)
assert m is not None
return int(m.group(1))
def set_negative_amount(form: dict[str, str]) -> None:
"""Sets a negative amount in the form data, keeping the balance.

View File

@ -1,315 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The common test libraries for the offset test cases.
"""
from __future__ import annotations
from datetime import date, timedelta
from decimal import Decimal
import httpx
from flask import Flask
from test_site import db
from testlib import NEXT_URI, Accounts
from testlib_journal_entry import match_journal_entry_detail
class JournalEntryLineItemData:
"""The journal entry line item data."""
def __init__(self, account: str, description: str, amount: str,
original_line_item: JournalEntryLineItemData | None = None):
"""Constructs the journal entry line item data.
:param account: The account code.
:param description: The description.
:param amount: The amount.
:param original_line_item: The original journal entry line item.
"""
self.journal_entry: JournalEntryData | None = None
self.id: int = -1
self.no: int = -1
self.original_line_item: JournalEntryLineItemData | None \
= original_line_item
self.account: str = account
self.description: str = description
self.amount: Decimal = Decimal(amount)
def form(self, prefix: str, debit_credit: str, index: int,
is_update: bool) -> dict[str, str]:
"""Returns the line item as form data.
:param prefix: The prefix of the form fields.
:param debit_credit: Either "debit" or "credit".
:param index: The line item index.
:param is_update: True for an update operation, or False otherwise
:return: The form data.
"""
prefix = f"{prefix}-{debit_credit}-{index}"
form: dict[str, str] = {f"{prefix}-account_code": self.account,
f"{prefix}-description": self.description,
f"{prefix}-amount": str(self.amount)}
if is_update and self.id != -1:
form[f"{prefix}-id"] = str(self.id)
form[f"{prefix}-no"] = str(index) if self.no == -1 else str(self.no)
if self.original_line_item is not None:
assert self.original_line_item.id != -1
form[f"{prefix}-original_line_item_id"] \
= str(self.original_line_item.id)
return form
class CurrencyData:
"""The journal entry currency data."""
def __init__(self, currency: str, debit: list[JournalEntryLineItemData],
credit: list[JournalEntryLineItemData]):
"""Constructs the journal entry currency data.
:param currency: The currency code.
:param debit: The debit line items.
:param credit: The credit line items.
"""
self.code: str = currency
self.debit: list[JournalEntryLineItemData] = debit
self.credit: list[JournalEntryLineItemData] = credit
def form(self, index: int, is_update: bool) -> dict[str, str]:
"""Returns the currency as form data.
:param index: The currency index.
:param is_update: True for an update operation, or False otherwise
:return: The form data.
"""
prefix: str = f"currency-{index}"
form: dict[str, str] = {f"{prefix}-code": self.code}
for i in range(len(self.debit)):
form.update(self.debit[i].form(prefix, "debit", i + 1, is_update))
for i in range(len(self.credit)):
form.update(self.credit[i].form(prefix, "credit", i + 1,
is_update))
return form
class JournalEntryData:
"""The journal entry data."""
def __init__(self, days: int, currencies: list[CurrencyData]):
"""Constructs a journal entry.
:param days: The number of days before today.
:param currencies: The journal entry currency data.
"""
self.id: int = -1
self.days: int = days
self.currencies: list[CurrencyData] = currencies
self.note: str | None = None
for currency in self.currencies:
for line_item in currency.debit:
line_item.journal_entry = self
for line_item in currency.credit:
line_item.journal_entry = self
def new_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the journal entry as a creation form.
:param csrf_token: The CSRF token.
:return: The journal entry as a creation form.
"""
return self.__form(csrf_token, is_update=False)
def update_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the journal entry as an update form.
:param csrf_token: The CSRF token.
:return: The journal entry as an update form.
"""
return self.__form(csrf_token, is_update=True)
def __form(self, csrf_token: str, is_update: bool = False) \
-> dict[str, str]:
"""Returns the journal entry as a form.
:param csrf_token: The CSRF token.
:param is_update: True for an update operation, or False otherwise
:return: The journal entry as a form.
"""
journal_entry_date: date = date.today() - timedelta(days=self.days)
form: dict[str, str] = {"csrf_token": csrf_token,
"next": NEXT_URI,
"date": journal_entry_date.isoformat()}
for i in range(len(self.currencies)):
form.update(self.currencies[i].form(i + 1, is_update))
if self.note is not None:
form["note"] = self.note
return form
class TestData:
"""The test data."""
def __init__(self, app: Flask, client: httpx.Client, csrf_token: str):
"""Constructs the test data.
:param app: The Flask application.
:param client: The client.
:param csrf_token: The CSRF token.
"""
self.app: Flask = app
self.client: httpx.Client = client
self.csrf_token: str = csrf_token
def couple(description: str, amount: str, debit: str, credit: str) \
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
"""Returns a couple of debit-credit line items.
:param description: The description.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit line item and credit line item.
"""
return JournalEntryLineItemData(debit, description, amount),\
JournalEntryLineItemData(credit, description, amount)
# Receivable original line items
self.l_r_or1d, self.l_r_or1c = couple(
"Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
self.l_r_or2d, self.l_r_or2c = couple(
"Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or3d, self.l_r_or3c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or4d, self.l_r_or4c = couple(
"Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
# Payable original line items
self.l_p_or1d, self.l_p_or1c = couple(
"Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
self.l_p_or2d, self.l_p_or2c = couple(
"Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
self.l_p_or3d, self.l_p_or3c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or4d, self.l_p_or4c = couple(
"Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
# Original journal entries
self.j_r_or1: JournalEntryData = JournalEntryData(
50, [CurrencyData("USD", [self.l_r_or1d, self.l_r_or4d],
[self.l_r_or1c, self.l_r_or4c])])
self.j_r_or2: JournalEntryData = JournalEntryData(
30, [CurrencyData("USD", [self.l_r_or2d, self.l_r_or3d],
[self.l_r_or2c, self.l_r_or3c])])
self.j_p_or1: JournalEntryData = JournalEntryData(
40, [CurrencyData("USD", [self.l_p_or1d, self.l_p_or4d],
[self.l_p_or1c, self.l_p_or4c])])
self.j_p_or2: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD", [self.l_p_or2d, self.l_p_or3d],
[self.l_p_or2c, self.l_p_or3c])])
self.__add_journal_entry(self.j_r_or1)
self.__add_journal_entry(self.j_r_or2)
self.__add_journal_entry(self.j_p_or1)
self.__add_journal_entry(self.j_p_or2)
# Receivable offset items
self.l_r_of1d, self.l_r_of1c = couple(
"Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of1c.original_line_item = self.l_r_or1d
self.l_r_of2d, self.l_r_of2c = couple(
"Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of2c.original_line_item = self.l_r_or1d
self.l_r_of3d, self.l_r_of3c = couple(
"Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of3c.original_line_item = self.l_r_or1d
self.l_r_of4d, self.l_r_of4c = couple(
"Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of4c.original_line_item = self.l_r_or2d
self.l_r_of5d, self.l_r_of5c = couple(
"Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of5c.original_line_item = self.l_r_or4d
# Payable offset items
self.l_p_of1d, self.l_p_of1c = couple(
"Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of1d.original_line_item = self.l_p_or1c
self.l_p_of2d, self.l_p_of2c = couple(
"Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of2d.original_line_item = self.l_p_or1c
self.l_p_of3d, self.l_p_of3c = couple(
"Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of3d.original_line_item = self.l_p_or1c
self.l_p_of4d, self.l_p_of4c = couple(
"Phone", "400", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of4d.original_line_item = self.l_p_or2c
self.l_p_of5d, self.l_p_of5c = couple(
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of5d.original_line_item = self.l_p_or4c
# Offset journal entries
self.j_r_of1: JournalEntryData = JournalEntryData(
25, [CurrencyData("USD", [self.l_r_of1d], [self.l_r_of1c])])
self.j_r_of2: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD",
[self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
[self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
self.j_r_of3: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.l_r_of5d], [self.l_r_of5c])])
self.j_p_of1: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.l_p_of1d], [self.l_p_of1c])])
self.j_p_of2: JournalEntryData = JournalEntryData(
10, [CurrencyData("USD",
[self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
[self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
self.j_p_of3: JournalEntryData = JournalEntryData(
5, [CurrencyData("USD", [self.l_p_of5d], [self.l_p_of5c])])
self.__add_journal_entry(self.j_r_of1)
self.__add_journal_entry(self.j_r_of2)
self.__add_journal_entry(self.j_r_of3)
self.__add_journal_entry(self.j_p_of1)
self.__add_journal_entry(self.j_p_of2)
self.__add_journal_entry(self.j_p_of3)
def __add_journal_entry(self, journal_entry_data: JournalEntryData) \
-> None:
"""Adds a journal entry.
:param journal_entry_data: The journal entry data.
:return: None.
"""
from accounting.models import JournalEntry
store_uri: str = "/accounting/journal-entries/store/transfer"
response: httpx.Response = self.client.post(
store_uri, data=journal_entry_data.new_form(self.csrf_token))
assert response.status_code == 302
journal_entry_id: int \
= match_journal_entry_detail(response.headers["Location"])
journal_entry_data.id = journal_entry_id
with self.app.app_context():
journal_entry: JournalEntry | None \
= db.session.get(JournalEntry, journal_entry_id)
assert journal_entry is not None
for i in range(len(journal_entry.currencies)):
for j in range(len(journal_entry.currencies[i].debit)):
journal_entry_data.currencies[i].debit[j].id \
= journal_entry.currencies[i].debit[j].id
for j in range(len(journal_entry.currencies[i].credit)):
journal_entry_data.currencies[i].credit[j].id \
= journal_entry.currencies[i].credit[j].id