Compare commits

...

8 Commits

Author SHA1 Message Date
260e3cbe82 Advanced to version 1.3.3. 2023-04-13 09:56:16 +08:00
cd039520b6 Added permission checks to the reset routes in the test site. 2023-04-13 09:54:20 +08:00
05e652aa62 Changed the "_journal_entries" and "_line_items" properties in the BaseTestData class from protected to private, renaming them to "__journal_entries" and "__line_items", respectively. There is no need to access it from the child classes anymore. 2023-04-13 09:28:53 +08:00
5c9bf0638c Removed the "csv_data" pseudo property from BaseTestData. 2023-04-13 09:25:50 +08:00
bbc78433fd Moved the sample data generation from the make-sample.py script to the test site. The sample data is generated at real time. This avoids the problem with pre-recorded sample data that the beginning of the months and weeks changes with the day resetting the sample data. 2023-04-13 09:23:57 +08:00
7bcc2b28b2 Moved the JournalEntryLineItemData, JournalEntryCurrencyData, JournalEntryData, and BaseTestData classes from testlib.py to the ".lib" module in the test site. 2023-04-13 08:30:07 +08:00
c1d9ca284c Changed the new_form and update_form methods of the JournalEntryData class in testlib.py to receive the next URI as the parameter instead of the constant, so that the JournalEntryData class can move to other places. 2023-04-13 08:23:52 +08:00
165e28441a Changed the sample data format from JSON to CSV for the test site live demonstration. 2023-04-12 21:33:34 +08:00
11 changed files with 710 additions and 770 deletions

View File

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

View File

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

View File

@ -1,306 +0,0 @@
#! 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

