Compare commits
No commits in common. "master" and "flask" have entirely different histories.
29
.gitignore
vendored
29
.gitignore
vendored
@ -1,24 +1,5 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
__pycache__
|
||||
/drcr/config.toml
|
||||
/drcr/static/build
|
||||
/instance
|
||||
/venv
|
||||
|
7
.vscode/extensions.json
vendored
7
.vscode/extensions.json
vendored
@ -1,7 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
661
COPYING
Normal file
661
COPYING
Normal file
@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
110
austax/__init__.py
Normal file
110
austax/__init__.py
Normal file
@ -0,0 +1,110 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import render_template, url_for
|
||||
|
||||
from drcr.models import AccountConfiguration, Posting, Transaction, TrialBalancer, reporting_commodity
|
||||
from drcr.database import db
|
||||
from drcr.webapp import eofy_date, sofy_date
|
||||
import drcr.plugins
|
||||
|
||||
from . import views # Load routes
|
||||
from .reports import tax_summary_report
|
||||
from .util import assert_aud
|
||||
|
||||
def plugin_init():
|
||||
drcr.plugins.data_sources.append(('cgt_adjustments', 'CGT adjustments'))
|
||||
drcr.plugins.advanced_reports.append(('cgt_assets', 'CGT assets'))
|
||||
drcr.plugins.advanced_reports.append(('tax_summary', 'Tax summary'))
|
||||
|
||||
drcr.plugins.account_kinds.append(('austax.income1', 'Salary or wages (1)'))
|
||||
drcr.plugins.account_kinds.append(('austax.income5', 'Australian Government allowances and payments (5)'))
|
||||
drcr.plugins.account_kinds.append(('austax.income10', 'Gross interest (10)'))
|
||||
drcr.plugins.account_kinds.append(('austax.income13', 'Partnerships and trusts (13)'))
|
||||
#drcr.plugins.account_kinds.append(('austax.income18', 'Net capital gain (18)'))
|
||||
drcr.plugins.account_kinds.append(('austax.income20', 'Foreign source income and foreign assets or property (20)'))
|
||||
drcr.plugins.account_kinds.append(('austax.d2', 'Work-related travel expenses (D2)'))
|
||||
drcr.plugins.account_kinds.append(('austax.d4', 'Work-related self-education expenses (D4)'))
|
||||
drcr.plugins.account_kinds.append(('austax.d5', 'Other work-related expenses (D5)'))
|
||||
drcr.plugins.account_kinds.append(('austax.d9', 'Gifts or donations (D9)'))
|
||||
drcr.plugins.account_kinds.append(('austax.d15', 'Other deductions (D15)'))
|
||||
drcr.plugins.account_kinds.append(('austax.offset', 'Tax offset'))
|
||||
drcr.plugins.account_kinds.append(('austax.paygw', 'PAYG withheld amounts'))
|
||||
drcr.plugins.account_kinds.append(('austax.cgtasset', 'CGT asset'))
|
||||
drcr.plugins.account_kinds.append(('austax.rfb', 'Reportable fringe benefit'))
|
||||
|
||||
drcr.plugins.transaction_providers.append(make_tax_transactions)
|
||||
|
||||
@assert_aud
|
||||
def make_tax_transactions(transactions, start_date=None, end_date=None, api_stage_until=None):
|
||||
if api_stage_until is not None and api_stage_until < 300:
|
||||
return transactions
|
||||
|
||||
# Get EOFY date
|
||||
dt = eofy_date()
|
||||
|
||||
if (start_date is not None and start_date > dt) or (end_date is not None and end_date < dt):
|
||||
return transactions
|
||||
|
||||
# Get trial balance
|
||||
balancer = TrialBalancer.from_cached(start_date=sofy_date(), end_date=dt)
|
||||
balancer.apply_transactions(transactions)
|
||||
|
||||
report = tax_summary_report(balancer)
|
||||
tax_amount = report.by_id('total_tax').amount - report.by_id('offsets').amount
|
||||
|
||||
# Estimated tax payable
|
||||
transactions.append(Transaction(
|
||||
dt=dt,
|
||||
description='Estimated income tax',
|
||||
postings=[
|
||||
Posting(account='Income Tax', quantity=tax_amount.quantity, commodity=reporting_commodity()),
|
||||
Posting(account='Income Tax Control', quantity=-tax_amount.quantity, commodity=reporting_commodity())
|
||||
]
|
||||
))
|
||||
|
||||
# Mandatory study loan repayment
|
||||
loan_repayment = report.by_id('loan_repayment').amount
|
||||
if loan_repayment.quantity != 0:
|
||||
transactions.append(Transaction(
|
||||
dt=dt,
|
||||
description='Mandatory study loan repayment payable',
|
||||
postings=[
|
||||
Posting(account='HELP', quantity=loan_repayment.quantity, commodity=reporting_commodity()), # FIXME: Correct account
|
||||
Posting(account='Income Tax Control', quantity=-loan_repayment.quantity, commodity=reporting_commodity())
|
||||
]
|
||||
))
|
||||
|
||||
accounts = dict(sorted(balancer.accounts.items()))
|
||||
|
||||
# Get account configurations
|
||||
account_configurations = AccountConfiguration.get_all_kinds()
|
||||
|
||||
# PAYG withholding
|
||||
for account_name, kinds in account_configurations.items():
|
||||
if 'austax.paygw' in kinds:
|
||||
if account_name in accounts and accounts[account_name].quantity != 0:
|
||||
# Transfer balance to Income Tax Control
|
||||
transactions.append(Transaction(
|
||||
dt=dt,
|
||||
description='PAYG withheld amounts',
|
||||
postings=[
|
||||
Posting(account='Income Tax Control', quantity=accounts[account_name].quantity, commodity=reporting_commodity()),
|
||||
Posting(account=account_name, quantity=-accounts[account_name].quantity, commodity=reporting_commodity())
|
||||
]
|
||||
))
|
||||
|
||||
return transactions
|
76
austax/models.py
Normal file
76
austax/models.py
Normal file
@ -0,0 +1,76 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from drcr.database import db
|
||||
from drcr.models import Amount, reporting_commodity
|
||||
from drcr.webapp import eofy_date
|
||||
|
||||
class CGTAsset(Amount):
|
||||
def __init__(self, quantity, commodity, account, acquisition_date):
|
||||
super().__init__(quantity, commodity)
|
||||
|
||||
self.account = account
|
||||
self.acquisition_date = acquisition_date
|
||||
|
||||
self.disposal_date = None
|
||||
self.disposal_value = None
|
||||
|
||||
self.cost_adjustments = []
|
||||
|
||||
def __repr__(self):
|
||||
return '<{}: {} [{:%Y-%m-%d}]>'.format(self.__class__.__name__, self.format('force'), self.acquisition_date)
|
||||
|
||||
def commodity_name(self):
|
||||
return self.commodity[:self.commodity.index('{')].strip()
|
||||
|
||||
def cost_adjustment(self):
|
||||
return Amount(sum(a.cost_adjustment for a in self.cost_adjustments), reporting_commodity())
|
||||
|
||||
def cost_adjustment_brought_forward(self):
|
||||
date1 = eofy_date()
|
||||
date1 = date1.replace(year=date1.year - 1)
|
||||
|
||||
return Amount(sum(a.cost_adjustment for a in self.cost_adjustments if a.dt <= date1), reporting_commodity())
|
||||
|
||||
def cost_adjustment_current_period(self):
|
||||
date1 = eofy_date()
|
||||
date1 = date1.replace(year=date1.year - 1)
|
||||
date2 = eofy_date()
|
||||
|
||||
return Amount(sum(a.cost_adjustment for a in self.cost_adjustments if a.dt > date1 and a.dt <= date2), reporting_commodity())
|
||||
|
||||
def gain(self):
|
||||
return self.disposal_value - (self.as_cost() + self.cost_adjustment())
|
||||
|
||||
class CGTCostAdjustment(db.Model):
|
||||
__tablename__ = 'austax_cgt_cost_adjustments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
quantity = db.Column(db.Integer)
|
||||
commodity = db.Column(db.String)
|
||||
account = db.Column(db.String)
|
||||
acquisition_date = db.Column(db.DateTime)
|
||||
|
||||
dt = db.Column(db.DateTime)
|
||||
description = db.Column(db.String)
|
||||
cost_adjustment = db.Column(db.Integer)
|
||||
|
||||
def asset(self):
|
||||
return CGTAsset(self.quantity, self.commodity, self.account, self.acquisition_date)
|
||||
|
||||
def cost_adjustment_amount(self):
|
||||
return Amount(self.cost_adjustment, reporting_commodity())
|
268
austax/reports.py
Normal file
268
austax/reports.py
Normal file
@ -0,0 +1,268 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from drcr import AMOUNT_DPS
|
||||
from drcr.database import db
|
||||
from drcr.models import AccountConfiguration, Amount, Metadata, Transaction, TrialBalancer, reporting_commodity
|
||||
from drcr.reports import Calculated, Report, Section, Spacer, Subtotal, entries_for_kind
|
||||
from drcr.webapp import eofy_date, sofy_date
|
||||
|
||||
from .tax_tables import base_tax, medicare_levy_threshold, medicare_levy_surcharge_single, repayment_rates, fbt_grossup
|
||||
from .util import assert_aud
|
||||
|
||||
@assert_aud
|
||||
def base_income_tax(year, taxable_income):
|
||||
"""Get the amount of base income tax"""
|
||||
|
||||
for i, (upper_limit, flat_amount, marginal_rate) in enumerate(base_tax[year]):
|
||||
if upper_limit is None or taxable_income.quantity <= upper_limit * (10**AMOUNT_DPS):
|
||||
lower_limit = base_tax[year][i - 1][0] or 0
|
||||
return Amount(flat_amount * (10**AMOUNT_DPS) + marginal_rate * (taxable_income.quantity - lower_limit * (10**AMOUNT_DPS)), reporting_commodity())
|
||||
|
||||
@assert_aud
|
||||
def lito(taxable_income, total_tax):
|
||||
"""Get the amount of low income tax offset"""
|
||||
|
||||
if taxable_income.quantity <= 3750000:
|
||||
# LITO is non-refundable
|
||||
# FIXME: This will not work if we implement multiple non-refundable tax offsets
|
||||
if total_tax.quantity <= 70000:
|
||||
return total_tax
|
||||
return Amount(70000, reporting_commodity())
|
||||
if taxable_income.quantity <= 4500000:
|
||||
return Amount(70000 - 0.05 * (taxable_income.quantity - 3750000), reporting_commodity())
|
||||
if taxable_income.quantity <= 6666700:
|
||||
return Amount(32500 - int(0.015 * (taxable_income.quantity - 4500000)), reporting_commodity())
|
||||
|
||||
return Amount(0, reporting_commodity())
|
||||
|
||||
@assert_aud
|
||||
def medicare_levy(year, taxable_income):
|
||||
lower_threshold, upper_threshold = medicare_levy_threshold[year]
|
||||
|
||||
if taxable_income.quantity < lower_threshold * 100:
|
||||
return Amount(0, reporting_commodity())
|
||||
|
||||
if taxable_income.quantity < upper_threshold * 100:
|
||||
# Medicare levy is 10% of the amount above the lower threshold
|
||||
return Amount((taxable_income - lower_threshold * 100) * 0.1, reporting_commodity())
|
||||
|
||||
# Normal Medicare levy
|
||||
return Amount(int(taxable_income.quantity * 0.02), reporting_commodity())
|
||||
|
||||
@assert_aud
|
||||
def medicare_levy_surcharge(year, taxable_income, rfb_grossedup):
|
||||
mls_income = taxable_income + rfb_grossedup
|
||||
|
||||
for i, (upper_limit, rate) in enumerate(medicare_levy_surcharge_single[year]):
|
||||
if upper_limit is None or mls_income.quantity <= upper_limit * (10**AMOUNT_DPS):
|
||||
return Amount(rate * mls_income.quantity, reporting_commodity())
|
||||
|
||||
@assert_aud
|
||||
def study_loan_repayment(year, taxable_income, rfb_grossedup):
|
||||
"""Get the amount of mandatory study loan repayment"""
|
||||
|
||||
repayment_income = taxable_income + rfb_grossedup
|
||||
|
||||
for upper_limit, rate in repayment_rates[year]:
|
||||
if upper_limit is None or repayment_income.quantity < upper_limit * (10**AMOUNT_DPS):
|
||||
return Amount(rate * repayment_income.quantity, reporting_commodity())
|
||||
|
||||
@assert_aud
|
||||
def tax_summary_report(balancer):
|
||||
accounts = dict(sorted(balancer.accounts.items()))
|
||||
|
||||
# Get account configurations
|
||||
account_configurations = AccountConfiguration.get_all_kinds()
|
||||
|
||||
report = Report(title='Tax summary')
|
||||
report.entries = [
|
||||
Section(
|
||||
entries=[
|
||||
Section(
|
||||
title='Salary or wages (1)',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'austax.income1', neg=True, floor=100) + [Subtotal('Total item 1', id='income1')],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='Australian Government allowances and payments (5)',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'austax.income5', neg=True) + [Subtotal('Total item 5', id='income5', floor=100)],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='Gross interest (10)',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'austax.income10', neg=True) + [Subtotal('Total item 10', id='income10', floor=100)],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='Partnerships and trusts (13)',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'austax.income13', neg=True, floor=100) + [Subtotal('Total item 13', id='income13', floor=100)],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
#Section(
|
||||
# title='Net capital gains (18)',
|
||||
# entries=entries_for_kind(account_configurations, accounts, 'austax.income18', neg=True) + [Subtotal('Total item 18', id='income18', floor=100)]
|
||||
#),
|
||||
#Spacer(),
|
||||
Section(
|
||||
title='Foreign source income and foreign assets or property (20)',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'austax.income20', neg=True, floor=100) + [Subtotal('Total item 20', id='income20', floor=100)],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
Calculated(
|
||||
'Total assessable income',
|
||||
lambda r: r.by_id('income1').amount + r.by_id('income5').amount + r.by_id('income10').amount + r.by_id('income13').amount + r.by_id('income20').amount,
|
||||
id='assessable',
|
||||
heading=True,
|
||||
bordered=True
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='Work-related travel expenses (D2)',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'austax.d2') + [Subtotal('Total item D2', id='d2', floor=100)],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='Work-related self-education expenses (D4)',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'austax.d4') + [Subtotal('Total item D4', id='d4', floor=100)],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='Other work-related expenses (D5)',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'austax.d5') + [Subtotal('Total item D5', id='d5', floor=100)],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='Gifts or donations (D9)',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'austax.d9') + [Subtotal('Total item D9', id='d9', floor=100)],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='Other deductions (D15)',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'austax.d15') + [Subtotal('Total item D15', id='d15', floor=100)],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
Calculated(
|
||||
'Total deductions',
|
||||
lambda r: r.by_id('d2').amount + r.by_id('d4').amount + r.by_id('d5').amount + r.by_id('d9').amount + r.by_id('d15').amount,
|
||||
id='deductions',
|
||||
heading=True,
|
||||
bordered=True
|
||||
),
|
||||
Spacer(),
|
||||
Calculated(
|
||||
'Net taxable income',
|
||||
lambda r: r.by_id('assessable').amount - r.by_id('deductions').amount,
|
||||
id='taxable',
|
||||
heading=True,
|
||||
bordered=True
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
entries=[
|
||||
Calculated(
|
||||
'Taxable value of reportable fringe benefits',
|
||||
lambda _: -sum((e.amount for e in entries_for_kind(account_configurations, accounts, 'austax.rfb')), Amount(0, reporting_commodity())),
|
||||
id='rfb_taxable'
|
||||
),
|
||||
Calculated(
|
||||
'Grossed-up value',
|
||||
lambda _: Amount(report.by_id('rfb_taxable').amount.quantity * fbt_grossup[eofy_date().year], reporting_commodity()),
|
||||
id='rfb_grossedup'
|
||||
)
|
||||
],
|
||||
visible=False # Precompute RFB amount as this is required for MLS
|
||||
),
|
||||
Section(
|
||||
entries=[
|
||||
Calculated(
|
||||
'Base income tax',
|
||||
lambda _: base_income_tax(eofy_date().year, report.by_id('taxable').amount)
|
||||
),
|
||||
Calculated(
|
||||
'Medicare levy',
|
||||
lambda _: medicare_levy(eofy_date().year, report.by_id('taxable').amount),
|
||||
auto_hide=True
|
||||
),
|
||||
Calculated(
|
||||
'Medicare levy surcharge',
|
||||
lambda _: medicare_levy_surcharge(eofy_date().year, report.by_id('taxable').amount, report.by_id('rfb_grossedup').amount),
|
||||
auto_hide=True
|
||||
),
|
||||
Subtotal('Total income tax', id='total_tax', bordered=True)
|
||||
]
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='Tax offsets',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'austax.offset', neg=True) + [
|
||||
Calculated('Low income tax offset', lambda _: lito(report.by_id('taxable').amount, report.by_id('total_tax').amount), auto_hide=True),
|
||||
Subtotal('Total tax offsets', id='offsets')
|
||||
],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
entries=[
|
||||
Calculated(
|
||||
'Taxable value of reportable fringe benefits',
|
||||
lambda _: report.by_id('rfb_taxable').amount,
|
||||
auto_hide=True
|
||||
),
|
||||
Calculated(
|
||||
'Grossed-up value',
|
||||
lambda _: report.by_id('rfb_grossedup').amount,
|
||||
auto_hide=True
|
||||
),
|
||||
Calculated(
|
||||
'Mandatory study loan repayment',
|
||||
lambda _: study_loan_repayment(eofy_date().year, report.by_id('taxable').amount, report.by_id('rfb_grossedup').amount),
|
||||
id='loan_repayment',
|
||||
heading=True,
|
||||
auto_hide=True
|
||||
)
|
||||
],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='PAYG withheld amounts',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'austax.paygw') + [Subtotal('Total withheld amounts', id='paygw')],
|
||||
auto_hide=True
|
||||
),
|
||||
Spacer(),
|
||||
Calculated(
|
||||
'ATO liability payable (refundable)',
|
||||
lambda _: report.by_id('total_tax').amount - report.by_id('offsets').amount - report.by_id('paygw').amount + report.by_id('loan_repayment').amount,
|
||||
heading=True,
|
||||
bordered=True
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
report.calculate()
|
||||
|
||||
return report
|
112
austax/tax_tables.py
Normal file
112
austax/tax_tables.py
Normal file
@ -0,0 +1,112 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
# Base income tax
|
||||
# https://www.ato.gov.au/rates/individual-income-tax-rates/
|
||||
# Maps each financial year to list of (upper limit (INclusive), flat amount, marginal rate)
|
||||
base_tax = {
|
||||
2024: [
|
||||
(18200, 0, 0),
|
||||
(45000, 0, 0.19),
|
||||
(120000, 5092, 0.325),
|
||||
(180000, 29467, 0.37),
|
||||
(None, 51667, 0.45)
|
||||
],
|
||||
2023: [
|
||||
(18200, 0, 0),
|
||||
(45000, 0, 0.19),
|
||||
(120000, 5092, 0.325),
|
||||
(180000, 29467, 0.37),
|
||||
(None, 51667, 0.45)
|
||||
]
|
||||
}
|
||||
|
||||
# Study and training loan (HELP, etc.) repayment thresholds and rates
|
||||
# https://www.ato.gov.au/Rates/HELP,-TSL-and-SFSS-repayment-thresholds-and-rates/
|
||||
# Maps each financial year to list of (upper limit (EXclusive), repayment rate)
|
||||
repayment_rates = {
|
||||
2024: [
|
||||
(51550, 0),
|
||||
(59519, 0.01),
|
||||
(63090, 0.02),
|
||||
(66876, 0.025),
|
||||
(70889, 0.03),
|
||||
(75141, 0.035),
|
||||
(79650, 0.04),
|
||||
(84430, 0.045),
|
||||
(89495, 0.05),
|
||||
(94866, 0.055),
|
||||
(100558, 0.06),
|
||||
(106591, 0.065),
|
||||
(112986, 0.07),
|
||||
(119765, 0.075),
|
||||
(126951, 0.08),
|
||||
(134569, 0.085),
|
||||
(142643, 0.09),
|
||||
(151201, 0.095),
|
||||
(None, 0.1)
|
||||
],
|
||||
2023: [
|
||||
(48361, 0),
|
||||
(55837, 0.01),
|
||||
(59187, 0.02),
|
||||
(62739, 0.025),
|
||||
(66503, 0.03),
|
||||
(70493, 0.035),
|
||||
(74723, 0.04),
|
||||
(79207, 0.045),
|
||||
(83959, 0.05),
|
||||
(88997, 0.055),
|
||||
(94337, 0.06),
|
||||
(99997, 0.065),
|
||||
(105997, 0.07),
|
||||
(112356, 0.075),
|
||||
(119098, 0.08),
|
||||
(126244, 0.085),
|
||||
(133819, 0.09),
|
||||
(141848, 0.095),
|
||||
(None, 0.1)
|
||||
]
|
||||
}
|
||||
|
||||
# Medicare levy thresholds
|
||||
# https://www.ato.gov.au/Individuals/Medicare-and-private-health-insurance/Medicare-levy/Medicare-levy-reduction/Medicare-levy-reduction-for-low-income-earners/
|
||||
# Maps each financial year to list of (lower threshold, upper threshold)
|
||||
medicare_levy_threshold = {
|
||||
2024: (26000, 32500), # Treasury Laws Amendment (Cost of Living—Medicare Levy) Act 2024
|
||||
2023: (24276, 30345),
|
||||
2022: (23365, 29207)
|
||||
}
|
||||
|
||||
# Medicare levy surcharge rates (singles)
|
||||
# https://www.ato.gov.au/individuals-and-families/medicare-and-private-health-insurance/medicare-levy-surcharge/medicare-levy-surcharge-income-thresholds-and-rates
|
||||
# Maps each financial year to list of (upper limit (INclusive), MLS rate)
|
||||
# FIXME: Only supports singles
|
||||
medicare_levy_surcharge_single = {
|
||||
2024: [
|
||||
(93000, 0),
|
||||
(108000, 0.01),
|
||||
(144000, 0.0125),
|
||||
(None, 0.015)
|
||||
]
|
||||
}
|
||||
|
||||
# FBT type 1 gross-up factor
|
||||
# https://www.ato.gov.au/rates/fbt/#GrossupratesforFBT
|
||||
fbt_grossup = {
|
||||
2024: 2.0802,
|
||||
2023: 2.0802
|
||||
}
|
77
austax/templates/cgt_adjustments.html
Normal file
77
austax/templates/cgt_adjustments.html
Normal file
@ -0,0 +1,77 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}CGT adjustments{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading">
|
||||
CGT adjustments
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex gap-x-2">
|
||||
<a href="{{ url_for('cgt_adjustment_new') }}" class="btn-primary pl-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New CGT adjustment
|
||||
</a>
|
||||
<a href="{{ url_for('cgt_adjustment_multinew') }}" class="btn-secondary pl-2 text-emerald-700 ring-emerald-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Multiple CGT adjustments
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Account</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Asset</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Units</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Acquisition date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Acquisition value</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Adjustment date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Description</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Cost adjustment </th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cgt_adjustment in cgt_adjustments %}
|
||||
<tr>
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ cgt_adjustment.account }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ cgt_adjustment.asset().commodity_name() }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ cgt_adjustment.asset().format('hide') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ cgt_adjustment.acquisition_date.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ cgt_adjustment.asset().as_cost().format() }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ cgt_adjustment.dt.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ cgt_adjustment.description }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ cgt_adjustment.cost_adjustment_amount().format_accounting() }}</td>
|
||||
<td class="py-0.5 pl-1 text-end">
|
||||
<a href="{{ url_for('cgt_adjustment_edit', id=cgt_adjustment.id) }}" class="text-gray-500 hover:text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 inline align-middle -mt-0.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
73
austax/templates/cgt_adjustments_edit.html
Normal file
73
austax/templates/cgt_adjustments_edit.html
Normal file
@ -0,0 +1,73 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ 'Edit' if adjustment else 'New' }} CGT adjustment{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-lg mx-auto px-4">
|
||||
<h1 class="page-heading mb-4">
|
||||
{{ 'Edit' if adjustment else 'New' }} CGT adjustment
|
||||
</h1>
|
||||
|
||||
<form method="POST">
|
||||
<div class="grid grid-cols-[max-content_1fr] space-y-2 mb-4 items-baseline">
|
||||
<h2 class="col-span-2 text-xl text-gray-900 font-semibold">CGT asset</h2>
|
||||
|
||||
<label for="acquisition_date" class="block text-gray-900 pr-4">Acquisition date</label>
|
||||
<div>
|
||||
<input type="date" class="bordered-field" name="acquisition_date" id="acquisition_date" value="{{ adjustment.acquisition_date.strftime('%Y-%m-%d') if adjustment else request.args.get('acquisition_date', '') }}">
|
||||
</div>
|
||||
<label for="account" class="block text-gray-900 pr-4">Account</label>
|
||||
<div class="relative combobox">
|
||||
<input type="text" class="bordered-field peer" name="account" id="account" value="{{ adjustment.account if adjustment else request.args.get('account', '') }}" autocomplete="off">
|
||||
{% include 'components/accounts_combobox_inner.html' %}
|
||||
</div>
|
||||
<label for="asset" class="block text-gray-900 pr-4">Asset</label>
|
||||
<div>
|
||||
<input type="text" class="bordered-field" name="asset" id="asset" value="{{ adjustment.asset().quantity_string() if adjustment else request.args.get('asset', '') }}">
|
||||
</div>
|
||||
|
||||
<h2 class="col-span-2 text-xl text-gray-900 font-semibold pt-4">CGT adjustment</h2>
|
||||
|
||||
<label for="dt" class="block text-gray-900 pr-4">Adjustment date</label>
|
||||
<div>
|
||||
<input type="date" class="bordered-field" name="dt" id="dt" value="{{ adjustment.dt.strftime('%Y-%m-%d') if adjustment else '' }}">
|
||||
</div>
|
||||
<label for="description" class="block text-gray-900 pr-4">Description</label>
|
||||
<div>
|
||||
<input type="text" class="bordered-field" name="description" id="description" value="{{ adjustment.description if adjustment else '' }}">
|
||||
</div>
|
||||
<label for="cost_adjustment" class="block text-gray-900 pr-4">Cost adjustment</label>
|
||||
<div class="relative shadow-sm">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||
</div>
|
||||
<input type="number" class="bordered-field pl-7" name="cost_adjustment" id="cost_adjustment" step="0.01" value="{{ adjustment.cost_adjustment_amount().quantity_string() if adjustment else '' }}" placeholder="0.00">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/combobox.js') }}"></script>
|
||||
{% endblock %}
|
82
austax/templates/cgt_adjustments_multinew.html
Normal file
82
austax/templates/cgt_adjustments_multinew.html
Normal file
@ -0,0 +1,82 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Multiple CGT adjustments{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-lg mx-auto px-4">
|
||||
<h1 class="page-heading mb-4">
|
||||
Multiple CGT adjustments
|
||||
</h1>
|
||||
|
||||
<form method="POST">
|
||||
<div class="grid grid-cols-[max-content_1fr] space-y-2 mb-4 items-baseline">
|
||||
<h2 class="col-span-2 text-xl text-gray-900 font-semibold">CGT assets</h2>
|
||||
|
||||
<label for="account" class="block text-gray-900 pr-4">Account</label>
|
||||
<div class="relative combobox">
|
||||
<input type="text" class="bordered-field peer" name="account" id="account" value="{{ account or '' }}" autocomplete="off">
|
||||
{% include 'components/accounts_combobox_inner.html' %}
|
||||
</div>
|
||||
<label for="commodity" class="block text-gray-900 pr-4">Commodity</label>
|
||||
<div>
|
||||
<input type="text" class="bordered-field" name="commodity" id="commodity" value="{{ commodity or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-blue-50 p-4 col-span-2">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm text-blue-700">The total cost adjustment will be distributed proportionally across all matching CGT assets.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="col-span-2 text-xl text-gray-900 font-semibold pt-4">CGT adjustment</h2>
|
||||
|
||||
<label for="dt" class="block text-gray-900 pr-4">Adjustment date</label>
|
||||
<div>
|
||||
<input type="date" class="bordered-field" name="dt" id="dt" value="{{ dt or '' }}">
|
||||
</div>
|
||||
<label for="description" class="block text-gray-900 pr-4">Description</label>
|
||||
<div>
|
||||
<input type="text" class="bordered-field" name="description" id="description" value="{{ description or '' }}">
|
||||
</div>
|
||||
<label for="cost_adjustment" class="block text-gray-900 pr-4">Total cost adjustment</label>
|
||||
<div class="relative shadow-sm">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||
</div>
|
||||
<input type="number" class="bordered-field pl-7" name="cost_adjustment" id="cost_adjustment" step="0.01" value="{{ cost_adjustment or '' }}" placeholder="0.00">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/combobox.js') }}"></script>
|
||||
{% endblock %}
|
78
austax/templates/cgt_assets.html
Normal file
78
austax/templates/cgt_assets.html
Normal file
@ -0,0 +1,78 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}CGT assets{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading">
|
||||
CGT assets
|
||||
</h1>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start border-l border-gray-300" colspan="2">Acquisition</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start border-l border-gray-300" colspan="2">Adjustment</th>
|
||||
<th class="print:hidden"></th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start border-l border-gray-300" colspan="2">Disposal</th>
|
||||
<th class="border-l border-gray-300"></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="py-0.5 text-gray-900 font-semibold text-start">Account</th>
|
||||
<th class="py-0.5 text-gray-900 font-semibold text-start">Asset</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Units</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start border-l border-gray-300">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Value</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end border-l border-gray-300">b/f </th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">{{ eofy_date.year }}</th>
|
||||
<th class="print:hidden"></th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start border-l border-gray-300">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Value</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end border-l border-gray-300">Gain </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for asset in assets %}
|
||||
<tr class="border-t border-gray-300">
|
||||
<td class="py-0.5 pr-1 text-gray-900">
|
||||
<a href="{{ url_for('account_transactions', account=asset.account, commodity_detail=1) }}" class="hover:text-blue-700 hover:underline">{{ asset.account }}</a>
|
||||
</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ asset.commodity_name() }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ asset.format('hide') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 border-l border-gray-300">{{ asset.acquisition_date.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ asset.as_cost().format() }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end border-l border-gray-300">{{ asset.cost_adjustment_brought_forward().format_accounting(link=url_for('cgt_adjustments', account=asset.account, commodity=asset.commodity, quantity=asset.quantity, acquisition_date=asset.acquisition_date.strftime('%Y-%m-%d'))) if asset.cost_adjustment_brought_forward().quantity != 0 }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ asset.cost_adjustment_current_period().format_accounting(link=url_for('cgt_adjustments', account=asset.account, commodity=asset.commodity, quantity=asset.quantity, acquisition_date=asset.acquisition_date.strftime('%Y-%m-%d'))) if asset.cost_adjustment_current_period().quantity != 0 }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-center print:hidden">
|
||||
<a href="{{ url_for('cgt_adjustment_new', account=asset.account, asset=asset.quantity_string(), acquisition_date=asset.acquisition_date.strftime('%Y-%m-%d')) }}" class="text-gray-500 hover:text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</a>
|
||||
</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 border-l border-gray-300">{{ asset.disposal_date.strftime('%Y-%m-%d') if asset.disposal_date else '' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ asset.disposal_value.format() if asset.disposal_value else '' }}</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end border-l border-gray-300">{% if asset.disposal_date %}{{ asset.gain().format_accounting() }}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
31
austax/util.py
Normal file
31
austax/util.py
Normal file
@ -0,0 +1,31 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from drcr.models import reporting_commodity
|
||||
|
||||
import functools
|
||||
|
||||
def assert_aud(f):
|
||||
"""Wrap a function to assert that the reporting_commodity is $"""
|
||||
|
||||
@functools.wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
if reporting_commodity() != '$':
|
||||
raise Exception('austax requires reporting_commodity to be $')
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
246
austax/views.py
Normal file
246
austax/views.py
Normal file
@ -0,0 +1,246 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import redirect, render_template, request, url_for
|
||||
|
||||
from drcr.models import AccountConfiguration, Amount, Posting, Transaction, TrialBalancer, reporting_commodity
|
||||
from drcr.database import db
|
||||
from drcr.plugins import render_plugin_template
|
||||
from drcr.transactions import api_transactions
|
||||
from drcr.webapp import all_accounts, app, eofy_date, sofy_date
|
||||
|
||||
from .models import CGTAsset, CGTCostAdjustment
|
||||
from .reports import tax_summary_report
|
||||
from .util import assert_aud
|
||||
|
||||
from datetime import datetime
|
||||
from math import copysign
|
||||
|
||||
@app.route('/tax/cgt-adjustments')
|
||||
@assert_aud
|
||||
def cgt_adjustments():
|
||||
adjustments = db.select(CGTCostAdjustment).order_by(CGTCostAdjustment.dt.desc(), CGTCostAdjustment.account, CGTCostAdjustment.id.desc())
|
||||
if 'account' in request.args:
|
||||
adjustments = adjustments.where(CGTCostAdjustment.account == request.args['account'])
|
||||
if 'quantity' in request.args:
|
||||
adjustments = adjustments.where(CGTCostAdjustment.quantity == request.args['quantity'])
|
||||
if 'commodity' in request.args:
|
||||
adjustments = adjustments.where(CGTCostAdjustment.commodity == request.args['commodity'])
|
||||
if 'acquisition_date' in request.args:
|
||||
adjustments = adjustments.where(CGTCostAdjustment.acquisition_date == datetime.strptime(request.args['acquisition_date'], '%Y-%m-%d'))
|
||||
|
||||
adjustments = db.session.scalars(adjustments).all()
|
||||
return render_plugin_template('austax', 'cgt_adjustments.html', cgt_adjustments=adjustments)
|
||||
|
||||
@app.route('/tax/cgt-adjustments/new', methods=['GET', 'POST'])
|
||||
@assert_aud
|
||||
def cgt_adjustment_new():
|
||||
if request.method == 'GET':
|
||||
return render_plugin_template('austax', 'cgt_adjustments_edit.html', adjustment=None, all_accounts=all_accounts())
|
||||
|
||||
asset = Amount.parse(request.form['asset'])
|
||||
adjustment = CGTCostAdjustment(
|
||||
quantity=asset.quantity,
|
||||
commodity=asset.commodity,
|
||||
account=request.form['account'],
|
||||
acquisition_date=datetime.strptime(request.form['acquisition_date'], '%Y-%m-%d'),
|
||||
dt=datetime.strptime(request.form['dt'], '%Y-%m-%d'),
|
||||
description=request.form['description'],
|
||||
cost_adjustment=Amount.parse(request.form['cost_adjustment']).quantity
|
||||
)
|
||||
db.session.add(adjustment)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('cgt_adjustments'))
|
||||
|
||||
@app.route('/tax/cgt-adjustments/edit', methods=['GET', 'POST'])
|
||||
@assert_aud
|
||||
def cgt_adjustment_edit():
|
||||
if request.method == 'GET':
|
||||
return render_plugin_template('austax', 'cgt_adjustments_edit.html', adjustment=db.session.get(CGTCostAdjustment, request.args['id']), all_accounts=all_accounts())
|
||||
|
||||
asset = Amount.parse(request.form['asset'])
|
||||
adjustment = db.session.get(CGTCostAdjustment, request.args['id'])
|
||||
adjustment.quantity = asset.quantity
|
||||
adjustment.commodity = asset.commodity
|
||||
adjustment.account = request.form['account']
|
||||
adjustment.acquisition_date = datetime.strptime(request.form['acquisition_date'], '%Y-%m-%d')
|
||||
adjustment.dt = datetime.strptime(request.form['dt'], '%Y-%m-%d')
|
||||
adjustment.description = request.form['description']
|
||||
adjustment.cost_adjustment = Amount.parse(request.form['cost_adjustment']).quantity
|
||||
|
||||
db.session.add(adjustment)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('cgt_adjustments'))
|
||||
|
||||
@app.route('/tax/cgt-adjustments/multi-new', methods=['GET', 'POST'])
|
||||
@assert_aud
|
||||
def cgt_adjustment_multinew():
|
||||
if request.method == 'GET':
|
||||
return render_plugin_template(
|
||||
'austax', 'cgt_adjustments_multinew.html',
|
||||
account=None,
|
||||
commodity=None,
|
||||
dt=None,
|
||||
description=None,
|
||||
cost_adjustment=None,
|
||||
all_accounts=all_accounts()
|
||||
)
|
||||
|
||||
# TODO: Preview mode?
|
||||
|
||||
total_adjustment = Amount.parse(request.form['cost_adjustment']).quantity
|
||||
|
||||
# Get all postings to the CGT asset account
|
||||
cgt_postings = db.session.scalars(
|
||||
db.select(Posting)
|
||||
.where(Posting.account == request.form['account'])
|
||||
.join(Posting.transaction)
|
||||
.order_by(Transaction.dt)
|
||||
).all()
|
||||
|
||||
# Process postings to determine final balances
|
||||
assets = []
|
||||
|
||||
for posting in cgt_postings:
|
||||
if '{' not in posting.commodity and posting.commodity != request.form['commodity']:
|
||||
continue
|
||||
if '{' in posting.commodity and posting.commodity[:posting.commodity.index('{')].strip() != request.form['commodity']:
|
||||
continue
|
||||
|
||||
if posting.quantity >= 0:
|
||||
assets.append(CGTAsset(posting.quantity, posting.commodity, posting.account, posting.transaction.dt))
|
||||
elif posting.quantity < 0:
|
||||
asset = next((a for a in assets if a.commodity == posting.commodity and a.account == posting.account), None)
|
||||
|
||||
if asset is None:
|
||||
raise Exception('Attempted credit {} without preceding debit balance'.quantity(posting.amount()))
|
||||
if asset.quantity + posting.quantity < 0:
|
||||
raise Exception('Attempted credit {} with insufficient debit balance {}'.quantity(posting.amount(), asset.amount()))
|
||||
|
||||
if asset.quantity + posting.quantity != 0:
|
||||
raise NotImplementedError('Partial disposal of CGT asset not implemented')
|
||||
|
||||
assets.remove(asset)
|
||||
|
||||
# Distribute total adjustment across matching assets
|
||||
total_quantity = sum(a.quantity for a in assets)
|
||||
cgt_adjustments = {}
|
||||
|
||||
for asset in assets:
|
||||
cgt_adjustments[asset] = total_adjustment * asset.quantity / total_quantity
|
||||
|
||||
# Round up as many as required to equal the total adjustment
|
||||
rounding_shortfall = abs(total_adjustment) - sum(int(abs(v)) for v in cgt_adjustments.values())
|
||||
largest_remainders = [(k, abs(v) - int(abs(v))) for k, v in cgt_adjustments.items()]
|
||||
largest_remainders.sort(key=lambda x: x[1], reverse=True)
|
||||
for asset, _ in largest_remainders[:rounding_shortfall]:
|
||||
adjustment = cgt_adjustments[asset]
|
||||
adjustment = copysign(int(abs(adjustment)) + 1, adjustment)
|
||||
cgt_adjustments[asset] = adjustment
|
||||
|
||||
# Round others down
|
||||
for asset, adjustment in cgt_adjustments.items():
|
||||
cgt_adjustments[asset] = copysign(int(abs(adjustment)), adjustment)
|
||||
|
||||
# Sanity check
|
||||
assert sum(v for v in cgt_adjustments.values()) == total_adjustment
|
||||
|
||||
# Add adjustments
|
||||
for asset, adjustment in cgt_adjustments.items():
|
||||
adjustment = CGTCostAdjustment(
|
||||
quantity=asset.quantity,
|
||||
commodity=asset.commodity,
|
||||
account=asset.account,
|
||||
acquisition_date=asset.acquisition_date,
|
||||
dt=datetime.strptime(request.form['dt'], '%Y-%m-%d'),
|
||||
description=request.form['description'],
|
||||
cost_adjustment=adjustment
|
||||
)
|
||||
db.session.add(adjustment)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('cgt_adjustments'))
|
||||
|
||||
@app.route('/tax/cgt-assets')
|
||||
@assert_aud
|
||||
def cgt_assets():
|
||||
# Find all CGT asset accounts
|
||||
cgt_accounts = []
|
||||
account_configurations = AccountConfiguration.get_all_kinds()
|
||||
for account_name, kinds in account_configurations.items():
|
||||
if 'austax.cgtasset' in kinds:
|
||||
cgt_accounts.append(account_name)
|
||||
|
||||
# Get all postings to CGT asset accounts
|
||||
cgt_postings = db.session.scalars(
|
||||
db.select(Posting)
|
||||
.where(Posting.account.in_(cgt_accounts))
|
||||
.join(Posting.transaction)
|
||||
.order_by(Transaction.dt)
|
||||
).all()
|
||||
|
||||
# Process postings to determine final balances
|
||||
assets = []
|
||||
|
||||
for posting in cgt_postings:
|
||||
if posting.commodity == reporting_commodity():
|
||||
# FIXME: Detect this better
|
||||
continue
|
||||
|
||||
if posting.quantity >= 0:
|
||||
assets.append(CGTAsset(posting.quantity, posting.commodity, posting.account, posting.transaction.dt))
|
||||
elif posting.quantity < 0:
|
||||
asset = next((a for a in assets if a.commodity == posting.commodity and a.account == posting.account), None)
|
||||
|
||||
if asset is None:
|
||||
raise Exception('Attempted credit {} without preceding debit balance'.format(posting.amount()))
|
||||
if asset.quantity + posting.quantity < 0:
|
||||
raise Exception('Attempted credit {} with insufficient debit balance {}'.format(posting.amount(), asset.amount()))
|
||||
|
||||
if asset.quantity + posting.quantity != 0:
|
||||
raise NotImplementedError('Partial disposal of CGT asset not implemented')
|
||||
|
||||
asset.disposal_date = posting.transaction.dt
|
||||
|
||||
# Calculate disposal value by searching for matching asset postings
|
||||
asset.disposal_value = Amount(0, reporting_commodity())
|
||||
for other_posting in posting.transaction.postings:
|
||||
if posting != other_posting and 'drcr.asset' in account_configurations.get(other_posting.account, []):
|
||||
asset.disposal_value.quantity += other_posting.amount().as_cost().quantity
|
||||
|
||||
# Process CGT adjustments
|
||||
for cost_adjustment in db.session.scalars(db.select(CGTCostAdjustment)).all():
|
||||
asset = next((a for a in assets if a.quantity == cost_adjustment.quantity and a.commodity == cost_adjustment.commodity and a.account == cost_adjustment.account and a.acquisition_date == cost_adjustment.acquisition_date), None)
|
||||
|
||||
if asset is None:
|
||||
raise Exception('No matching CGT asset for {}'.format(repr(cost_adjustment.asset())))
|
||||
|
||||
asset.cost_adjustments.append(cost_adjustment)
|
||||
|
||||
return render_plugin_template('austax', 'cgt_assets.html', assets=assets, eofy_date=eofy_date())
|
||||
|
||||
@app.route('/tax/summary')
|
||||
@assert_aud
|
||||
def tax_summary():
|
||||
# Get trial balance
|
||||
balancer = TrialBalancer.from_cached(start_date=sofy_date(), end_date=eofy_date())
|
||||
balancer.apply_transactions(api_transactions(api_stage_until=299))
|
||||
|
||||
report = tax_summary_report(balancer)
|
||||
return render_template('report.html', report=report)
|
4
build_css.sh
Executable file
4
build_css.sh
Executable file
@ -0,0 +1,4 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
# Pass -w to watch continuously
|
||||
tailwindcss -c drcr/css/tailwind.config.js -i drcr/css/main.css -o drcr/static/build/main.css $@
|
19
drcr/__init__.py
Normal file
19
drcr/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
AMOUNT_DPS = 2
|
||||
|
||||
from .webapp import app
|
3
drcr/config.toml.example
Normal file
3
drcr/config.toml.example
Normal file
@ -0,0 +1,3 @@
|
||||
SQLALCHEMY_DATABASE_URI = "sqlite:///drcr.db"
|
||||
|
||||
PLUGINS = ["austax"]
|
21
drcr/css/main.css
Normal file
21
drcr/css/main.css
Normal file
@ -0,0 +1,21 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.bordered-field {
|
||||
@apply block w-full border-0 py-1 text-gray-900 placeholder:text-gray-400 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-emerald-600;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center gap-x-1.5 bg-emerald-600 px-3 py-1 text-white shadow-sm hover:bg-emerald-700;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center gap-x-1.5 px-3 py-1 text-gray-800 shadow-sm ring-1 ring-inset ring-gray-400 hover:bg-gray-50;
|
||||
}
|
||||
.checkbox-primary {
|
||||
@apply h-4 w-4 border-gray-300 text-emerald-600 shadow-sm focus:ring-emerald-600 -mt-0.5;
|
||||
}
|
||||
.page-heading {
|
||||
@apply text-xl sm:text-base font-medium text-gray-700 print:text-xl print:text-gray-900;
|
||||
}
|
||||
}
|
@ -1,12 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
|
||||
import tailwindcssforms from '@tailwindcss/forms';
|
||||
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
module.exports = {
|
||||
content: ["./*/templates/**/*.html"],
|
||||
theme: {
|
||||
extend: {},
|
||||
fontFamily: {
|
||||
@ -14,6 +8,6 @@ export default {
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
tailwindcssforms,
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
}
|
19
drcr/database.py
Normal file
19
drcr/database.py
Normal file
@ -0,0 +1,19 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
0
drcr/journal/__init__.py
Normal file
0
drcr/journal/__init__.py
Normal file
32
drcr/journal/models.py
Normal file
32
drcr/journal/models.py
Normal file
@ -0,0 +1,32 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from ..database import db
|
||||
from ..models import Amount
|
||||
|
||||
class BalanceAssertion(db.Model):
|
||||
__tablename__ = 'balance_assertions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
dt = db.Column(db.DateTime)
|
||||
description = db.Column(db.String)
|
||||
account = db.Column(db.String)
|
||||
quantity = db.Column(db.Integer)
|
||||
commodity = db.Column(db.String)
|
||||
|
||||
def balance(self):
|
||||
return Amount(self.quantity, self.commodity)
|
202
drcr/journal/views.py
Normal file
202
drcr/journal/views.py
Normal file
@ -0,0 +1,202 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import abort, redirect, render_template, request, url_for
|
||||
|
||||
from .. import AMOUNT_DPS
|
||||
from ..database import db
|
||||
from ..models import Amount, Posting, Transaction, TrialBalancer, queue_invalidate_running_balances, reporting_commodity
|
||||
from ..transactions import all_transactions
|
||||
from ..webapp import all_accounts, app
|
||||
from .models import BalanceAssertion
|
||||
from ..statements.models import StatementLineReconciliation
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@app.route('/journal')
|
||||
def journal():
|
||||
transactions = db.session.scalars(db.select(Transaction).options(db.selectinload(Transaction.postings)).order_by(Transaction.dt.desc(), Transaction.id.desc())).all()
|
||||
|
||||
return render_template(
|
||||
'journal/journal.html',
|
||||
commodity_detail=request.args.get('commodity_detail', '0') == '1',
|
||||
transactions=transactions
|
||||
)
|
||||
|
||||
@app.route('/journal/new-transaction', methods=['GET', 'POST'])
|
||||
def journal_new_transaction():
|
||||
if request.method == 'GET':
|
||||
return render_template('journal/journal_edit_transaction.html', transaction=None, all_accounts=all_accounts())
|
||||
|
||||
# New transaction
|
||||
transaction = Transaction(
|
||||
dt=datetime.strptime(request.form['dt'], '%Y-%m-%d'),
|
||||
description=request.form['description'],
|
||||
postings=[]
|
||||
)
|
||||
|
||||
for account, sign, amount_str in zip(request.form.getlist('account'), request.form.getlist('sign'), request.form.getlist('amount')):
|
||||
amount = Amount.parse(amount_str)
|
||||
if sign == 'cr':
|
||||
amount = -amount
|
||||
|
||||
posting = Posting(
|
||||
account=account,
|
||||
quantity=amount.quantity,
|
||||
commodity=amount.commodity
|
||||
)
|
||||
transaction.postings.append(posting)
|
||||
|
||||
# Invalidate future running balances
|
||||
queue_invalidate_running_balances(account, transaction.dt)
|
||||
|
||||
transaction.assert_valid()
|
||||
db.session.add(transaction)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(request.form.get('referrer', '') or url_for('journal'))
|
||||
|
||||
@app.route('/journal/edit-transaction', methods=['GET', 'POST'])
|
||||
def journal_edit_transaction():
|
||||
transaction = db.session.get(Transaction, request.args['id'])
|
||||
if not transaction:
|
||||
abort(404)
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('journal/journal_edit_transaction.html', transaction=transaction, all_accounts=all_accounts())
|
||||
|
||||
if request.form.get('action', None) == 'delete':
|
||||
# Delete transaction
|
||||
db.session.delete(transaction)
|
||||
|
||||
# Delete reconciliations if required
|
||||
for posting in transaction.postings:
|
||||
for reconciliation in StatementLineReconciliation.query.filter(StatementLineReconciliation.posting == posting):
|
||||
db.session.delete(reconciliation)
|
||||
|
||||
db.session.commit()
|
||||
return redirect(request.form.get('referrer', '') or url_for('journal'))
|
||||
|
||||
# Edit transaction
|
||||
transaction.dt = datetime.strptime(request.form['dt'], '%Y-%m-%d')
|
||||
transaction.description = request.form['description']
|
||||
|
||||
new_postings = []
|
||||
for account, sign, amount_str in zip(request.form.getlist('account'), request.form.getlist('sign'), request.form.getlist('amount')):
|
||||
amount = Amount.parse(amount_str)
|
||||
if sign == 'cr':
|
||||
amount = -amount
|
||||
|
||||
posting = Posting(
|
||||
account=account,
|
||||
quantity=amount.quantity,
|
||||
commodity=amount.commodity
|
||||
)
|
||||
new_postings.append(posting)
|
||||
|
||||
# Invalidate future running balances
|
||||
queue_invalidate_running_balances(account, transaction.dt)
|
||||
|
||||
# Fix up reconciliations
|
||||
for old_posting in transaction.postings:
|
||||
for reconciliation in StatementLineReconciliation.query.filter(StatementLineReconciliation.posting == old_posting):
|
||||
# See if there is a corresponding new posting
|
||||
new_posting = next((p for p in new_postings if p.account == old_posting.account and p.quantity == old_posting.quantity and p.commodity == old_posting.commodity), None)
|
||||
if new_posting is not None:
|
||||
# Match up reconciliation
|
||||
reconciliation.posting = new_posting
|
||||
else:
|
||||
# No matching reconciliation
|
||||
db.session.delete(reconciliation)
|
||||
|
||||
transaction.postings = new_postings # This queues the old postings for deletion
|
||||
transaction.assert_valid()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(request.form.get('referrer', '') or url_for('journal'))
|
||||
|
||||
@app.route('/balance-assertions')
|
||||
def balance_assertions():
|
||||
assertions = db.session.scalars(db.select(BalanceAssertion).order_by(BalanceAssertion.dt.desc(), BalanceAssertion.id.desc())).all()
|
||||
|
||||
# Check assertion status
|
||||
transactions = all_transactions()
|
||||
assertion_status = {}
|
||||
for assertion in assertions:
|
||||
# FIXME: This is very inefficient
|
||||
balancer = TrialBalancer()
|
||||
balancer.apply_transactions([t for t in transactions if t.dt <= assertion.dt])
|
||||
|
||||
# TODO: Commodities
|
||||
if assertion.account in balancer.accounts and balancer.accounts[assertion.account].quantity == assertion.quantity:
|
||||
assertion_status[assertion] = True
|
||||
else:
|
||||
assertion_status[assertion] = False
|
||||
|
||||
return render_template('journal/balance_assertions.html', assertions=assertions, assertion_status=assertion_status)
|
||||
|
||||
@app.route('/balance-assertions/new', methods=['GET', 'POST'])
|
||||
def balance_assertions_new():
|
||||
if request.method == 'GET':
|
||||
return render_template('journal/balance_assertions_edit.html', assertion=None, all_accounts=all_accounts())
|
||||
|
||||
quantity = round(float(request.form['amount']) * (10**AMOUNT_DPS))
|
||||
if request.form['sign'] == 'cr':
|
||||
quantity = -quantity
|
||||
|
||||
# New balance assertion
|
||||
assertion = BalanceAssertion(
|
||||
dt=datetime.strptime(request.form['dt'], '%Y-%m-%d'),
|
||||
description=request.form['description'],
|
||||
account=request.form['account'],
|
||||
quantity=quantity,
|
||||
commodity=reporting_commodity()
|
||||
)
|
||||
db.session.add(assertion)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('balance_assertions'))
|
||||
|
||||
@app.route('/balance-assertions/edit', methods=['GET', 'POST'])
|
||||
def balance_assertions_edit():
|
||||
assertion = db.session.get(BalanceAssertion, request.args['id'])
|
||||
if not assertion:
|
||||
abort(404)
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('journal/balance_assertions_edit.html', assertion=assertion, all_accounts=all_accounts())
|
||||
|
||||
if request.form.get('action', None) == 'delete':
|
||||
# Delete balance assertion
|
||||
db.session.delete(assertion)
|
||||
db.session.commit()
|
||||
return redirect(request.form.get('referrer', '') or url_for('balance_assertions'))
|
||||
|
||||
quantity = round(float(request.form['amount']) * (10**AMOUNT_DPS))
|
||||
if request.form['sign'] == 'cr':
|
||||
quantity = -quantity
|
||||
|
||||
# Edit balance assertion
|
||||
assertion.dt = datetime.strptime(request.form['dt'], '%Y-%m-%d')
|
||||
assertion.description = request.form['description']
|
||||
assertion.account = request.form['account']
|
||||
assertion.quantity = quantity
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('balance_assertions'))
|
375
drcr/models.py
Normal file
375
drcr/models.py
Normal file
@ -0,0 +1,375 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from . import AMOUNT_DPS
|
||||
from .database import db
|
||||
|
||||
import functools
|
||||
from itertools import groupby
|
||||
|
||||
class Transaction(db.Model):
|
||||
__tablename__ = 'transactions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
dt = db.Column(db.DateTime)
|
||||
description = db.Column(db.String)
|
||||
|
||||
postings = db.relationship('Posting', back_populates='transaction', cascade='all, delete-orphan')
|
||||
|
||||
def __init__(self, dt=None, description=None, postings=None):
|
||||
self.dt = dt
|
||||
self.description = description
|
||||
self.postings = postings or []
|
||||
|
||||
def assert_valid(self):
|
||||
"""Assert that debits equal credits"""
|
||||
|
||||
total_dr = 0
|
||||
total_cr = 0
|
||||
|
||||
for posting in self.postings:
|
||||
amount_cost = posting.amount().as_cost().quantity
|
||||
if amount_cost > 0:
|
||||
total_dr += amount_cost
|
||||
elif amount_cost < 0:
|
||||
total_cr -= amount_cost
|
||||
|
||||
if total_dr != total_cr:
|
||||
raise AssertionError('Transaction debits ({}) and credits ({}) do not balance'.format(total_dr, total_cr))
|
||||
|
||||
class Posting(db.Model):
|
||||
__tablename__ = 'postings'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
transaction_id = db.Column(db.Integer, db.ForeignKey('transactions.id'))
|
||||
|
||||
description = db.Column(db.String)
|
||||
account = db.Column(db.String)
|
||||
quantity = db.Column(db.Integer)
|
||||
commodity = db.Column(db.String)
|
||||
|
||||
# Running balance of the account in units of reporting_commodity
|
||||
# Only takes into consideration Transactions stored in database, not API-generated ones
|
||||
running_balance = db.Column(db.Integer)
|
||||
|
||||
transaction = db.relationship('Transaction', back_populates='postings')
|
||||
|
||||
def __init__(self, description=None, account=None, quantity=None, commodity=None, running_balance=None):
|
||||
self.description = description
|
||||
self.account = account
|
||||
self.quantity = quantity
|
||||
self.commodity = commodity
|
||||
self.running_balance = running_balance
|
||||
|
||||
def amount(self):
|
||||
return Amount(self.quantity, self.commodity)
|
||||
|
||||
def queue_invalidate_running_balances(account, dt_from):
|
||||
"""
|
||||
Invalidate running_balances for Postings in the specified account, from the given date onwards
|
||||
|
||||
NOTE: Does not call db.session.commit()
|
||||
"""
|
||||
|
||||
for posting in db.session.scalars(db.select(Posting).join(Posting.transaction).where((Transaction.dt >= dt_from) & (Posting.account == account))).all():
|
||||
posting.running_balance = None
|
||||
|
||||
class Amount:
|
||||
__slots__ = ['quantity', 'commodity']
|
||||
|
||||
def __init__(self, quantity, commodity):
|
||||
self.quantity = quantity
|
||||
self.commodity = commodity
|
||||
|
||||
@classmethod
|
||||
def parse(self, amount_str):
|
||||
if ' ' not in amount_str:
|
||||
# Default commodity
|
||||
quantity = round(float(amount_str) * (10**AMOUNT_DPS))
|
||||
return Amount(quantity, reporting_commodity())
|
||||
|
||||
quantity_str = amount_str[:amount_str.index(' ')]
|
||||
quantity = round(float(quantity_str) * (10**AMOUNT_DPS))
|
||||
|
||||
commodity = amount_str[amount_str.index(' ')+1:]
|
||||
return Amount(quantity, commodity)
|
||||
|
||||
def __repr__(self):
|
||||
return '<{}: {}>'.format(self.__class__.__name__, self.format('force'))
|
||||
|
||||
def __abs__(self):
|
||||
return Amount(abs(self.quantity), self.commodity)
|
||||
|
||||
def __neg__(self):
|
||||
return Amount(-self.quantity, self.commodity)
|
||||
|
||||
def __add__(self, other):
|
||||
if self.commodity != other.commodity:
|
||||
raise ValueError('Cannot add incompatible commodities {} and {}'.format(self.commodity, other.commodity))
|
||||
return Amount(self.quantity + other.quantity, self.commodity)
|
||||
|
||||
def __sub__(self, other):
|
||||
return self + (-other)
|
||||
|
||||
def clone(self):
|
||||
return Amount(self.quantity, self.commodity)
|
||||
|
||||
def format(self, commodity='non_reporting'):
|
||||
if commodity not in ('non_reporting', 'force', 'hide'):
|
||||
raise ValueError('Invalid commodity reporting option')
|
||||
|
||||
if (self.commodity == reporting_commodity() and commodity in ('non_reporting', 'force')) or commodity == 'hide':
|
||||
return Markup('{:,.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' '))
|
||||
elif len(self.commodity) == 1:
|
||||
return Markup('{0}{1:,.{dps}f}'.format(self.commodity, self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' '))
|
||||
else:
|
||||
return Markup('{1:,.{dps}f} {0}'.format(self.commodity, self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' '))
|
||||
|
||||
def format_accounting(self, link=None):
|
||||
if self.quantity >= 0:
|
||||
text = '{:,.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' ')
|
||||
space = ' '
|
||||
else:
|
||||
text = '({:,.{dps}f})'.format(-self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' ')
|
||||
space = ''
|
||||
|
||||
if link is None:
|
||||
return Markup(text + space)
|
||||
else:
|
||||
return Markup('<a href="{}" class="hover:text-blue-700 hover:underline">{}</a>{}'.format(link, text, space))
|
||||
|
||||
def quantity_string(self):
|
||||
if self.commodity == reporting_commodity():
|
||||
return '{:.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS)
|
||||
elif len(self.commodity) == 1:
|
||||
return '{0}{1:.{dps}f}'.format(self.commodity, self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS)
|
||||
else:
|
||||
return '{1:.{dps}f} {0}'.format(self.commodity, self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS)
|
||||
|
||||
def as_cost(self):
|
||||
"""Convert commodity to reporting currency in cost basis"""
|
||||
|
||||
if self.commodity == reporting_commodity():
|
||||
return self
|
||||
|
||||
# TODO: Refactor this
|
||||
|
||||
if '{{' in self.commodity:
|
||||
cost = float(self.commodity[self.commodity.index('{{')+2:self.commodity.index('}}')])
|
||||
if self.quantity < 0:
|
||||
cost = -cost
|
||||
return Amount(round(cost * (10**AMOUNT_DPS)), reporting_commodity())
|
||||
elif '{' in self.commodity:
|
||||
cost = float(self.commodity[self.commodity.index('{')+1:self.commodity.index('}')])
|
||||
return Amount(round(cost * self.quantity), reporting_commodity()) # FIXME: Custom reporting currency
|
||||
else:
|
||||
raise Exception('No cost base for commodity {}'.format(self.commodity))
|
||||
|
||||
class Balance:
|
||||
"""A collection of Amount's"""
|
||||
|
||||
def __init__(self):
|
||||
self.amounts = []
|
||||
|
||||
def clone(self):
|
||||
balance = Balance()
|
||||
balance.amounts = [a.clone() for a in self.amounts]
|
||||
return balance
|
||||
|
||||
def add(self, rhs):
|
||||
amount = next((a for a in self.amounts if a.commodity == rhs.commodity), None)
|
||||
if amount is None:
|
||||
self.amounts.append(rhs)
|
||||
else:
|
||||
amount.quantity += rhs.quantity
|
||||
|
||||
def clean(self):
|
||||
"""Remove zero amounts"""
|
||||
self.amounts = [a for a in self.amounts if a.quantity != 0]
|
||||
|
||||
class TrialBalancer:
|
||||
"""
|
||||
Applies transactions to generate a trial balance
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.accounts = {}
|
||||
|
||||
@classmethod
|
||||
def from_cached(cls, start_date=None, end_date=None):
|
||||
"""Obtain a TrialBalancer based on the cached running_balance"""
|
||||
|
||||
# First, recompute any running_balance if required
|
||||
stale_accounts = db.session.scalars('SELECT DISTINCT account FROM postings WHERE running_balance IS NULL').all()
|
||||
if stale_accounts:
|
||||
# Get all relevant Postings in database in correct order
|
||||
# FIXME: Recompute balances only from the last non-stale balance to be more efficient
|
||||
postings = db.session.scalars(db.select(Posting).join(Posting.transaction).where(Posting.account.in_(stale_accounts)).order_by(Transaction.dt, Transaction.id)).all()
|
||||
|
||||
accounts = {}
|
||||
|
||||
for posting in postings:
|
||||
if posting.account not in accounts:
|
||||
accounts[posting.account] = Amount(0, reporting_commodity())
|
||||
|
||||
# FIXME: Handle commodities better (ensure compatible commodities)
|
||||
accounts[posting.account].quantity += posting.amount().as_cost().quantity
|
||||
|
||||
posting.running_balance = accounts[posting.account].quantity
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if start_date is not None:
|
||||
result_start_date = cls()
|
||||
|
||||
# First SELECT the last applicable dt by account
|
||||
# Then, among the transactions with that dt, SELECT the last applicable transaction_id
|
||||
# Then extract the running_balance for each account at that transaction_id
|
||||
# NB: We need to specify DATE(...) otherwise SQLite appears to do a string comparison which doesn't work properly with SQLAlchemy's prepared statement?
|
||||
running_balances = db.session.execute('''
|
||||
SELECT p3.account, running_balance FROM
|
||||
(
|
||||
SELECT p1.account, max(p2.transaction_id) AS max_tid FROM
|
||||
(
|
||||
SELECT account, max(dt) AS max_dt FROM postings JOIN transactions ON postings.transaction_id = transactions.id WHERE DATE(dt) < DATE(:start_date) GROUP BY account
|
||||
) p1
|
||||
JOIN postings p2 ON p1.account = p2.account AND p1.max_dt = transactions.dt JOIN transactions ON p2.transaction_id = transactions.id GROUP BY p2.account
|
||||
) p3
|
||||
JOIN postings p4 ON p3.account = p4.account AND p3.max_tid = p4.transaction_id
|
||||
''', {'start_date': start_date})
|
||||
|
||||
for running_balance in running_balances.all():
|
||||
result_start_date.accounts[running_balance.account] = Amount(running_balance.running_balance, reporting_commodity())
|
||||
|
||||
if end_date is None:
|
||||
result = cls()
|
||||
|
||||
running_balances = db.session.execute('''
|
||||
SELECT p3.account, running_balance FROM
|
||||
(
|
||||
SELECT p1.account, max(p2.transaction_id) AS max_tid FROM
|
||||
(
|
||||
SELECT account, max(dt) AS max_dt FROM postings JOIN transactions ON postings.transaction_id = transactions.id GROUP BY account
|
||||
) p1
|
||||
JOIN postings p2 ON p1.account = p2.account AND p1.max_dt = transactions.dt JOIN transactions ON p2.transaction_id = transactions.id GROUP BY p2.account
|
||||
) p3
|
||||
JOIN postings p4 ON p3.account = p4.account AND p3.max_tid = p4.transaction_id
|
||||
''')
|
||||
|
||||
for running_balance in running_balances.all():
|
||||
result.accounts[running_balance.account] = Amount(running_balance.running_balance, reporting_commodity())
|
||||
|
||||
if end_date is not None:
|
||||
result = cls()
|
||||
|
||||
running_balances = db.session.execute('''
|
||||
SELECT p3.account, running_balance FROM
|
||||
(
|
||||
SELECT p1.account, max(p2.transaction_id) AS max_tid FROM
|
||||
(
|
||||
SELECT account, max(dt) AS max_dt FROM postings JOIN transactions ON postings.transaction_id = transactions.id WHERE DATE(dt) <= DATE(:end_date) GROUP BY account
|
||||
) p1
|
||||
JOIN postings p2 ON p1.account = p2.account AND p1.max_dt = transactions.dt JOIN transactions ON p2.transaction_id = transactions.id GROUP BY p2.account
|
||||
) p3
|
||||
JOIN postings p4 ON p3.account = p4.account AND p3.max_tid = p4.transaction_id
|
||||
''', {'end_date': end_date})
|
||||
|
||||
for running_balance in running_balances.all():
|
||||
result.accounts[running_balance.account] = Amount(running_balance.running_balance, reporting_commodity())
|
||||
|
||||
# Subtract balances at start_date from balances at end_date if required
|
||||
if start_date is not None:
|
||||
for k in result.accounts.keys():
|
||||
# If k not in result_start_date, then the balance at start_date was necessarily 0 and subtraction is not required
|
||||
if k in result_start_date.accounts:
|
||||
result.accounts[k].quantity -= result_start_date.accounts[k].quantity
|
||||
|
||||
return result
|
||||
|
||||
def apply_transactions(self, transactions):
|
||||
for transaction in transactions:
|
||||
for posting in transaction.postings:
|
||||
if posting.account not in self.accounts:
|
||||
self.accounts[posting.account] = Amount(0, reporting_commodity())
|
||||
|
||||
# FIXME: Handle commodities better (ensure compatible commodities)
|
||||
self.accounts[posting.account].quantity += posting.amount().as_cost().quantity
|
||||
|
||||
def transfer_balance(self, source_account, destination_account, description=None):
|
||||
"""Transfer the balance of the source account to the destination account"""
|
||||
|
||||
# TODO: Keep a record of internal transactions?
|
||||
|
||||
if source_account == destination_account:
|
||||
# Don't do anything in this case!!
|
||||
return
|
||||
|
||||
if source_account not in self.accounts:
|
||||
return
|
||||
|
||||
if destination_account not in self.accounts:
|
||||
self.accounts[destination_account] = Amount(0, reporting_commodity())
|
||||
|
||||
# FIXME: Handle commodities
|
||||
self.accounts[destination_account].quantity += self.accounts[source_account].quantity
|
||||
del self.accounts[source_account]
|
||||
|
||||
class AccountConfiguration(db.Model):
|
||||
__tablename__ = 'account_configurations'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
account = db.Column(db.String)
|
||||
kind = db.Column(db.String)
|
||||
data = db.Column(db.JSON)
|
||||
|
||||
@staticmethod
|
||||
def get_all():
|
||||
account_configurations = db.session.execute(db.select(AccountConfiguration).order_by(AccountConfiguration.account)).scalars()
|
||||
account_configurations = {v: list(g) for v, g in groupby(account_configurations, lambda c: c.account)}
|
||||
|
||||
return account_configurations
|
||||
|
||||
@staticmethod
|
||||
def get_all_kinds():
|
||||
account_configurations = AccountConfiguration.get_all()
|
||||
kinds = {k: [vv.kind for vv in v] for k, v in account_configurations.items()}
|
||||
|
||||
return kinds
|
||||
|
||||
# ----------------
|
||||
# Metadata helpers
|
||||
|
||||
class Metadata(db.Model):
|
||||
__tablename__ = 'metadata'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
key = db.Column(db.String)
|
||||
value = db.Column(db.String)
|
||||
|
||||
@staticmethod
|
||||
def get(key):
|
||||
return Metadata.query.filter_by(key=key).one().value
|
||||
|
||||
@functools.cache # Very poor performance if result is not cached!
|
||||
def reporting_commodity():
|
||||
"""Get the native reporting commodity"""
|
||||
|
||||
return Metadata.get('reporting_commodity')
|
44
drcr/plugins.py
Normal file
44
drcr/plugins.py
Normal file
@ -0,0 +1,44 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import render_template
|
||||
from jinja2 import PackageLoader
|
||||
|
||||
from .webapp import app
|
||||
|
||||
import importlib
|
||||
|
||||
data_sources = [] # list of tuplet (view name, label)
|
||||
advanced_reports = [] # list of tuplet (view name, label)
|
||||
|
||||
account_kinds = [
|
||||
# list of tuplet (id, label)
|
||||
('drcr.asset', 'Asset'),
|
||||
('drcr.liability', 'Liability'),
|
||||
('drcr.income', 'Income'),
|
||||
('drcr.expense', 'Expense'),
|
||||
('drcr.equity', 'Equity')
|
||||
]
|
||||
|
||||
transaction_providers = [] # list of callable
|
||||
|
||||
def init_plugins():
|
||||
for plugin in app.config['PLUGINS']:
|
||||
module = importlib.import_module(plugin)
|
||||
module.plugin_init()
|
||||
|
||||
def render_plugin_template(plugin_name, template_path, **kwargs):
|
||||
return render_template(PackageLoader(plugin_name).load(app.jinja_env, template_path, app.jinja_env.globals), **kwargs)
|
244
drcr/reports.py
Normal file
244
drcr/reports.py
Normal file
@ -0,0 +1,244 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import url_for
|
||||
|
||||
from .models import AccountConfiguration, Amount, TrialBalancer, reporting_commodity
|
||||
from .transactions import all_transactions, api_transactions
|
||||
from .webapp import eofy_date, sofy_date
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class Report:
|
||||
def __init__(self, title=None, entries=None):
|
||||
self.title = title
|
||||
self.entries = entries or []
|
||||
|
||||
def calculate(self):
|
||||
"""Calculate all subtotals, etc."""
|
||||
|
||||
for entry in self.entries:
|
||||
entry.calculate(self)
|
||||
|
||||
def by_id(self, id):
|
||||
# TODO: Make more efficient?
|
||||
|
||||
for entry in self.entries:
|
||||
if entry.id == id:
|
||||
return entry
|
||||
if isinstance(entry, Section):
|
||||
result = entry.by_id(id)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
class Section:
|
||||
def __init__(self, title=None, entries=None, *, id=None, visible=True, auto_hide=False):
|
||||
self.title = title
|
||||
self.entries = entries or []
|
||||
self.id = id
|
||||
self.visible = visible
|
||||
self.auto_hide = auto_hide
|
||||
|
||||
def calculate(self, parent):
|
||||
for entry in self.entries:
|
||||
entry.calculate(self)
|
||||
|
||||
if self.auto_hide and self.visible:
|
||||
if not any(isinstance(e, Entry) and e.visible for e in self.entries):
|
||||
# Auto hide if no visible entries (other than Subtotal)
|
||||
self.visible = False
|
||||
|
||||
# Hide next Spacer
|
||||
idx = parent.entries.index(self)
|
||||
if idx + 1 < len(parent.entries):
|
||||
if isinstance(parent.entries[idx + 1], Spacer):
|
||||
parent.entries[idx + 1].visible = False
|
||||
|
||||
def by_id(self, id):
|
||||
# TODO: Make more efficient?
|
||||
|
||||
for entry in self.entries:
|
||||
if entry.id == id:
|
||||
return entry
|
||||
if isinstance(entry, Section):
|
||||
result = entry.by_id(id)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
class Entry:
|
||||
def __init__(self, text=None, amount=None, *, id=None, visible=True, auto_hide=False, link=None, heading=False, bordered=False):
|
||||
self.text = text
|
||||
self.amount = amount
|
||||
self.id = id
|
||||
self.visible = visible
|
||||
self.auto_hide = auto_hide
|
||||
self.link = link
|
||||
self.heading = heading
|
||||
self.bordered = bordered
|
||||
|
||||
def calculate(self, parent):
|
||||
pass
|
||||
|
||||
class Subtotal:
|
||||
def __init__(self, text=None, *, id=None, visible=True, bordered=False, floor=0):
|
||||
self.text = text
|
||||
self.id = id
|
||||
self.visible = visible
|
||||
self.bordered = bordered
|
||||
self.floor = floor
|
||||
|
||||
self.amount = None
|
||||
|
||||
def calculate(self, parent):
|
||||
amount = sum(e.amount.quantity for e in parent.entries if isinstance(e, Entry))
|
||||
if self.floor:
|
||||
amount = (amount // self.floor) * self.floor
|
||||
|
||||
self.amount = Amount(amount, reporting_commodity())
|
||||
|
||||
class Calculated(Entry):
|
||||
def __init__(self, text=None, calc=None, **kwargs):
|
||||
super().__init__(text=text, **kwargs)
|
||||
self.calc = calc
|
||||
|
||||
self.amount = None
|
||||
|
||||
def calculate(self, parent):
|
||||
self.amount = self.calc(parent)
|
||||
|
||||
if self.auto_hide and self.visible:
|
||||
if self.amount.quantity == 0:
|
||||
self.visible = False
|
||||
|
||||
class Spacer:
|
||||
id = None
|
||||
|
||||
def __init__(self, *, visible=True):
|
||||
self.visible = visible
|
||||
|
||||
def calculate(self, parent):
|
||||
pass
|
||||
|
||||
def validate_accounts(accounts, account_configurations):
|
||||
for account in accounts:
|
||||
n = sum(1 for c in account_configurations.get(account, []) if c in ('drcr.asset', 'drcr.liability', 'drcr.equity', 'drcr.income', 'drcr.expense'))
|
||||
if n != 1:
|
||||
raise Exception('Account "{}" mapped to {} account types (expected 1)'.format(account, n))
|
||||
|
||||
def entries_for_kind(account_configurations, accounts, kind, neg=False, floor=0):
|
||||
entries = []
|
||||
for account_name, amount in accounts.items():
|
||||
if kind in account_configurations.get(account_name, []) and amount.quantity != 0:
|
||||
if neg:
|
||||
amount = -amount
|
||||
if floor:
|
||||
amount.quantity = (amount.quantity // floor) * floor
|
||||
entries.append(Entry(text=account_name, amount=amount, link=url_for('account_transactions', account=account_name)))
|
||||
return entries
|
||||
|
||||
def balance_sheet_report():
|
||||
# Get trial balance
|
||||
balancer = TrialBalancer.from_cached()
|
||||
balancer.apply_transactions(api_transactions())
|
||||
|
||||
accounts = dict(sorted(balancer.accounts.items()))
|
||||
|
||||
# Get account configurations
|
||||
account_configurations = AccountConfiguration.get_all_kinds()
|
||||
validate_accounts(accounts, account_configurations)
|
||||
|
||||
day_before_sofy = sofy_date()
|
||||
day_before_sofy -= timedelta(days=1)
|
||||
|
||||
report = Report(
|
||||
title='Balance sheet',
|
||||
entries=[
|
||||
Section(
|
||||
title='Assets',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'drcr.asset') + [Subtotal('Total assets', bordered=True)]
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='Liabilities',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'drcr.liability', True) + [Subtotal('Total liabilities', bordered=True)]
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='Equity',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'drcr.equity', True) + [
|
||||
Calculated(
|
||||
'Current year surplus (deficit)',
|
||||
lambda _: income_statement_report().by_id('net_surplus').amount,
|
||||
link=url_for('income_statement')
|
||||
),
|
||||
Calculated(
|
||||
'Accumulated surplus (deficit)',
|
||||
lambda _: income_statement_report(start_date=datetime.min, end_date=day_before_sofy).by_id('net_surplus').amount
|
||||
),
|
||||
Subtotal('Total equity', bordered=True)
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
report.calculate()
|
||||
|
||||
return report
|
||||
|
||||
def income_statement_report(start_date=None, end_date=None):
|
||||
if start_date is None:
|
||||
start_date = sofy_date()
|
||||
if end_date is None:
|
||||
end_date = eofy_date()
|
||||
|
||||
# Get trial balance
|
||||
balancer = TrialBalancer.from_cached(start_date=start_date, end_date=end_date)
|
||||
balancer.apply_transactions(api_transactions(start_date=start_date, end_date=end_date))
|
||||
|
||||
accounts = dict(sorted(balancer.accounts.items()))
|
||||
|
||||
# Get account configurations
|
||||
account_configurations = AccountConfiguration.get_all_kinds()
|
||||
validate_accounts(accounts, account_configurations)
|
||||
|
||||
report = Report(
|
||||
title='Income statement',
|
||||
entries=[
|
||||
Section(
|
||||
title='Income',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'drcr.income', True) + [Subtotal('Total income', id='total_income', bordered=True)]
|
||||
),
|
||||
Spacer(),
|
||||
Section(
|
||||
title='Expenses',
|
||||
entries=entries_for_kind(account_configurations, accounts, 'drcr.expense') + [Subtotal('Total expenses', id='total_expenses', bordered=True)]
|
||||
),
|
||||
Spacer(),
|
||||
Calculated(
|
||||
'Net surplus (deficit)',
|
||||
lambda r: r.by_id('total_income').amount - r.by_id('total_expenses').amount,
|
||||
id='net_surplus',
|
||||
heading=True,
|
||||
bordered=True
|
||||
)
|
||||
]
|
||||
)
|
||||
report.calculate()
|
||||
|
||||
return report
|
0
drcr/statements/__init__.py
Normal file
0
drcr/statements/__init__.py
Normal file
0
drcr/statements/importers/__init__.py
Normal file
0
drcr/statements/importers/__init__.py
Normal file
57
drcr/statements/importers/ofx1.py
Normal file
57
drcr/statements/importers/ofx1.py
Normal file
@ -0,0 +1,57 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import lxml.etree as ET
|
||||
|
||||
from ..models import StatementLine, reporting_commodity
|
||||
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
|
||||
def import_ofx1(file):
|
||||
raw_ofx = file.read().decode('utf-8')
|
||||
|
||||
# Convert OFX header to SGML and parse
|
||||
raw_payload = raw_ofx[raw_ofx.index('<OFX>'):]
|
||||
sgml_input = StringIO(raw_payload.replace('&', '&'))
|
||||
try:
|
||||
tree = ET.parse(sgml_input, ET.HTMLParser())
|
||||
except Exception as ex:
|
||||
raise ex
|
||||
root = tree.getroot()
|
||||
|
||||
# Read transactions
|
||||
lines = [] # Do first pass to catch "extra description lines"
|
||||
for transaction in root.find('.//banktranlist').findall('.//stmttrn'):
|
||||
date = transaction.find('.//dtposted').text
|
||||
date = date[0:4] + '-' + date[4:6] + '-' + date[6:8]
|
||||
description = transaction.find('.//memo').text.strip()
|
||||
amount = transaction.find('.//trnamt').text.strip()
|
||||
|
||||
lines.append([date, description, amount, []])
|
||||
|
||||
imported_statement_lines = []
|
||||
|
||||
# Import
|
||||
for date, description, amount, notes in lines:
|
||||
imported_statement_lines.append(StatementLine(
|
||||
dt=datetime.strptime(date, '%Y-%m-%d'),
|
||||
description=description,
|
||||
quantity=round(float(amount)*100),
|
||||
commodity=reporting_commodity()
|
||||
))
|
||||
|
||||
return imported_statement_lines
|
61
drcr/statements/importers/ofx2.py
Normal file
61
drcr/statements/importers/ofx2.py
Normal file
@ -0,0 +1,61 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from ..models import StatementLine, reporting_commodity
|
||||
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
def import_ofx2(file):
|
||||
raw_ofx = file.read().decode('utf-8')
|
||||
|
||||
# Convert OFX header to XML and parse
|
||||
xml_header = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'
|
||||
raw_payload = raw_ofx[raw_ofx.index('?>')+2:]
|
||||
xml_input = StringIO(xml_header + raw_payload.replace('&', '&'))
|
||||
try:
|
||||
tree = ET.parse(xml_input)
|
||||
except Exception as ex:
|
||||
raise ex
|
||||
root = tree.getroot()
|
||||
|
||||
# Read transactions
|
||||
lines = [] # Do first pass to catch "extra description lines"
|
||||
for transaction in root.find('BANKMSGSRSV1').find('STMTTRNRS').find('STMTRS').find('BANKTRANLIST').findall('STMTTRN'):
|
||||
date = transaction.find('DTPOSTED').text
|
||||
date = date[0:4] + '-' + date[4:6] + '-' + date[6:8]
|
||||
description = transaction.find('NAME').text
|
||||
amount = transaction.find('TRNAMT').text
|
||||
|
||||
if amount == '0':
|
||||
lines[-1][3].append(description)
|
||||
continue
|
||||
|
||||
lines.append([date, description, amount, []])
|
||||
|
||||
imported_statement_lines = []
|
||||
|
||||
# Import
|
||||
for date, description, amount, notes in lines:
|
||||
imported_statement_lines.append(StatementLine(
|
||||
dt=datetime.strptime(date, '%Y-%m-%d'),
|
||||
description=description,
|
||||
quantity=round(float(amount)*100),
|
||||
commodity=reporting_commodity()
|
||||
))
|
||||
|
||||
return imported_statement_lines
|
69
drcr/statements/models.py
Normal file
69
drcr/statements/models.py
Normal file
@ -0,0 +1,69 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from ..database import db
|
||||
from ..models import Amount, Posting, Transaction
|
||||
|
||||
class StatementLine(db.Model):
|
||||
__tablename__ = 'statement_lines'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
source_account = db.Column(db.String)
|
||||
dt = db.Column(db.DateTime)
|
||||
description = db.Column(db.String)
|
||||
quantity = db.Column(db.Integer)
|
||||
balance = db.Column(db.Integer)
|
||||
commodity = db.Column(db.String)
|
||||
|
||||
reconciliation = db.relationship('StatementLineReconciliation', back_populates='statement_line', uselist=False)
|
||||
|
||||
def amount(self):
|
||||
return Amount(self.quantity, self.commodity)
|
||||
|
||||
def into_transaction(self):
|
||||
if self.reconciliation:
|
||||
# Will already be accounted for in a StatementLineTransaction
|
||||
raise Exception('Should not call into_transaction on a reconciled StatementLine')
|
||||
|
||||
# Not classified
|
||||
unclassified_name = 'Unclassified Statement Line Debits' if -self.quantity >= 0 else 'Unclassified Statement Line Credits'
|
||||
|
||||
return Transaction(
|
||||
dt=self.dt,
|
||||
description=self.description,
|
||||
postings=[
|
||||
Posting(account=self.source_account, quantity=self.quantity, commodity=self.commodity),
|
||||
Posting(account=unclassified_name, quantity=-self.quantity, commodity=self.commodity)
|
||||
]
|
||||
)
|
||||
|
||||
def is_complex(self):
|
||||
if self.reconciliation and len(self.reconciliation.posting.transaction.postings) > 2:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
class StatementLineReconciliation(db.Model):
|
||||
__tablename__ = 'statement_line_reconciliations'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
statement_line_id = db.Column(db.Integer, db.ForeignKey('statement_lines.id'))
|
||||
posting_id = db.Column(db.Integer, db.ForeignKey('postings.id'))
|
||||
|
||||
statement_line = db.relationship('StatementLine', back_populates='reconciliation')
|
||||
posting = db.relationship('Posting')
|
203
drcr/statements/views.py
Normal file
203
drcr/statements/views.py
Normal file
@ -0,0 +1,203 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import abort, redirect, render_template, request, url_for
|
||||
|
||||
from .. import AMOUNT_DPS
|
||||
from ..database import db
|
||||
from ..models import Amount, Posting, Transaction
|
||||
from ..webapp import all_accounts, app
|
||||
from .models import StatementLine, StatementLineReconciliation
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@app.route('/statement-lines')
|
||||
def statement_lines():
|
||||
# JOIN all associated postings (called in is_complex/charge_account)
|
||||
statement_lines = db.select(StatementLine).options(
|
||||
db.joinedload(StatementLine.reconciliation).joinedload(StatementLineReconciliation.posting).joinedload(Posting.transaction).joinedload(Transaction.postings)
|
||||
).order_by(StatementLine.dt.desc(), StatementLine.id.desc())
|
||||
|
||||
if 'account' in request.args:
|
||||
statement_lines = statement_lines.where(StatementLine.source_account == request.args['account'])
|
||||
|
||||
if request.args.get('unclassified', '0') == '1':
|
||||
statement_lines = statement_lines.where(StatementLine.reconciliation == None)
|
||||
|
||||
page = db.paginate(statement_lines, per_page=int(request.args.get('per_page', 1000)))
|
||||
|
||||
return render_template('statements/statement_lines.html', page=page, all_accounts=all_accounts())
|
||||
|
||||
@app.route('/statement-lines/charge', methods=['POST'])
|
||||
def statement_line_charge():
|
||||
statement_line = db.session.get(StatementLine, request.form['line-id'])
|
||||
if not statement_line:
|
||||
abort(404)
|
||||
|
||||
if statement_line.reconciliation:
|
||||
raise Exception('NYI')
|
||||
|
||||
# Create transaction
|
||||
posting_source = Posting(account=statement_line.source_account, quantity=statement_line.quantity, commodity=statement_line.commodity)
|
||||
posting_charge = Posting(account=request.form['charge-account'], quantity=-statement_line.quantity, commodity=statement_line.commodity)
|
||||
transaction = Transaction(
|
||||
dt=statement_line.dt,
|
||||
description=statement_line.description,
|
||||
postings=[posting_source, posting_charge]
|
||||
)
|
||||
db.session.add(transaction)
|
||||
|
||||
# Reconcile statement line
|
||||
reconciliation = StatementLineReconciliation(
|
||||
statement_line=statement_line,
|
||||
posting=posting_source
|
||||
)
|
||||
db.session.add(reconciliation)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return 'OK'
|
||||
|
||||
@app.route('/statement-lines/charge-complex', methods=['GET', 'POST'])
|
||||
def statement_line_charge_complex():
|
||||
statement_line = db.session.get(StatementLine, request.args['line-id'])
|
||||
if not statement_line:
|
||||
abort(404)
|
||||
|
||||
if statement_line.reconciliation:
|
||||
raise Exception('Cannot reconcile an already reconciled statement line')
|
||||
|
||||
if request.method == 'GET':
|
||||
# Create template transaction
|
||||
posting_source = Posting(account=statement_line.source_account, quantity=statement_line.quantity, commodity=statement_line.commodity)
|
||||
posting_charge = Posting(account='', quantity=-statement_line.quantity, commodity=statement_line.commodity)
|
||||
transaction = Transaction(
|
||||
dt=statement_line.dt,
|
||||
description=statement_line.description,
|
||||
postings=[posting_source, posting_charge]
|
||||
)
|
||||
|
||||
return render_template('journal/journal_edit_transaction.html', transaction=transaction, all_accounts=all_accounts())
|
||||
|
||||
# New transaction
|
||||
transaction = Transaction(
|
||||
dt=datetime.strptime(request.form['dt'], '%Y-%m-%d'),
|
||||
description=request.form['description'],
|
||||
postings=[]
|
||||
)
|
||||
|
||||
for account, sign, amount_str in zip(request.form.getlist('account'), request.form.getlist('sign'), request.form.getlist('amount')):
|
||||
amount = Amount.parse(amount_str)
|
||||
if sign == 'cr':
|
||||
amount = -amount
|
||||
|
||||
posting = Posting(
|
||||
account=account,
|
||||
quantity=amount.quantity,
|
||||
commodity=amount.commodity
|
||||
)
|
||||
transaction.postings.append(posting)
|
||||
|
||||
transaction.assert_valid()
|
||||
db.session.add(transaction)
|
||||
|
||||
# Reconcile statement line
|
||||
posting_source = next((p for p in transaction.postings if p.account == statement_line.source_account and p.quantity == statement_line.quantity and p.commodity == statement_line.commodity), None)
|
||||
if not posting_source:
|
||||
raise Exception('Transaction must have posting corresponding to statement line')
|
||||
|
||||
reconciliation = StatementLineReconciliation(
|
||||
statement_line=statement_line,
|
||||
posting=posting_source
|
||||
)
|
||||
db.session.add(reconciliation)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('statement_lines'))
|
||||
|
||||
@app.route('/statement-lines/reconcile-transfer', methods=['POST'])
|
||||
def statement_line_reconcile_transfer():
|
||||
line_ids = request.form.getlist('sel-line-id')
|
||||
if len(line_ids) != 2:
|
||||
raise Exception('Must select exactly 2 statement lines')
|
||||
|
||||
line1 = db.session.get(StatementLine, line_ids[0])
|
||||
line2 = db.session.get(StatementLine, line_ids[1])
|
||||
|
||||
# Check same amount
|
||||
if line1.quantity != -line2.quantity or line1.commodity != line2.commodity:
|
||||
raise Exception('Selected statement line debits/credits must equal')
|
||||
|
||||
if line1.reconciliation or line2.reconciliation:
|
||||
raise Exception('NYI')
|
||||
|
||||
# Create transaction
|
||||
posting1 = Posting(account=line1.source_account, quantity=line1.quantity, commodity=line1.commodity)
|
||||
posting2 = Posting(account=line2.source_account, quantity=line2.quantity, commodity=line2.commodity)
|
||||
transaction = Transaction(
|
||||
dt=line1.dt,
|
||||
description=line1.description,
|
||||
postings=[posting1, posting2]
|
||||
)
|
||||
db.session.add(transaction)
|
||||
|
||||
# Reconcile statement lines
|
||||
db.session.add(StatementLineReconciliation(
|
||||
statement_line=line1,
|
||||
posting=posting1
|
||||
))
|
||||
db.session.add(StatementLineReconciliation(
|
||||
statement_line=line2,
|
||||
posting=posting2
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(request.referrer or url_for('statement_lines'))
|
||||
|
||||
@app.route('/statement-lines/import', methods=['GET', 'POST'])
|
||||
def statement_lines_import():
|
||||
if request.method == 'GET':
|
||||
return render_template('statements/import.html', all_accounts=all_accounts())
|
||||
|
||||
# Import using importer
|
||||
if request.form['format'] == 'ofx1':
|
||||
from .importers.ofx1 import import_ofx1
|
||||
statement_lines = import_ofx1(request.files['file'])
|
||||
elif request.form['format'] == 'ofx2':
|
||||
from .importers.ofx2 import import_ofx2
|
||||
statement_lines = import_ofx2(request.files['file'])
|
||||
else:
|
||||
abort(400)
|
||||
|
||||
# Fill in source_account
|
||||
for statement_line in statement_lines:
|
||||
statement_line.source_account = request.form['source-account']
|
||||
|
||||
# Ignore pending, etc.
|
||||
# FIXME: This needs to be customisable
|
||||
statement_lines = [l for l in statement_lines if 'PENDING' not in l.description]
|
||||
|
||||
if request.form['action'] == 'preview':
|
||||
return render_template('statements/import_preview.html', statement_lines=statement_lines)
|
||||
|
||||
# Add to database
|
||||
for statement_line in statement_lines:
|
||||
db.session.add(statement_line)
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('statement_lines'))
|
67
drcr/static/js/combobox.js
Normal file
67
drcr/static/js/combobox.js
Normal file
@ -0,0 +1,67 @@
|
||||
/* DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
function updateComboboxInputs(elCombobox, elInput) {
|
||||
elCombobox.querySelector('ul').querySelectorAll('li').forEach((elLi) => {
|
||||
const liText = elLi.querySelector('.combobox-text').innerText;
|
||||
if (liText.toLowerCase().startsWith(elInput.value.toLowerCase())) {
|
||||
elLi.classList.remove('hidden');
|
||||
elLi.classList.add('block');
|
||||
|
||||
if (liText == elInput.value) {
|
||||
elLi.dataset.selected = 'selected';
|
||||
} else {
|
||||
elLi.dataset.selected = '';
|
||||
}
|
||||
} else {
|
||||
elLi.classList.remove('block');
|
||||
elLi.classList.add('hidden');
|
||||
elLi.dataset.selected = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initCombobox(elCombobox) {
|
||||
const elInput = elCombobox.querySelector('input');
|
||||
updateComboboxInputs(elCombobox, elInput);
|
||||
|
||||
// Update combobox options on input
|
||||
elInput.addEventListener('input', (evt) => {
|
||||
updateComboboxInputs(elCombobox, elInput);
|
||||
});
|
||||
|
||||
// Open dropdown on click button
|
||||
elCombobox.querySelector('button').addEventListener('click', (evt) => {
|
||||
elInput.focus();
|
||||
});
|
||||
|
||||
// Update combobox value on select
|
||||
elCombobox.querySelector('ul').querySelectorAll('li').forEach((elLi) => {
|
||||
elLi.addEventListener('mousedown', (evt) => {
|
||||
const liText = elLi.querySelector('.combobox-text').innerText;
|
||||
elInput.value = liText;
|
||||
updateComboboxInputs(elCombobox, elInput);
|
||||
});
|
||||
});
|
||||
|
||||
// Disable autocomplete
|
||||
// Set this in Javascript rather than in HTML so that browser will continue to persist values after refresh
|
||||
elInput.autocomplete = 'off';
|
||||
}
|
||||
|
||||
// Init comboboxes
|
||||
document.querySelectorAll('.combobox').forEach(initCombobox);
|
80
drcr/templates/base.html
Normal file
80
drcr/templates/base.html
Normal file
@ -0,0 +1,80 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='build/main.css') }}">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,slnt,wght,GRAD@8..144,-10..0,100..1000,-200..150&display=swap">
|
||||
</head>
|
||||
<body class="h-full">
|
||||
{% block body %}
|
||||
<div class="min-h-full">
|
||||
{# Navigation bar #}
|
||||
<nav class="border-b border-gray-200 bg-white print:hidden">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div class="flex h-12 justify-between ml-[-0.25rem]"> {# Adjust margin by -0.25rem to align navbar text with body text #}
|
||||
<div class="flex">
|
||||
<div class="flex flex-shrink-0">
|
||||
<a href="{{ url_for('index') }}" class="border-transparent text-gray-900 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium">
|
||||
DrCr
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Menu items #}
|
||||
<div class="hidden sm:-my-px sm:ml-6 sm:flex sm:space-x-4">
|
||||
<a href="{{ url_for('journal') }}" class="border-transparent text-gray-700 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm">
|
||||
Journal
|
||||
</a>
|
||||
<a href="{{ url_for('statement_lines') }}" class="border-transparent text-gray-700 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm">
|
||||
Statement lines
|
||||
</a>
|
||||
<a href="{{ url_for('trial_balance') }}" class="border-transparent text-gray-700 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm">
|
||||
Trial balance
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="py-8">
|
||||
<main>
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{% if config['DEBUG'] %}
|
||||
<footer class="print:hidden">
|
||||
<div class="mt-6 mx-auto max-w-3xl px-4 sm:px-6 lg:max-w-7xl lg:px-8">
|
||||
<div class="border-t border-gray-200 py-4 text-center text-sm text-gray-500 sm:text-left">
|
||||
Queries executed in {{ dbtime() }} msec. Page generated in __EXECUTION_TIME__ msec.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
132
drcr/templates/chart_of_accounts.html
Normal file
132
drcr/templates/chart_of_accounts.html
Normal file
@ -0,0 +1,132 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Chart of accounts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading">
|
||||
Chart of accounts
|
||||
</h1>
|
||||
|
||||
<form method="POST">
|
||||
<div class="my-2 py-2 flex gap-x-2 items-baseline bg-white sticky top-0">
|
||||
<div class="relative dropdownbox w-[450px]"> {# FIXME: Width hardcoded #}
|
||||
<button type="button" class="relative w-full cursor-default bg-white bordered-field pl-3 pr-10 text-left">
|
||||
<span class="dropdownbox-text block truncate">Asset</span> {# FIXME: Hardcoded default #}
|
||||
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<input type="hidden" name="kind" value="drcr.asset"> {# FIXME: Hardcoded default #}
|
||||
<ul class="hidden absolute z-20 mt-1 max-h-60 w-full overflow-auto bg-white py-1 text-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{% for plugin_name, plugin_account_kinds in account_kinds_by_plugin.items() %}
|
||||
<li class="relative cursor-default select-none py-1 pl-3 pr-9 text-gray-500 border-b border-gray-300{% if loop.index0 > 0 %} pt-4{% endif %}">
|
||||
<span class="block truncate text-xs font-bold uppercase">{{ plugin_name }}</span>
|
||||
</li>
|
||||
{% for account_kind in plugin_account_kinds %}
|
||||
<li class="group relative cursor-default select-none py-1 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-emerald-600" data-value="{{ account_kind[0] }}"{% if account_kind[0] == 'drcr.asset' %} data-selected="selected"{% endif %}>
|
||||
<span class="combobox-text block truncate group-data-[selected=selected]:font-semibold">{{ account_kind[1] }}</span>
|
||||
<span class="hidden group-data-[selected=selected]:flex absolute inset-y-0 right-0 items-center pr-4 text-emerald-600 group-hover:text-white">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<button formaction="{{ url_for('account_add_kind') }}" class="btn-primary" type="submit">Add type</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Account</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-start">Associated types</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account in accounts %}
|
||||
<tr class="border-t border-gray-300">
|
||||
<td class="py-0.5 pr-1 text-gray-900 align-baseline"><input class="checkbox-primary" type="checkbox" name="sel-account" value="{{ account }}"></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 align-baseline">{{ account }}</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 align-baseline">
|
||||
{% if account in account_configurations %}
|
||||
<ul class="list-disc ml-5">
|
||||
{% for account_configuration in account_configurations[account] %}
|
||||
<li>{{ account_kinds_map[account_configuration.kind] }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.querySelectorAll('.dropdownbox').forEach((elDropdownbox) => {
|
||||
const elButton = elDropdownbox.querySelector('button');
|
||||
const elInput = elDropdownbox.querySelector('input');
|
||||
const elUl = elDropdownbox.querySelector('ul');
|
||||
|
||||
// Open and close dropdown box
|
||||
elButton.addEventListener('click', (evt) => {
|
||||
if (elUl.classList.contains('hidden')) {
|
||||
elUl.classList.remove('hidden');
|
||||
elUl.classList.add('block');
|
||||
} else {
|
||||
elUl.classList.remove('block');
|
||||
elUl.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Update value on select
|
||||
elUl.querySelectorAll('li').forEach((elLi) => {
|
||||
if (!elLi.dataset.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
elLi.addEventListener('click', (evt) => {
|
||||
const liText = elLi.querySelector('.combobox-text').innerText;
|
||||
const liValue = elLi.dataset.value;
|
||||
|
||||
elButton.querySelector('.dropdownbox-text').innerText = liText;
|
||||
elInput.value = liValue;
|
||||
|
||||
elUl.querySelectorAll('li').forEach((elLi2) => {
|
||||
elLi2.dataset.selected = '';
|
||||
});
|
||||
elLi.dataset.selected = 'selected';
|
||||
|
||||
elUl.classList.remove('block');
|
||||
elUl.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
34
drcr/templates/components/accounts_combobox_inner.html
Normal file
34
drcr/templates/components/accounts_combobox_inner.html
Normal file
@ -0,0 +1,34 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
<button type="button" class="absolute inset-y-0 right-0 flex items-center px-2 focus:outline-none">
|
||||
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="hidden peer-focus:block absolute z-20 mt-1 max-h-60 w-full overflow-auto bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{% for account in all_accounts %}
|
||||
<li class="group relative cursor-default select-none py-1 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-emerald-600">
|
||||
<span class="combobox-text block truncate group-data-[selected=selected]:font-semibold">{{ account }}</span>
|
||||
<span class="hidden group-data-[selected=selected]:flex absolute inset-y-0 right-0 items-center pr-4 text-emerald-600 group-hover:text-white">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
73
drcr/templates/general_ledger.html
Normal file
73
drcr/templates/general_ledger.html
Normal file
@ -0,0 +1,73 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}General ledger{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading">
|
||||
General ledger
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex">
|
||||
{% if commodity_detail %}
|
||||
<a href="{{ url_for('general_ledger') }}" class="btn-secondary">
|
||||
Hide commodity detail
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('general_ledger', commodity_detail=1) }}" class="btn-secondary">
|
||||
Show commodity detail
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start" colspan="3">Description</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">Cr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for transaction in transactions %}
|
||||
<tr class="border-t border-gray-300">
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ transaction.dt.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900" colspan="3">{{ transaction.description }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% for posting in transaction.postings %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ posting.description or '' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end"><i>{{ 'Dr' if posting.quantity >= 0 else 'Cr' }}</i></td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ posting.account }}</td>
|
||||
{% if commodity_detail %}
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ posting.amount().format('force') if posting.quantity >= 0 else '' }}</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">{{ (posting.amount()|abs).format('force') if posting.quantity < 0 else '' }}</td>
|
||||
{% else %}
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ posting.amount().as_cost().format() if posting.quantity >= 0 else '' }}</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">{{ (posting.amount()|abs).as_cost().format() if posting.quantity < 0 else '' }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
53
drcr/templates/index.html
Normal file
53
drcr/templates/index.html
Normal file
@ -0,0 +1,53 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}DrCr{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid grid-cols-3 divide-x divide-gray-200">
|
||||
<div class="pr-4">
|
||||
<h2 class="font-medium text-gray-700 mb-2">Data sources</h2>
|
||||
<ul class="list-disc ml-6">
|
||||
<li><a href="{{ url_for('journal') }}" class="text-gray-900 hover:text-blue-700 hover:underline">Journal</a></li>
|
||||
<li><a href="{{ url_for('statement_lines') }}" class="text-gray-900 hover:text-blue-700 hover:underline">Statement lines</a></li>
|
||||
<li><a href="{{ url_for('balance_assertions') }}" class="text-gray-900 hover:text-blue-700 hover:underline">Balance assertions</a></li>
|
||||
<li><a href="{{ url_for('chart_of_accounts') }}" class="text-gray-900 hover:text-blue-700 hover:underline">Chart of accounts</a></li>
|
||||
{% for report in data_sources %}
|
||||
<li><a href="{{ url_for(report[0]) }}" class="text-gray-900 hover:text-blue-700 hover:underline">{{ report[1] }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<h2 class="font-medium text-gray-700 mb-2">General reports</h2>
|
||||
<ul class="list-disc ml-6">
|
||||
<li><a href="{{ url_for('general_ledger') }}" class="text-gray-900 hover:text-blue-700 hover:underline">General ledger</a></li>
|
||||
<li><a href="{{ url_for('trial_balance') }}" class="text-gray-900 hover:text-blue-700 hover:underline">Trial balance</a></li>
|
||||
<li><a href="{{ url_for('balance_sheet') }}" class="text-gray-900 hover:text-blue-700 hover:underline">Balance sheet</a></li>
|
||||
<li><a href="{{ url_for('income_statement') }}" class="text-gray-900 hover:text-blue-700 hover:underline">Income statement</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<h2 class="font-medium text-gray-700 mb-2">Advanced reports</h2>
|
||||
<ul class="list-disc ml-6">
|
||||
{% for report in advanced_reports %}
|
||||
<li><a href="{{ url_for(report[0]) }}" class="text-gray-900 hover:text-blue-700 hover:underline">{{ report[1] }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
77
drcr/templates/journal/balance_assertions.html
Normal file
77
drcr/templates/journal/balance_assertions.html
Normal file
@ -0,0 +1,77 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Balance assertions{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading">
|
||||
Balance assertions
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex gap-x-2">
|
||||
<a href="{{ url_for('balance_assertions_new') }}" class="btn-primary pl-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New assertion
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Description</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Account</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Balance</th>
|
||||
<th></th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for assertion in assertions %}
|
||||
<tr>
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ assertion.dt.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ assertion.description }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900"><a href="{{ url_for('account_transactions', account=assertion.account) }}" class="text-gray-900 hover:text-blue-700 hover:underline">{{ assertion.account }}</a></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ (assertion.balance()|abs).format() }}</td>
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ 'Dr' if assertion.quantity >= 0 else 'Cr' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">
|
||||
{% if assertion_status[assertion] %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4">
|
||||
<path fill-rule="evenodd" d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4 text-red-500">
|
||||
<path fill-rule="evenodd" d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Zm2.78-4.22a.75.75 0 0 1-1.06 0L8 9.06l-1.72 1.72a.75.75 0 1 1-1.06-1.06L6.94 8 5.22 6.28a.75.75 0 0 1 1.06-1.06L8 6.94l1.72-1.72a.75.75 0 1 1 1.06 1.06L9.06 8l1.72 1.72a.75.75 0 0 1 0 1.06Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">
|
||||
<a href="{{ url_for('balance_assertions_edit', id=assertion.id) }}" class="text-gray-500 hover:text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 inline align-middle -mt-0.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
70
drcr/templates/journal/balance_assertions_edit.html
Normal file
70
drcr/templates/journal/balance_assertions_edit.html
Normal file
@ -0,0 +1,70 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ 'Edit' if assertion else 'New' }} balance assertion{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-lg mx-auto px-4">
|
||||
<h1 class="page-heading mb-4">
|
||||
{{ 'Edit' if assertion else 'New' }} balance assertion
|
||||
</h1>
|
||||
|
||||
<form method="POST">
|
||||
<div class="grid grid-cols-[max-content_1fr] space-y-2 mb-4 items-baseline">
|
||||
<label for="dt" class="block text-gray-900 pr-4">Date</label>
|
||||
<div>
|
||||
<input type="date" class="bordered-field" name="dt" id="dt" value="{{ assertion.dt.strftime('%Y-%m-%d') if assertion else '' }}">
|
||||
</div>
|
||||
<label for="description" class="block text-gray-900 pr-4">Description</label>
|
||||
<div>
|
||||
<input type="text" class="bordered-field" name="description" id="description" value="{{ assertion.description if assertion else '' }}">
|
||||
</div>
|
||||
<label for="account" class="block text-gray-900 pr-4">Account</label>
|
||||
<div class="relative combobox">
|
||||
<input type="text" class="bordered-field peer" name="account" id="account" value="{{ assertion.account if assertion else '' }}" autocomplete="off">
|
||||
{% include 'components/accounts_combobox_inner.html' %}
|
||||
</div>
|
||||
<label for="amount" class="block text-gray-900 pr-4">Balance</label>
|
||||
<div class="relative shadow-sm">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||
</div>
|
||||
{# TODO: Display existing credit assertion as credit, not as negative debit #}
|
||||
<input type="number" class="bordered-field pl-7 pr-16" name="amount" step="0.01" value="{{ assertion.balance().quantity_string() if assertion else '' }}" placeholder="0.00">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center">
|
||||
<select class="h-full border-0 bg-transparent py-0 pl-2 pr-8 text-gray-900 focus:ring-2 focus:ring-inset focus:ring-indigo-600" name="sign">
|
||||
<option value="dr">Dr</option>
|
||||
<option value="cr">Cr</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4 space-x-2">
|
||||
{% if assertion and assertion.id %}
|
||||
<button type="submit" name="action" value="delete" class="btn-secondary text-red-600 ring-red-500" onclick="return confirm('Are you sure you want to delete this balance assertion? This operation is irreversible.');">Delete</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/combobox.js') }}"></script>
|
||||
{% endblock %}
|
86
drcr/templates/journal/journal.html
Normal file
86
drcr/templates/journal/journal.html
Normal file
@ -0,0 +1,86 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Journal{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading">
|
||||
Journal
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex gap-x-2">
|
||||
<a href="{{ url_for('journal_new_transaction') }}" class="btn-primary pl-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New transaction
|
||||
</a>
|
||||
{% if commodity_detail %}
|
||||
<a href="{{ url_for('journal') }}" class="btn-secondary">
|
||||
Hide commodity detail
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('journal', commodity_detail=1) }}" class="btn-secondary">
|
||||
Show commodity detail
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start" colspan="3">Description</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">Cr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for transaction in transactions %}
|
||||
<tr class="border-t border-gray-300">
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ transaction.dt.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900" colspan="3">
|
||||
{{ transaction.description }}
|
||||
<a href="{{ url_for('journal_edit_transaction', id=transaction.id) }}" class="text-gray-500 hover:text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 inline align-middle -mt-0.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
</a>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% for posting in transaction.postings %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ posting.description or '' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end"><i>{{ 'Dr' if posting.quantity >= 0 else 'Cr' }}</i></td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ posting.account }}</td>
|
||||
{% if commodity_detail %}
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ posting.amount().format('force') if posting.quantity >= 0 else '' }}</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">{{ (posting.amount()|abs).format('force') if posting.quantity < 0 else '' }}</td>
|
||||
{% else %}
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ posting.amount().as_cost().format() if posting.quantity >= 0 else '' }}</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">{{ (posting.amount()|abs).as_cost().format() if posting.quantity < 0 else '' }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
241
drcr/templates/journal/journal_edit_transaction.html
Normal file
241
drcr/templates/journal/journal_edit_transaction.html
Normal file
@ -0,0 +1,241 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ 'Edit' if transaction and transaction.id else 'New' }} transaction{% endblock %}
|
||||
|
||||
{# Macros for template posting rows as these are reused #}
|
||||
{% macro template_dr() %}
|
||||
<tr>
|
||||
<td></td>
|
||||
{#<td></td>#}
|
||||
<td class="py-1 px-1" colspan="2">
|
||||
<div class="relative flex">
|
||||
<div class="relative flex flex-grow items-stretch shadow-sm">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center z-10">
|
||||
<select class="h-full border-0 bg-transparent py-0 pl-2 pr-8 text-gray-900 focus:ring-2 focus:ring-inset focus:ring-indigo-600" name="sign" onchange="changeDrCr(this)">
|
||||
<option value="dr" selected>Dr</option>
|
||||
<option value="cr">Cr</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="relative combobox w-full">
|
||||
<input type="text" class="bordered-field pl-16 peer" name="account">
|
||||
{% include 'components/accounts_combobox_inner.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<a class="relative -ml-px px-2 py-2 text-gray-500 hover:text-gray-700" href="#" onclick="addPosting(this);return false;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="amount-dr has-amount py-1 px-1">
|
||||
<div class="relative shadow-sm">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
{# FIXME: Gracefully handle when the reporting commodity is not a single character #}
|
||||
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||
</div>
|
||||
<input type="text" class="bordered-field pl-7" name="amount" oninput="changeAmount(this)">
|
||||
</div>
|
||||
</td>
|
||||
<td class="amount-cr py-1 pl-1"></td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
{% macro template_cr() %}
|
||||
<tr>
|
||||
<td></td>
|
||||
{#<td></td>#}
|
||||
<td class="py-1 px-1" colspan="2">
|
||||
<div class="relative flex">
|
||||
<div class="relative flex flex-grow items-stretch shadow-sm">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center z-10">
|
||||
<select class="h-full border-0 bg-transparent py-0 pl-2 pr-8 text-gray-900 focus:ring-2 focus:ring-inset focus:ring-indigo-600" name="sign" onchange="changeDrCr(this)">
|
||||
<option value="dr">Dr</option>
|
||||
<option value="cr" selected>Cr</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="relative combobox w-full">
|
||||
<input type="text" class="bordered-field pl-16 peer" name="account">
|
||||
{% include 'components/accounts_combobox_inner.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<a class="relative -ml-px px-2 py-2 text-gray-500 hover:text-gray-700" href="#" onclick="addPosting(this);return false;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="amount-dr py-1 px-1"></td>
|
||||
<td class="amount-cr has-amount py-1 pl-1">
|
||||
<div class="relative shadow-sm">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||
</div>
|
||||
<input type="text" class="bordered-field pl-7" name="amount" oninput="changeAmount(this)">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading mb-4">
|
||||
{{ 'Edit' if transaction and transaction.id else 'New' }} transaction
|
||||
</h1>
|
||||
|
||||
<form method="POST" id="edit-transaction-form">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th class="pt-0.5 pb-1 pr-1 text-gray-900 font-semibold text-start">Date</th>
|
||||
<th class="pt-0.5 pb-1 px-1 text-gray-900 font-semibold text-start" colspan="2">Description</th>
|
||||
<th class="pt-0.5 pb-1 px-1 text-gray-900 font-semibold text-start">Dr</th>
|
||||
<th class="pt-0.5 pb-1 pl-1 text-gray-900 font-semibold text-start">Cr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pt-2 pb-1 pr-1">
|
||||
<input type="date" class="bordered-field" name="dt" value="{{ transaction.dt.strftime('%Y-%m-%d') if transaction else '' }}">
|
||||
</td>
|
||||
<td class="pt-2 pb-1 px-1" colspan="2">
|
||||
<input type="text" class="bordered-field" name="description" value="{{ transaction.description if transaction else '' }}">
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% if transaction %}
|
||||
{% for posting in transaction.postings %}
|
||||
<tr>
|
||||
<td></td>
|
||||
{#<td></td>#}
|
||||
<td class="py-1 px-1" colspan="2">
|
||||
<div class="relative flex">
|
||||
<div class="relative flex flex-grow items-stretch shadow-sm">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center z-10">
|
||||
<select class="h-full border-0 bg-transparent py-0 pl-2 pr-8 text-gray-900 focus:ring-2 focus:ring-inset focus:ring-indigo-600" name="sign" onchange="changeDrCr(this)">
|
||||
<option value="dr"{% if posting.quantity >= 0 %} selected{% endif %}>Dr</option>
|
||||
<option value="cr"{% if posting.quantity < 0 %} selected{% endif %}>Cr</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="relative combobox w-full">
|
||||
<input type="text" class="bordered-field pl-16 peer" name="account" value="{{ posting.account }}">
|
||||
{% include 'components/accounts_combobox_inner.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<a class="relative -ml-px px-2 py-2 text-gray-500 hover:text-gray-700" href="#" onclick="addPosting(this);return false;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
{% if posting.quantity >= 0 %}
|
||||
<td class="amount-dr has-amount py-1 px-1">
|
||||
<div class="relative shadow-sm">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||
</div>
|
||||
<input type="text" class="bordered-field pl-7" name="amount" value="{{ posting.amount().quantity_string() }}" oninput="changeAmount(this)">
|
||||
</div>
|
||||
</td>
|
||||
<td class="amount-cr py-1 pl-1"></td>
|
||||
{% else %}
|
||||
<td class="amount-dr py-1 px-1"></td>
|
||||
<td class="amount-cr has-amount py-1 pl-1">
|
||||
<div class="relative shadow-sm">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||
</div>
|
||||
<input type="text" class="bordered-field pl-7" name="amount" value="{{ (posting.amount()|abs).quantity_string() }}" oninput="changeAmount(this)">
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{{ template_dr() }}
|
||||
{{ template_cr() }}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex justify-end mt-4 space-x-2">
|
||||
{% if transaction and transaction.id %}
|
||||
<button type="submit" name="action" value="delete" class="btn-secondary text-red-600 ring-red-500" onclick="return confirm('Are you sure you want to delete this transaction? This operation is irreversible.');">Delete</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="referrer" value="{{ request.referrer or '' }}">
|
||||
</form>
|
||||
|
||||
{# Save HTML for template posting rows so we can access this from JS when required #}
|
||||
<table style="display:none" id="template-dr">{{ template_dr() }}</table>
|
||||
<table style="display:none" id="template-cr">{{ template_cr() }}</table>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function changeDrCr(el) {
|
||||
let trPosting = el.parentNode.parentNode.parentNode.parentNode.parentNode;
|
||||
let amountContent = trPosting.querySelector('.has-amount').innerHTML;
|
||||
let amountValue = trPosting.querySelector('.has-amount input').value;
|
||||
|
||||
// Remove input boxes
|
||||
for (let td of trPosting.querySelectorAll('.amount-dr, .amount-cr')) {
|
||||
td.innerHTML = '';
|
||||
td.classList.remove('has-amount');
|
||||
}
|
||||
|
||||
// Add correct input box
|
||||
let td = trPosting.querySelector(el.value === 'dr' ? '.amount-dr' : '.amount-cr');
|
||||
td.innerHTML = amountContent;
|
||||
td.classList.add('has-amount');
|
||||
td.querySelector('input').value = amountValue;
|
||||
}
|
||||
|
||||
function addPosting(el) {
|
||||
let trPosting = el.parentNode.parentNode.parentNode;
|
||||
let sign = trPosting.querySelector('select').value; // Use same sign as row clicked
|
||||
|
||||
// Add new posting row
|
||||
let trNew = document.createElement('tr');
|
||||
trNew.innerHTML = document.getElementById(sign === 'dr' ? 'template-dr' : 'template-cr').querySelector('tr').innerHTML;
|
||||
trPosting.after(trNew);
|
||||
|
||||
// Initialise new combobox
|
||||
initCombobox(trNew.querySelector('.combobox'));
|
||||
}
|
||||
|
||||
function changeAmount(el) {
|
||||
// Update linked postings if there are only 2 and the first is edited
|
||||
// This allows changing the second posting independently by editing it (e.g. mixing commodities of equivalent total value)
|
||||
let amountInputs = document.querySelectorAll('#edit-transaction-form input[name="amount"]');
|
||||
if (amountInputs.length === 2 && el === amountInputs[0]) {
|
||||
for (inp of amountInputs) {
|
||||
if (inp !== el) {
|
||||
// Update other input with amount
|
||||
inp.value = el.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/combobox.js') }}"></script>
|
||||
{% endblock %}
|
79
drcr/templates/report.html
Normal file
79
drcr/templates/report.html
Normal file
@ -0,0 +1,79 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ report.title }}{% endblock %}
|
||||
|
||||
{% macro render_section(section) %}
|
||||
{% if section.title %}
|
||||
<tr>
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{{ section.title }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% for entry in section.entries %}
|
||||
{{ render_entry(entry) }}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_entry(entry) %}
|
||||
{% if entry.visible %}
|
||||
{% if entry.__class__.__name__ == 'Section' %}
|
||||
{{ render_section(entry) }}
|
||||
{% elif entry.__class__.__name__ == 'Subtotal' %}
|
||||
<tr{% if entry.bordered %} class="border-y border-gray-300"{% endif %}>
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{{ entry.text }}</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">{{ entry.amount.format_accounting() }}</th>
|
||||
</tr>
|
||||
{% elif entry.__class__.__name__ == 'Spacer' %}
|
||||
<tr><td colspan="2" class="py-0.5"> </td></tr>
|
||||
{% else %}
|
||||
<tr{% if entry.bordered %} class="border-y border-gray-300"{% endif %}>
|
||||
{% if entry.heading %}<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{% else %}<td class="py-0.5 pr-1 text-gray-900 text-start">{% endif %}
|
||||
{% if entry.link %}
|
||||
<a href="{{ entry.link }}" class="hover:text-blue-700 hover:underline">{{ entry.text }}</a>
|
||||
{% else %}
|
||||
{{ entry.text }}
|
||||
{% endif %}
|
||||
</{{ 'th' if entry.heading else 'td' }}>
|
||||
{% if entry.heading %}<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">{% else %}<td class="py-0.5 pl-1 text-gray-900 text-end">{% endif %}
|
||||
{{ entry.amount.format_accounting() }}
|
||||
</{{ 'th' if entry.heading else 'td' }}>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading">
|
||||
{{ report.title }}
|
||||
</h1>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th></th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">{{ reporting_commodity }} </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in report.entries %}
|
||||
{{ render_entry(entry) }}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
77
drcr/templates/statements/import.html
Normal file
77
drcr/templates/statements/import.html
Normal file
@ -0,0 +1,77 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Import statement{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading mb-4">
|
||||
Import statement
|
||||
</h1>
|
||||
|
||||
<h2 class="text-xl text-gray-900 font-semibold mb-1">OFX 1.x</h2>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="format" value="ofx1">
|
||||
<div class="flex">
|
||||
<div class="grow mr-2 relative combobox">
|
||||
<input type="text" class="bordered-field peer" name="source-account" placeholder="Source account" autocomplete="off">
|
||||
{% include 'components/accounts_combobox_inner.html' %}
|
||||
</div>
|
||||
<div class="flex">
|
||||
</div>
|
||||
<div class="flex grow mr-2">
|
||||
<label for="file_ofx1" class="btn-primary bg-gray-600 hover:bg-gray-700">Browse</label>
|
||||
<input type="file" class="file:hidden block w-full border-0 py-1 px-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300" name="file" id="file_ofx1" accept=".ofx">
|
||||
</div>
|
||||
<div class="mr-2">
|
||||
<button type="submit" name="action" value="preview" class="btn-secondary">Preview</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" name="action" value="import" class="btn-primary">Import</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h2 class="text-xl text-gray-900 font-semibold mt-4 mb-1">OFX 2.x</h2>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="format" value="ofx2">
|
||||
<div class="flex">
|
||||
<div class="grow mr-2 relative combobox">
|
||||
<input type="text" class="bordered-field peer" name="source-account" placeholder="Source account" autocomplete="off">
|
||||
{% include 'components/accounts_combobox_inner.html' %}
|
||||
</div>
|
||||
<div class="flex">
|
||||
</div>
|
||||
<div class="flex grow mr-2">
|
||||
<label for="file_ofx2" class="btn-primary bg-gray-600 hover:bg-gray-700">Browse</label>
|
||||
<input type="file" class="file:hidden block w-full border-0 py-1 px-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300" name="file" id="file_ofx2" accept=".ofx">
|
||||
</div>
|
||||
<div class="mr-2">
|
||||
<button type="submit" name="action" value="preview" class="btn-secondary">Preview</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" name="action" value="import" class="btn-primary">Import</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/combobox.js') }}"></script>
|
||||
{% endblock %}
|
48
drcr/templates/statements/import_preview.html
Normal file
48
drcr/templates/statements/import_preview.html
Normal file
@ -0,0 +1,48 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Import statement preview{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading mb-4">
|
||||
Import statement preview
|
||||
</h1>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Description</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Cr</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for line in statement_lines %}
|
||||
<tr>
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ line.dt.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ line.description }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ line.amount().format() if line.quantity >= 0 else '' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ (line.amount()|abs).format() if line.quantity < 0 else '' }}</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">{{ line.balance or '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
148
drcr/templates/statements/statement_lines.html
Normal file
148
drcr/templates/statements/statement_lines.html
Normal file
@ -0,0 +1,148 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Statement lines{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading">
|
||||
Statement lines
|
||||
</h1>
|
||||
|
||||
<form method="POST">
|
||||
<div class="my-2 py-2 flex bg-white sticky top-0">
|
||||
<div class="grow flex gap-x-2 items-baseline">
|
||||
<button formaction="{{ url_for('statement_line_reconcile_transfer') }}" class="btn-secondary text-emerald-700 ring-emerald-600" type="submit">
|
||||
Reconcile selected as transfer
|
||||
</button>
|
||||
<a href="{{ url_for('statement_lines_import') }}" class="btn-secondary">
|
||||
Import statement
|
||||
</a>
|
||||
{% if request.args.get('unclassified', '0') != '1' %}
|
||||
<a href="{{ url_for('statement_lines', **dict(request.args, unclassified=1)) }}" class="btn-secondary">
|
||||
Show only unclassified lines
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm">
|
||||
{% if page.prev_num %}
|
||||
<a href="{{ url_for('statement_lines', **dict(request.args, page=page.prev_num)) }}" class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% for pageno in page.iter_pages() %}
|
||||
{% if pageno %}
|
||||
{% if pageno == page.page %}
|
||||
<a href="{{ url_for('statement_lines', **dict(request.args, page=pageno)) }}" class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-emerald-600">{{ pageno }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('statement_lines', **dict(request.args, page=pageno)) }}" class="relative inline-flex items-center px-4 py-2 text-sm text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">{{ pageno }}</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300">…</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if page.next_num %}
|
||||
<a href="{{ url_for('statement_lines', **dict(request.args, page=page.next_num)) }}" class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 bg-white hover:bg-gray-50">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th></th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Source account</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Date</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Description</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Charged to</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-end">Dr</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-end">Cr</th>
|
||||
<th class="py-0.5 pl-1 align-bottom text-gray-900 font-semibold text-end">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for line in page.items %}
|
||||
<tr data-line-id="{{ line.id }}">
|
||||
<td class="py-0.5 pr-1 align-baseline"><input class="checkbox-primary" type="checkbox" name="sel-line-id" value="{{ line.id }}"></td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900"><a href="{{ url_for('statement_lines', account=line.source_account) }}" class="hover:text-blue-700 hover:underline">{{ line.source_account }}</a></td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900">{{ line.dt.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900">{{ line.description }}</td>
|
||||
<td class="charge-account py-0.5 px-1 align-baseline text-gray-900">
|
||||
{% if not line.reconciliation %}
|
||||
<a href="#" class="text-red-500 hover:text-red-600 hover:underline" onclick="classifyLine({{ line.id }});return false;">Unclassified</a>
|
||||
<a href="{{ url_for('statement_line_charge_complex') }}?line-id={{ line.id }}" class="text-gray-500 hover:text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 inline align-middle -mt-0.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
</a>
|
||||
{% elif line.is_complex() %}
|
||||
<i>(Complex)</i>
|
||||
<a href="{{ url_for('journal_edit_transaction', id=line.reconciliation.posting.transaction.id) }}" class="text-gray-500 hover:text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 inline align-middle -mt-0.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
</a>
|
||||
{% else %}
|
||||
{% for posting in line.reconciliation.posting.transaction.postings if posting.account != line.source_account %}
|
||||
<a href="#" class="hover:text-blue-700 hover:underline" onclick="classifyLine({{ line.id }});return false;">{{ posting.account }}</a>
|
||||
{% endfor %}
|
||||
<a href="{{ url_for('journal_edit_transaction', id=line.reconciliation.posting.transaction.id) }}" class="text-gray-500 hover:text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 inline align-middle -mt-0.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900 text-end">{{ line.amount().format() if line.quantity >= 0 else '' }}</td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900 text-end">{{ (line.amount()|abs).format() if line.quantity < 0 else '' }}</td>
|
||||
<td class="py-0.5 pl-1 align-baseline text-gray-900 text-end">{{ line.balance or '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function classifyLine(lineId) {
|
||||
let chargeAccount = prompt('Charge to:');
|
||||
if (!chargeAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.addEventListener('load', function() {
|
||||
if (xhr.status === 200) {
|
||||
document.querySelector('[data-line-id="' + lineId + '"] .charge-account a').innerText = chargeAccount;
|
||||
document.querySelector('[data-line-id="' + lineId + '"] .charge-account a').className = 'text-body';
|
||||
} else {
|
||||
alert('Error when charging statement line');
|
||||
}
|
||||
});
|
||||
xhr.open('POST', '{{ url_for("statement_line_charge") }}');
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
xhr.send('line-id=' + lineId + '&charge-account=' + chargeAccount);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
117
drcr/templates/transactions.html
Normal file
117
drcr/templates/transactions.html
Normal file
@ -0,0 +1,117 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Account transactions{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading">
|
||||
Account transactions
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex gap-x-2">
|
||||
<a href="{{ url_for('journal_new_transaction') }}" class="btn-primary pl-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New transaction
|
||||
</a>
|
||||
<a href="{{ url_for('account_transactions', account=account, commodity_detail=1) }}" class="btn-secondary">
|
||||
Show commodity detail
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Description</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Related Account</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Cr</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Balance</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="min-w-full">
|
||||
{% for transaction in transactions %}
|
||||
{% if transaction.postings|length == 2 %}
|
||||
{% for posting in transaction.postings if posting.account == account %}
|
||||
<tr class="border-t border-gray-300">
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ transaction.dt.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">
|
||||
{{ transaction.description }}
|
||||
{% if transaction.id %}
|
||||
<a href="{{ url_for('journal_edit_transaction', id=transaction.id) }}" class="text-gray-500 hover:text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 inline align-middle -mt-0.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{% for p in transaction.postings if p.account != account %}<a href="{{ url_for('account_transactions', account=p.account) }}" class="text-gray-900 hover:text-blue-700 hover:underline">{{ p.account }}</a>{% endfor %}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ posting.amount().as_cost().format() if posting.quantity >= 0 else '' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ (posting.amount()|abs).as_cost().format() if posting.quantity < 0 else '' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ (running_totals[posting]|abs).format() }}</td>
|
||||
<td class="py-0.5 text-gray-900">{{ 'Dr' if running_totals[posting].quantity >= 0 else 'Cr' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr class="border-t border-gray-300">
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ transaction.dt.strftime('%Y-%m-%d') }}</td>
|
||||
<td colspan="2" class="py-0.5 px-1 text-gray-900">
|
||||
{{ transaction.description }}
|
||||
{% if transaction.id %}
|
||||
<a href="{{ url_for('journal_edit_transaction', id=transaction.id) }}" class="text-gray-500 hover:text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 inline align-middle -mt-0.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% for posting in transaction.postings if posting.account == account %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end"><i>{{ 'Dr' if posting.quantity >= 0 else 'Cr' }}</i></td>
|
||||
<td class="py-0.5 px-1 text-gray-900"><a href="{{ url_for('account_transactions', account=account) }}" class="text-gray-900 hover:text-blue-700 hover:underline">{{ account }}</a></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ posting.amount().as_cost().format() if posting.quantity >= 0 else '' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ (posting.amount()|abs).as_cost().format() if posting.quantity < 0 else '' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ (running_totals[posting]|abs).format() }}</td>
|
||||
<td class="py-0.5 text-gray-900">{{ 'Dr' if running_totals[posting].quantity >= 0 else 'Cr' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for posting in transaction.postings if posting.account != account %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end"><i>{{ 'Dr' if posting.quantity >= 0 else 'Cr' }}</i></td>
|
||||
<td class="py-0.5 px-1 text-gray-900"><a href="{{ url_for('account_transactions', account=posting.account) }}" class="text-gray-900 hover:text-blue-700 hover:underline">{{ posting.account }}</a></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ posting.amount().as_cost().format() if posting.quantity >= 0 else '' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ (posting.amount()|abs).as_cost().format() if posting.quantity < 0 else '' }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
93
drcr/templates/transactions_commodity_detail.html
Normal file
93
drcr/templates/transactions_commodity_detail.html
Normal file
@ -0,0 +1,93 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Account transactions{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading">
|
||||
Account transactions
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex gap-x-2">
|
||||
<a href="{{ url_for('journal_new_transaction') }}" class="btn-primary pl-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New transaction
|
||||
</a>
|
||||
<a href="{{ url_for('account_transactions', account=account) }}" class="btn-secondary">
|
||||
Hide commodity detail
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Description</th>
|
||||
<th></th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Amount</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Balance</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for transaction in transactions %}
|
||||
<tr class="border-t border-gray-300">
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ transaction.dt.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">
|
||||
{{ transaction.description }}
|
||||
{% if transaction.id %}
|
||||
<a href="{{ url_for('journal_edit_transaction', id=transaction.id) }}" class="text-gray-500 hover:text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 inline align-middle -mt-0.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% for amount in running_totals[transaction].amounts %}
|
||||
{# FIXME: Assumes at most one posting per commodity #}
|
||||
{% for posting in transaction.postings if posting.commodity == amount.commodity and posting.account == account %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ 'Dr' if posting.quantity >= 0 else 'Cr' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ (posting.amount()|abs).format('force') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ (amount|abs).format('force') }}</td>
|
||||
<td class="py-0.5 text-gray-900">{{ 'Dr' if amount.quantity >= 0 else 'Cr' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ (amount|abs).format('force') }}</td>
|
||||
<td class="py-0.5 text-gray-900">{{ 'Dr' if amount.quantity >= 0 else 'Cr' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
49
drcr/templates/trial_balance.html
Normal file
49
drcr/templates/trial_balance.html
Normal file
@ -0,0 +1,49 @@
|
||||
{# DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Trial balance{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="page-heading mb-4">
|
||||
Trial balance
|
||||
</h1>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Account</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">Cr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for name, balance in accounts.items() %}
|
||||
<tr>
|
||||
<td class="py-0.5 pr-1 text-gray-900"><a href="{{ url_for('account_transactions', account=name) }}" class="hover:text-blue-700 hover:underline">{{ name }}</a></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ balance.format() if balance.quantity >= 0 else '' }}</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">{{ (balance|abs).format() if balance.quantity < 0 else '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Total</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 text-end">{{ total_dr.format() }}</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 text-end">{{ (total_cr|abs).format() }}</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
72
drcr/transactions.py
Normal file
72
drcr/transactions.py
Normal file
@ -0,0 +1,72 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from .database import db
|
||||
from .models import Transaction
|
||||
from .plugins import transaction_providers
|
||||
from .statements.models import StatementLine
|
||||
|
||||
def limit_query_dt(query, field, start_date=None, end_date=None):
|
||||
"""Helper function to limit the query between the start and end dates"""
|
||||
|
||||
if start_date and end_date:
|
||||
return query.where((field >= start_date) & (field <= end_date))
|
||||
if start_date:
|
||||
return query.where(field >= start_date)
|
||||
if end_date:
|
||||
return query.where(field <= end_date)
|
||||
return query
|
||||
|
||||
def all_transactions(start_date=None, end_date=None, api_stage_until=None, join_postings=True):
|
||||
"""Return all transactions, including from DB and API"""
|
||||
|
||||
transactions = db_transactions(start_date=start_date, end_date=end_date, join_postings=join_postings)
|
||||
transactions.extend(api_transactions(start_date=start_date, end_date=end_date, api_stage_until=api_stage_until))
|
||||
return transactions
|
||||
|
||||
def db_transactions(start_date=None, end_date=None, join_postings=True):
|
||||
"""Return only transactions from DB"""
|
||||
|
||||
# All Transactions in database between start_date and end_date
|
||||
query = db.select(Transaction)
|
||||
query = limit_query_dt(query, Transaction.dt, start_date, end_date)
|
||||
if join_postings:
|
||||
query = query.options(db.selectinload(Transaction.postings))
|
||||
|
||||
transactions = db.session.scalars(query).all()
|
||||
return transactions
|
||||
|
||||
def api_transactions(start_date=None, end_date=None, api_stage_until=None):
|
||||
"""Return only transactions from API"""
|
||||
|
||||
# Stage 0: DB transactions
|
||||
# Stage 100: Ordinary transactions
|
||||
# Stage 200: Closing entries prior to tax
|
||||
# Stage 300: Tax entries
|
||||
# Stage 400: Closing entries after tax
|
||||
|
||||
transactions = []
|
||||
|
||||
# Unreconciled StatementLines
|
||||
query = db.select(StatementLine).where(StatementLine.reconciliation == None)
|
||||
query = limit_query_dt(query, StatementLine.dt, start_date, end_date)
|
||||
transactions.extend(line.into_transaction() for line in db.session.scalars(query).all())
|
||||
|
||||
# Plugins
|
||||
for transaction_provider in transaction_providers:
|
||||
transactions = transaction_provider(transactions, start_date=start_date, end_date=end_date, api_stage_until=api_stage_until)
|
||||
|
||||
return transactions
|
142
drcr/views.py
Normal file
142
drcr/views.py
Normal file
@ -0,0 +1,142 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import redirect, render_template, request, url_for
|
||||
|
||||
from .database import db
|
||||
from .models import AccountConfiguration, Amount, Balance, Posting, TrialBalancer, reporting_commodity
|
||||
from .plugins import account_kinds, advanced_reports, data_sources
|
||||
from .reports import balance_sheet_report, income_statement_report
|
||||
from .transactions import all_transactions, api_transactions
|
||||
from .webapp import app
|
||||
|
||||
from datetime import datetime
|
||||
from itertools import groupby
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html', data_sources=data_sources, advanced_reports=advanced_reports)
|
||||
|
||||
@app.route('/chart-of-accounts')
|
||||
def chart_of_accounts():
|
||||
#accounts = sorted(db.session.execute(db.select(Posting.account)).unique().scalars().all())
|
||||
accounts = sorted(list(set(p.account for t in all_transactions() for p in t.postings)))
|
||||
|
||||
# Get existing AccountConfiguration's
|
||||
account_configurations = AccountConfiguration.get_all()
|
||||
|
||||
# Preprocess account kinds
|
||||
account_kinds_by_plugin = {v: list(g) for v, g in groupby(account_kinds, key=lambda k: k[0][:k[0].index('.')])}
|
||||
account_kinds_map = {name: label for name, label in account_kinds}
|
||||
|
||||
# TODO: Handle orphans
|
||||
return render_template(
|
||||
'chart_of_accounts.html',
|
||||
accounts=accounts,
|
||||
account_configurations=account_configurations,
|
||||
account_kinds_by_plugin=account_kinds_by_plugin,
|
||||
account_kinds_map=account_kinds_map
|
||||
)
|
||||
|
||||
@app.route('/chart-of-accounts/add-kind', methods=['POST'])
|
||||
def account_add_kind():
|
||||
for account in request.form.getlist('sel-account'):
|
||||
account_configuration = AccountConfiguration(account=account, kind=request.form['kind'])
|
||||
db.session.add(account_configuration)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('chart_of_accounts'))
|
||||
|
||||
@app.route('/general-ledger')
|
||||
def general_ledger():
|
||||
return render_template(
|
||||
'general_ledger.html',
|
||||
commodity_detail=request.args.get('commodity_detail', '0') == '1',
|
||||
transactions=sorted(all_transactions(), key=lambda t: t.dt, reverse=True)
|
||||
)
|
||||
|
||||
@app.route('/trial-balance')
|
||||
def trial_balance():
|
||||
dt = datetime.strptime(request.args['date'], '%Y-%m-%d') if 'date' in request.args else None
|
||||
|
||||
balancer = TrialBalancer.from_cached(end_date=dt)
|
||||
balancer.apply_transactions(api_transactions(end_date=dt))
|
||||
|
||||
total_dr = Amount(sum(v.quantity for v in balancer.accounts.values() if v.quantity > 0), reporting_commodity())
|
||||
total_cr = Amount(sum(v.quantity for v in balancer.accounts.values() if v.quantity < 0), reporting_commodity())
|
||||
|
||||
return render_template('trial_balance.html', accounts=dict(sorted(balancer.accounts.items())), total_dr=total_dr, total_cr=total_cr)
|
||||
|
||||
@app.route('/account-transactions')
|
||||
def account_transactions():
|
||||
# FIXME: Filter in SQL
|
||||
transactions = [t for t in all_transactions() if any(p.account == request.args['account'] for p in t.postings)]
|
||||
|
||||
if request.args.get('commodity_detail', '0') == '1':
|
||||
# Pre-compute running totals
|
||||
# At the level of individual transactions
|
||||
running_totals = {}
|
||||
running_total = Balance()
|
||||
for transaction in sorted(transactions, key=lambda t: t.dt):
|
||||
for posting in transaction.postings:
|
||||
if posting.account == request.args['account']:
|
||||
running_total.add(posting.amount())
|
||||
|
||||
running_totals[transaction] = running_total.clone()
|
||||
running_total.clean()
|
||||
|
||||
return render_template(
|
||||
'transactions_commodity_detail.html',
|
||||
account=request.args['account'],
|
||||
running_totals=running_totals,
|
||||
transactions=reversed(sorted(transactions, key=lambda t: t.dt))
|
||||
)
|
||||
else:
|
||||
# Pre-compute running totals
|
||||
# There can be more than one posting per account per transaction, so track the running total at the level of individual postings
|
||||
running_totals = {}
|
||||
running_total = Amount(0, reporting_commodity())
|
||||
for transaction in sorted(transactions, key=lambda t: t.dt):
|
||||
for posting in transaction.postings:
|
||||
if posting.account == request.args['account']:
|
||||
running_total += posting.amount().as_cost()
|
||||
running_totals[posting] = running_total
|
||||
|
||||
return render_template(
|
||||
'transactions.html',
|
||||
account=request.args['account'],
|
||||
running_totals=running_totals,
|
||||
transactions=reversed(sorted(transactions, key=lambda t: t.dt))
|
||||
)
|
||||
|
||||
@app.route('/balance-sheet')
|
||||
def balance_sheet():
|
||||
report = balance_sheet_report()
|
||||
return render_template('report.html', report=report)
|
||||
|
||||
@app.route('/income-statement')
|
||||
def income_statement():
|
||||
if 'end_date' in request.args:
|
||||
start_date = datetime.strptime(request.args['start_date'], '%Y-%m-%d') if 'start_date' in request.args else datetime.min
|
||||
end_date = datetime.strptime(request.args['end_date'], '%Y-%m-%d')
|
||||
|
||||
print(end_date)
|
||||
else:
|
||||
start_date, end_date = None, None
|
||||
|
||||
report = income_statement_report(start_date=start_date, end_date=end_date)
|
||||
return render_template('report.html', report=report)
|
109
drcr/webapp.py
Normal file
109
drcr/webapp.py
Normal file
@ -0,0 +1,109 @@
|
||||
# DrCr: Web-based double-entry bookkeeping framework
|
||||
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from flask import Flask, g
|
||||
import toml
|
||||
app = Flask(__name__)
|
||||
app.config.from_file('config.toml', load=toml.load)
|
||||
|
||||
from flask_sqlalchemy.record_queries import get_recorded_queries
|
||||
|
||||
from .database import db
|
||||
from .models import Amount, Metadata, Transaction, reporting_commodity
|
||||
from .plugins import init_plugins
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
|
||||
app.config['SQLALCHEMY_RECORD_QUERIES'] = app.debug
|
||||
db.init_app(app)
|
||||
|
||||
def all_accounts():
|
||||
"""Return all accounts in alphabetical order"""
|
||||
|
||||
return db.session.scalars('SELECT DISTINCT account FROM postings ORDER BY account').all()
|
||||
|
||||
def eofy_date():
|
||||
"""Get the datetime for the end of the financial year"""
|
||||
|
||||
return datetime.strptime(Metadata.get('eofy_date'), '%Y-%m-%d')
|
||||
|
||||
def sofy_date():
|
||||
"""Get the datetime for the start of the financial year"""
|
||||
|
||||
dt = eofy_date()
|
||||
dt = dt.replace(year=dt.year - 1)
|
||||
dt += timedelta(days=1)
|
||||
return dt
|
||||
|
||||
from . import views
|
||||
from .journal import views
|
||||
from .statements import views
|
||||
|
||||
init_plugins()
|
||||
|
||||
@app.cli.command('initdb')
|
||||
def initdb():
|
||||
"""Initialise database tables"""
|
||||
|
||||
db.create_all()
|
||||
|
||||
# FIXME: Need to init metadata
|
||||
|
||||
@app.cli.command('recache_balances')
|
||||
def recache_balances():
|
||||
"""Recompute running_balance for all postings"""
|
||||
|
||||
# Get all Transactions in database in correct order
|
||||
transactions = db.session.scalars(db.select(Transaction).options(db.selectinload(Transaction.postings)).order_by(Transaction.dt, Transaction.id)).all()
|
||||
|
||||
accounts = {}
|
||||
|
||||
for transaction in transactions:
|
||||
for posting in transaction.postings:
|
||||
if posting.account not in accounts:
|
||||
accounts[posting.account] = Amount(0, reporting_commodity())
|
||||
|
||||
# FIXME: Handle commodities better (ensure compatible commodities)
|
||||
accounts[posting.account].quantity += posting.amount().as_cost().quantity
|
||||
|
||||
posting.running_balance = accounts[posting.account].quantity
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@app.context_processor
|
||||
def add_reporting_commodity():
|
||||
return dict(reporting_commodity=reporting_commodity())
|
||||
|
||||
if app.debug:
|
||||
@app.before_request
|
||||
def before_request():
|
||||
g.start = time.time()
|
||||
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
diff = time.time() - g.start
|
||||
if response.response and response.status_code == 200 and response.content_type.startswith('text/html'):
|
||||
response.set_data(response.get_data().replace(b'__EXECUTION_TIME__', bytes(format(diff * 1000, '.1f'), 'utf-8')))
|
||||
return response
|
||||
|
||||
@app.context_processor
|
||||
def add_dbtime():
|
||||
def dbtime():
|
||||
queries = get_recorded_queries()
|
||||
total_duration = sum(q.duration for q in queries)
|
||||
return format(total_duration * 1000, '.1f')
|
||||
return dict(dbtime=dbtime)
|
31
index.html
31
index.html
@ -1,31 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<html class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" type="text/css" href="/src/style.css">
|
||||
<!-- TODO: Embed Roboto Flex font -->
|
||||
<title>DrCr</title>
|
||||
</head>
|
||||
<body class="h-full">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
37
package.json
37
package.json
@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "drcr",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-sql": "~2",
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
"clusterize.js": "^1.0.0",
|
||||
"csv-parse": "^5.6.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/clusterize.js": "^0.18.3",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.3.1",
|
||||
"vue-tsc": "^2.0.22"
|
||||
}
|
||||
}
|
1906
pnpm-lock.yaml
generated
1906
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
15
requirements.txt
Normal file
15
requirements.txt
Normal file
@ -0,0 +1,15 @@
|
||||
Flask==2.2.2
|
||||
Flask-SQLAlchemy==3.0.2
|
||||
toml==0.10.2
|
||||
|
||||
# For OFX 1.x import
|
||||
lxml==5.2.2
|
||||
|
||||
# Dependencies
|
||||
click==8.1.3
|
||||
greenlet==3.0.3
|
||||
itsdangerous==2.1.2
|
||||
Jinja2==3.1.2
|
||||
MarkupSafe==2.1.1
|
||||
SQLAlchemy==1.4.45
|
||||
Werkzeug==2.2.2
|
3
run_debug.sh
Executable file
3
run_debug.sh
Executable file
@ -0,0 +1,3 @@
|
||||
#!/usr/bin/bash
|
||||
. venv/bin/activate
|
||||
flask -A drcr --debug run
|
123
schema.sql
123
schema.sql
@ -1,123 +0,0 @@
|
||||
-- DrCr: Web-based double-entry bookkeeping framework
|
||||
-- Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
--
|
||||
-- This program is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published by
|
||||
-- the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
---------
|
||||
-- Tables
|
||||
|
||||
CREATE TABLE account_configurations (
|
||||
id INTEGER NOT NULL,
|
||||
account VARCHAR,
|
||||
kind VARCHAR,
|
||||
data JSON,
|
||||
PRIMARY KEY(id)
|
||||
);
|
||||
|
||||
CREATE TABLE balance_assertions (
|
||||
id INTEGER NOT NULL,
|
||||
dt DATETIME,
|
||||
description VARCHAR,
|
||||
account VARCHAR,
|
||||
quantity INTEGER,
|
||||
commodity VARCHAR,
|
||||
PRIMARY KEY(id)
|
||||
);
|
||||
|
||||
CREATE TABLE metadata (
|
||||
id INTEGER NOT NULL,
|
||||
key VARCHAR,
|
||||
value VARCHAR,
|
||||
PRIMARY KEY(id)
|
||||
);
|
||||
|
||||
CREATE TABLE postings (
|
||||
id INTEGER NOT NULL,
|
||||
transaction_id INTEGER,
|
||||
description VARCHAR,
|
||||
account VARCHAR,
|
||||
quantity INTEGER,
|
||||
commodity VARCHAR,
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE statement_line_reconciliations (
|
||||
id INTEGER NOT NULL,
|
||||
statement_line_id INTEGER,
|
||||
posting_id INTEGER,
|
||||
PRIMARY KEY(id),
|
||||
FOREIGN KEY(statement_line_id) REFERENCES statement_lines(id),
|
||||
FOREIGN KEY(posting_id) REFERENCES postings(id)
|
||||
);
|
||||
|
||||
CREATE TABLE statement_lines (
|
||||
id INTEGER NOT NULL,
|
||||
source_account VARCHAR,
|
||||
dt DATETIME,
|
||||
description VARCHAR,
|
||||
quantity INTEGER,
|
||||
balance INTEGER,
|
||||
commodity VARCHAR,
|
||||
PRIMARY KEY(id)
|
||||
);
|
||||
|
||||
CREATE TABLE transactions (
|
||||
id INTEGER NOT NULL,
|
||||
dt DATETIME,
|
||||
description VARCHAR,
|
||||
PRIMARY KEY(id)
|
||||
);
|
||||
|
||||
--------
|
||||
-- Views
|
||||
|
||||
-- Join transactions and postings
|
||||
CREATE VIEW joined_transactions AS
|
||||
SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity
|
||||
FROM transactions
|
||||
JOIN postings ON transactions.id = postings.transaction_id
|
||||
ORDER BY dt, transaction_id, postings.id;
|
||||
|
||||
-- Convert amounts into cost basis in reporting commodity
|
||||
CREATE VIEW transactions_with_quantity_ascost AS
|
||||
SELECT
|
||||
*,
|
||||
CAST(ROUND(
|
||||
-- If already in reporting commodity
|
||||
IIF(
|
||||
commodity = '$',
|
||||
quantity,
|
||||
-- Else if specified as total cost
|
||||
IIF(
|
||||
commodity LIKE '% {{%}}',
|
||||
substr(commodity, instr(commodity, ' {{') + 3, length(commodity) - instr(commodity, ' {{') - 4) * sign(quantity) * 100,
|
||||
-- Else if specified as unit cost
|
||||
IIF(
|
||||
commodity LIKE '% {%}',
|
||||
substr(commodity, instr(commodity, ' {') + 2, length(commodity) - instr(commodity, ' {') - 2) * quantity,
|
||||
-- Unexpected
|
||||
NULL
|
||||
)
|
||||
)
|
||||
)
|
||||
) AS INTEGER) AS quantity_ascost
|
||||
FROM joined_transactions;
|
||||
|
||||
-- Sum running balances
|
||||
CREATE VIEW transactions_with_running_balances AS
|
||||
SELECT
|
||||
*,
|
||||
SUM(quantity_ascost) OVER (PARTITION BY account ROWS UNBOUNDED PRECEDING) AS running_balance
|
||||
FROM transactions_with_quantity_ascost;
|
7
src-tauri/.gitignore
vendored
7
src-tauri/.gitignore
vendored
@ -1,7 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
5932
src-tauri/Cargo.lock
generated
5932
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,30 +0,0 @@
|
||||
[package]
|
||||
name = "drcr"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "drcr_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.8", features = ["json", "time"] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||
tauri-plugin-store = "2"
|
||||
tokio = { version = "1", features = ["sync"] }
|
@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for all windows",
|
||||
"windows": [
|
||||
"*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-show",
|
||||
"dialog:default",
|
||||
"shell:allow-open",
|
||||
"sql:default",
|
||||
"sql:allow-execute",
|
||||
"store:default"
|
||||
]
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 572 B |
@ -1,89 +0,0 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
mod sql;
|
||||
|
||||
use tauri::{AppHandle, Builder, Manager, State};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use std::fs;
|
||||
|
||||
struct AppState {
|
||||
db_filename: Option<String>,
|
||||
sql_transactions: Vec<Option<crate::sql::SqliteTransaction>>,
|
||||
}
|
||||
|
||||
// Filename state
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_open_filename(state: State<'_, Mutex<AppState>>) -> Result<Option<String>, tauri_plugin_sql::Error> {
|
||||
let state = state.lock().await;
|
||||
Ok(state.db_filename.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_open_filename(state: State<'_, Mutex<AppState>>, app: AppHandle, filename: Option<String>) -> Result<(), tauri_plugin_sql::Error> {
|
||||
let mut state = state.lock().await;
|
||||
state.db_filename = filename.clone();
|
||||
|
||||
// Persist in store
|
||||
let store = app.store("store.json").expect("Error opening store");
|
||||
store.set("db_filename", filename);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Main method
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
Builder::default()
|
||||
.setup(|app| {
|
||||
// Get open filename
|
||||
let store = app.store("store.json")?;
|
||||
let db_filename = match store.get("db_filename") {
|
||||
None => None,
|
||||
Some(serde_json::Value::String(s)) => {
|
||||
if fs::exists(&s)? {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
_ => panic!("Unexpected db_filename in store")
|
||||
};
|
||||
|
||||
app.manage(Mutex::new(AppState {
|
||||
db_filename: db_filename,
|
||||
sql_transactions: Vec::new(),
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_sql::Builder::new().build())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_open_filename, set_open_filename,
|
||||
sql::sql_transaction_begin, sql::sql_transaction_execute, sql::sql_transaction_select, sql::sql_transaction_rollback, sql::sql_transaction_commit
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Error while running tauri application");
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
drcr_lib::run()
|
||||
}
|
@ -1,233 +0,0 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use serde_json::Value as JsonValue;
|
||||
use sqlx::{Column, Executor, Row, Sqlite, Transaction, TypeInfo, Value, ValueRef};
|
||||
use sqlx::query::Query;
|
||||
use sqlx::sqlite::{SqliteArguments, SqliteRow, SqliteValueRef};
|
||||
use sqlx::types::time::{Date, PrimitiveDateTime, Time};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use tauri::State;
|
||||
use tauri_plugin_sql::{DbInstances, DbPool, Error};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub type SqliteTransaction = Transaction<'static, Sqlite>;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sql_transaction_begin(state: State<'_, Mutex<AppState>>, db_instances: State<'_, DbInstances>, db: String) -> Result<usize, Error> {
|
||||
let instances = db_instances.0.read().await;
|
||||
let db = instances.get(&db).ok_or(Error::DatabaseNotLoaded(db))?;
|
||||
|
||||
let pool = match db {
|
||||
DbPool::Sqlite(pool) => pool,
|
||||
//_ => panic!("Unexpected non-SQLite backend"),
|
||||
};
|
||||
|
||||
// Open transaction
|
||||
let transaction = pool.begin().await?;
|
||||
|
||||
// Store transaction in state
|
||||
let mut state = state.lock().await;
|
||||
let available_index = state.sql_transactions.iter().position(|t| t.is_none());
|
||||
match available_index {
|
||||
Some(i) => {
|
||||
state.sql_transactions[i] = Some(transaction);
|
||||
Ok(i)
|
||||
}
|
||||
None => {
|
||||
state.sql_transactions.push(Some(transaction));
|
||||
Ok(state.sql_transactions.len() - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sql_transaction_execute(state: State<'_, Mutex<AppState>>, transaction_instance_id: usize, query: String, values: Vec<JsonValue>) -> Result<(u64, i64), Error> {
|
||||
let mut state = state.lock().await;
|
||||
let transaction =
|
||||
state.sql_transactions.get_mut(transaction_instance_id)
|
||||
.expect("Invalid database transaction ID")
|
||||
.as_mut() // Take reference to transaction rather than moving out of the Vec
|
||||
.expect("Database transaction ID used after closed");
|
||||
|
||||
let query = prepare_query(&query, values);
|
||||
let result = transaction.execute(query).await?;
|
||||
Ok((
|
||||
result.rows_affected(),
|
||||
result.last_insert_rowid(),
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sql_transaction_select(state: State<'_, Mutex<AppState>>, transaction_instance_id: usize, query: String, values: Vec<JsonValue>) -> Result<Vec<IndexMap<String, JsonValue>>, Error> {
|
||||
let mut state = state.lock().await;
|
||||
let transaction =
|
||||
state.sql_transactions.get_mut(transaction_instance_id)
|
||||
.expect("Invalid database transaction ID")
|
||||
.as_mut() // Take reference to transaction rather than moving out of the Vec
|
||||
.expect("Database transaction ID used after closed");
|
||||
|
||||
let query = prepare_query(&query, values);
|
||||
let rows = transaction.fetch_all(query).await?;
|
||||
rows_to_vec(rows)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sql_transaction_rollback(state: State<'_, Mutex<AppState>>, transaction_instance_id: usize) -> Result<(), Error> {
|
||||
let mut state = state.lock().await;
|
||||
|
||||
let transaction = state.sql_transactions.get_mut(transaction_instance_id)
|
||||
.expect("Invalid database transaction ID")
|
||||
.take() // Remove from Vec
|
||||
.expect("Database transaction ID used after closed");
|
||||
|
||||
transaction.rollback().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sql_transaction_commit(state: State<'_, Mutex<AppState>>, transaction_instance_id: usize) -> Result<(), Error> {
|
||||
let mut state = state.lock().await;
|
||||
|
||||
let transaction = state.sql_transactions.get_mut(transaction_instance_id)
|
||||
.expect("Invalid database transaction ID")
|
||||
.take() // Remove from Vec
|
||||
.expect("Database transaction ID used after closed");
|
||||
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_query<'a, 'b: 'a>(_query: &'b str, _values: Vec<JsonValue>) -> Query<'b, Sqlite, SqliteArguments<'a>> {
|
||||
// Copied from tauri_plugin_sql/src/commands.rs
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// Licensed under MIT/Apache 2.0
|
||||
|
||||
let mut query = sqlx::query(_query);
|
||||
for value in _values {
|
||||
if value.is_null() {
|
||||
query = query.bind(None::<JsonValue>);
|
||||
} else if value.is_string() {
|
||||
query = query.bind(value.as_str().unwrap().to_owned())
|
||||
} else if let Some(number) = value.as_number() {
|
||||
query = query.bind(number.as_f64().unwrap_or_default())
|
||||
} else {
|
||||
query = query.bind(value);
|
||||
}
|
||||
}
|
||||
query
|
||||
}
|
||||
|
||||
fn rows_to_vec(rows: Vec<SqliteRow>) -> Result<Vec<IndexMap<String, JsonValue>>, Error> {
|
||||
// Copied from tauri_plugin_sql/src/commands.rs
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// Licensed under MIT/Apache 2.0
|
||||
|
||||
let mut values = Vec::new();
|
||||
for row in rows {
|
||||
let mut value = IndexMap::default();
|
||||
for (i, column) in row.columns().iter().enumerate() {
|
||||
let v = row.try_get_raw(i)?;
|
||||
|
||||
let v = decode_sqlite_to_json(v)?;
|
||||
|
||||
value.insert(column.name().to_string(), v);
|
||||
}
|
||||
|
||||
values.push(value);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn decode_sqlite_to_json(v: SqliteValueRef) -> Result<JsonValue, Error> {
|
||||
// Copied from tauri_plugin_sql/src/decode/sqlite.rs
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// Licensed under MIT/Apache 2.0
|
||||
|
||||
// Same as tauri_plugin_sql::decode::sqlite::to_json but that function is not exposed
|
||||
|
||||
if v.is_null() {
|
||||
return Ok(JsonValue::Null);
|
||||
}
|
||||
|
||||
let res = match v.type_info().name() {
|
||||
"TEXT" => {
|
||||
if let Ok(v) = v.to_owned().try_decode() {
|
||||
JsonValue::String(v)
|
||||
} else {
|
||||
JsonValue::Null
|
||||
}
|
||||
}
|
||||
"REAL" => {
|
||||
if let Ok(v) = v.to_owned().try_decode::<f64>() {
|
||||
JsonValue::from(v)
|
||||
} else {
|
||||
JsonValue::Null
|
||||
}
|
||||
}
|
||||
"INTEGER" | "NUMERIC" => {
|
||||
if let Ok(v) = v.to_owned().try_decode::<i64>() {
|
||||
JsonValue::Number(v.into())
|
||||
} else {
|
||||
JsonValue::Null
|
||||
}
|
||||
}
|
||||
"BOOLEAN" => {
|
||||
if let Ok(v) = v.to_owned().try_decode() {
|
||||
JsonValue::Bool(v)
|
||||
} else {
|
||||
JsonValue::Null
|
||||
}
|
||||
}
|
||||
"DATE" => {
|
||||
if let Ok(v) = v.to_owned().try_decode::<Date>() {
|
||||
JsonValue::String(v.to_string())
|
||||
} else {
|
||||
JsonValue::Null
|
||||
}
|
||||
}
|
||||
"TIME" => {
|
||||
if let Ok(v) = v.to_owned().try_decode::<Time>() {
|
||||
JsonValue::String(v.to_string())
|
||||
} else {
|
||||
JsonValue::Null
|
||||
}
|
||||
}
|
||||
"DATETIME" => {
|
||||
if let Ok(v) = v.to_owned().try_decode::<PrimitiveDateTime>() {
|
||||
JsonValue::String(v.to_string())
|
||||
} else {
|
||||
JsonValue::Null
|
||||
}
|
||||
}
|
||||
"BLOB" => {
|
||||
if let Ok(v) = v.to_owned().try_decode::<Vec<u8>>() {
|
||||
JsonValue::Array(v.into_iter().map(|n| JsonValue::Number(n.into())).collect())
|
||||
} else {
|
||||
JsonValue::Null
|
||||
}
|
||||
}
|
||||
"NULL" => JsonValue::Null,
|
||||
_ => return Err(Error::UnsupportedDatatype(v.type_info().name().to_string())),
|
||||
};
|
||||
|
||||
Ok(res)
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "drcr",
|
||||
"version": "0.1.0",
|
||||
"identifier": "me.yingtongli.drcr",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "DrCr",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/icon.png"
|
||||
]
|
||||
}
|
||||
}
|
38
src/App.vue
38
src/App.vue
@ -1,38 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="min-h-full">
|
||||
<HeaderBar />
|
||||
<div class="py-8">
|
||||
<main>
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<NoFileView v-if="db.filename === null" />
|
||||
<RouterView v-if="db.filename !== null" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HeaderBar from './components/HeaderBar.vue';
|
||||
import NoFileView from './pages/NoFileView.vue';
|
||||
|
||||
import { db } from './db.js';
|
||||
</script>
|
@ -1,73 +0,0 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { db } from './db.ts';
|
||||
|
||||
export interface Amount {
|
||||
quantity: number;
|
||||
commodity: string;
|
||||
}
|
||||
|
||||
export class Balance {
|
||||
// A collection of Amount's
|
||||
amounts: Amount[] = [];
|
||||
|
||||
add(quantity: number, commodity: string) {
|
||||
const existingAmount = this.amounts.find((a) => a.commodity === commodity);
|
||||
if (existingAmount) {
|
||||
existingAmount.quantity += quantity;
|
||||
} else {
|
||||
this.amounts.push({ quantity: quantity, commodity: commodity });
|
||||
}
|
||||
}
|
||||
|
||||
clone(): Balance {
|
||||
const newBalance = new Balance();
|
||||
for (const amount of this.amounts) {
|
||||
newBalance.amounts.push({ quantity: amount.quantity, commodity: amount.commodity });
|
||||
}
|
||||
return newBalance;
|
||||
}
|
||||
|
||||
clean() {
|
||||
this.amounts = this.amounts.filter((a) => a.quantity !== 0);
|
||||
}
|
||||
}
|
||||
|
||||
export function asCost(quantity: number, commodity: string): number {
|
||||
// Convert the amount to cost price in the reporting commodity
|
||||
// NB: This function is rarely used - most conversions are performed in SQL via the transactions_with_quantity_ascost view
|
||||
|
||||
if (commodity === db.metadata.reporting_commodity) {
|
||||
return quantity;
|
||||
}
|
||||
if (commodity.indexOf('{{') >= 0) {
|
||||
// Total price
|
||||
const price = parseFloat(commodity.substring(commodity.indexOf('{{') + 2, commodity.indexOf('}}', commodity.indexOf('{{'))));
|
||||
|
||||
// Multiply by Math.sign(quantity) in case the quantity is negative
|
||||
// FIXME: This yields unexpected results when trying to deduct a partial amount from a commodity specified in total price terms
|
||||
return Math.round(Math.sign(quantity) * price * Math.pow(10, db.metadata.dps));
|
||||
}
|
||||
if (commodity.indexOf('{') >= 0) {
|
||||
// Unit price
|
||||
const price = parseFloat(commodity.substring(commodity.indexOf('{') + 1, commodity.indexOf('}', commodity.indexOf('{'))));
|
||||
return Math.round(quantity * price);
|
||||
}
|
||||
throw new Error('No cost base specified: ' + quantity + ' ' + commodity);
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[max-content_1fr] space-y-2 mb-4 items-baseline">
|
||||
<label for="dt" class="block text-gray-900 pr-4">Date</label>
|
||||
<div>
|
||||
<input type="date" class="bordered-field" id="dt" v-model="assertion.dt">
|
||||
</div>
|
||||
<label for="description" class="block text-gray-900 pr-4">Description</label>
|
||||
<div>
|
||||
<input type="text" class="bordered-field" id="description" v-model="assertion.description" placeholder=" ">
|
||||
</div>
|
||||
<label for="account" class="block text-gray-900 pr-4">Account</label>
|
||||
<ComboBoxAccounts v-model="assertion.account" />
|
||||
<label for="amount" class="block text-gray-900 pr-4">Balance</label>
|
||||
<div class="relative shadow-sm">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span class="text-gray-500">{{ db.metadata.reporting_commodity }}</span>
|
||||
</div>
|
||||
<!-- TODO: Display existing credit assertion as credit, not as negative debit -->
|
||||
<input type="number" class="bordered-field pl-7 pr-16" step="0.01" v-model="assertion.amount_abs" placeholder="0.00">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center">
|
||||
<select class="h-full border-0 bg-transparent py-0 pl-2 pr-8 text-gray-900 focus:ring-2 focus:ring-inset focus:ring-indigo-600" v-model="assertion.sign">
|
||||
<option value="dr">Dr</option>
|
||||
<option value="cr">Cr</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4 space-x-2">
|
||||
<button class="btn-secondary text-red-600 ring-red-500" @click="deleteAssertion" v-if="assertion.id !== null">Delete</button>
|
||||
<button class="btn-primary" @click="saveAssertion">Save</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
import { DT_FORMAT, db, deserialiseAmount } from '../db.ts';
|
||||
import ComboBoxAccounts from './ComboBoxAccounts.vue';
|
||||
|
||||
export interface EditingAssertion {
|
||||
id: number | null,
|
||||
dt: string,
|
||||
description: string,
|
||||
account: string,
|
||||
sign: string,
|
||||
amount_abs: string,
|
||||
}
|
||||
|
||||
const { assertion } = defineProps<{ assertion: EditingAssertion }>();
|
||||
|
||||
async function saveAssertion() {
|
||||
// Save changes to the assertion
|
||||
const amount_abs = deserialiseAmount('' + assertion.amount_abs);
|
||||
const quantity = assertion.sign === 'dr' ? amount_abs.quantity : -amount_abs.quantity;
|
||||
|
||||
const session = await db.load();
|
||||
|
||||
if (assertion.id === null) {
|
||||
await session.execute(
|
||||
`INSERT INTO balance_assertions (dt, description, account, quantity, commodity)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[dayjs(assertion.dt).format(DT_FORMAT), assertion.description, assertion.account, quantity, amount_abs.commodity]
|
||||
);
|
||||
} else {
|
||||
await session.execute(
|
||||
`UPDATE balance_assertions
|
||||
SET dt = $1, description = $2, account = $3, quantity = $4, commodity = $5
|
||||
WHERE id = $6`,
|
||||
[dayjs(assertion.dt).format(DT_FORMAT), assertion.description, assertion.account, quantity, amount_abs.commodity, assertion.id]
|
||||
);
|
||||
}
|
||||
|
||||
await getCurrentWindow().close();
|
||||
}
|
||||
|
||||
async function deleteAssertion() {
|
||||
// Delete the current assertion
|
||||
if (!await confirm('Are you sure you want to delete this balance assertion? This operation is irreversible.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await db.load();
|
||||
|
||||
await session.execute(
|
||||
`DELETE FROM balance_assertions
|
||||
WHERE id = $1`,
|
||||
[assertion.id]
|
||||
);
|
||||
|
||||
await getCurrentWindow().close();
|
||||
}
|
||||
</script>
|
@ -1,53 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- WebKit bug: Does not align baseline correctly unless some text or placeholder is present -->
|
||||
<input type="text" class="bordered-field peer" :class="inputClass" id="account" v-model="selectedValue" placeholder=" " autocomplete="off" ref="inputField">
|
||||
<button type="button" class="absolute inset-y-0 right-0 flex items-center px-2 focus:outline-none" @click="inputField!.focus()">
|
||||
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
<ul class="hidden peer-focus:block absolute z-20 mt-1 max-h-60 w-full overflow-auto bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" v-if="values.length > 0">
|
||||
<li
|
||||
v-for="value in values"
|
||||
v-show="value.toLowerCase().startsWith(selectedValue.toLowerCase())"
|
||||
class="group relative cursor-default select-none py-1 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-emerald-600 wk-aa"
|
||||
:data-selected="value === selectedValue ? 'selected': ''"
|
||||
@mousedown="selectedValue = value"
|
||||
>
|
||||
<span class="block truncate group-data-[selected=selected]:font-semibold">{{ value }}</span>
|
||||
<span class="hidden group-data-[selected=selected]:flex absolute inset-y-0 right-0 items-center pr-4 text-emerald-600 group-hover:text-white">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronUpDownIcon } from '@heroicons/vue/24/outline';
|
||||
import { defineModel, defineProps, useTemplateRef } from 'vue';
|
||||
|
||||
const { values, inputClass } = defineProps<{ values: string[], inputClass?: string }>();
|
||||
const inputField = useTemplateRef('inputField');
|
||||
|
||||
const selectedValue = defineModel({ default: '' });
|
||||
</script>
|
@ -1,46 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<ComboBox :values="accounts" :inputClass="inputClass" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps, ref } from 'vue';
|
||||
|
||||
import { db } from '../db.ts';
|
||||
import ComboBox from './ComboBox.vue';
|
||||
|
||||
const { inputClass } = defineProps<{ inputClass?: string }>();
|
||||
|
||||
const accounts = ref([] as string[]);
|
||||
|
||||
async function load() {
|
||||
// Load account names
|
||||
const session = await db.load();
|
||||
|
||||
const rawAccounts: {account: string}[] = await session.select(
|
||||
`SELECT DISTINCT account
|
||||
FROM postings
|
||||
ORDER BY account`
|
||||
);
|
||||
|
||||
accounts.value = rawAccounts.map((a) => a.account);
|
||||
}
|
||||
load();
|
||||
</script>
|
@ -1,63 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<template v-if="reports.length > 0">
|
||||
<h1 class="page-heading">
|
||||
{{ reports[0].title }}
|
||||
</h1>
|
||||
|
||||
<slot />
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th></th>
|
||||
<th v-for="label of labels" class="py-0.5 pl-1 text-gray-900 font-semibold text-end">{{ label }} </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ComparativeDynamicReportEntry :row="[row[0], row]" v-for="row of joinedEntries" />
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps } from 'vue';
|
||||
|
||||
import { DynamicReport } from '../reports/base.ts';
|
||||
import ComparativeDynamicReportEntry from './ComparativeDynamicReportEntry.vue';
|
||||
|
||||
const { reports, labels } = defineProps<{ reports: DynamicReport[], labels: string[] }>();
|
||||
|
||||
const joinedEntries = computed(() => {
|
||||
// FIXME: Validate reports are of the same type, etc.
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < reports[0].entries.length; i++) {
|
||||
const thisRow = [];
|
||||
for (let report of reports) {
|
||||
thisRow.push(report.entries[i]);
|
||||
}
|
||||
result.push(thisRow);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
</script>
|
@ -1,106 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<template v-if="row[0] instanceof Entry">
|
||||
<!-- NB: Subtotal and Calculated are subclasses of Entry -->
|
||||
<tr :class="row[0].bordered ? 'border-y border-gray-300' : null">
|
||||
<component :is="row[0].heading ? 'th' : 'td'" class="py-0.5 pr-1 text-gray-900 text-start" :class="{ 'font-semibold': row[0].heading }">
|
||||
<a :href="row[0].link" class="hover:text-blue-700 hover:underline" v-if="row[0].link !== null">{{ row[0].text }}</a>
|
||||
<template v-if="row[0].link === null">{{ row[0].text }}</template>
|
||||
</component>
|
||||
<template v-for="entry of row[1]">
|
||||
<component :is="row[0].heading ? 'th' : 'td'" class="py-0.5 pl-1 text-gray-900 text-end" :class="{ 'font-semibold': row[0].heading }" v-html="entry ? ppBracketed((entry as Entry).quantity, (entry as Entry).link ?? undefined) : ''" />
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-if="row[0] instanceof Section">
|
||||
<tr v-if="row[0].title !== null">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{{ row[0].title }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<ComparativeDynamicReportEntry :row="childRow" v-for="childRow of joinedChildren" />
|
||||
</template>
|
||||
<template v-if="row[0] instanceof Spacer">
|
||||
<tr><td :colspan="row[1].length + 1" class="py-0.5"> </td></tr>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps } from 'vue';
|
||||
|
||||
import { ppBracketed } from '../display.ts';
|
||||
import { DynamicReportNode, Entry, Section, Spacer, Subtotal } from '../reports/base.ts';
|
||||
|
||||
const { row } = defineProps<{ row: [DynamicReportNode, (DynamicReportNode | null)[]] }>();
|
||||
|
||||
const joinedChildren = computed(() => {
|
||||
// First get all children's names
|
||||
const joinedNames: string[] = [];
|
||||
for (let cell of row[1]) {
|
||||
for (let entry of (cell as any).entries) {
|
||||
if (entry instanceof Subtotal) { // Handle Subtotal separately
|
||||
continue;
|
||||
}
|
||||
if (!joinedNames.includes((entry as any).text)) {
|
||||
joinedNames.push((entry as any).text);
|
||||
}
|
||||
}
|
||||
}
|
||||
joinedNames.sort();
|
||||
|
||||
// Then return joined children in order of sorted names
|
||||
const result: [DynamicReportNode, (DynamicReportNode | null)[]][] = [];
|
||||
for (let name of joinedNames) {
|
||||
const thisRow: DynamicReportNode[] = [];
|
||||
let thisRowExample = null;
|
||||
for (let cell of row[1]) {
|
||||
let thisCell = null;
|
||||
for (let entry of (cell as any).entries) {
|
||||
if ((entry as any).text === name) {
|
||||
thisCell = entry;
|
||||
thisRowExample = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
thisRow.push(thisCell);
|
||||
}
|
||||
result.push([thisRowExample, thisRow]);
|
||||
}
|
||||
|
||||
// Add Subtotal
|
||||
const subtotalRow = [];
|
||||
let subtotalExample = null;
|
||||
for (let cell of row[1]) {
|
||||
let thisCell = null;
|
||||
for (let entry of (cell as any).entries) {
|
||||
if (entry instanceof Subtotal) {
|
||||
thisCell = entry;
|
||||
subtotalExample = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
subtotalRow.push(thisCell);
|
||||
}
|
||||
if (subtotalExample) {
|
||||
result.push([subtotalExample, subtotalRow]);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
</script>
|
@ -1,56 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<button type="button" class="relative w-full cursor-default bg-white bordered-field pl-3 pr-10 text-left" @click="isOpen = !isOpen">
|
||||
<span class="block truncate">{{ selectedValue[1] }}</span>
|
||||
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" />
|
||||
</span>
|
||||
</button>
|
||||
<ul class="absolute z-20 mt-1 max-h-60 w-full overflow-auto bg-white py-1 text-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" :class="isOpen ? 'block' : 'hidden'">
|
||||
<template v-for="([categoryName, categoryItems], index) in values">
|
||||
<li class="relative cursor-default select-none py-1 pl-3 pr-9 text-gray-500 border-b border-gray-300" :class="{ 'pt-4': index > 0 }" v-if="categoryName">
|
||||
<span class="block truncate text-xs font-bold uppercase">{{ categoryName }}</span>
|
||||
</li>
|
||||
<li v-for="item in categoryItems" class="group relative cursor-default select-none py-1 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-emerald-600" :data-selected="item[0] === selectedValue[0] ? 'selected' : null" @click="selectedValue = item; isOpen = false">
|
||||
<span class="block truncate group-data-[selected=selected]:font-semibold">{{ item[1] }}</span>
|
||||
<span class="hidden group-data-[selected=selected]:flex absolute inset-y-0 right-0 items-center pr-4 text-emerald-600 group-hover:text-white">
|
||||
<CheckIcon class="h-5 w-5" />
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
import { defineModel, defineProps, ref } from 'vue';
|
||||
|
||||
const { values } = defineProps<{ values: [string | null, [string, string][]][] }>(); // Array of [category name, [internal identifier, pretty name]]
|
||||
|
||||
const selectedValue = defineModel({ default: null! as [string, string] }); // Vue bug: Compiler produces broken code if setting default directly here
|
||||
if (selectedValue.value === null) {
|
||||
selectedValue.value = values[0][1][0];
|
||||
}
|
||||
|
||||
const isOpen = ref(false);
|
||||
</script>
|
@ -1,49 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<template v-if="report !== null">
|
||||
<h1 class="page-heading">
|
||||
{{ report.title }}
|
||||
</h1>
|
||||
|
||||
<slot />
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th></th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">{{ db.metadata.reporting_commodity }} </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<DynamicReportEntry :entry="entry" v-for="entry of report.entries" />
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
import { db } from '../db.ts';
|
||||
import { DynamicReport } from '../reports/base.ts';
|
||||
import DynamicReportEntry from './DynamicReportEntry.vue';
|
||||
|
||||
const { report } = defineProps<{ report: DynamicReport | null }>();
|
||||
</script>
|
@ -1,49 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<template v-if="entry instanceof Entry">
|
||||
<!-- NB: Subtotal and Calculated are subclasses of Entry -->
|
||||
<tr :class="entry.bordered ? 'border-y border-gray-300' : null">
|
||||
<component :is="entry.heading ? 'th' : 'td'" class="py-0.5 pr-1 text-gray-900 text-start" :class="{ 'font-semibold': entry.heading }">
|
||||
<a :href="entry.link" class="hover:text-blue-700 hover:underline" v-if="entry.link !== null">{{ entry.text }}</a>
|
||||
<template v-if="entry.link === null">{{ entry.text }}</template>
|
||||
</component>
|
||||
<component :is="entry.heading ? 'th' : 'td'" class="py-0.5 pl-1 text-gray-900 text-end" :class="{ 'font-semibold': entry.heading }" v-html="ppBracketed(entry.quantity, entry.link ?? undefined)" />
|
||||
</tr>
|
||||
</template>
|
||||
<template v-if="entry instanceof Section">
|
||||
<tr v-if="entry.title !== null">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{{ entry.title }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<DynamicReportEntry :entry="child" v-for="child of entry.entries" />
|
||||
</template>
|
||||
<template v-if="entry instanceof Spacer">
|
||||
<tr><td colspan="2" class="py-0.5"> </td></tr>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
import { ppBracketed } from '../display.ts';
|
||||
import { DynamicReportNode, Entry, Section, Spacer } from '../reports/base.ts';
|
||||
|
||||
const { entry } = defineProps<{ entry: DynamicReportNode }>();
|
||||
</script>
|
@ -1,74 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<nav class="border-b border-gray-200 bg-white print:hidden" v-if="isMainWindow">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div class="flex h-12 justify-between ml-[-0.25rem] w-full"><!-- Adjust margin by -0.25rem to align navbar text with body text -->
|
||||
<div class="flex w-full">
|
||||
<div class="flex flex-shrink-0">
|
||||
<RouterLink to="/" class="border-transparent text-gray-900 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium">
|
||||
DrCr
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Menu items -->
|
||||
<div v-if="db.filename !== null" class="hidden sm:-my-px sm:ml-6 sm:flex sm:gap-4 w-full">
|
||||
<RouterLink :to="{ name: 'journal' }" class="border-transparent text-gray-700 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm">
|
||||
Journal
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'statement-lines' }" class="border-transparent text-gray-700 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm">
|
||||
Statement lines
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'trial-balance'}" class="border-transparent text-gray-700 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm">
|
||||
Trial balance
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'balance-sheet'}" class="border-transparent text-gray-700 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm">
|
||||
Balance sheet
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'income-statement'}" class="border-transparent text-gray-700 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm">
|
||||
Income statement
|
||||
</RouterLink>
|
||||
|
||||
<a href="#" @click="closeFile" class="ml-auto border-transparent text-gray-700 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm">
|
||||
Close file
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { db } from '../db.js';
|
||||
|
||||
// Only display header bar in main window
|
||||
const isMainWindow = getCurrentWindow().label === 'main';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function closeFile() {
|
||||
await db.init(null);
|
||||
router.push({ name: 'index' });
|
||||
}
|
||||
</script>
|
@ -1,315 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th class="pt-0.5 pb-1 pr-1 text-gray-900 font-semibold text-start">Date</th>
|
||||
<th class="pt-0.5 pb-1 px-1 text-gray-900 font-semibold text-start" colspan="2">Description</th>
|
||||
<th class="pt-0.5 pb-1 px-1 text-gray-900 font-semibold text-start">Dr</th>
|
||||
<th class="pt-0.5 pb-1 pl-1 text-gray-900 font-semibold text-start">Cr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="pt-2 pb-1 pr-1">
|
||||
<input type="date" class="bordered-field" v-model="transaction.dt">
|
||||
</td>
|
||||
<td class="pt-2 pb-1 px-1" colspan="2">
|
||||
<input type="text" class="bordered-field" v-model="transaction.description">
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr v-for="posting in transaction.postings">
|
||||
<td></td>
|
||||
<td class="py-1 px-1">{{ posting.description }}</td>
|
||||
<td class="py-1 px-1">
|
||||
<div class="relative flex">
|
||||
<div class="relative flex flex-grow items-stretch shadow-sm">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center z-10">
|
||||
<select class="h-full border-0 bg-transparent py-0 pl-2 pr-8 text-gray-900 focus:ring-2 focus:ring-inset focus:ring-emerald-600" v-model="posting.sign">
|
||||
<option value="dr">Dr</option>
|
||||
<option value="cr">Cr</option>
|
||||
</select>
|
||||
</div>
|
||||
<ComboBoxAccounts v-model="posting.account" class="w-full" inputClass="pl-16" />
|
||||
</div>
|
||||
<button class="relative -ml-px px-2 py-2 text-gray-500 hover:text-gray-700" @click="addPosting(posting)">
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<template v-if="posting.sign == 'dr'">
|
||||
<td class="amount-dr has-amount py-1 px-1">
|
||||
<div class="relative shadow-sm">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span class="text-gray-500">{{ db.metadata.reporting_commodity }}</span>
|
||||
</div>
|
||||
<input type="text" class="bordered-field pl-7" v-model="posting.amount_abs">
|
||||
</div>
|
||||
</td>
|
||||
<td class="amount-cr py-1 pl-1"></td>
|
||||
</template>
|
||||
<template v-if="posting.sign == 'cr'">
|
||||
<td class="amount-dr py-1 px-1"></td>
|
||||
<td class="amount-cr has-amount py-1 pl-1">
|
||||
<div class="relative shadow-sm">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span class="text-gray-500">{{ db.metadata.reporting_commodity }}</span>
|
||||
</div>
|
||||
<input type="text" class="bordered-field pl-7" v-model="posting.amount_abs">
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex justify-end mt-4 space-x-2">
|
||||
<button class="btn-secondary text-red-600 ring-red-500" @click="deleteTransaction" v-if="transaction.id !== null">Delete</button>
|
||||
<button class="btn-primary" @click="saveTransaction">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-red-50 mt-4 p-4 col-span-2" v-if="error !== null">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm text-red-700">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { PlusIcon, XCircleIcon } from '@heroicons/vue/24/solid';
|
||||
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { DT_FORMAT, Posting, Transaction, db, deserialiseAmount } from '../db.ts';
|
||||
import ComboBoxAccounts from './ComboBoxAccounts.vue';
|
||||
|
||||
interface EditingPosting {
|
||||
id: number | null,
|
||||
description: string | null,
|
||||
account: string,
|
||||
originalAccount: string | null,
|
||||
sign: string, // Keep track of Dr/Cr status so this can be independently changed in the UI
|
||||
amount_abs: string,
|
||||
}
|
||||
export interface EditingTransaction {
|
||||
id: number | null,
|
||||
dt: string,
|
||||
description: string,
|
||||
postings: EditingPosting[]
|
||||
}
|
||||
|
||||
const { transaction } = defineProps<{ transaction: EditingTransaction }>();
|
||||
|
||||
const error = ref(null as string | null);
|
||||
|
||||
function addPosting(posting: EditingPosting) {
|
||||
const index = transaction.postings.indexOf(posting);
|
||||
transaction.postings.splice(index + 1, 0, {
|
||||
id: null,
|
||||
description: null,
|
||||
account: '',
|
||||
originalAccount: null,
|
||||
sign: posting.sign, // Create the new posting with the same sign as the entry clicked on
|
||||
amount_abs: ''
|
||||
});
|
||||
}
|
||||
|
||||
async function saveTransaction() {
|
||||
error.value = null;
|
||||
|
||||
// Prepare transaction for save
|
||||
const newTransaction = new Transaction(
|
||||
transaction.id,
|
||||
dayjs(transaction.dt).format(DT_FORMAT),
|
||||
transaction.description,
|
||||
[]
|
||||
);
|
||||
|
||||
for (const posting of transaction.postings) {
|
||||
const amount_abs = deserialiseAmount(posting.amount_abs);
|
||||
|
||||
newTransaction.postings.push({
|
||||
id: posting.id,
|
||||
description: posting.description,
|
||||
account: posting.account,
|
||||
originalAccount: posting.originalAccount,
|
||||
quantity: posting.sign === 'dr' ? amount_abs.quantity : -amount_abs.quantity,
|
||||
commodity: amount_abs.commodity
|
||||
} as Posting);
|
||||
}
|
||||
|
||||
// Validate transaction
|
||||
if (!newTransaction.doesBalance()) {
|
||||
error.value = 'Debits and credits do not balance.';
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await db.load();
|
||||
|
||||
// Validate statement line reconciliations
|
||||
// Keep track of mapping, so we can fix up the reconciliation posting_id if renumbering occurs
|
||||
const postingsToReconciliations = new Map();
|
||||
|
||||
if (newTransaction.id !== null) {
|
||||
// Get statement line reconciliations affected by this transaction
|
||||
const joinedReconciliations: any[] = await session.select(
|
||||
`SELECT statement_line_reconciliations.id, postings.id AS posting_id, source_account, statement_lines.quantity, statement_lines.commodity
|
||||
FROM statement_line_reconciliations
|
||||
JOIN postings ON statement_line_reconciliations.posting_id = postings.id
|
||||
JOIN statement_lines ON statement_line_reconciliations.statement_line_id = statement_lines.id
|
||||
WHERE postings.transaction_id = $1`,
|
||||
[newTransaction.id]
|
||||
);
|
||||
|
||||
for (const joinedReconciliation of joinedReconciliations) {
|
||||
for (const posting of newTransaction.postings) {
|
||||
if (posting.id === joinedReconciliation.posting_id) {
|
||||
if (posting.account !== joinedReconciliation.source_account || posting.quantity !== joinedReconciliation.quantity || posting.commodity !== joinedReconciliation.commodity) {
|
||||
error.value = 'Edit would break reconciled statement line.';
|
||||
return;
|
||||
}
|
||||
postingsToReconciliations.set(posting, joinedReconciliation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save changes to database atomically
|
||||
const dbTransaction = await session.begin();
|
||||
|
||||
if (newTransaction.id === null) {
|
||||
// Insert new transaction
|
||||
const result = await dbTransaction.execute(
|
||||
`INSERT INTO transactions (dt, description)
|
||||
VALUES ($1, $2)`,
|
||||
[newTransaction.dt, newTransaction.description]
|
||||
);
|
||||
newTransaction.id = result.lastInsertId;
|
||||
} else {
|
||||
// Update existing transaction
|
||||
await dbTransaction.execute(
|
||||
`UPDATE transactions
|
||||
SET dt = $1, description = $2
|
||||
WHERE id = $3`,
|
||||
[newTransaction.dt, newTransaction.description, newTransaction.id]
|
||||
);
|
||||
}
|
||||
|
||||
let insertPostings = false;
|
||||
|
||||
for (const posting of newTransaction.postings) {
|
||||
if (posting.id === null) {
|
||||
// When we encounter a new posting, delete and re-insert all subsequent postings to preserve the order
|
||||
insertPostings = true;
|
||||
}
|
||||
|
||||
if (insertPostings) {
|
||||
// Delete existing posting if required
|
||||
if (posting.id !== null) {
|
||||
await dbTransaction.execute(
|
||||
`DELETE FROM postings
|
||||
WHERE id = $1`,
|
||||
[posting.id]
|
||||
);
|
||||
}
|
||||
|
||||
// Insert new posting
|
||||
const result = await dbTransaction.execute(
|
||||
`INSERT INTO postings (transaction_id, description, account, quantity, commodity)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[newTransaction.id, posting.description, posting.account, posting.quantity, posting.commodity]
|
||||
);
|
||||
|
||||
// Fixup reconciliation if required
|
||||
const joinedReconciliation = postingsToReconciliations.get(posting);
|
||||
if (joinedReconciliation) {
|
||||
await dbTransaction.execute(
|
||||
`UPDATE statement_line_reconciliations
|
||||
SET posting_id = $1
|
||||
WHERE id = $2`,
|
||||
[result.lastInsertId, joinedReconciliation.id]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Update existing posting
|
||||
await dbTransaction.execute(
|
||||
`UPDATE postings
|
||||
SET description = $1, account = $2, quantity = $3, commodity = $4
|
||||
WHERE id = $5`,
|
||||
[posting.description, posting.account, posting.quantity, posting.commodity, posting.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await dbTransaction.commit();
|
||||
|
||||
await emit('transaction-updated', {id: newTransaction.id});
|
||||
await getCurrentWindow().close();
|
||||
}
|
||||
|
||||
async function deleteTransaction() {
|
||||
if (!await confirm('Are you sure you want to delete this transaction? This operation is irreversible.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete atomically
|
||||
const session = await db.load();
|
||||
const dbTransaction = await session.begin();
|
||||
|
||||
// Cascade delete statement line reconciliations
|
||||
await dbTransaction.execute(
|
||||
`DELETE FROM statement_line_reconciliations
|
||||
WHERE posting_id IN (
|
||||
SELECT postings.id FROM postings WHERE transaction_id = $1
|
||||
)`,
|
||||
[transaction.id]
|
||||
);
|
||||
|
||||
// Delete postings
|
||||
await dbTransaction.execute(
|
||||
`DELETE FROM postings
|
||||
WHERE transaction_id = $1`,
|
||||
[transaction.id]
|
||||
);
|
||||
|
||||
// Delete transaction
|
||||
await dbTransaction.execute(
|
||||
`DELETE FROM transactions
|
||||
WHERE id = $1`,
|
||||
[transaction.id]
|
||||
);
|
||||
|
||||
await dbTransaction.commit();
|
||||
|
||||
await getCurrentWindow().close();
|
||||
}
|
||||
</script>
|
262
src/db.ts
262
src/db.ts
@ -1,262 +0,0 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import Database from '@tauri-apps/plugin-sql';
|
||||
|
||||
import { reactive } from 'vue';
|
||||
|
||||
import { Balance } from './amounts.ts';
|
||||
import { ExtendedDatabase } from './dbutil.ts';
|
||||
|
||||
export const DT_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSS000';
|
||||
|
||||
export const db = reactive({
|
||||
filename: null as (string | null),
|
||||
|
||||
// Cached
|
||||
metadata: {
|
||||
version: null! as number,
|
||||
eofy_date: null! as string,
|
||||
reporting_commodity: null! as string,
|
||||
dps: null! as number,
|
||||
},
|
||||
|
||||
init: async function(filename: string | null): Promise<void> {
|
||||
// Set the DB filename and initialise cached data
|
||||
this.filename = filename;
|
||||
|
||||
await invoke('set_open_filename', { 'filename': filename });
|
||||
|
||||
if (filename !== null) {
|
||||
await getCurrentWindow().setTitle('DrCr – ' + filename?.replaceAll('\\', '/').split('/').at(-1));
|
||||
} else {
|
||||
await getCurrentWindow().setTitle('DrCr');
|
||||
}
|
||||
|
||||
if (filename !== null) {
|
||||
// Initialise cached data
|
||||
const session = await this.load();
|
||||
const metadataRaw: {key: string, value: string}[] = await session.select("SELECT * FROM metadata");
|
||||
const metadataObject = Object.fromEntries(metadataRaw.map((x) => [x.key, x.value]));
|
||||
this.metadata.version = parseInt(metadataObject.version);
|
||||
this.metadata.eofy_date = metadataObject.eofy_date;
|
||||
this.metadata.reporting_commodity = metadataObject.reporting_commodity;
|
||||
this.metadata.dps = parseInt(metadataObject.amount_dps);
|
||||
}
|
||||
},
|
||||
|
||||
load: async function(): Promise<ExtendedDatabase> {
|
||||
return new ExtendedDatabase(await Database.load('sqlite:' + this.filename));
|
||||
},
|
||||
});
|
||||
|
||||
export async function totalBalances(session: ExtendedDatabase): Promise<Map<string, number>> {
|
||||
const resultsRaw: {account: string, quantity: number}[] = await session.select(
|
||||
`-- Get last transaction for each account
|
||||
WITH max_dt_by_account AS (
|
||||
SELECT account, max(dt) AS max_dt
|
||||
FROM joined_transactions
|
||||
GROUP BY account
|
||||
),
|
||||
max_tid_by_account AS (
|
||||
SELECT max_dt_by_account.account, max(transaction_id) AS max_tid
|
||||
FROM max_dt_by_account
|
||||
JOIN joined_transactions ON max_dt_by_account.account = joined_transactions.account AND max_dt_by_account.max_dt = joined_transactions.dt
|
||||
GROUP BY max_dt_by_account.account
|
||||
)
|
||||
-- Get running balance at last transaction for each account
|
||||
SELECT max_tid_by_account.account, running_balance AS quantity
|
||||
FROM max_tid_by_account
|
||||
JOIN transactions_with_running_balances ON max_tid = transactions_with_running_balances.transaction_id AND max_tid_by_account.account = transactions_with_running_balances.account`
|
||||
);
|
||||
|
||||
return new Map(resultsRaw.map((x) => [x.account, x.quantity]));
|
||||
}
|
||||
|
||||
export async function totalBalancesAtDate(session: ExtendedDatabase, dt: string): Promise<Map<string, number>> {
|
||||
const resultsRaw: {account: string, quantity: number}[] = await session.select(
|
||||
`-- Get last transaction for each account
|
||||
WITH max_dt_by_account AS (
|
||||
SELECT account, max(dt) AS max_dt
|
||||
FROM joined_transactions
|
||||
WHERE DATE(dt) <= DATE($1)
|
||||
GROUP BY account
|
||||
),
|
||||
max_tid_by_account AS (
|
||||
SELECT max_dt_by_account.account, max(transaction_id) AS max_tid
|
||||
FROM max_dt_by_account
|
||||
JOIN joined_transactions ON max_dt_by_account.account = joined_transactions.account AND max_dt_by_account.max_dt = joined_transactions.dt
|
||||
GROUP BY max_dt_by_account.account
|
||||
)
|
||||
-- Get running balance at last transaction for each account
|
||||
SELECT max_tid_by_account.account, running_balance AS quantity
|
||||
FROM max_tid_by_account
|
||||
JOIN transactions_with_running_balances ON max_tid = transactions_with_running_balances.transaction_id AND max_tid_by_account.account = transactions_with_running_balances.account`,
|
||||
[dt]
|
||||
);
|
||||
|
||||
return new Map(resultsRaw.map((x) => [x.account, x.quantity]));
|
||||
}
|
||||
|
||||
export function joinedToTransactions(joinedTransactionPostings: JoinedTransactionPosting[]): Transaction[] {
|
||||
// Group postings into transactions
|
||||
const transactions: Transaction[] = [];
|
||||
|
||||
for (const joinedTransactionPosting of joinedTransactionPostings) {
|
||||
if (transactions.length === 0 || transactions.at(-1)!.id !== joinedTransactionPosting.transaction_id) {
|
||||
transactions.push(new Transaction(
|
||||
joinedTransactionPosting.transaction_id,
|
||||
joinedTransactionPosting.dt,
|
||||
joinedTransactionPosting.transaction_description,
|
||||
[]
|
||||
));
|
||||
}
|
||||
|
||||
transactions.at(-1)!.postings.push({
|
||||
id: joinedTransactionPosting.id,
|
||||
description: joinedTransactionPosting.description,
|
||||
account: joinedTransactionPosting.account,
|
||||
quantity: joinedTransactionPosting.quantity,
|
||||
commodity: joinedTransactionPosting.commodity,
|
||||
quantity_ascost: joinedTransactionPosting.quantity_ascost,
|
||||
running_balance: joinedTransactionPosting.running_balance
|
||||
});
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
export async function getAccountsForKind(session: ExtendedDatabase, kind: string): Promise<string[]> {
|
||||
const rawAccountsForKind: {account: string}[] = await session.select(
|
||||
`SELECT account
|
||||
FROM account_configurations
|
||||
WHERE kind = $1
|
||||
ORDER BY account`,
|
||||
[kind]
|
||||
);
|
||||
const accountsForKind = rawAccountsForKind.map((a) => a.account);
|
||||
return accountsForKind;
|
||||
}
|
||||
|
||||
export function serialiseAmount(quantity: number, commodity: string): string {
|
||||
// Pretty print the amount for an editable input
|
||||
if (quantity < 0) {
|
||||
return '-' + serialiseAmount(-quantity, commodity);
|
||||
}
|
||||
|
||||
// Scale quantity by decimal places
|
||||
const factor = Math.pow(10, db.metadata.dps);
|
||||
const wholePart = Math.floor(quantity / factor);
|
||||
const fracPart = quantity % factor;
|
||||
const quantityString = wholePart.toString() + '.' + fracPart.toString().padStart(db.metadata.dps, '0');
|
||||
|
||||
if (commodity === db.metadata.reporting_commodity) {
|
||||
return quantityString;
|
||||
}
|
||||
|
||||
if (commodity.length === 1) {
|
||||
return commodity + quantityString;
|
||||
}
|
||||
|
||||
return quantityString + ' ' + commodity;
|
||||
}
|
||||
|
||||
export function deserialiseAmount(amount: string): { quantity: number, commodity: string } {
|
||||
const factor = Math.pow(10, db.metadata.dps);
|
||||
|
||||
if (amount.indexOf(' ') < 0) {
|
||||
// Default commodity
|
||||
const quantity = Math.round(parseFloat(amount) * factor)
|
||||
|
||||
if (!Number.isSafeInteger(quantity)) { throw new Error('Quantity not representable by safe integer'); }
|
||||
|
||||
return {
|
||||
'quantity': quantity,
|
||||
commodity: db.metadata.reporting_commodity
|
||||
};
|
||||
}
|
||||
|
||||
// FIXME: Parse single letter commodities
|
||||
|
||||
const quantityStr = amount.substring(0, amount.indexOf(' '));
|
||||
const quantity = Math.round(parseFloat(quantityStr) * factor)
|
||||
|
||||
if (!Number.isSafeInteger(quantity)) { throw new Error('Quantity not representable by safe integer'); }
|
||||
|
||||
const commodity = amount.substring(amount.indexOf(' ') + 1);
|
||||
|
||||
return {
|
||||
'quantity': quantity,
|
||||
'commodity': commodity
|
||||
};
|
||||
}
|
||||
|
||||
// Type definitions
|
||||
|
||||
export class Transaction {
|
||||
constructor(
|
||||
public id: number | null = null,
|
||||
public dt: string = '',
|
||||
public description: string = '',
|
||||
public postings: Posting[] = [],
|
||||
) {}
|
||||
|
||||
doesBalance(): boolean {
|
||||
const balance = new Balance();
|
||||
for (const posting of this.postings) {
|
||||
balance.add(posting.quantity, posting.commodity);
|
||||
}
|
||||
balance.clean();
|
||||
return balance.amounts.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Posting {
|
||||
id: number | null,
|
||||
description: string | null,
|
||||
account: string,
|
||||
quantity: number,
|
||||
commodity: string,
|
||||
quantity_ascost?: number,
|
||||
running_balance?: number
|
||||
}
|
||||
|
||||
export interface JoinedTransactionPosting {
|
||||
transaction_id: number,
|
||||
dt: string,
|
||||
transaction_description: string,
|
||||
id: number,
|
||||
description: string,
|
||||
account: string,
|
||||
quantity: number,
|
||||
commodity: string,
|
||||
quantity_ascost?: number,
|
||||
running_balance?: number
|
||||
}
|
||||
|
||||
export interface StatementLine {
|
||||
id: number | null,
|
||||
source_account: string,
|
||||
dt: string,
|
||||
description: string,
|
||||
quantity: number,
|
||||
balance: number | null,
|
||||
commodity: string
|
||||
}
|
@ -1,98 +0,0 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import Database, { QueryResult } from '@tauri-apps/plugin-sql';
|
||||
|
||||
export class ExtendedDatabase {
|
||||
db: Database;
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async execute(query: string, bindValues?: unknown[]): Promise<QueryResult> {
|
||||
return await this.db.execute(query, bindValues);
|
||||
}
|
||||
|
||||
async select<T>(query: string, bindValues?: unknown[]): Promise<T> {
|
||||
return await this.db.select(query, bindValues);
|
||||
}
|
||||
|
||||
async begin(): Promise<DatabaseTransaction> {
|
||||
const transactionInstanceId: number = await invoke('sql_transaction_begin', {
|
||||
db: this.db.path
|
||||
});
|
||||
const db_transaction = new DatabaseTransaction(this, transactionInstanceId);
|
||||
registry.register(db_transaction, transactionInstanceId, db_transaction); // Remember to rollback and close connection on finalization
|
||||
return db_transaction;
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseTransaction {
|
||||
db: ExtendedDatabase;
|
||||
transactionInstanceId: number;
|
||||
|
||||
constructor(db: ExtendedDatabase, transactionInstanceId: number) {
|
||||
this.db = db;
|
||||
this.transactionInstanceId = transactionInstanceId;
|
||||
}
|
||||
|
||||
async execute(query: string, bindValues?: unknown[]): Promise<QueryResult> {
|
||||
const [rowsAffected, lastInsertId] = await invoke('sql_transaction_execute', {
|
||||
transactionInstanceId: this.transactionInstanceId,
|
||||
query,
|
||||
values: bindValues ?? []
|
||||
}) as [number, number];
|
||||
|
||||
return {
|
||||
lastInsertId: lastInsertId,
|
||||
rowsAffected: rowsAffected
|
||||
};
|
||||
}
|
||||
|
||||
async select<T>(query: string, bindValues?: unknown[]): Promise<T> {
|
||||
const result: T = await invoke('sql_transaction_select', {
|
||||
transactionInstanceId: this.transactionInstanceId,
|
||||
query,
|
||||
values: bindValues ?? []
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async rollback(): Promise<void> {
|
||||
registry.unregister(this);
|
||||
await invoke('sql_transaction_rollback', {
|
||||
transactionInstanceId: this.transactionInstanceId
|
||||
});
|
||||
}
|
||||
|
||||
async commit(): Promise<void> {
|
||||
registry.unregister(this);
|
||||
await invoke('sql_transaction_commit', {
|
||||
transactionInstanceId: this.transactionInstanceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new FinalizationRegistry(async (transactionInstanceId) => {
|
||||
// Remember to rollback and close connection on finalization
|
||||
await invoke('sql_transaction_rollback', {
|
||||
transactionInstanceId: transactionInstanceId
|
||||
});
|
||||
});
|
@ -1,60 +0,0 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { db } from './db.ts';
|
||||
|
||||
export function pp(quantity: number): string {
|
||||
// Pretty print the quantity
|
||||
if (quantity < 0) {
|
||||
return '−' + pp(-quantity);
|
||||
}
|
||||
|
||||
const factor = Math.pow(10, db.metadata.dps);
|
||||
const wholePart = Math.floor(quantity / factor);
|
||||
const fracPart = quantity % factor;
|
||||
|
||||
return wholePart.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '\u202F') + '.' + fracPart.toString().padStart(db.metadata.dps, '0');
|
||||
}
|
||||
|
||||
export function ppWithCommodity(quantity: number, commodity: string): string {
|
||||
// Pretty print the amount including commodity
|
||||
if (commodity.length === 1) {
|
||||
return commodity + pp(quantity);
|
||||
} else {
|
||||
return pp(quantity) + ' ' + commodity;
|
||||
}
|
||||
}
|
||||
|
||||
export function ppBracketed(quantity: number, link?: string): string {
|
||||
// Pretty print the quantity with brackets for negative numbers
|
||||
let text, space;
|
||||
if (quantity >= 0) {
|
||||
text = pp(quantity);
|
||||
space = ' ';
|
||||
} else {
|
||||
text = '(' + pp(-quantity) + ')';
|
||||
space = '';
|
||||
}
|
||||
|
||||
if (link) {
|
||||
// Put the space outside of the hyperlink so it is not underlined
|
||||
return '<a href="' + encodeURI(link) + '" class="hover:text-blue-700 hover:underline">' + text + '</a>' + space;
|
||||
} else {
|
||||
return text + space;
|
||||
}
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { parse } from 'csv-parse/browser/esm/sync';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { DT_FORMAT, StatementLine, db } from '../db.ts';
|
||||
|
||||
export default function importCsv(sourceAccount: string, content: string): StatementLine[] {
|
||||
const records = parse(content, {
|
||||
skip_empty_lines: true,
|
||||
});
|
||||
|
||||
// Validate column layout
|
||||
if (records.length === 0) {
|
||||
throw new Error('Empty CSV file');
|
||||
}
|
||||
if (records[0][0] !== 'Date') {
|
||||
throw new Error('Unexpected column 1, expected "Date"');
|
||||
}
|
||||
if (records[0][1] !== 'Description') {
|
||||
throw new Error('Unexpected column 1, expected "Description"');
|
||||
}
|
||||
if (records[0][2] !== 'Amount') {
|
||||
throw new Error('Unexpected column 1, expected "Amount"');
|
||||
}
|
||||
|
||||
const statementLines: StatementLine[] = [];
|
||||
|
||||
// Parse records
|
||||
for (let i = 1; i < records.length; i++) {
|
||||
const record = records[i];
|
||||
|
||||
const date = dayjs(record[0], 'YYYY-MM-DD').format(DT_FORMAT);
|
||||
const description = record[1];
|
||||
const amount = record[2];
|
||||
|
||||
const quantity = Math.round(parseFloat(amount) * Math.pow(10, db.metadata.dps));
|
||||
if (!Number.isSafeInteger(quantity)) { throw new Error('Quantity not representable by safe integer'); }
|
||||
|
||||
statementLines.push({
|
||||
id: null,
|
||||
source_account: sourceAccount,
|
||||
dt: date,
|
||||
description: description,
|
||||
quantity: quantity,
|
||||
balance: null,
|
||||
commodity: db.metadata.reporting_commodity,
|
||||
});
|
||||
}
|
||||
|
||||
return statementLines;
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import importOfx1 from './ofx1.ts';
|
||||
import importOfx2 from './ofx2.ts';
|
||||
import { StatementLine } from '../db.ts';
|
||||
|
||||
export default function importOfxAutodetectVersion(sourceAccount: string, content: string): StatementLine[] {
|
||||
if (content.startsWith('<?')) {
|
||||
// XML-style: OFX2
|
||||
return importOfx2(sourceAccount, content);
|
||||
} else {
|
||||
// Assume SGML style: OFX1
|
||||
return importOfx1(sourceAccount, content);
|
||||
}
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { DT_FORMAT, StatementLine, db } from '../db.ts';
|
||||
|
||||
export default function importOfx1(sourceAccount: string, content: string): StatementLine[] {
|
||||
// Import an OFX1 SGML file
|
||||
|
||||
// Strip OFX header and parse
|
||||
const raw_payload = content.substring(content.indexOf('<OFX')).replaceAll('&', '&');
|
||||
const tree = new DOMParser().parseFromString(raw_payload, 'text/html'); // HTML was originally based on SGML so use this parser
|
||||
|
||||
// Read transactions
|
||||
const statementLines: StatementLine[] = [];
|
||||
|
||||
for (const transaction of tree.querySelectorAll('banktranlist stmttrn')) {
|
||||
let dateRaw = getNodeText(transaction.querySelector('dtposted'));
|
||||
if (dateRaw && dateRaw.indexOf('[') >= 0) {
|
||||
// Ignore time zone
|
||||
dateRaw = dateRaw?.substring(0, dateRaw.indexOf('['));
|
||||
}
|
||||
const date = dayjs(dateRaw, 'YYYYMMDDHHmmss.SSS').hour(0).minute(0).second(0).millisecond(0).format(DT_FORMAT);
|
||||
|
||||
const description = getNodeText(transaction.querySelector('memo'));
|
||||
const amount = getNodeText(transaction.querySelector('trnamt'));
|
||||
|
||||
const quantity = Math.round(parseFloat(amount!) * Math.pow(10, db.metadata.dps));
|
||||
if (!Number.isSafeInteger(quantity)) { throw new Error('Quantity not representable by safe integer'); }
|
||||
|
||||
if (description.indexOf('PENDING') >= 0) {
|
||||
// FIXME: This needs to be configurable
|
||||
continue;
|
||||
}
|
||||
|
||||
statementLines.push({
|
||||
id: null,
|
||||
source_account: sourceAccount,
|
||||
dt: date,
|
||||
description: description ?? '',
|
||||
quantity: quantity,
|
||||
balance: null,
|
||||
commodity: db.metadata.reporting_commodity
|
||||
});
|
||||
}
|
||||
|
||||
return statementLines;
|
||||
}
|
||||
|
||||
function getNodeText(node: Node | null): string {
|
||||
// Get text of the first text node
|
||||
// HTML parser does not understand SGML/OFX nesting rules, so siblings will be incorrectly considered as children
|
||||
// Therefore we use only the first text node
|
||||
|
||||
if (node === null) {
|
||||
throw new Error('Node not found');
|
||||
}
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE && child.nodeValue !== null && child.nodeValue.trim().length > 0) {
|
||||
return child.nodeValue.trim();
|
||||
}
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No text in node');
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { DT_FORMAT, StatementLine, db } from '../db.ts';
|
||||
|
||||
export default function importOfx2(sourceAccount: string, content: string): StatementLine[] {
|
||||
// Import an OFX2 XML file
|
||||
|
||||
// Convert OFX header to XML and parse
|
||||
const xml_header = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>';
|
||||
const raw_payload = content.substring(content.indexOf('?>') + 2).replaceAll('&', '&');
|
||||
const tree = new DOMParser().parseFromString(xml_header + raw_payload, 'application/xml');
|
||||
|
||||
// Read transactions
|
||||
const statementLines: StatementLine[] = [];
|
||||
|
||||
for (const transaction of tree.querySelectorAll('BANKMSGSRSV1 STMTTRNRS STMTRS BANKTRANLIST STMTTRN')) {
|
||||
let dateRaw = transaction.querySelector('DTPOSTED')!.textContent;
|
||||
if (dateRaw && dateRaw.indexOf('[') >= 0) {
|
||||
// Ignore time zone
|
||||
dateRaw = dateRaw?.substring(0, dateRaw.indexOf('['));
|
||||
}
|
||||
const date = dayjs(dateRaw, 'YYYYMMDDHHmmss').hour(0).minute(0).second(0).millisecond(0).format(DT_FORMAT);
|
||||
const description = transaction.querySelector('NAME')!.textContent;
|
||||
const amount = transaction.querySelector('TRNAMT')!.textContent;
|
||||
|
||||
if (amount === '0') {
|
||||
// Continuation line
|
||||
statementLines.at(-1)!.description += '\n' + description;
|
||||
} else {
|
||||
const quantity = Math.round(parseFloat(amount!) * Math.pow(10, db.metadata.dps));
|
||||
if (!Number.isSafeInteger(quantity)) { throw new Error('Quantity not representable by safe integer'); }
|
||||
|
||||
statementLines.push({
|
||||
id: null,
|
||||
source_account: sourceAccount,
|
||||
dt: date,
|
||||
description: description ?? '',
|
||||
quantity: quantity,
|
||||
balance: null,
|
||||
commodity: db.metadata.reporting_commodity
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return statementLines;
|
||||
}
|
73
src/main.ts
73
src/main.ts
@ -1,73 +0,0 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
|
||||
import { createApp } from 'vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import App from './App.vue';
|
||||
|
||||
import { db } from './db.ts';
|
||||
|
||||
async function initApp() {
|
||||
// Init router
|
||||
const routes = [
|
||||
{ path: '/', name: 'index', component: () => import('./pages/HomeView.vue') },
|
||||
{ path: '/balance-assertions', name: 'balance-assertions', component: () => import('./pages/BalanceAssertionsView.vue') },
|
||||
{ path: '/balance-assertions/edit/:id', name: 'balance-assertions-edit', component: () => import('./pages/EditBalanceAssertionView.vue') },
|
||||
{ path: '/balance-assertions/new', name: 'balance-assertions-new', component: () => import('./pages/NewBalanceAssertionView.vue') },
|
||||
{ path: '/balance-sheet', name: 'balance-sheet', component: () => import('./reports/BalanceSheetReport.vue') },
|
||||
{ path: '/chart-of-accounts', name: 'chart-of-accounts', component: () => import('./pages/ChartOfAccountsView.vue') },
|
||||
{ path: '/general-ledger', name: 'general-ledger', component: () => import('./pages/GeneralLedgerView.vue') },
|
||||
{ path: '/income-statement', name: 'income-statement', component: () => import('./reports/IncomeStatementReport.vue') },
|
||||
{ path: '/journal', name: 'journal', component: () => import('./pages/JournalView.vue') },
|
||||
{ path: '/journal/edit/:id', name: 'journal-edit-transaction', component: () => import('./pages/EditTransactionView.vue') },
|
||||
{ path: '/journal/new', name: 'journal-new-transaction', component: () => import('./pages/NewTransactionView.vue') },
|
||||
{ path: '/statement-lines', name: 'statement-lines', component: () => import('./pages/StatementLinesView.vue') },
|
||||
{ path: '/statement-lines/import', name: 'import-statement', component: () => import('./pages/ImportStatementView.vue') },
|
||||
{ path: '/transactions/:account', name: 'transactions', component: () => import('./pages/TransactionsView.vue') },
|
||||
{ path: '/trial-balance', name: 'trial-balance', component: () => import('./pages/TrialBalanceView.vue') },
|
||||
];
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
// Init state
|
||||
const dbFilename: string = await invoke('get_open_filename');
|
||||
if (dbFilename !== null) {
|
||||
await db.init(dbFilename); // Ensure all metadata cached before loading Vue
|
||||
}
|
||||
|
||||
// Create Vue app
|
||||
createApp(App).use(router).mount('#app');
|
||||
}
|
||||
|
||||
(window as any).openLinkInNewWindow = function(link: HTMLAnchorElement) {
|
||||
const webview = new WebviewWindow('dialog' + +new Date(), {
|
||||
url: link.href,
|
||||
});
|
||||
webview.once('tauri://error', function(e) {
|
||||
console.error(e);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
initApp();
|
@ -1,123 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading">
|
||||
Balance assertions
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex gap-x-2">
|
||||
<a :href="$router.resolve({name: 'balance-assertions-new'}).fullPath" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
New assertion
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Description</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Account</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Balance</th>
|
||||
<th></th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="assertion of balanceAssertions">
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ dayjs(assertion.dt).format('YYYY-MM-DD') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ assertion.description }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900"><RouterLink :to="{ name: 'transactions', params: { account: assertion.account } }" class="text-gray-900 hover:text-blue-700 hover:underline">{{ assertion.account }}</RouterLink></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ pp(Math.abs(assertion.quantity)) }}</td>
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ assertion.quantity >= 0 ? 'Dr' : 'Cr' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">
|
||||
<CheckIcon class="w-4 h-4" v-if="assertion.isValid" />
|
||||
<XMarkIcon class="w-4 h-4 text-red-500" v-if="!assertion.isValid" />
|
||||
</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">
|
||||
<a :href="'/balance-assertions/edit/' + assertion.id" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { db } from '../db.ts';
|
||||
import { pp } from '../display.ts';
|
||||
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
|
||||
import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
|
||||
const balanceAssertions = ref([] as ValidatedBalanceAssertion[]);
|
||||
|
||||
interface ValidatedBalanceAssertion {
|
||||
id: number,
|
||||
dt: string,
|
||||
description: string,
|
||||
account: string,
|
||||
quantity: number,
|
||||
commodity: string,
|
||||
isValid: boolean,
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
|
||||
const rawBalanceAssertions: any[] = await session.select(
|
||||
`SELECT *
|
||||
FROM balance_assertions
|
||||
ORDER BY dt DESC, id DESC`
|
||||
);
|
||||
|
||||
// Get transactions
|
||||
const reportingWorkflow = new ReportingWorkflow();
|
||||
await reportingWorkflow.generate(session);
|
||||
const transactions = reportingWorkflow.getTransactionsAtStage(ReportingStage.OrdinaryAPITransactions);
|
||||
|
||||
for (const balanceAssertion of rawBalanceAssertions) {
|
||||
// Check assertion status
|
||||
const balanceAssertionDt = dayjs(balanceAssertion.dt);
|
||||
|
||||
let accountBalance = 0;
|
||||
for (const transaction of transactions) {
|
||||
if (dayjs(transaction.dt) <= balanceAssertionDt) {
|
||||
for (const posting of transaction.postings) {
|
||||
if (posting.account === balanceAssertion.account) {
|
||||
accountBalance += posting.quantity_ascost!;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
balanceAssertion.isValid = balanceAssertion.quantity === accountBalance && balanceAssertion.commodity === db.metadata.reporting_commodity;
|
||||
}
|
||||
|
||||
balanceAssertions.value = rawBalanceAssertions as ValidatedBalanceAssertion[];
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
@ -1,136 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading">
|
||||
Chart of accounts
|
||||
</h1>
|
||||
|
||||
<div class="my-2 py-2 flex gap-x-2 items-baseline bg-white sticky top-0">
|
||||
<DropdownBox class="w-[450px]" :values="accountKindsByModule" v-model="selectedAccountKind" />
|
||||
<button class="btn-primary" @click="addAccountType">Add type</button>
|
||||
<button class="btn-secondary text-red-600 ring-red-500" @click="removeAccountType">Remove type</button>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Account</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-start">Associated types</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-t border-gray-300" v-for="[account, thisAccountKinds] in accounts.entries()">
|
||||
<td class="py-0.5 pr-1 text-gray-900 align-baseline"><input class="checkbox-primary" type="checkbox" v-model="selectedAccounts" :value="account"></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 align-baseline">{{ account }}</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 align-baseline">
|
||||
<ul class="list-disc ml-5" v-if="thisAccountKinds">
|
||||
<!-- First display known account kinds -->
|
||||
<template v-for="[accountKind, accountKindPrettyName] in accountKindsMap.entries()">
|
||||
<li v-if="thisAccountKinds.indexOf(accountKind) >= 0">{{ accountKindPrettyName }}</li>
|
||||
</template>
|
||||
<!-- Then display unknown account kinds -->
|
||||
<template v-for="accountKind in thisAccountKinds">
|
||||
<li v-if="!accountKindsMap.has(accountKind)" class="italic">{{ accountKind }}</li>
|
||||
</template>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { accountKinds } from '../registry.ts';
|
||||
import { db } from '../db.ts';
|
||||
import DropdownBox from '../components/DropdownBox.vue';
|
||||
|
||||
const accountKindsMap = new Map(accountKinds);
|
||||
const accountKindsByModule = [...Map.groupBy(accountKinds, (k) => k[0].split('.')[0]).entries()];
|
||||
|
||||
const accounts = ref(new Map<string, string[]>());
|
||||
const selectedAccounts = ref([]);
|
||||
const selectedAccountKind = ref(accountKinds[0]);
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
|
||||
const accountKindsRaw: {account: string, kind: string | null}[] = await session.select(
|
||||
`SELECT q1.account, q2.kind FROM
|
||||
(SELECT account FROM account_configurations UNION SELECT account FROM postings ORDER BY account) q1
|
||||
LEFT JOIN account_configurations q2 ON q1.account = q2.account`
|
||||
);
|
||||
|
||||
for (const accountKindRaw of accountKindsRaw) {
|
||||
const kinds = accounts.value.get(accountKindRaw.account) ?? [];
|
||||
if (accountKindRaw.kind !== null) {
|
||||
kinds.push(accountKindRaw.kind);
|
||||
}
|
||||
accounts.value.set(accountKindRaw.account, kinds);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
async function addAccountType() {
|
||||
// Associate selected accounts with the selected account kind
|
||||
const session = await db.load();
|
||||
const dbTransaction = await session.begin();
|
||||
|
||||
for (const account of selectedAccounts.value) {
|
||||
await dbTransaction.execute(
|
||||
`INSERT INTO account_configurations (account, kind)
|
||||
VALUES ($1, $2)`,
|
||||
[account, selectedAccountKind.value[0]]
|
||||
);
|
||||
}
|
||||
|
||||
await dbTransaction.commit();
|
||||
|
||||
selectedAccounts.value = [];
|
||||
|
||||
// Reload data
|
||||
accounts.value.clear();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function removeAccountType() {
|
||||
// De-associate selected accounts with the selected account kind
|
||||
const session = await db.load();
|
||||
const dbTransaction = await session.begin();
|
||||
|
||||
for (const account of selectedAccounts.value) {
|
||||
await dbTransaction.execute(
|
||||
`DELETE FROM account_configurations
|
||||
WHERE account = $1 AND kind = $2`,
|
||||
[account, selectedAccountKind.value[0]]
|
||||
);
|
||||
}
|
||||
|
||||
await dbTransaction.commit();
|
||||
|
||||
selectedAccounts.value = [];
|
||||
|
||||
// Reload data
|
||||
accounts.value.clear();
|
||||
await load();
|
||||
}
|
||||
</script>
|
@ -1,67 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading mb-4">
|
||||
Edit balance assertion
|
||||
</h1>
|
||||
|
||||
<BalanceAssertionEditor :assertion="assertion" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { db, serialiseAmount } from '../db.ts';
|
||||
import BalanceAssertionEditor, { EditingAssertion } from '../components/BalanceAssertionEditor.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const assertion = ref({
|
||||
id: null,
|
||||
dt: null!,
|
||||
description: null!,
|
||||
account: null!,
|
||||
sign: null!,
|
||||
amount_abs: null!,
|
||||
} as EditingAssertion);
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
|
||||
const rawAssertions: any[] = await session.select(
|
||||
`SELECT *
|
||||
FROM balance_assertions
|
||||
WHERE id = $1`,
|
||||
[route.params.id]
|
||||
);
|
||||
const rawAssertion = rawAssertions[0];
|
||||
|
||||
// Format parameters for display
|
||||
rawAssertion.dt = dayjs(rawAssertion.dt).format('YYYY-MM-DD');
|
||||
rawAssertion.sign = rawAssertion.quantity >= 0 ? 'dr' : 'cr';
|
||||
rawAssertion.amount_abs = serialiseAmount(Math.abs(rawAssertion.quantity), rawAssertion.commodity);
|
||||
|
||||
assertion.value = rawAssertion as EditingAssertion;
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
@ -1,73 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading mb-4">
|
||||
Edit transaction
|
||||
</h1>
|
||||
|
||||
<TransactionEditor :transaction="transaction" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { JoinedTransactionPosting, db, joinedToTransactions, serialiseAmount } from '../db.ts';
|
||||
import TransactionEditor, { EditingTransaction } from '../components/TransactionEditor.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const transaction = ref({
|
||||
id: null,
|
||||
dt: null!,
|
||||
description: null!,
|
||||
postings: []
|
||||
} as EditingTransaction);
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
|
||||
const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select(
|
||||
`SELECT transaction_id, dt, transaction_description, id, description, account, quantity, commodity
|
||||
FROM joined_transactions
|
||||
WHERE transaction_id = $1
|
||||
ORDER BY id`,
|
||||
[route.params.id]
|
||||
);
|
||||
|
||||
const transactions = joinedToTransactions(joinedTransactionPostings);
|
||||
if (transactions.length !== 1) { throw new Error('Unexpected number of transactions returned from SQL'); }
|
||||
const rawTransaction = transactions[0] as any;
|
||||
|
||||
// Format dt
|
||||
rawTransaction.dt = dayjs(rawTransaction.dt).format('YYYY-MM-DD');
|
||||
|
||||
// Initialise originalAccount, sign and amount_abs
|
||||
for (const posting of rawTransaction.postings) {
|
||||
posting.originalAccount = posting.account;
|
||||
posting.sign = posting.quantity >= 0 ? 'dr' : 'cr';
|
||||
posting.amount_abs = serialiseAmount(Math.abs(posting.quantity), posting.commodity);
|
||||
}
|
||||
|
||||
transaction.value = rawTransaction as EditingTransaction;
|
||||
}
|
||||
load();
|
||||
</script>
|
@ -1,167 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading">
|
||||
General ledger
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex gap-x-2 items-center">
|
||||
<!-- Use a rather than RouterLink because RouterLink adds its own event handler -->
|
||||
<a :href="$router.resolve({name: 'journal-new-transaction'}).fullPath" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
New transaction
|
||||
</a>
|
||||
<div class="flex items-baseline">
|
||||
<input id="only-unclassified" class="ml-3 mr-1 self-center checkbox-primary" type="checkbox" v-model="commodityDetail">
|
||||
<label for="only-unclassified" class="text-gray-900">Show commodity detail</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="transaction-list" class="max-h-[100vh] overflow-y-scroll wk-aa">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold lg:w-[12ex] text-start">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start" colspan="3">Description</th>
|
||||
<template v-if="commodityDetail">
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">Cr</th>
|
||||
</template>
|
||||
<template v-if="!commodityDetail">
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold lg:w-[12ex] text-end">Dr</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold lg:w-[12ex] text-end">Cr</th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="6">Loading data…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Clusterize from 'clusterize.js';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { PencilIcon, PlusIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { Transaction, db } from '../db.ts';
|
||||
import { pp, ppWithCommodity } from '../display.ts';
|
||||
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
|
||||
import { renderComponent } from '../webutil.ts';
|
||||
|
||||
const commodityDetail = ref(false);
|
||||
|
||||
const transactions = ref([] as Transaction[]);
|
||||
let clusterize: Clusterize | null = null;
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
const reportingWorkflow = new ReportingWorkflow();
|
||||
await reportingWorkflow.generate(session);
|
||||
|
||||
transactions.value = reportingWorkflow.getTransactionsAtStage(ReportingStage.FINAL_STAGE);
|
||||
|
||||
// Display transactions in reverse chronological order
|
||||
// We must sort here because they are returned by reportingWorkflow in order of ReportingStage
|
||||
transactions.value.sort((a, b) => (b.dt.localeCompare(a.dt)) || ((b.id ?? 0) - (a.id ?? 0)));
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon
|
||||
const rows = [];
|
||||
|
||||
for (const transaction of transactions.value) {
|
||||
let editLink = '';
|
||||
if (transaction.id !== null) {
|
||||
editLink = `<a href="/journal/edit/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
}
|
||||
rows.push(
|
||||
`<tr class="border-t border-gray-300">
|
||||
<td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td>
|
||||
<td class="py-0.5 px-1 text-gray-900" colspan="3">${ transaction.description } ${ editLink }</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>`
|
||||
);
|
||||
|
||||
for (const posting of transaction.postings) {
|
||||
if (commodityDetail.value) {
|
||||
rows.push(
|
||||
`<tr>
|
||||
<td class=""></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 lg:w-[30%]">${ posting.description ?? '' }</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end"><i>${ posting.quantity >= 0 ? 'Dr' : 'Cr' }</i></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 lg:w-[30%]"><a href="/transactions/${ encodeURIComponent(posting.account) }" class="text-gray-900 hover:text-blue-700 hover:underline">${ posting.account }</a></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">
|
||||
${ posting.quantity >= 0 ? ppWithCommodity(posting.quantity, posting.commodity) : '' }
|
||||
</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">
|
||||
${ posting.quantity < 0 ? ppWithCommodity(-posting.quantity, posting.commodity) : '' }
|
||||
</td>
|
||||
</tr>`
|
||||
);
|
||||
} else {
|
||||
rows.push(
|
||||
`<tr>
|
||||
<td class=""></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 lg:w-[30%]">${ posting.description ?? '' }</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end"><i>${ posting.quantity >= 0 ? 'Dr' : 'Cr' }</i></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 lg:w-[30%]"><a href="/transactions/${ encodeURIComponent(posting.account) }" class="text-gray-900 hover:text-blue-700 hover:underline">${ posting.account }</a></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 lg:w-[12ex] text-end">
|
||||
${ posting.quantity >= 0 ? pp(posting.quantity_ascost!) : '' }
|
||||
</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 lg:w-[12ex] text-end">
|
||||
${ posting.quantity < 0 ? pp(-posting.quantity_ascost!) : '' }
|
||||
</td>
|
||||
</tr>`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clusterize === null) {
|
||||
clusterize = new Clusterize({
|
||||
'rows': rows,
|
||||
scrollElem: document.getElementById('transaction-list')!,
|
||||
contentElem: document.querySelector('#transaction-list tbody')!,
|
||||
show_no_data_row: false,
|
||||
});
|
||||
} else {
|
||||
clusterize.update(rows);
|
||||
}
|
||||
}
|
||||
|
||||
watch(commodityDetail, renderTable);
|
||||
watch(transactions, renderTable);
|
||||
|
||||
load();
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clusterize !== null) {
|
||||
clusterize.destroy();
|
||||
}
|
||||
});
|
||||
</script>
|
@ -1,47 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-3 divide-x divide-gray-200">
|
||||
<div class="pr-4">
|
||||
<h2 class="font-medium text-gray-700 mb-2">Data sources</h2>
|
||||
<ul class="list-disc ml-6">
|
||||
<li><RouterLink :to="{ name: 'journal' }" class="text-gray-900 hover:text-blue-700 hover:underline">Journal</RouterLink></li>
|
||||
<li><RouterLink :to="{ name: 'statement-lines' }" class="text-gray-900 hover:text-blue-700 hover:underline">Statement lines</RouterLink></li>
|
||||
<li><RouterLink :to="{ name: 'balance-assertions' }" class="text-gray-900 hover:text-blue-700 hover:underline">Balance assertions</RouterLink></li>
|
||||
<li><RouterLink :to="{ name: 'chart-of-accounts' }" class="text-gray-900 hover:text-blue-700 hover:underline">Chart of accounts</RouterLink></li>
|
||||
<!-- TODO: Plugin reports -->
|
||||
</ul>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<h2 class="font-medium text-gray-700 mb-2">General reports</h2>
|
||||
<ul class="list-disc ml-6">
|
||||
<li><RouterLink :to="{ name: 'general-ledger' }" class="text-gray-900 hover:text-blue-700 hover:underline">General ledger</RouterLink></li>
|
||||
<li><RouterLink :to="{ name: 'trial-balance' }" class="text-gray-900 hover:text-blue-700 hover:underline">Trial balance</RouterLink></li>
|
||||
<li><RouterLink :to="{ name: 'balance-sheet' }" class="text-gray-900 hover:text-blue-700 hover:underline">Balance sheet</RouterLink></li>
|
||||
<li><RouterLink :to="{ name: 'income-statement' }" class="text-gray-900 hover:text-blue-700 hover:underline">Income statement</RouterLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<h2 class="font-medium text-gray-700 mb-2">Advanced reports</h2>
|
||||
<ul class="list-disc ml-6">
|
||||
<!-- TODO: Plugin reports -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -1,144 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading mb-4">
|
||||
Import statement
|
||||
</h1>
|
||||
|
||||
<div class="grid grid-cols-[max-content_1fr] space-y-2 mb-4 items-baseline">
|
||||
<label for="format" class="block text-gray-900 pr-4">File type</label>
|
||||
<div>
|
||||
<select class="bordered-field" id="format" v-model="format">
|
||||
<option value="ofx">OFX (1.x/2.x)</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
</div>
|
||||
<label for="account" class="block text-gray-900 pr-4">Source account</label>
|
||||
<ComboBoxAccounts v-model="sourceAccount" />
|
||||
<label for="file" class="block text-gray-900 pr-4">File</label>
|
||||
<div class="flex grow">
|
||||
<!-- WebKit: file:hidden hides the filename as well so we have a dummy text input -->
|
||||
<input type="text" class="bordered-field" :value="selectedFilename" @click="openFileDialog" placeholder=" " readonly>
|
||||
<input type="file" class="hidden" id="file" accept=".ofx" ref="file" @change="fileInputChanged">
|
||||
<label for="file" class="btn-primary bg-gray-600 hover:bg-gray-700">Browse</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4 space-x-2">
|
||||
<button class="btn-secondary" @click="previewImport">Preview</button>
|
||||
</div>
|
||||
|
||||
<div v-if="statementLines.length > 0">
|
||||
<h2 class="page-heading my-4">
|
||||
Import statement preview
|
||||
</h2>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Description</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Cr</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="line in statementLines">
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ dayjs(line.dt).format('YYYY-MM-DD') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ line.description }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ line.quantity >= 0 ? ppWithCommodity(line.quantity, line.commodity) : '' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ line.quantity < 0 ? ppWithCommodity(-line.quantity, line.commodity) : '' }}</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">{{ line.balance ?? '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex justify-end mt-4 space-x-2">
|
||||
<button class="btn-primary" @click="doImport">Import</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { StatementLine, db } from '../db.ts';
|
||||
import ComboBoxAccounts from '../components/ComboBoxAccounts.vue';
|
||||
import { ppWithCommodity } from '../display.ts';
|
||||
|
||||
import importCsv from '../importers/csv.ts';
|
||||
import importOfxAutodetectVersion from '../importers/ofx.ts';
|
||||
|
||||
const fileInput = useTemplateRef('file');
|
||||
|
||||
const format = ref('ofx');
|
||||
const selectedFilename = ref('');
|
||||
const sourceAccount = ref('');
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const statementLines = ref([] as StatementLine[]);
|
||||
|
||||
function openFileDialog() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function fileInputChanged() {
|
||||
selectedFilename.value = fileInput.value!.files?.item(0)?.name ?? '';
|
||||
}
|
||||
|
||||
async function previewImport() {
|
||||
const file = fileInput.value!.files?.item(0);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await file.text();
|
||||
|
||||
if (format.value === 'csv') {
|
||||
statementLines.value = importCsv(sourceAccount.value, content);
|
||||
} else if (format.value === 'ofx') {
|
||||
statementLines.value = importOfxAutodetectVersion(sourceAccount.value, content);
|
||||
} else {
|
||||
throw new Error('Unexpected import format');
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
// Import statement lines to database atomically
|
||||
const session = await db.load();
|
||||
const dbTransaction = await session.begin();
|
||||
|
||||
for (const line of statementLines.value) {
|
||||
await dbTransaction.execute(
|
||||
`INSERT INTO statement_lines (source_account, dt, description, quantity, balance, commodity)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[line.source_account, line.dt, line.description, line.quantity, line.balance, line.commodity]
|
||||
);
|
||||
}
|
||||
|
||||
dbTransaction.commit();
|
||||
|
||||
router.push({ name: 'statement-lines' });
|
||||
}
|
||||
</script>
|
@ -1,165 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading">
|
||||
Journal
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex gap-x-2 items-center">
|
||||
<!-- Use a rather than RouterLink because RouterLink adds its own event handler -->
|
||||
<a :href="$router.resolve({name: 'journal-new-transaction'}).fullPath" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
New transaction
|
||||
</a>
|
||||
<div class="flex items-baseline">
|
||||
<input id="only-unclassified" class="ml-3 mr-1 self-center checkbox-primary" type="checkbox" v-model="commodityDetail">
|
||||
<label for="only-unclassified" class="text-gray-900">Show commodity detail</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="transaction-list" class="max-h-[100vh] overflow-y-scroll wk-aa">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold lg:w-[12ex] text-start">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start" colspan="3">Description</th>
|
||||
<template v-if="commodityDetail">
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">Cr</th>
|
||||
</template>
|
||||
<template v-if="!commodityDetail">
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold lg:w-[12ex] text-end">Dr</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold lg:w-[12ex] text-end">Cr</th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="6">Loading data…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Clusterize from 'clusterize.js';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { PencilIcon } from '@heroicons/vue/24/outline';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { JoinedTransactionPosting, Transaction, db, joinedToTransactions } from '../db.ts';
|
||||
import { pp, ppWithCommodity } from '../display.ts';
|
||||
import { renderComponent } from '../webutil.ts';
|
||||
|
||||
const commodityDetail = ref(false);
|
||||
|
||||
const transactions = ref([] as Transaction[]);
|
||||
let clusterize: Clusterize | null = null;
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
|
||||
const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select(
|
||||
`SELECT transaction_id, dt, transaction_description, id, description, account, quantity, commodity, quantity_ascost
|
||||
FROM transactions_with_quantity_ascost
|
||||
ORDER BY dt DESC, transaction_id DESC, id`
|
||||
);
|
||||
|
||||
transactions.value = joinedToTransactions(joinedTransactionPostings);
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon
|
||||
const rows = [];
|
||||
|
||||
for (const transaction of transactions.value) {
|
||||
rows.push(
|
||||
`<tr class="border-t border-gray-300">
|
||||
<td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td>
|
||||
<td class="py-0.5 px-1 text-gray-900" colspan="3">
|
||||
${ transaction.description }
|
||||
<a href="/journal/edit/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>`
|
||||
);
|
||||
for (const posting of transaction.postings) {
|
||||
if (commodityDetail.value) {
|
||||
rows.push(
|
||||
`<tr>
|
||||
<td class=""></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 lg:w-[30%]">${ posting.description ?? '' }</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end"><i>${ posting.quantity >= 0 ? 'Dr' : 'Cr' }</i></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 lg:w-[30%]"><a href="/transactions/${ encodeURIComponent(posting.account) }" class="text-gray-900 hover:text-blue-700 hover:underline">${ posting.account }</a></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">
|
||||
${ posting.quantity >= 0 ? ppWithCommodity(posting.quantity, posting.commodity) : '' }
|
||||
</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">
|
||||
${ posting.quantity < 0 ? ppWithCommodity(-posting.quantity, posting.commodity) : '' }
|
||||
</td>
|
||||
</tr>`
|
||||
);
|
||||
} else {
|
||||
rows.push(
|
||||
`<tr>
|
||||
<td class=""></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 lg:w-[30%]">${ posting.description ?? '' }</td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end"><i>${ posting.quantity >= 0 ? 'Dr' : 'Cr' }</i></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 lg:w-[30%]"><a href="/transactions/${ encodeURIComponent(posting.account) }" class="text-gray-900 hover:text-blue-700 hover:underline">${ posting.account }</a></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 lg:w-[12ex] text-end">
|
||||
${ posting.quantity >= 0 ? pp(posting.quantity_ascost!) : '' }
|
||||
</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 lg:w-[12ex] text-end">
|
||||
${ posting.quantity < 0 ? pp(-posting.quantity_ascost!) : '' }
|
||||
</td>
|
||||
</tr>`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clusterize === null) {
|
||||
clusterize = new Clusterize({
|
||||
'rows': rows,
|
||||
scrollElem: document.getElementById('transaction-list')!,
|
||||
contentElem: document.querySelector('#transaction-list tbody')!,
|
||||
show_no_data_row: false,
|
||||
});
|
||||
} else {
|
||||
clusterize.update(rows);
|
||||
}
|
||||
}
|
||||
|
||||
watch(commodityDetail, renderTable);
|
||||
watch(transactions, renderTable);
|
||||
|
||||
load();
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clusterize !== null) {
|
||||
clusterize.destroy();
|
||||
}
|
||||
});
|
||||
</script>
|
@ -1,41 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading mb-4">
|
||||
New balance assertion
|
||||
</h1>
|
||||
|
||||
<BalanceAssertionEditor :assertion="assertion" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import BalanceAssertionEditor, { EditingAssertion } from '../components/BalanceAssertionEditor.vue';
|
||||
|
||||
const assertion = ref({
|
||||
id: null,
|
||||
dt: dayjs().format('YYYY-MM-DD'),
|
||||
description: '',
|
||||
account: '',
|
||||
sign: 'dr',
|
||||
amount_abs: '',
|
||||
} as EditingAssertion);
|
||||
</script>
|
@ -1,59 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading mb-4">
|
||||
New transaction
|
||||
</h1>
|
||||
|
||||
<TransactionEditor :transaction="transaction" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import TransactionEditor, { EditingTransaction } from '../components/TransactionEditor.vue';
|
||||
|
||||
// Initialise blank transaction
|
||||
const transaction = ref({
|
||||
id: null,
|
||||
dt: dayjs().format('YYYY-MM-DD'),
|
||||
description: '',
|
||||
postings: [
|
||||
// One blank Dr and one blank Cr posting
|
||||
{
|
||||
id: null,
|
||||
description: null,
|
||||
account: '',
|
||||
originalAccount: null,
|
||||
sign: 'dr',
|
||||
amount_abs: '',
|
||||
},
|
||||
{
|
||||
id: null,
|
||||
description: null,
|
||||
account: '',
|
||||
originalAccount: null,
|
||||
sign: 'cr',
|
||||
amount_abs: '',
|
||||
}
|
||||
]
|
||||
} as EditingTransaction);
|
||||
</script>
|
@ -1,42 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<p class="text-gray-900 mb-4">Welcome to DrCr. No file is currently open.</p>
|
||||
<ul class="list-disc ml-6">
|
||||
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">New file</a></li>-->
|
||||
<li><a href="#" @click="openFile" class="text-gray-900 hover:text-blue-700 hover:underline">Open file</a></li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
|
||||
import { db } from '../db.js';
|
||||
|
||||
async function openFile() {
|
||||
const file = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
|
||||
if (file !== null) {
|
||||
await db.init(file);
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,406 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2025 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading">
|
||||
Statement lines
|
||||
</h1>
|
||||
|
||||
<div class="my-2 py-2 flex bg-white sticky top-0">
|
||||
<div class="grow flex gap-x-2 items-baseline">
|
||||
<button @click="reconcileAsTransfer" class="btn-secondary text-emerald-700 ring-emerald-600">
|
||||
Reconcile selected as transfer
|
||||
</button>
|
||||
<RouterLink :to="{ name: 'import-statement' }" class="btn-secondary">
|
||||
Import statement
|
||||
</RouterLink>
|
||||
<div class="flex items-baseline">
|
||||
<input id="only-unclassified" class="ml-3 mr-1 self-center checkbox-primary" type="checkbox" v-model="showOnlyUnclassified">
|
||||
<label for="only-unclassified" class="text-gray-900">Show only unclassified lines</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="statement-line-list" class="min-h-[50vh] max-h-[100vh] overflow-y-scroll wk-aa relative">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th></th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Source account</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold lg:w-[12ex] text-start">Date</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Description</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Charged to</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold lg:w-[12ex] text-end">Dr</th>
|
||||
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold lg:w-[12ex] text-end">Cr</th>
|
||||
<th class="py-0.5 pl-1 align-bottom text-gray-900 font-semibold text-end">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody @click="onClickTableElement">
|
||||
<tr>
|
||||
<td></td>
|
||||
<td class="py-0.5 px-1" colspan="7">Loading data…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Component for reconciling statement lines -->
|
||||
<div id="statement-line-classifier" class="hidden absolute">
|
||||
<div class="flex items-stretch">
|
||||
<ComboBoxAccounts v-model="classificationAccount" class="statement-line-classifier-input" />
|
||||
<button @click="onLineClassified" id="statement-line-classifier-button" type="button" class="relative -ml-px inline-flex items-center gap-x-1.5 px-3 py-1 text-gray-800 shadow-sm ring-1 ring-inset ring-gray-400 bg-white hover:bg-gray-50">
|
||||
<CheckIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Clusterize from 'clusterize.js';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { CheckIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import ComboBoxAccounts from '../components/ComboBoxAccounts.vue';
|
||||
import { db } from '../db.ts';
|
||||
import { renderComponent } from '../webutil.ts';
|
||||
import { ppWithCommodity } from '../display.ts';
|
||||
|
||||
interface StatementLine {
|
||||
id: number,
|
||||
source_account: string,
|
||||
dt: string,
|
||||
description: string,
|
||||
quantity: number,
|
||||
balance: number | null,
|
||||
commodity: string,
|
||||
transaction_id: number,
|
||||
posting_accounts: string[]
|
||||
}
|
||||
|
||||
const showOnlyUnclassified = ref(false);
|
||||
const statementLines = ref([] as StatementLine[]);
|
||||
|
||||
const classificationLineId = ref(0);
|
||||
const classificationAccount = ref('');
|
||||
|
||||
let clusterize: Clusterize | null = null;
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
|
||||
const joinedStatementLines: any[] = await session.select(
|
||||
`SELECT statement_lines.*, p2.transaction_id, p2.account AS posting_account
|
||||
FROM statement_lines
|
||||
LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id
|
||||
LEFT JOIN postings ON statement_line_reconciliations.posting_id = postings.id
|
||||
LEFT JOIN transactions ON postings.transaction_id = transactions.id
|
||||
LEFT JOIN postings p2 ON transactions.id = p2.transaction_id
|
||||
ORDER BY statement_lines.dt DESC, statement_lines.id DESC, p2.id`
|
||||
);
|
||||
|
||||
// Unflatten statement lines
|
||||
const newStatementLines: StatementLine[] = [];
|
||||
|
||||
for (const joinedStatementLine of joinedStatementLines) {
|
||||
if (newStatementLines.length === 0 || newStatementLines.at(-1)!.id !== joinedStatementLine.id) {
|
||||
newStatementLines.push({
|
||||
id: joinedStatementLine.id,
|
||||
source_account: joinedStatementLine.source_account,
|
||||
dt: joinedStatementLine.dt,
|
||||
description: joinedStatementLine.description,
|
||||
quantity: joinedStatementLine.quantity,
|
||||
balance: joinedStatementLine.balance,
|
||||
commodity: joinedStatementLine.commodity,
|
||||
transaction_id: joinedStatementLine.transaction_id,
|
||||
posting_accounts: []
|
||||
});
|
||||
}
|
||||
if (joinedStatementLine.posting_account !== null) {
|
||||
newStatementLines.at(-1)!.posting_accounts.push(joinedStatementLine.posting_account);
|
||||
}
|
||||
}
|
||||
|
||||
statementLines.value = newStatementLines;
|
||||
}
|
||||
|
||||
function onClickTableElement(event: MouseEvent) {
|
||||
// Use event delegation to avoid polluting global scope with the event listener
|
||||
if (event.target && (event.target as Element).classList.contains('classify-link')) {
|
||||
// ------------------------
|
||||
// Show classify line panel
|
||||
|
||||
// Prevent selecting a different line when already classifying one line
|
||||
if ((document.getElementById('statement-line-classifier-button')! as HTMLButtonElement).disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set global state
|
||||
const td = (event.target as Element).closest('td')!; // Reconciliation cell
|
||||
const tr = td.closest('tr')!;
|
||||
classificationLineId.value = parseInt(tr.dataset.lineId!);
|
||||
|
||||
// Show all other reconciliation cells
|
||||
for (const el of document.querySelectorAll('#statement-line-list .charge-account > span')) {
|
||||
el.classList.remove('invisible');
|
||||
}
|
||||
|
||||
// Hide contents of the cell
|
||||
const span = td.querySelector('span')!; // Span wrapper for reconciliation cell content
|
||||
span.classList.add('invisible');
|
||||
|
||||
// Position the classify line panel in place (relative to #statement-line-list)
|
||||
const outerDiv = document.getElementById('statement-line-list')!;
|
||||
const divReconciler = document.getElementById('statement-line-classifier')!;
|
||||
divReconciler.classList.remove('hidden');
|
||||
divReconciler.style.top = (td.getBoundingClientRect().y - outerDiv.getBoundingClientRect().y - 4) + 'px';
|
||||
divReconciler.style.left = (td.getBoundingClientRect().x - outerDiv.getBoundingClientRect().x) + 'px';
|
||||
|
||||
// Focus classify line panel
|
||||
divReconciler.querySelector('input')!.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function onLineClassified(event: Event) {
|
||||
// Callback when clicking OK to classify a statement line
|
||||
if ((event.target! as any).disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lineId = classificationLineId.value;
|
||||
const chargeAccount = classificationAccount.value;
|
||||
|
||||
if (!chargeAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable further submissions
|
||||
(document.querySelector('.statement-line-classifier-input')! as HTMLInputElement).disabled = true;
|
||||
(document.getElementById('statement-line-classifier-button')! as HTMLButtonElement).disabled = true;
|
||||
|
||||
const statementLine = statementLines.value.find((l) => l.id === lineId)!;
|
||||
|
||||
if (statementLine.posting_accounts.length !== 0) {
|
||||
await alert('Cannot reconcile already reconciled statement line');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if account exists
|
||||
const session = await db.load();
|
||||
const countResult = await session.select('SELECT COUNT(*) FROM postings WHERE account = $1', [chargeAccount]) as any[];
|
||||
const doesAccountExist = countResult[0]['COUNT(*)'] > 0;
|
||||
if (!doesAccountExist) {
|
||||
// Prompt for confirmation
|
||||
if (!await confirm('Account "' + chargeAccount + '" does not exist. Continue to reconcile this transaction and create a new account?')) {
|
||||
(document.querySelector('.statement-line-classifier-input')! as HTMLInputElement).disabled = false;
|
||||
(document.getElementById('statement-line-classifier-button')! as HTMLButtonElement).disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert transaction and statement line reconciliation atomically
|
||||
const dbTransaction = await session.begin();
|
||||
|
||||
// Insert transaction
|
||||
const transactionResult = await dbTransaction.execute(
|
||||
`INSERT INTO transactions (dt, description)
|
||||
VALUES ($1, $2)`,
|
||||
[statementLine.dt, statementLine.description]
|
||||
);
|
||||
const transactionId = transactionResult.lastInsertId;
|
||||
|
||||
// Insert posting for this account
|
||||
const accountPostingResult = await dbTransaction.execute(
|
||||
`INSERT INTO postings (transaction_id, description, account, quantity, commodity)
|
||||
VALUES ($1, NULL, $2, $3, $4)`,
|
||||
[transactionId, statementLine.source_account, statementLine.quantity, statementLine.commodity]
|
||||
);
|
||||
const accountPostingId = accountPostingResult.lastInsertId;
|
||||
|
||||
// Insert posting for the charge account - no need to remember this ID
|
||||
await dbTransaction.execute(
|
||||
`INSERT INTO postings (transaction_id, description, account, quantity, commodity)
|
||||
VALUES ($1, NULL, $2, $3, $4)`,
|
||||
[transactionId, chargeAccount, -statementLine.quantity, statementLine.commodity]
|
||||
);
|
||||
|
||||
// Insert statement line reconciliation
|
||||
await dbTransaction.execute(
|
||||
`INSERT INTO statement_line_reconciliations (statement_line_id, posting_id)
|
||||
VALUES ($1, $2)`,
|
||||
[statementLine.id, accountPostingId]
|
||||
);
|
||||
|
||||
dbTransaction.commit();
|
||||
|
||||
// Reset statement line classifier state
|
||||
classificationAccount.value = '';
|
||||
(document.querySelector('.statement-line-classifier-input')! as HTMLInputElement).disabled = false;
|
||||
(document.getElementById('statement-line-classifier-button')! as HTMLButtonElement).disabled = false;
|
||||
|
||||
// Hide the statement line classifier
|
||||
document.getElementById('statement-line-classifier')!.classList.add('hidden');
|
||||
|
||||
// Reload transactions and re-render the table
|
||||
await load();
|
||||
}
|
||||
|
||||
async function reconcileAsTransfer() {
|
||||
const selectedCheckboxes = document.querySelectorAll('.statement-line-checkbox:checked');
|
||||
|
||||
if (selectedCheckboxes.length !== 2) {
|
||||
await alert('Must select exactly 2 statement lines');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedLineIds = [...selectedCheckboxes].map((el) => parseInt(el.closest('tr')?.dataset.lineId!));
|
||||
|
||||
const line1 = statementLines.value.find((l) => l.id === selectedLineIds[0])!;
|
||||
const line2 = statementLines.value.find((l) => l.id === selectedLineIds[1])!;
|
||||
|
||||
// Sanity checks
|
||||
if (line1.quantity + line2.quantity !== 0 || line1.commodity !== line2.commodity) {
|
||||
await alert('Selected statement line debits/credits must equal');
|
||||
return;
|
||||
}
|
||||
if (line1.posting_accounts.length !== 0 || line2.posting_accounts.length !== 0) {
|
||||
await alert('Cannot reconcile already reconciled statement lines');
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert transaction and statement line reconciliation atomically
|
||||
const session = await db.load();
|
||||
const dbTransaction = await session.begin();
|
||||
|
||||
// Insert transaction
|
||||
const transactionResult = await dbTransaction.execute(
|
||||
`INSERT INTO transactions (dt, description)
|
||||
VALUES ($1, $2)`,
|
||||
[line1.dt, line1.description]
|
||||
);
|
||||
const transactionId = transactionResult.lastInsertId;
|
||||
|
||||
// Insert posting for line1
|
||||
const postingResult1 = await dbTransaction.execute(
|
||||
`INSERT INTO postings (transaction_id, description, account, quantity, commodity)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[transactionId, line1.description, line1.source_account, line1.quantity, line1.commodity]
|
||||
);
|
||||
const postingId1 = postingResult1.lastInsertId;
|
||||
|
||||
// Insert statement line reconciliation
|
||||
await dbTransaction.execute(
|
||||
`INSERT INTO statement_line_reconciliations (statement_line_id, posting_id)
|
||||
VALUES ($1, $2)`,
|
||||
[line1.id, postingId1]
|
||||
);
|
||||
|
||||
// Insert posting for line2
|
||||
const postingResult2 = await dbTransaction.execute(
|
||||
`INSERT INTO postings (transaction_id, description, account, quantity, commodity)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[transactionId, line2.description, line2.source_account, line2.quantity, line2.commodity]
|
||||
);
|
||||
const postingId2 = postingResult2.lastInsertId;
|
||||
|
||||
// Insert statement line reconciliation
|
||||
await dbTransaction.execute(
|
||||
`INSERT INTO statement_line_reconciliations (statement_line_id, posting_id)
|
||||
VALUES ($1, $2)`,
|
||||
[line2.id, postingId2]
|
||||
);
|
||||
|
||||
dbTransaction.commit();
|
||||
|
||||
// Reload transactions and re-render the table
|
||||
await load();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon
|
||||
const rows = [];
|
||||
|
||||
for (const line of statementLines.value) {
|
||||
let reconciliationCell, checkboxCell;
|
||||
if (line.posting_accounts.length === 0) {
|
||||
// Unreconciled
|
||||
reconciliationCell =
|
||||
`<a href="#" class="classify-link text-red-500 hover:text-red-600 hover:underline" onclick="return false;">Unclassified</a>`;
|
||||
checkboxCell = `<input class="checkbox-primary statement-line-checkbox" type="checkbox">`; // Only show checkbox for unreconciled lines
|
||||
} else if (line.posting_accounts.length === 2) {
|
||||
// Simple reconciliation
|
||||
const otherAccount = line.posting_accounts.find((a) => a !== line.source_account);
|
||||
reconciliationCell =
|
||||
`<span>${ otherAccount }</span>
|
||||
<a href="/journal/edit/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
checkboxCell = '';
|
||||
|
||||
if (showOnlyUnclassified.value) { continue; }
|
||||
} else {
|
||||
// Complex reconciliation
|
||||
reconciliationCell =
|
||||
`<i>(Complex)</i>
|
||||
<a href="/journal/edit/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
checkboxCell = '';
|
||||
|
||||
if (showOnlyUnclassified.value) { continue; }
|
||||
}
|
||||
|
||||
rows.push(
|
||||
`<tr data-line-id="${ line.id }">
|
||||
<td class="py-0.5 pr-1 align-baseline">${ checkboxCell }</td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900"><a href="/transactions/${ encodeURIComponent(line.source_account) }" class="hover:text-blue-700 hover:underline">${ line.source_account }</a></td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex]">${ dayjs(line.dt).format('YYYY-MM-DD') }</td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900">${ line.description }</td>
|
||||
<td class="charge-account py-0.5 px-1 align-baseline text-gray-900"><span>${ reconciliationCell }</span></td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex] text-end">${ line.quantity >= 0 ? ppWithCommodity(line.quantity, line.commodity) : '' }</td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex] text-end">${ line.quantity < 0 ? ppWithCommodity(-line.quantity, line.commodity) : '' }</td>
|
||||
<td class="py-0.5 pl-1 align-baseline text-gray-900 text-end">${ line.balance ?? '' }</td>
|
||||
</tr>`
|
||||
)
|
||||
}
|
||||
|
||||
if (clusterize === null) {
|
||||
clusterize = new Clusterize({
|
||||
'rows': rows,
|
||||
scrollElem: document.getElementById('statement-line-list')!,
|
||||
contentElem: document.querySelector('#statement-line-list tbody')!,
|
||||
show_no_data_row: false
|
||||
});
|
||||
} else {
|
||||
clusterize.update(rows);
|
||||
}
|
||||
|
||||
// Hide the statement line classifier
|
||||
document.getElementById('statement-line-classifier')!.classList.add('hidden');
|
||||
}
|
||||
|
||||
watch(showOnlyUnclassified, renderTable);
|
||||
watch(statementLines, renderTable);
|
||||
|
||||
load();
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clusterize !== null) {
|
||||
clusterize.destroy();
|
||||
}
|
||||
});
|
||||
</script>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user