@ -26,9 +26,10 @@ import httpx
from flask import Flask
from test_site import db
from testlib import Accounts, create_test_app, get_client, \
match_journal_entry_detail, JournalEntryLineItemData, \
JournalEntryCurrencyData, JournalEntryData, BaseTestData
from test_site.lib import JournalEntryLineItemData, JournalEntryCurrencyData, \
JournalEntryData, BaseTestData
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
match_journal_entry_detail
PREFIX: str = "/accounting/journal-entries"
"""The URL prefix for the journal entry management."""
@ -84,14 +85,14 @@ class OffsetTestCase(unittest.TestCase):
original_line_item=self.data.l_r_or3d)])])
# Non-existing original line item ID
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-original_line_item_id"] = "9999"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# The same debit or credit
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_or1c.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
@ -106,7 +107,8 @@ class OffsetTestCase(unittest.TestCase):
account.is_need_offset = False
db.session.commit()
response = self.client.post(
store_uri, data=journal_entry_data.new_form(self.csrf_token))
store_uri,
data=journal_entry_data.new_form(self.csrf_token, NEXT_URI))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
@ -115,7 +117,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
# The original line item is also an offset
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_of1d.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
@ -124,21 +126,21 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not the same account
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-amount"] \
= str(journal_entry_data.currencies[0].credit[0].amount
+ Decimal("0.01"))
@ -147,7 +149,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-3-amount"] \
= str(journal_entry_data.currencies[0].credit[2].amount
+ Decimal("0.01"))
@ -158,14 +160,14 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items
old_days = journal_entry_data.days
journal_entry_data.days = old_days + 1
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
journal_entry_data.days = old_days
# Success
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
journal_entry_id: int \
@ -194,14 +196,14 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[2].amount = Decimal("600")
# Non-existing original line item ID
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-original_line_item_id"] = "9999"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# The same debit or credit
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_or1c.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
@ -217,7 +219,8 @@ class OffsetTestCase(unittest.TestCase):
account.is_need_offset = False
db.session.commit()
response = self.client.post(
update_uri, data=journal_entry_data.update_form(self.csrf_token))
update_uri,
data=journal_entry_data.update_form(self.csrf_token, NEXT_URI))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context():
@ -226,7 +229,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
# The original line item is also an offset
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_of1d.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
@ -235,21 +238,21 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01"))
@ -261,7 +264,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01"))
@ -275,14 +278,14 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items
old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days
# Success
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
@ -307,21 +310,21 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[1].amount = Decimal("3.4")
# Not the same currency
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount
- Decimal("0.01"))
@ -333,7 +336,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-2-amount"] \
= str(journal_entry_data.currencies[0].debit[1].amount
- Decimal("0.01"))
@ -347,21 +350,21 @@ class OffsetTestCase(unittest.TestCase):
# Not after the offset items
old_days: int = journal_entry_data.days
journal_entry_data.days = old_days - 1
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days
# Not deleting matched original line items
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
del form["currency-1-debit-1-id"]
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Success
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
@ -408,14 +411,14 @@ class OffsetTestCase(unittest.TestCase):
[])])
# Non-existing original line item ID
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-original_line_item_id"] = "9999"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# The same debit or credit
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_or1d.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
@ -430,7 +433,8 @@ class OffsetTestCase(unittest.TestCase):
account.is_need_offset = False
db.session.commit()
response = self.client.post(
store_uri, data=journal_entry_data.new_form(self.csrf_token))
store_uri,
data=journal_entry_data.new_form(self.csrf_token, NEXT_URI))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
@ -439,7 +443,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
# The original line item is also an offset
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_of1c.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
@ -448,21 +452,21 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not the same account
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01"))
@ -471,7 +475,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01"))
@ -482,14 +486,14 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items
old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
journal_entry_data.days = old_days
# Success
form = journal_entry_data.new_form(self.csrf_token)
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
journal_entry_id: int \
@ -518,14 +522,14 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[2].amount = Decimal("900")
# Non-existing original line item ID
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-original_line_item_id"] = "9999"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# The same debit or credit
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_or1d.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
@ -541,7 +545,8 @@ class OffsetTestCase(unittest.TestCase):
account.is_need_offset = False
db.session.commit()
response = self.client.post(
update_uri, data=journal_entry_data.update_form(self.csrf_token))
update_uri,
data=journal_entry_data.update_form(self.csrf_token, NEXT_URI))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context():
@ -550,7 +555,7 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
# The original line item is also an offset
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_of1c.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
@ -559,21 +564,21 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01"))
@ -585,7 +590,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01"))
@ -599,14 +604,14 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items
old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days
# Success
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
journal_entry_id: int \
@ -635,21 +640,21 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[1].amount = Decimal("0.9")
# Not the same currency
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount
- Decimal("0.01"))
@ -661,7 +666,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form["currency-1-debit-2-amount"] \
= str(journal_entry_data.currencies[0].debit[1].amount
- Decimal("0.01"))
@ -675,21 +680,21 @@ class OffsetTestCase(unittest.TestCase):
# Not after the offset items
old_days: int = journal_entry_data.days
journal_entry_data.days = old_days - 1
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days
# Not deleting matched original line items
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
del form["currency-1-credit-1-id"]
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Success
form = journal_entry_data.update_form(self.csrf_token)
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],

View File

@ -23,7 +23,8 @@ from datetime import date
import httpx
from flask import Flask
from testlib import create_test_app, get_client, Accounts, BaseTestData
from test_site.lib import BaseTestData
from testlib import create_test_app, get_client, Accounts
PREFIX: str = "/accounting"
"""The URL prefix for the reports."""

View File

@ -17,8 +17,10 @@
"""The authentication for the Mia! Accounting demonstration website.
"""
import typing as t
from flask import Blueprint, render_template, Flask, redirect, url_for, \
session, request, g, Response
session, request, g, Response, abort
from . import db
@ -93,6 +95,31 @@ def current_user() -> User | None:
return g.user
def admin_required(view: t.Callable) -> t.Callable:
"""The view decorator to require the user to be an administrator.
:param view: The view.
:return: The decorated view.
"""
def decorated_view(*args, **kwargs):
"""The decorated view that tests against a permission rule.
:param args: The arguments of the view.
:param kwargs: The keyword arguments of the view.
:return: The response of the view.
:raise Forbidden: When the user is denied.
"""
from accounting.utils.next_uri import append_next
if "user" not in session:
return redirect(append_next(url_for("auth.login")))
if session["user"] != "admin":
abort(403)
return view(*args, **kwargs)
return decorated_view
def init_app(app: Flask) -> None:
"""Initialize the localization.

File diff suppressed because one or more lines are too long

335
tests/test_site/lib.py Normal file
View File

@ -0,0 +1,335 @@
# The Mia! Accounting Demonstration Website.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/13
# 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 library for the Mia! Accounting demonstration website.
"""
from __future__ import annotations
import typing as t
from abc import ABC, abstractmethod
from datetime import date, timedelta
from decimal import Decimal
from secrets import randbelow
import sqlalchemy as sa
from flask import Flask
from . import db
from .auth import User
class Accounts:
"""The shortcuts to the common accounts."""
CASH: str = "1111-001"
BANK: str = "1113-001"
RECEIVABLE: str = "1141-001"
MACHINERY: str = "1441-001"
PAYABLE: str = "2141-001"
SERVICE: str = "4611-001"
RENT_EXPENSE: str = "6252-001"
MEAL: str = "6272-001"
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, next_uri: str) -> dict[str, str]:
"""Returns the journal entry as a creation form.
:param csrf_token: The CSRF token.
:param next_uri: The next URI.
:return: The journal entry as a creation form.
"""
return self.__form(csrf_token, next_uri, is_update=False)
def update_form(self, csrf_token: str, next_uri: str) -> dict[str, str]:
"""Returns the journal entry as an update form.
:param csrf_token: The CSRF token.
:param next_uri: The next URI.
:return: The journal entry as an update form.
"""
return self.__form(csrf_token, next_uri, is_update=True)
def __form(self, csrf_token: str, next_uri: str, is_update: bool = False) \
-> dict[str, str]:
"""Returns the journal entry as a form.
:param csrf_token: The CSRF token.
:param next_uri: The next URI.
: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()
@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

@ -17,25 +17,22 @@
"""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
render_template, current_app
from flask_babel import lazy_gettext
from accounting.utils.cast import s
from . import db
from .auth import User, current_user
from .auth import admin_required
from .lib import Accounts, JournalEntryLineItemData, JournalEntryData, \
JournalEntryCurrencyData, BaseTestData
bp: Blueprint = Blueprint("reset", __name__, url_prefix="/")
@bp.get("reset", endpoint="reset-page")
@admin_required
def reset() -> str:
"""Resets the sample data.
@ -45,79 +42,33 @@ def reset() -> str:
@bp.post("sample", endpoint="sample")
@admin_required
def reset_sample() -> redirect:
"""Resets the sample data.
:return: Redirection to the accounting application.
"""
from accounting.utils.cast import s
__reset_database()
__populate_sample_data()
SampleData(current_app, "editor").populate()
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")
@admin_required
def clean_up() -> redirect:
"""Clean-up the database data.
:return: Redirection to the accounting application.
"""
from accounting.utils.cast import s
__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.
@ -141,6 +92,273 @@ def __reset_database() -> None:
init_base_accounts_command()
init_accounts_command(session["user"])
init_currencies_command(session["user"])
db.session.commit()
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)])]))
def init_app(app: Flask) -> None:
@ -150,4 +368,3 @@ def init_app(app: Flask) -> None:
:return: None.
"""
app.register_blueprint(bp)

View File

@ -23,8 +23,9 @@ import httpx
from flask import Flask
from test_site import db
from testlib import create_test_app, get_client, Accounts, \
JournalEntryCurrencyData, JournalEntryData, BaseTestData
from test_site.lib import JournalEntryCurrencyData, JournalEntryData, \
BaseTestData
from testlib import create_test_app, get_client, Accounts
PREFIX: str = "/accounting/unmatched-offsets"
"""The URL prefix for the unmatched offset management."""

View File

@ -19,21 +19,13 @@
"""
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, db
from test_site.auth import User
from test_site import create_app
TEST_SERVER: str = "https://testserver"
"""The test server URI."""
@ -163,334 +155,3 @@ def match_journal_entry_detail(location: str) -> int:
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